Conversation
…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>
5 tasks done
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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.replacesonask_operator. The predecessor ends ascancelled/disposed_by = agent/ reasonreplaced, 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.409 replaces_endedwith ids and enums only. A predecessor past its deadline is expired on the spot and the replace is refused, sosupersedes_expiredworks at once.raised_by = 'agent'can be replaced — never a platform-raised, gate or NULL-raiser row, never another agent's. Every such attempt gets one uniform422 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 with409 already_pending {request_id}unlessreplacesnames 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./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.0090_operator_queue_replaceon0089…).ask_operatordescription (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 thereplacesparameter; 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)
already_pendingguard (added by the plan, not in the issue).request_idin a badge; the Operating Room has no deep link to a single item.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:
b12152f9: thealready_pendingguard 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_servicereade.statuswhere the exception carriesstatus_code.cdd31c0f: the guard moved inside the lock (two concurrent identical raises could both insert); approvals with a real proposal only;replaces_endedcarriesdisposed_byand no longer calls every cancel an operator's; no ask titles on public and paid turns; stale "person | timeout" strings.b8ad5960restoresdocs/user-docs/agents/recommended-fleet-prompt.mdto 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 --noEmitclean; 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_activemissing).Before merge
backend-unit-test(the shuffled, parallel run) does not run here. That run found an order-dependent failure on the chain's other PR today that no engineer-side run had shown — read the full run once this PR retargetsdevbefore merging.0090_operator_queue_replaceis the third0090in open work (fix(operator-queue): platform alerts are conditions — one pending row per subject (#3246, part 1) #3255, feat(pull): /chat on the durable queue for pull pilots (#3127) #3145). The later ones renumber: file name, revision id and docstring,down_revision, the SQLite migration docstring,ALEMBIC_REVISIONintest_3247_replace_ask.py, the database architecture line.utils/operatorQueue.js,operator_queue.ts, docs. Both add an ending author (platformthere,agenthere): keep both. Landing fix(operator-queue): platform alerts are conditions — one pending row per subject (#3246, part 1) #3255 first means resolving them once, here.operating-room.md) and feat(pull): /chat on the durable queue for pull pilots (#3127) #3145 (db/migrations.py).dev(493d2a6).Handoffs
🤖 Generated with Claude Code