feat(operator-queue): ask endings — one sink, endings ledger, person-only endings, wake on any ending, self-readback (abilityai/trinity-enterprise#611, PR A of 2) - #3023
Conversation
…only endings, wake on any ending, self-readback (Abilityai/trinity-enterprise#611, PR A of 2) Every way an ask ends goes through one sink, services/ask_service.py: the operator answer, the Workspace answer, single cancel, bulk cancel, and the poller's expiry. Each ending runs the same four steps: - a compare-and-set that also writes the endings ledger (disposition, disposed_at, disposed_by person|timeout, disposed_by_email, disposition_reason, batch_id); - one audit row per transition; - one thin /ws trigger; - the registered ending observers, which receive only the rows this call won. What changes: - Only a person ends an ask. respond / cancel / bulk-cancel and the Workspace answer refuse agent-, system- and every other non-person key with 403 person_required (an allowlist). - An answer after the deadline is a 409 expired, even before the sweep. - The ent#329 wake now fires on any ending, under the same opt-in. A cancel or an expiry wakes the agent once per agent per event (trigger operator_ending); an expiry is framed as "Denied by timeout". - An agent reads back its own ask by request_id: new GET /api/agents/{name}/operator-queue/{request_id}, and MCP get_my_ask. - Every composed turn gets an "Ended asks" Execution Context line. - Endings show with who and when in the Operations Resolved feed, a /m "Recently ended" strip, and the Workspace (ended asks listed for 7 days with a coarse who). - Migration pair operator_queue_ask_object (SQLite) / 0075 (Alembic): 12 nullable columns. Review and CSO fixes, each with a failing test first and a red mutation: - an out-of-range deadline no longer fails ingest; - the wake's container check runs off the event loop; - an operator's Clear All no longer hides a client's ended asks; - the Clear-All confirmation promises a wake only to running agents; - a system-scoped key can no longer answer an ask through the Workspace answer route. Part of Abilityai/trinity-enterprise#611 (PR A of 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, Clear-All read-through, deadline overflow and the off-loop running check Part of Abilityai/trinity-enterprise#611 (PR A of 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The two red checks (
The same failure appears on other open PRs; #3015 caps the test pin. Production images pin I'll re-run once #3015 lands. Everything else is green, including journey-smoke, e2e, the regression diff and pytest (head). |
/review ReportBranch: Execution coverage
Fix mutations: 10 in total, all red, each on the line that applies the fix and restored byte-identically. They are listed in the PR body under Mutation. Critical FindingsNone. Informational Findings — all fixed on this branch, each with a failing test first
Security (
|
|
✅ Alembic head check clear — merging this PR into Previously flagged; resolved. Advisory — this check does not block merge. · head_sha: |
# Conflicts: # src/backend/db/migrations.py # tests/registry.json
…0076_operator_queue_ask_object #3017 landed 0075_auto_sync_enabled_backfill on 0074, the same parent as 0075_operator_queue_ask_object, which made two heads: upgrade head would then apply zero revisions (#2068). Renumber ours to 0076 and parent it on dev's 0075. The two revisions touch disjoint tables (agent git config vs operator_queue), so the order carries no design decision. The SQLite migration keeps its name (operator_queue_ask_object), so a database that already applied it does not run it again; its list entry follows dev's. Point-in-time security reports keep the number they audited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # tests/registry.json
vybe
left a comment
There was a problem hiding this comment.
Validated at lane C + schema, head dfcb7c0: READY. No criticals. All checks green on this head. The four terminal writes are CAS-gated on status == 'pending' with the ledger in the same UPDATE; the person gate is an allowlist that runs before the row read; new /ws triggers carry identifiers only; the three schema sources agree column for column and the tree resolves to one Alembic head (0076).
Follow-ups, none blocking, all for your call:
- Ended asks above the composer are unbounded.
PortalConversation.vue:435-443mountsPortalAskswithoutpending-only, andfetchAsksnow sendsinclude_ended: true, so theshrink-0block above the composer renders every ask that ended in the last 7 days (server limit 200) with no max-height or scroll. On a phone a handful of ended asks takes most of the screen. It needs a bound or a collapsed form; which one is a design choice. - Cancel
reasonhas no in-product producer:stores/operatorQueue.js:209sends{ ids }only, so the field is reachable by raw API alone. to_role,resolved_to,proposalandsupersedes_expiredhave no writer until #3028 lands.- The expiry wake is agent-drivable: an opted-in agent can file short-deadline asks and be woken each poll cycle (
operator_resume_service.py,maybe_dispatch_ending). Bounded by the opt-in andmax_parallel_tasks, but uncapped. - Machine keys are denied
disposed_by_emailbut still receiveresponded_by_email, which holds the same value on answered rows. client_portal/asks/service.pyanswer_askreportsresume_requestedfrom the opt-in alone, so a platform-minted row whose observer skips dispatch still reports true.
The branch is left in place because #3028 is stacked on it.
… fields; keep-both elsewhere dev (#3023) and this branch each added a third field to PortalPrincipal. dev's is_person stays third, since code on dev already constructs it positionally; is_admin goes last, which is what its own comment asks for. get_portal_principal passes both. The one positional construction that meant is_admin (test_ent465_suggestions.py) now names it. migrations.py tail and tests/registry.json: both sides kept, dev's first; the registry is rebuilt from the merge stages, not spliced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ipts, re-asks (Abilityai/trinity-enterprise#611, PR B) An agent raises an ask through ONE platform call instead of appending to ~/.trinity/operator-queue.json: POST /api/agents/{name}/operator-queue and the MCP tool ask_operator, both self-acting (the agent comes from the key; any other principal gets 403 agent_identity_required). - ask_service.raise_ask is the create entry point of the ask sink, the seam the gate path (trinity-enterprise#164) calls with raised_by="gate". Static validation with named 422s; replay BEFORE every time-dependent check; the 15-minute deadline floor; the own-expired-predecessor link and the reask_requires_link refusal; role addressing; the #1632 rate buckets; an atomic per-agent create (replay, depth cap and insert under one lock: a PG advisory transaction lock, SQLite BEGIN IMMEDIATE); one `raised` audit row and a thin operator_queue_new trigger. - The receipt names the role, never an email. A replay returns the first receipt with `differs`; `wakes_on_ending` says whether the owner's wake opt-in is on. - Native rows sit outside the file contract everywhere. The poller skips a file entry that re-uses a native id before any branch reads it (logged once), creates through create_operator_queue_item_with_outcome and counts only what it inserted, and names the file channel's deprecation once per agent per process. - The Operator Communication prompt leads with ask_operator by its bare name; the file is the fallback for two releases (the meta-prompt copy and the agent guide mirror it). - The Operations cards link a re-ask and the expired ask it re-raises. Rebuilt on dev after #3023 squash-merged (f6dfb00): this commit is the net delta of the former PR B commits 19b2e53..3a2dffd (the feat above, the two review fixes: the `raised` audit row is the agent's, and reads never name a native ask's resolved person; plus the docs), replayed onto dev unchanged. Same 47 files and the same added/removed lines as B's old diff against #3023's final head dfcb7c0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ipts, re-asks (Abilityai/trinity-enterprise#611, PR B) (#3028) * feat(operator-queue): agents raise asks natively — ask_operator, receipts, re-asks (Abilityai/trinity-enterprise#611, PR B) An agent raises an ask through ONE platform call instead of appending to ~/.trinity/operator-queue.json: POST /api/agents/{name}/operator-queue and the MCP tool ask_operator, both self-acting (the agent comes from the key; any other principal gets 403 agent_identity_required). - ask_service.raise_ask is the create entry point of the ask sink, the seam the gate path (trinity-enterprise#164) calls with raised_by="gate". Static validation with named 422s; replay BEFORE every time-dependent check; the 15-minute deadline floor; the own-expired-predecessor link and the reask_requires_link refusal; role addressing; the #1632 rate buckets; an atomic per-agent create (replay, depth cap and insert under one lock: a PG advisory transaction lock, SQLite BEGIN IMMEDIATE); one `raised` audit row and a thin operator_queue_new trigger. - The receipt names the role, never an email. A replay returns the first receipt with `differs`; `wakes_on_ending` says whether the owner's wake opt-in is on. - Native rows sit outside the file contract everywhere. The poller skips a file entry that re-uses a native id before any branch reads it (logged once), creates through create_operator_queue_item_with_outcome and counts only what it inserted, and names the file channel's deprecation once per agent per process. - The Operator Communication prompt leads with ask_operator by its bare name; the file is the fallback for two releases (the meta-prompt copy and the agent guide mirror it). - The Operations cards link a re-ask and the expired ask it re-raises. Rebuilt on dev after #3023 squash-merged (f6dfb00): this commit is the net delta of the former PR B commits 19b2e53..3a2dffd (the feat above, the two review fixes: the `raised` audit row is the agent's, and reads never name a native ask's resolved person; plus the docs), replayed onto dev unchanged. Same 47 files and the same added/removed lines as B's old diff against #3023's final head dfcb7c0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): wire OPERATOR_QUEUE_PROPOSAL_MAX_BYTES like its siblings (Abilityai/trinity-enterprise#611, PR B) The native ask path reads OPERATOR_QUEUE_PROPOSAL_MAX_BYTES (default 8192), but no compose file passed it into the backend container, so setting it in .env had no effect on a deployed instance. The 14 sibling operator-queue caps are all in the three compose files and .env.example; this one now is too. It sits right after OPTIONS_MAX_BYTES, the same position as the constant in operator_queue_service.py. So prod and hosted keep identical environment lists, and the #2280 parity guard compares them in order. The deploy guide's env table and requirement §26.10 name it as well. Found by /validate-pr §4.9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): review round on the ask sink — the gate's reserved prefix, raised_by on the receipt, primary's empty answer, rate check first (Abilityai/trinity-enterprise#611, PR B) From the changes-requested review and the merge-train note on #3028: - `gate-` is a platform-reserved id prefix. A gate raise (`raised_by="gate"`, `channel="gate"`) must use it, and an agent's raise may not. So an agent can no longer pre-create the gate's id and have the gate's raise answered as a replay of the agent's own proposal. And it is no longer woken to redo an action the gate resumes itself, because `is_platform_minted` keys on the reserved tuple and gates both wakes. The receipt now says who raised the ask (`raised_by`), and an unknown raiser or channel is a ValueError. - A provider that answers "nobody" (`[]`) for `primary` now sends the ask to the operators with `resolved: false` (the ent#606 fallback). The owner stands in only when no provider answers at all. - The rate check runs right after the replay, before the re-ask scan and the owner lookup read the database. A refused ask now spends a token; a replay still spends none. - The native insert takes ON CONFLICT DO NOTHING. The file poller does not take the per-agent lock, so on PostgreSQL a file entry re-using the id can land between the replay check and the insert. That now comes back as a replay of the file row, not a 500. - `get_my_ask` names a re-ask's predecessor by request_id, as the receipt does. The row still stores its uuid. - Docstrings: a gate raise shares the agent's rate and depth budget (it fails closed), and ending observers run in-process and at most once. `channel` is documented as file|mcp|gate. Tests came first: 18 went red before their fixes. New tests also run the real owner lookup, and a real minted key through the route: key resolution, the identity check and the ephemeral fence. Each of 13 mutations of the fixed lines turns a test red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(operator-queue): an approval shows its proposal wherever the decision is made (Abilityai/trinity-enterprise#611, PR B) The prompt tells agents to put the exact action in `proposal` "so the operator can verify what they are approving", but nothing rendered it. An agent that followed the prompt moved the one thing an approver must check out of view (review on #3028). - `components/operator/QueueProposal.vue` renders it read-only. The rows come from `utils/operatorQueue.js::proposalRows`: top-level fields in the agent's order, text verbatim, anything else as compact JSON. It is agent-authored, so the values are text only (never v-html). The component uses gray tokens only, and the list scrolls inside its own box. - It appears on every card that offers the decision: the open Operations card (above the collapsed context, never behind "Show details"), the /m ops card and the Workspace ask. - The Workspace projection (`WorkspaceAsk`) now includes `proposal`; `context` stays out. An ended ask keeps its proposal, so the person sees what was decided. - `QueueItemDetail.vue` has no importer and reads store state that no longer exists, so it is left alone. Mounted specs cover the three surfaces, the rule and the text-only rendering (12 tests), with two projection tests beside them. Each of 7 mutations of the wiring turns a test red. Checked by eye on a local instance in light and dark. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(operator-queue): the approval's second review pass — order-safe test, gate rows' receipts, replay and re-asks stay with their raiser, proposal on Resolved (Abilityai/trinity-enterprise#611, PR B) From the approving review on #3028: - `test_an_agent_may_not_author_a_gate_id` now has its own agent and ids. A sibling test creates `gate-call-abc` on the same agent, and the unit database keeps rows between tests. The test failed on 2 of the 3 nightly seeds and passed on the one seed PR CI uses. It now passes under 20 seeds, the nightly three included. - A gate row's receipt no longer promises a wake: `wakes_on_ending` is false on a platform-minted row, which `_wake_filer` skips. - `channel="gate"` if and only if `raised_by="gate"`; anything else is a ValueError. - A gate raise is never answered as a replay of a row it did not raise, at either replay site (409 `request_id_taken`). Example: a file row named `gate-…` written before the prefix was reserved. - The re-ask guard (C6) and the re-ask link compare with the SAME raiser's expired asks. An agent's expired ask no longer blocks, or gets linked by, a gate raise, and the reverse. `list_expired_proposals_for_agent` gains a `raised_by` filter. `_raiser_of` treats a row older than the column as the agent's own, and a platform alarm as nobody's. - The Operations Resolved card keeps the proposal too, so the record says what was decided (the Workspace already did). Tests came first: 9 went red before their fixes, and 2 more pin the legacy rows. Each of 12 mutations of the fixed lines, both replay call sites included, turns a test red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(operator-queue): the race tests' holds patch the running method's globals (Abilityai/trinity-enterprise#611, PR B) The full unit island at nightly seed 67890 failed the staged-race test on PostgreSQL's sqlite leg with `created` where `replayed` was expected. The holds were installed with `monkeypatch.setattr(dbq, "make_insert", …)` after `import db.operator_queue as dbq`. In a full-suite run a sibling test can leave a stand-in for `db.operator_queue` that re-exports the real class. The patch lands on the stand-in, while the real method reads its own module dict, so the hold never fires. Reproduced with a plugin that installs such a stand-in: - the committed staged-race tests fail; - the concurrent-cap test passes even with the lock removed. All four holds now go through `create_native_item.__globals__` (test_1632's `_patch_engine`), and the table object comes from the same dict. With the stand-in installed, the fixed tests pass, and removing the lock turns the cap test red again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ent#610 stopgap) Since #3023 the Workspace asks list carries asks that ended in the last 7 days, and the strip above the composer rendered all of them. Answered approval tiles piled up in every chat with the agent. Per the ent#610 ruling, an ended ask belongs to history, not to the strip. The strip now hosts PortalAsks `pending-only`, like the Work tab. It is no longer gated on a count in the conversation: PortalAsks decides its own visibility, including the ent#468 "sent" confirmation that must outlive the last pending ask. A parent v-if on the pending count would unmount it the instant it appears. The top gap follows content via :has(). Mount tests cover: - the strip passes pending-only; - a pending ask shows and an answered one never does; - only ended asks renders nothing; - answering the last ask keeps the confirmation; - without pending-only, history still lists ended asks. Three source-text pins that asserted the old gating shape now assert the new one, with the same intent. Mutation: dropping pending-only turns the wiring test red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Description
An ask ends in exactly one of three ways — answered, cancelled or expired. This PR records every ending, audits it once, delivers it to the agent that raised it, and shows it with who and when. It is PR A of the ent#611 ask object; PR B adds the native create path (
ask_operatorover MCP).services/ask_service.py: the operator answer, the Workspace answer, single cancel, bulk cancel and the poller's expiry. Before this there were five write sites, each doing its own subset: single cancel did none of audit, broadcast or wake; respond audited nothing; expiry never broadcast. Each ending now runs the same four steps:disposition,disposed_at,disposed_by ∈ {person, timeout},disposed_by_email,disposition_reason,batch_id);/wstrigger;person_required. It is an allowlist overmcp_scope, and it runs before the row is read. Before this, an agent's own key resolved to its owner and could answer its own approval.expired, even before the poller sweeps the row.expires_atis stored ISO-Z at ingest (Invariant #16).operator_responseresume.operator_ending). An expiry carries the rider verbatim: "Denied by timeout; do not re-ask the same action without new information." The operator's reason is framed as data.GET /api/agents/{name}/operator-queue/{request_id}and MCPget_my_ask: self-only (identity comes from the key), a redacted projection, and still readable after Clear All./mgets a "Recently ended" strip.you/the operator/timeout) and never an operator's email or reason. The sidebar count and "Waiting on you" stay pending-only.operator_queue_ask_object(SQLite) and Alembic0076_operator_queue_ask_objecton0075_auto_sync_enabled_backfill. Twelve nullable TEXT columns, no backfill. Six serve the endings ledger; six serve the agent-raised ask that PR B writes.Design decisions beyond the approved plan
ask_service._opted_in, one flag read per agent per event). An agent that has not opted in is never handed to a spawner, which keeps ent#430's rule that an opted-out agent is not dispatched at all. The spawned work re-reads the flag at spend time, and that read is the authority.answer()validates first). The sink is the one writer of an answer, so no entry point can skip the check. The routes mapResponseNotOfferedErrorto their existing 422 shapes, and the bug: POST /api/operator-queue/{id}/respond accepts any string as an approval decision — no layer checks response ∈ options #2376 AST guard now namesservices/ask_service.py.operator_queue_respondedand the newoperator_queue_cancelledcarry{id, agent_name}only.operator_queue_respondedused to broadcast the answer text and the responder's email.operator_queue_cleareddropscleared_by; it is allowlisted in the ent#467 guard with its reason.operator_queue_syncannounces it, even when Docker is unreadable._broadcast_payloadis a named identity function so that the ent#467 guard, which follows a*broadcast*(…)call back to its dict literal, can read every payload.answerAskreplaces the row with the server'sansweredprojection instead of removing it.pending-only.operator_endingis registered whereveroperator_responseis: the Executions filter, the analytics bucket,_AUTONOMOUS_TRIGGERS,PULL_REACHABLE_TRIGGERS,canvas_service._OPERATOR_SIDE_TRIGGERS, and the frontendKNOWN_TRIGGERS.operator_responsewas also missing fromKNOWN_TRIGGERS: at 17 characters it exceeds the 16-character fallback and was folding into "other".#wake-when-an-ask-ends, and every live link was updated.Fixed during review and
/cso --diff(each with a failing test first and a red mutation)cleared_at, which is the operator's list hygiene, just as the agent's readback does.disposed_by='person'.PortalPrincipalnow carriesis_person, and the answer route refuses a non-person with the same 403. The Workspace's read breadth for system keys (bug: Agent Detail and Workspace over-fetch on load (21 redundant requests, N+1 roster) #2198) is unchanged. Report:docs/security-reports/cso-diff-2026-09-25-ent611-ask-endings.md.Related Issue
Part of abilityai/trinity-enterprise#611 (PR A of 2). The issue moves to
status-in-devonly when PR B lands.This PR has no closing keyword, on purpose. It is PR A of 2, and the issue closes with PR B. Adding one at merge time would mark the issue done before its second half ships.
Journey Impact
Journey Impact: extends: J05
Type of Change
Behaviour change worth naming: agent- and system-scoped keys can no longer answer or cancel asks. An agent answering its own approval was self-approval; the MCP
respond_to_operator_queuedescription says so.Testing
Backend.
tests/unit/test_ent611_ask_endings.py, 162 tests, real SQLite for every ledger writer. Neighbour suites were updated where the sink moved a call site: ent#329, ent#430 ×2, #2915, #2376, ent#467, ent#499, #2048, #2198. The full unit island ran sequentially on the merged tree (669f585a1): 18,209 passed and 40 failed. The 40 are the same order-dependent set as a cleancec2a64berun:test_ent666_objective_join×21,test_ent477_*×15 andtest_retention_floor×4. Each passes in isolation. They predate this PR and are now registered as debt.Frontend.
operatorQueueEnding.spec.js,portalAskEndings.spec.js,mobileAdminRecentlyEnded.spec.js(mounted, jsdom) andworkspaceAsks.spec.js. The raw-colour, loading-gate and source-text ratchets are green with no baseline edits: the new UI reuses each file's existing class strings.MCP.
operator_queue.test.ts(theget_my_askstub client) andaccess-wiring.test.ts(transport: identity from the key; a user-scoped key refused).Guards.
check_alembic_heads(one head,0076) andcheck_alembic_parityboth PASS. The enterprise-docs guard has 0 hits, with a positive control.Mutation:
answer()drops its_status_conflictrefusal →TestSinklost/late-answer tests and the tightened G2 guard (test_ent329_operator_resume.py) go red.bulk_cancelhands observers the REQUESTED rows →TestTheWakeGetsOnlyWhatEnded::test_pending_a_and_already_cancelled_b_wake_for_a_onlygoes red.TestWakeObserverRoutingandtest_ent499_problem_report.pygo red.get_my_askcall site uses a fixed agent →operator_queue.test.tsandaccess-wiring.test.tsgo red.services/ask_service.py.OverflowError→TestCreate::test_expires_at_is_stored_as_iso_z[9999…]andtest_the_stored_deadline_never_reads_as_a_rewrite[9999…]go red.TestEndingWake::test_the_running_check_never_blocks_the_event_loopgoes red.include_cleareddropped →TestWorkspaceListing::test_an_operator_clear_all_never_hides_an_ending_from_the_person_it_was_forgoes red.TestWorkspaceAnswerIsPersonOnly::test_a_non_person_platform_principal_is_refused_before_the_row_is_readgoes red.is_personnever computed →TestWorkspaceAnswerIsPersonOnly::test_the_platform_path_carries_whether_the_caller_is_a_person[system-False]goes red.Each file was restored byte-identically.
What CI does not run
batch_idon exactly the flipped rows), and an expiry swept about 2 s after its deadline, each ledgered;answered/cancelled(has_reason, no text) /bulk_cancel/expired;operator_responseand 2operator_endingwakes, all successful, with the expiry wake carrying the rider verbatim. A not-opted-in agent was never woken;person_required);get_my_askover MCP returning the redacted projection;/mstrip, and the Workspace flow as a client.0076upgrade on real PostgreSQL runs only in the path-filteredpg-migrationsjob.Merge notes
0076_operator_queue_ask_objectparents on0075_auto_sync_enabled_backfill. It was0075on0074until fix(git-sync): the auto-sync toggle is authoritative and live (#3010) #3017 landed its own0075on the same parent; it was re-parented and renumbered then (the two touch disjoint tables). If another revision lands ondevfirst, re-parentdown_revisiononto the new head at merge;alembic-head-watchwill comment.dependencies.py,client_portal/portal_auth.py): journey-smoke and frontend-e2e must be green on this PR.responded_by_email/addressed_to_emailon queue reads;max_parallel_tasks).stale_idmis-flag, where every ended ask reads "Re-used id" on the Resolved card.Checklist
security.md§26.6 and new §26.9 OPS-001-ENDINGS;operating-room.md(Endings),execution-context-injection.md,mobile-admin-pwa.md,workspace-work.md,workspace-agents-at-the-centre.md,workspace-rail.md;api-endpoints.md,database.md,mcp-server.md,background-services.md,workspace.md;🤖 Generated with Claude Code