diff --git a/config/trinity-meta-prompt/prompt.md b/config/trinity-meta-prompt/prompt.md index 1c9a1aff3..d68fa8e6d 100644 --- a/config/trinity-meta-prompt/prompt.md +++ b/config/trinity-meta-prompt/prompt.md @@ -54,7 +54,7 @@ Before performing an action that cannot be undone or verified afterwards — pay **Priority levels:** `critical`, `high`, `medium`, `low` -**Set `expires_at`** on requests that gate an action: an ISO-8601 time with a timezone, at least 15 minutes out. If it passes without a response the ask ends `expired` — treat that as "not approved; do not proceed", and do not re-ask the same action without new information. When you do re-ask, set `supersedes_expired` to the expired ask's `request_id`. +**Set `expires_at`** on requests that gate an action: an ISO-8601 time with a timezone, at least 15 minutes out. If it passes without a response the ask ends `expired` — treat that as "not approved; do not proceed", and do not re-ask the same action without new information. When you do re-ask, set `supersedes_expired` to the expired ask's `request_id`. If the person you addressed dismisses it, it ends `dismissed` (`response` empty) — they chose not to decide: do not proceed, and do not raise the same ask again straight away. ### The queue file (fallback) @@ -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`, `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. +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`. While Trinity is holding new entries from this file, the file carries a `platform.ingestion` block: `reason` is `queue_full` (you already have `max_pending` open requests — wait for one to end), `rate_limited` (too many too fast — they are read again shortly) or `invalid_id` (an entry's `id` is malformed and will never be read — fix it), and `since` says when the hold began. Held entries stay `pending` and are read again every few seconds; the block is removed once nothing is held. `ask_operator` refuses the same cases immediately, with the reason. 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 diff --git a/docker/base-image/agent_server/auto_sync.py b/docker/base-image/agent_server/auto_sync.py index 1ad19f149..6ab2f6821 100644 --- a/docker/base-image/agent_server/auto_sync.py +++ b/docker/base-image/agent_server/auto_sync.py @@ -184,8 +184,8 @@ async def run_one_cycle(client: httpx.AsyncClient, home: Path, run_once) -> Opti # trinity-enterprise#703 — the pull cycle (invariant G3: human and fleet work # reaches the agent within a bound). A second loop beside the push loop, gated # per cycle on the owner's `pull_sync_enabled`; the cycle itself is -# `routers/git.py::_run_pull_once` (never under a running execution, never -# discards local work). +# `routers/git.py::_run_pull_once` (never starts under a running execution, +# never resets over a registered one). # --------------------------------------------------------------------------- _last_pull_resolved: Optional[bool] = None diff --git a/docker/base-image/agent_server/routers/git.py b/docker/base-image/agent_server/routers/git.py index 442f78da8..d61bdd99c 100644 --- a/docker/base-image/agent_server/routers/git.py +++ b/docker/base-image/agent_server/routers/git.py @@ -1177,9 +1177,10 @@ def git(*args, timeout=60): def _integrate_remote(home_dir: Path, branch: str, ahead: int) -> Optional[str]: - """Bring `origin/` into the checked-out branch without ever losing - local work: a fast-forward when nothing is committed locally, a rebase - (aborted on conflict) when something is. See `_with_stash`.""" + """Bring `origin/` into the checked-out branch, putting uncommitted + edits back (or naming the stash): a fast-forward when nothing is committed + locally, a rebase (aborted on conflict) when something is. See + `_with_stash` / `_safe_to_reset` for what an undo does not protect.""" def step() -> Optional[str]: if ahead == 0: ff = run_registered(["git", "merge", "--ff-only", f"origin/{branch}"], @@ -1254,8 +1255,10 @@ def _run_pull_once(home_dir: Path) -> Dict: sync-state.json happens under that lock). - Stale lock litter is reaped first, as the push cycle does: a pull-only agent has no push cycle to do it. - - Refuses to run over unmerged paths. Local work is never discarded; a - failure is recorded, not resolved. + - Refuses to run over unmerged paths. An undo never resets over a + registered execution (`_safe_to_reset`); writes outside the process + registry (Files API, docker exec, web terminal) made during the + integrate window are not protected. A failure is recorded, not resolved. `behind_after_pull` is the checked-out branch's own lag behind `origin/`, not its lag behind `main`; `behind_main` in the git diff --git a/docs/memory/architecture/api-endpoints.md b/docs/memory/architecture/api-endpoints.md index 26bdd5112..73c90f8f6 100644 --- a/docs/memory/architecture/api-endpoints.md +++ b/docs/memory/architecture/api-endpoints.md @@ -274,7 +274,7 @@ WebSocket events: `operator_queue_new`, `operator_queue_responded` / `operator_q **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. **One ownership check (trinity-enterprise#610 PR A2):** `asks/service._owned_ask` (lookup + addressee + `_VISIBLE_KINDS` + roster) is shared by the answer path (non-strict — its refusal is the 404 whatever the cause) and the context read (strict — a roster outage is 503); an addressed row of a kind no list shows is now a 404 on answer too. -**Ingestion caps (#1632).** There is **no HTTP create** — every operator-queue item is created through `db.create_operator_queue_item`, and the only untrusted producer is the sync service reading the agent-authored `~/.trinity/operator-queue.json` (`operator_queue_service._sync_agent`). #1402 makes this queue the approval channel for irreversible actions, so a compromised / prompt-injected agent that floods plausible "approve this" items causes operator fatigue → reflexive approval (XSS is already handled by DOMPurify; the exposure is volume + social engineering). The one agent-authored seam is bounded by two independent limits plus field hygiene, all env-tunable (see requirements §26.7): (1) a per-agent **pending-DEPTH cap** (`db.count_operator_queue_pending_for_agent`, default 25) — the **primary** bound, DB-measured ⇒ Redis-independent; at the cap the sync **stops** ingesting (never drips a growing file) and holds the surplus behind one aggregated alert; (2) a per-agent + fleet **create RATE limit** (`rate_limiter.check`, 60/60s + 300/60s, fail-open); (3) a total field-hygiene clamp (`_clamp_ingested_item`, inside the #1525 create try/except) — truncate-with-marker title/question, context/options over cap → marker (validated `execution_id` only), non-dict context → `{}`, `created_at` normalized to ingest time, priority validate-only; (4) a **reserved-id guard** rejecting agent ids that start with a platform-reserved prefix (`queue-flood-`/`poison-`/`cb-dormant-`/`sync-failing-`/`git-bloat-`/`skill-not-found-`/`val_`/`system-seed-`/`base-image-stale-`/`alert-budget-`) so an agent can't pre-create — and via `on_conflict_do_nothing` silence — its own flood alarm or the #1402 poison alert, plus a malformed/oversize-id reject; (5) a per-cycle scan bound (skip an oversized file wholesale; cap at 500 requests/cycle). The 5s sync loop is **leader-locked** (`opqueue:leader`, mirror monitoring #1464) so `--workers 2` doesn't double-charge / double-broadcast / double-scan. Platform creates bypass `_sync_agent`, and the exemption is **scoped by influence, not by caller location (#1677)**: a *platform-only* emitter (volume bound by platform cadence — edge-triggered, idempotent/bucketed id, leader-locked, or operator-driven: lease-reaper poison-park #1402, `validation_service._notify_operator_on_failure` — converted to a **direct DB create** in #1632 — and the internal breaker/skill/git alerts) stays direct and unthrottled, while an *agent-influenceable* one — `task_execution_service._alert_skill_not_found` (#1410), whose per-command dedup distinct unknown slash-commands defeat at $0/turn — routes through `operator_queue_service.create_bounded_alert`: a per-(agent, registered-type) pending-DEPTH budget (`OPERATOR_ALERT_MAX_PENDING_PER_TYPE`, default 5; type derived from `item["type"]` against the `_BUDGETED_ALERT_TYPES` frozenset; **fail-closed on every arm** — an unreadable count / unregistered type / failed create suppresses the ALERT only, the FAILED execution rows stay the primary surface, and the paired notification is gated on the same bool), with ONE cooldown-gated `alert-budget-{agent}-{type}-b{bucket}` episode alert per window (deterministic bucket ⇒ the `(agent_name, request_id)` on-conflict target dedups cross-worker; no `held` count, no agent-controlled text). The classification is CI-forced by the AST caller-parity guard `tests/unit/test_1677_operator_alert_emitters.py` (every `create_operator_queue_item` call site must be allowlisted platform-only or routed; OSS tree only — the private submodule owns its twin); a sink-level default bound was rejected because it fails QUIETLY at the load-bearing poison-park create where the parity test fails LOUDLY at CI (#1890's lesson deliberately inverted). A generous DB-sink belt in `create_item` (reject title>4 KiB / question>16 KiB / context>64 KiB / id>512; the derived `execution_id` COLUMN belted to None on non-str/>512, #1677) is a second layer so the exemption is never solely load-bearing (#1525 validate-at-boundary-AND-at-sink). A depth-held / rate-skipped / oversize-file episode emits **one** `queue-flood-{agent}-{utc_now_iso()}` alert (un-guessable id, priority `high`, in-memory cooldown, emit-failure-safe). The platform-emitter residual named on the pull-mode default-ON gate list (#1081 / `TARGET_ARCHITECTURE.md`) is closed by #1677; the remaining gate items are unchanged. +**Ingestion caps (#1632).** There is **no HTTP create** — every operator-queue item is created through `db.create_operator_queue_item`, and the only untrusted producer is the sync service reading the agent-authored `~/.trinity/operator-queue.json` (`operator_queue_service._sync_agent`). #1402 makes this queue the approval channel for irreversible actions, so a compromised / prompt-injected agent that floods plausible "approve this" items causes operator fatigue → reflexive approval (XSS is already handled by DOMPurify; the exposure is volume + social engineering). The one agent-authored seam is bounded by two independent limits plus field hygiene, all env-tunable (see requirements §26.7): (1) a per-agent **pending-DEPTH cap** (`db.count_operator_queue_pending_for_agent`, default 25) — the **primary** bound, DB-measured ⇒ Redis-independent; at the cap the sync **stops** ingesting (never drips a growing file) and holds the surplus behind one aggregated alert; (2) a per-agent + fleet **create RATE limit** (`rate_limiter.check`, 60/60s + 300/60s, fail-open); (3) a total field-hygiene clamp (`_clamp_ingested_item`, inside the #1525 create try/except) — truncate-with-marker title/question, context/options over cap → marker (validated `execution_id` only), non-dict context → `{}`, `created_at` normalized to ingest time, priority validate-only; (4) a **reserved-id guard** rejecting agent ids that start with a platform-reserved prefix (`queue-flood-`/`poison-`/`cb-dormant-`/`sync-failing-`/`git-bloat-`/`skill-not-found-`/`val_`/`system-seed-`/`base-image-stale-`/`alert-budget-`) so an agent can't pre-create — and via `on_conflict_do_nothing` silence — its own flood alarm or the #1402 poison alert, plus a malformed/oversize-id reject; (5) a per-cycle scan bound (skip an oversized file wholesale; cap at 500 requests/cycle). The 5s sync loop is **leader-locked** (`opqueue:leader`, mirror monitoring #1464) so `--workers 2` doesn't double-charge / double-broadcast / double-scan. Platform creates bypass `_sync_agent`, and the exemption is **scoped by influence, not by caller location (#1677)**: a *platform-only* emitter (volume bound by platform cadence — edge-triggered, idempotent/bucketed id, leader-locked, or operator-driven: lease-reaper poison-park #1402, `validation_service._notify_operator_on_failure` — converted to a **direct DB create** in #1632 — and the internal breaker/skill/git alerts) stays direct and unthrottled, while an *agent-influenceable* one — `task_execution_service._alert_skill_not_found` (#1410), whose per-command dedup distinct unknown slash-commands defeat at $0/turn — routes through `operator_queue_service.create_bounded_alert`: a per-(agent, registered-type) pending-DEPTH budget (`OPERATOR_ALERT_MAX_PENDING_PER_TYPE`, default 5; type derived from `item["type"]` against the `_BUDGETED_ALERT_TYPES` frozenset; **fail-closed on every arm** — an unreadable count / unregistered type / failed create suppresses the ALERT only, the FAILED execution rows stay the primary surface, and the paired notification is gated on the same bool), with ONE cooldown-gated `alert-budget-{agent}-{type}-b{bucket}` episode alert per window (deterministic bucket ⇒ the `(agent_name, request_id)` on-conflict target dedups cross-worker; no `held` count, no agent-controlled text). The classification is CI-forced by the AST caller-parity guard `tests/unit/test_1677_operator_alert_emitters.py` (every `create_operator_queue_item` call site must be allowlisted platform-only or routed; OSS tree only — the private submodule owns its twin); a sink-level default bound was rejected because it fails QUIETLY at the load-bearing poison-park create where the parity test fails LOUDLY at CI (#1890's lesson deliberately inverted). A generous DB-sink belt in `create_item` (reject title>4 KiB / question>16 KiB / context>64 KiB / id>512; the derived `execution_id` COLUMN belted to None on non-str/>512, #1677) is a second layer so the exemption is never solely load-bearing (#1525 validate-at-boundary-AND-at-sink). A depth-held / rate-skipped / oversize-file episode emits **one** `queue-flood-{agent}-{utc_now_iso()}` item per over-cap **episode** (#3130: edge-triggered — a second alert needs the condition to have cleared; the 300 s cooldown is only the minimum spacing between episodes), created through `create_bounded_alert` as the budgeted type `queue_flood` (#1677 backstop: ≤5 pending per agent), un-guessable id, priority `high`, wrapped so an emit failure never kills the sync. Platform-minted rows do **not** count toward the agent's own depth (file seam and native `queue_full` read one predicate, `_own_pending_conds`, with `_RESERVED_ID_PREFIXES` excluded) — before #3130 the alert counted toward the depth it reported and was re-minted every cooldown window (386 of one agent's 435 rows). A held file is told so: a file-level `platform.ingestion` block (`reason` ∈ `queue_full`/`rate_limited`/`invalid_id`, `max_pending`, `since`) rides the guarded write-back, written only when it changes and removed once nothing is held; a refused write is retried only after the file changes. The pre-#3130 backlog is collapsed once by the `supersede_queue_flood_backlog` migration (both tracks): newest pending flood row per agent kept, the rest `cancelled`, `disposed_by = 'platform'`, reason `superseded`. The platform-emitter residual named on the pull-mode default-ON gate list (#1081 / `TARGET_ARCHITECTURE.md`) is closed by #1677; the remaining gate items are unchanged. ### Respond → Resume Dispatch (ent#329) diff --git a/docs/memory/architecture/backend.md b/docs/memory/architecture/backend.md index a994943c9..8d60e0d8e 100644 --- a/docs/memory/architecture/backend.md +++ b/docs/memory/architecture/backend.md @@ -138,7 +138,7 @@ - `a2a_outbound.py` - Open-core seam for the OUTBOUND A2A target registry, and the **deliberate inverse of `a2a_gate`: it FAILS CLOSED** — no provider, a provider that raises, or a provider returning a malformed object all refuse the call, because this seam decides *where a credential is sent* whereas `a2a_gate` only restricts an already-authenticated caller. The `isinstance(ResolvedEndpoint)` check on the return value is load-bearing rather than defensive: under a stubbed `sys.modules` a `MagicMock` module returns a truthy endpoint with a mock `.url`, silently inverting fail-closed *inside the suite that proves it closed*. Ships the **OSS provider** — admin-managed named endpoints in `system_settings` as one AES-256-GCM envelope (Invariant #12's `elevenlabs_api_key_encrypted` shape), so OSS is functional with **no new table, no migration, no Alembic revision**. Shipping a working source rather than only the seam is deliberate: a seam with no registered provider resolves nothing, so the tool would answer "no targets configured" on every install. A private per-agent provider may register and take precedence (#736). Each record's credential carries a **kind** (`api_key` default — i.e. every pre-#3185 record — or `payment_token`), a LABEL on the one slot rather than a second secret: it rides that slot's existing three write paths, is inferred from the value when omitted using the SAME predicate the client sends on (`a2a_protocol.decode_payment_token`, so the label can never disagree with the transport), and `api_key` is persisted as the key's ABSENCE so a relabel leaves a pre-#3185-shaped record (#3185) - `ask_service.py` - The ask sink (trinity-enterprise#611): the one path that raises an ask (`raise_ask`, the native create behind `ask_operator`; the gate path calls it with `raised_by="gate"`); a `gate` raise may pass `addressee=` to reach exactly one person, bypassing role resolution — an agent's raise that names one is a programming error, so an agent never chooses who is asked (ent#661) and the one path that ends it (`answer` / `cancel` / `bulk_cancel` / `expire`: CAS writer → audit → thin trigger → ending observers). See [operating-room.md → Endings / Raising an ask](../feature-flows/operating-room.md#endings-trinity-enterprise611). - `portal_capabilities.py` - Open-core seam for **per-person Workspace capabilities** that depend on data the core does not hold (whether an email was invited to something a module owns): a provider registry keyed by capability name, asked by the roster as `has(name, email)`. OSS registers nothing → False; **fails closed** (a raising provider, or any answer but True, is False) because a bit that errs toward True advertises routes that would refuse the caller. A seam file — grepped by `enterprise-docs-guard.yml` (ent#661) -- `turn_context.py` - Open-core seam for **lines added to a Workspace turn**: a list-based provider registry (`register_provider` / `collect(TurnContext)`) that both composers call — the 1:1 chat (`portal_chat`, on BOTH the resumed and the cold arm, beside the open-canvas line) and the room wake (`_wake_agent`, ahead of the file manifest). OSS registers nothing → `""`, turns byte-identical. The context is the PLATFORM's: `chat_id` is the resolved chat row or room, and `internal_audience` is the caller's `is_platform` for a chat and `not room_is_user_facing(...)` for a room, so an internal-only line never reaches a turn an outside client can read. Fails open: a provider that raises is skipped. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (ent#661) +- `turn_context.py` - Open-core seam for **lines added to a Workspace turn**: a list-based provider registry (`register_provider` / `collect(TurnContext)`) that both composers call — the 1:1 chat (`portal_chat`, on BOTH the resumed and the cold arm, beside the open-canvas line) and the room wake (`_wake_agent`, ahead of the file manifest). OSS registers one provider — the discussed-ask line (`client_portal/asks/service.py::_register_turn_context`, registered at import; trinity-enterprise#747), which answers only for a chat that is an ask's discussion chat; every other turn gets `""` and composes byte-identically. A private module may register its own. The context is the PLATFORM's: `chat_id` is the resolved chat row or room, and `internal_audience` is the caller's `is_platform` for a chat and `not room_is_user_facing(...)` for a room, so an internal-only line never reaches a turn an outside client can read. Fails open: a provider that raises is skipped. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (ent#661) - `assignment_provider.py` - Open-core seam for **role assignments** — which human fills which business role for an agent, and which of them the agent primarily serves. OSS registers no provider → `resolve_assignment(agent_name, triggered_by)` returns `None` and the execution-context block renders byte-identically to a build without the seam; a registered module answers with a display name, a role id, the stakeholder list, and whether proactive contact is on file. **Deliberately SYNC**: `compose_system_prompt` is a plain `def` called from async handlers, so a provider that blocks on I/O here stalls the worker — providers answer from memory, never over HTTP. The seam owns the failure handling rather than delegating it (the `mfa_gate` position, not `a2a_outbound`'s): `compose_system_prompt` has NO exception handler, so an escape costs all three of its callers the execution-context block and costs one of them the platform prompt entirely. Shape validation sits beside the `try` because `try`/`except` cannot see the defect it catches — a `str` where a list was promised iterates into single characters and renders the WRONG prompt without raising, which is harder to notice than a missing line. `triggered_by` is passed through so a provider can suppress the answer for an audience that must not see staff identities; the seam carries the label and never decides that policy. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (trinity-enterprise#500) A provider may also answer `people_for(agent_name, role)` (optional; trinity-enterprise#611): `resolve_role_people` returns the people who fill a role for the ask sink's `to` addressing, and `None` on any failure, so the core defaults hold. - `role_addressing.py` - The ONE rule that turns an outbound object's `to:` role (`primary | approver | viewer | operator`) into people (trinity-enterprise#606): a provider's `people_for` answer first, else primary → owner, operator → nobody, approver/viewer refused (`RoleRefused`, named codes). Shared by asks (`ask_service._address`), reports (`routers/reports._report_audience`) and messages (`routers/messages._recipient`); agent-supplied emails are still honoured and logged as deprecated (`log_email_addressing`). - `a2a_outbound_service.py` - Outbound orchestration: kill switch → rate bounds (per-agent **and** a fleet Redis key — a per-agent limit bounds one agent, the fleet is the exhaustion path) → resolve → validate → `effect_guard` → call → `agent_activities` row. Ordering is load-bearing: bounds before resolution so a flood can't become a DNS amplifier, and validation before the guard so a refused URL never burns an effect claim (#736) diff --git a/docs/memory/architecture/execution.md b/docs/memory/architecture/execution.md index d5c8ac136..9c92a11cb 100644 --- a/docs/memory/architecture/execution.md +++ b/docs/memory/architecture/execution.md @@ -31,8 +31,8 @@ Two independent per-agent breakers, separate Redis namespaces and separate Lua, **Dispatch breaker** (`dispatch_breaker.py`, key `agent:dispatch:{name}`, RELIABILITY-007): producer-side, fed *only* by execution outcomes in `task_execution_service` — counts **AUTH only** (`error_code == AUTH`, agent answers HTTP 503), NOT TIMEOUT/AGENT_ERROR (D10). Consecutive-failure machine `closed → open → half-open(probe) → closed`; default threshold 3, base cooldown 30s, exponential backoff (D9). `record_outcome(error_code)` returns the `(prior, new)` transition; the **caller** backgrounds the drain on `→open` (no `capacity`/`db` import in the breaker → no circular dep, D3). Never raises. `record_failure("missed_heartbeat")` is the #307 seam. `record_success` is a no-op write (Lua early-return) when already closed with zero failures, so healthy agents don't churn Redis. Gating: per-agent `circuit_breaker_enabled` (default OFF) AND global `DISPATCH_BREAKER_ENABLED` must both be on. **Execution-path flow** (details in [dispatch-circuit-breaker.md](../feature-flows/dispatch-circuit-breaker.md)): -- `CapacityManager.acquire(...)` gates the breaker at the TOP (before overflow). A deny raises `CircuitOpen` before any slot/overflow work — a doomed task is never enqueued (**no-enqueue invariant**, D2). A half-open **probe** is admitted ONLY into a free slot (full → fast-fail, never a verdict-less backlog row that stalls backoff, F1). -- `task_execution_service` (single execution path) records every outcome: `record_outcome(None)` at success (resets), `record_outcome(AUTH)` at the HTTP-error terminal (counts). On `→open` it backgrounds `_fail_backlog_and_audit` via `_spawn_bg` (`db.fail_queued_for_agent` → FAILED + clear queue + audit); catches `CircuitOpen` → `TaskExecutionResult(CIRCUIT_OPEN)` + FAILED row. The step-3b pre-dispatch check fast-fails on `state == "open"` only on the backlog-drain path (`slot_already_held and not dispatch_gate_checked`), never blocking an already-admitted probe. +- `CapacityManager.acquire(...)` gates the breaker at the TOP (before overflow). A deny raises `CircuitOpen` before any slot/overflow work — a doomed task is never enqueued (**no-enqueue invariant**, D2). A half-open **probe** is admitted ONLY into a free slot (full → fast-fail, never a verdict-less backlog row that stalls backoff, F1). On a pull pilot (`pull_exclusive`) the probe is enqueued instead: the queue is that agent's real dispatch, the worker runs the row and the pull sink records the verdict, and a slot would push the probe past the pool its work must go through (#1982, #2514). +- `task_execution_service` (single execution path) records every outcome: `record_outcome(None)` at success (resets), `record_outcome(AUTH)` at the HTTP-error terminal (counts). The pull sink (`pull_coordination_service.apply_task_result`) records the same verdicts on its CAS-won branch — success, or `auth` — so a pilot's breaker trips on pulled auth failures and closes on a pulled probe (#2514). On `→open` it backgrounds `_fail_backlog_and_audit` via `_spawn_bg` (`db.fail_queued_for_agent` → FAILED + clear queue + audit); catches `CircuitOpen` → `TaskExecutionResult(CIRCUIT_OPEN)` + FAILED row. The step-3b pre-dispatch check fast-fails on `state == "open"` only on the backlog-drain path (`slot_already_held and not dispatch_gate_checked`), never blocking an already-admitted probe. - **Backstop**: if the inline drain is lost, the 60s `run_maintenance` sweep (`_backstop_open_breaker_backlog`) re-fails queued backlog for any still-open breaker (~60s, not the 24h generic expiry). **Lifecycle clearing (#1560):** both breakers are keyed by agent **name**, carry no TTL, and are therefore inherited by the next container to hold that name — a fresh, healthy agent fast-failed as "unhealthy" without ever being contacted. `services/agent_runtime_state.py` is the single enumeration point for every name-keyed per-agent keyspace (heartbeat, both breakers, slots) and is called from six lifecycle points: `start_agent_internal` (the load-bearing one — every config-drift recreate passes through it, as does the #1809 image-drift recreate: a rebuilt `trinity-agent-base` is adopted on the next **cold** start via the lazily-evaluated `check_base_image_matches` predicate (the start path passes `require_running=False` to `recreate_container_with_updated_config` — #2186: it recreates stopped containers by design and starts them on the next line, and #2092's `True` default made cold-start adoption unreachable and any stopped-agent drift a 500) — fail-open, never **acted on** for a running agent or an ephemeral ghost (#1816 splits the same comparison into a 3-state `check_base_image_state` so a running agent's drift can be *reported* without being acted on; the boolean wrapper is `state != "drift"`); cleared *before* the recreate, since `containers_run(detach=True)` brings the replacement up; guarded on `needs_recreation or not was_already_running` so a no-op start can't reset a live breaker; **#1816** additionally makes the whole `needs_recreation` block a no-op for a **running** `trinity-system` — a structural `is_system AND was_already_running` gate that returns `recreate_deferred="system_agent_running"` instead of replacing the orchestrator's container mid-operation, independent of how many config predicates exist), agent create, the `trinity-system` bootstrap (a permanently-recycled fixed name), delete, rename (old **and** new name), and the retention purge (the instant `is_agent_name_reserved` stops matching and the name becomes reusable). Slots are cleared only where the container is provably gone or stopped — `force_clear_slots` wholesale-`DEL`s `agent:slots:{name}` and would drop capacity accounting for an in-flight #1083 async execution on a running agent. `tests/unit/test_1560_agent_redis_key_parity.py` fails CI when a new `agent:*` keyspace ships unregistered. diff --git a/docs/memory/feature-flows/agent-canvas.md b/docs/memory/feature-flows/agent-canvas.md index 8dce8e4d9..c246cefb4 100644 --- a/docs/memory/feature-flows/agent-canvas.md +++ b/docs/memory/feature-flows/agent-canvas.md @@ -899,6 +899,26 @@ What it pinned, each now structural rather than per-case: - **Switching canvases is guarded.** `CanvasPanel.select` drops a fetch a later selection superseded — the header used to name one canvas while the slower fetch's blocks belonged to another. +- **A newly created canvas is followed (#3218).** The panel's selection rules, + in order: the first load selects the first row of the pinned-first order; a + refresh that brings a canvas id the previous list did not have selects it + (`canvasesAppeared` — the id that appeared, never `rows[0]`, so a pinned + canvas sorting ahead does not steal it; several at once → the most recently + updated) and emits `canvas-selected`, so the next turn carries it; a rewrite + of the open canvas re-reads it in place (ent#475); a deleted open canvas + falls back to the new arrival if there is one, else the first row; a list + sharing no id with the previous one (the panel is not keyed per agent, so + switching agents swaps the list under it) reads as a first load, and so does + the first list after an empty one (Agent Detail mounts the panel with `[]` + before its list loads; an empty list is not a baseline). A rewrite + of a *different*, existing canvas does **not** pull focus — deliberately, so + an agent maintaining several canvases cannot keep moving the reader. While + the reader is mid-interaction (manage mode, a search query, the share dialog) + the switch is **deferred**, never forced: the arrival is remembered and + followed when the interaction ends, dropped if the reader picks a canvas + meanwhile (a match the search selects counts as a pick) or the arrival is + gone. Applies on every `CanvasPanel` mount + (Agent Detail's Canvas tab, the Workspace rail, the voice column). - **The call columns are flex shares, not row percentages.** `
` `sm:flex-[2_1_0%]` and the voice canvas `sm:flex-[3_1_0%]` — the old `w-[40%]` + `w-[60%]` beside the 18rem sidebar summed to 100% + 18rem and @@ -964,3 +984,4 @@ rail's Canvas tab outside a call still refreshes on its own triggers. | 2026-09-07 | claude | The render bar (#2583): the canvas gallery e2e (17 canvases, Agent Detail + rail + voice column, both themes, measured), the flex-share call columns, per-block containers, the per-block error boundary, bounded table/timeline/diagram-error viewports, container-keyed KPI grid, long-token wrapping, `min(px, 100%)` inline widths, chart legend/flat-range/isolated-point/axis-spacing fixes, the stale-fetch guard on canvas switching, and the ` diff --git a/src/frontend/src/components/portal/PortalInbox.vue b/src/frontend/src/components/portal/PortalInbox.vue index 1fc896024..6c59911d5 100644 --- a/src/frontend/src/components/portal/PortalInbox.vue +++ b/src/frontend/src/components/portal/PortalInbox.vue @@ -182,7 +182,7 @@ @back="back" @open-chat="(url) => url && $emit('open-chat', url)" @reply="(url, reply) => url && $emit('reply', url, reply)" - @open-thread="(t) => $emit('open-chat', `/workspace/c/${t.id}`)" + @open-thread="(t) => $emit('open-thread', t)" /> @@ -242,7 +242,10 @@ const props = defineProps({ // verdict: a false result is `inbox-pane-read-error`. markRead: { type: Function, default: null }, }) -const emit = defineEmits(['refresh', 'open-chat', 'reply', 'update:preview', 'open-menu', 'open-canvas']) +// `open-thread` carries `{ id, agent_name }`, not a URL: the chat can be one this +// shell has not listed yet (a Discuss chat, trinity-enterprise#747), and only the +// shell's `openThread` adopts an id the thread list does not hold (#3140). +const emit = defineEmits(['refresh', 'open-chat', 'open-thread', 'reply', 'update:preview', 'open-menu', 'open-canvas']) const store = useClientPortalStore() const route = useRoute() diff --git a/src/frontend/src/components/portal/PortalInboxList.vue b/src/frontend/src/components/portal/PortalInboxList.vue index 85e824270..eaa1de1f4 100644 --- a/src/frontend/src/components/portal/PortalInboxList.vue +++ b/src/frontend/src/components/portal/PortalInboxList.vue @@ -212,6 +212,8 @@ function askStatusLabel(it) { if (s === 'answered') return it.ask?.ended_by === 'you' ? 'Answered · you' : 'Answered' if (s === 'expired') return 'Expired' if (s === 'cancelled') return 'Cancelled' + // trinity-enterprise#748: the addressee's own "no answer". + if (s === 'dismissed') return it.ask?.ended_by === 'you' ? 'Dismissed · you' : 'Dismissed' if (s === 'unavailable') return 'No longer available' return s || '' } diff --git a/src/frontend/src/components/portal/portalChatAsks.js b/src/frontend/src/components/portal/portalChatAsks.js index e3205b3d0..38c3169b3 100644 --- a/src/frontend/src/components/portal/portalChatAsks.js +++ b/src/frontend/src/components/portal/portalChatAsks.js @@ -15,9 +15,20 @@ import { relativeTime } from './portalUtils' // The asks drawn in the chat on screen. Only the platform's literal `true` // counts (ent#734 writes it; the backend projection already coerces, and this // repeats the rule so a stray truthy value can never draw a background ask). +// trinity-enterprise#747: a chat opened to DISCUSS an ask draws that ask too, +// whatever raised it — the discussion is where it is decided. export function chatTurnAsks(asks, sessionId) { if (!sessionId || !Array.isArray(asks)) return [] - return asks.filter((a) => a && a.raised_in_turn === true && a.chat_id === sessionId) + return asks.filter((a) => a && ( + (a.raised_in_turn === true && a.chat_id === sessionId) + || isDiscussedIn(a, sessionId) + )) +} + +// trinity-enterprise#747: is `sessionId` the chat this ask's addressee opened +// to discuss it? +export function isDiscussedIn(ask, sessionId) { + return !!sessionId && !!ask && ask.discussion_chat_id === sessionId } const ms = (iso) => { @@ -50,15 +61,20 @@ function rowTime(item) { // `truncated` (earlier messages are not shown): an ask older than the first row // shown belongs to the part that is not, so it is left out rather than stacked // on top with none of its conversation around it. -export function placeAsksInThread(items, asks, { truncated = false } = {}) { +// +// `sessionId` (trinity-enterprise#747): the ask a chat was opened to DISCUSS is +// the chat's subject, not a row of its history — it heads the thread (it +// predates every message there) and survives `truncated`, so a long +// discussion never loses the card it is about. +export function placeAsksInThread(items, asks, { truncated = false, sessionId = null } = {}) { const rows = Array.isArray(items) ? items : [] if (!Array.isArray(asks) || !asks.length) return rows let pending = asks - .map((a) => ({ a, at: ms(a.created_at) ?? -Infinity })) + .map((a) => ({ a, at: isDiscussedIn(a, sessionId) ? -Infinity : (ms(a.created_at) ?? -Infinity) })) .sort((x, y) => (x.at - y.at) || String(x.a.id).localeCompare(String(y.a.id))) if (truncated) { const first = rows.map(rowTime).find((t) => t !== null) - if (first !== undefined) pending = pending.filter((p) => p.at >= first) + if (first !== undefined) pending = pending.filter((p) => p.at >= first || isDiscussedIn(p.a, sessionId)) } const out = [] let next = 0 diff --git a/src/frontend/src/stores/clientPortal.js b/src/frontend/src/stores/clientPortal.js index 5a58a5f2e..9b1bca4f9 100644 --- a/src/frontend/src/stores/clientPortal.js +++ b/src/frontend/src/stores/clientPortal.js @@ -55,6 +55,14 @@ export function pruneCarryLog(entries, now = Date.now()) { // is the shape that drifts while each side's tests pin its own version. import { REPORT_ROWS_PAGE as ROWS_PAGE } from '@/utils/reportPaging' +// trinity-enterprise#748: how long a Dismiss can be undone. Nothing reaches the +// server — and so nothing reaches the agent — until it lapses. +export const ASK_DISMISS_UNDO_MS = 5000 +// The pending dismissals' timers, by ask id. Outside the state on purpose: a +// timer handle is not data any surface renders, and Pinia would proxy it. +const askDismissTimers = new Map() +let askDismissSeq = 0 + // #2791: exported so the cross-tab listener and the shared 401 verdict can ask // whether a CLIENT session is live without re-deriving the key. export const PORTAL_TOKEN_KEY = 'trinity.portalToken' @@ -268,6 +276,12 @@ let briefingsBatchInFlight = false // stale answer would win. `hydrateBriefings` re-runs once for a dirty name. const briefingsDirty = new Set() +// trinity-enterprise#748: the ask as the server WILL project it once the +// dismissal lands — shown during the Undo window. +function dismissedLocally(ask) { + return { ...ask, status: 'dismissed', ended_by: 'you', ended_at: new Date().toISOString() } +} + export const useClientPortalStore = defineStore('clientPortal', { state: () => ({ // ent#555 — agent name → the canvas id the rail currently shows. @@ -312,6 +326,17 @@ export const useClientPortalStore = defineStore('clientPortal', { asksLoaded: false, asksFailed: false, asksLoadedAt: null, + // trinity-enterprise#748: dismissals not yet confirmed by the server, by ask + // id → `{ before, committing }`: the row as it was, and — once the POST is + // on the wire, when it can no longer be undone — that request's token. The list shows the + // ask as dismissed meanwhile, so it leaves every surface at once. + askDismissals: {}, + // A dismissal the server refused, by ask id → the message; the ask is back. + askDismissErrors: {}, + // trinity-enterprise#747: an answer that woke its agent — `{ askId, + // agentName, chatId, since }` — whose result the chat `chatId` is waiting + // to show. Cleared when the result lands or the wait gives up. + askResultWatch: null, // Where the user was when it expired, so re-authenticating returns them // there instead of the roster root. resumePath: null, @@ -616,6 +641,13 @@ export const useClientPortalStore = defineStore('clientPortal', { this.asksLoaded = false this.asksFailed = false this.asksLoadedAt = null + // trinity-enterprise#748: a dismissal still in its Undo window belongs to + // the session that made it — it is dropped, never sent under the next one. + askDismissTimers.forEach(clearTimeout) + askDismissTimers.clear() + this.askDismissals = {} + this.askDismissErrors = {} + this.askResultWatch = null // Round-3 /cso: the session list keeps its last good copy the same way // (#2198), so it is session state and goes with the session too. this.lastSessions = [] @@ -2132,7 +2164,17 @@ export const useClientPortalStore = defineStore('clientPortal', { params: agentName ? { agent_name: agentName, include_ended: true } : { include_ended: true }, }) if (stale()) return [] - this.asks = Array.isArray(data) ? data : [] + // trinity-enterprise#748: an ask inside its Undo window stays dismissed + // on screen; the poll's "pending" is only the server not knowing yet. + // One the server already ended some other way (answered, expired) + // has nothing left to dismiss or undo: its window closes here. + this.asks = (Array.isArray(data) ? data : []).map((a) => { + const d = this.askDismissals[a.id] + if (!d) return a + if (a.status === 'pending') return dismissedLocally(a) + if (!d.committing) this.dropAskDismissal(a.id) + return a + }) this.asksAvailable = true this.asksAbsent = false this.asksLoaded = true @@ -2228,6 +2270,104 @@ export const useClientPortalStore = defineStore('clientPortal', { this.asks = data && data.id === askId ? this.asks.map((a) => (a.id === askId ? data : a)) : this.asks.filter((a) => a.id !== askId) + // trinity-enterprise#747: the agent was woken with this answer, and its + // result will land as a message in the ask's DISCUSSION chat, and only + // there (the backend's `_workspace_destination`, #3181 review F1 — an + // undiscussed ask's run stays owner-only). The open chat watches for it + // (no history poll exists otherwise, ent#457). + if (data && data.resume_requested === true) { + const chatId = data.discussion_chat_id || null + this.askResultWatch = chatId + ? { askId, agentName: data.agent_name, chatId, since: Date.now() } + : null + } + return data + }, + + // trinity-enterprise#748 — Dismiss, in one click, with an Undo window. + // + // The ask is shown as dismissed at once (every surface reads `asks`, so it + // leaves the Inbox, the chat and the counts together), and the server is + // told only when `delayMs` lapses. Undo inside the window restores the row + // and sends nothing — so nothing reaches the agent. Closing the tab inside + // the window drops the dismissal: the ask stays waiting, the safe direction. + dismissAsk(askId, { delayMs = ASK_DISMISS_UNDO_MS } = {}) { + const row = this.asks.find((a) => a.id === askId) + if (!row || row.status !== 'pending' || this.askDismissals[askId]) return false + this.askDismissals = { ...this.askDismissals, [askId]: { before: row, committing: false } } + const { [askId]: _cleared, ...errors } = this.askDismissErrors + this.askDismissErrors = errors + this.asks = this.asks.map((a) => (a.id === askId ? dismissedLocally(a) : a)) + askDismissTimers.set(askId, setTimeout(() => { this.commitDismissAsk(askId) }, delayMs)) + return true + }, + + // Undo a dismissal still in its window. False once it went out: the + // server may already have told the agent. + undoDismissAsk(askId) { + const d = this.askDismissals[askId] + if (!d || d.committing) return false + this.dropAskDismissal(askId) + this.asks = this.asks.map((a) => (a.id === askId ? d.before : a)) + return true + }, + + dropAskDismissal(askId) { + clearTimeout(askDismissTimers.get(askId)) + askDismissTimers.delete(askId) + const { [askId]: _gone, ...rest } = this.askDismissals + this.askDismissals = rest + }, + + // The window lapsed: tell the server. The row is replaced by the server's + // projection — `dismissed`, or how the ask really ended when an answer or + // the deadline got there first (the server's no-op). A refusal puts the + // ask back as it was and says why, beside it. + async commitDismissAsk(askId) { + const d = this.askDismissals[askId] + clearTimeout(askDismissTimers.get(askId)) + askDismissTimers.delete(askId) + if (!d || d.committing) return null + // On the wire from here: Undo is withdrawn before the request, never after. + // A token, not the entry object: state reads come back as reactive + // proxies, so object identity never matches. + const token = ++askDismissSeq + this.askDismissals = { ...this.askDismissals, [askId]: { before: d.before, committing: token } } + const mine = () => this.askDismissals[askId]?.committing === token + try { + const { data } = await portalHttp.post( + `/api/enterprise/client-portal/asks/${encodeURIComponent(askId)}/dismiss`, + {}, + { headers: this.authHeader }, + ) + if (!mine()) return data + this.dropAskDismissal(askId) + if (data && data.id === askId) this.asks = this.asks.map((a) => (a.id === askId ? data : a)) + return data + } catch (err) { + if (!mine()) return null + this.dropAskDismissal(askId) + this.asks = this.asks.map((a) => (a.id === askId ? d.before : a)) + this.askDismissErrors = { + ...this.askDismissErrors, + [askId]: err.response?.data?.detail?.message || 'Could not dismiss this ask. Try again.', + } + return null + } + }, + + // trinity-enterprise#747 — open (or continue) the chat in which this ask is + // talked through with its agent. The ask row is refreshed from the reply, + // so every surface learns its `discussion_chat_id` at once. Rethrows: the + // control that asked says why it could not open. + async discussAsk(askId) { + const { data } = await portalHttp.post( + `/api/enterprise/client-portal/asks/${encodeURIComponent(askId)}/discuss`, + {}, + { headers: this.authHeader }, + ) + const ask = data?.ask + if (ask && ask.id === askId) this.asks = this.asks.map((a) => (a.id === askId ? ask : a)) return data }, diff --git a/src/frontend/src/utils/operatorQueue.js b/src/frontend/src/utils/operatorQueue.js index 2858999ed..2c4bcaa14 100644 --- a/src/frontend/src/utils/operatorQueue.js +++ b/src/frontend/src/utils/operatorQueue.js @@ -126,6 +126,8 @@ export const QUEUE_TYPE_LABELS = Object.freeze({ approval: 'Needs approval', question: 'Question', alert: 'Heads up', + // #3130: the flood alarm became a budgeted platform type; it is still an alert. + queue_flood: 'Heads up', }) /** @@ -325,7 +327,9 @@ export const QUEUE_RESPONSE_NOT_ADDRESSEE = * item is still pending. * * `{ kind, label, who, when }`: - * - `kind` — `answered | cancelled | expired`: the ledger's `disposition`, + * - `kind` — `answered | dismissed | cancelled | expired`: the ledger's + * `disposition` (`dismissed`, trinity-enterprise#748: the person it was + * addressed to chose not to answer; its status is `cancelled`), * else the terminal status (a row that ended before the ledger). The * Workspace projection's own `status` (`answered`) reads the same way. * - `who` — the person, for the Operating Room (`disposed_by_email`, or a @@ -338,16 +342,17 @@ export const QUEUE_RESPONSE_NOT_ADDRESSEE = */ export const ENDING_LABELS = Object.freeze({ answered: 'Answered', + dismissed: 'Dismissed', cancelled: 'Cancelled', expired: 'Expired', }) function endingKind(item) { const d = item.disposition - if (d === 'answered' || d === 'cancelled' || d === 'expired') return d + if (d === 'answered' || d === 'dismissed' || d === 'cancelled' || d === 'expired') return d const s = item.status if (s === 'responded' || s === 'acknowledged' || s === 'answered') return 'answered' - if (s === 'cancelled' || s === 'expired') return s + if (s === 'dismissed' || s === 'cancelled' || s === 'expired') return s return null } diff --git a/src/frontend/src/views/Portal.vue b/src/frontend/src/views/Portal.vue index ada793329..927f0b3fa 100644 --- a/src/frontend/src/views/Portal.vue +++ b/src/frontend/src/views/Portal.vue @@ -362,6 +362,7 @@ @update:preview="(k) => { inboxPreview = k }" @refresh="refreshThreads" @open-chat="(url) => router.push(url)" + @open-thread="openNewThread" @reply="replyInChat" >