Skip to content

feat(operator-queue): an agent sees its pending asks and can replace one (#3247) - #3256

Draft
vybe wants to merge 11 commits into
feature/3243-atomic-asksfrom
feature/3247-replace-pending-ask
Draft

vybe wants to merge 11 commits into
feature/3243-atomic-asksfrom
feature/3247-replace-pending-ask

Conversation

@vybe

@vybe vybe commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #3247 — third of the chain #3242 → #3243 → #3247. Stacked on #3253 — base is feature/3243-atomic-asks; the stack merges together (operator, 2026-10-05). Draft until the chain is in.

What

A scheduled run no longer has to re-ask what is already pending: it is shown its own open asks, and it can replace one.

  • The agent sees its pending asks. Every composed turn (push, chat and pull-claimed) lists the agent's own pending asks beside the ended ones — request_id (type, age) "title", oldest first, at most 8, then "and N more". It reads the same rows that count toward the open-ask limit, is omitted when empty, and never fails the turn. On public and paid turns the entries carry no title.
  • replaces on ask_operator. The predecessor ends as cancelled / disposed_by = agent / reason replaced, the successor is created, and the two are linked (replaces / replaced_by) — one step inside the per-agent locked transaction. An approval is never changed in place.
  • A person's answer wins. A predecessor that was answered, cancelled or expired is never touched; the agent gets 409 replaces_ended with ids and enums only. A predecessor past its deadline is expired on the spot and the replace is refused, so supersedes_expired works at once.
  • Ownership. Only the agent's own rows with raised_by = 'agent' can be replaced — never a platform-raised, gate or NULL-raiser row, never another agent's. Every such attempt gets one uniform 422 invalid_replaces, and the compare-and-set repeats the check.
  • already_pending. An agent-raised approval whose proposal equals one of the agent's own pending agent-raised approvals is refused with 409 already_pending {request_id} unless replaces names it. It runs inside the lock, applies to approvals with a non-empty proposal only, and never refuses or counts a gate or platform raise.
  • Surfaces. A replaced ask reads "Replaced by the agent" and names its successor; the successor shows "Replaces …" — Operations cards and detail, /m, the Workspace ask views, get_my_ask. The Workspace names the other ask only when the same person could already see it. No wake fires for the replaced ask.
  • Schema. Two nullable link columns on both tracks (Alembic 0090_operator_queue_replace on 0089…).
  • Contract text. One clause in the ask_operator description (1,762 characters, under the 1,800 test from feat(operator-queue): atomic asks — authoring rules in the ask_operator contract, option and title limits (#3243) #3253) and the full rule in the replaces parameter; the prompt gains "do not re-ask what is still pending; if the facts changed, replace it".

Rulings carried (orchestrator, on the operator's behalf — plan file)

  • T1–T8 as the plan recommended, ruled by the operator 2026-10-05, including T3 the schema change and T8 the already_pending guard (added by the plan, not in the issue).
  • Accepted deviation: the two asks name each other by request_id in a badge; the Operating Room has no deep link to a single item.
  • This is the narrow slice of abilityai/trinity-enterprise#619; priority inheritance, withdrawal without a successor and the wider correction flow stay there.

Review + security

Correctness + security review (claude-fable-5-1, by reading): MERGEABLE, no critical finding. A separate wide test sweep then found what reading had missed:

  • Fixed in b12152f9: the already_pending guard also refused the skill-approval gate's deliberate one-approval-per-occurrence raises (four ent#751 tests red on the branch, green on its base). The guard is now the agent's own. The same commit fixes an older bug the refusal exposed: skill_gate_service read e.status where the exception carries status_code.
  • Fixed in cdd31c0f: the guard moved inside the lock (two concurrent identical raises could both insert); approvals with a real proposal only; replaces_ended carries disposed_by and no longer calls every cancel an operator's; no ask titles on public and paid turns; stale "person | timeout" strings.
  • b8ad5960 restores docs/user-docs/agents/recommended-fleet-prompt.md to its base content: that page mirrors a canonical prompt owned elsewhere, and its sentence "Only a person ends an ask" is the owner's to reword.
  • /cso --diff: nothing supported.

Tests

Reported by the builders on the fix tip: named sets 581 + 382 passed; the 43-file importer sweep in shuffled order 461 + 478 + 489 passed; mcp-server 697 passed, tsc --noEmit clean; frontend unit 267 files / 4,670 passed (before the fix commits, which touch no frontend file). One pre-existing collection error at the base too: test_inter_agent_timeout_unit.py (dispatch_breaker_active missing).

Before merge

Handoffs

  • The canonical fleet prompt's rule "Only a person ends an ask" — owner's decision whether to mention the agent's own replace.
  • abilityai/trinity-enterprise#619 keeps the rest of the correction flow.

🤖 Generated with Claude Code

Trinity Agent (trinity) and others added 11 commits October 5, 2026 15:24
…3247)

CP1 of the replace-a-pending-ask slice. `operator_queue` gains two nullable
TEXT link columns: `replaces` on the successor (the predecessor row's uuid)
and `replaced_by` on the predecessor (the successor row's uuid). Both are
stamped in the one per-agent locked transaction the next checkpoint adds, so
each row is self-describing on every surface that holds only one of the pair.

Both schema tracks (Invariant #9): SQLite entry `operator_queue_replace` and
Alembic `0090_operator_queue_replace` chained after this branch's head
`0089_supersede_queue_flood_backlog` — `0090` is also taken by the
independent #3246 branch and open PR #3145, so expect a renumber at merge;
`check_alembic_heads.py` names the fork. `schema.py` and `tables.py` DDL
updated (the `disposed_by` comment gains `agent`); `_row_to_item` /
`_SELECT_COLS` carry the columns; `_MACHINE_ROW_FIELDS` and the ent#715
`MACHINE_ROW_KEYS` pin are extended in the same commit. No index.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CP2 of the replace-a-pending-ask slice. `create_native_item` gains
`replaces=<predecessor uuid>`, handled inside the existing per-agent lock
after the replay check: a compare-and-set ends the predecessor (`cancelled`
/ `disposed_by='agent'` / `disposition_reason='replaced'`, `replaced_by` =
the uuid this call minted) only if it is still `pending`, not past its
deadline (respond's clause, so "denied by timeout" cannot be sidestepped),
under this agent AND `raised_by='agent'` — the belt beneath the sink's
ownership gate. The successor is inserted with `replaces` set.

Lost CAS: the predecessor already ended → `replaces_ended`, the row as it
stands, nothing written — a person's answer always wins. Still pending past
its deadline → expired here as the clock would (`expired_now`, T5b), then
refused. Not this agent's own agent-raised ask → `replaces_not_own`,
nothing written. Over the cap after the CAS, or a colliding insert after it
(the PG file-poller race), `_RollBack` unwinds the whole transaction so a
half-replace never commits; a replay never re-applies the replace. The
`database.py` facade mirrors the parameter (both `_FACADE` pins stay green).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g asks (#3247)

CP3 of the replace-a-pending-ask slice. `ask_operator` / `raise_ask` gain
`replaces=<request_id>`: `_replace_target` is the security gate — the lookup
is scoped to `(agent_name, request_id)` and the row must carry
`raised_by == 'agent'` ON THE COLUMN (never `_raiser_of`, which calls an
unreserved NULL-raiser platform alarm "agent"); one uniform 422
`invalid_replaces` for malformed / self / missing / another agent's / a
gate's / a platform's / a pre-#611 row. The CAS belts it again.

The predecessor ends `cancelled` / `disposed_by='agent'` /
`disposition_reason='replaced'` through the one ending sink (`_ended`): an
agent-keyed `replaced` audit row, a thin `operator_queue_cancelled`, and the
observers — `_wake_filer` skips an ending the agent authored itself (T6),
asserted on the post-CAS row. A predecessor that already ended is refused
409 `replaces_ended` with ids and enums only (a person's answer stands,
read with `get_my_ask`); still pending past its deadline it is expired
in-transaction and the normal expiry event fires first (T5b). T8: a new
ask whose canonical `proposal` equals one of the agent's own PENDING asks is
refused 409 `already_pending {request_id}` unless `replaces` names it
(`list_pending_proposals_for_agent` + facade). Receipt gains `replaces`,
`replaced_by`, `disposed_by`; `_differs` compares `replaces`; the readback
maps both links to request_ids; `OperatorAskCreate.replaces`.

Pins re-set in the same commit: `RECEIPT_KEYS`, the ent#611 `_FACADE` list
and `READBACK_KEYS`, the ent#329 `_SET_CAS_ACCESSORS` (the native create
is a CAS writer whose `predecessor` is the won row), and the ent#611 body
helper now proposes a distinct action per ask (T8's accepted consequence).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every composed turn (push, chat and pull-claimed — all go through
compose_system_prompt) now lists the agent's own pending asks beside the
ended ones: `request_id (type, age) "title"`, oldest first, at most 8,
titles sanitized and cut to 48 characters, then "and N more — list them
with list_operator_queue". Drawn from _own_pending_conds, the predicate
that spends the open-ask budget, so the line and queue_full cannot
disagree. Omitted when there are none; a failed read omits the line,
never the turn. An ending the agent authored itself (disposed_by=agent)
reads `replaced` on the Ended asks line.

Tests: TestPendingLine in test_3247_replace_ask.py (red before the
renderer existed: no Pending asks line), facade pin in test_ent611.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ask_operator: one clause right after the idempotency sentence ("Never
  re-ask what is still pending … set replaces"); the full rule and every
  refusal (replaces_ended, invalid_replaces, already_pending, replaced_by)
  in the new `replaces` parameter description. The "(and in your owner's
  Workspace …)" aside moved into the `to` parameter to pay for it. The
  published description is 1,762 characters (bound: 1,800, unchanged).
- get_my_ask: disposed_by gains `agent`; names replaced_by / replaces.
- types.ts: replaces on the create, replaces/replaced_by/disposed_by on
  the receipt and the item.
- Platform prompt (both authored copies): "Do not re-ask what is still
  pending" paragraph naming replaces_ended and already_pending; contract
  step 3 says the Execution Context also lists pending asks. Agent guide
  follows. SENTINELS += the rule, replaces_ended, already_pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
utils/operatorQueue.js — one rule for every Operations surface:
- endingKind: a cancel with disposed_by (or the Workspace projection's
  ended_by) = 'agent' is `replaced`; ENDING_LABELS.replaced = 'Replaced';
  queueEnding.who = 'the agent' → "Replaced by the agent". A person's
  cancel, a timeout and a platform ending keep their own words.
- queueReaskBadges: the successor says "Replaces <request_id>" from its
  own `replaces`, the predecessor "Replaced by <request_id>" from its own
  `replaced_by`; the loaded list only supplies the other ask's request_id
  (a fallback label when it is not loaded).
ResolvedCard hides the raw `replaced` reason token on an agent-authored
ending; both cards key the badge testid by the badge key. /m inherits.

Spec operatorQueueReplace.spec.js mounts ResolvedCard, QueueCard and
MobileAdmin; red before the change (a replaced ask read "Cancelled").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A replaced ask reads "Replaced by the agent" on every Workspace view and
leaves the "needs you" count; the replacement shows "Replaces <request_id>".

- `_ending_of` maps `disposed_by == 'agent'` to `ended_by: agent` BEFORE the
  email arm, so the agent's replace is never read as a person's or the
  operator's ending.
- `WorkspaceAsk` gains `replaces` / `replaced_by`: the OTHER ask's request_id,
  nothing else about it, and only when this ask's addressee could already see
  that ask (same agent, same addressee, a visible kind). A replacement
  addressed to someone else or to the operator is never named.
- `PortalAsks.endingLine`, `PortalInboxList.askStatusLabel` and
  `portalChatAsks.askHistoryLine` read through one `workspaceEndingText`.

Red with the source reverted: workspaceReplacedAsk.mount.spec.js (5 of 9) and
TestWorkspaceProjection (5 of 5) fail; green restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… false (#3247)

- requirements/security.md §26.9: `disposed_by` is person | timeout |
  platform | agent; "only a person ends an ask" names its one exception (an
  agent replacing its OWN pending ask); the Execution Context line covers
  pull-claimed turns and the new pending line; the replaced-ask surfaces,
  with the accepted deviation stated: the two asks name each other by
  request_id in a badge, and there is no deep link to a single item.
- §26.10: replace (`invalid_replaces`, `replaces_ended`, atomicity, no wake),
  `already_pending` (T8), and the pending line.
- feature-flows/operating-room.md: the replace flow, the ledger enum, the
  stale pull-turn line (pull-claimed turns DO compose the platform prompt),
  a changelog row.
- architecture/api-endpoints.md, backend.md, database.md (the two link
  columns and `disposed_by = 'agent'`).
- user-docs/automation/approvals.md (Replace concept, `replaces?`, who can
  end an item, the pending list) and recommended-fleet-prompt.md ("Only a
  person ends an ask" gains the replace exception).
- A learnings fragment; tests/registry.json entries for
  test_3247_replace_ask.py, operatorQueueReplace.spec.js and
  workspaceReplacedAsk.mount.spec.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n, and a refused gate raise surfaces its real code (#3247)

The T8 guard refused the skill gate's second approval for the same
occurrence (trinity-enterprise#751 raises one approval per occurrence with
the same proposal): four ent#751 tests were red on this branch and green
on its base. The guard now runs only for an agent-raised ask and compares
only against the pending asks the same agent raised itself
(`raised_by == 'agent'` on the column); a gate raise and a platform row are
never refused by it and never counted by it.

The refusal also exposed an older bug: `skill_gate_service` read `e.status`
where `AskRejected` carries `status_code`, turning any sink refusal into an
AttributeError (a 503 `approval_unavailable`). A refused gate raise now
reaches the caller with its real status and code.

Every other caller of the raise path was checked: the three gate sites pass
`raised_by="gate"`, the router passes `raised_by="agent"`, and nothing in
the private submodule calls it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…approvals only, disposed_by on replaces_ended, no titles on outside turns, stale strings (#3247)

I2: the duplicate-proposal guard moves from the sink into the create's own
locked transaction (`_pending_proposal_match`, after the replace's
compare-and-set and before the depth count), so two concurrent raises with
one proposal have exactly one winner and a refusal rolls the CAS back with
everything else; a test models the interleaving with two threads.

I3: the guard applies to an agent's `approval` with a non-empty proposal
only — a question or an alert is never guarded, and a missing or empty
proposal is not a proposal, on both sides of the comparison.

I4: `replaces_ended` carries `disposed_by` and its message is branched on it,
so a platform ending is not called an operator's.

I1: the pending line shows the agent's own ask titles only on a turn serving
the owner or an operator; on `public` and `paid` turns each entry is
`request_id (type, age)` with no title (`build_execution_context`, the one
place the audience is known). Both modes tested; security requirement and
operating-room flow updated.

I5: the two "person | timeout" comments (dependencies.py, types.ts) name all
four authors, and respond_to_operator_queue's description now reads: a
person answers or cancels, an agent can only replace its own pending ask,
the platform expires. ask_operator's description is unchanged (1,762 chars).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…o the branch base (#3247)

`docs/user-docs/agents/recommended-fleet-prompt.md` mirrors a canonical
prompt text that is owned and edited elsewhere first; CP7's change to its
"Only a person ends an ask" sentence is the owner's call, not this branch's.
The file is restored to its content at the branch base; whether that
sentence should mention the agent's own replace now needs the owner's
decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant