Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4c78218
docs(requirements): divergence age and the divergence freeze (trinity…
AndriiPasternak31 Sep 27, 2026
6c841c4
feat(sync-health): one policy for state, reason and the freeze (trini…
AndriiPasternak31 Sep 27, 2026
a97c58a
feat(sync-health): persist divergence and dirt episode clocks, dirty …
AndriiPasternak31 Sep 27, 2026
54653d1
feat(sync-health): the poller keeps the divergence and dirt clocks an…
AndriiPasternak31 Sep 27, 2026
7e253f5
feat(sync-health): one sync_diverged operator-queue item per divergen…
AndriiPasternak31 Sep 27, 2026
7c31ad6
feat(scheduling): the git-sync freeze keys on divergence age for work…
AndriiPasternak31 Sep 27, 2026
fd2052f
feat(sync-health): the batch and per-agent reads carry the backend's …
AndriiPasternak31 Sep 27, 2026
1958810
feat(dashboard): the sync dot renders the backend's state and reason …
AndriiPasternak31 Sep 27, 2026
8ae49a5
docs(sync-health): divergence age, the one policy and the divergence …
AndriiPasternak31 Sep 27, 2026
2578a71
fix(sync-health): a kept divergence clock keeps its counts (trinity-e…
AndriiPasternak31 Sep 27, 2026
f800e91
docs(learnings): a value kept on unknown keeps its companion values (…
AndriiPasternak31 Sep 27, 2026
9229f08
Merge origin/dev into ent706-707: re-chain the migration after #3005
AndriiPasternak31 Sep 28, 2026
c640e0b
fix(workspace): history never carries a turn's cost (#3063)
dolho Sep 29, 2026
8e3d5a6
feat(pull): platform-injected execution id; refuse unguarded effects …
obasilakis Sep 29, 2026
5aeaf00
fix(mcp): honour the manual execution id only from the platform heade…
obasilakis Sep 29, 2026
fea7e36
fix(workspace): the sync chat reply never carries a turn's cost eithe…
dolho Sep 29, 2026
3f13c6b
fix(operator-queue): machine keys read the queue without a person's i…
webmixgamer Sep 29, 2026
d0a55b7
docs(operator-queue): cso diff report + flow sync for the machine-key…
webmixgamer Sep 29, 2026
f563515
Merge remote-tracking branch 'origin/dev' into feature/715-queue-pers…
webmixgamer Sep 29, 2026
e0fb3d7
test(operator-queue): every prefix the reads treat as about a person …
webmixgamer Sep 29, 2026
c3255e1
Merge origin/dev into ent706-707: re-chain the migration after #3064
AndriiPasternak31 Sep 29, 2026
ad97efd
Merge remote-tracking branch 'origin/dev' into AndriiPasternak31/ent7…
AndriiPasternak31 Sep 29, 2026
bed8db8
merge-train: replace an internal agent name with a placeholder in PUL…
vybe Sep 29, 2026
6f4ef99
Merge remote-tracking branch 'origin/dev' into AndriiPasternak31/ent7…
vybe Sep 29, 2026
4c9fc80
train: #3075
vybe Sep 29, 2026
89c6f0c
train: #3090
vybe Sep 29, 2026
d66555d
train: #3086
vybe Sep 29, 2026
720a59b
train: #3035
vybe Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/trinity-meta-prompt/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Until it is removed, an entry appended to the `requests` array of `~/.trinity/op
}
```

The operator's answer is written back into the entry: `status: "responded"` with `response`, `responded_by` and `responded_at`. An item that has waited past the operator's aging bound carries a `platform.aging_since` timestamp written by Trinity — read it, never write to `platform`. After processing a response, update the item's status to `"acknowledged"`. Keep only `pending` and `responded` items plus up to 3 recent `acknowledged` items. The platform database is the permanent record. An ID you raised with `ask_operator` is never read from the file.
The operator's answer is written back into the entry: `status: "responded"` with `response`, `response_text` and `responded_at` — never who answered. An item that has waited past the operator's aging bound carries a `platform.aging_since` timestamp written by Trinity — read it, never write to `platform`. After processing a response, update the item's status to `"acknowledged"`. Keep only `pending` and `responded` items plus up to 3 recent `acknowledged` items. The platform database is the permanent record. An ID you raised with `ask_operator` is never read from the file.

### When to Use

Expand Down
14 changes: 12 additions & 2 deletions docker/base-image/agent_server/mcp_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,19 @@ class McpValidationError(ValueError):
# documented default for fresh installs.
_TRINITY_DEFAULT_URL = "http://mcp-server:8080/mcp"
_TRINITY_BEARER_RE = re.compile(r"^Bearer\s+trinity_mcp_[A-Za-z0-9_-]{1,200}$")
# #2392: the injected entry also carries the spawning execution's id, expanded
# by Claude Code from its process env. Only this exact literal is canonical.
_TRINITY_EXEC_ID_HEADER = "X-Trinity-Execution-Id"
_TRINITY_EXEC_ID_VALUE = "${TRINITY_EXECUTION_ID:-manual}"


def _is_canonical_trinity_entry(server: dict) -> bool:
"""Return True iff ``server`` looks exactly like the Trinity-injected
entry: only ``type``, ``url``, ``headers`` keys; ``type=http``; ``url``
matches the configured Trinity MCP URL; ``headers`` contains only
``Authorization`` with a ``Bearer trinity_mcp_…`` token of the right
shape.
shape, optionally plus ``X-Trinity-Execution-Id`` whose value is exactly
``${TRINITY_EXECUTION_ID:-manual}`` (#2392).

Strict allowlist — any extra key, any wrong value, any extra header,
any non-bearer auth scheme is rejected. Owners who want to redefine
Expand All @@ -114,7 +119,12 @@ def _is_canonical_trinity_entry(server: dict) -> bool:
return False

headers = server.get("headers")
if not isinstance(headers, dict) or list(headers.keys()) != ["Authorization"]:
if not isinstance(headers, dict):
return False
if set(headers.keys()) == {"Authorization", _TRINITY_EXEC_ID_HEADER}:
if headers[_TRINITY_EXEC_ID_HEADER] != _TRINITY_EXEC_ID_VALUE:
return False
elif list(headers.keys()) != ["Authorization"]:
return False
auth = headers.get("Authorization")
if not isinstance(auth, str) or not _TRINITY_BEARER_RE.match(auth):
Expand Down
21 changes: 19 additions & 2 deletions docker/base-image/agent_server/services/trinity_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@

logger = logging.getLogger(__name__)

# #2392: every Trinity MCP call carries the id of the execution that spawned the
# CLI, so the backend can attribute tool calls. The value is expanded by the CLI
# itself from its own process env (TRINITY_EXECUTION_ID is set per spawn by the
# agent server), so one config file serves every concurrent execution.
EXECUTION_ID_HEADER = "X-Trinity-Execution-Id"
EXECUTION_ID_HEADER_VALUE = "${TRINITY_EXECUTION_ID:-manual}"


def inject_trinity_mcp_if_configured() -> bool:
"""
Expand Down Expand Up @@ -51,7 +58,9 @@ def _inject_claude_mcp(trinity_mcp_url: str, trinity_mcp_api_key: str) -> bool:
"type": "http",
"url": trinity_mcp_url,
"headers": {
"Authorization": f"Bearer {trinity_mcp_api_key}"
"Authorization": f"Bearer {trinity_mcp_api_key}",
# Claude Code expands ${VAR:-default} in http MCP headers.
EXECUTION_ID_HEADER: EXECUTION_ID_HEADER_VALUE,
}
}
}
Expand Down Expand Up @@ -118,7 +127,10 @@ def _inject_gemini_mcp(trinity_mcp_url: str, trinity_mcp_api_key: str) -> bool:
settings["mcpServers"]["trinity"] = {
"url": trinity_mcp_url,
"headers": {
"Authorization": f"Bearer {trinity_mcp_api_key}"
"Authorization": f"Bearer {trinity_mcp_api_key}",
# Gemini CLI expands $VAR / ${VAR} / ${VAR:-default} across
# settings.json at load (packages/cli/src/utils/envVarResolver.ts).
EXECUTION_ID_HEADER: EXECUTION_ID_HEADER_VALUE,
}
}

Expand Down Expand Up @@ -401,6 +413,11 @@ def _inject_codex_mcp(trinity_mcp_url: str, trinity_mcp_api_key: str) -> bool:
server = {
"url": trinity_mcp_url,
"bearer_token_env_var": "TRINITY_MCP_API_KEY",
# #2392: Codex has no ${VAR} expansion; env_http_headers maps header ->
# env var name, read from the codex process env. When the var is unset
# or blank Codex OMITS the header (no `manual` default); the backend
# treats an absent id as absent.
"env_http_headers": {EXECUTION_ID_HEADER: "TRINITY_EXECUTION_ID"},
}
if _upsert_codex_mcp_servers({"trinity": server}):
logger.info("Injected Trinity MCP server into codex config.toml")
Expand Down
2 changes: 2 additions & 0 deletions docs/memory/architecture/agent-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ An agent's Claude Code marketplace-plugin selection is a first-class, **committe

**Backend side** (details in [git-sync-health.md](../feature-flows/git-sync-health.md)): `SyncHealthService` polls git-enabled agents every 60s (#2742: from the `synchealth:leader` lease-holder only, so `--workers 2` runs one poll not two — **fail-open**, because an observability feed must not go dark when Redis is; the cadence is `SYNC_HEALTH_POLL_INTERVAL_SECONDS`, default unchanged at 60 s. One poller instead of two halves the rate `consecutive_failures` climbs, so `sync_failing` now fires at ~3 min rather than ~90 s), upserts `agent_sync_state` (`consecutive_failures` ++ on fail / reset on success; `ahead_working`/`behind_working` expose working-branch divergence, P6; `git_dir_bytes` #1596 + `pack_count`/`loose_objects`/`maintenance_failures` #1595 carry the repo-health curve — all agent-supplied ints coerced at the boundary, the `/health clone_status` convention), emits `sync_failing` operator-queue entries at ≥3 failures (S1) and edge-triggered `git_bloat` entries when `git_dir_bytes` crosses `GIT_DIR_ALERT_BYTES` (default 10 GiB) or maintenance fails 3× running (#1595 — the failure class was previously silent until the disk filled). Powers the dashboard sync dot, `GET /api/agents/sync-health` (now incl. `git_dir_bytes`), and `GET /api/fleet/sync-audit` — whose `duplicate_binding` flag marks agents sharing a `(github_repo, working_branch)` pair (§P5 silent-clobber setup) (S6, #390).

**Divergence age and the divergence freeze (trinity-enterprise#706).** Health measures whether the agent and its repository *agree*, not only whether the last push succeeded. The poller also keeps four columns: `diverged_since` (first poll at which the working tuple — origin on the agent's own branch, #2105 — showed ahead or behind; set once, cleared when both are known 0 or ahead is 0 with no upstream, **kept** when ahead could not be computed, so unknown never starts or clears it), `dirty_files` (the status call's `changes_count`) with its own `dirty_since` clock, and `last_successful_push_at` (the later of the heartbeat's #3011 record, an operator Push's `agent_git_config.last_sync_at` and the stored value; agent timestamps must carry an offset and not be in the future). **One policy module decides everything:** `services/sync_freeze_policy.py::classify` (stdlib-only, never raises) returns `state` / `reason` / `recommendation` / `binding` / `freeze` / `freeze_reason`; it is vendored **byte-identically** to `src/scheduler/sync_freeze_policy.py` (the #1088 precedent, Invariant #5; `test_ent706_sync_policy_parity.py`), and `services/sync_health_view.py::sync_view` is the one backend call site (it resolves `git_service.is_push_denied` so the leaf stays pure). **Work agent** = `source_mode = 0 OR auto_sync_enabled = 1` — fork-to-own keeps `source_mode = 1` in the DB but auto-syncs to its own fork. **Freeze** = `freeze_schedules_if_sync_failing AND (sync_failing OR (work agent AND diverged > 24 h AND last_check_at within 15 min))`; the freshness clause fails open because the poller writes nothing for an unreachable agent. Enforced in two places over the one rule: the scheduler's direct-DB `SchedulerDatabase.sync_freeze_reason` (fail-open; a reason string, never a truthy `(bool, reason)` tuple) writes `Git sync frozen: <reason>` into the skipped row — **one row per cron tick** while the freeze lasts, pruned by execution retention — and `GET /api/internal/agents/{name}/sync-health-status` reports the same decision with `freeze_reason`. While a divergence freeze is in force the poller raises **one** `sync_diverged` operator-queue item per episode under its lease, id `sync-diverged-{agent}-{diverged_since}` (reserved prefix; the `(agent_name, request_id)` conflict target collapses repeats and a fail-open double leader), with counts and the recommendation and no agent-authored error text; it is not auto-resolved. Un-freezing is automatic when divergence clears. The dot (`utils/syncHealth.js`) renders the backend's `state` and `reason` and holds no threshold. No backfill: every clock starts at the first poll after upgrade, which is the 24 h soak before any divergence freeze. Requirements §11.19.

### Post-Creation Repo Binding (ent#109)

"Bind this agent to a GitHub repo you own" — the ownership retrofit ent#123 left open. A tokenless public-template agent (the default Cornelius) accumulates a knowledge base it cannot push anywhere, and the only documented escape was "create a new agent with fork-to-own and import your data", which discards the agent's identity, its 180-day name reservation and its history. `POST /api/agents/{name}/git/bind-to-own-repo` creates a user-owned repo from the agent's **current workspace volume** (not from its template — that is the reason it cannot share ent#93's copy step), repoints `origin` in place, and re-bakes the container env. It is a **rebind, not a fork verb**: an already-writable agent is an ordinary rebind, which is what makes AC #3 ("works for any agent") literally true. Owner-only **and** human-only (`reject_agent_principal` — an agent-scoped key resolves to its owner *carrying the owner's role*, so a role gate alone is satisfied by any agent's injected key on a default admin-owned install). Requirements §11.12; flow [agent-repo-binding.md](../feature-flows/agent-repo-binding.md).
Expand Down
6 changes: 3 additions & 3 deletions docs/memory/architecture/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| GET | `/api/agents` | List all agents |
| GET | `/api/agents/context-stats` | Context & activity state for all agents |
| GET | `/api/agents/autonomy-status` | Autonomy status for all accessible agents |
| GET | `/api/agents/sync-health` | Per-agent git sync health for dashboard dots (#389) |
| GET | `/api/agents/sync-health` | Per-agent git sync health for dashboard dots (#389); carries the backend's `state` / `reason` / `recommendation` / `binding` / `freeze` (ent#706) |
| GET | `/api/agents/subscription-pressure` | Batch subscription-pressure rows for dashboard badges (#471): per accessible agent (pure-DB `visible_agent_names`, ent#384 helper) — `auth_mode` (`AgentAuthStatus` vocabulary), `subscription_name`, `failure_events_24h` + its auth-kind slice `auth_failures_24h` (#2352), one-gate `rate_limited_now`, the probe's own `token_status` (`ok`|`invalid_token`|`rate_limited`|`error`), fresh `utilization_5h_pct`. Explicit `response_model`; registered before `/{agent_name}` (Invariant #4) |
| POST | `/api/agents` | Create agent. Accepts an optional `display_label` (ent#1640) — the human-facing name set at creation (normalized + named-error validated like `PUT /label`); omit/blank → renders under the slug. Accepts `import_intent: fork\|copy\|clone` for `github:` templates (ent#15 — copy = backend-materialized snapshot, no sync/row/PAT; see [github-import-intents.md](../feature-flows/github-import-intents.md)) and an `Idempotency-Key` header (Invariant #18, scope `agent_create:{user_id}`) |
| POST | `/api/agents/deploy-local` | Deploy a packaged local agent (base64 tar.gz; `require_role("creator")`). #2060 integrity contract: embedded `.trinity-manifest.json` verified post-extract + post-copy (fail-closed `MANIFEST_DRIFT`/`MANIFEST_REQUIRED`), in-root symlinks preserved, caps carry observed+limit, evidence-bearing response (`verified`/counts/`compatibility_hard_count`), `Idempotency-Key` (scope `agent_deploy:{user_id}`) + per-base-name `agent:deploy_op:` lock, failed deploys compensate (residue removed, workspace volume reclaimed, stopped previous version restarted). See [local-agent-deploy.md](../feature-flows/local-agent-deploy.md) |
Expand Down Expand Up @@ -145,7 +145,7 @@ The per-agent VoIP config + voice-picker UI lives in the agent Settings/Sharing
| GET/PUT/DELETE | `/api/agents/{name}/github-pat` | PAT config status / set per-agent PAT (validated, encrypted) / clear (revert to global) |
| GET/PUT | `/api/agents/{name}/git/auto-sync` | Per-agent 15-min auto-sync heartbeat flag |
| GET/PUT | `/api/agents/{name}/git/freeze-schedules-if-failing` | Freeze-on-sync-failure flag |
| GET | `/api/agents/{name}/git/sync-state` | Persisted sync-state row |
| GET | `/api/agents/{name}/git/sync-state` | Persisted sync-state row plus the policy verdict (ent#706) |
| POST | `/api/agents/{name}/git/reset-to-main-preserve-state` | Recovery reset — see [Git Sync Health](agent-lifecycle.md#git-sync-health-389390) |
| POST | `/api/agents/{name}/git/bind-to-own-repo` | **Bind to a repo the caller owns** (ent#109) — create it if needed, push the agent's CURRENT workspace history, repoint `origin`, persist the per-agent PAT, re-bake the container env. `OwnedAgentByName` **+ `reject_agent_principal`** (human-only), `Idempotency-Key` verb-folded. See [Post-Creation Repo Binding](agent-lifecycle.md#post-creation-repo-binding-ent109) |
| GET | `/api/agents/{name}/git/bind-to-own-repo/status` | Resolve a binding whose HTTP response was lost — reports the DB row vs the live container's `origin` (`origin_in_sync`) rather than a remembered request (ent#109) |
Expand Down Expand Up @@ -265,7 +265,7 @@ Generalises #868 to agent scope (`db/schedules.py:get_agent_analytics`); read-on

Bulk ops scope writes to the caller's accessible agents (tri-state: admin = no filter, empty set = no-op). The sync service write-back also propagates `cancelled`/`expired` status into agent queue files (in-place flips of still-`pending` entries only) so agents stop waiting on cleared items and stale file entries can't resurrect purged rows (#1017). The Operations UI exposes these as a per-tab **Clear All** button (`notifications` tab uses `POST /api/notifications/dismiss-all` — bulk pending+acknowledged → dismissed, same accessible-set scoping).

WebSocket events: `operator_queue_new`, `operator_queue_responded` / `operator_queue_cancelled` (thin `{id, agent_name}` since trinity-enterprise#611 — listeners refetch), `operator_queue_acknowledged`, `operator_queue_cleared` (one per bulk op, #1017; count only — `notifications_cleared` for the notifications variant), `operator_queue_sync` (#2915, one per poll cycle; announces expiry too). Backed by the 5s Operator Queue Sync background service. Every ending — answer, cancel, bulk cancel, expiry — goes through one sink, `services/ask_service.py` (trinity-enterprise#611: CAS + endings ledger → one audit row per transition → one thin trigger → the registered ending observers); the machine keys never see the new person fields (`disposed_by_email`, `resolved_to`) on get/list. See [operating-room.md → Endings](../feature-flows/operating-room.md#endings-trinity-enterprise611).
WebSocket events: `operator_queue_new`, `operator_queue_responded` / `operator_queue_cancelled` (thin `{id, agent_name}` since trinity-enterprise#611 — listeners refetch), `operator_queue_acknowledged`, `operator_queue_cleared` (one per bulk op, #1017; count only — `notifications_cleared` for the notifications variant), `operator_queue_sync` (#2915, one per poll cycle; announces expiry too). Backed by the 5s Operator Queue Sync background service. Every ending — answer, cancel, bulk cancel, expiry — goes through one sink, `services/ask_service.py` (trinity-enterprise#611: CAS + endings ledger → one audit row per transition → one thin trigger → the registered ending observers); the machine keys never see a person's identity on get/list — an allowlist projection (`_MACHINE_ROW_FIELDS`: no `responded_by_*`, `addressed_to_email`, `disposed_by_email`, `resolved_to`), and the platform's heads-ups about a person are not returned to them at all (trinity-enterprise#715). See [operating-room.md → Endings](../feature-flows/operating-room.md#endings-trinity-enterprise611).

**Addressed asks (ent#364).** `operator_queue.addressed_to_email` names the HUMAN an item is for; `agent_name` only ever said which agent. NULL — every pre-ent#364 row — means "the operator answers", so the column is additive by construction. It is a **column and not a key in `context`** because it is an authorization decision (who may answer, whose Workspace sidebar it appears in) and `context` is agent-authored JSON whose clamp bounds size and type only. Validated at the one agent-authored boundary (`operator_queue_service._validated_addressee`, inside the #1632 clamp) against `client_portal.service.agent_on_roster(agent, email, include_owned=False)` — an agent may address only someone already on its roster — and **fails closed**: malformed, off-roster, or an unreadable roster all drop to NULL. The read/answer surface is **OSS core** (ent#428): `client_portal/asks/`, mounted unconditionally in `main.py` at `/api/enterprise/client-portal/asks` — a prefix that is retained history, like the `enterprise_`-prefixed portal tables, not a licensing claim. It re-checks the roster at read time so a revoked share stops showing an already-raised ask, projects an explicit client-facing shape (never `context`), and answers through the same ask sink as the operator respond route (`services/ask_service.py`, trinity-enterprise#611), so the ledger, write-back, audit and the WS trigger are unforked, and it is person-only like respond (a non-person platform principal gets 403 `person_required`). `responded_by_id` stays NULL for a client answerer — there is no `users` row, and inventing one would be a lie in the audit trail. **The list fails loud (trinity-enterprise#610 PR A0):** `GET /api/enterprise/client-portal/asks` returns **503 `{code: "asks_unavailable", message}`** with `Retry-After: 20` (the Workspace poll interval) when any of its queue reads raises (the agent set, the count or the page, #3059) or the read-time roster re-check cannot read the roster (`asks/service.AsksUnavailable`, strict `_on_roster`); it never answers an outage with `[]`. `answer_ask`'s roster failure stays the uniform 404.

Expand Down
Loading
Loading