From 8be17ddf3a451a47b15c83a571dcf68cbc72d6f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:53:42 -0700 Subject: [PATCH] feat(chat): host writes one saved row per subagent (#27947) * feat(chat): read one saved row per subagent Adds the singular `subagent` journal block (one row per child, revised in place, with a plain-text twin for older clients) and teaches every reader to draw it: desktop and phone fold a turn's sibling rows into the existing "Ran N subagents" row, the client and off-page rosters name children from their own rows, the reopen pass settles a row a dead host left working, and the phone and worker-transcript wires bound it. No producer writes the block yet; that switch lands in the follow-up. * feat(chat): host writes one saved row per subagent Claude, Codex and Grok now only report child-work evidence. The host writes each subagent's row from its child record, so the chat row, strip and sidebar read one lifecycle. Deletes the shared subagent tracker, the per-agent rosters and their caps. An accepted Stop settles only the run it was sent to, provisionally, until that run reports its own ending. Work the host store has no room to record keeps the running-tasks strip and its Stop instead of reading as idle. * fix(chat): open subagent background flag, separate nested group ids, page window skips subagent rows - background is an open boolean so a later build's value never fails a chat load - a helper's own helpers fold under a groupId of their own, so the phone opens them apart - each subagent's own row rides along with the page window instead of counting as conversation * fix(chat): subagent row writer reads the live turn once, stop acks survive a failed batch - the row writer reads the journal's live turn at most once per change and only for a subagent with no saved, queued or in-flight row; a revision keeps its first scope - a background stop names each accepted task as its answer lands, so a stop-all batch that times out or fails part way still ends the runs it was answered for (Claude and Grok) - the strip's untargeted Stop rule lives in the shared view (offersStopAll); both strips inline it again; incomplete tracking no longer holds commands or rewind - clients admit a frame whose only change is incomplete tracking - drop the Claude stop's dead publish; the real-CLI stop test asserts the run's own ending - list the writer test with the real SQLite tests * fix(chat): a late Grok report never revives an ended helper; Codex keeps announced helpers - Grok: once a helper's run ended, a later progress or repeated spawn sends no live evidence, so its record and saved row stay ended after the next send retires the record; the test rig now retires finished records on each turn open, as the host does - Codex: past the bound, only a child that ran and ended is evicted, so an announced helper that has not started keeps its record, row, Stop and parent - cut: Codex parentTurnId (written, never read), the Claude identities' turn-end forwarding (call the corrections directly), the duplicate Grok outcome map - update stale comments that described the deleted producers * test(chat): Claude frames to saved row, kept Codex path and usage rules, agent ingestion exemption - a captured background Claude helper keeps its saved row working past its spawn result and the parent turn's end, through the real adapter, host and child records, until its own end - restore focused tests for the Codex root path, label trim and placeholder, usage before announcement and parent usage - the per-session live limit test uses tasks: subagents are exempt from it * fix(chat): incomplete tracking follows the refused children, and the strip's Stop reaches them - the host remembers which children the store refused for room, by handle; each leaves when a later report of it is admitted or it reports its own end, so incomplete clears on a truthful signal; the provider session's end still clears all, and past 512 a flag holds until then - a parent row the store refused counts every live child it reports as refused - with work no record lists, an untargeted strip Stop while no turn runs ends the agent through the idle sweep's stop; under a running turn it leaves the agent alone - the background Stop moves into its own module * fix(chat): the incomplete Stop is offered for every agent and decides inside its step - the strip offers the untargeted Stop for work no row lists whenever no turn runs, whatever untargeted stop the provider has (ACP agents, Codex before it proves terminal stops), since the host reaches it by ending the agent; never under a running turn - the Stop reads whether work is unlisted and whether a turn runs inside its own step, after the listed stops land, so a wake-up turn opened meanwhile is never ended - Codex executions past the bound evict the oldest ended child, else the oldest never started - test the writer's unnamed-subagent label; qualify the writer's retry comment * refactor(chat): the subagent row writer joins the views with their records Drops the shared host-only row projection and its hook-server read: the writer's wiring reads the views every surface reads and the records they come from, and takes residency and the ending's basis from the record. * refactor(chat): Grok subagent evidence reads its ending through the one outcome map * refactor(chat): a subagent's group entry as one object * fix(chat): a background Stop with nothing listed asks the agent for no task stops So an agent with no stop route (an ACP agent the probe found none for) never refuses before the Stop ends the work no row lists. Tests: the unlisted case is untargeted and reads unlisted work inside the step; the repeat-press rig lists the task its Stops reach; reflow the writer header. * test(chat): a nested helper's saved parent; describe how incomplete tracking clears * fix(chat): a closed session claims no Stop; a helper's row follows its own running work - a session close Orca proves is loss of contact, not a Stop the CLI accepted: Claude no longer ends what still ran as stopped, so the host settles it unknown and its row reads unverifiable, as before this stack; stop-acknowledged comes only from a Stop the CLI accepted - a helper that finished while work it started still runs is written working, by the strip's own rule; once that work ends, or the session ends, it reads its own ending again - the CLI starting a completed helper again under the same spawn call is the helper waking when its work ends: the decoder reports it as a restart, so its one row revises in place and ends with its real duration; after any other ending such a start is still read as late - tests from a captured 2.1.280 frame order; item 4 Stops pin their stop-acknowledged basis * refactor(chat): drop the unused Stop basis on a child's own ending Only the acknowledged-Stop edge writes that basis now; reflow the decoder header. * fix(chat): an older history page keeps a turn's subagent ordinals and row A page now reaches back to the first sibling of each subagent group it holds part of, at most one more page of conversation, so loading the older page no longer renumbers same-named helpers or moves (and closes) their folded row. --- .../scripts/vitest-sqlite-runtime-files.mjs | 3 +- .../MobileNativeChatBackgroundTasks.tsx | 5 +- ...ent-timeline-characterization.test.ts.snap | 538 ----- src/main/acp/acp-session-update.ts | 4 +- .../acp-structured-child-session-end.test.ts | 94 +- src/main/acp/acp-structured-child-stop.ts | 39 +- ...cp-structured-child-work-admission.test.ts | 142 +- .../acp-structured-child-work.test-support.ts | 40 +- .../acp/acp-structured-child-work.test.ts | 27 +- ...tructured-grok-subagent-recordings.test.ts | 63 +- .../acp-structured-host-subagent-stop.test.ts | 30 +- src/main/acp/acp-structured-lane.ts | 86 +- .../acp/acp-structured-session-adapter.ts | 2 +- src/main/acp/acp-subagent-child-work.ts | 78 - ...subagent-timeline-characterization.test.ts | 115 -- src/main/acp/acp-subagent-timeline.test.ts | 128 -- src/main/acp/acp-subagent-timeline.ts | 133 -- src/main/acp/acp-subagents.ts | 173 ++ src/main/acp/acp-timeline-event.ts | 13 +- .../acp/acp-timeline-fixture.test-support.ts | 29 + ...p-timeline-grok-subagent-retention.test.ts | 96 +- .../acp/acp-timeline-grok-subagents.test.ts | 69 +- src/main/acp/acp-timeline-translator.ts | 5 +- .../agent-hooks/agent-hook-runtime-deps.ts | 3 +- ...agent-roster-characterization.test.ts.snap | 1734 ----------------- .../claude-background-stop-records.test.ts | 66 +- ...laude-captured-wake-frames.test-fixture.ts | 125 ++ .../claude/claude-child-work-decoder.test.ts | 41 +- src/main/claude/claude-child-work-decoder.ts | 68 +- src/main/claude/claude-command-turn.test.ts | 26 - src/main/claude/claude-message-journaling.ts | 8 +- src/main/claude/claude-open-turn.ts | 12 +- src/main/claude/claude-result-journaling.ts | 6 +- ...ude-structured-child-work-captures.test.ts | 7 +- ...ude-structured-child-work-producer.test.ts | 13 +- .../claude-structured-control-actions.test.ts | 74 +- .../claude-structured-control-actions.ts | 30 +- ...-journal-translation-context-usage.test.ts | 2 +- ...ured-journal-translation-subagents.test.ts | 197 +- .../claude-structured-journal-translation.ts | 29 +- ...aude-structured-real-cli-stop-task.test.ts | 183 ++ .../claude-structured-session-adapter.ts | 3 +- .../claude-structured-session-close.test.ts | 4 +- .../claude/claude-structured-session-close.ts | 8 +- src/main/claude/claude-subagent-identities.ts | 101 + .../claude-subagent-journaled-identities.ts | 86 + .../claude-subagent-journaled-roster.test.ts | 170 -- .../claude-subagent-journaled-roster.ts | 75 - ...aude-subagent-resume-after-restart.test.ts | 323 +-- ...e-subagent-roster-characterization.test.ts | 196 -- .../claude-subagent-roster-retention.test.ts | 196 -- .../claude/claude-subagent-roster.test.ts | 624 ------ src/main/claude/claude-subagent-roster.ts | 223 --- .../claude-subagent-task-frames.test.ts | 102 +- .../claude/claude-subagent-task-frames.ts | 62 +- ...agent-roster-characterization.test.ts.snap | 1425 -------------- .../codex/codex-background-task-frames.ts | 22 +- .../codex-background-task-tracker.test.ts | 2 +- .../codex/codex-background-task-tracker.ts | 22 +- .../codex/codex-child-work-evidence.test.ts | 13 + src/main/codex/codex-child-work-evidence.ts | 12 +- .../codex-default-mode-subagents.test.ts | 74 +- ...dex-structured-child-work-producer.test.ts | 13 +- .../codex/codex-structured-journal-limits.ts | 6 - ...-structured-journal-thread-item-routing.ts | 31 +- ...-structured-journal-translation-restore.ts | 2 +- ...urnal-translation-subagent-linkage.test.ts | 10 +- ...ured-journal-translation-subagents.test.ts | 83 +- ...-structured-journal-translation-writers.ts | 24 +- .../codex-structured-journal-translation.ts | 38 +- .../codex/codex-subagent-activity.test.ts | 44 + src/main/codex/codex-subagent-activity.ts | 17 +- ...odex-subagent-execution-projection.test.ts | 44 +- .../codex/codex-subagent-executions.test.ts | 61 +- src/main/codex/codex-subagent-executions.ts | 56 +- ...x-subagent-roster-characterization.test.ts | 189 -- .../codex-subagent-roster-restart.test.ts | 62 - .../codex-subagent-roster-retention.test.ts | 322 --- src/main/codex/codex-subagent-roster-state.ts | 23 - src/main/codex/codex-subagent-roster.test.ts | 779 -------- src/main/codex/codex-subagent-roster.ts | 241 --- .../codex/codex-thread-token-totals.test.ts | 24 - src/main/codex/codex-thread-token-totals.ts | 32 - ...urnal-subagent-group-body-fallback.test.ts | 57 - .../journal-subagent-group-body.ts | 51 - .../journal-subagent-item.ts | 4 +- .../journal-subagent-liveness.test.ts | 18 +- .../journal-subagent-liveness.ts | 6 +- ...ession-history-conversation-window.test.ts | 76 + .../agent-session-history-page-bounds.ts | 27 + .../agent-session-history-page.ts | 12 +- .../structured-agent-session-adapter.ts | 10 +- ...tructured-agent-session-background-stop.ts | 62 + ...nt-session-background-task-channel.test.ts | 24 +- ...d-agent-session-background-task-channel.ts | 14 +- ...t-session-claude-stop-ends-session.test.ts | 4 +- ...-agent-session-claude-subagent-row.test.ts | 300 +++ ...tructured-agent-session-client-delivery.ts | 3 + ...structured-agent-session-host-mutations.ts | 16 +- ...ructured-agent-session-host-status-feed.ts | 37 +- .../structured-agent-session-host.ts | 5 +- ...ructured-agent-session-mutation-context.ts | 4 + ...structured-agent-session-mutation-plans.ts | 8 +- ...uctured-agent-session-repeat-press.test.ts | 18 + ...agent-session-status-feed-children.test.ts | 4 +- .../structured-agent-session-status-feed.ts | 9 + ...red-agent-session-status-ownership.test.ts | 115 ++ ...ructured-agent-session-status-ownership.ts | 70 +- ...red-agent-session-subagent-recency.test.ts | 22 +- ...ctured-agent-session-subagent-rows.test.ts | 279 +++ .../structured-agent-session-subagent-rows.ts | 180 ++ .../structured-agent-session-turns-cancel.ts | 21 +- .../structured-agent-session-turns.test.ts | 115 +- .../subagent-roster-retention.test.ts | 150 -- .../native-chat/subagent-roster-retention.ts | 76 - .../journaled-subagent-groups.ts | 112 -- .../subagent-row-sink-port.ts | 37 - .../subagent-tracker/subagent-row-writes.ts | 29 - .../subagent-tracker-entries.ts | 69 - .../subagent-tracker-groups.ts | 167 -- .../subagent-tracker-types.ts | 88 - .../subagent-tracker/subagent-tracker.test.ts | 314 --- .../subagent-tracker/subagent-tracker.ts | 308 --- .../worker-transcript-payload.test.ts | 5 +- ...tructured-codex-child-work-runtime.test.ts | 4 +- .../agent-child-row-parity.test.tsx | 4 +- .../NativeChatBackgroundTasksStatus.test.tsx | 28 +- .../NativeChatBackgroundTasksStatus.tsx | 13 +- ...tiveChatStructuredSession.test-harness.tsx | 3 +- .../NativeChatStructuredSessionStatus.tsx | 3 +- ...ubagent-child-row-fallback-pixels.test.tsx | 2 +- src/shared/agent-child-work-stop-targets.ts | 40 +- ...on-background-task-state-admission.test.ts | 17 + ...-session-background-task-state-equality.ts | 3 +- .../agent-session-background-task-wire.ts | 5 + ...nt-status-child-work-evidence-admission.ts | 11 +- .../agent-status-child-work-evidence.ts | 20 +- ...t-status-child-work-reconciliation.test.ts | 36 +- .../agent-status-child-work-reconciliation.ts | 28 +- ...tatus-child-work-stop-acknowledged.test.ts | 117 ++ src/shared/codex-child-command-description.ts | 4 +- src/shared/native-chat-subagent-items.ts | 16 +- src/shared/native-chat-subagent-summary.ts | 12 +- ...ured-session-background-tasks-view.test.ts | 34 +- ...tructured-session-background-tasks-view.ts | 19 +- 145 files changed, 3238 insertions(+), 10487 deletions(-) delete mode 100644 src/main/acp/__snapshots__/acp-subagent-timeline-characterization.test.ts.snap delete mode 100644 src/main/acp/acp-subagent-child-work.ts delete mode 100644 src/main/acp/acp-subagent-timeline-characterization.test.ts delete mode 100644 src/main/acp/acp-subagent-timeline.test.ts delete mode 100644 src/main/acp/acp-subagent-timeline.ts create mode 100644 src/main/acp/acp-subagents.ts delete mode 100644 src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap create mode 100644 src/main/claude/claude-captured-wake-frames.test-fixture.ts create mode 100644 src/main/claude/claude-structured-real-cli-stop-task.test.ts create mode 100644 src/main/claude/claude-subagent-identities.ts create mode 100644 src/main/claude/claude-subagent-journaled-identities.ts delete mode 100644 src/main/claude/claude-subagent-journaled-roster.test.ts delete mode 100644 src/main/claude/claude-subagent-journaled-roster.ts delete mode 100644 src/main/claude/claude-subagent-roster-characterization.test.ts delete mode 100644 src/main/claude/claude-subagent-roster-retention.test.ts delete mode 100644 src/main/claude/claude-subagent-roster.test.ts delete mode 100644 src/main/claude/claude-subagent-roster.ts delete mode 100644 src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap create mode 100644 src/main/codex/codex-subagent-activity.test.ts delete mode 100644 src/main/codex/codex-subagent-roster-characterization.test.ts delete mode 100644 src/main/codex/codex-subagent-roster-restart.test.ts delete mode 100644 src/main/codex/codex-subagent-roster-retention.test.ts delete mode 100644 src/main/codex/codex-subagent-roster-state.ts delete mode 100644 src/main/codex/codex-subagent-roster.test.ts delete mode 100644 src/main/codex/codex-subagent-roster.ts delete mode 100644 src/main/codex/codex-thread-token-totals.test.ts delete mode 100644 src/main/codex/codex-thread-token-totals.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-subagent-group-body.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-background-stop.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.ts delete mode 100644 src/main/native-chat/subagent-roster-retention.test.ts delete mode 100644 src/main/native-chat/subagent-roster-retention.ts delete mode 100644 src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts delete mode 100644 src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts delete mode 100644 src/main/native-chat/subagent-tracker/subagent-row-writes.ts delete mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts delete mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts delete mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker-types.ts delete mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker.test.ts delete mode 100644 src/main/native-chat/subagent-tracker/subagent-tracker.ts create mode 100644 src/shared/agent-session-background-task-state-admission.test.ts create mode 100644 src/shared/agent-status-child-work-stop-acknowledged.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 3113f414809..40a668f58eb 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -45,7 +45,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/claude/claude-subagent-permission-request.test.ts', 'src/main/claude/claude-subagent-resume-after-restart.test.ts', 'src/main/codex/codex-provider-retry-idle-sweep.test.ts', - 'src/main/codex/codex-subagent-roster-retention.test.ts', 'src/main/codex/codex-structured-journal-exit-convergence.test.ts', 'src/main/codex/codex-structured-journal-goal-revision.test.ts', 'src/main/codex/codex-structured-journal-repeated-questions.test.ts', @@ -81,6 +80,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts', @@ -180,6 +180,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-background-tasks.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts', diff --git a/mobile/src/session/MobileNativeChatBackgroundTasks.tsx b/mobile/src/session/MobileNativeChatBackgroundTasks.tsx index 0b1e53e5262..26c2adad013 100644 --- a/mobile/src/session/MobileNativeChatBackgroundTasks.tsx +++ b/mobile/src/session/MobileNativeChatBackgroundTasks.tsx @@ -236,7 +236,10 @@ function MobileNativeChatBackgroundTasksImpl({ ) : ( {say('detailsUnavailable')} )} - {!view.supportsStop && view.supportsStopAll ? ( + {view.incomplete && groups.length > 0 ? ( + {say('detailsUnavailable')} + ) : null} + {view.offersStopAll ? ( a group past 64 children drops the rest 1`] = ` -{ - "events": 1, - "has": [ - true, - false, - ], - "lastLabels": "l":"X 60","state":"working","startedAt":1010},{"id":"c60","label":"X 61","state":"working","startedAt":1010},{"id":"c61","label":"X 62","state":"working","startedAt":1010},{"id":"c62","label":"X 63","state":"working","startedAt":1010},{"id":"c63","label":"X 64","state":"working","startedAt":1010}]}]", -} -`; - -exports[`ACP subagent timeline rows (characterization) > children sharing a label, a provider-named ordinal, and outcomes in one batch 1`] = ` -[ - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 4 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "Audit", - "startedAt": 1010, - "state": "working", - }, - { - "id": "a2", - "label": "Audit 2", - "startedAt": 1010, - "state": "working", - }, - { - "id": "a3", - "label": "Audit 2 2", - "startedAt": 1010, - "state": "working", - }, - { - "id": "a4", - "label": "subagent", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-1", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "subagentIdentities": [ - "a1", - "a2", - "a3", - "a4", - ], - "type": "item.update", - }, - ], - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 4 subagents (1 failed)", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "Audit", - "settledAt": 1020, - "startedAt": 1010, - "state": "stopped", - }, - { - "id": "a2", - "label": "Audit 2", - "settledAt": 1020, - "startedAt": 1010, - "state": "completed", - }, - { - "id": "a3", - "label": "Audit 2 2", - "settledAt": 1020, - "startedAt": 1010, - "state": "failed", - }, - { - "id": "a4", - "label": "subagent", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-1", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "subagentIdentities": [ - "a1", - "a2", - "a3", - "a4", - ], - "type": "item.update", - }, - { - "body": { - "blocks": [ - { - "text": "late", - "type": "text", - }, - ], - "kind": "message", - "role": "assistant", - }, - "item": "subagent-result:a2", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "producer": { - "agentId": "a2", - "producerKind": "agent", - }, - "type": "item.update", - }, - ], -] -`; - -exports[`ACP subagent timeline rows (characterization) > knownOnly updates, turn from the update, and the no-turn group 1`] = ` -{ - "has": [ - false, - true, - true, - ], - "log": [ - [], - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "Spawned", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "turn-0", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-0", - "join": { - "thread": "parent", - "turn": "turn-0", - }, - "subagentIdentities": [ - "a1", - ], - "type": "item.update", - }, - ], - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "a2", - "label": "Loose", - "startedAt": 1030, - "state": "working", - }, - ], - "groupId": "thread", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:thread", - "join": { - "thread": "parent", - }, - "subagentIdentities": [ - "a2", - ], - "type": "item.update", - }, - ], - [ - { - "body": { - "blocks": [ - { - "text": "Ran 1 subagent (1 stopped)", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "Spawned", - "settledAt": 1040, - "startedAt": 1020, - "state": "stopped", - }, - ], - "groupId": "turn-0", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-0", - "join": { - "thread": "parent", - "turn": "turn-0", - }, - "subagentIdentities": [ - "a1", - ], - "type": "item.update", - }, - ], - ], -} -`; - -exports[`ACP subagent timeline rows (characterization) > more than 32 settled groups, then a late update for an evicted child 1`] = ` -{ - "has": [ - true, - true, - true, - ], - "tail": [ - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "live", - "label": "Live", - "startedAt": 1350, - "state": "working", - }, - ], - "groupId": "turn-live", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-live", - "join": { - "thread": "parent", - "turn": "turn-live", - }, - "subagentIdentities": [ - "live", - ], - "type": "item.update", - }, - ], - [], - [], - ], -} -`; - -exports[`ACP subagent timeline rows (characterization) > one child: working, tokens, completed with its reply, replayed 1`] = ` -[ - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "Explore", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-1", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "subagentIdentities": [ - "a1", - ], - "type": "item.update", - }, - ], - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "Explore", - "startedAt": 1010, - "state": "working", - "tokens": 0, - }, - ], - "groupId": "turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-1", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "subagentIdentities": [ - "a1", - ], - "type": "item.update", - }, - ], - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "Explore", - "startedAt": 1010, - "state": "working", - "tokens": 200, - }, - ], - "groupId": "turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-1", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "subagentIdentities": [ - "a1", - ], - "type": "item.update", - }, - ], - [ - { - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "Explore", - "settledAt": 1040, - "startedAt": 1010, - "state": "completed", - "tokens": 200, - }, - ], - "groupId": "turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-1", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "subagentIdentities": [ - "a1", - ], - "type": "item.update", - }, - { - "body": { - "blocks": [ - { - "text": "Found it", - "type": "text", - }, - ], - "kind": "message", - "role": "assistant", - }, - "item": "subagent-result:a1", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "producer": { - "agentId": "a1", - "producerKind": "agent", - }, - "type": "item.update", - }, - ], - [], - [], -] -`; - -exports[`ACP subagent timeline rows (characterization) > writes nothing after dispose 1`] = ` -{ - "has": false, - "log": [ - [ - { - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "a1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "item": "subagents:turn-1", - "join": { - "thread": "parent", - "turn": "turn-1", - }, - "subagentIdentities": [ - "a1", - ], - "type": "item.update", - }, - ], - [], - ], -} -`; diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index b9d45a7245b..1a5e92bcfe5 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -6,7 +6,7 @@ import { import { acpWindowUsage } from './acp-context-usage' import type { AcpDialect } from './acp-dialects/acp-dialect' import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' -import type { AcpSubagentTimeline } from './acp-subagent-timeline' +import type { AcpSubagents } from './acp-subagents' import type { AcpToolTimeline } from './acp-tool-timeline' import type { SessionNotification } from './generated/acp-protocol.generated' import { acpNamedTextKey } from './acp-turn-messages' @@ -29,7 +29,7 @@ export function acpSessionUpdate( tools: AcpToolTimeline dialect: AcpDialect backgroundTasks: AcpBackgroundTaskTimeline - subagents: AcpSubagentTimeline + subagents: AcpSubagents messageKey?: string } ): ProviderTimelineEvent[] { diff --git a/src/main/acp/acp-structured-child-session-end.test.ts b/src/main/acp/acp-structured-child-session-end.test.ts index 475b6a2430d..7acf01695bb 100644 --- a/src/main/acp/acp-structured-child-session-end.test.ts +++ b/src/main/acp/acp-structured-child-session-end.test.ts @@ -1,16 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import { isSubagentBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' -import { acpChildWorkStatusSink } from './acp-structured-child-work.test-support' +import { + acpChildWorkStatusSink, + acpSubagentRecords +} from './acp-structured-child-work.test-support' import { PROVIDER_SESSION, replyChunk } from './acp-structured-adapter.test-support' import { openAttachedHostRig, promptIdOf, send, stop } from './acp-structured-host.test-support' import { readAcpFixture } from './acp-timeline-fixture.test-support' import { AcpTimelineTranslator } from './acp-timeline-translator' -import type { AcpTimelineEvent } from './acp-timeline-event' const cleanup: (() => Promise)[] = [] afterEach(async () => { @@ -21,14 +23,6 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -function agents(events: AcpTimelineEvent[]) { - return events.flatMap((event) => - event.type === 'item.update' && event.body.kind === 'message' - ? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] - ) -} - function translator(dialect: AcpDialect = GROK_ACP_DIALECT) { const lane = new AcpTimelineTranslator({ sessionId: PROVIDER_SESSION, dialect }) const { promptId } = lane.openPrompt('parent', 100) @@ -46,6 +40,9 @@ function translator(dialect: AcpDialect = GROK_ACP_DIALECT) { }, 102 ) + const records = acpSubagentRecords() + const host = () => records(lane.subagents.drainEvidence()) + host() const end = ( reason: string, sessionId = 'child', @@ -67,7 +64,7 @@ function translator(dialect: AcpDialect = GROK_ACP_DIALECT) { }, 120 ) - return { lane, end, promptId } + return { lane, end, host, promptId } } describe('owned Grok child-session completion', () => { @@ -78,19 +75,17 @@ describe('owned Grok child-session completion', () => { '_x.ai/session/update' ])('settles a known child through %s without ending or charging the parent turn', (method) => { const f = translator() - const events = f.end('end_turn', 'child', {}, method) - expect(agents(events)).toEqual([ - { id: 'child', label: 'Original label', state: 'completed', startedAt: 102, settledAt: 120 } - ]) - expect(events).toMatchObject([ + expect(f.end('end_turn', 'child', {}, method)).toEqual([]) + expect(f.host()).toMatchObject([ { - type: 'item.update', - item: 'subagents:prompt:parent', - join: { turn: f.promptId }, - subagentIdentities: ['child'] + providerId: 'child', + description: 'Original label', + membership: 'settled', + outcome: 'succeeded' } ]) expect(f.end('cancelled')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }]) const parent = f.lane.notification( 'session/update', replyChunk(f.promptId, 'Parent continues'), @@ -106,9 +101,8 @@ describe('owned Grok child-session completion', () => { it('latches cancellation against conflicting child ends, parent reports and later working progress', () => { const f = translator() - expect(agents(f.end('cancelled'))).toMatchObject([ - { id: 'child', state: 'stopped', settledAt: 120 } - ]) + expect(f.end('cancelled')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'cancelled' }]) expect(f.end('end_turn')).toEqual([]) expect( f.lane.notification( @@ -128,8 +122,9 @@ describe('owned Grok child-session completion', () => { }, 122 ) - expect(agents(progress)).toMatchObject([ - { id: 'child', state: 'stopped', settledAt: 120, tokens: 7 } + expect(progress).toEqual([]) + expect(f.host()).toMatchObject([ + { providerId: 'child', membership: 'settled', outcome: 'cancelled' } ]) }) @@ -138,6 +133,7 @@ describe('owned Grok child-session completion', () => { expect(f.end('future_stop_reason')).toEqual([]) expect(f.end('cancelled', 'unowned-child')).toEqual([]) expect(f.end('cancelled', 'child', {}, '_x.ai/other_notification')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) expect(f.lane.promptResult('parent', { stopReason: 'end_turn' }, 122)).toMatchObject([ { type: 'turn.end', turn: f.promptId, outcome: 'success' } ]) @@ -154,7 +150,9 @@ describe('owned Grok child-session completion', () => { f.lane.notification('_x.ai/session_notification', { sessionId: 'child', update }, 120) ).toEqual([]) } - expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) + expect(f.end('end_turn')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }]) }) it('ignores malformed completions and child-scoped roster reports without learning a new child', () => { @@ -170,7 +168,9 @@ describe('owned Grok child-session completion', () => { ).toEqual([]) } expect(f.end('cancelled', 'nested-child')).toEqual([]) - expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) + expect(f.end('end_turn')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }]) }) it('ignores replay and loading even when a loading notice explicitly says it is live', () => { @@ -181,7 +181,9 @@ describe('owned Grok child-session completion', () => { expect(f.end('cancelled')).toEqual([]) expect(f.end('cancelled', 'child', { isReplay: false })).toEqual([]) f.lane.finishLoad() - expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) + expect(f.end('end_turn')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }]) f.lane.dispose() expect(f.end('cancelled')).toEqual([]) }) @@ -192,6 +194,7 @@ describe('owned Grok child-session completion', () => { notification: GROK_ACP_DIALECT.notification }) expect(f.end('cancelled')).toEqual([]) + expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }]) }) }) @@ -241,14 +244,15 @@ describe('recorded child-session outcome during a full parent Stop', () => { await hosted.rows() vi.setSystemTime(1791496678726) agent.notify(selectedFinished.method, selectedFinished.params) - const rosters = async () => + // One saved row per subagent, which the host writes from its records. + const subagents = async () => (await hosted.rows()).flatMap((row) => row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) + ? row.body.blocks.filter(isSubagentBlock).map((block) => ({ row, block })) : [] ) - const [original] = await rosters() - expect(original.group.agents).toMatchObject([ + const original = await subagents() + expect(original.map(({ block }) => block)).toMatchObject([ { id: 'subagent-1', state: 'stopped' }, { id: 'subagent-2', state: 'working' } ]) @@ -272,16 +276,18 @@ describe('recorded child-session outcome during a full parent Stop', () => { }) expect(await stop(hosted.host)).toMatchObject({ ok: true, value: { cancelled: true } }) await vi.advanceTimersByTimeAsync(17) - const [beforeParentResult] = await rosters() - expect.soft(beforeParentResult.row.itemId).toBe(original.row.itemId) - expect.soft(beforeParentResult.row.turnScope).toEqual(original.row.turnScope) + const beforeParentResult = await subagents() expect - .soft(beforeParentResult.group.agents) - .toEqual([ - original.group.agents[0], - reason === 'cancelled' - ? { ...original.group.agents[1], state: 'stopped', settledAt: 1791496686302 } - : original.group.agents[1] + .soft(beforeParentResult.map(({ row }) => row.itemId)) + .toEqual(original.map(({ row }) => row.itemId)) + expect + .soft(beforeParentResult.map(({ row }) => row.turnScope)) + .toEqual(original.map(({ row }) => row.turnScope)) + expect + .soft(beforeParentResult.map(({ block }) => block)) + .toMatchObject([ + original[0].block, + reason === 'cancelled' ? { id: 'subagent-2', state: 'stopped' } : original[1].block ]) expect.soft((await hosted.turns()).at(-1)).toMatchObject({ state: 'running' }) expect.soft(child.closes).toBe(0) @@ -297,8 +303,8 @@ describe('recorded child-session outcome during a full parent Stop', () => { state: 'interrupted', outcome: 'cancellation' }) - const [settled] = await rosters() - expect(settled.group.agents).toMatchObject([ + const settled = await subagents() + expect(settled.map(({ block }) => block)).toMatchObject([ { id: 'subagent-1', state: 'stopped' }, { id: 'subagent-2', state } ]) diff --git a/src/main/acp/acp-structured-child-stop.ts b/src/main/acp/acp-structured-child-stop.ts index acabe6ba6b6..2ed2f7c40a8 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -2,10 +2,18 @@ import type { AcpStructuredSession } from './acp-structured-session' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' -import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets' +import type { + AgentChildWorkStopAcknowledgement, + AgentSessionBackgroundTaskStops +} from '../../shared/agent-child-work-stop-targets' import { acpBackgroundTaskStopId } from './acp-background-task-child-work' +import { acpSubagentOutcome } from './acp-subagents' -type ChildStopResult = { cancelled: boolean; stillRunning?: true } +type ChildStopResult = { + cancelled: boolean + stillRunning?: true + acknowledged?: AgentChildWorkStopAcknowledgement +} type SubagentStop = NonNullable type BackgroundTaskStop = NonNullable @@ -72,8 +80,7 @@ export async function probeAcpChildStops( async function stopAcpSubagent( session: AcpStructuredSession, control: SubagentStop, - id: string, - at: () => number + id: string ): Promise { const request = control.request(session.lane.translator.providerSessionId, id) let value: unknown @@ -87,10 +94,17 @@ async function stopAcpSubagent( throw error } const result = control.response(value, id) - if (result.state && result.state !== 'working') { - session.lane.apply(session.lane.translator.reconcileSubagent(id, result.state, at())) + if (result.state === 'working') { + return { cancelled: result.cancelled } } - return { cancelled: result.cancelled } + // Not found is no acceptance; an answer that names how the subagent finished is its own ending. + const outcome = result.state === undefined ? undefined : acpSubagentOutcome(result.state) + return result.cancelled || outcome !== undefined + ? { + cancelled: result.cancelled, + acknowledged: { taskId: id, ...(outcome !== undefined ? { outcome } : {}) } + } + : { cancelled: false } } async function stopAcpBackgroundTask( @@ -126,8 +140,8 @@ export async function stopAcpChildren( session: AcpStructuredSession, fence: number, taskIds: readonly string[], - at: () => number -): Promise { + acknowledge: (acknowledgement: AgentChildWorkStopAcknowledgement) => void = () => {} +): Promise<{ cancelled: boolean; stillRunning?: true }> { const subagentStop = session.childStops?.subagents ? session.spec.dialect.subagentStop : undefined const taskStop = session.childStops?.backgroundTasks ? session.spec.dialect.backgroundTaskStop @@ -141,14 +155,17 @@ export async function stopAcpChildren( let stillRunning = false for (const id of taskIds) { const taskId = acpBackgroundTaskStopId(id) - const result = + const result: ChildStopResult = taskId !== undefined && taskStop ? await stopAcpBackgroundTask(session, taskStop, taskId) : subagentStop - ? await stopAcpSubagent(session, subagentStop, id, at) + ? await stopAcpSubagent(session, subagentStop, id) : { cancelled: false } cancelled ||= result.cancelled stillRunning ||= result.stillRunning === true + if (result.acknowledged) { + acknowledge(result.acknowledged) + } } return { cancelled, ...(stillRunning ? { stillRunning: true as const } : {}) } } diff --git a/src/main/acp/acp-structured-child-work-admission.test.ts b/src/main/acp/acp-structured-child-work-admission.test.ts index c7d54a72c8c..aa8ebe163dc 100644 --- a/src/main/acp/acp-structured-child-work-admission.test.ts +++ b/src/main/acp/acp-structured-child-work-admission.test.ts @@ -1,14 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' -import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' import { closeProviderTimelineRigs, openProviderTimelineRig, - providerTurnItemId, refusingSink } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { AcpStructuredLane } from './acp-structured-lane' +import type { AcpTimelineEvent } from './acp-timeline-event' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' @@ -16,10 +14,6 @@ import { recordingStructuredAgentSessionLogger, testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' -import { AgentHookServer } from '../agent-hooks/server' const lanes: AcpStructuredLane[] = [] afterEach(async () => { @@ -27,14 +21,17 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -function roster(state: 'working' | 'completed'): ProviderTimelineEvent { - return { - type: 'item.update', - item: 'subagents:turn', - body: subagentGroupJournalBody('turn', [ - { id: 'child', label: 'Exact label', state, startedAt: 1 } - ]) - } +/** What Grok says about one subagent, translated as the adapter would. */ +function subagent(lane: AcpStructuredLane, state: 'working' | 'completed'): AcpTimelineEvent[] { + const update = + state === 'working' + ? { sessionUpdate: 'subagent_spawned', subagent_id: 'child', description: 'Exact label' } + : { sessionUpdate: 'subagent_finished', subagent_id: 'child', status: 'completed' } + return lane.translator.notification( + '_x.ai/session/update', + { sessionId: 'provider-session-1', update }, + 1 + ) } async function fixture(reason: 'backpressure' | 'failed' = 'backpressure') { @@ -60,101 +57,7 @@ async function fixture(reason: 'backpressure' | 'failed' = 'backpressure') { return { rig, lane, refused, delivery, failed } } -describe('ACP roster evidence at the ordered journal boundary', () => { - it('does not settle host child work from a preliminary parent settlement while its roster write is refused', async () => { - const rig = await openProviderTimelineRig() - const server = new AgentHookServer() - const subject = makeStructuredAgentStatusSubject( - { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - 'session-timeline' - ) - server.ingestStructuredStatus( - { - sessionId: subject.sessionId, - workspaceId: subject.workspaceId, - agent: 'grok', - status: 'working', - hostExecutionOwned: true, - latestPrompt: 'Delegate', - updatedAt: 1 - }, - subject - ) - let refuseRoster = false - const internalSettlements = vi.fn() - const delivery = vi.fn((evidence: AgentChildWorkEvidence[]) => - server.ingestStructuredChildWork(subject, evidence, 'grok') - ) - const lane = new AcpStructuredLane({ - sink: { - ...rig.sink, - tryAppendTransition: (transition) => { - if (refuseRoster && !transition.lifecycle) { - return { accepted: false, reason: 'backpressure' } - } - if (refuseRoster && transition.lifecycle) { - internalSettlements() - } - return rig.sink.tryAppendTransition(transition) - } - }, - sessionId: 'session-timeline', - providerSessionId: 'provider-session-1', - agent: 'grok', - agentName: 'Grok', - generation: 'gen-1', - dialect: GROK_ACP_DIALECT, - logger: recordingStructuredAgentSessionLogger().logger, - onInputAccepted: () => {}, - onFailed: () => {}, - onChildWorkEvidence: delivery, - now: () => 10 - }) - lanes.push(lane) - lane.apply([{ type: 'turn.open', turn: 'turn', at: 1 }, roster('working')]) - await rig.rows() - const running = await rig.turn('turn') - const identity = parseAgentJournalItemKey(providerTurnItemId('turn')) - if (!running || !identity) { - throw new Error('Missing running parent turn') - } - await rig.journal.appendItem( - identity, - agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 2 }), - { fence: 1, turnScope: { kind: 'thread' } } - ) - expect(lane.openTurnId).not.toBeNull() - refuseRoster = true - lane.apply([roster('completed')]) - const heldRows = await rig.rows() - expect(internalSettlements).toHaveBeenCalledTimes(1) - expect(lane.openTurnId).toBeNull() - expect( - heldRows.some( - (row) => - row.body.kind === 'message' && - row.body.blocks.some( - (block) => block.type === 'subagent-group' && block.agents[0]?.state === 'working' - ) - ) - ).toBe(true) - expect(server.getStructuredChildWorkViews(subject)).toMatchObject([ - { membership: 'live', state: 'working' } - ]) - expect(delivery).toHaveBeenCalledTimes(1) - refuseRoster = false - lane.retry() - await rig.rows() - expect(server.getStructuredChildWorkViews(subject)).toMatchObject([ - { membership: 'settled', outcome: 'succeeded' } - ]) - expect(delivery).toHaveBeenCalledTimes(2) - }) +describe('ACP subagent evidence at the ordered journal boundary', () => { it('delivers acquisition-time snapshots only after the journal binds and publishes the parent', async () => { const rig = await openProviderTimelineRig() const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) @@ -217,7 +120,8 @@ describe('ACP roster evidence at the ordered journal boundary', () => { it('holds working and finished snapshots in order across backpressure and publishes each once', async () => { const { rig, lane, refused, delivery } = await fixture() refused.value = true - lane.apply([roster('working'), roster('completed')]) + lane.apply(subagent(lane, 'working')) + lane.apply(subagent(lane, 'completed')) await rig.rows() expect(delivery).toEqual([]) refused.value = false @@ -231,16 +135,16 @@ describe('ACP roster evidence at the ordered journal boundary', () => { expect(delivery).toHaveLength(2) }) - it('publishes no roster facts for fatal refusal, disposed held events or events dropped after session end', async () => { + it('publishes no subagent facts for fatal refusal, disposed held events or events dropped after session end', async () => { const fatal = await fixture('failed') fatal.refused.value = true - fatal.lane.apply([roster('working')]) + fatal.lane.apply(subagent(fatal.lane, 'working')) await fatal.rig.rows() expect(fatal.failed).toHaveBeenCalledWith('failed') expect(fatal.delivery).toEqual([]) const held = await fixture() held.refused.value = true - held.lane.apply([roster('working')]) + held.lane.apply(subagent(held.lane, 'working')) held.lane.dispose() held.refused.value = false held.lane.retry() @@ -251,7 +155,7 @@ describe('ACP roster evidence at the ordered journal boundary', () => { { type: 'session.ended', verdict: { state: 'interrupted', completedAt: 10 } } ]) await ended.rig.rows() - ended.lane.apply([roster('working')]) + ended.lane.apply(subagent(ended.lane, 'working')) await ended.rig.rows() expect(ended.delivery.flat().map((edge) => edge.type)).toEqual(['session-ended']) }) @@ -277,21 +181,23 @@ describe('ACP roster evidence at the ordered journal boundary', () => { }) lane.dispose() lanes.push(throwing) - throwing.apply([roster('working'), roster('completed')]) + throwing.apply(subagent(throwing, 'working')) + throwing.apply(subagent(throwing, 'completed')) + throwing.apply([{ type: 'turn.open', turn: 'turn', at: 2 }]) expect(await rig.rows()).toHaveLength(1) expect(failure).toHaveBeenCalledTimes(2) }) it('releases live ownership when session-end admission is held and disposal ends observation', async () => { const { rig, lane, refused, delivery } = await fixture() - lane.apply([roster('working')]) + lane.apply(subagent(lane, 'working')) await rig.rows() refused.value = true lane.apply([{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 20 } }]) lane.dispose() await rig.rows() expect(delivery.flat().map((edge) => edge.type)).toEqual(['live', 'session-ended']) - lane.apply([roster('working')]) + lane.apply(subagent(lane, 'working')) lane.retry() await rig.rows() expect(delivery.flat().map((edge) => edge.type)).toEqual(['live', 'session-ended']) diff --git a/src/main/acp/acp-structured-child-work.test-support.ts b/src/main/acp/acp-structured-child-work.test-support.ts index 821a11cfe70..60961989736 100644 --- a/src/main/acp/acp-structured-child-work.test-support.ts +++ b/src/main/acp/acp-structured-child-work.test-support.ts @@ -1,5 +1,9 @@ import { AgentHookServer } from '../agent-hooks/server' -import type { AgentStatusStructuredSessionSubject } from '../../shared/agent-status-subject' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { + makeStructuredAgentStatusSubject, + type AgentStatusStructuredSessionSubject +} from '../../shared/agent-status-subject' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-ownership' export function acpChildWorkStatusSink() { @@ -13,7 +17,39 @@ export function acpChildWorkStatusSink() { forget: (subject) => server.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => server.ingestStructuredChildWork(subject, evidence, provider), - readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + readChildWork: (subject) => server.getStructuredChildWorkViews(subject), + readChildWorkRecords: (subject) => server.getStructuredChildWork(subject) } return { sink, views: () => (parent ? server.getStructuredChildWorkViews(parent) : []) } } + +/** A host holding one working Grok parent: what a translator reported becomes its child records, + * the one authority on each subagent. */ +export function acpSubagentRecords() { + const { sink, views } = acpChildWorkStatusSink() + const subject = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-timeline' + ) + sink.publish( + { + sessionId: subject.sessionId, + workspaceId: subject.workspaceId, + agent: 'grok', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'Delegate', + updatedAt: 1 + }, + subject + ) + return (evidence: AgentChildWorkEvidence[]) => { + sink.publishChildWork?.(subject, evidence, 'grok') + return views() + } +} diff --git a/src/main/acp/acp-structured-child-work.test.ts b/src/main/acp/acp-structured-child-work.test.ts index 98a234e2d39..f7a05016964 100644 --- a/src/main/acp/acp-structured-child-work.test.ts +++ b/src/main/acp/acp-structured-child-work.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { _internals } from '../agent-hooks/server' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import { isSubagentBlock } from '../../shared/native-chat-types' import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { @@ -89,9 +89,7 @@ async function fixture() { (await hosted.host.history({ sessionId: SESSION, direction: 'tail' })).page.backgroundTasks const roster = async () => (await hosted.rows()).flatMap((row) => - row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] + row.body.kind === 'message' ? row.body.blocks.filter(isSubagentBlock) : [] ) const targetedStop = (taskId: string) => { const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const, taskId } @@ -154,14 +152,15 @@ describe('Grok roster evidence through the existing host child store', () => { ).toBeNull() }) - it('shares exact ordinal labels between roster, agent strip and sidebar after parent completion', async () => { + it('shares one label and one id between saved rows, agent strip and sidebar after parent completion', async () => { const f = await fixture() await f.spawn('first') await f.spawn('sibling') - expect(await f.roster()).toMatchObject([{ label: 'Exact label' }, { label: 'Exact label 2' }]) + // Each saved row keeps the raw label; the shared presentation derives the ordinal, as the strip does. + expect(await f.roster()).toMatchObject([{ label: 'Exact label' }, { label: 'Exact label' }]) expect((await f.strip())?.tasks).toMatchObject([ { id: 'first', kind: 'agent', description: 'Exact label' }, - { id: 'sibling', kind: 'agent', description: 'Exact label 2' } + { id: 'sibling', kind: 'agent', description: 'Exact label' } ]) const stripIds = (await f.strip())?.children?.map((view) => view.id) expect(f.sidebar.at(-1)?.map((view) => view.id)).toEqual(stripIds) @@ -189,7 +188,11 @@ describe('Grok roster evidence through the existing host child store', () => { }) }) expect(await f.targetedStop('first')).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(f.childWork.views().every((view) => view.membership === 'live')).toBe(true) + // The accepted Stop settles that run until Grok's own ending says how it ended. + expect(f.childWork.views()).toMatchObject([ + { providerId: 'first', membership: 'settled', outcome: 'cancelled' }, + { providerId: 'sibling', membership: 'live' } + ]) expect(f.agent.frames.filter((frame) => frame.method === 'session/cancel')).toEqual([]) await f.finish('first', 'cancelled') expect((await f.roster())[0].state).toBe('stopped') @@ -277,7 +280,7 @@ describe('Grok roster evidence through the existing host child store', () => { await f.spawn(first, 'First', 'turn-A') await f.spawn(second, 'Second', 'turn-B') const entries = await f.roster() - expect(entries[0].id).toBe(entries[1].id) + expect(entries[0].id).not.toBe(entries[1].id) const bothStrip = (await f.strip())?.tasks const bothSidebar = f.sidebar.at(-1) await f.finish(first, 'completed') @@ -301,7 +304,7 @@ describe('Grok roster evidence through the existing host child store', () => { { description: entry.label, kind: 'agent', stoppable: false } ]) expect((await f.strip())?.tasks?.[0]?.id).toMatch(/^acp-child:[0-9a-f]{64}$/) - expect((await f.strip())?.tasks?.[0]?.id).not.toBe(entry.id) + expect((await f.strip())?.tasks?.[0]?.id).toBe(entry.id) }) it('keeps 34 live groups in both host surfaces and never recreates the first settled eviction', async () => { @@ -403,10 +406,10 @@ describe('Grok roster evidence through the existing host child store', () => { } ) - it('records a terminal first snapshot without leaving either running surface occupied', async () => { + it('records nothing for a subagent first reported finished, and occupies no running surface', async () => { const f = await fixture() await f.finish('finished-before-spawn', 'completed') - expect(await f.roster()).toMatchObject([{ id: 'finished-before-spawn', state: 'completed' }]) + expect(await f.roster()).toEqual([]) expect(f.childWork.views()).toEqual([]) expect(await f.strip()).toBeNull() }) diff --git a/src/main/acp/acp-structured-grok-subagent-recordings.test.ts b/src/main/acp/acp-structured-grok-subagent-recordings.test.ts index 21f02f0e302..1cb51a1ba30 100644 --- a/src/main/acp/acp-structured-grok-subagent-recordings.test.ts +++ b/src/main/acp/acp-structured-grok-subagent-recordings.test.ts @@ -1,10 +1,11 @@ // The adapter against Grok's recorded subagents (Grok 1.0.46): each recording plays back as the -// agent, and the assertions read the roster and reply rows a client would see. +// agent, and the assertions read what it reports to the host about each subagent and the reply +// rows a client would see. import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../shared/native-chat-types' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs, SESSION @@ -43,12 +44,32 @@ function send(rig: AcpAdapterRig, clientMessageId: string) { return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: ask, fence: 1 }) } -async function subagents(rig: AcpAdapterRig) { - return (await rig.rig.rows()).flatMap((row) => - row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] - ) +type Reported = { id: string; label?: string; outcome?: string; tokens?: number } + +/** Each subagent as the adapter last reported it to the host. */ +function subagents(evidence: readonly AgentChildWorkEvidence[]): Reported[] { + const reported = new Map() + for (const edge of evidence) { + const handle = + edge.type === 'live' ? edge.child.handle : edge.type === 'ended' ? edge.handle : null + if (!handle) { + continue + } + const prior = reported.get(handle.id) ?? { id: handle.id } + const tokens = + edge.type === 'live' + ? edge.child.totalTokens + : edge.type === 'ended' + ? edge.totalTokens + : undefined + reported.set(handle.id, { + ...prior, + ...(edge.type === 'live' && edge.child.description ? { label: edge.child.description } : {}), + ...(edge.type === 'ended' ? { outcome: edge.outcome } : {}), + ...(tokens !== undefined ? { tokens } : {}) + }) + } + return [...reported.values()] } async function replies(rig: AcpAdapterRig) { @@ -77,15 +98,15 @@ async function stop(rig: AcpAdapterRig, replay: GrokFixtureReplay) { } describe('Grok subagent recordings through the adapter', () => { - it('a foreground subagent completes in the roster and its reply opens under it', async () => { + it('a foreground subagent completes and its reply opens under it', async () => { const { rig, replay, evidence } = await replaying('s7-subagent-foreground') await send(rig, 'send-1') await waitFor(() => expect(replay.awaiting).toBeNull()) await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) ) - expect(await subagents(rig)).toMatchObject([ - { id: 'subagent-1', label: 'Count note lines', state: 'completed', tokens: 13778 } + expect(subagents(evidence)).toMatchObject([ + { id: 'subagent-1', label: 'Count note lines', outcome: 'succeeded', tokens: 13778 } ]) expect(await replies(rig)).toEqual([ { agentId: 'subagent-1', blocks: [{ type: 'text', text: '4' }] } @@ -101,13 +122,13 @@ describe('Grok subagent recordings through the adapter', () => { ) }) - it('a background subagent completes in the roster and stays out of the background tasks', async () => { + it('a background subagent completes and stays out of the background tasks', async () => { const { rig, replay, evidence } = await replaying('s7-subagent-background') await send(rig, 'send-1') await waitFor(() => expect(replay.awaiting).toBeNull()) - await waitFor(async () => - expect(await subagents(rig)).toMatchObject([ - { id: 'subagent-1', label: 'List folder files', state: 'completed' } + await waitFor(() => + expect(subagents(evidence)).toMatchObject([ + { id: 'subagent-1', label: 'List folder files', outcome: 'succeeded' } ]) ) expect(await replies(rig)).toEqual([ @@ -126,12 +147,12 @@ describe('Grok subagent recordings through the adapter', () => { }) it('Stop while a foreground and then a background subagent runs ends each stopped', async () => { - const { rig, replay } = await replaying('s7-subagent-stop') + const { rig, replay, evidence } = await replaying('s7-subagent-stop') await send(rig, 'send-1') await stop(rig, replay) await waitFor(() => expect(replay.awaiting).toBe('session/prompt')) - await waitFor(async () => - expect(await subagents(rig)).toMatchObject([{ label: 'Slow sleeper', state: 'stopped' }]) + await waitFor(() => + expect(subagents(evidence)).toMatchObject([{ label: 'Slow sleeper', outcome: 'cancelled' }]) ) await send(rig, 'send-2') await stop(rig, replay) @@ -145,9 +166,9 @@ describe('Grok subagent recordings through the adapter', () => { 'success' ]) ) - expect(await subagents(rig)).toMatchObject([ - { id: 'subagent-1', label: 'Slow sleeper', state: 'stopped' }, - { id: 'subagent-2', label: 'Background sleeper', state: 'stopped' } + expect(subagents(evidence)).toMatchObject([ + { id: 'subagent-1', label: 'Slow sleeper', outcome: 'cancelled' }, + { id: 'subagent-2', label: 'Background sleeper', outcome: 'cancelled' } ]) expect(await replies(rig)).toEqual([]) expect(await backgroundTasks(rig)).toEqual([]) diff --git a/src/main/acp/acp-structured-host-subagent-stop.test.ts b/src/main/acp/acp-structured-host-subagent-stop.test.ts index e851df7f931..a0a5a4b8c39 100644 --- a/src/main/acp/acp-structured-host-subagent-stop.test.ts +++ b/src/main/acp/acp-structured-host-subagent-stop.test.ts @@ -3,7 +3,7 @@ import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { PipedChildProcess, PipedProcessSpawner } from '@orca/process-host/process-spec' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import { isSubagentBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' @@ -100,7 +100,7 @@ async function stoppingChild(reportsOutcome: boolean) { const rosters = async () => (await hosted.rows()).flatMap((row) => row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) + ? row.body.blocks.filter(isSubagentBlock).map((block) => ({ row, block })) : [] ) const original = (await rosters())[0] @@ -125,7 +125,7 @@ describe('Grok child outcome during a full host Stop', () => { await vi.advanceTimersByTimeAsync(TURN_ANSWER_AFTER_STOP_MS) expect(fixture.closeAt).toEqual([STOP_AT + TURN_ANSWER_AFTER_STOP_MS]) expect((await fixture.turns()).at(-1)).toMatchObject({ state: 'interrupted' }) - expect((await fixture.rosters())[0].group.agents[0].state).toBe('working') + expect((await fixture.rosters())[0].block.state).toBe('working') expect(fixture.childWork.views()).toMatchObject([ { membership: 'live', providerId: 'subagent-1' } ]) @@ -133,18 +133,16 @@ describe('Grok child outcome during a full host Stop', () => { const [cancelled] = await fixture.rosters() expect(cancelled.row.itemId).toBe(fixture.original.row.itemId) expect(cancelled.row.turnScope).toEqual(fixture.original.row.turnScope) - expect(cancelled.group.agents).toEqual([ - { - ...fixture.original.group.agents[0], - state: 'stopped', - settledAt: STOP_AT + 233 - } - ]) + expect(cancelled.block).toEqual({ + ...fixture.original.block, + state: 'stopped', + settledAt: STOP_AT + 233 + }) await vi.advanceTimersByTimeAsync(EXIT_AFTER_STOP_MS - 233) expect(fixture.childWork.views()).toMatchObject([ { membership: 'settled', outcome: 'cancelled' } ]) - expect((await fixture.rosters())[0].group).toEqual(cancelled.group) + expect((await fixture.rosters())[0].block).toEqual(cancelled.block) const settled = await fixture.rows() fixture.agent.notify(fixture.finished.method, fixture.finished.params) expect(await fixture.rows()).toEqual(settled) @@ -158,11 +156,13 @@ describe('Grok child outcome during a full host Stop', () => { expect(fixture.childWork.views()).toMatchObject([{ membership: 'settled', outcome: 'unknown' }]) expect(unavailable.row.itemId).toBe(fixture.original.row.itemId) // Contact was lost when the session ended, a moment the host observed. - expect(unavailable.group.agents).toEqual([ - { ...fixture.original.group.agents[0], state: 'unverifiable', settledAt: expect.any(Number) } - ]) + expect(unavailable.block).toEqual({ + ...fixture.original.block, + state: 'unverifiable', + settledAt: expect.any(Number) + }) fixture.agent.notify(fixture.finished.method, fixture.finished.params) - expect((await fixture.rosters())[0].group).toEqual(unavailable.group) + expect((await fixture.rosters())[0].block).toEqual(unavailable.block) await fixture.host.close(SESSION, 'user-close') }) }) diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 045e1b544d5..b21c0405cd5 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -13,7 +13,6 @@ import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' import { AcpTimelineTranslator } from './acp-timeline-translator' -import { acpSubagentChildWork, isAcpSubagentChildWorkEvent } from './acp-subagent-child-work' import { acpBackgroundTaskChildWork, isAcpBackgroundTaskChildWorkEvent @@ -29,6 +28,10 @@ import type { AcpTextDrop } from './acp-turn-messages' /** How long a held event waits for the sink to say it drained before trying again on its own. */ const BACKPRESSURE_RETRY_MS = 250 +/** Subagent evidence holds its place among the events: it reaches the host only after the journal + * wrote what came before it. */ +type SubagentWork = { type: 'subagent.work'; evidence: AgentChildWorkEvidence[] } + export type AcpStructuredLaneDeps = { sink: ProviderTimelineSink sessionId: string @@ -52,11 +55,12 @@ export type AcpStructuredLaneDeps = { export class AcpStructuredLane { readonly translator: AcpTimelineTranslator private readonly assembler: ProviderTimelineAssembler - private readonly backlog: AcpTimelineEvent[] = [] + private readonly backlog: (AcpTimelineEvent | SubagentWork)[] = [] private readonly turnWatchers = new Set<() => void>() private readonly requestIdentity: ReturnType private retryTimer: ReturnType | null = null private failed = false + private ended = false private disposed = false private readonly logger: StructuredAgentSessionLogger @@ -102,6 +106,10 @@ export class AcpStructuredLane { return } this.backlog.push(...events) + const evidence = this.translator.subagents.drainEvidence() + if (evidence.length > 0) { + this.backlog.push({ type: 'subagent.work', evidence }) + } this.drain() } @@ -165,11 +173,20 @@ export class AcpStructuredLane { this.clearRetry() while (this.backlog.length > 0 && !this.failed && !this.disposed) { const event = this.backlog[0] + if (event.type === 'subagent.work') { + if (!this.queueSubagentWork(event.evidence)) { + return + } + this.backlog.shift() + continue + } const { admission, dropped } = this.assembler.apply( event, - isAcpSubagentChildWorkEvent(event) || isAcpBackgroundTaskChildWorkEvent(event) - ? () => this.publishChildWork(event) - : undefined + event.type === 'session.ended' + ? () => this.publishSessionEnd() + : isAcpBackgroundTaskChildWorkEvent(event) + ? () => this.publishChildWork(event) + : undefined ) if (dropped === 'stream-mismatch' && event.type === 'text.delta') { this.reportTextDrop({ @@ -182,17 +199,12 @@ export class AcpStructuredLane { }) } if (!admission.accepted) { - if (admission.reason === 'backpressure') { - this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS) - return - } - this.failed = true - this.backlog.length = 0 - this.notifyTurnWatchers() - this.deps.onFailed(admission.reason) + this.refused(admission.reason) return } this.backlog.shift() + // A subagent the provider reports after its session ended is no live child of it. + this.ended ||= event.type === 'session.ended' && dropped === undefined if (event.type === 'input.accepted') { this.deps.onInputAccepted(event.clientMessageId) } @@ -203,14 +215,50 @@ export class AcpStructuredLane { private publishChildWork(event: AcpTimelineEvent): void { const observedAt = this.deps.now?.() ?? Date.now() const stops = this.deps.childStops?.() - const evidence = [ - ...acpSubagentChildWork(event, observedAt, stops?.subagents === true), - ...acpBackgroundTaskChildWork(event, observedAt, stops?.backgroundTasks === true) - ] - if (!evidence.length) { + const evidence = acpBackgroundTaskChildWork(event, observedAt, stops?.backgroundTasks === true) + if (evidence.length > 0) { + this.publishEvidence(evidence) + } + } + + private refused(reason: string): void { + if (reason === 'backpressure') { + this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS) return } - this.publishEvidence(evidence) + this.failed = true + this.backlog.length = 0 + this.notifyTurnWatchers() + this.deps.onFailed(reason) + } + + /** False when the sink held it back; it stays first in the backlog. */ + private queueSubagentWork(evidence: AgentChildWorkEvidence[]): boolean { + if (this.ended) { + return true + } + const admission = this.deps.sink.tryAppendTransition({ + steps: [], + lifecycle: false, + publish: false, + onPublished: () => this.publishSubagentWork(evidence) + }) + if (!admission.accepted) { + this.refused(admission.reason) + } + return admission.accepted + } + + /** A subagent's stop is offered only once the agent proved it honours one. */ + private publishSubagentWork(evidence: AgentChildWorkEvidence[]): void { + const stoppable = this.deps.childStops?.().subagents === true + this.publishEvidence( + evidence.map((edge) => + edge.type === 'live' + ? { ...edge, child: { ...edge.child, stoppable: stoppable && edge.child.stoppable } } + : edge + ) + ) } private publishEvidence(evidence: AgentChildWorkEvidence[]): void { diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 766d4b9ed73..d1911c3c481 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -235,7 +235,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte stopBackgroundTasks: NonNullable = ( input - ) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, () => this.now()) + ) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge) backgroundTaskStops: NonNullable = ( sessionId diff --git a/src/main/acp/acp-subagent-child-work.ts b/src/main/acp/acp-subagent-child-work.ts deleted file mode 100644 index 2e5a33b53b8..00000000000 --- a/src/main/acp/acp-subagent-child-work.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' -import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' -import type { AcpTimelineEvent } from './acp-timeline-event' -import { createHash } from 'node:crypto' -import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards' - -export function isAcpSubagentChildWorkEvent(event: AcpTimelineEvent): boolean { - return ( - event.type === 'session.ended' || - (['item.open', 'item.update', 'item.close'].includes(event.type) && - 'body' in event && - event.body.kind === 'message' && - event.body.blocks.some(isSubagentGroupBlock)) - ) -} - -/** Project the admitted snapshot, never the translator's potentially newer roster. */ -export function acpSubagentChildWork( - event: AcpTimelineEvent, - observedAt: number, - stoppable: boolean -): AgentChildWorkEvidence[] { - if (event.type === 'session.ended') { - return [{ type: 'session-ended', observedAt }] - } - if ( - !['item.open', 'item.update', 'item.close'].includes(event.type) || - !('body' in event) || - event.body.kind !== 'message' - ) { - return [] - } - const evidence: AgentChildWorkEvidence[] = [] - for (const block of event.body.blocks) { - if (!isSubagentGroupBlock(block)) { - continue - } - for (const [index, child] of block.agents.entries()) { - const identity = event.subagentIdentities?.[index] ?? child.id - const handle = { - idKind: 'task_id' as const, - id: identity, - // Invocation fences are smaller than aliases; keep the roster's full stable handle. - ...(identity.length > AGENT_CHILD_WORK_ID_MAX_LENGTH - ? { runId: `acp-child:${createHash('sha256').update(identity).digest('hex')}` } - : {}) - } - if (child.state === 'working') { - evidence.push({ - type: 'live', - observedAt, - child: { - handle, - kind: 'agent', - residency: 'background', - state: 'working', - description: child.label, - totalTokens: child.tokens, - // Display-clipped handles cannot safely address the provider's cancellation API. - stoppable: stoppable && identity === child.id && !/…~\d+$/.test(child.id) - } - }) - } else { - const outcome: AgentChildWorkOutcome = - child.state === 'failed' - ? 'failed' - : child.state === 'stopped' - ? 'cancelled' - : child.state === 'unverifiable' - ? 'unknown' - : 'succeeded' - evidence.push({ type: 'ended', observedAt, handle, outcome, totalTokens: child.tokens }) - } - } - } - return evidence -} diff --git a/src/main/acp/acp-subagent-timeline-characterization.test.ts b/src/main/acp/acp-subagent-timeline-characterization.test.ts deleted file mode 100644 index f28c8346b4c..00000000000 --- a/src/main/acp/acp-subagent-timeline-characterization.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Golden timeline events from the ACP subagent roster for scripted update sequences, so moving it -// onto shared code cannot change a saved byte. - -import { describe, expect, it } from 'vitest' -import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' -import { AcpSubagentTimeline } from './acp-subagent-timeline' -import type { AcpTimelineEvent } from './acp-timeline-event' - -function harness() { - const timeline = new AcpSubagentTimeline() - const log: AcpTimelineEvent[][] = [] - let at = 1_000 - return { - timeline, - log, - send: (updates: AcpSubagentUpdate[], join: { thread?: string; turn?: string }) => { - log.push(timeline.translate(updates, join, (at += 10))) - } - } -} - -const JOIN = { thread: 'parent', turn: 'turn-1' } - -describe('ACP subagent timeline rows (characterization)', () => { - it('one child: working, tokens, completed with its reply, replayed', () => { - const h = harness() - h.send([{ id: 'a1', label: 'Explore', state: 'working' }], JOIN) - h.send([{ id: 'a1', tokens: 0 }], JOIN) - h.send([{ id: 'a1', tokens: 200 }], JOIN) - h.send([{ id: 'a1', state: 'completed', result: 'Found it' }], JOIN) - h.send([{ id: 'a1', state: 'completed', result: 'Found it' }], JOIN) - h.send([{ id: 'a1', state: 'failed' }], JOIN) - expect(h.log).toMatchSnapshot() - }) - - it('children sharing a label, a provider-named ordinal, and outcomes in one batch', () => { - const h = harness() - h.send( - [ - { id: 'a1', label: 'Audit' }, - { id: 'a2', label: 'Audit' }, - { id: 'a3', label: 'Audit 2' }, - { id: 'a4' } - ], - JOIN - ) - h.send( - [ - { id: 'a1', state: 'stopped' }, - { id: 'a2', state: 'unverifiable' }, - { id: 'a2', state: 'completed', result: 'late' }, - { id: 'a3', state: 'failed' } - ], - JOIN - ) - expect(h.log).toMatchSnapshot() - }) - - it('knownOnly updates, turn from the update, and the no-turn group', () => { - const h = harness() - h.send([{ id: 'unknown', state: 'completed', knownOnly: true }], JOIN) - h.send([{ id: 'a1', label: 'Spawned', turn: 'turn-0' }], JOIN) - h.send([{ id: 'a2', label: 'Loose' }], { thread: 'parent' }) - h.send([{ id: 'a1', state: 'stopped', knownOnly: true }], JOIN) - expect({ - log: h.log, - has: ['unknown', 'a1', 'a2'].map((id) => h.timeline.has(id)) - }).toMatchSnapshot() - }) - - it('a group past 64 children drops the rest', () => { - const h = harness() - h.send( - Array.from({ length: 66 }, (_, index): AcpSubagentUpdate => ({ - id: `c${index}`, - label: 'X' - })), - JOIN - ) - const last = h.log.at(-1)?.at(-1) - expect({ - events: h.log.at(-1)?.length, - lastLabels: - last && 'body' in last && last.body.kind === 'message' - ? JSON.stringify(last.body.blocks).slice(-300) - : null, - has: [h.timeline.has('c63'), h.timeline.has('c64')] - }).toMatchSnapshot() - }) - - it('more than 32 settled groups, then a late update for an evicted child', () => { - const h = harness() - for (let index = 0; index < 34; index++) { - h.send([{ id: `c${index}`, label: `Job ${index}`, state: 'completed' }], { - thread: 'parent', - turn: `turn-${index}` - }) - } - h.send([{ id: 'live', label: 'Live' }], { thread: 'parent', turn: 'turn-live' }) - h.send([{ id: 'c0', state: 'failed' }], JOIN) - h.send([{ id: 'c0', label: 'Job 0' }], JOIN) - expect({ - tail: h.log.slice(-3), - has: [h.timeline.has('c0'), h.timeline.has('c1'), h.timeline.has('c33')] - }).toMatchSnapshot() - }) - - it('writes nothing after dispose', () => { - const h = harness() - h.send([{ id: 'a1', label: 'A' }], JOIN) - h.timeline.dispose() - h.send([{ id: 'a1', state: 'completed' }], JOIN) - expect({ log: h.log, has: h.timeline.has('a1') }).toMatchSnapshot() - }) -}) diff --git a/src/main/acp/acp-subagent-timeline.test.ts b/src/main/acp/acp-subagent-timeline.test.ts deleted file mode 100644 index 86e9864b795..00000000000 --- a/src/main/acp/acp-subagent-timeline.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' -import { isSubagentGroupBlock, type NativeChatSubagentState } from '../../shared/native-chat-types' -import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' -import { grokSubagentNotification } from './acp-dialects/grok-subagents' -import { AcpSubagentTimeline } from './acp-subagent-timeline' - -function spawnGroups( - timeline: AcpSubagentTimeline, - count: number, - state: NativeChatSubagentState = 'working' -) { - for (let index = 0; index < count; index++) { - timeline.translate( - [{ id: `child-${index}`, turn: `turn-${index}`, state }], - { thread: 'parent', turn: `turn-${index}` }, - index - ) - } -} - -describe('ACP subagent retention', () => { - it('keeps every live owner beyond both former cache limits and releases newly settled groups', () => { - const timeline = new AcpSubagentTimeline() - for (let group = 0; group < 33; group++) { - timeline.translate( - Array.from({ length: 64 }, (_, child): AcpSubagentUpdate => ({ - id: `${group}:${child}`, - turn: `turn-${group}`, - state: 'working' - })), - { thread: 'parent' }, - group - ) - } - expect(timeline.has('0:0')).toBe(true) - expect(timeline.has('32:63')).toBe(true) - expect(timeline.retentionSizes().groups).toBe(33) - timeline.translate( - Array.from({ length: 63 }, (_, child): AcpSubagentUpdate => ({ - id: `0:${child}`, - state: 'completed', - result: `Answer ${child}` - })), - { thread: 'parent' }, - 100 - ) - expect(timeline.retentionSizes()).toMatchObject({ groups: 33, replies: 63 }) - const events = timeline.translate( - [{ id: '0:63', state: 'completed', result: 'Last sibling' }], - { thread: 'parent', turn: 'later' }, - 101 - ) - expect(events.map((event) => event.type === 'item.update' && event.join?.turn)).toEqual([ - 'turn-0', - 'turn-0' - ]) - // The settled group is released with its replies; its children stay known as settled. - expect(timeline.retentionSizes()).toMatchObject({ groups: 32, replies: 0 }) - expect(timeline.has('0:0')).toBe(true) - expect(timeline.translate([{ id: '0:0', state: 'working' }], {}, 102)).toEqual([]) - }) - - it('bounds settled history and evicted child ids while keeping live groups', () => { - const timeline = new AcpSubagentTimeline() - timeline.translate([{ id: 'live', turn: 'original' }], {}, 0) - for (let group = 0; group < 65; group++) { - timeline.translate( - Array.from({ length: 64 }, (_, child): AcpSubagentUpdate => ({ - id: `${group}:${child}`, - turn: `turn-${group}`, - state: 'completed', - result: 'Answer' - })), - {}, - group + 1 - ) - } - expect(timeline.retentionSizes()).toEqual({ - groups: 32, - replies: 31 * 64, - settledIdentities: 32 * 64 - }) - expect(timeline.has('live')).toBe(true) - expect(timeline.has('0:0')).toBe(false) - expect(timeline.has('33:0')).toBe(true) - expect(timeline.translate([{ id: '33:0', state: 'working' }], {}, 2200)).toEqual([]) - }) - - it('bounds retained descriptions, preserving ordinary duplicate labels and clipped distinctions', () => { - const timeline = new AcpSubagentTimeline() - const description = 'A'.repeat(1024 * 1024) - const updates = ['large-1', 'large-2'].flatMap((id) => { - const update = grokSubagentNotification({ - sessionUpdate: 'subagent_spawned', - subagent_id: id, - description - }) - return update ? [update] : [] - }) - const events = timeline.translate( - [...updates, { id: 'normal-1', label: 'Review' }, { id: 'normal-2', label: 'Review' }], - { turn: 'turn' }, - 0 - ) - const labels = events - .flatMap((event) => - event.type === 'item.update' && event.body.kind === 'message' - ? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - : [] - ) - .map((entry) => entry.label) - expect(labels.every((label) => label.length <= MAX_SUBAGENT_FIELD_CHARS)).toBe(true) - expect(new Set(labels).size).toBe(4) - expect(labels.slice(2)).toEqual(['Review', 'Review 2']) - }) - - it('drops ownership and cached replies on disposal even if the producer continues', () => { - const timeline = new AcpSubagentTimeline() - spawnGroups(timeline, 33) - timeline.translate([{ id: 'child-0', state: 'completed', result: 'Answer' }], {}, 100) - timeline.dispose() - const empty = { groups: 0, settledIdentities: 0, replies: 0 } - expect(timeline.retentionSizes()).toEqual(empty) - expect(timeline.translate([{ id: 'child-1', tokens: 9 }, { id: 'new' }], {}, 101)).toEqual([]) - expect(timeline.retentionSizes()).toEqual(empty) - }) -}) diff --git a/src/main/acp/acp-subagent-timeline.ts b/src/main/acp/acp-subagent-timeline.ts deleted file mode 100644 index 33b651f2b42..00000000000 --- a/src/main/acp/acp-subagent-timeline.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' -import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker' -import type { SubagentGroup } from '../native-chat/subagent-tracker/subagent-tracker-types' -import { acpSubagentIdentity, type AcpTimelineEvent } from './acp-timeline-event' -import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' - -/** The group a subagent reported outside any turn belongs to. */ -const OUTSIDE_TURN = 'thread' - -/** One roster row per spawning turn, revised in place, plus each completed subagent's reply as its - * own row, filed under its id so it opens beneath its roster entry. The rows follow the shared - * subagent tracker's rules; this module only reads the dialect's updates. Lives as long as the - * provider child: a subagent an earlier run spawned is not known here, and its row is that run's. */ -export class AcpSubagentTimeline { - /** Row events the tracker wrote during the current `translate`. */ - private written: AcpTimelineEvent[] = [] - private readonly results = new Map() - /** The time of the updates being translated: when they were observed. */ - private at = 0 - private readonly tracker = new SubagentTracker({ - // The host admits the events later and retries a refused one itself. - outsideTurn: (groupId) => groupId === OUTSIDE_TURN, - port: { - write: (group, { body }) => { - if (body) { - this.written.push({ - type: 'item.update', - item: `subagents:${group.groupId}`, - body, - subagentIdentities: [...group.entries.keys()].map(acpSubagentIdentity), - join: group.placement - }) - } - return { accepted: true } - } - }, - now: () => this.at, - onEvict: (group) => { - for (const id of group.entries.keys()) { - this.results.delete(id) - } - } - }) - private disposed = false - - /** Known children include recently evicted outcomes, so they cannot become background tasks. */ - has(id: string): boolean { - return this.tracker.has(id) - } - - /** Retention is not observable through the update API, so expose what it holds. */ - retentionSizes(): { groups: number; settledIdentities: number; replies: number } { - return { ...this.tracker.sizes(), replies: this.results.size } - } - - /** The journal owns session-end settlement; no later frame may reacquire working ownership. */ - dispose(): void { - this.disposed = true - this.tracker.dispose() - this.results.clear() - this.written = [] - } - - translate( - updates: AcpSubagentUpdate[], - join: ProviderTimelineJoin, - at: number - ): AcpTimelineEvent[] { - if (this.disposed) { - return [] - } - const replies: AcpTimelineEvent[] = [] - this.written = [] - // One revision per changed row; replies are cached before the batch releases settled rows, so a - // released row's replies go with it. - this.tracker.batch(() => { - for (const update of updates) { - this.apply(update, join, at) - const located = this.tracker.locate(update.id) - if (update.result && located?.tracked.entry.state === 'completed') { - replies.push(...this.reply(update.id, located.group, update.result)) - } - } - }) - const events = [...this.written, ...replies] - this.written = [] - return events - } - - private reply( - id: string, - group: SubagentGroup, - text: string - ): AcpTimelineEvent[] { - const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - if (this.results.get(id) === bounded) { - return [] - } - this.results.set(id, bounded) - return [ - { - type: 'item.update', - item: `subagent-result:${id}`, - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: bounded }] }, - producer: { agentId: id, producerKind: 'agent' }, - join: group.placement - } - ] - } - - private apply(update: AcpSubagentUpdate, join: ProviderTimelineJoin, at: number): void { - const turn = update.turn ?? join.turn - const placement: ProviderTimelineJoin = { - ...(join.thread === undefined ? {} : { thread: join.thread }), - ...(turn === undefined ? {} : { turn }) - } - this.at = at - this.tracker.report({ - id: update.id, - group: { id: turn ?? OUTSIDE_TURN, placement: () => placement }, - announces: update.knownOnly !== true, - ...(update.label !== undefined ? { label: update.label } : {}), - ...(update.state !== undefined ? { state: update.state } : {}), - ...(update.tokens !== undefined ? { tokens: update.tokens } : {}), - // A subagent the provider runs outlives the prompt turn that spawned it. - backgrounded: true - }) - } -} diff --git a/src/main/acp/acp-subagents.ts b/src/main/acp/acp-subagents.ts new file mode 100644 index 00000000000..a8ce14c9a55 --- /dev/null +++ b/src/main/acp/acp-subagents.ts @@ -0,0 +1,173 @@ +// An ACP agent's subagents, read as evidence for the host's child records, plus each completed +// subagent's reply as its own row. +// +// The host decides what every subagent is doing and writes its saved row; this only reads the +// dialect's updates. It lives as long as the provider child: a subagent an earlier run spawned is +// not known here. + +import { createHash } from 'node:crypto' +import { BoundedMap } from '../../shared/bounded-map' +import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' +import type { + AgentChildWorkEvidence, + AgentChildWorkEvidenceHandle +} from '../../shared/agent-status-child-work-evidence' +import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards' +import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { boundSubagentLabel } from '../native-chat/agent-session-journal/journal-subagent-item' +import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' +import type { AcpTimelineEvent } from './acp-timeline-event' + +/** Subagents remembered, so a later report about one is still known as a subagent's. */ +const MAX_KNOWN_SUBAGENTS = 2048 + +type KnownSubagent = { + /** Where its reply row goes: the turn that spawned it. */ + placement: ProviderTimelineJoin + /** The reply already written, so a repeated result writes nothing. */ + reply?: string + /** How it first ended: a reply belongs only to a run that completed. */ + ended?: NativeChatSubagentState +} + +const OUTCOMES: Record< + Exclude, + AgentChildWorkOutcome +> = { + completed: 'succeeded', + failed: 'failed', + stopped: 'cancelled', + unverifiable: 'unknown' +} + +/** How a subagent's run ended, by the state the dialect read; undefined while it runs. */ +export function acpSubagentOutcome( + state: NativeChatSubagentState +): AgentChildWorkOutcome | undefined { + return state === 'working' || state === 'idle' ? undefined : OUTCOMES[state] +} + +export class AcpSubagents { + private readonly known = new BoundedMap({ + maxEntries: MAX_KNOWN_SUBAGENTS + }) + private readonly pending: AgentChildWorkEvidence[] = [] + + /** Known children include settled ones, so they cannot become background tasks. */ + has(id: string): boolean { + return this.known.has(id) + } + + dispose(): void { + this.known.clear() + this.pending.length = 0 + } + + /** What the updates translated since the last drain said, for the host's records. */ + drainEvidence(): AgentChildWorkEvidence[] { + return this.pending.splice(0) + } + + translate( + updates: readonly AcpSubagentUpdate[], + join: ProviderTimelineJoin, + at: number + ): AcpTimelineEvent[] { + const replies: AcpTimelineEvent[] = [] + for (const update of updates) { + const known = this.known.get(update.id) + if (!known && update.knownOnly === true) { + continue + } + const turn = update.turn ?? join.turn + const subagent = known ?? { + placement: { + ...(join.thread === undefined ? {} : { thread: join.thread }), + ...(turn === undefined ? {} : { turn }) + } + } + this.known.set(update.id, subagent) + const ending = update.state !== undefined && acpSubagentOutcome(update.state) !== undefined + // A late report about an ended run is not a new run: the host drops the run's record at the + // next send, so a live edge then would bring it back as working. + if (ending || subagent.ended === undefined) { + this.pending.push(subagentEvidence(update, at)) + } + if (ending) { + subagent.ended ??= update.state + } + if (update.result && subagent.ended === 'completed') { + replies.push(...this.reply(update.id, subagent, update.result)) + } + } + return replies + } + + private reply(id: string, subagent: KnownSubagent, text: string): AcpTimelineEvent[] { + const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + if (subagent.reply === bounded) { + return [] + } + subagent.reply = bounded + return [ + { + type: 'item.update', + item: `subagent-result:${id}`, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: bounded }] }, + producer: { agentId: id, producerKind: 'agent' }, + join: subagent.placement + } + ] + } +} + +/** A subagent runs on past the prompt turn that spawned it, and the provider names no run. */ +function subagentEvidence(update: AcpSubagentUpdate, observedAt: number): AgentChildWorkEvidence { + const handle = subagentHandle(update.id) + const outcome = update.state === undefined ? undefined : acpSubagentOutcome(update.state) + if (outcome === undefined) { + return { + type: 'live', + observedAt, + child: { + handle, + kind: 'agent', + residency: 'background', + state: update.state === 'idle' ? 'idle' : 'working', + // The strip and the saved row show the same label. + ...(update.label !== undefined ? { description: boundSubagentLabel(update.label) } : {}), + ...(update.tokens !== undefined ? { totalTokens: update.tokens } : {}), + // An id the handle had to digest cannot address the provider's cancellation. + stoppable: handle.id === update.id + } + } + } + return { + type: 'ended', + observedAt, + handle, + outcome, + ...(update.tokens !== undefined ? { totalTokens: update.tokens } : {}) + } +} + +function subagentHandle(id: string): AgentChildWorkEvidenceHandle { + const bounded = + id.length <= MAX_SUBAGENT_FIELD_CHARS + ? id + : `acp-child:${createHash('sha256').update(id).digest('hex')}` + return { + idKind: 'task_id', + id: bounded, + // Invocation fences are smaller than handles; keep the full stable handle. + ...(bounded.length > AGENT_CHILD_WORK_ID_MAX_LENGTH + ? { runId: `acp-child:${createHash('sha256').update(bounded).digest('hex')}` } + : {}) + } +} diff --git a/src/main/acp/acp-timeline-event.ts b/src/main/acp/acp-timeline-event.ts index 605a4e53184..9b2c8eea878 100644 --- a/src/main/acp/acp-timeline-event.ts +++ b/src/main/acp/acp-timeline-event.ts @@ -1,14 +1,3 @@ -import { createHash } from 'node:crypto' -import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -export type AcpTimelineEvent = ProviderTimelineEvent & { - /** Event-local identities in roster order, before display clipping; never journaled. */ - subagentIdentities?: readonly string[] -} - -export function acpSubagentIdentity(providerId: string): string { - return providerId.length <= MAX_SUBAGENT_FIELD_CHARS - ? providerId - : `acp-child:${createHash('sha256').update(providerId).digest('hex')}` -} +export type AcpTimelineEvent = ProviderTimelineEvent diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index c611cfabbb9..3f16ad4139c 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -11,6 +11,9 @@ import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/p import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' import { AcpTimelineTranslator } from './acp-timeline-translator' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view' +import { acpSubagentRecords } from './acp-structured-child-work.test-support' import { PromptResponseSchema } from './generated/acp-protocol.generated' const frameSchema = z.object({ @@ -73,6 +76,15 @@ export async function openAcpFixtureRig() { const translator = () => new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT }) let lane = translator() + const records = acpSubagentRecords() + // What each subagent's saved row last said: its host record, kept past the record's retirement. + const latest = new Map() + const report = (evidence: AgentChildWorkEvidence[]) => { + for (const view of records(evidence)) { + latest.set(view.id, view) + } + } + const drain = () => report(lane.subagents.drainEvidence()) const requests = new Map< string | number, { request: string; presentation: AcpRequestPresentation } @@ -80,6 +92,16 @@ export async function openAcpFixtureRig() { const apply = (events: ProviderTimelineEvent[]) => { for (const event of events) { expect(rig.assembler.apply(event).admission.accepted).toBe(true) + if (event.type === 'turn.open') { + // As the host retires finished records when the user's next send is accepted. + drain() + report([{ type: 'turn-started', observedAt: event.at }]) + } + if (event.type === 'session.ended') { + // As the lane reports a session end once the journal admitted it. + drain() + report([{ type: 'session-ended', observedAt: 9_000 }]) + } } } const feed = async (frames: AcpFixtureFrame[]) => { @@ -92,6 +114,7 @@ export async function openAcpFixtureRig() { const session = z.object({ sessionId: z.string() }).parse(message.params) if (providerSessionId !== session.sessionId) { providerSessionId = session.sessionId + drain() lane = translator() } apply(lane.openPrompt(`${frame.process}:${message.id}`, at).events) @@ -162,8 +185,14 @@ export async function openAcpFixtureRig() { /** A new provider child: the sweep, a new generation's assembler and a new translator. */ restart: async () => { await rig.restart() + drain() lane = translator() }, + /** Every subagent reported so far, as its host record last read. */ + subagents: () => { + drain() + return [...latest.values()] + }, finishLoad: () => lane.finishLoad() } } diff --git a/src/main/acp/acp-timeline-grok-subagent-retention.test.ts b/src/main/acp/acp-timeline-grok-subagent-retention.test.ts index edacc93ab73..e6a9d442cac 100644 --- a/src/main/acp/acp-timeline-grok-subagent-retention.test.ts +++ b/src/main/acp/acp-timeline-grok-subagent-retention.test.ts @@ -1,9 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' -import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../shared/native-chat-types' -import { - closeProviderTimelineRigs, - providerItemId -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { AcpStructuredLane } from './acp-structured-lane' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' @@ -67,16 +66,14 @@ function laterGroups(fixture: Fixture, settled: boolean) { } } -async function roster(fixture: Fixture) { - return (await fixture.rig.rows()).flatMap((row) => - row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) - : [] - ) +function record(fixture: Fixture, id: string) { + return fixture.subagents().filter((view) => view.providerId === id) } -async function original(fixture: Fixture) { - return (await roster(fixture)).find(({ group }) => group.groupId === 'prompt:original') +/** The scope of the rows the latest turn writes. */ +async function openTurnScope(fixture: Fixture) { + const turn = (await fixture.rig.rows()).findLast((row) => readAgentJournalTurn(row.body)) + return turn ? { kind: 'turn', turnItemId: turn.itemId } : undefined } function toolResult(fixture: Fixture, kind: 'output' | 'kill', at: number) { @@ -117,12 +114,13 @@ function toolResult(fixture: Fixture, kind: 'output' | 'kill', at: number) { ) } -describe('Grok roster ownership after more than 32 spawning groups', () => { +describe('a Grok subagent reported on long after the turn that spawned it', () => { it.each([false, true])( - 'finishes the original durable row with siblings and reply when later groups settled=%s', + 'finishes the original child and files its reply under its spawn turn when 33 later turns settled=%s', async (settled) => { const fixture = await openAcpFixtureRig() openTurn(fixture, 'original', 1) + const spawnScope = await openTurnScope(fixture) spawn(fixture, 'old', 'original', 2) spawn(fixture, 'sibling', 'original', 3) notify( @@ -131,8 +129,6 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { 4 ) fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 5)) - const before = await original(fixture) - expect(before).toBeDefined() laterGroups(fixture, settled) openTurn(fixture, 'current', 300) notify( @@ -146,27 +142,17 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { }, 301 ) - const after = await original(fixture) - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(after?.row.turnScope).toEqual(before?.row.turnScope) - expect(after?.group.agents).toEqual([ + expect(record(fixture, 'old')).toMatchObject([ { - id: 'old', - label: 'Original label', - startedAt: 2, - settledAt: 301, - state: 'completed', - tokens: 12 - }, - { id: 'sibling', label: 'Original label 2', startedAt: 3, settledAt: 4, state: 'completed' } + description: 'Original label', + membership: 'settled', + outcome: 'succeeded', + totalTokens: 12 + } ]) - expect( - (await roster(fixture)).flatMap(({ group }) => - group.agents.filter((entry) => entry.id === 'old') - ) - ).toHaveLength(1) + expect(record(fixture, 'sibling')).toMatchObject([{ outcome: 'succeeded' }]) const reply = (await fixture.rig.rows()).find((row) => row.agentId === 'old') - expect(reply?.turnScope).toEqual(before?.row.turnScope) + expect(reply?.turnScope).toEqual(spawnScope) expect(reply?.body).toMatchObject({ kind: 'message', role: 'assistant', @@ -182,20 +168,11 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { openTurn(fixture, 'original', 1) spawn(fixture, 'old', 'original', 2) fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 3)) - const before = await original(fixture) laterGroups(fixture, false) openTurn(fixture, 'current', 300) toolResult(fixture, kind, 301) - const after = await original(fixture) - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(after?.group.agents).toEqual([ - { - id: 'old', - label: 'Original label', - startedAt: 2, - settledAt: 301, - state: kind === 'output' ? 'completed' : 'stopped' - } + expect(record(fixture, 'old')).toMatchObject([ + { membership: 'settled', outcome: kind === 'output' ? 'succeeded' : 'cancelled' } ]) const rows = await fixture.rig.rows() expect( @@ -207,13 +184,12 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { } ) - it('drops late progress, repeated spawn and finish for a stopped child after its settled group is evicted', async () => { + it('keeps a stopped child stopped through late progress, a repeated spawn and a finish', async () => { const fixture = await openAcpFixtureRig() openTurn(fixture, 'original', 1) spawn(fixture, 'old', 'original', 2) toolResult(fixture, 'kill', 3) fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 4)) - const before = await original(fixture) laterGroups(fixture, true) openTurn(fixture, 'current', 300) notify(fixture, { sessionUpdate: 'subagent_progress', subagent_id: 'old', tokens_used: 9 }, 301) @@ -228,17 +204,13 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { }, 303 ) - expect(await original(fixture)).toEqual(before) - expect( - (await roster(fixture)).flatMap(({ group }) => - group.agents.filter((entry) => entry.id === 'old') - ) - ).toHaveLength(1) + expect(record(fixture, 'old')).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) expect((await fixture.rig.rows()).filter((row) => row.agentId === 'old')).toEqual([]) }) - it('lane disposal ends in-memory ownership while the journal settles a disconnected producer as unverifiable', async () => { + it('lane disposal ends observation: the host hears the session end, and nothing after it', async () => { const fixture = await openAcpFixtureRig() + const evidence: AgentChildWorkEvidence[] = [] const lane = new AcpStructuredLane({ sink: fixture.rig.sink, sessionId: 'session-timeline', @@ -251,7 +223,8 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { onInputAccepted: () => {}, onFailed: (reason) => { throw new Error(reason) - } + }, + onChildWorkEvidence: (batch) => evidence.push(...batch) }) lane.apply( lane.translator.notification( @@ -279,12 +252,9 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { 2 ) expect(events).toEqual([]) - const row = await fixture.rig.row( - providerItemId('item', 'subagents:thread', { namespace: 'session-1', thread: 'session-1' }) - ) - expect( - row?.body.kind === 'message' && - row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents) - ).toMatchObject([{ id: 'old', state: 'unverifiable' }]) + await fixture.rig.rows() + // Disposal says the session ended once more; nothing reports the child live again. + expect(evidence.map((edge) => edge.type).slice(0, 2)).toEqual(['live', 'session-ended']) + expect(evidence.filter((edge) => edge.type === 'live')).toHaveLength(1) }) }) diff --git a/src/main/acp/acp-timeline-grok-subagents.test.ts b/src/main/acp/acp-timeline-grok-subagents.test.ts index 2acd725cf28..18129c702f9 100644 --- a/src/main/acp/acp-timeline-grok-subagents.test.ts +++ b/src/main/acp/acp-timeline-grok-subagents.test.ts @@ -1,13 +1,10 @@ // Grok's recorded subagents (Grok 1.0.46, s7 fixtures) through translation, assembly and the -// journal: each subagent is an entry in the shared roster row, and its reply is its own row. +// journal: each subagent is one host record, which the host saves as its row, and its reply is its +// own row. import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { - isBackgroundTaskBlock, - isSubagentGroupBlock, - type NativeChatSubagentEntry -} from '../../shared/native-chat-types' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' @@ -15,15 +12,14 @@ afterEach(closeProviderTimelineRigs) type Fixture = Awaited> -async function rosters(fixture: Fixture) { - return (await fixture.rig.rows()).flatMap((row) => { - const group = row.body.kind === 'message' ? row.body.blocks.find(isSubagentGroupBlock) : null - return group ? [{ row, group }] : [] - }) -} - -async function entries(fixture: Fixture): Promise { - return (await rosters(fixture)).flatMap(({ group }) => group.agents) +function subagents(fixture: Fixture) { + return fixture.subagents().map((view) => ({ + id: view.providerId, + label: view.description, + membership: view.membership, + outcome: view.outcome, + tokens: view.totalTokens + })) } function replies(rows: AgentJournalRenderItem[]) { @@ -54,25 +50,18 @@ async function untilSpawned() { } describe('Grok subagents through the shared timeline', () => { - it('a foreground subagent is one roster entry that completes, with its reply filed under it', async () => { + it('a foreground subagent is one record that completes, with its reply filed under it', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s7-subagent-foreground')) - const [roster] = await rosters(fixture) - expect(roster?.group.agents).toEqual([ + expect(subagents(fixture)).toEqual([ { id: 'subagent-1', label: 'Count note lines', - state: 'completed', - tokens: 13778, - startedAt: expect.any(Number), - settledAt: expect.any(Number) + membership: 'settled', + outcome: 'succeeded', + tokens: 13778 } ]) - expect(roster?.row.body).toMatchObject({ - role: 'system', - blocks: [{ type: 'text', text: 'Ran 1 subagent' }, { type: 'subagent-group' }] - }) - expect(roster?.row.turnScope?.kind).toBe('turn') expect(replies(rows)).toEqual([ { agentId: 'subagent-1', role: 'assistant', blocks: [{ type: 'text', text: '4' }] } ]) @@ -89,8 +78,8 @@ describe('Grok subagents through the shared timeline', () => { it('a background subagent completes from Grok, and reading its output adds no background task', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s7-subagent-background')) - expect(await entries(fixture)).toMatchObject([ - { id: 'subagent-1', label: 'List folder files', state: 'completed', tokens: 13829 } + expect(subagents(fixture)).toMatchObject([ + { id: 'subagent-1', label: 'List folder files', outcome: 'succeeded', tokens: 13829 } ]) expect(replies(rows)).toEqual([ { @@ -105,13 +94,9 @@ describe('Grok subagents through the shared timeline', () => { it('Stop ends a foreground and a background subagent as stopped, and a later read keeps them so', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s7-subagent-stop')) - expect(await entries(fixture)).toMatchObject([ - { id: 'subagent-1', label: 'Slow sleeper', state: 'stopped', tokens: 4094 }, - { id: 'subagent-2', label: 'Background sleeper', state: 'stopped', tokens: 4094 } - ]) - expect((await rosters(fixture)).map(({ group }) => group.groupId)).toEqual([ - 'prompt:p1:5', - 'prompt:p1:6' + expect(subagents(fixture)).toMatchObject([ + { id: 'subagent-1', label: 'Slow sleeper', outcome: 'cancelled', tokens: 4094 }, + { id: 'subagent-2', label: 'Background sleeper', outcome: 'cancelled', tokens: 4094 } ]) expect(replies(rows)).toEqual([]) expect(backgroundTasks(rows)).toEqual([]) @@ -125,7 +110,7 @@ describe('Grok subagents through the shared timeline', () => { it('a kill stops a running subagent without a background-task row, and nothing relights it', async () => { const fixture = await openAcpFixtureRig() await fixture.feed(await untilSpawned()) - expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['working']) + expect(subagents(fixture).map((entry) => entry.membership)).toEqual(['live']) const lane = fixture.lane() fixture.apply( lane.notification( @@ -162,8 +147,8 @@ describe('Grok subagents through the shared timeline', () => { 2002 ) ) - expect(await entries(fixture)).toMatchObject([ - { id: 'subagent-1', state: 'stopped', settledAt: 2000, tokens: 7 } + expect(subagents(fixture)).toMatchObject([ + { id: 'subagent-1', membership: 'settled', outcome: 'cancelled' } ]) expect(backgroundTasks(await fixture.rig.rows())).toEqual([]) }) @@ -194,14 +179,14 @@ describe('Grok subagents through the shared timeline', () => { 2000 ) ) - expect((await entries(fixture)).map((entry) => entry.id)).toEqual(['subagent-1']) + expect(subagents(fixture).map((entry) => entry.id)).toEqual(['subagent-1']) }) it('a subagent still running when the session ends reads unverifiable', async () => { const fixture = await openAcpFixtureRig() await fixture.feed(await untilSpawned()) - expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['working']) + expect(subagents(fixture).map((entry) => entry.membership)).toEqual(['live']) fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }]) - expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['unverifiable']) + expect(subagents(fixture).map((entry) => entry.outcome)).toEqual(['unknown']) }) }) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index a5bd8746b3b..cb462f806a3 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -9,7 +9,7 @@ import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' import { ACP_SUBSTANTIVE_UPDATES, acpSessionUpdate } from './acp-session-update' -import { AcpSubagentTimeline } from './acp-subagent-timeline' +import { AcpSubagents } from './acp-subagents' import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnFailures, @@ -39,7 +39,8 @@ export class AcpTimelineTranslator { private readonly prompts: AcpPromptTurns private readonly tools = new AcpToolTimeline() private readonly backgroundTasks: AcpBackgroundTaskTimeline - private readonly subagents = new AcpSubagentTimeline() + /** Its evidence is drained by the lane, in the journal's order. */ + readonly subagents = new AcpSubagents() private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) private readonly failures: AcpTurnFailures diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts index 774344dfe7c..340f4fe8ee7 100644 --- a/src/main/agent-hooks/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -33,7 +33,8 @@ export function agentHookRuntimeDeps( forget: (subject) => server.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => server.ingestStructuredChildWork(subject, evidence, provider), - readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + readChildWork: (subject) => server.getStructuredChildWorkViews(subject), + readChildWorkRecords: (subject) => server.getStructuredChildWork(subject) }, checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => diff --git a/src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap b/src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap deleted file mode 100644 index e894e027798..00000000000 --- a/src/main/claude/__snapshots__/claude-subagent-roster-characterization.test.ts.snap +++ /dev/null @@ -1,1734 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Claude subagent roster rows (characterization) > a child announced outside any turn is not swept by a turn end 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Loose", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "outside-turn", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:outside-turn", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "thread", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent (1 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Loose", - "settledAt": 1030, - "startedAt": 1010, - "state": "unverifiable", - }, - ], - "groupId": "outside-turn", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:outside-turn", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "thread", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > a later run inherits the row an earlier run journaled and revises it 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "startedAt": 5, - "state": "completed", - }, - { - "id": "t2", - "label": "Audit 2", - "settledAt": 9, - "startedAt": 6, - "state": "completed", - }, - ], - "groupId": "s:turn-0", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-0", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-0", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "startedAt": 1020, - "state": "working", - }, - { - "id": "t2", - "label": "Audit 2", - "settledAt": 9, - "startedAt": 6, - "state": "completed", - }, - ], - "groupId": "s:turn-0", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-0", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-0", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t4", - "label": "Audit", - "startedAt": 1030, - "state": "working", - }, - ], - "groupId": "s:turn-3", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-3", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-3", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 2 subagents (1 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "settledAt": 1040, - "startedAt": 1020, - "state": "unverifiable", - }, - { - "id": "t2", - "label": "Audit 2", - "settledAt": 9, - "startedAt": 6, - "state": "completed", - }, - ], - "groupId": "s:turn-0", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-0", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-0", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent (1 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "t4", - "label": "Audit", - "settledAt": 1040, - "startedAt": 1030, - "state": "unverifiable", - }, - ], - "groupId": "s:turn-3", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-3", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-3", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > a provisional row from child traffic takes the announced name and canonical id 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "u1", - "label": "subagent", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Named", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Named", - "settledAt": 1050, - "startedAt": 1010, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > a refused append or publish is retried by the next write of that row 1`] = ` -[ - { - "accepted": false, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "B", - "startedAt": 1030, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "B", - "settledAt": 1040, - "startedAt": 1030, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": false, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "B", - "settledAt": 1040, - "startedAt": 1030, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 2 subagents (1 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "settledAt": 1060, - "startedAt": 1010, - "state": "unverifiable", - }, - { - "id": "t2", - "label": "B", - "settledAt": 1040, - "startedAt": 1030, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > a resume under a new tool id in a later turn reopens the original row 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "settledAt": 1020, - "startedAt": 1010, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "startedAt": 1040, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "settledAt": 1060, - "startedAt": 1040, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > an announcement that is not a subagent tombstones the provisional row 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "u1", - "label": "subagent", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "tombstone", - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted child 1`] = `138`; - -exports[`Claude subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted child 2`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t33", - "label": "Job 33", - "startedAt": 1670, - "state": "working", - }, - ], - "groupId": "s:turn-33", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-33", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-33", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t33", - "label": "Job 33", - "settledAt": 1680, - "startedAt": 1670, - "state": "completed", - }, - ], - "groupId": "s:turn-33", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-33", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-33", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "live", - "label": "Live", - "startedAt": 1690, - "state": "working", - }, - ], - "groupId": "s:turn-live", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-live", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-live", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > one foreground child settled by its spawn call result 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "settledAt": 1030, - "startedAt": 1010, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > two children sharing a label, one backgrounded, through turn end and session end 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "Audit 2", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "Audit 2", - "startedAt": 1020, - "state": "working", - }, - { - "id": "t3", - "label": "Audit 2 2", - "startedAt": 1030, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents (2 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "settledAt": 1040, - "startedAt": 1010, - "state": "unverifiable", - }, - { - "id": "t2", - "label": "Audit 2", - "startedAt": 1020, - "state": "working", - }, - { - "id": "t3", - "label": "Audit 2 2", - "settledAt": 1040, - "startedAt": 1030, - "state": "unverifiable", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents (1 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "settledAt": 1040, - "startedAt": 1010, - "state": "unverifiable", - }, - { - "id": "t2", - "label": "Audit 2", - "startedAt": 1020, - "state": "working", - }, - { - "id": "t3", - "label": "Audit 2 2", - "settledAt": 1050, - "startedAt": 1030, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 3 subagents (2 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "Audit", - "settledAt": 1040, - "startedAt": 1010, - "state": "unverifiable", - }, - { - "id": "t2", - "label": "Audit 2", - "settledAt": 1060, - "startedAt": 1020, - "state": "unverifiable", - }, - { - "id": "t3", - "label": "Audit 2 2", - "settledAt": 1050, - "startedAt": 1030, - "state": "completed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Claude subagent roster rows (characterization) > verdicts after lost contact, failures and stops 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "B", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "B", - "startedAt": 1020, - "state": "working", - }, - { - "id": "t3", - "label": "C", - "startedAt": 1030, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents (1 stopped)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "B", - "settledAt": 1040, - "startedAt": 1020, - "state": "stopped", - }, - { - "id": "t3", - "label": "C", - "startedAt": 1030, - "state": "working", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents (1 failed)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "startedAt": 1010, - "state": "working", - }, - { - "id": "t2", - "label": "B", - "settledAt": 1040, - "startedAt": 1020, - "state": "stopped", - }, - { - "id": "t3", - "label": "C", - "settledAt": 1050, - "startedAt": 1030, - "state": "failed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 3 subagents (1 failed)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "settledAt": 1060, - "startedAt": 1010, - "state": "unverifiable", - }, - { - "id": "t2", - "label": "B", - "settledAt": 1040, - "startedAt": 1020, - "state": "stopped", - }, - { - "id": "t3", - "label": "C", - "settledAt": 1050, - "startedAt": 1030, - "state": "failed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 3 subagents (1 failed)", - "type": "text", - }, - { - "agents": [ - { - "id": "t1", - "label": "A", - "settledAt": 1070, - "startedAt": 1010, - "state": "completed", - }, - { - "id": "t2", - "label": "B", - "settledAt": 1040, - "startedAt": 1020, - "state": "stopped", - }, - { - "id": "t3", - "label": "C", - "settledAt": 1050, - "startedAt": 1030, - "state": "failed", - }, - ], - "groupId": "s:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "claude-subagents:s:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "turn-row:s:turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; diff --git a/src/main/claude/claude-background-stop-records.test.ts b/src/main/claude/claude-background-stop-records.test.ts index 0092d9e2f29..935e5bc5021 100644 --- a/src/main/claude/claude-background-stop-records.test.ts +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -3,7 +3,11 @@ // order through the real adapter and a real hook server. import { afterEach, describe, expect, it, vi } from 'vitest' -import { agentChildWorkStopTargets } from '../../shared/agent-child-work-stop-targets' +import { + agentChildWorkStopEvidence, + agentChildWorkStopTargets, + type AgentChildWorkStopAcknowledgement +} from '../../shared/agent-child-work-stop-targets' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { conversationCommandBlocked } from '../native-chat/agent-session-wire/structured-conversation-command-admission' import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' @@ -46,43 +50,55 @@ async function droppedFromRoster() { ]) expect(run.adapter.backgroundTaskState('session-1')).toBeNull() expect(blocked()?.message).toBe('Stop background tasks before using this command.') - return { run, records, blocked, stopCalls } + /** A Stop as the host sends it: targets captured first, what the CLI accepted settled after. */ + const stop = async (taskId?: string) => { + const targets = agentChildWorkStopTargets(records(), taskId) + const acknowledged: AgentChildWorkStopAcknowledgement[] = [] + const outcome = await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: targets.map((target) => target.providerId), + acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement) + }) + host.ingestStructuredChildWork( + parent, + agentChildWorkStopEvidence(targets, acknowledged, 6_500), + 'claude' + ) + return outcome + } + const basis = () => host.getStructuredChildWork(parent).map((record) => record.outcomeBasis) + return { run, records, blocked, stopCalls, stop, basis } } describe('a background Stop acts on the host child records', () => { it("stops the row's own task, which the records keep after the roster dropped it", async () => { - const { run, records, blocked, stopCalls } = await droppedFromRoster() + const { records, blocked, stopCalls, stop, basis } = await droppedFromRoster() - const outcome = await run.adapter.stopBackgroundTasks({ - sessionId: 'session-1', - fence: 7, - taskIds: agentChildWorkStopTargets(records(), 'agent-1') - }) + const outcome = await stop('agent-1') - expect(outcome).toEqual({ cancelled: true }) + expect(outcome).toMatchObject({ cancelled: true }) expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }]) // The CLI acknowledges a task it no longer runs with no frame: the acknowledgement ends it. expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + expect(basis()).toEqual(['stop-acknowledged']) expect(blocked()).toBeNull() }) it('stops every task the strip offers a stop when none is named', async () => { - const { run, records, blocked, stopCalls } = await droppedFromRoster() + const { records, blocked, stopCalls, stop, basis } = await droppedFromRoster() - const outcome = await run.adapter.stopBackgroundTasks({ - sessionId: 'session-1', - fence: 7, - taskIds: agentChildWorkStopTargets(records()) - }) + const outcome = await stop() - expect(outcome).toEqual({ cancelled: true }) + expect(outcome).toMatchObject({ cancelled: true }) expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }]) expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + expect(basis()).toEqual(['stop-acknowledged']) expect(blocked()).toBeNull() }) it('leaves a task that ended on its own frame as it ended: its row offers no stop', async () => { - const { run, records, stopCalls } = await droppedFromRoster() + const { run, records, stopCalls, stop } = await droppedFromRoster() run.replay([ { at: 7_000, @@ -95,25 +111,17 @@ describe('a background Stop acts on the host child records', () => { } ]) - const outcome = await run.adapter.stopBackgroundTasks({ - sessionId: 'session-1', - fence: 7, - taskIds: agentChildWorkStopTargets(records(), 'agent-1') - }) + const outcome = await stop('agent-1') // The finished row offers no stop, so nothing reaches the CLI and the outcome stands. - expect(outcome).toEqual({ cancelled: false }) + expect(outcome).toMatchObject({ cancelled: false }) expect(stopCalls()).toEqual([]) expect(records()).toMatchObject([{ membership: 'settled', outcome: 'succeeded' }]) }) it('lets the task’s own completion, written before the acknowledgement, replace the Stop', async () => { - const { run, records } = await droppedFromRoster() - await run.adapter.stopBackgroundTasks({ - sessionId: 'session-1', - fence: 7, - taskIds: agentChildWorkStopTargets(records(), 'agent-1') - }) + const { run, records, stop } = await droppedFromRoster() + await stop('agent-1') expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) // The completion the CLI wrote before its answer, which the SDK delivered after it. diff --git a/src/main/claude/claude-captured-wake-frames.test-fixture.ts b/src/main/claude/claude-captured-wake-frames.test-fixture.ts new file mode 100644 index 00000000000..62a19ca45b0 --- /dev/null +++ b/src/main/claude/claude-captured-wake-frames.test-fixture.ts @@ -0,0 +1,125 @@ +// Claude CLI 2.1.280 stream-json capture, cut to the frames and fields the child-work path reads. +// Ids and prompts are replaced; the frame order and the relative clock (`at`, ms after the spawn) +// are the captured ones. + +import { + result, + says, + spawn, + system, + toolResult, + type CapturedFrame +} from './claude-captured-frame-builders.test-fixture' + +/** A background agent backgrounds its own 40 s shell and ends its turn; the CLI reports it + * completed, then starts it again under the same spawn call when the shell ends, and it ends again + * with its answer. CLI 2.1.280. */ +export const WAKES_AFTER_ITS_SHELL: CapturedFrame[] = [ + { + at: 0, + frame: spawn( + 'toolu_agent', + 'Agent', + { description: 'Sleep 40 seconds then reply', run_in_background: true }, + null + ) + }, + { + at: 15, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + description: 'Sleep 40 seconds then reply', + subagent_type: 'general-purpose', + is_backgrounded: true, + task_type: 'local_agent' + }) + }, + { at: 30, frame: toolResult('toolu_agent', 'Async agent launched successfully.', null) }, + { at: 1_944, frame: result('success') }, + { + at: 4_343, + frame: spawn( + 'toolu_shell', + 'Bash', + { + command: 'sleep 40', + description: 'Wait 40 seconds in background', + run_in_background: true + }, + 'toolu_agent' + ) + }, + { + at: 4_431, + frame: system('task_started', { + task_id: 'shell-1', + owned_by_subagent: true, + tool_use_id: 'toolu_shell', + description: 'Wait 40 seconds in background', + is_backgrounded: true, + task_type: 'local_bash' + }) + }, + { + at: 4_441, + frame: toolResult( + 'toolu_shell', + 'Command running in background with ID: shell-1.', + 'toolu_agent' + ) + }, + { + at: 11_913, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } }) + }, + { + at: 11_913, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'completed', + summary: '`sleep 40` is still running in the background.', + usage: { total_tokens: 17_928, tool_uses: 2, duration_ms: 11_901 } + }) + }, + { + at: 44_454, + frame: system('task_updated', { task_id: 'shell-1', patch: { status: 'completed' } }) + }, + { + at: 44_454, + frame: system('task_notification', { + task_id: 'shell-1', + tool_use_id: 'toolu_shell', + status: 'completed', + summary: 'Background command "Wait 40 seconds in background" completed (exit code 0)' + }) + }, + { + at: 44_460, + frame: system('task_started', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + description: 'Sleep 40 seconds then reply', + subagent_type: 'general-purpose', + is_backgrounded: true, + task_type: 'local_agent' + }) + }, + { at: 46_198, frame: says('agent-1-final', 'DONE', 'toolu_agent') }, + { + at: 46_198, + frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } }) + }, + { + at: 46_199, + frame: system('task_notification', { + task_id: 'agent-1', + tool_use_id: 'toolu_agent', + status: 'completed', + summary: 'DONE', + usage: { total_tokens: 18_181, tool_uses: 2, duration_ms: 46_184 } + }) + } +] diff --git a/src/main/claude/claude-child-work-decoder.test.ts b/src/main/claude/claude-child-work-decoder.test.ts index ae4ca6a9c38..084fd6eac0e 100644 --- a/src/main/claude/claude-child-work-decoder.test.ts +++ b/src/main/claude/claude-child-work-decoder.test.ts @@ -145,7 +145,7 @@ describe('Claude child-work decoder', () => { ]) }) - it('reports a start for an ended child as a restart, and a late start of its run as nothing', () => { + it('reports the run a completion ended waking under the same spawn call as that run again', () => { const decoder = decoderWith( backgroundAgent, system('task_notification', { @@ -155,6 +155,29 @@ describe('Claude child-work decoder', () => { }) ) decoder.observe(backgroundAgent) + decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) + expect(decoder.drain(500)).toEqual([ + expect.objectContaining({ + type: 'live', + restart: true, + child: expect.objectContaining({ + handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_bg' } + }) + }), + expect.objectContaining({ type: 'live' }) + ]) + }) + + it('reports a start for an ended child as a restart, and a late start of a stopped run as nothing', () => { + const decoder = decoderWith( + backgroundAgent, + system('task_notification', { + task_id: 'agent-bg', + tool_use_id: 'toolu_bg', + status: 'stopped' + }) + ) + decoder.observe(backgroundAgent) decoder.observe(system('task_updated', { task_id: 'agent-bg', patch: { description: 'Late' } })) decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) expect(decoder.drain(500)).toEqual([]) @@ -185,22 +208,12 @@ describe('Claude child-work decoder', () => { expect(decoder.drain(500)).toEqual([]) }) - it('bounds the live children it tracks', () => { + it('admits every live child: only the host store bounds them', () => { const decoder = new ClaudeChildWorkDecoder() - for (let index = 0; index < 257; index += 1) { + for (let index = 0; index < 300; index += 1) { decoder.observe({ ...backgroundAgent, task_id: `agent-${index}` }) } - expect(decoder.drain(500)).toHaveLength(256) - }) - - it('ends what still runs as stopped when Orca ends the session, and only then', () => { - const decoder = decoderWith(backgroundAgent) - decoder.stopLive() - decoder.clear() - expect(decoder.drain(500)).toEqual([ - expect.objectContaining({ type: 'ended', outcome: 'cancelled' }), - { type: 'session-ended', observedAt: 500 } - ]) + expect(decoder.drain(500)).toHaveLength(300) }) it('marks the end of the provider session', () => { diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index ce975e8d39f..6cdab01d8f3 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -3,16 +3,15 @@ // A child is live from its `task_started` until its own terminal `task_updated` or // `task_notification`; nothing else ends it. A roster (`background_tasks_changed`), a turn ending // or a spawn call returning is the parent's view of the child, not the child's, and the CLI sends -// every child its own terminal frame, so none of them settles one. When Orca ends the session and -// proves the close, what is still live is stopped (`stopLive`); any other end leaves it for the -// host to settle as unknown. A live child blocked on a permission request reads waiting; no task -// frame says so, so the caller hands over which children a request blocks. Edges wait here until -// the frame is journaled, then take the host clock. +// every child its own terminal frame, so none of them settles one. When the session ends, however +// it ends, the host settles what is still live as unknown. A live child blocked on a permission +// request reads waiting; no task frame says so, so the caller hands over which children a request +// blocks. Edges wait here until the frame is journaled, then take the host clock. import type { AgentChildWorkKind, AgentChildWorkOperation, - AgentChildWorkOutcomeBasis + AgentChildWorkOutcome } from '../../shared/agent-status-child-work' import type { AgentChildWorkEvidence, @@ -30,14 +29,15 @@ import { taskUsageTotalTokens, terminalClaudeTaskRunState } from './claude-background-task-frames' +import { isClaudeSubagentTask } from './claude-subagent-task-frames' import { claudeChildWorkOutcome, claudeTaskProgressFacts, type ClaudeTaskFacts } from './claude-child-work-evidence' -/** Live children one session tracks, and the ended ids it remembers to recognise a restart. */ -const MAX_LIVE_TASKS = 256 +/** Ended ids remembered to recognise a restart. Live children take no bound here: refusing one + * would hide a running child; the host's global limits bound them. */ const MAX_ENDED_TASKS = 256 type DecodedClaudeTask = { @@ -85,8 +85,11 @@ function observation( export class ClaudeChildWorkDecoder { private readonly live = new Map() - /** Ended task ids, with the spawn call each ended under. */ - private readonly ended = new Map() + /** Ended task ids, with the spawn call each ended under and how. */ + private readonly ended = new Map< + string, + { run: string | undefined; outcome: AgentChildWorkOutcome } + >() /** The children a pending permission request blocks, as the caller last derived them. */ private waiting: ReadonlySet = new Set() private pending: PendingEdge[] = [] @@ -119,16 +122,6 @@ export class ClaudeChildWorkDecoder { } } - /** The CLI acknowledged a stop of this task, and one it no longer knows is acknowledged with no - * frame at all, so the acknowledgement ends a task still live here. Provisionally: the SDK hands - * the acknowledgement over as soon as it reads it, ahead of frames the CLI wrote before it, so - * the task's own ending may still arrive and replace this one. */ - stopAcknowledged(id: string): void { - if (this.live.has(id)) { - this.end(id, 'stopped', { basis: 'stop-acknowledged' }) - } - } - /** Which children a pending request blocks, re-derived by the caller before every drain. A * live child that starts or stops waiting is a live edge of its own. */ observeWaiting(waiting: ReadonlySet): void { @@ -142,14 +135,6 @@ export class ClaudeChildWorkDecoder { } } - /** Orca ended the session and proved the close: what still ran is stopped. The - * ending is Orca's, not the child's, so a frame of the child's own still replaces it. */ - stopLive(): void { - for (const id of this.live.keys()) { - this.end(id, 'stopped', { basis: 'stop-acknowledged' }) - } - } - /** The provider session is gone: the host settles what it still holds live. */ clear(): void { this.live.clear() @@ -171,12 +156,15 @@ export class ClaudeChildWorkDecoder { } const toolUseId = taskAliasId(message.tool_use_id) const existing = this.live.get(id) - const restart = !existing && this.ended.has(id) - // A start under the spawn call a run ended with is that run's late start, not a new run. - if (restart && toolUseId !== undefined && this.ended.get(id) === toolUseId) { + const ended = existing ? undefined : this.ended.get(id) + const restart = ended !== undefined + // Under the spawn call a run ended with, a start after it completed is that run waking (the + // CLI ends a helper's turn while work it started runs, then resumes it when that work ends); + // after any other ending it is the ended run's late start. + if (ended?.run !== undefined && ended.run === toolUseId && ended.outcome !== 'succeeded') { return } - const kind = classifyClaudeBackgroundTaskKind(message.task_type) + const kind = startedTaskKind(message) this.upsert( id, { @@ -251,9 +239,6 @@ export class ClaudeChildWorkDecoder { facts: ClaudeTaskFacts, restart = false ): void { - if (!this.live.has(id) && this.live.size >= MAX_LIVE_TASKS) { - return - } this.ended.delete(id) this.live.set(id, task) this.report(id, task, facts, restart) @@ -276,20 +261,19 @@ export class ClaudeChildWorkDecoder { runId?: string lastMessage?: string totalTokens?: number - basis?: AgentChildWorkOutcomeBasis } ): void { const task = this.live.get(id) this.live.delete(id) + const outcome = claudeChildWorkOutcome(status) this.ended.delete(id) - this.ended.set(id, reported.runId ?? task?.toolUseId) + this.ended.set(id, { run: reported.runId ?? task?.toolUseId, outcome }) if (this.ended.size > MAX_ENDED_TASKS) { const [oldest] = this.ended.keys() if (oldest !== undefined) { this.ended.delete(oldest) } } - const outcome = claudeChildWorkOutcome(status) this.pending.push((observedAt) => ({ type: 'ended', observedAt, @@ -299,9 +283,15 @@ export class ClaudeChildWorkDecoder { ...(reported.runId !== undefined ? { runId: reported.runId } : {}) }, outcome, - ...(reported.basis ? { basis: reported.basis } : {}), ...(reported.lastMessage ? { lastMessage: reported.lastMessage } : {}), ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}) })) } } + +/** Releases predating `task_type` still name an agent in `subagent_type`. */ +function startedTaskKind(message: Record): AgentChildWorkKind { + return isClaudeSubagentTask(message) + ? 'agent' + : classifyClaudeBackgroundTaskKind(message.task_type) +} diff --git a/src/main/claude/claude-command-turn.test.ts b/src/main/claude/claude-command-turn.test.ts index d777e8a423d..d8dfb63aaad 100644 --- a/src/main/claude/claude-command-turn.test.ts +++ b/src/main/claude/claude-command-turn.test.ts @@ -246,32 +246,6 @@ describe('the command turn at each point the ordinary result path threads throug expect(finished.translator.currentTurnId).not.toBeNull() }) - it("settles a child still working in the command's turn when the command ends", () => { - const { begin, frame, rows } = harness() - begin('compact-input') - frame({ - type: 'system', - subtype: 'task_started', - task_id: 'task-1', - task_type: 'local_agent', - description: 'Map the lane' - }) - frame({ type: 'result', subtype: 'success', is_error: false }) - const group = rows.get( - agentJournalItemKey({ - provider: 'orca', - clientMessageId: `claude-subagents:${CAPTURED_COMPACT_SESSION_ID}:${RUNNING.turnId}` - }) - ) - const agents = - group?.body.kind === 'message' - ? group.body.blocks.flatMap((block) => - block.type === 'subagent-group' ? block.agents : [] - ) - : [] - expect(agents).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - it("records the context window the command's result reports on the command's turn", () => { const { begin, frame, commandTurn } = harness() begin('compact-input') diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index bc7cb347ca0..7dbade47848 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -30,7 +30,7 @@ import type { createClaudeStreamedBlockRegistry } from './claude-streamed-block- import type { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' import type { ClaudeStreamedThinking } from './claude-streamed-thinking' import type { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections' -import type { ClaudeSubagentRoster } from './claude-subagent-roster' +import type { ClaudeSubagentIdentities } from './claude-subagent-identities' import { claudeTurnOpenedBySendEcho, type ClaudeTurnSource } from './claude-turn-opening' import type { ClaudeOpenTurn } from './claude-open-turn' import type { ClaudeAuthenticationFailures } from './claude-authentication-failures' @@ -41,7 +41,7 @@ export type ClaudeMessageJournalContext = { streamedBlocks: ReturnType streamedText: ReturnType streamedThinking: ClaudeStreamedThinking - subagents: ClaudeSubagentRoster + subagents: ClaudeSubagentIdentities toolOrigins: ClaudeToolOriginRegistry backgroundTasks: ClaudeBackgroundTaskRows providerFallback: ClaudeProviderFrameFallback @@ -67,9 +67,6 @@ export function journalClaudeMessage( return false } let changed = false - if (envelope.parentToolUseId) { - ctx.subagents.observeChildActivity(envelope.parentToolUseId) - } const results = claudeToolResults(envelope) // Everything this envelope journals belongs to whoever produced the envelope. // A child's rows live in the parent's journal, so without this the parent's @@ -139,7 +136,6 @@ export function journalClaudeMessage( const resultIdentity = claudeToolIdentity(envelope.sessionId, result.toolUseId) const resultBody = claudeToolBody({ tool, result }) ctx.sink.appendItem(resultIdentity, resultBody, stamp(resultIdentity, resultBody)) - ctx.subagents.observeToolResult(result.toolUseId, result.failed) if ( results.length === 1 && envelope.parentToolUseId === null && diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 84b4ed44c68..9732ca2ffb3 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -24,8 +24,8 @@ import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-ope export type ClaudeOpenTurnDeps = { sink: StructuredAgentSessionEventSink - /** Settles the superseded turn's children; they get no later event of their own. */ - settleChildren: (groupKey: string | null) => void + /** The superseded turn's spawn calls get no announcement now. */ + turnSuperseded: () => void /** Ends what the ending turn left open, at the instant it ended; no later frame will. */ endOpenWork: (completedAt: number) => void /** A turn opening moves the conversation on. */ @@ -80,10 +80,6 @@ export class ClaudeOpenTurn { : AGENT_JOURNAL_THREAD_SCOPE } - get groupKey(): string | null { - return this.current ? `${this.current.sessionId}:${this.current.turnId}` : null - } - get isOpen(): boolean { return this.current !== null } @@ -112,7 +108,7 @@ export class ClaudeOpenTurn { open(turn: ClaudeCurrentTurn, observedAt: number): void { this.deps.onOpen?.() if (this.current) { - this.deps.settleChildren(this.groupKey) + this.deps.turnSuperseded() this.deps.endOpenWork(observedAt) this.publish(this.current, { state: 'interrupted', @@ -130,7 +126,7 @@ export class ClaudeOpenTurn { beginCommand(turn: ClaudeCurrentTurn): void { this.deps.onOpen?.() if (this.current) { - this.deps.settleChildren(this.groupKey) + this.deps.turnSuperseded() this.deps.endOpenWork(turn.startedAt) this.publish(this.current, { state: 'interrupted', diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index c971b2333c6..a62b09a7ccf 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -21,7 +21,6 @@ export type ClaudeResultJournalContext = Pick< | 'sink' | 'streamedBlocks' | 'streamedText' - | 'subagents' | 'providerFallback' | 'corrections' | 'turn' @@ -36,7 +35,6 @@ export function journalClaudeResult( sink, streamedBlocks, streamedText, - subagents, providerFallback, corrections, turn, @@ -65,9 +63,7 @@ export function journalClaudeResult( if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) - // The turn is over however it ended, so a foreground child still - // reported as working will never be settled by an event. - subagents.settleTurn(turn.groupKey) + corrections.abandon() context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, leftToStop)) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn diff --git a/src/main/claude/claude-structured-child-work-captures.test.ts b/src/main/claude/claude-structured-child-work-captures.test.ts index cdad53f8823..c7541bb901c 100644 --- a/src/main/claude/claude-structured-child-work-captures.test.ts +++ b/src/main/claude/claude-structured-child-work-captures.test.ts @@ -87,7 +87,8 @@ describe('Claude child work from captured frame orders', () => { }) }) - it('stops what still runs when Orca ends the session, and keeps every record', async () => { + // A close Orca proves is still loss of contact: no Stop was accepted, so no outcome is claimed. + it('leaves how they ended unknown when Orca ends the session, and keeps every record', async () => { const run = await producer(hostWithParent()) const [running] = until(MOVED_TO_BACKGROUND, 51_275) run.replay(running) @@ -99,8 +100,8 @@ describe('Claude child work from captured frame orders', () => { outcome })) ).toEqual([ - { description: 'Run 45s sleep command', membership: 'settled', outcome: 'cancelled' }, - { description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'cancelled' } + { description: 'Run 45s sleep command', membership: 'settled', outcome: 'unknown' }, + { description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'unknown' } ]) }) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index 86d1d9a7ac4..7210a654037 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -18,8 +18,8 @@ afterEach(() => vi.restoreAllMocks()) describe('Claude structured child-work producer', () => { it('delivers evidence only after the journal wrote and published the frame', async () => { const { send, records } = await producer() - send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' })) - const deliveries = send( + const spawnCall = send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' })) + const announced = send( system('task_started', { task_id: 'agent-bg', tool_use_id: 'toolu_bg', @@ -29,8 +29,8 @@ describe('Claude structured child-work producer', () => { is_backgrounded: true }) ) - const kinds = deliveries.map((delivery) => delivery.kind) - // The frame's own rows, then the parent's republished row, and only then its children. + const kinds = [...spawnCall, ...announced].map((delivery) => delivery.kind) + // The spawn call's own row, then the parent's republished row, and only then its child. expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) expect(kinds.at(-1)).toBe('evidence') @@ -195,7 +195,8 @@ describe('Claude structured child-work producer', () => { step.check?.() } await adapter.closeSession('session-1') - // Orca ended the session: what still ran is stopped with it, and every record stays. + // Orca ended the session: what still ran ends unknown, as any lost contact does, and every + // record stays. const summary = records().map((record) => ({ description: record.description, membership: record.membership, @@ -210,7 +211,7 @@ describe('Claude structured child-work producer', () => { generation: 1 }, { description: 'npm test', membership: 'settled', outcome: 'cancelled', generation: 1 }, - { description: 'Audit the build', membership: 'settled', outcome: 'cancelled', generation: 2 } + { description: 'Audit the build', membership: 'settled', outcome: 'unknown', generation: 2 } ]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 115c22f31d7..ff34648136e 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -308,34 +308,28 @@ describe('stopClaudeBackgroundTasks', () => { return { childWork, connection: { stopTask } } as unknown as ClaudeSession } - it('stops exactly the ids the host named, and ends each acknowledged one', async () => { + it('stops exactly the ids the host named, and reports each acknowledged one', async () => { const childWork = liveChildWork(['task-agent', 'task-bash']) const stopTask = vi.fn(async (_taskId: string, _options?: { timeoutMs?: number }) => {}) const session = stoppingSession(childWork, stopTask) + const acknowledge = vi.fn() await expect( - stopClaudeBackgroundTasks(session, 5_000, () => true, ['task-agent', 'task-bash']) + stopClaudeBackgroundTasks( + session, + 5_000, + () => true, + ['task-agent', 'task-bash'], + acknowledge + ) ).resolves.toEqual({ cancelled: true }) + expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-agent' }], [{ taskId: 'task-bash' }]]) expect(stopTask.mock.calls).toEqual([ ['task-agent', { timeoutMs: 5_000 }], ['task-bash', { timeoutMs: 5_000 }] ]) - expect(childWork.drain(2)).toEqual([ - { - type: 'ended', - observedAt: 2, - handle: { idKind: 'task_id', id: 'task-agent' }, - outcome: 'cancelled', - basis: 'stop-acknowledged' - }, - { - type: 'ended', - observedAt: 2, - handle: { idKind: 'task_id', id: 'task-bash' }, - outcome: 'cancelled', - basis: 'stop-acknowledged' - } - ]) + // The host settles what was acknowledged; the decoder records nothing of its own. + expect(childWork.drain(2)).toEqual([]) }) it('stops issuing requests when ownership changes between tasks', async () => { @@ -356,15 +350,16 @@ describe('stopClaudeBackgroundTasks', () => { throw new ClaudeControlRequestError('stop_task', 'Task task-live is owned by another agent') }) const session = stoppingSession(childWork, stopTask) + const acknowledge = vi.fn() await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live']) + stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live'], acknowledge) + ).resolves.toEqual({ cancelled: false }) + await expect( + stopClaudeBackgroundTasks(session, undefined, () => true, [], acknowledge) ).resolves.toEqual({ cancelled: false }) - expect(childWork.drain(2)).toEqual([]) - await expect(stopClaudeBackgroundTasks(session, undefined, () => true, [])).resolves.toEqual({ - cancelled: false - }) expect(stopTask).toHaveBeenCalledTimes(1) + expect(acknowledge).not.toHaveBeenCalled() }) it('stops the remaining tasks after one request fails, then reports the failure', async () => { @@ -376,34 +371,39 @@ describe('stopClaudeBackgroundTasks', () => { } }) const session = stoppingSession(childWork, stopTask) + const acknowledge = vi.fn() await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2']) + stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2'], acknowledge) ).rejects.toBe(lost) expect(stopTask.mock.calls.map(([taskId]) => taskId)).toEqual(['task-1', 'task-2']) - expect(childWork.drain(2)).toMatchObject([ - { type: 'ended', handle: { id: 'task-2' }, outcome: 'cancelled' } - ]) + // The failure is reported, and the stop it did not touch is still named. + expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-2' }]]) }) it('stops asking after a request times out: a CLI not answering costs one deadline, not one per task', async () => { const childWork = liveChildWork(['task-0', 'task-1', 'task-2', 'task-3', 'task-4']) // Never answered, behind the real deadline wrapper, with a short deadline. - const stopTask = vi.fn((_taskId: string, options?: { timeoutMs?: number }) => - runClaudeControl('stop_task', () => new Promise(() => {}), options?.timeoutMs) + const stopTask = vi.fn((taskId: string, options?: { timeoutMs?: number }) => + taskId === 'task-0' + ? Promise.resolve() + : runClaudeControl('stop_task', () => new Promise(() => {}), options?.timeoutMs) ) const session = stoppingSession(childWork, stopTask) + const acknowledge = vi.fn() await expect( - stopClaudeBackgroundTasks(session, 50, () => true, [ - 'task-0', - 'task-1', - 'task-2', - 'task-3', - 'task-4' - ]) + stopClaudeBackgroundTasks( + session, + 50, + () => true, + ['task-0', 'task-1', 'task-2', 'task-3', 'task-4'], + acknowledge + ) ).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError) - expect(stopTask).toHaveBeenCalledTimes(1) + expect(stopTask).toHaveBeenCalledTimes(2) + // What the CLI accepted before it went quiet is still named. + expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-0' }]]) expect(childWork.drain(2)).toEqual([]) }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 03648b4bbb6..3342b2351ac 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,4 +1,5 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' +import type { AgentChildWorkStopAcknowledgement } from '../../shared/agent-child-work-stop-targets' import type { ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' @@ -61,13 +62,15 @@ export async function cancelClaudeTurn( } } -/** Stops each task the host named. An acknowledged stop ends the task's record: the CLI answers - * success for a task it no longer knows without sending that task any frame. */ +/** Stops each task the host named, and names each the CLI accepted the stop of as it does: the host + * ends that run provisionally, since the CLI answers success for a task it no longer knows without + * sending that task any frame. */ export async function stopClaudeBackgroundTasks( session: ClaudeSession, timeoutMs: number | undefined, isCurrent: ClaudeTurnCancellationGuard, - taskIds: readonly string[] + taskIds: readonly string[], + acknowledge: (acknowledgement: AgentChildWorkStopAcknowledgement) => void = () => {} ): Promise<{ cancelled: boolean }> { let cancelled = false // A failed request for one task still leaves the others to stop; it is reported after them. A @@ -80,8 +83,8 @@ export async function stopClaudeBackgroundTasks( } try { await session.connection.stopTask(taskId, { timeoutMs }) - session.childWork.stopAcknowledged(taskId) cancelled = true + acknowledge({ taskId }) } catch (error) { if (error instanceof ClaudeControlRequestTimeoutError) { throw error @@ -97,8 +100,7 @@ export async function stopClaudeBackgroundTasks( return { cancelled } } -/** Stops the tasks while `session` is still the one the host asked about, then publishes its child - * work so the host's records follow every acknowledged stop. */ +/** Stops the tasks while `session` is still the one the host asked about. */ export async function stopCurrentClaudeBackgroundTasks(input: { sessions: ReadonlyMap session: ClaudeSession @@ -106,7 +108,7 @@ export async function stopCurrentClaudeBackgroundTasks(input: { fence: number taskIds: readonly string[] timeoutMs: number | undefined - publishChildWork: (session: ClaudeSession) => void + acknowledge?: (acknowledgement: AgentChildWorkStopAcknowledgement) => void }): Promise<{ cancelled: boolean }> { const { sessions, session, sessionId, fence } = input const acquisitionGeneration = session.acquisitionGeneration @@ -114,13 +116,13 @@ export async function stopCurrentClaudeBackgroundTasks(input: { sessions.get(sessionId) === session && session.fence === fence && session.acquisitionGeneration === acquisitionGeneration - try { - return await stopClaudeBackgroundTasks(session, input.timeoutMs, isCurrent, input.taskIds) - } finally { - if (isCurrent()) { - input.publishChildWork(session) - } - } + return stopClaudeBackgroundTasks( + session, + input.timeoutMs, + isCurrent, + input.taskIds, + input.acknowledge + ) } export async function answerClaudePrompt( diff --git a/src/main/claude/claude-structured-journal-translation-context-usage.test.ts b/src/main/claude/claude-structured-journal-translation-context-usage.test.ts index 534600c71ea..6d969eb3a0b 100644 --- a/src/main/claude/claude-structured-journal-translation-context-usage.test.ts +++ b/src/main/claude/claude-structured-journal-translation-context-usage.test.ts @@ -339,7 +339,7 @@ describe('context usage on journal rows', () => { const onOpen = vi.fn() const turn = new ClaudeOpenTurn({ sink: journal().sink, - settleChildren: () => {}, + turnSuperseded: () => {}, endOpenWork: () => {}, onOpen }) diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts index e1da571eb34..697da0d2a9d 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -3,18 +3,12 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import type { - NativeChatSubagentEntry, - NativeChatSubagentGroupBlock -} from '../../shared/native-chat-types' import type { StructuredAgentSessionAppendOptions, StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1' - /** The union's other arms carry no client message id, so reading one narrows. */ function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null { return identity.provider === 'orca' ? identity.clientMessageId : null @@ -51,27 +45,6 @@ function harness() { const run = scheduled as (() => void) | null run?.() } - const groupRows = () => - items.filter((item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID) - const agentsOf = (body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] => { - if (!body || body.kind !== 'message') { - return [] - } - const block = body.blocks.find( - (candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group' - ) - return block ? block.agents : [] - } - /** The last roster row written for one group, so a test can read a group that - * is no longer the live one. */ - const rosterIn = (groupId: string): NativeChatSubagentEntry[] => - agentsOf( - items.findLast((item) => orcaClientMessageId(item.identity) === `claude-subagents:${groupId}`) - ?.body - ) - const rosterOf = (turnUuid: string): NativeChatSubagentEntry[] => - rosterIn(`claude-session:${turnUuid}`) - const roster = (): NativeChatSubagentEntry[] => agentsOf(groupRows().at(-1)?.body) const fallbackRows = (): AgentJournalItemBody[] => items .filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:')) @@ -92,10 +65,6 @@ function harness() { return { translator, items, - groupRows, - roster, - rosterIn, - rosterOf, fallbackRows, linkageOfProse, writesOfProse, @@ -158,8 +127,8 @@ function resultFrame() { } describe('claude journal translation — subagents', () => { - it('rosters a spawned subagent and settles it on the spawn call result', () => { - const { translator, roster, fallbackRows } = harness() + it('writes no subagent rows of its own: the host saves each one from its records', () => { + const { translator, items, fallbackRows } = harness() translator.handle(userTurn('user-1')) translator.handle( systemFrame('task_started', { @@ -170,141 +139,20 @@ describe('claude journal translation — subagents', () => { description: 'Map the lane' }) ) - expect(roster()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Map the lane', state: 'working' }) - ]) - // The task frames stay status-chrome, so none of them prints an opcode row. + translator.handle(spawnResult('user-2', 'toolu_1')) + translator.handle(resultFrame()) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) + expect( + items.filter( + (item) => + item.body.kind === 'message' && + item.body.blocks.some( + (block) => block.type === 'subagent' || block.type === 'subagent-group' + ) + ) + ).toEqual([]) + // The task frames stay status chrome, so none of them prints an opcode row. expect(fallbackRows()).toEqual([]) - translator.handle(spawnResult('user-2', 'toolu_1')) - expect(roster()).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('marks a child still working at turn end unverifiable', () => { - const { translator, roster } = harness() - translator.handle(userTurn('user-1')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - description: 'Map the lane' - }) - ) - translator.handle(resultFrame()) - expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('leaves a backgrounded child running past the end of its turn', () => { - const { translator, roster } = harness() - translator.handle(userTurn('user-1')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-1', - tool_use_id: 'toolu_1', - task_type: 'local_agent', - description: 'Watch the build', - is_backgrounded: true - }) - ) - // A backgrounded spawn returns its tool result immediately; the child runs on. - translator.handle(spawnResult('user-2', 'toolu_1')) - translator.handle(resultFrame()) - expect(roster()).toEqual([expect.objectContaining({ state: 'working' })]) - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('keeps a backgrounded shell task out of the roster entirely', () => { - const { translator, groupRows } = harness() - translator.handle(userTurn('user-1')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-bash', - tool_use_id: 'toolu_bash', - task_type: 'local_bash', - description: 'sleep 20', - is_backgrounded: true - }) - ) - translator.handle(resultFrame()) - expect(groupRows()).toEqual([]) - }) - - it('shows a subagent whose release announces no task frames, from its child traffic', () => { - const { translator, roster } = harness() - translator.handle(userTurn('user-1')) - translator.handle({ - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'assistant', - uuid: 'child-1', - session_id: 'claude-session', - parent_tool_use_id: 'toolu_1', - message: { role: 'assistant', content: [{ type: 'text', text: 'looking' }] } - } - }) - expect(roster()).toEqual([ - expect.objectContaining({ id: 'toolu_1', label: 'subagent', state: 'working' }) - ]) - }) - - it('settles the turn a new turn superseded, and leaves the new one running', () => { - const { translator, rosterOf } = harness() - translator.handle(userTurn('user-1')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - description: 'First turn' - }) - ) - // A second turn starts with no result frame for the first: the first turn - // ends here, and nothing else will ever name its group again. - translator.handle(userTurn('user-2')) - translator.handle( - systemFrame('task_started', { - task_id: 'task-2', - task_type: 'local_agent', - description: 'Second turn' - }) - ) - expect(rosterOf('user-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - expect(rosterOf('user-2')).toEqual([expect.objectContaining({ state: 'working' })]) - }) - - it('does not let an unrelated turn end settle a child announced outside a turn', () => { - const { translator, rosterIn } = harness() - // No turn is live yet, so this child has no turn key to belong to. - translator.handle( - systemFrame('task_started', { - task_id: 'task-early', - task_type: 'local_agent', - description: 'Before the turn' - }) - ) - translator.handle(userTurn('user-1')) - translator.handle(resultFrame()) - expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })]) - // The outcome still lands, which a latched `unverifiable` would have lost. - translator.handle( - systemFrame('task_updated', { task_id: 'task-early', patch: { status: 'completed' } }) - ) - expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('settles a child left outside every turn when the session ends', () => { - const { translator, rosterIn } = harness() - translator.handle( - systemFrame('task_started', { - task_id: 'task-early', - task_type: 'local_agent', - description: 'Before the turn' - }) - ) - translator.handle(userTurn('user-1')) - translator.handle(resultFrame()) - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) }) }) @@ -829,19 +677,4 @@ describe('claude journal translation — which agent produced a row', () => { expect(linkageOfProse('shell output')).toMatchObject({ producerKind: 'background' }) }) - - it("leaves the spawn-group row the parent's, though a child frame triggered it", () => { - // Hazard: the group row is written from a child's frame but describes the - // PARENT's children. Stamping it as a child's would hide the roster from - // the very row that owns it. Asserted on the written row, not on a field. - const { translator, groupRows } = harness() - translator.handle(userTurn('user-1')) - translator.handle(spawnCall('assistant-1', 'toolu_1')) - translator.handle(announce('task-1', 'toolu_1')) - translator.handle(childProse('child-1', 'toolu_1', 'looking')) - - const groupRow = groupRows().at(-1) - expect(groupRow).toBeDefined() - expect(groupRow?.options?.agentId).toBeUndefined() - }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 37029917137..779f1581460 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -16,8 +16,8 @@ import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' import { ClaudeToolOriginRegistry } from './claude-tool-origin-registry' import { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections' import { createClaudeStreamedThinking } from './claude-streamed-thinking' -import { ClaudeSubagentRoster } from './claude-subagent-roster' -import { ClaudeJournaledRoster } from './claude-subagent-journaled-roster' +import { ClaudeSubagentIdentities } from './claude-subagent-identities' +import { ClaudeJournaledSubagents } from './claude-subagent-journaled-identities' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' import { @@ -63,7 +63,9 @@ export function createClaudeJournalTranslator( const streamedBlocks = createClaudeStreamedBlockRegistry() const turn = new ClaudeOpenTurn({ sink: deps.sink, - settleChildren: (groupKey) => subagents.settleTurn(groupKey), + // No announcement can come after its turn, so a correction still owed is never coming; the + // rows keep the stamp they already have. + turnSuperseded: () => corrections.abandon(), endOpenWork: (completedAt) => streamedThinking.finishOpen(completedAt), onOpen: () => context.markActivity() }) @@ -71,18 +73,12 @@ export function createClaudeJournalTranslator( const fallbackId = deps.fallbackIdPrefix ?? 'acquisition' const providerFallback = createClaudeProviderFrameFallback(deps.sink, fallbackId, turnScope) const toolOrigins = new ClaudeToolOriginRegistry() - const journaled = new ClaudeJournaledRoster(() => deps.sink.journalLinkage?.() ?? null) - const subagents = new ClaudeSubagentRoster({ - sink: deps.sink, - currentGroupKey: () => turn.groupKey, - currentTurnScope: turnScope, + const journaled = new ClaudeJournaledSubagents(() => deps.sink.journalLinkage?.() ?? null) + const subagents = new ClaudeSubagentIdentities({ isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), childOwnerRefOf: (toolUseId) => toolOrigins.childOwnerRef(toolUseId), - // A restarted provider continues the roster earlier runs journaled. - journaled, - // A settled group can receive no further announcement, so a correction - // still owed is never coming; the rows keep the stamp they already have. - onIdentitiesFinal: () => corrections.abandon() + // A restarted provider continues what earlier runs journaled. + journaled }) const childQueries = claudeChildToolQueries({ tools, toolOrigins, linkage: subagents.linkage }) const corrections = new ClaudeProvisionalRowCorrections({ @@ -104,7 +100,7 @@ export function createClaudeJournalTranslator( const backgroundTasks = new ClaudeBackgroundTaskRows({ sink: deps.sink, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), - rosteredByEarlierRun: (taskId) => journaled.groupOf(taskId) !== null, + rosteredByEarlierRun: journaled.isSubagent, // A typed task row is provider output: journaling one must open a resumed // turn, or the session shows the row while reading idle. openOutputTurn: (frame, observedAt) => @@ -188,7 +184,7 @@ export function createClaudeJournalTranslator( if (event.type === 'ended') { prompts.retryPendingCancellations() flush() - subagents.settleSession() + corrections.abandon() backgroundTasks.settleSession() // The host saw the child end, so the turn's end is observed, not lost. Whether it was a // person's Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news. @@ -272,7 +268,7 @@ export function createClaudeJournalTranslator( // after an idle belongs to a turn, and suppressing it would read as // idle while the agent works. if (claudeSessionStateEndsTurn(event.message)) { - subagents.settleTurn(turn.groupKey) + corrections.abandon() // No verdict: the CLI said the turn is over, not how it ended. turn.settle({ state: 'completed', completedAt: event.observedAt ?? Date.now() }) } @@ -317,6 +313,7 @@ export function createClaudeJournalTranslator( tools.clear() prompts.clear() streamedBlocks.clear() + corrections.abandon() subagents.dispose() backgroundTasks.dispose() toolOrigins.clear() diff --git a/src/main/claude/claude-structured-real-cli-stop-task.test.ts b/src/main/claude/claude-structured-real-cli-stop-task.test.ts new file mode 100644 index 00000000000..719e41c5027 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli-stop-task.test.ts @@ -0,0 +1,183 @@ +// A targeted Stop against the real CLI: `stop_task` on one live background agent. The CLI's +// acknowledgement is what the host settles that run on until the agent's own ending arrives. +// Runs only where a signed-in Claude CLI exists, like the rest of the real-CLI suite. + +import { randomUUID } from 'node:crypto' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + realClaudeAuthenticated, + realClaudeAvailable, + realClaudeCommand, + realClaudeLaunchHome +} from './claude-real-cli-availability-test-support' +import { claudeStartupSettled } from './claude-structured-session-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' + +const SESSION_ID = 'real-cli-stop-task' +const SETTINGS = JSON.stringify({ permissions: { allow: ['Bash(sleep:*)'] } }) + +function identity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: SESSION_ID, + workspaceId: 'real-cli-stop-task-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: claudeProviderHandle(providerSessionId, null) + } +} + +async function until(check: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (check()) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return check() +} + +/** The task frames the CLI sent about one task, in order. */ +function taskFrames(events: readonly ClaudeStructuredSessionEvent[], taskId: string) { + return events.flatMap((event) => + event.type === 'message' && + event.message.type === 'system' && + typeof event.message.subtype === 'string' && + event.message.subtype.startsWith('task_') && + event.message.task_id === taskId + ? [event.message] + : [] + ) +} + +describe.skipIf(!realClaudeAvailable)('Claude structured real CLI targeted stop', () => { + it.skipIf(!realClaudeAuthenticated)( + 'acknowledges stop_task on one live background agent', + async () => { + const providerSessionId = randomUUID() + const { claudeConfigDir, env } = realClaudeLaunchHome() + const cwd = await mkdtemp(join(tmpdir(), 'orca-real-stop-task-')) + const events: ClaudeStructuredSessionEvent[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: realClaudeCommand, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: SETTINGS }, + sessionId: providerSessionId + }, + cwd, + env, + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1 + }) + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-stop-task', + events: sink + }) + await claudeStartupSettled(adapter, SESSION_ID) + await expect( + adapter.dispatch({ + sessionId: SESSION_ID, + clientMessageId: 'client-A', + body: { + kind: 'message', + role: 'user', + blocks: [ + { + type: 'text', + text: 'Use the Agent tool once, with run_in_background set to true and description "Sleeper", asking the subagent to run `sleep 120` with the Bash tool. Do not wait for it. Then reply exactly: LAUNCHED' + } + ] + }, + requestedAt: Date.now(), + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + + const started = () => + events.flatMap((event) => + event.type === 'message' && + event.message.type === 'system' && + event.message.subtype === 'task_started' && + event.message.task_type === 'local_agent' && + typeof event.message.task_id === 'string' + ? [event.message.task_id] + : [] + ) + expect(await until(() => started().length > 0, 90_000)).toBe(true) + const [taskId] = started() + // Give the subagent time to reach its sleep, so the stop lands on a live run. + await new Promise((resolve) => setTimeout(resolve, 5_000)) + + const acknowledged: unknown[] = [] + const outcome = await adapter.stopBackgroundTasks({ + sessionId: SESSION_ID, + fence: 1, + taskIds: [taskId], + acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement) + }) + expect(outcome).toMatchObject({ cancelled: true }) + expect(acknowledged).toEqual([{ taskId }]) + + // The CLI sends the run its own ending, and it says the run stopped. + const ended = await until( + () => + taskFrames(events, taskId).some( + (frame) => + frame.subtype === 'task_notification' || + (frame.subtype === 'task_updated' && + typeof frame.patch === 'object' && + frame.patch !== null && + 'status' in frame.patch) + ), + 30_000 + ) + expect(ended).toBe(true) + const frames = taskFrames(events, taskId).map((frame) => ({ + subtype: frame.subtype, + status: + frame.status ?? + (typeof frame.patch === 'object' && frame.patch !== null && 'status' in frame.patch + ? frame.patch.status + : undefined) + })) + console.info('[real-cli stop_task] task frames', JSON.stringify(frames)) + const endings = frames.filter((frame) => frame.status !== undefined) + expect(endings.length).toBeGreaterThan(0) + expect( + endings.every((frame) => + ['stopped', 'killed', 'cancelled'].includes(String(frame.status)) + ) + ).toBe(true) + } finally { + await adapter.closeAll() + } + }, + 180_000 + ) +}) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6f8bc901989..bbc1d6fea64 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -197,8 +197,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ...input, sessions: this.sessions, session: this.session(input.sessionId), - timeoutMs: this.deps.requestTimeoutMs, - publishChildWork: (session) => this.publishChildWork(input.sessionId, session) + timeoutMs: this.deps.requestTimeoutMs }) /** The tracker's own roster, for the tests that compare it with the host's child records. No * production code reads it: what runs, what a Stop reaches and what blocks a command are all diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 5c36899ade6..8619569ca84 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -144,8 +144,8 @@ describe('Claude published session close lifecycle', () => { expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) expect(disposeTranslator).toHaveBeenCalledOnce() - // A close Orca asked for stops the child still running, then the host hears the session end. - expect(childWork).toEqual(['live', 'ended', 'session-ended']) + // A close Orca asked for claims no outcome for the child still running: only the session ends. + expect(childWork).toEqual(['live', 'session-ended']) // Nothing of the dead child stays indexed for a retry. await expect(adapter.closeSession('session-1')).resolves.toBe(true) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 93a607c13f3..f6b4b0a8c9e 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -119,12 +119,8 @@ async function finalizeClaudePublishedSession( rootExitVerdict = cleanupError } // Queues the session's ending for the host's child records; the adapter delivers it after close. - // A proven close stopped what still ran: on POSIX the whole tree was seen gone; on Windows Claude - // left after its stdin ended, or taskkill reported its tree terminated. Any other end, like an - // exit of the session's own, leaves how it ended unknown. - if (connectionClosed === true) { - session.childWork.stopLive() - } + // Even a proven close is Orca losing contact, not a Stop the CLI accepted: what still ran ends + // with no outcome anyone reported. session.childWork.clear() session.backgroundTasks.clear() // The exit is proven, so the session ends now. Saving its resume point is bookkeeping that diff --git a/src/main/claude/claude-subagent-identities.ts b/src/main/claude/claude-subagent-identities.ts new file mode 100644 index 00000000000..a3eb74619ff --- /dev/null +++ b/src/main/claude/claude-subagent-identities.ts @@ -0,0 +1,101 @@ +// Which Claude subagent a frame belongs to, read from Claude's task frames. +// +// The host's child records decide what every subagent is doing and what its saved row says; the +// task-frame decoder is what feeds them. This only names children, so each row a child writes is +// filed under it: `task_id` is the key, and each `tool_use_id` is one run of it, since Claude +// re-announces a resumed task under a NEW tool id. An announcement that a task is not a subagent +// is remembered too, so later frames for it are never read as a subagent's. + +import { BoundedMap } from '../../shared/bounded-map' +import { ClaudeSubagentIds } from './claude-subagent-id-aliases' +import type { ClaudeJournaledSubagents } from './claude-subagent-journaled-identities' +import { + ClaudeSubagentLinkage, + type ClaudeAgentLinkageSource, + type ClaudeSubagentLinkageSource +} from './claude-subagent-linkage' +import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' + +/** Children whose current run attribution remembers; an older one's rows read as its first run. */ +const MAX_KNOWN_SUBAGENTS = 1024 + +type KnownSubagent = { run: string | null; attempt: number } + +export type ClaudeSubagentIdentitiesDeps = { + /** Whether a tool id was forwarded at the TOP level. A child parented to one + * was spawned by a call the transcript shows, so its announcement is still + * expected; a child parented to anything else names an id that only ever + * existed inside a sidechain, which this CLI will never announce. */ + isForwardedParentTool?: (toolUseId: string) => boolean + /** The reference naming the child that journaled a tool call, when a child + * did. It is how a grandchild's row reaches the agent that spawned it. */ + childOwnerRefOf?: (toolUseId: string) => string | null + /** What earlier provider runs of this session journaled. */ + journaled?: ClaudeJournaledSubagents +} + +export class ClaudeSubagentIdentities { + private readonly ids: ClaudeSubagentIds + private readonly known = new BoundedMap({ + maxEntries: MAX_KNOWN_SUBAGENTS + }) + /** Who produced a row, for every write site journaling this session. */ + readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource + + constructor(private readonly deps: ClaudeSubagentIdentitiesDeps) { + this.ids = new ClaudeSubagentIds(deps.journaled?.canonical) + this.linkage = new ClaudeSubagentLinkage({ + ids: this.ids, + trackedFor: (canonicalId) => { + const attempt = + this.known.get(canonicalId)?.attempt ?? deps.journaled?.attempt(canonicalId) ?? 0 + return attempt > 0 ? { attempt } : null + }, + isForwardedParentTool: deps.isForwardedParentTool, + childOwnerRefOf: deps.childOwnerRefOf, + spawnRefOf: (canonicalId) => this.known.get(canonicalId)?.run ?? null + }) + } + + /** Consume a `message:system:task_*` frame. Returns false when it is not one. */ + observeSystemFrame(message: Record): boolean { + const frame = readClaudeSubagentTaskFrame(message) + if (!frame) { + return false + } + if (frame.excluded) { + for (const id of [frame.taskId, frame.toolUseId]) { + if (id !== null) { + this.ids.exclude(id) + } + } + return true + } + if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) { + return true + } + if (frame.toolUseId) { + this.ids.alias(frame.toolUseId, frame.taskId) + } + if (frame.announcesSubagent) { + this.announce(frame.taskId, frame.toolUseId) + } + return true + } + + dispose(): void { + this.ids.clear() + this.known.clear() + } + + /** A new spawn call is the child's next run; one an earlier provider process ran it under was + * that process's, so this run follows the last one its rows record. */ + private announce(taskId: string, run: string | null): void { + const known = this.known.get(taskId) + if (known && (run === null || run === known.run)) { + return + } + const previous = known?.attempt ?? this.deps.journaled?.attempt(taskId) ?? 0 + this.known.set(taskId, { run, attempt: previous + 1 }) + } +} diff --git a/src/main/claude/claude-subagent-journaled-identities.ts b/src/main/claude/claude-subagent-journaled-identities.ts new file mode 100644 index 00000000000..126beaf43e4 --- /dev/null +++ b/src/main/claude/claude-subagent-journaled-identities.ts @@ -0,0 +1,86 @@ +// What earlier runs of this session recorded about its Claude subagents, re-derived from the rows +// they journaled: the task id each spawn call resolved to (a resumed child's frames still carry the +// old call), the latest run of each child, and which task ids were subagents at all. + +import type { AgentJournalProducerLinkage } from '../../shared/agent-session-journal-types' +import { namedSubagents } from '../../shared/native-chat-subagent-items' +import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { isBoundedClaudeTaskId } from './claude-background-task-tracker' + +type Reading = { + canonicalByToolUse: Map + attemptByAgent: Map + subagents: Set +} + +/** Read once per bound journal epoch. Before the journal is bound nothing is read and nothing is + * kept, so the first read after bind still sees every row. */ +export class ClaudeJournaledSubagents { + private journal: StructuredAgentSessionLinkageJournal | null = null + private epoch: string | null = null + private reading: Reading | null = null + + constructor(private readonly bound: () => StructuredAgentSessionLinkageJournal | null) {} + + /** The task id an earlier run resolved this spawn call to. */ + canonical = (toolUseId: string): string | null => + this.current()?.canonicalByToolUse.get(toolUseId) ?? null + + /** The latest run any row records for this child; 0 when no row names it. */ + attempt = (agentId: string): number => this.current()?.attemptByAgent.get(agentId) ?? 0 + + /** An earlier run saved this task as a subagent, so it is not a background task. */ + isSubagent = (taskId: string): boolean => this.current()?.subagents.has(taskId) === true + + private current(): Reading | null { + const journal = this.bound() + if (!journal) { + return null + } + if (!this.reading || journal !== this.journal || journal.epoch !== this.epoch) { + this.journal = journal + this.epoch = journal.epoch + this.reading = read(journal) + } + return this.reading + } +} + +function read(journal: StructuredAgentSessionLinkageJournal): Reading { + const reading: Reading = { + canonicalByToolUse: new Map(), + attemptByAgent: new Map(), + subagents: new Set() + } + journal.visitItemsWithLinkage((_itemId, _sequence, body, linkage) => { + readAgentRow(reading, linkage) + if (body.kind === 'message' && linkage.agentId === undefined) { + for (const entry of body.blocks.flatMap(namedSubagents)) { + reading.subagents.add(entry.id) + } + } + }) + return reading +} + +function readAgentRow( + reading: Reading, + { agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage +): void { + if (producerKind !== 'agent' || agentId === undefined) { + return + } + reading.attemptByAgent.set( + agentId, + Math.max(attempt ?? 1, reading.attemptByAgent.get(agentId) ?? 1) + ) + // A row stamped with its own reference was never resolved, so it names no alias. + if ( + providerParentRef !== undefined && + agentId !== providerParentRef && + isBoundedClaudeTaskId(agentId) && + isBoundedClaudeTaskId(providerParentRef) + ) { + reading.canonicalByToolUse.set(providerParentRef, agentId) + } +} diff --git a/src/main/claude/claude-subagent-journaled-roster.test.ts b/src/main/claude/claude-subagent-journaled-roster.test.ts deleted file mode 100644 index b6f806fbe89..00000000000 --- a/src/main/claude/claude-subagent-journaled-roster.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemBody, - AgentJournalProducerLinkage, - AgentJournalTurnScope -} from '../../shared/agent-session-journal-types' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' -import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' -import { - ClaudeJournaledRoster, - claudeSubagentGroupIdentity -} from './claude-subagent-journaled-roster' - -type Row = { - itemId: string - sequence: number - body: AgentJournalItemBody - linkage: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } -} - -const prose: AgentJournalItemBody = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'working on it' }] -} - -const agentRow = (sequence: number, linkage: AgentJournalProducerLinkage): Row => ({ - itemId: `claude:claude-session:row-${sequence}`, - sequence, - body: prose, - linkage -}) - -const groupRow = ( - sequence: number, - groupId: string, - agents: NativeChatSubagentEntry[], - itemId = agentJournalItemKey(claudeSubagentGroupIdentity(groupId)), - turnScope?: AgentJournalTurnScope -): Row => ({ - itemId, - sequence, - body: subagentGroupJournalBody(groupId, agents), - linkage: turnScope ? { turnScope } : {} -}) - -const entry = (id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry => ({ - id, - label: `Child ${id}`, - state, - startedAt: 1 -}) - -function linkageJournal( - epoch: string, - rows: () => Row[] -): { - epoch: string - visitItemsWithLinkage: StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'] -} { - return { - epoch, - visitItemsWithLinkage: (visit) => { - for (const row of rows()) { - visit(row.itemId, row.sequence, row.body, row.linkage) - } - } - } -} - -describe('ClaudeJournaledRoster', () => { - it('recalls only an agent row that resolved its reference to another id', () => { - const rows = [ - agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), - // Stamped with its own reference: never resolved, so it names no alias. - agentRow(2, { agentId: 'toolu_raw', providerParentRef: 'toolu_raw', producerKind: 'agent' }), - // A backgrounded shell is not a subagent and never outlives its process. - agentRow(3, { - agentId: 'shell-1', - providerParentRef: 'toolu_shell', - producerKind: 'background' - }) - ] - const journal = linkageJournal('epoch-1', () => rows) - const journaled = new ClaudeJournaledRoster(() => journal) - expect(journaled.canonical('toolu_a')).toBe('task-a') - expect(journaled.canonical('toolu_raw')).toBeNull() - expect(journaled.canonical('toolu_shell')).toBeNull() - }) - - it('reads the latest run any row of a child records', () => { - const rows = [ - agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), - agentRow(2, { - agentId: 'task-a', - providerParentRef: 'toolu_a', - producerKind: 'agent', - attempt: 3 - }), - agentRow(3, { - agentId: 'task-a', - providerParentRef: 'toolu_a', - producerKind: 'agent', - attempt: 2 - }) - ] - const journal = linkageJournal('epoch-1', () => rows) - const journaled = new ClaudeJournaledRoster(() => journal) - expect(journaled.attempt('task-a')).toBe(3) - expect(journaled.attempt('task-unknown')).toBe(1) - }) - - it('hands an earlier run’s group over once, and places a child in the newest row listing it', () => { - const turnA: AgentJournalTurnScope = { - kind: 'turn', - turnItemId: 'claude:claude-session:turn-a' - } - const rows = [ - groupRow( - 10, - 'turn-a', - [entry('task-a', 'failed'), entry('task-b', 'failed')], - undefined, - turnA - ), - groupRow(20, 'turn-b', [entry('task-a', 'working')]), - // Another lane's group block under a key this roster never writes. - groupRow(30, 'turn-c', [entry('task-c', 'working')], 'orca:elsewhere') - ] - const journal = linkageJournal('epoch-1', () => rows) - const journaled = new ClaudeJournaledRoster(() => journal) - expect(journaled.groupOf('task-a')).toBe('turn-b') - expect(journaled.groupOf('task-b')).toBe('turn-a') - expect(journaled.groupOf('task-c')).toBeNull() - const claimed = journaled.claimGroup('turn-a') - expect(claimed?.entries.map((agent) => agent.id)).toEqual(['task-a', 'task-b']) - // The row keeps the turn it was created beside. - expect(claimed?.placement).toEqual(turnA) - // After that the roster's own copy is the newer one. - expect(journaled.claimGroup('turn-a')).toBeNull() - }) - - it('reads nothing before the journal is bound, and does not keep that answer', () => { - let bound: StructuredAgentSessionLinkageJournal | null = null - const journaled = new ClaudeJournaledRoster(() => bound) - expect(journaled.canonical('toolu_a')).toBeNull() - expect(journaled.claimGroup('turn-a')).toBeNull() - bound = linkageJournal('epoch-1', () => [ - agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }), - groupRow(2, 'turn-a', [entry('task-a', 'failed')]) - ]) - expect(journaled.canonical('toolu_a')).toBe('task-a') - expect(journaled.claimGroup('turn-a')?.entries).toHaveLength(1) - }) - - it('re-reads the same journal once its epoch is replaced', () => { - let rows = [ - agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }) - ] - const journal = linkageJournal('epoch-1', () => rows) - const journaled = new ClaudeJournaledRoster(() => journal) - expect(journaled.canonical('toolu_a')).toBe('task-a') - rows = [agentRow(1, { agentId: 'task-b', providerParentRef: 'toolu_b', producerKind: 'agent' })] - journal.epoch = 'epoch-2' - expect(journaled.canonical('toolu_a')).toBeNull() - expect(journaled.canonical('toolu_b')).toBe('task-b') - }) -}) diff --git a/src/main/claude/claude-subagent-journaled-roster.ts b/src/main/claude/claude-subagent-journaled-roster.ts deleted file mode 100644 index 3d5d5df6b4f..00000000000 --- a/src/main/claude/claude-subagent-journaled-roster.ts +++ /dev/null @@ -1,75 +0,0 @@ -// What earlier runs of this session left of the Claude subagent roster, re-derived -// from the rows they journaled. -// -// Beyond the group rows every agent's tracker inherits, a Claude run needs two more -// facts earlier runs recorded on their child rows: the canonical task id each spawn -// call resolved to (a resumed child's frames still carry the old call), and the -// latest run of each child. Both come from the same read of the journal. - -import type { - AgentJournalItemIdentity, - AgentJournalProducerLinkage, - AgentJournalTurnScope -} from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { JournaledSubagentGroups } from '../native-chat/subagent-tracker/journaled-subagent-groups' -import type { JournaledSubagentSource } from '../native-chat/subagent-tracker/subagent-tracker-types' -import { isBoundedClaudeTaskId } from './claude-background-task-tracker' - -/** Durable journal identity for a group's row — stable across revisions and - * across a restart, so replay finds the same row instead of appending a new one. */ -export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` } -} - -/** What the roster asks of earlier runs. */ -export type ClaudeJournaledRosterSource = JournaledSubagentSource & { - /** The task id an earlier run resolved this spawn call to. */ - canonical: (toolUseId: string) => string | null -} - -type ClaudeAgentRows = { - canonicalByToolUse: Map - attemptByAgent: Map -} - -export class ClaudeJournaledRoster implements ClaudeJournaledRosterSource { - private readonly rows: JournaledSubagentGroups - - constructor(bound: () => StructuredAgentSessionLinkageJournal | null) { - this.rows = new JournaledSubagentGroups(bound, claudeSubagentGroupIdentity, { - create: () => ({ canonicalByToolUse: new Map(), attemptByAgent: new Map() }), - visit: readAgentRow - }) - } - - canonical = (toolUseId: string): string | null => - this.rows.extra()?.canonicalByToolUse.get(toolUseId) ?? null - - groupOf = (entryId: string): string | null => this.rows.groupOf(entryId) - - claimGroup = (groupId: string) => this.rows.claimGroup(groupId) - - attempt = (agentId: string): number => this.rows.extra()?.attemptByAgent.get(agentId) ?? 1 -} - -function readAgentRow( - reading: ClaudeAgentRows, - { agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage -): void { - if (producerKind !== 'agent' || agentId === undefined) { - return - } - if (attempt !== undefined && attempt > (reading.attemptByAgent.get(agentId) ?? 1)) { - reading.attemptByAgent.set(agentId, attempt) - } - // A row stamped with its own reference was never resolved, so it names no alias. - if ( - providerParentRef !== undefined && - agentId !== providerParentRef && - isBoundedClaudeTaskId(agentId) && - isBoundedClaudeTaskId(providerParentRef) - ) { - reading.canonicalByToolUse.set(providerParentRef, agentId) - } -} diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index 1a02e576984..e8bd07bc2a3 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -2,21 +2,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' -import { - isBackgroundTaskBlock, - isSubagentGroupBlock, - type NativeChatSubagentEntry -} from '../../shared/native-chat-types' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' -import { claudeSubagentGroupIdentity } from './claude-subagent-journaled-roster' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -89,30 +79,6 @@ const taskFailed = (taskId: string, toolUseId: string) => status: 'failed' }) -const taskCompleted = (taskId: string, toolUseId: string) => - frame({ - type: 'system', - subtype: 'task_notification', - uuid: `completed-${taskId}`, - task_id: taskId, - tool_use_id: toolUseId, - status: 'completed' - }) - -/** A call a CHILD makes: its frame names the child's own spawn call as parent. */ -const childToolCall = ( - uuid: string, - parentToolUseId: string, - id: string, - input: Record -) => - frame({ - type: 'assistant', - uuid, - parent_tool_use_id: parentToolUseId, - message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Agent', input }] } - }) - const childSays = (uuid: string, parentToolUseId: string, text: string) => frame({ type: 'assistant', @@ -164,70 +130,6 @@ const proseRow = (journal: AgentSessionJournal, text: string): AgentJournalRende item.body.blocks.some((block) => block.type === 'text' && block.text === text) ) -type GroupRow = { groupId: string; revision: number; agents: NativeChatSubagentEntry[] } - -/** Every spawn-group row, as a reader sees it now. */ -const groupRows = (journal: AgentSessionJournal): GroupRow[] => - journal.snapshot().items.flatMap((item) => { - const block = item.body.kind === 'message' ? item.body.blocks.find(isSubagentGroupBlock) : null - return block ? [{ groupId: block.groupId, revision: item.revision, agents: block.agents }] : [] - }) - -const rowsListing = (journal: AgentSessionJournal, agentId: string): GroupRow[] => - groupRows(journal).filter((row) => row.agents.some((agent) => agent.id === agentId)) - -/** Every task a background-task row names. */ -const backgroundTaskIds = (journal: AgentSessionJournal): string[] => - journal - .snapshot() - .items.flatMap((item) => - item.body.kind === 'message' - ? item.body.blocks.flatMap((block) => (isBackgroundTaskBlock(block) ? [block.taskId] : [])) - : [] - ) - -/** Every id any spawn-group row names: what a transcript can put a name on. */ -const rosterIds = (journal: AgentSessionJournal): Set => - new Set(groupRows(journal).flatMap((row) => row.agents.map((agent) => agent.id))) - -/** An older build re-rostered a resumed child in the later turn's row, so two rows list it. */ -async function journalAnOlderBuildListedTwice(): Promise { - const journal = await openJournal() - const older = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - older.bind({ journal, fence: 1, publish: () => {} }) - const listed = (id: string, state: NativeChatSubagentEntry['state']) => ({ - id, - label: `Child ${id}`, - state, - startedAt: 1, - settledAt: 2 - }) - for (const [groupId, agents] of [ - [ - 'claude-session:turn-a', - [listed('agent-a', 'unverifiable'), listed('agent-b', 'unverifiable')] - ], - ['claude-session:turn-b', [listed('agent-a', 'completed')]] - ] as const) { - older.sink.appendItem( - claudeSubagentGroupIdentity(groupId), - subagentGroupJournalBody(groupId, agents), - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - await expect(older.drained()).resolves.toEqual({ ok: true }) - older.close() - return journal -} - -const agentAStateByRow = (journal: AgentSessionJournal): Record => - Object.fromEntries( - rowsListing(journal, 'agent-a').map((row) => [ - row.groupId, - row.agents.find((agent) => agent.id === 'agent-a')?.state - ]) - ) - describe('a Claude subagent resumed after its provider restarted', () => { it('keeps the canonical id its first run was given, so one child stays one child', async () => { const journal = await openJournal() @@ -284,234 +186,13 @@ describe('a Claude subagent resumed after its provider restarted', () => { agentId: 'agent-b', providerParentRef: 'toolu_spawn_b' }) - // The resume revised the child's own entry, as it would have without a restart: one row names - // it, and its rows say which run of it they are. - expect(rowsListing(journal, 'agent-a')).toEqual([ - expect.objectContaining({ - groupId: 'claude-session:turn-a', - agents: [ - expect.objectContaining({ id: 'agent-a', state: 'working' }), - expect.objectContaining({ id: 'agent-b', state: 'working' }) - ] - }) - ]) + // Its rows say which run of it they are. expect(proseRow(journal, 'A reads the rig recipes')?.attempt).toBeUndefined() expect(proseRow(journal, 'A restarts the receiver')?.attempt).toBe(2) expect(proseRow(journal, 'B checks the rig')?.attempt).toBe(2) - // What a reader sees: every child row names an agent a roster can name. - const named = rosterIds(journal) - const childRows = journal.snapshot().items.filter((item) => item.agentId !== undefined) - expect(childRows.length).toBeGreaterThan(0) - expect(childRows.filter((item) => !named.has(item.agentId ?? ''))).toEqual([]) await second.exit() }) - it('keeps the earlier run’s children in the roster row no turn owns', async () => { - // A backgrounded child spawns its own child after the parent's turn has ended, so the - // grandchild is rostered outside any turn; the next provider run does the same again. - const journal = await openJournal() - const first = acquire(journal) - first.translator.handle(userTurn('turn-a')) - first.translator.handle( - toolCall('spawn-c', 'toolu_spawn_c', 'Agent', { - description: 'Readiness', - run_in_background: true - }) - ) - first.translator.handle(taskStarted('agent-c', 'toolu_spawn_c', 'Readiness')) - first.translator.handle(toolResult('spawned-c', 'toolu_spawn_c', 'Async agent launched.')) - first.translator.handle(result('result-a')) - first.translator.handle( - childToolCall('c-spawn', 'toolu_spawn_c', 'toolu_spawn_x', { description: 'Sweep leases' }) - ) - first.translator.handle(taskStarted('agent-x', 'toolu_spawn_x', 'Sweep leases')) - first.translator.handle(childSays('x-1', 'toolu_spawn_x', 'X reads the hydration')) - first.translator.handle(taskCompleted('agent-x', 'toolu_spawn_x')) - first.translator.handle(taskCompleted('agent-c', 'toolu_spawn_c')) - await first.exit() - expect(rowsListing(journal, 'agent-x')).toEqual([ - expect.objectContaining({ groupId: 'outside-turn' }) - ]) - - const second = acquire(journal) - second.translator.handle(userTurn('turn-b')) - second.translator.handle( - toolCall('spawn-d', 'toolu_spawn_d', 'Agent', { - description: 'Checklist', - run_in_background: true - }) - ) - second.translator.handle(taskStarted('agent-d', 'toolu_spawn_d', 'Checklist')) - second.translator.handle(toolResult('spawned-d', 'toolu_spawn_d', 'Async agent launched.')) - second.translator.handle(result('result-b')) - second.translator.handle( - childToolCall('d-spawn', 'toolu_spawn_d', 'toolu_spawn_y', { description: 'Run checklist' }) - ) - second.translator.handle(taskStarted('agent-y', 'toolu_spawn_y', 'Run checklist')) - await second.settle() - - const outside = groupRows(journal).filter((row) => row.groupId === 'outside-turn') - expect(outside).toEqual([ - expect.objectContaining({ - agents: [ - expect.objectContaining({ id: 'agent-x', label: 'Sweep leases', state: 'completed' }), - expect.objectContaining({ id: 'agent-y', label: 'Run checklist', state: 'working' }) - ] - }) - ]) - expect(proseRow(journal, 'X reads the hydration')?.agentId).toBe('agent-x') - expect(rosterIds(journal).has('agent-x')).toBe(true) - await second.exit() - }) - - it('takes Claude’s verdict on a child the earlier run lost contact with, and reopens it on an announcement', async () => { - // The provider exited while its backgrounded child still ran, so the roster said contact was - // lost. The next run is told the child did not finish, works on its own, then messages it. - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(1_000_000) - const journal = await openJournal() - const first = acquire(journal) - first.translator.handle(userTurn('turn-a')) - first.translator.handle( - toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { - description: 'Grok PR', - run_in_background: true - }) - ) - first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Grok PR')) - first.translator.handle(toolResult('spawned-a', 'toolu_spawn_a', 'Async agent launched.')) - first.translator.handle(childSays('a-1', 'toolu_spawn_a', 'A reads the docs')) - first.translator.handle(result('result-a')) - await first.exit() - const [lost] = rowsListing(journal, 'agent-a') - expect(lost?.agents).toEqual([ - expect.objectContaining({ id: 'agent-a', state: 'unverifiable' }) - ]) - const lostAt = lost?.agents[0]?.settledAt - expect(lostAt).toBe(1_000_000) - - // The restart comes much later: that is not when the child stopped. - vi.setSystemTime(5_000_000) - const second = acquire(journal) - second.translator.handle(userTurn('turn-b')) - second.translator.handle( - frame({ - type: 'system', - subtype: 'task_notification', - uuid: 'unfinished-a', - task_id: 'agent-a', - status: 'stopped', - summary: 'Background agent "Grok PR" didn\'t finish before the previous session ended' - }) - ) - second.translator.handle(toolCall('bash-1', 'toolu_bash_1', 'Bash', { command: 'git status' })) - second.translator.handle(toolResult('bashed-1', 'toolu_bash_1', 'clean')) - await second.settle() - // The child's own row takes the verdict, keeping when that run lost it; no second row names it. - expect(rowsListing(journal, 'agent-a')).toEqual([ - expect.objectContaining({ - groupId: 'claude-session:turn-a', - agents: [expect.objectContaining({ id: 'agent-a', state: 'stopped', settledAt: lostAt })] - }) - ]) - expect(backgroundTaskIds(journal)).toEqual([]) - - // The resume is a new run: its clock starts now, not at the first run's start. - vi.setSystemTime(5_100_000) - second.translator.handle( - toolCall('resume-a', 'toolu_message_a', 'SendMessage', { to: 'agent-a', message: 'resume' }) - ) - second.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Grok PR')) - second.translator.handle(childSays('a-2', 'toolu_spawn_a', 'A resumes the capture')) - await second.settle() - expect(rowsListing(journal, 'agent-a')).toEqual([ - expect.objectContaining({ - groupId: 'claude-session:turn-a', - agents: [expect.objectContaining({ id: 'agent-a', state: 'working', startedAt: 5_100_000 })] - }) - ]) - expect(rowsListing(journal, 'agent-a')[0]?.agents[0]?.settledAt).toBeUndefined() - expect(proseRow(journal, 'A resumes the capture')).toMatchObject({ - agentId: 'agent-a', - attempt: 2 - }) - await second.exit() - }) - - it('invents no stop time for a child whose host died without sweeping it', async () => { - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(1_000_000) - const crashed = await openJournal() - const first = acquire(crashed) - first.translator.handle(userTurn('turn-a')) - first.translator.handle( - toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { - description: 'Grok PR', - run_in_background: true - }) - ) - first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Grok PR')) - await first.settle() - // The host dies: nothing sweeps the child, and reopening the journal can say only that - // contact was lost, not when. - await crashed.close() - const journal = await openJournal() - const [lost] = rowsListing(journal, 'agent-a')[0]?.agents ?? [] - expect(lost).toMatchObject({ id: 'agent-a', state: 'unverifiable' }) - expect(lost?.settledAt).toBeUndefined() - - vi.setSystemTime(5_000_000) - const second = acquire(journal) - second.translator.handle(userTurn('turn-b')) - second.translator.handle( - frame({ - type: 'system', - subtype: 'task_notification', - uuid: 'unfinished-a', - task_id: 'agent-a', - status: 'stopped' - }) - ) - await second.settle() - const [stopped] = rowsListing(journal, 'agent-a')[0]?.agents ?? [] - expect(stopped).toMatchObject({ id: 'agent-a', state: 'stopped' }) - expect(stopped?.settledAt).toBeUndefined() - await second.exit() - }) - - it('keeps one live entry for a child an older build listed in two rows', async () => { - const journal = await journalAnOlderBuildListedTwice() - const run = acquire(journal) - run.translator.handle(userTurn('turn-c')) - run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a')) - // A frame for a sibling only the other row lists brings that row in too. - run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b')) - run.translator.handle(taskCompleted('agent-a', 'toolu_message_a')) - await run.settle() - - // The copy the resume reopened is the one its outcome lands on; nothing is left running. - expect(agentAStateByRow(journal)).toEqual({ - 'claude-session:turn-a': 'unverifiable', - 'claude-session:turn-b': 'completed' - }) - await run.exit() - }) - - it('resumes a twice-listed child in its later row even when a sibling reaches the older row first', async () => { - const journal = await journalAnOlderBuildListedTwice() - const run = acquire(journal) - run.translator.handle(userTurn('turn-c')) - run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b')) - run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a')) - await run.settle() - - expect(agentAStateByRow(journal)).toEqual({ - 'claude-session:turn-a': 'unverifiable', - 'claude-session:turn-b': 'working' - }) - await run.exit() - }) - it('still files a child no journaled row resolved under its own reference, never the parent', async () => { const journal = await openJournal() const first = acquire(journal) diff --git a/src/main/claude/claude-subagent-roster-characterization.test.ts b/src/main/claude/claude-subagent-roster-characterization.test.ts deleted file mode 100644 index d48ca7f1ae4..00000000000 --- a/src/main/claude/claude-subagent-roster-characterization.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Golden row writes for the Claude subagent roster: every append, tombstone and publish it makes -// for scripted frame sequences, so moving the roster onto shared code cannot change a saved byte. - -import { describe, expect, it } from 'vitest' -import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' -import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support' -import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' -import { ClaudeSubagentRoster } from './claude-subagent-roster' - -/** The clock moves once per delivered frame, never per read, so a row's times do not depend on how - * often the roster happens to read it. */ -function harness(journaled?: ClaudeJournaledRosterSource) { - const recorder = recordingSubagentRowSink() - let clock = 1_000 - let key: string | null = 's:turn-1' - const scope = (): AgentJournalTurnScope => - key === null ? { kind: 'thread' } : { kind: 'turn', turnItemId: `turn-row:${key}` } - const inner = new ClaudeSubagentRoster({ - sink: recorder.sink, - currentGroupKey: () => key, - currentTurnScope: scope, - ...(journaled ? { journaled } : {}), - now: () => clock - }) - const tick = (deliver: () => T): T => { - clock += 10 - return deliver() - } - const roster = { - observeSystemFrame: (message: Record) => - tick(() => inner.observeSystemFrame(message)), - observeChildActivity: (toolUseId: string) => tick(() => inner.observeChildActivity(toolUseId)), - observeToolResult: (toolUseId: string, failed: boolean) => - tick(() => inner.observeToolResult(toolUseId, failed)), - settleTurn: (groupKey: string | null) => tick(() => inner.settleTurn(groupKey)), - settleSession: () => tick(() => inner.settleSession()), - dispose: () => tick(() => inner.dispose()) - } - return { - ...recorder, - roster, - turn: (next: string | null) => { - key = next - } - } -} - -function frame(subtype: string, fields: Record): Record { - return { type: 'system', subtype, session_id: 's', ...fields } -} - -const started = (fields: Record) => - frame('task_started', { task_type: 'local_agent', ...fields }) -const notified = (fields: Record) => frame('task_notification', fields) - -describe('Claude subagent roster rows (characterization)', () => { - it('one foreground child settled by its spawn call result', () => { - const { roster, log } = harness() - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' })) - roster.observeSystemFrame(frame('task_progress', { task_id: 't1', tool_use_id: 'u1' })) - roster.observeToolResult('u1', false) - roster.observeToolResult('u1', false) - roster.settleTurn('s:turn-1') - expect(log).toMatchSnapshot() - }) - - it('two children sharing a label, one backgrounded, through turn end and session end', () => { - const { roster, log } = harness() - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' })) - roster.observeSystemFrame( - started({ task_id: 't2', tool_use_id: 'u2', description: 'Audit', is_backgrounded: true }) - ) - roster.observeSystemFrame(started({ task_id: 't3', tool_use_id: 'u3', description: 'Audit 2' })) - roster.settleTurn('s:turn-1') - roster.observeSystemFrame(notified({ task_id: 't3', tool_use_id: 'u3', status: 'completed' })) - roster.settleSession() - roster.dispose() - expect(log).toMatchSnapshot() - }) - - it('verdicts after lost contact, failures and stops', () => { - const { roster, log } = harness() - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'A' })) - roster.observeSystemFrame(started({ task_id: 't2', tool_use_id: 'u2', description: 'B' })) - roster.observeSystemFrame(started({ task_id: 't3', tool_use_id: 'u3', description: 'C' })) - roster.observeSystemFrame(notified({ task_id: 't2', tool_use_id: 'u2', status: 'killed' })) - roster.observeToolResult('u3', true) - roster.settleTurn('s:turn-1') - roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'completed' })) - roster.observeSystemFrame(notified({ task_id: 't2', tool_use_id: 'u2', status: 'completed' })) - expect(log).toMatchSnapshot() - }) - - it('more than 32 settled groups, then a late frame for an evicted child', () => { - const { roster, log, turn } = harness() - for (let index = 0; index < 34; index++) { - turn(`s:turn-${index}`) - roster.observeSystemFrame( - started({ task_id: `t${index}`, tool_use_id: `u${index}`, description: `Job ${index}` }) - ) - roster.observeSystemFrame( - notified({ task_id: `t${index}`, tool_use_id: `u${index}`, status: 'completed' }) - ) - } - turn('s:turn-live') - roster.observeSystemFrame(started({ task_id: 'live', tool_use_id: 'ul', description: 'Live' })) - roster.observeSystemFrame(notified({ task_id: 't0', tool_use_id: 'u0', status: 'failed' })) - roster.observeSystemFrame(started({ task_id: 't0', tool_use_id: 'u0', description: 'Job 0' })) - expect(log.length).toMatchSnapshot() - expect(log.slice(-6)).toMatchSnapshot() - }) - - it('a provisional row from child traffic takes the announced name and canonical id', () => { - const { roster, log } = harness() - roster.observeChildActivity('u1') - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Named' })) - roster.observeChildActivity('u1') - roster.observeChildActivity('u-unknown') - roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'completed' })) - expect(log).toMatchSnapshot() - }) - - it('an announcement that is not a subagent tombstones the provisional row', () => { - const { roster, log } = harness() - roster.observeChildActivity('u1') - roster.observeSystemFrame( - frame('task_started', { task_id: 't1', tool_use_id: 'u1', task_type: 'local_bash' }) - ) - roster.observeChildActivity('u1') - expect(log).toMatchSnapshot() - }) - - it('a resume under a new tool id in a later turn reopens the original row', () => { - const { roster, log, turn } = harness() - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' })) - roster.observeToolResult('u1', false) - roster.settleTurn('s:turn-1') - turn('s:turn-2') - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1b', description: 'Audit' })) - roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'failed' })) - roster.observeToolResult('u1b', false) - roster.settleTurn('s:turn-2') - expect(log).toMatchSnapshot() - }) - - it('a child announced outside any turn is not swept by a turn end', () => { - const { roster, log, turn } = harness() - turn(null) - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Loose' })) - turn('s:turn-9') - roster.settleTurn('s:turn-9') - roster.settleTurn(null) - expect(log).toMatchSnapshot() - }) - - it('a refused append or publish is retried by the next write of that row', () => { - const { roster, log, refuseNextAppend, refuseNextPublish } = harness() - refuseNextAppend() - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'A' })) - roster.observeSystemFrame(frame('task_progress', { task_id: 't1', tool_use_id: 'u1' })) - roster.observeSystemFrame(started({ task_id: 't2', tool_use_id: 'u2', description: 'B' })) - refuseNextPublish() - roster.observeToolResult('u2', false) - roster.observeToolResult('u2', false) - roster.settleTurn('s:turn-1') - expect(log).toMatchSnapshot() - }) - - it('a later run inherits the row an earlier run journaled and revises it', () => { - const earlier: NativeChatSubagentEntry[] = [ - { id: 't1', label: 'Audit', state: 'working', startedAt: 5 }, - { id: 't2', label: 'Audit 2', state: 'completed', startedAt: 6, settledAt: 9 } - ] - let claimed = false - const journaled: ClaudeJournaledRosterSource = { - canonical: (toolUseId) => (toolUseId === 'u1' ? 't1' : null), - groupOf: (id) => (id === 't1' || id === 't2' ? 's:turn-0' : null), - claimGroup: (groupId) => { - if (groupId !== 's:turn-0' || claimed) { - return null - } - claimed = true - return { entries: earlier, placement: { kind: 'turn', turnItemId: 'turn-row:s:turn-0' } } - }, - attempt: (id) => (id === 't1' ? 2 : 1) - } - const { roster, log, turn } = harness(journaled) - turn('s:turn-3') - roster.observeSystemFrame(notified({ task_id: 't1', status: 'completed' })) - roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u9', description: 'Audit' })) - roster.observeSystemFrame(started({ task_id: 't4', tool_use_id: 'u4', description: 'Audit' })) - roster.settleSession() - expect(log).toMatchSnapshot() - }) -}) diff --git a/src/main/claude/claude-subagent-roster-retention.test.ts b/src/main/claude/claude-subagent-roster-retention.test.ts deleted file mode 100644 index 1549b2778b2..00000000000 --- a/src/main/claude/claude-subagent-roster-retention.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody, - type AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ClaudeSubagentRoster } from './claude-subagent-roster' -import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' - -function harness(journaled?: ClaudeJournaledRosterSource) { - const rows = new Map() - let group = 'oldest' - let writes = 0 - let refuseAppend = false - let refusePublish = false - const append = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { - if (identity.provider !== 'orca') { - throw new Error('unexpected roster identity') - } - rows.set(identity.clientMessageId, body) - writes++ - } - const sink: StructuredAgentSessionEventSink = { - appendItem: append, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body) => { - if (refuseAppend) { - return { accepted: false, reason: 'backpressure' } - } - append(identity, body) - return { accepted: true } - }, - tryPublish: () => - refusePublish ? { accepted: false, reason: 'backpressure' } : { accepted: true } - } - const roster = new ClaudeSubagentRoster({ - sink, - journaled, - currentGroupKey: () => group, - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - const start = (id: string, tool = id, backgrounded = false): void => { - roster.observeSystemFrame({ - type: 'system', - subtype: 'task_started', - task_type: 'local_agent', - task_id: id, - tool_use_id: tool, - description: id, - is_backgrounded: backgrounded - }) - } - const finish = (id: string, tool = id): void => { - roster.observeSystemFrame({ - type: 'system', - subtype: 'task_notification', - task_id: id, - tool_use_id: tool, - status: 'completed' - }) - } - const agents = (key: string) => { - const body = rows.get(`claude-subagents:${key}`) - return body?.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : [] - } - const churn = (count: number, settled = true): void => { - for (let index = 0; index < count; index++) { - group = `later-${index}` - start(`later-child-${index}`) - if (settled) { - finish(`later-child-${index}`) - } - } - } - return { - roster, - start, - finish, - agents, - churn, - writes: () => writes, - setGroup: (key: string) => { - group = key - }, - refuse: (append: boolean, publish: boolean) => { - refuseAppend = append - refusePublish = publish - } - } -} - -describe('Claude shared settled-only roster retention', () => { - it('keeps every child of the oldest live group past 33 later spawn groups', () => { - const h = harness() - h.start('a', 'tool-a', true) - h.start('b', 'tool-b', true) - h.roster.settleTurn('oldest') - h.churn(34) - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['working', 'working']) - h.finish('a', 'tool-a') - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'working']) - h.finish('b', 'tool-b') - expect(h.agents('oldest').map((entry) => entry.id)).toEqual(['a', 'b']) - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'completed']) - }) - - it('reaches every still-running group in the session-end sweep', () => { - const h = harness() - h.start('a', 'tool-a', true) - h.start('b') - h.churn(34, false) - h.roster.settleSession() - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable']) - expect(h.agents('later-33')[0].state).toBe('unverifiable') - }) - - it('ignores old announcements after settled eviction and alias-cache churn, but admits a new invocation', () => { - const h = harness() - h.start('a', 'first') - h.finish('a', 'first') - h.start('a', 'second') - h.finish('a', 'second') - h.churn(600) - const before = h.writes() - h.start('a', 'first') - h.start('a', 'second') - h.roster.observeSystemFrame({ - type: 'system', - subtype: 'task_started', - task_type: 'local_agent', - task_id: 'a' - }) - h.roster.observeChildActivity('first') - expect(h.writes()).toBe(before) - expect(h.roster.linkage.settledLinkageFor('first').linkage.agentId).toBe('a') - h.setGroup('resumed') - h.start('a', 'third') - expect(h.agents('resumed')).toEqual([expect.objectContaining({ id: 'a', state: 'working' })]) - }) - - it('keeps an inherited row reachable when child traffic precedes its resume announcement', () => { - let claimed = false - const h = harness({ - canonical: (tool) => (tool === 'original-spawn' ? 'a' : null), - groupOf: (id) => (['a', 'b'].includes(id) ? 'inherited' : null), - claimGroup: (group) => { - if (group !== 'inherited' || claimed) { - return null - } - claimed = true - return { - entries: ['a', 'b'].map((id) => ({ - id, - label: id, - state: 'completed', - startedAt: 1, - settledAt: 2 - })), - placement: AGENT_JOURNAL_THREAD_SCOPE - } - }, - attempt: () => 4 - }) - h.churn(32, false) - h.roster.observeChildActivity('original-spawn') - h.setGroup('resumed') - h.start('a', 'resume-tool', true) - expect(h.agents('inherited').map((entry) => entry.state)).toEqual(['working', 'completed']) - expect(h.agents('resumed')).toHaveLength(0) - expect(h.roster.linkage.settledLinkageFor('original-spawn').linkage).toMatchObject({ - agentId: 'a', - attempt: 5 - }) - }) - - for (const failure of ['append', 'publish']) { - it(`retains a terminal group whose final ${failure} was refused and retries it`, () => { - const h = harness() - h.start('a') - h.start('b') - h.refuse(failure === 'append', failure === 'publish') - h.finish('a') - h.finish('b') - h.refuse(false, false) - h.churn(34) - h.finish('b') - expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'completed']) - const before = h.writes() - h.start('a') - expect(h.writes()).toBe(before) - }) - } -}) diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts deleted file mode 100644 index 54b1db9750d..00000000000 --- a/src/main/claude/claude-subagent-roster.test.ts +++ /dev/null @@ -1,624 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import { describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import type { - NativeChatSubagentEntry, - NativeChatSubagentGroupBlock -} from '../../shared/native-chat-types' -import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { - createDeferredStructuredAgentSessionEventSink, - type StructuredAgentSessionEventSink -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ClaudeSubagentRoster } from './claude-subagent-roster' -import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' -import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' - -const TURN_1 = 'claude-session:turn-1' - -function agentsOf(body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] { - if (!body || body.kind !== 'message') { - return [] - } - const block = body.blocks.find( - (candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group' - ) - return block ? block.agents : [] -} - -function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolean { - return identity.provider === 'orca' && identity.clientMessageId === `claude-subagents:${groupId}` -} - -function harness(groupKey: string | null = TURN_1) { - const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] - const tombstones: AgentJournalItemIdentity[] = [] - const sink: StructuredAgentSessionEventSink = { - appendItem: (identity, body) => items.push({ identity, body }), - appendTombstone: (identity) => tombstones.push(identity), - publish: vi.fn() - } - let clock = 1_000 - let pinnedClock: number | null = null - let key = groupKey - const roster = new ClaudeSubagentRoster({ - sink, - currentGroupKey: () => key, - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, - now: () => pinnedClock ?? (clock += 1) - }) - const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body) - /** The last row written for one group, so a test can read a row that is no - * longer the newest one. */ - const rolesIn = (groupId: string): NativeChatSubagentEntry[] => - agentsOf(items.findLast((item) => isGroupRow(item.identity, groupId))?.body) - return { - roster, - items, - tombstones, - roles, - rolesIn, - setGroupKey: (next: string | null) => { - key = next - }, - pinClock: (at: number) => { - pinnedClock = at - } - } -} - -function system(subtype: string, fields: Record): Record { - return { type: 'system', subtype, session_id: 'claude-session', ...fields } -} - -function started(fields: Record): Record { - return system('task_started', { task_type: 'local_agent', ...fields }) -} - -describe('ClaudeSubagentRoster', () => { - it('builds the row from task_started, with the fallback sentence beside the block', () => { - const { roster, items, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Review the diff' }) - ) - expect(items).toHaveLength(1) - expect(items[0]?.identity).toEqual({ - provider: 'orca', - clientMessageId: 'claude-subagents:claude-session:turn-1' - }) - const body = items[0]?.body - expect(body?.kind === 'message' && body.blocks[0]).toEqual({ - type: 'text', - text: 'Kicked off 1 subagent' - }) - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Review the diff', state: 'working' }) - ]) - }) - - it('keeps a backgrounded shell task out of the roster', () => { - const { roster, items } = harness() - roster.observeSystemFrame( - system('task_started', { - task_id: 'task-bash', - tool_use_id: 'toolu_bash', - task_type: 'local_bash', - description: 'sleep 20', - is_backgrounded: true - }) - ) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-bash', patch: { status: 'running' } }) - ) - // Its own frames carry a tool_use_id, so only the excluded-id memory stops it. - roster.observeChildActivity('toolu_bash') - expect(items).toHaveLength(0) - }) - - it('never renders a task marked skip_transcript', () => { - const { roster, items } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-a', tool_use_id: 'toolu_a', skip_transcript: true }) - ) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } }) - ) - roster.observeChildActivity('toolu_a') - expect(items).toHaveLength(0) - }) - - it('drops a provisional row once an announcement says the task is not a subagent', () => { - const { roster, items, tombstones, roles } = harness() - roster.observeChildActivity('toolu_bash') - expect(roles()).toHaveLength(1) - roster.observeSystemFrame( - system('task_started', { - task_id: 'task-bash', - tool_use_id: 'toolu_bash', - task_type: 'local_bash' - }) - ) - expect(tombstones).toEqual([ - { provider: 'orca', clientMessageId: 'claude-subagents:claude-session:turn-1' } - ]) - expect(items).toHaveLength(1) - }) - - it('does not duplicate a resumed task re-announced under a new tool_use_id', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_first', description: 'Audit' }) - ) - roster.observeChildActivity('toolu_first') - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_second', description: 'Audit' }) - ) - roster.observeChildActivity('toolu_second') - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Audit', state: 'working' }) - ]) - }) - - it('adopts a row built from child traffic when the announcement finally names it', () => { - const { roster, roles } = harness() - roster.observeChildActivity('toolu_1') - expect(roles()).toEqual([expect.objectContaining({ id: 'toolu_1', label: 'subagent' })]) - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) - ) - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) - ]) - }) - - it('is idempotent: a repeated frame writes no new revision', () => { - const { roster, items } = harness() - const frame = started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' }) - roster.observeSystemFrame(frame) - roster.observeSystemFrame(frame) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'running' } }) - ) - expect(items).toHaveLength(1) - }) - - it('latches a terminal state against a later live report', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'failed' } }) - ) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'running' } }) - ) - expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) - }) - - it('ignores an update for a task it never rostered', () => { - const { roster, items } = harness() - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-unknown', patch: { status: 'running' } }) - ) - expect(items).toHaveLength(0) - }) - - it('disambiguates children that share a description', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Explore' })) - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Explore' })) - expect(roles().map((agent) => agent.label)).toEqual(['Explore', 'Explore 2']) - }) - - describe('turn end', () => { - it('leaves a backgrounded child working and marks a foreground one unverifiable', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-fg', description: 'Foreground' })) - roster.observeSystemFrame( - started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) - ) - roster.settleTurn(TURN_1) - expect(roles()).toEqual([ - expect.objectContaining({ label: 'Foreground', state: 'unverifiable' }), - expect.objectContaining({ label: 'Background', state: 'working' }) - ]) - }) - - it('never re-settles a child that already reported an outcome', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } }) - ) - roster.settleTurn(TURN_1) - expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('sweeps backgrounded children only when the provider itself is gone', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) - ) - roster.settleTurn(TURN_1) - roster.settleSession() - expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - }) - - describe('spawn tool result', () => { - it('settles a foreground child', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' })) - roster.observeToolResult('toolu_1', false) - expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('reports a failed spawn as failed', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' })) - roster.observeToolResult('toolu_1', true) - expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) - }) - - it('ignores the immediate result a backgrounded spawn returns', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', is_backgrounded: true }) - ) - roster.observeToolResult('toolu_1', false) - expect(roles()).toEqual([expect.objectContaining({ state: 'working' })]) - }) - - it('ignores results for tools that are not spawn calls', () => { - const { roster, items } = harness() - roster.observeToolResult('toolu_read', false) - expect(items).toHaveLength(0) - }) - }) - - describe('label ordinals', () => { - it('never re-issues an ordinal a removed row gave up', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' })) - // task-1 is re-announced as a shell task, so its row goes; reclaiming the - // ordinal it held would print a second 'Audit 2' beside the one still shown. - roster.observeSystemFrame( - system('task_started', { task_id: 'task-1', task_type: 'local_bash' }) - ) - roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit' })) - expect(roles().map((agent) => agent.label)).toEqual(['Audit 2', 'Audit 3']) - }) - - it('never generates a label a provider-supplied one already took', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' })) - // The provider's own name for the third child is the label the ordinal just - // generated for the second; a per-base counter would print it twice. - roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit 2' })) - const labels = roles().map((agent) => agent.label) - expect(labels).toEqual(['Audit', 'Audit 2', 'Audit 2 2']) - expect(new Set(labels).size).toBe(labels.length) - }) - }) - - describe('child traffic for an id the CLI never declared', () => { - it('creates nothing once the CLI has announced any task at all', () => { - const { roster, items } = harness() - // A rejected announcement still proves this CLI declares what it spawns. - roster.observeSystemFrame( - system('task_started', { task_id: 'task-bash', task_type: 'local_bash' }) - ) - roster.observeChildActivity('toolu_never_announced') - expect(items).toHaveLength(0) - }) - - it('rejects an over-long provisional id instead of storing it as an entry id', () => { - const { roster, items } = harness() - // The announced path drops an id past `claudeTaskId`'s bound; the - // provisional one writes the same durable entry id, so it must too. - roster.observeChildActivity(`toolu_${'x'.repeat(512)}`) - expect(items).toHaveLength(0) - roster.observeChildActivity(`toolu_${'x'.repeat(500)}`) - expect(items).toHaveLength(1) - }) - - it('still rosters a subagent announced after a task the filter rejected', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - system('task_started', { task_id: 'task-bash', task_type: 'local_bash' }) - ) - // The gate closes the child-traffic fallback, never the announcement path. - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) - ) - roster.observeChildActivity('toolu_1') - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) - ]) - }) - - it('leaves a grandchild parented inside the sidechain out of the roster', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) - ) - roster.observeChildActivity('toolu_1') - // A tool the subagent itself ran: never announced, so never excluded either. - roster.observeChildActivity('toolu_inner') - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) - ]) - }) - - it('still mints the provisional row for a release that announces no task', () => { - const { roster, roles } = harness() - roster.observeChildActivity('toolu_1') - // Not an announcement: the fallback path stays open for this release. - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-x', patch: { status: 'running' } }) - ) - roster.observeChildActivity('toolu_2') - expect(roles().map((agent) => agent.label)).toEqual(['subagent', 'subagent 2']) - }) - }) - - describe('groups that no later event can reach', () => { - it('retains live groups past the settled-history bound', () => { - const { roster, rolesIn, setGroupKey } = harness('turn-0') - for (let index = 0; index < 33; index += 1) { - setGroupKey(`turn-${index}`) - roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' })) - } - expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'working' })]) - expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })]) - }) - - it('loses contact with a live child when the translator is disposed without an end', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) - ) - roster.dispose() - expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('writes nothing on dispose when the session already settled', () => { - const { roster, items } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - roster.settleSession() - const written = items.length - roster.dispose() - expect(items).toHaveLength(written) - }) - }) - - it('groups children outside any turn under their own row', () => { - const { roster, items } = harness(null) - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) - expect(items[0]?.identity).toEqual({ - provider: 'orca', - clientMessageId: 'claude-subagents:outside-turn' - }) - }) -}) - -describe('ClaudeSubagentRoster — the turn that is ending', () => { - it('leaves a child announced outside any turn alone when an unrelated turn ends', () => { - const { roster, rolesIn, setGroupKey } = harness(null) - roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) - setGroupKey(TURN_1) - roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' })) - roster.settleTurn(TURN_1) - expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })]) - expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - // `unverifiable` latches, so sweeping it above would have swallowed this. - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-early', patch: { status: 'completed' } }) - ) - expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('sweeps the outside-turn group when a turn with no key of its own ends', () => { - const { roster, rolesIn } = harness(null) - roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) - roster.settleTurn(null) - expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('still settles an outside-turn child once the session itself ends', () => { - const { roster, rolesIn, setGroupKey } = harness(null) - roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) - setGroupKey(TURN_1) - roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' })) - roster.settleTurn(TURN_1) - roster.settleSession() - expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('sweeps the turn that ended, not whichever turn is live now', () => { - const { roster, rolesIn, setGroupKey } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'First turn' })) - setGroupKey('claude-session:turn-2') - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Second turn' })) - // Turn 1's result lands after turn 2 has already begun. - roster.settleTurn(TURN_1) - expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - expect(rolesIn('claude-session:turn-2')).toEqual([ - expect.objectContaining({ state: 'working' }) - ]) - }) -}) - -describe('ClaudeSubagentRoster — through the real sink queue', () => { - it('lands every revision, not just the one that was already in flight', async () => { - const appended: AgentJournalItemBody[] = [] - let published = 0 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones. - const journal = withJournalQueueMembers({ - appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - appended.push(body) - return { cursor: { epoch: 'e', sequence: appended.length } } - }, - appendTombstone: async () => ({ epoch: 'e', sequence: 0 }) - }) as unknown as AgentSessionJournal - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind({ - journal, - fence: 1, - publish: () => { - published += 1 - } - }) - const roster = new ClaudeSubagentRoster({ - sink: deferred.sink, - currentGroupKey: () => TURN_1, - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - - // The first append is in flight while the rest are submitted; each must still land. - roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' })) - roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' })) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } }) - ) - const drained = await deferred.drained() - - expect(drained).toEqual({ ok: true }) - expect(agentsOf(appended.at(-1))).toEqual([ - expect.objectContaining({ id: 'task-1', label: 'One', state: 'completed' }), - expect.objectContaining({ id: 'task-2', label: 'Two', state: 'working' }) - ]) - expect(published).toBeGreaterThan(0) - }) -}) - -describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', () => { - it('accepts a notification after the foreground turn lost contact', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1' })) - roster.settleTurn(TURN_1) - roster.observeSystemFrame( - system('task_notification', { task_id: 'task-1', status: 'completed' }) - ) - expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) - }) - - it('settles a background child from its notification without a task_updated', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', is_backgrounded: true })) - roster.settleTurn(TURN_1) - roster.observeSystemFrame(system('task_notification', { task_id: 'task-1', status: 'failed' })) - expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) - }) - - it('bounds lifetime admissions when reclassification repeatedly removes entries', () => { - const { roster, items } = harness() - for (let i = 0; i < 100; i++) { - roster.observeSystemFrame(started({ task_id: `task-${i}`, description: `Agent ${i}` })) - roster.observeSystemFrame( - system('task_started', { task_id: `task-${i}`, task_type: 'local_bash' }) - ) - } - expect(items).toHaveLength(64) - }) -}) - -describe('ClaudeSubagentRoster — resumed invocation', () => { - it('reopens one canonical child on a new announcement without replaying old results', () => { - const { roster, roles, pinClock } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - roster.observeToolResult('first', false) - // Resumed long after its first run: the new run starts its own clock. - pinClock(900_000) - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) - ) - expect(roles()).toEqual([ - expect.objectContaining({ id: 'task-1', state: 'working', startedAt: 900_000 }) - ]) - expect(roles()[0].settledAt).toBeUndefined() - expect(roster.linkage.settledLinkageFor('resumed')).toMatchObject({ - linkage: { agentId: 'task-1', attempt: 2 } - }) - roster.observeSystemFrame( - system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' }) - ) - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - expect(roles()[0].state).toBe('working') - roster.observeSystemFrame( - system('task_notification', { - task_id: 'task-1', - tool_use_id: 'resumed', - status: 'completed' - }) - ) - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) - ) - expect(roles()[0].state).toBe('completed') - }) -}) - -describe('ClaudeSubagentRoster — invocation fences', () => { - it('ignores a previous invocation tool result even without a background flag', () => { - const { roster, roles } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - roster.observeToolResult('first', false) - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'next' })) - roster.observeToolResult('first', true) - expect(roles()[0].state).toBe('working') - roster.observeToolResult('next', false) - expect(roles()[0].state).toBe('completed') - }) - - it('does not treat an evicted alias as a new invocation', () => { - const { roster, rolesIn, setGroupKey } = harness() - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - roster.observeToolResult('first', false) - setGroupKey('churn') - for (let i = 0; i < 513; i++) { - roster.observeSystemFrame( - system('task_updated', { task_id: `other-${i}`, tool_use_id: `tool-${i}` }) - ) - } - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) - expect(rolesIn(TURN_1)[0].state).toBe('completed') - }) - - it('keeps reopening a child resumed many times, and never revives a run it has forgotten', () => { - const { roster, roles } = harness() - for (let i = 0; i < 20; i++) { - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` })) - expect(roles()[0].state).toBe('working') - roster.observeToolResult(`tool-${i}`, false) - expect(roles()[0].state).toBe('completed') - } - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' })) - roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-19' })) - expect(roles()[0].state).toBe('completed') - expect(roster.linkage.settledLinkageFor('tool-19')).toMatchObject({ - linkage: { agentId: 'task-1', attempt: 20 } - }) - }) -}) - -it('merges an explicit foreground patch without clearing on absent metadata', () => { - const { roster, roles } = harness() - roster.observeSystemFrame( - started({ task_id: 'task-1', tool_use_id: 'tool', is_backgrounded: true }) - ) - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { description: 'Audit' } }) - ) - roster.observeToolResult('tool', false) - expect(roles()[0].state).toBe('working') - roster.observeSystemFrame( - system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: false } }) - ) - roster.observeToolResult('tool', false) - expect(roles()[0].state).toBe('completed') -}) diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts deleted file mode 100644 index f59347e835a..00000000000 --- a/src/main/claude/claude-subagent-roster.ts +++ /dev/null @@ -1,223 +0,0 @@ -// The Claude subagent roster: one journal row per turn that spawned children. -// -// Entries are built from `task_started`, never from child traffic: a -// BACKGROUNDED subagent emits no child frames at all, so a roster fed by -// `parent_tool_use_id` alone would leave every one of them an unlabelled row -// forever. Child traffic only creates an entry for CLI releases that announce -// no task frames. -// -// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is -// the key and each tool id is one run of it; keying on the tool id would -// duplicate the child on every resume. Everything the row says follows the -// shared subagent tracker's rules; this module only reads Claude's frames. - -import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { subagentRowSinkPort } from '../native-chat/subagent-tracker/subagent-row-sink-port' -import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker' -import type { SubagentReport } from '../native-chat/subagent-tracker/subagent-tracker-types' -import { isBoundedClaudeTaskId } from './claude-background-task-tracker' -import { ClaudeSubagentIds } from './claude-subagent-id-aliases' -import { - claudeSubagentGroupIdentity, - type ClaudeJournaledRosterSource -} from './claude-subagent-journaled-roster' -import { - ClaudeSubagentLinkage, - type ClaudeAgentLinkageSource, - type ClaudeSubagentLinkageSource -} from './claude-subagent-linkage' -import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' - -/** The turn a group belongs to when Claude reports a task outside any turn. */ -const OUTSIDE_TURN = 'outside-turn' - -export type ClaudeSubagentRosterDeps = { - sink: StructuredAgentSessionEventSink - /** The turn that owns children spawned right now; null outside any turn. */ - currentGroupKey: () => string | null - /** That turn's scope, which the roster row it spawns belongs to. */ - currentTurnScope: () => AgentJournalTurnScope - /** Whether a tool id was forwarded at the TOP level. A child parented to one - * was spawned by a call the transcript shows, so its announcement is still - * expected; a child parented to anything else names an id that only ever - * existed inside a sidechain, which this CLI will never announce. */ - isForwardedParentTool?: (toolUseId: string) => boolean - /** The reference naming the child that journaled a tool call, when a child - * did. It is how a grandchild's row reaches the agent that spawned it. */ - childOwnerRefOf?: (toolUseId: string) => string | null - /** What earlier provider runs of this session journaled of this roster. */ - journaled?: ClaudeJournaledRosterSource - /** A settled group can receive no further announcement, so an identity still - * provisional will stay that way. Fires on EVERY settle path, so a caller - * holding rows against a pending identity cannot miss one. */ - onIdentitiesFinal?: () => void - now?: () => number -} - -export class ClaudeSubagentRoster { - private readonly tracker: SubagentTracker - private readonly ids: ClaudeSubagentIds - /** Who produced a row, for every write site journaling this session. */ - readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource - /** Set by ANY `task_started`, including one the subagent filter rejects. Once - * this CLI has proven it declares its tasks, child traffic for an id it never - * announced is a nested tool or a grandchild, not a subagent. */ - private announcesTasks = false - - constructor(private readonly deps: ClaudeSubagentRosterDeps) { - this.tracker = new SubagentTracker({ - port: subagentRowSinkPort(deps.sink, claudeSubagentGroupIdentity), - outsideTurn: (groupId) => groupId === OUTSIDE_TURN, - ...(deps.journaled ? { journaled: deps.journaled } : {}), - ...(deps.now ? { now: deps.now } : {}) - }) - this.ids = new ClaudeSubagentIds(deps.journaled?.canonical) - this.linkage = new ClaudeSubagentLinkage({ - ids: this.ids, - trackedFor: (canonicalId) => - this.tracker.locate(canonicalId) ? { attempt: this.tracker.attempt(canonicalId) } : null, - isForwardedParentTool: deps.isForwardedParentTool, - childOwnerRefOf: deps.childOwnerRefOf, - spawnRefOf: (canonicalId) => this.tracker.locate(canonicalId)?.tracked.run ?? null - }) - } - - /** Consume a `message:system:task_*` frame. Returns false when it is not one. */ - observeSystemFrame(message: Record): boolean { - const frame = readClaudeSubagentTaskFrame(message) - if (!frame) { - return false - } - this.announcesTasks ||= frame.announcement - if (frame.excluded) { - // Child traffic may already have built a provisional row under the tool id; - // the announcement is the first frame that says it is not a subagent. - for (const id of [frame.taskId, frame.toolUseId]) { - if (id !== null) { - this.ids.exclude(id) - this.tracker.remove(id) - } - } - return true - } - if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) { - return true - } - if (frame.toolUseId) { - this.ids.alias(frame.toolUseId, frame.taskId) - if (!this.tracker.locate(frame.taskId)) { - // A provisional row built under the tool id takes the canonical id the announcement names. - this.tracker.rekey(frame.toolUseId, frame.taskId) - } - } - this.tracker.report({ - id: frame.taskId, - ...(frame.toolUseId ? { run: frame.toolUseId } : {}), - group: this.currentGroup(), - announces: frame.announcesSubagent, - label: frame.label, - state: frame.state, - ...(frame.backgrounded !== null ? { backgrounded: frame.backgrounded } : {}) - }) - return true - } - - /** - * A frame carrying `parent_tool_use_id` — the child's own traffic. It refreshes - * nothing on an announced child; it exists so a CLI release that sends no task - * frames still shows the subagent it is running. - */ - observeChildActivity(parentToolUseId: string): void { - const canonical = this.ids.canonical(parentToolUseId) - if ( - this.ids.isExcluded(parentToolUseId, canonical) || - this.tracker.hasSettled(canonical, parentToolUseId) - ) { - return - } - const located = this.tracker.locate(canonical) - if (located) { - // Child traffic can precede the announcement that reopens an inherited row. - if (located.tracked.inherited) { - this.tracker.retain(located.group) - } - return - } - if ( - this.tracker.hasSettled(canonical, null) || - // This CLI announces what it spawns, so an id it never declared is a nested - // Task, a workflow child, or a grandchild — not a subagent. A row invented - // for one is unlabelled forever and can only ever end `unverifiable`. - this.announcesTasks || - // A provisional id becomes the same durable entry key, so it takes the - // bound an announced id does. - !isBoundedClaudeTaskId(canonical) - ) { - return - } - this.tracker.report({ - id: canonical, - run: parentToolUseId, - group: this.currentGroup(), - announces: true, - state: 'working' - }) - } - - /** - * The parent turn's tool result for a spawn call. It settles a foreground - * child, whose result IS the turn's evidence the child finished. A backgrounded - * child's spawn call returns immediately while the child keeps running, so its - * result proves nothing and is ignored. - */ - observeToolResult(toolUseId: string, failed: boolean): void { - const canonical = this.ids.canonical(toolUseId) - const located = this.tracker.locate(canonical) - if ( - !located || - located.tracked.backgrounded || - (located.tracked.run !== null && located.tracked.run !== toolUseId) - ) { - return - } - this.tracker.report({ - id: canonical, - run: toolUseId, - group: this.currentGroup(), - announces: false, - state: failed ? 'failed' : 'completed' - }) - } - - /** The parent turn ended. A foreground child still working loses contact with - * it; a backgrounded one was told to outlive the turn. Only the group this key - * names: `OUTSIDE_TURN` belongs to no turn, so no turn's end says anything about it. */ - settleTurn(groupKey: string | null): void { - this.tracker.settleTurn(groupKey ?? OUTSIDE_TURN) - this.deps.onIdentitiesFinal?.() - } - - /** The provider is gone. Nothing more will arrive for any child, backgrounded - * or not, so every one of them loses contact at once. */ - settleSession(): void { - this.tracker.settleSession() - this.deps.onIdentitiesFinal?.() - } - - dispose(): void { - // Teardown paths reach here without an `ended` event; a session that did - // settle first leaves every child terminal, so this writes nothing. - this.settleSession() - this.tracker.dispose() - this.ids.clear() - this.announcesTasks = false - } - - private currentGroup(): SubagentReport['group'] { - return { - id: this.deps.currentGroupKey() ?? OUTSIDE_TURN, - placement: this.deps.currentTurnScope - } - } -} diff --git a/src/main/claude/claude-subagent-task-frames.test.ts b/src/main/claude/claude-subagent-task-frames.test.ts index 58950307ba5..480c59dab4d 100644 --- a/src/main/claude/claude-subagent-task-frames.test.ts +++ b/src/main/claude/claude-subagent-task-frames.test.ts @@ -27,7 +27,6 @@ describe('readClaudeSubagentTaskFrame', () => { expect(frame).toMatchObject({ taskId: 'task-1', toolUseId: 'toolu_1', - label: 'Review the diff', announcesSubagent: true, excluded: false }) @@ -75,20 +74,12 @@ describe('readClaudeSubagentTaskFrame', () => { } }) - it('caps a subagent_type label the way a description is capped', () => { - const frame = readClaudeSubagentTaskFrame( - system('task_started', { task_id: 'task-1', subagent_type: 'a'.repeat(900) }) - ) - // The roster stores this label verbatim, so nothing downstream bounds it. - expect(frame?.label).toHaveLength(512) - }) - it('falls back to subagent_type only when the release sends no task_type', () => { expect( readClaudeSubagentTaskFrame( system('task_started', { task_id: 'task-old', subagent_type: 'explorer' }) ) - ).toMatchObject({ announcesSubagent: true, label: 'explorer' }) + ).toMatchObject({ announcesSubagent: true }) expect( readClaudeSubagentTaskFrame(system('task_started', { task_id: 'task-bare' })) ).toMatchObject({ announcesSubagent: false, excluded: true }) @@ -120,96 +111,11 @@ describe('readClaudeSubagentTaskFrame', () => { }) }) - describe('status', () => { - it('collapses every in-flight status to working', () => { - for (const status of ['pending', 'running', 'paused']) { - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { task_id: 'task-1', patch: { status } }) - ) - ).toMatchObject({ state: 'working' }) - } - }) - - it('maps the settled statuses onto the carrier vocabulary', () => { - const mapped: [string, string][] = [ - ['completed', 'completed'], - ['failed', 'failed'], - ['killed', 'stopped'], - ['stopped', 'stopped'] - ] - for (const [status, state] of mapped) { - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { task_id: 'task-1', patch: { status } }) - ) - ).toMatchObject({ state }) - } - }) - - it('reports no state for a status it cannot map', () => { - for (const status of ['__proto__', 'toString', 'invented', 7, null]) { - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { task_id: 'task-1', patch: { status } }) - ) - ).toMatchObject({ state: null }) - } - }) - - it('treats progress as no lifecycle verdict', () => { - for (const subtype of ['task_progress']) { - expect( - readClaudeSubagentTaskFrame( - system(subtype, { task_id: 'task-1', status: 'completed', patch: { status: 'failed' } }) - ) - ).toMatchObject({ state: null }) - } - }) - }) - - it('reads the notification verdict from its top-level status', () => { - for (const state of ['completed', 'failed', 'stopped']) { - expect( - readClaudeSubagentTaskFrame( - system('task_notification', { - task_id: 'task-1', - status: state, - patch: { status: 'running' } - }) - ) - ).toMatchObject({ state }) - } - }) - - it('reads the backgrounded flag from the frame or its patch', () => { + it('reads the spawn call id from an update patch', () => { expect( readClaudeSubagentTaskFrame( - system('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - is_backgrounded: true - }) + system('task_updated', { task_id: 'task-1', patch: { tool_use_id: 'toolu_1' } }) ) - ).toMatchObject({ backgrounded: true }) - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } }) - ) - ).toMatchObject({ backgrounded: true }) - expect( - readClaudeSubagentTaskFrame(system('task_updated', { task_id: 'task-1', patch: {} })) - ).toMatchObject({ backgrounded: null }) - }) - - it('collapses a multi-line description into one bounded label', () => { - expect( - readClaudeSubagentTaskFrame( - system('task_updated', { - task_id: 'task-1', - patch: { description: ' audit\n the lockfile ' } - }) - ) - ).toMatchObject({ label: 'audit the lockfile' }) + ).toMatchObject({ taskId: 'task-1', toolUseId: 'toolu_1', excluded: false }) }) }) diff --git a/src/main/claude/claude-subagent-task-frames.ts b/src/main/claude/claude-subagent-task-frames.ts index 99984a21069..0182f02cf7f 100644 --- a/src/main/claude/claude-subagent-task-frames.ts +++ b/src/main/claude/claude-subagent-task-frames.ts @@ -1,4 +1,4 @@ -// Claude's declarative task protocol, read as subagent roster events. +// Claude's declarative task protocol, read for which tasks are subagents and which ids name them. // // `local_agent`, `local_workflow` and `local_bash` tasks all arrive on the same // `message:system:task_*` channel and ALL carry a `tool_use_id`, so id presence @@ -6,10 +6,8 @@ // the subagent roster. `task_type` is the discriminator, with `subagent_type` // covering CLI releases that predate it. -import type { NativeChatSubagentState } from '../../shared/native-chat-types' import { classifyClaudeBackgroundTaskKind, - claudeTaskDescription, claudeTaskId, isBoundedClaudeTaskId } from './claude-background-task-tracker' @@ -23,37 +21,16 @@ const TASK_SUBTYPES: ReadonlySet = new Set([ 'task_notification' ]) -/** Provider status → the carrier's vocabulary. `killed` and `stopped` both mean - * the task was deliberately ended, which the carrier calls `stopped`; every - * in-flight status collapses to `working`. A Map, not an object, so a payload - * carrying `__proto__` as its status cannot resolve to an inherited value. */ -const TASK_STATES: ReadonlyMap = new Map([ - ['pending', 'working'], - ['running', 'working'], - ['paused', 'working'], - ['completed', 'completed'], - ['failed', 'failed'], - ['killed', 'stopped'], - ['stopped', 'stopped'] -] satisfies [string, NativeChatSubagentState][]) - export type ClaudeSubagentTaskFrame = { - /** Canonical, resume-stable id — the roster key. */ + /** Canonical, resume-stable id. */ taskId: string /** Re-minted when Claude re-announces a resumed task, so it is only an alias. */ toolUseId: string | null - label: string | null - /** null when the frame reported no lifecycle status. */ - state: NativeChatSubagentState | null - backgrounded: boolean | null - /** Any `task_started`, subagent or not. Proof this CLI declares its tasks. */ - announcement: boolean - /** `task_started` for a task the roster should show. Only an announcement - * creates an entry: an update carries no `task_type`, so honouring one for an - * unknown id would roster whatever else shares this channel. */ + /** `task_started` for a subagent. Only an announcement says what a task is: an update carries + * no `task_type`. */ announcesSubagent: boolean - /** Ambient housekeeping, or a task that is not a subagent at all. Its ids must - * never reach the roster, by this frame or by later child traffic. */ + /** Ambient housekeeping, or a task that is not a subagent at all. Its ids must never be read as + * a subagent's, by this frame or by later child traffic. */ excluded: boolean } @@ -72,10 +49,6 @@ export function isClaudeSubagentTask(message: Record): boolean ) } -function taskState(value: unknown): NativeChatSubagentState | null { - return typeof value === 'string' ? (TASK_STATES.get(value) ?? null) : null -} - export function readClaudeSubagentTaskFrame( message: Record ): ClaudeSubagentTaskFrame | null { @@ -90,8 +63,8 @@ export function readClaudeSubagentTaskFrame( if (!taskId) { return null } - const patch = claudeRecord(message.patch) - const toolUseId = claudeText(message.tool_use_id) ?? claudeText(patch?.tool_use_id) + const toolUseId = + claudeText(message.tool_use_id) ?? claudeText(claudeRecord(message.patch)?.tool_use_id) const announcement = subtype === 'task_started' // Housekeeping Claude runs for itself; the user never asked for it. const suppressed = message.ambient === true || message.skip_transcript === true @@ -99,25 +72,6 @@ export function readClaudeSubagentTaskFrame( return { taskId, toolUseId: toolUseId && isBoundedClaudeTaskId(toolUseId) ? toolUseId : null, - label: - claudeTaskDescription(message.description) ?? - claudeTaskDescription(patch?.description) ?? - // Bounded like a description: the roster stores whatever this returns. - (announcement ? (claudeTaskDescription(message.subagent_type) ?? null) : null), - // Notifications carry terminal evidence; progress carries usage only. - state: - subtype === 'task_notification' - ? taskState(message.status) - : announcement || subtype === 'task_updated' - ? taskState(patch?.status ?? message.status) - : null, - backgrounded: - typeof patch?.is_backgrounded === 'boolean' - ? patch.is_backgrounded - : typeof message.is_backgrounded === 'boolean' - ? message.is_backgrounded - : null, - announcement, announcesSubagent: subagent, excluded: announcement && !subagent } diff --git a/src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap b/src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap deleted file mode 100644 index 1f759ef11af..00000000000 --- a/src/main/codex/__snapshots__/codex-subagent-roster-characterization.test.ts.snap +++ /dev/null @@ -1,1425 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Codex subagent roster rows (characterization) > a child announced outside any parent turn 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "loose", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "thread-parent:outside-turn", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:outside-turn", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "thread", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "loose", - "settledAt": 1030, - "startedAt": 1020, - "state": "completed", - }, - ], - "groupId": "thread-parent:outside-turn", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:outside-turn", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "thread", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Codex subagent roster rows (characterization) > a child given new work in a later parent turn, and a late end of its earlier run 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "settledAt": 1030, - "startedAt": 1020, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "startedAt": 1050, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "settledAt": 1080, - "startedAt": 1050, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Codex subagent roster rows (characterization) > a child running when the session ends, then a late verdict 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "startedAt": 1020, - "state": "working", - }, - { - "id": "c2", - "label": "b", - "startedAt": 1040, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "startedAt": 1020, - "state": "working", - }, - { - "id": "c2", - "label": "b", - "settledAt": 1050, - "startedAt": 1040, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 2 subagents (1 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "settledAt": 1060, - "startedAt": 1020, - "state": "unverifiable", - }, - { - "id": "c2", - "label": "b", - "settledAt": 1050, - "startedAt": 1040, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "settledAt": 1070, - "startedAt": 1020, - "state": "completed", - }, - { - "id": "c2", - "label": "b", - "settledAt": 1050, - "startedAt": 1040, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Codex subagent roster rows (characterization) > a child thread closing mid-turn, then its turn completing 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent (1 unverifiable)", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "settledAt": 1030, - "startedAt": 1020, - "state": "unverifiable", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "settledAt": 1040, - "startedAt": 1020, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Codex subagent roster rows (characterization) > a refused append or publish is handed back, and the redelivered frame writes it 1`] = ` -{ - "admissions": [ - true, - false, - true, - true, - true, - true, - ], - "log": [ - { - "accepted": false, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "settledAt": 1040, - "startedAt": 1020, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": false, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "settledAt": 1040, - "startedAt": 1020, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "a", - "settledAt": 1040, - "startedAt": 1020, - "state": "completed", - "tokens": 50, - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - ], -} -`; - -exports[`Codex subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted run 1`] = `138`; - -exports[`Codex subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted run 2`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c33", - "label": "job33", - "startedAt": 2010, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-33", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-33", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-33", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c33", - "label": "job33", - "settledAt": 2020, - "startedAt": 2010, - "state": "completed", - }, - ], - "groupId": "thread-parent:turn-33", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-33", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-33", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "live", - "label": "live", - "startedAt": 2040, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-live", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-live", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-live", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; - -exports[`Codex subagent roster rows (characterization) > more than 32 settled groups, then a late frame for an evicted run 3`] = ` -{ - "groups": 32, - "settledIdentities": 6, -} -`; - -exports[`Codex subagent roster rows (characterization) > one child: announced, runs a turn, reports tokens, completes 1`] = ` -{ - "admissions": [ - true, - true, - true, - true, - true, - true, - true, - true, - ], - "log": [ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "startedAt": 1020, - "state": "working", - "tokens": 120, - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "startedAt": 1020, - "state": "working", - "tokens": 340, - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "settledAt": 1070, - "startedAt": 1020, - "state": "completed", - "tokens": 340, - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "reader", - "settledAt": 1070, - "startedAt": 1020, - "state": "completed", - "tokens": 400, - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - ], -} -`; - -exports[`Codex subagent roster rows (characterization) > two children sharing a path label, one failing and one interrupted 1`] = ` -[ - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 1 subagent", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "worker", - "startedAt": 1020, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 2 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "worker", - "startedAt": 1020, - "state": "working", - }, - { - "id": "c2", - "label": "worker 2", - "startedAt": 1040, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "worker", - "startedAt": 1020, - "state": "working", - }, - { - "id": "c2", - "label": "worker 2", - "startedAt": 1040, - "state": "working", - }, - { - "id": "c3", - "label": "worker 2 2", - "startedAt": 1060, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents (1 failed)", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "worker", - "settledAt": 1070, - "startedAt": 1020, - "state": "failed", - }, - { - "id": "c2", - "label": "worker 2", - "startedAt": 1040, - "state": "working", - }, - { - "id": "c3", - "label": "worker 2 2", - "startedAt": 1060, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Kicked off 3 subagents (1 failed)", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "worker", - "settledAt": 1070, - "startedAt": 1020, - "state": "failed", - }, - { - "id": "c2", - "label": "worker 2", - "settledAt": 1080, - "startedAt": 1040, - "state": "stopped", - }, - { - "id": "c3", - "label": "worker 2 2", - "startedAt": 1060, - "state": "working", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, - { - "accepted": true, - "body": { - "blocks": [ - { - "text": "Ran 3 subagents (1 failed)", - "type": "text", - }, - { - "agents": [ - { - "id": "c1", - "label": "worker", - "settledAt": 1070, - "startedAt": 1020, - "state": "failed", - }, - { - "id": "c2", - "label": "worker 2", - "settledAt": 1080, - "startedAt": 1040, - "state": "stopped", - }, - { - "id": "c3", - "label": "worker 2 2", - "settledAt": 1090, - "startedAt": 1060, - "state": "unverifiable", - }, - ], - "groupId": "thread-parent:turn-1", - "type": "subagent-group", - }, - ], - "kind": "message", - "role": "system", - }, - "identity": { - "clientMessageId": "codex-subagents:thread-parent:turn-1", - "provider": "orca", - }, - "op": "append", - "turnScope": { - "kind": "turn", - "turnItemId": "thread-parent/turn-1", - }, - }, - { - "accepted": true, - "op": "publish", - }, -] -`; diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index 06536a857b8..c7b66988447 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -1,6 +1,6 @@ import type { NativeChatSubagentState } from '../../shared/native-chat-types' import { readCodexSubagentAnnouncements } from './codex-subagent-activity' -import { codexChildTurnState } from './codex-subagent-executions' +import { codexChildTurnState, type CodexSubagentExecutions } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' import { readCodexTurnId } from './codex-structured-thread-facts' @@ -8,7 +8,6 @@ import { readCodexTurnId } from './codex-structured-thread-facts' export type CodexAnnouncedChild = { agentThreadId: string label: string | null - parentTurnId: string | null | undefined /** The reporting thread, for a spawn: the agent that spawned the child. */ spawnerThreadId: string | undefined } @@ -83,10 +82,27 @@ export function readCodexBackgroundTaskFrame( (announcement) => ({ agentThreadId: announcement.agentThreadId, label: announcement.label, - parentTurnId: announcement.namesParentTurn ? readCodexTurnId(event.params) : undefined, // Only a spawn names the spawner: other announcements ride whichever agent acted. spawnerThreadId: announcement.spawned ? event.threadId : undefined }) ) return children.length > 0 ? { kind: 'subagents', children } : null } + +/** What one frame says about the child executions. Repeating it changes nothing, so the journal + * reads it before it writes the frame's rows and the background tasks read it again after. */ +export function observeCodexExecutionFrame( + executions: CodexSubagentExecutions, + frame: CodexBackgroundTaskFrame | null, + primaryThreadId: string +): void { + if (frame?.kind === 'subagents') { + for (const child of frame.children) { + executions.register(child.agentThreadId, child.label, child.spawnerThreadId) + } + } else if (frame?.kind === 'thread-closed') { + executions.closeThread(frame.threadId) + } else if (frame && frame.threadId !== primaryThreadId) { + executions.observeTurn(frame.threadId, frame.turnId, frame.state) + } +} diff --git a/src/main/codex/codex-background-task-tracker.test.ts b/src/main/codex/codex-background-task-tracker.test.ts index 7926131ae5b..5072b5cae3d 100644 --- a/src/main/codex/codex-background-task-tracker.test.ts +++ b/src/main/codex/codex-background-task-tracker.test.ts @@ -77,7 +77,7 @@ describe('readCodexBackgroundTaskFrame', () => { it('reads activity as child metadata without inferring execution state', () => { expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({ kind: 'subagents', - children: [{ agentThreadId: CHILD, label: 'count_a', parentTurnId: PARENT_TURN }] + children: [{ agentThreadId: CHILD, label: 'count_a' }] }) }) diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 3456fec089b..2ce5cf5a4de 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -4,6 +4,7 @@ import type { } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { + observeCodexExecutionFrame, readCodexBackgroundTaskFrame, type CodexBackgroundTaskEvent } from './codex-background-task-frames' @@ -12,7 +13,7 @@ import { CodexBackgroundCommandTracker } from './codex-background-command-tracke import { CodexChildWorkEvidence } from './codex-child-work-evidence' import type { CodexAbandonedCommand } from './codex-prompt-registry' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' -import { boundSubagentField } from '../native-chat/agent-session-journal/journal-subagent-group-body' +import { boundSubagentLabel } from '../native-chat/agent-session-journal/journal-subagent-item' /** Where a session's child-work evidence goes, and the host clock that stamps it. */ export type CodexChildWorkSink = { @@ -153,20 +154,7 @@ export class CodexBackgroundTaskTracker { ? [command] : []) ] - if (frame?.kind === 'subagents') { - for (const child of frame.children) { - this.executions.register( - child.agentThreadId, - child.label, - child.parentTurnId, - child.spawnerThreadId - ) - } - } else if (frame?.kind === 'thread-closed') { - this.executions.closeThread(frame.threadId) - } else if (frame && frame.threadId !== this.primaryThreadId) { - this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) - } + observeCodexExecutionFrame(this.executions, frame, this.primaryThreadId) this.childWork.observe(event, frame, commands) if (!frame) { return itemEvent || commands.length > 0 ? this.refresh() : false @@ -200,10 +188,10 @@ export class CodexBackgroundTaskTracker { private tasks(): AgentSessionBackgroundTask[] { const children = this.executions.workingChildren() - const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({ + const agents: AgentSessionBackgroundTask[] = children.map((child) => ({ id: `codex-agent:${child.agentThreadId}`, kind: 'agent', - ...(child.label ? { description: boundSubagentField(child.label, index) } : {}) + ...(child.label ? { description: boundSubagentLabel(child.label) } : {}) })) return [ ...agents, diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 9ac082784ac..00023b11bac 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -136,6 +136,19 @@ describe('Codex child-work evidence', () => { expect(log.flat().filter((edge) => edge.type === 'live')).toHaveLength(1) }) + it('keeps usage that arrives before the child is announced, and never credits the parent its own', () => { + const { send, byKind } = harness() + const usage = (threadId: string, totalTokens: number): CodexBackgroundTaskEvent => ({ + method: 'thread/tokenUsage/updated', + threadId, + params: { threadId, tokenUsage: { total: { totalTokens } } } + }) + send(turn('turn/started', PRIMARY, PARENT_TURN), usage(PRIMARY, 9_000), usage(CHILD, 1_500)) + expect(byKind('agent')).toEqual([]) + send(spawned(), turn('turn/started', CHILD, 'c1')) + expect(byKind('agent')).toEqual([expect.objectContaining({ totalTokens: 1_500 })]) + }) + it('makes no record for a child whose turn began before its announcement, until it lands', () => { const { send, records } = harness() send(turn('turn/started', PRIMARY, PARENT_TURN), turn('turn/started', CHILD, 'c1')) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 2eb57d7d6f9..9569acac65c 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -34,7 +34,7 @@ import { codexThreadWaitsOnUser, readCodexTurnId } from './codex-structured-thre import { CODEX_TOKEN_USAGE_METHOD, readCodexThreadTokenTotal } from './codex-subagent-activity' import type { CodexExecutionChild, CodexSubagentExecutions } from './codex-subagent-executions' -/** The executions' own child bound. */ +/** Facts kept for settled children; a working child always has its own. */ const MAX_CHILD_FACTS = 128 const MAX_OPEN_CALLS_PER_CHILD = 16 const CHILD_FRAME_METHODS: ReadonlySet = new Set([ @@ -113,7 +113,7 @@ export class CodexChildWorkEvidence { this.queueCommands(commands) for (const threadId of this.childThreads(event, frame)) { const facts = this.factsFor(threadId) - if (facts && event.threadId === threadId) { + if (event.threadId === threadId) { this.record(facts, event) } this.queueChild(threadId) @@ -299,19 +299,19 @@ export class CodexChildWorkEvidence { } } - private factsFor(threadId: string): ChildFacts | undefined { + private factsFor(threadId: string): ChildFacts { const existing = this.facts.get(threadId) if (existing) { return existing } if (this.facts.size >= MAX_CHILD_FACTS) { + // Refusing a working child its facts would drop its every edge; the host's limits bound them. const idle = [...this.facts.keys()].find( (id) => this.executions.find(id)?.execution?.state !== 'working' ) - if (idle === undefined) { - return undefined + if (idle !== undefined) { + this.facts.delete(idle) } - this.facts.delete(idle) } const facts: ChildFacts = { openCalls: new Map(), waiting: false } this.facts.set(threadId, facts) diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 61c99be2f19..0b58dd84630 100644 --- a/src/main/codex/codex-default-mode-subagents.test.ts +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -1,7 +1,7 @@ // Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` items that // spawn, message, wait on or close it. These frames, through the real adapter, must register that -// helper the same way a `subAgentActivity` does: one child in the strip, the host's records and -// the roster row. +// helper the same way a `subAgentActivity` does: one child in the strip and the host's records, +// which the host saves as the helper's row. import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -179,21 +179,15 @@ async function session() { const strip = () => adapter.backgroundTaskState('session-1')?.tasks ?? [] const toolRow = (name: string) => [...rows.values()].find((body) => body.kind === 'tool-call' && body.name === name) - const rosterRows = () => - [...rows.values()].flatMap((body) => - body.kind === 'message' - ? body.blocks.flatMap((block) => (block.type === 'subagent-group' ? [block] : [])) - : [] - ) const rawCollabRows = () => [...rows.values()].filter( (body) => body.kind === 'status' && body.providerFrame?.kind === 'item:collabAgentToolCall' ) - return { send, agents, commands, strip, toolRow, rosterRows, rawCollabRows } + return { send, agents, commands, strip, toolRow, rawCollabRows } } describe('Codex default-mode helpers', () => { - it('registers a helper announced only by its spawn call, in the strip, the records and the roster row', async () => { + it('registers a helper announced only by its spawn call, in the strip and the records', async () => { const run = await session() run.send(turn('turn/started', THREAD_ID, PARENT_TURN), spawnStarted) // The call has not said which thread the helper is yet. @@ -212,11 +206,6 @@ describe('Codex default-mode helpers', () => { expect(run.strip()).toEqual([ { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } ]) - expect(run.rosterRows()).toEqual([ - expect.objectContaining({ - agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] - }) - ]) // While the helper runs, its command is the helper's work, not the session's own. run.send(helperShell('item/started')) @@ -245,9 +234,6 @@ describe('Codex default-mode helpers', () => { }) ]) expect(run.strip()).toEqual([]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, state: 'completed' }) - ]) }) it('labels a command the helper leaves running with the helper, once its turn is over', async () => { @@ -316,12 +302,7 @@ describe('Codex default-mode helpers', () => { expect.objectContaining({ membership: 'live', state: 'working', description: LABEL }) ]) expect(run.strip()).toHaveLength(1) - expect(run.rosterRows()).toEqual([ - expect.objectContaining({ - agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] - }) - ]) - // The roster row does not stand in for the call's own row. + // The call keeps its own row. expect(run.toolRow('spawn_agent')).toMatchObject({ state: 'completed', input: { description: LABEL } @@ -355,11 +336,6 @@ describe('Codex default-mode helpers', () => { expect(run.strip()).toEqual([ { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } ]) - expect(run.rosterRows()).toEqual([ - expect.objectContaining({ - agents: [expect.objectContaining({ id: HELPER, state: 'working' })] - }) - ]) }) it('registers nothing for a spawn that created no thread', async () => { @@ -376,7 +352,6 @@ describe('Codex default-mode helpers', () => { ) expect(run.agents()).toEqual([]) expect(run.strip()).toEqual([]) - expect(run.rosterRows()).toEqual([]) }) it('leaves a helper running when its caller failed to close it', async () => { @@ -415,9 +390,6 @@ describe('Codex default-mode helpers', () => { ) expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) expect(run.strip()).toHaveLength(1) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, state: 'working' }) - ]) // A close that works aborts the helper's turn, which Codex reports on the helper's thread. run.send(closeCompleted, turn('turn/completed', HELPER, HELPER_TURN, 'interrupted')) @@ -425,9 +397,6 @@ describe('Codex default-mode helpers', () => { expect.objectContaining({ membership: 'settled', outcome: 'cancelled' }) ]) expect(run.strip()).toEqual([]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, state: 'stopped' }) - ]) expect(run.toolRow('close_agent')).toMatchObject({ state: 'completed', input: { description: LABEL } @@ -480,9 +449,6 @@ describe('Codex default-mode helpers', () => { expect(run.strip()).toEqual([ { id: `codex-agent:${HELPER}`, kind: 'agent', description: 'sleeper' } ]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, label: 'sleeper' }) - ]) }) }) @@ -520,9 +486,6 @@ describe('a helper whose spawn was never seen', () => { parentChildWorkId: run.agents()[0]?.childWorkId }) ]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, label: 'subagent', state: 'working' }) - ]) expect(run.toolRow('send_input')).toMatchObject({ input: { description: HELPER } }) }) @@ -548,10 +511,6 @@ describe('a helper whose spawn was never seen', () => { expect.objectContaining({ membership: 'live' }), expect.objectContaining({ membership: 'live' }) ]) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, label: 'subagent' }), - expect.objectContaining({ id: second, label: 'subagent 2' }) - ]) }) it("reopens a helper's one entry when a later turn sends it work, even after that turn ended", async () => { @@ -567,11 +526,9 @@ describe('a helper whose spawn was never seen', () => { turn('turn/completed', THREAD_ID, 'parent-turn-2'), turn('turn/started', HELPER, 'helper-turn-2') ) - // One entry per helper: a second row naming it would show it without its own section. - const groups = new Set(run.rosterRows().map((row) => row.groupId)) - expect(groups.size).toBe(1) - expect(run.rosterRows().at(-1)?.agents).toEqual([ - expect.objectContaining({ id: HELPER, state: 'working' }) + // One record per helper, back at work. + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live', state: 'working' }) ]) }) @@ -590,14 +547,13 @@ describe('a helper whose spawn was never seen', () => { } } expect(readCodexSubagentAnnouncements(waited, THREAD_ID)).toEqual([ - { agentThreadId: HELPER, label: null, namesParentTurn: false, spawned: false }, - { agentThreadId: second, label: null, namesParentTurn: false, spawned: false } + { agentThreadId: HELPER, label: null, spawned: false }, + { agentThreadId: second, label: null, spawned: false } ]) }) }) -describe('the roster row follows a helper whose turn ends', () => { - const lastRow = (run: Awaited>) => run.rosterRows().at(-1)?.agents +describe('the record follows a helper whose turn ends', () => { const running = async () => { const run = await session() run.send( @@ -605,11 +561,10 @@ describe('the roster row follows a helper whose turn ends', () => { activityStarted, turn('turn/started', HELPER, HELPER_TURN) ) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) return run } - it('settles the row with the strip and the record on the failed completion that follows a fatal error', async () => { + it('settles the strip and the record on the failed completion that follows a fatal error', async () => { const run = await running() // Codex sends the fatal error, then the helper's failed `turn/completed` 0-32 ms later. run.send({ @@ -623,24 +578,21 @@ describe('the roster row follows a helper whose turn ends', () => { }) expect(run.strip()).toEqual([expect.objectContaining({ id: `codex-agent:${HELPER}` })]) expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) run.send(turn('turn/completed', HELPER, HELPER_TURN, 'failed')) expect(run.strip()).toEqual([]) expect(run.agents()).toEqual([ expect.objectContaining({ membership: 'settled', outcome: 'failed' }) ]) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'failed' })]) }) - it('settles the row with the strip and the record when its thread closes with no turn/completed', async () => { + it('settles the strip and the record when its thread closes with no turn/completed', async () => { const run = await running() run.send({ method: 'thread/closed', params: { threadId: HELPER } }) expect(run.strip()).toEqual([]) expect(run.agents()).toEqual([ expect.objectContaining({ membership: 'settled', outcome: 'unknown' }) ]) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'unverifiable' })]) }) }) diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 4317b5669d7..2c762121327 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -203,11 +203,13 @@ const shellFrame = ( describe('Codex structured child-work producer', () => { it('delivers evidence only after the journal wrote and published the frame', async () => { const { send, records } = await producer() - send(turn('turn/started', THREAD_ID, 'p1')) - send(turn('turn/started', REVIEWER, 'r1')) - const deliveries = send(spawned(REVIEWER, 'review', 'p1')) + const deliveries = [ + ...send(turn('turn/started', THREAD_ID, 'p1')), + ...send(turn('turn/started', REVIEWER, 'r1')), + ...send(spawned(REVIEWER, 'review', 'p1')) + ] const kinds = deliveries.map((delivery) => delivery.kind) - // The frame's own rows, then the parent's republished row, and only then its children. + // The turn's own rows, then the parent's republished row, and only then its child. expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) expect(kinds.at(-1)).toBe('evidence') @@ -474,7 +476,8 @@ describe('Codex structured child-work producer', () => { before = step.parent step.check?.() } - expect(journalMoments).toBeGreaterThan(steps.length) + // The host writes the children's rows, so not every step writes one here. + expect(journalMoments).toBeGreaterThan(0) const settled = records() await adapter.closeSession('session-1') // Every child had already ended; closing the session changes none of what they said. diff --git a/src/main/codex/codex-structured-journal-limits.ts b/src/main/codex/codex-structured-journal-limits.ts index 5d5d4c1c7f8..c68d9684ed7 100644 --- a/src/main/codex/codex-structured-journal-limits.ts +++ b/src/main/codex/codex-structured-journal-limits.ts @@ -11,9 +11,3 @@ export const MAX_CODEX_PENDING_PROMPTS = 128 export const MAX_CODEX_IDENTITY_ENTRIES = 512 export const MAX_CODEX_DETAIL_ENTRIES = 512 export const MAX_CODEX_DETAIL_BYTES = 64 * 1024 -/** The shared subagent tracker's bounds, which Codex's spawn-group rows follow. */ -export { MAX_SUBAGENT_GROUPS as MAX_CODEX_SUBAGENT_GROUPS } from '../native-chat/subagent-tracker/subagent-tracker-groups' -export { MAX_SUBAGENTS_PER_GROUP as MAX_CODEX_SUBAGENTS_PER_GROUP } from '../native-chat/subagent-tracker/subagent-tracker' -/** Threads whose latest token total is retained. Usage frames arrive for - * threads that are not yet (or never become) roster children. */ -export const MAX_CODEX_TOKEN_USAGE_THREADS = 256 diff --git a/src/main/codex/codex-structured-journal-thread-item-routing.ts b/src/main/codex/codex-structured-journal-thread-item-routing.ts index 85769d0ac82..bccadba6e9e 100644 --- a/src/main/codex/codex-structured-journal-thread-item-routing.ts +++ b/src/main/codex/codex-structured-journal-thread-item-routing.ts @@ -1,6 +1,7 @@ -// Where one `item/started` or `item/completed` notification goes: the subagent -// roster, the typed item translator, or the generic fallback — and, when the item -// is Codex echoing a send back, the turn attribution that echo unlocks. +// Where one `item/started` or `item/completed` notification goes: nowhere for a subagent's +// activity item, whose child draws its own row from the host's records; else the typed item +// translator or the generic fallback — and, when the item is Codex echoing a send back, the turn +// attribution that echo unlocks. import type { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames' import type { CodexJournalItems } from './codex-structured-journal-items' @@ -8,12 +9,13 @@ import type { CodexJournalTranslationAdmission, CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' -import type { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import type { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries' import { readCodexThreadItem } from './codex-structured-item-translation' -import { readCodexNotificationThreadItem } from './codex-subagent-activity' -import type { CodexSubagentRoster } from './codex-subagent-roster' -import { readCodexTurnId } from './codex-structured-thread-facts' +import { + readCodexNotificationThreadItem, + readCodexSubagentActivity +} from './codex-subagent-activity' +import { CODEX_JOURNAL_ADMITTED } from './codex-structured-journal-contracts' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' type ThreadItemEvent = Extract @@ -23,25 +25,14 @@ export type CodexThreadItemRouting = CodexJournalTranslationAdmission | null export function createCodexThreadItemRouter(input: { deps: Pick - subagents: Pick items: Pick - activeTurns: Pick turnBoundaries: Pick genericFrames: Pick }): (event: ThreadItemEvent) => CodexThreadItemRouting { return (event) => { const subagentItem = readCodexNotificationThreadItem(event.params, readCodexThreadItem) - // Null means the roster did not claim it; fall through to normal item - // handling. Returning here unconditionally swallows every other item. - const subagentAdmission = subagentItem - ? input.subagents.handleItem({ - threadId: event.threadId, - turnId: readCodexTurnId(event.params) ?? input.activeTurns.current(event.threadId), - item: subagentItem - }) - : null - if (subagentAdmission) { - return subagentAdmission + if (subagentItem && readCodexSubagentActivity(subagentItem) !== null) { + return CODEX_JOURNAL_ADMITTED } const translated = input.items.handle(event) if (translated.handled && translated.dispatchEcho) { diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 9f7efc15ae4..09bf20b8403 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -36,7 +36,7 @@ export function restoreCodexHistoryItem( const item = readCodexNotificationThreadItem(event.params, readCodexThreadItem) const announcements = item ? readCodexSubagentAnnouncements(item, input.primaryThreadId) : [] for (const announcement of announcements) { - input.executions.register(announcement.agentThreadId, announcement.label, undefined) + input.executions.register(announcement.agentThreadId, announcement.label) } const compaction = input.compactions.handle(event) if (compaction) { diff --git a/src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts b/src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts index 16474874d5d..8202eb8754b 100644 --- a/src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts +++ b/src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts @@ -109,14 +109,8 @@ describe('codex journal translation — subagent producer linkage', () => { expect(commandRows.map((write) => write.linkage)).toEqual(commandRows.map(() => CHILD_LINKAGE)) expect(linkageOf('child prose')).toEqual(CHILD_LINKAGE) expect(linkageOf('own prose')).toEqual({}) - // The spawn-group row is the parent's list of its children, though a child's - // frames also write it. - expect( - writes.filter((write) => write.key.includes('codex-subagents')).map((w) => w.linkage) - ).toEqual(expect.arrayContaining([{}])) - expect( - writes.filter((write) => write.key.includes('codex-subagents') && write.linkage.agentId) - ).toEqual([]) + // The host writes each child's own row; the translator writes none. + expect(writes.filter((write) => write.key.includes('subagent'))).toEqual([]) }) it('stamps a child row that arrives before the spawn announcement names it', () => { diff --git a/src/main/codex/codex-structured-journal-translation-subagents.test.ts b/src/main/codex/codex-structured-journal-translation-subagents.test.ts index 8db30d31b21..ff1b458b4c7 100644 --- a/src/main/codex/codex-structured-journal-translation-subagents.test.ts +++ b/src/main/codex/codex-structured-journal-translation-subagents.test.ts @@ -5,7 +5,6 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -79,38 +78,27 @@ function deliverActivity( translator.handle(notification('item/completed', params)) } -function rosterAgents(rows: Row[]): { id: string; state: string; tokens?: number }[] { - const body = rows.findLast((row) => row.key.startsWith('orca:codex-subagents'))?.body - if (!body || body.kind !== 'message') { - return [] - } - return body.blocks.find(isSubagentGroupBlock)?.agents ?? [] -} - describe('codex journal translation — subagents', () => { - it('renders a spawn group as one roster row and no opcode-shaped duplicate', () => { + it('writes neither a subagent row nor an opcode-shaped one: the host saves each child', () => { const { translator, rows } = harness() translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) deliverActivity(translator, subagentItem('started', 'child-1', '/root/list_directory')) deliverActivity(translator, subagentItem('interacted', 'child-1', '/root/list_directory')) - expect(rosterAgents(rows)).toMatchObject([ - { id: 'child-1', label: 'list_directory', state: 'working' } - ]) - // Four wire deliveries (two items, each sent twice) collapse to ONE roster - // row, and none of the gray `codex · item:subAgentActivity` rows survive. + // Four wire deliveries (two items, each sent twice), and none of the gray + // `codex · item:subAgentActivity` rows survive. const providerFrameKinds = rows.flatMap((row) => row.body.kind === 'status' && row.body.providerFrame ? [row.body.providerFrame.kind] : [] ) expect(providerFrameKinds).toEqual([]) - expect(rows.filter((row) => row.key.startsWith('orca:codex-subagents'))).toHaveLength(1) + expect(rows.filter((row) => row.key.includes('subagent'))).toEqual([]) }) - // The roster claims the item, but claiming it must not take the turn tail with + // The translator claims the item, but claiming it must not take the turn tail with // it: the activity table is reached only through the publish arm, so a bare // return leaves the tail stuck on whatever the previous frame said. - it('still publishes the turn tail for an item the roster claims', () => { + it('still publishes the turn tail for an item it claims', () => { const { translator, activities } = harness() translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) @@ -122,63 +110,4 @@ describe('codex journal translation — subagents', () => { text: 'Coordinating with another agent' }) }) - - it('consumes thread/tokenUsage/updated instead of swallowing it as chrome', () => { - const { translator, rows } = harness() - - translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) - deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) - translator.handle( - notification('thread/tokenUsage/updated', { - threadId: 'child-1', - tokenUsage: { total: { totalTokens: 40661 } } - }) - ) - - expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', tokens: 40661 }]) - }) - - // The QA scenario this row got wrong: three `spawn_agent` children were still - // running when a mid-turn correction ended their turn and opened a new one. - // They reported `completed` 57-87s later, so a turn boundary is a fact about - // the turn and never evidence that contact with a child was lost. - it('leaves children working when their turn ends and a newer turn opens', () => { - const { translator, rows } = harness() - - translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) - deliverActivity(translator, subagentItem('started', 'child-1', '/root/read_readme')) - deliverActivity(translator, subagentItem('started', 'child-2', '/root/read_package')) - translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) - translator.handle(notification('turn/started', { turn: { id: 'turn-2' } })) - - expect(rosterAgents(rows)).toMatchObject([ - { id: 'child-1', state: 'working' }, - { id: 'child-2', state: 'working' } - ]) - - // And the verdict a child reports after its turn ended still lands on the row. - deliverActivity(translator, subagentItem('completed', 'child-1', '/root/read_readme')) - - expect(rosterAgents(rows)).toMatchObject([ - { id: 'child-1', state: 'completed' }, - { id: 'child-2', state: 'working' } - ]) - }) - - it('sweeps every group when the provider ends', () => { - const { translator, rows } = harness() - - translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) - deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) - translator.handle({ - type: 'ended', - sessionId: SESSION_ID, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: 1, - acquisitionGeneration: 'gen-1' - } as CodexStructuredSessionEvent) - - expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state: 'unverifiable' }]) - }) }) diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index 9e577074c5e..589d3c6a7d9 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -1,6 +1,6 @@ // The translator's writers, built once. Split out so the translator reads as // routing, and so one place shows that every writer is handed the same -// row attribution: the roster's producer, which knows each child thread, and the turn scope. +// row attribution: the producer each child thread is, and the turn scope. import { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' @@ -11,8 +11,8 @@ import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import { CodexJournalTurnScopes } from './codex-journal-turn-scopes' import { CodexJournalCommandTurn } from './codex-journal-command-turn' -import { CodexSubagentRoster } from './codex-subagent-roster' -import type { CodexRowAttribution } from './codex-subagent-linkage' +import { CodexSubagentExecutions } from './codex-subagent-executions' +import { CodexSubagentLinkage, type CodexRowAttribution } from './codex-subagent-linkage' export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslatorDeps) { const activeTurns = new CodexJournalActiveTurns() @@ -25,14 +25,10 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator activeTurn, commandScope: (turnId) => commands.scopeFor(turnId) }) - const subagents = new CodexSubagentRoster({ - sink: deps.sink, - primaryThreadId, - activeTurn, - turnScopeFor: (threadId, turnId) => turnScopes.scopeFor(threadId, turnId), - ...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {}) - }) - const { linkageFor } = subagents.linkage + // The translator and the background-task tracker both feed these from the same frames, which + // repeat harmlessly; the rows only read them. + const executions = deps.subagentExecutions ?? new CodexSubagentExecutions() + const { linkageFor } = new CodexSubagentLinkage({ primaryThreadId, executions }) const attributionFor: CodexRowAttribution = (threadId, turnId) => ({ ...linkageFor(threadId, turnId), turnScope: turnScopes.scopeFor(threadId, turnId) @@ -43,13 +39,13 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator producerDeps, activeTurn, (threadId, turnId) => genericFrames.suppress(threadId, turnId), - // A collab call's row names its helpers the way the roster does. - (threadId) => subagents.executions.label(threadId) + // A collab call's row names its helpers the way their own rows do. + (threadId) => executions.label(threadId) ) return { activeTurns, commands, - subagents, + executions, attributionFor, genericFrames, items, diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 19fee37a536..31f92ec2c30 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -1,5 +1,4 @@ import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity' -import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity' import { CODEX_JOURNAL_ADMITTED, type CodexJournalTranslationAdmission, @@ -17,6 +16,10 @@ import { publishCodexTurnLifecycle } from './codex-structured-journal-translatio import { codexProviderRetryRowBody, isCodexProviderRetryFrame } from './codex-provider-retry-row' import { createCodexThreadItemRouter } from './codex-structured-journal-thread-item-routing' import { codexThreadStoppedRunning, readCodexTurnId } from './codex-structured-thread-facts' +import { + observeCodexExecutionFrame, + readCodexBackgroundTaskFrame +} from './codex-background-task-frames' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' export type { @@ -42,7 +45,7 @@ export function createCodexJournalTranslator( const { activeTurns, commands, - subagents, + executions, attributionFor, genericFrames, items, @@ -83,9 +86,7 @@ export function createCodexJournalTranslator( } const routeThreadItem = createCodexThreadItemRouter({ deps, - subagents, items, - activeTurns, turnBoundaries, genericFrames }) @@ -122,7 +123,7 @@ export function createCodexJournalTranslator( primaryThreadId: deps.primaryThreadId?.() ?? null, compactions, items, - executions: subagents.executions + executions }), ...(deps.sessionId !== undefined ? { @@ -140,6 +141,15 @@ export function createCodexJournalTranslator( }) }, handle: (event) => { + const primaryThreadId = deps.primaryThreadId?.() ?? null + if (event.type === 'notification' && primaryThreadId !== null) { + // Before any row: the rows this frame writes name the child run it starts. + observeCodexExecutionFrame( + executions, + readCodexBackgroundTaskFrame(event, primaryThreadId), + primaryThreadId + ) + } if (event.type === 'ended') { const streamAdmission = flushStreams() if (!streamAdmission.accepted) { @@ -173,11 +183,6 @@ export function createCodexJournalTranslator( if (!admission.accepted) { return admission } - // No event will ever settle a child once the provider is gone. - const sweep = subagents.settleSession() - if (!sweep.accepted) { - return sweep - } readActivity = createCodexProviderActivityReader() deps.sink.setActivity?.(null) items.activeItems.clear() @@ -212,10 +217,6 @@ export function createCodexJournalTranslator( return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) } if (event.method === 'turn/started' || event.method === 'turn/completed') { - const childAdmission = subagents.handleTurnEvent(event) - if (!childAdmission.accepted) { - return childAdmission - } const admission = event.method === 'turn/started' ? turnBoundaries.start(event) @@ -233,14 +234,6 @@ export function createCodexJournalTranslator( if (goal) { return publishActivity(event, goal) } - if (event.method === CODEX_TOKEN_USAGE_METHOD) { - // Classified `status-chrome`, so the generic-frame path swallows it - // before the journal. The roster consumes it as a typed notification. - const admission = subagents.handleTokenUsage(event.params) - if (admission) { - return admission - } - } if (event.method === 'item/started' || event.method === 'item/completed') { const routed = routeThreadItem(event) // Not a bare return: a claimed item must not skip the turn-tail arm, @@ -298,7 +291,6 @@ export function createCodexJournalTranslator( items.dispose() prompts.dispose() genericFrames.dispose() - subagents.dispose() turnBoundaries.clear() compactions.clear() goals.dispose() diff --git a/src/main/codex/codex-subagent-activity.test.ts b/src/main/codex/codex-subagent-activity.test.ts new file mode 100644 index 00000000000..7e074c07e91 --- /dev/null +++ b/src/main/codex/codex-subagent-activity.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + codexSubagentLabel, + isCodexRootAgentActivity, + readCodexSubagentAnnouncements +} from './codex-subagent-activity' + +const activity = (agentPath: string | null) => ({ + kind: 'started', + agentThreadId: 'child', + agentPath +}) + +describe('a Codex subagent read from its agent path', () => { + it.each(['/root', '/root/', '/root//'])('reads %s as the turn itself, never a child', (path) => { + expect(isCodexRootAgentActivity(activity(path))).toBe(true) + }) + + it('counts a single-segment path other than root, and a path-less one, as a child', () => { + expect(isCodexRootAgentActivity(activity('/morpheus'))).toBe(false) + expect(isCodexRootAgentActivity(activity(null))).toBe(false) + }) + + it('labels a child by its trimmed last segment, and by nothing when that is blank', () => { + expect(codexSubagentLabel(activity('/root//list_directory'))).toBe('list_directory') + expect(codexSubagentLabel(activity('/root/ read '))).toBe('read') + expect(codexSubagentLabel(activity('/root/ '))).toBeNull() + }) + + it('announces no child for the tree root or the session’s own thread', () => { + const item = (agentThreadId: string, agentPath: string) => ({ + type: 'subAgentActivity', + id: `activity-${agentThreadId}`, + kind: 'started', + agentThreadId, + agentPath + }) + expect(readCodexSubagentAnnouncements(item('root-thread', '/root'), 'primary')).toEqual([]) + expect(readCodexSubagentAnnouncements(item('primary', '/root/x'), 'primary')).toEqual([]) + expect(readCodexSubagentAnnouncements(item('child', '/root/x'), 'primary')).toEqual([ + { agentThreadId: 'child', label: 'x', spawned: true } + ]) + }) +}) diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts index 0a00297dff8..09e8272be1d 100644 --- a/src/main/codex/codex-subagent-activity.ts +++ b/src/main/codex/codex-subagent-activity.ts @@ -96,18 +96,10 @@ export function codexSubagentLabel(activity: CodexSubagentActivity): string | nu export type CodexSubagentAnnouncement = { agentThreadId: string label: string | null - /** The item's turn is the one the child was spawned or messaged from. */ - namesParentTurn: boolean /** The item is the spawn itself, so the thread that carried it spawned the child. */ spawned: boolean } -/** Collab calls that hand a helper work from the caller's turn, as an `interacted` activity does. */ -const CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER: ReadonlySet = new Set([ - 'spawnAgent', - 'sendInput' -]) - /** The children an item announces: a `subAgentActivity`'s, or every helper a collab call names, * keyed by thread id so repeats announce one child. Neither the tree root nor the session's own * thread is a child. Any call counts, since a helper whose spawn was never seen (compacted out of @@ -130,7 +122,6 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] { { agentThreadId: activity.agentThreadId, label: codexSubagentLabel(activity), - namesParentTurn: activity.kind === 'started' || activity.kind === 'interacted', spawned: activity.kind === 'started' } ] @@ -143,13 +134,7 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] { // Only a spawn's prompt describes its helper; another call's prompt is a later message to it, // so a helper no spawn named reads as any unnamed subagent does. const label = spawned ? codexCollabHelperLabel(call.prompt) : null - const namesParentTurn = CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER.has(call.tool) - return call.helperThreadIds.map((agentThreadId) => ({ - agentThreadId, - label, - namesParentTurn, - spawned - })) + return call.helperThreadIds.map((agentThreadId) => ({ agentThreadId, label, spawned })) } export type CodexThreadTokenTotal = { threadId: string; totalTokens: number } diff --git a/src/main/codex/codex-subagent-execution-projection.test.ts b/src/main/codex/codex-subagent-execution-projection.test.ts index c3564b5e3c7..8c2fb39de8a 100644 --- a/src/main/codex/codex-subagent-execution-projection.test.ts +++ b/src/main/codex/codex-subagent-execution-projection.test.ts @@ -75,23 +75,9 @@ function harness() { } return admission } - function state(parentTurn: string): string | undefined { - for (const body of rows.values()) { - if (body.kind !== 'message') { - continue - } - for (const block of body.blocks) { - if (block.type === 'subagent-group' && block.groupId === `${PRIMARY}:${parentTurn}`) { - return block.agents[0]?.state - } - } - } - return undefined - } return { send, tracker, - state, refuse: (value: boolean) => { refused = value }, @@ -111,14 +97,12 @@ describe('shared child execution projection', () => { const h = harness() h.send(activity('parent-1')) h.send(activity('parent-2', 'interacted')) - expect(h.state('parent-1')).toBeUndefined() - expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state).toBeNull() h.dispose() }) it.each(['parent-1', 'parent-2'])( - 'reopens a child for real follow-up in %s in its own row and fences old execution events', + 'reopens a child for real follow-up in %s and fences old execution events', (parent) => { const h = harness() firstRun(h) @@ -130,19 +114,13 @@ describe('shared child execution projection', () => { expect(h.tracker.state).toBeNull() h.send(turn('turn/started', CHILD, 'child-2')) h.send(turn('turn/completed', PRIMARY, parent)) - // One entry per child: the follow-up reopens the row that spawned it. - expect(h.state('parent-1')).toBe('working') + // One entry per child: the follow-up reopens the child that was spawned. expect(h.tracker.state?.tasks).toHaveLength(1) h.send(turn('turn/completed', CHILD, 'child-1')) h.send(turn('turn/started', CHILD, 'child-1')) h.send(activity('parent-1', 'completed')) - expect(h.state('parent-1')).toBe('working') expect(h.tracker.state?.tasks).toHaveLength(1) - if (parent === 'parent-2') { - expect(h.state('parent-2')).toBeUndefined() - } h.send(turn('turn/completed', CHILD, 'child-2')) - expect(h.state('parent-1')).toBe('completed') expect(h.tracker.state).toBeNull() h.dispose() } @@ -154,8 +132,6 @@ describe('shared child execution projection', () => { h.send(turn('turn/completed', PRIMARY, 'parent-1')) h.send(activity('parent-2', 'interacted')) h.send(turn('turn/completed', PRIMARY, 'parent-2')) - expect(h.state('parent-1')).toBe('completed') - expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state).toBeNull() h.dispose() }) @@ -167,8 +143,6 @@ describe('shared child execution projection', () => { h.send(activity('parent-2', 'interacted')) h.send(activity('parent-1', 'completed')) h.send(turn('turn/started', CHILD, 'child-2')) - expect(h.state('parent-1')).toBe('working') - expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state?.tasks).toHaveLength(1) h.dispose() }) @@ -184,28 +158,20 @@ describe('shared child execution projection', () => { h.send(turn('turn/started', CHILD, 'child-1')) h.send(turn('turn/completed', PRIMARY, 'parent-2')) expect(h.tracker.state?.tasks).toHaveLength(1) - expect(h.state('parent-2')).toBeUndefined() h.send(turn('turn/completed', CHILD, 'child-1')) - expect(h.state('parent-1')).toBe('completed') - expect(h.state('parent-2')).toBeUndefined() expect(h.tracker.state).toBeNull() h.dispose() }) - it('does not expose pending child settlement before journal admission and retries the same fact', () => { + it('settles a child on its own completion, which writes no row the journal could hold back', () => { const h = harness() h.send(turn('turn/started', PRIMARY, 'parent-1')) h.send(activity('parent-1')) h.send(turn('turn/started', CHILD, 'child-1')) h.send(turn('turn/completed', PRIMARY, 'parent-1')) - const complete = turn('turn/completed', CHILD, 'child-1') - h.refuse(true) - expect(h.send(complete)).toEqual({ accepted: false, reason: 'backpressure' }) expect(h.tracker.state?.tasks).toHaveLength(1) - expect(h.state('parent-1')).toBe('working') - h.refuse(false) - expect(h.send(complete)).toEqual({ accepted: true }) - expect(h.state('parent-1')).toBe('completed') + h.refuse(true) + expect(h.send(turn('turn/completed', CHILD, 'child-1'))).toEqual({ accepted: true }) expect(h.tracker.state).toBeNull() h.dispose() }) diff --git a/src/main/codex/codex-subagent-executions.test.ts b/src/main/codex/codex-subagent-executions.test.ts index c7f7955bb23..d298d2d351a 100644 --- a/src/main/codex/codex-subagent-executions.test.ts +++ b/src/main/codex/codex-subagent-executions.test.ts @@ -4,12 +4,12 @@ import { CodexSubagentExecutions } from './codex-subagent-executions' describe('CodexSubagentExecutions retention and identity', () => { it('bounds settled history through repeated execution without evicting live children', () => { const executions = new CodexSubagentExecutions() - executions.register('long-lived', 'long-lived', 'parent') + executions.register('long-lived', 'long-lived') executions.observeTurn('long-lived', 'long-lived-turn', 'working') for (let index = 0; index < 1_000; index++) { const id = `child-${index}` executions.observeTurn(id, id, 'working') - executions.register(id, id, 'parent') + executions.register(id, id) executions.observeTurn(id, id, 'completed') } expect(executions.workingChildren().map((child) => child.agentThreadId)).toEqual(['long-lived']) @@ -18,27 +18,60 @@ describe('CodexSubagentExecutions retention and identity', () => { expect(settledTurns).toBeLessThanOrEqual(256) }) - it('retains early live owner events at capacity and makes room only after settlement', () => { + it('keeps every running child past the settled-history bound', () => { const executions = new CodexSubagentExecutions() - for (let index = 0; index < 128; index++) { + for (let index = 0; index < 200; index++) { + expect(executions.observeTurn(`child-${index}`, `turn-${index}`, 'working')).not.toBeNull() + executions.register(`child-${index}`, `child-${index}`) + } + expect(executions.workingChildren()).toHaveLength(200) + executions.observeTurn('child-0', 'turn-0', 'completed') + executions.register('next', 'next') + expect(executions.find('child-0')).toBeUndefined() + expect(executions.workingChildren()).toHaveLength(199) + }) + + it('evicts a child that ended before one announced but not yet started, which keeps its spawner', () => { + const executions = new CodexSubagentExecutions() + executions.register('announced', 'announced', 'spawner') + for (let index = 0; index < 127; index++) { executions.observeTurn(`child-${index}`, `turn-${index}`, 'working') } - expect(executions.observeTurn('overflow', 'overflow', 'working')).toBeNull() - for (let index = 0; index < 128; index++) { - executions.register(`child-${index}`, `child-${index}`, 'parent') + executions.observeTurn('child-5', 'turn-5', 'completed') + executions.register('next', 'next') + expect(executions.find('child-5')).toBeUndefined() + expect(executions.find('announced')).toMatchObject({ + registered: true, + spawnerThreadId: 'spawner' + }) + executions.observeTurn('announced', 'turn-a', 'working') + expect(executions.workingChildren().map((child) => child.agentThreadId)).toContain('announced') + }) + + it('past the bound, evicts the oldest never-started child once no child has ended', () => { + const executions = new CodexSubagentExecutions() + for (let index = 0; index < 200; index++) { + executions.register(`restored-${index}`, `restored-${index}`) } - expect(executions.workingChildren()).toHaveLength(128) - executions.observeTurn('child-0', 'turn-0', 'completed') - expect(executions.observeTurn('overflow', 'overflow', 'working')).not.toBeNull() - executions.register('overflow', 'overflow', 'parent') - expect(executions.workingChildren()).toHaveLength(128) + expect(executions.retentionSizes().children).toBe(128) + expect(executions.find('restored-0')).toBeUndefined() + expect(executions.find('restored-199')).toBeDefined() + }) + + it('collapses a label’s whitespace, and keeps the first label a child was given', () => { + const executions = new CodexSubagentExecutions() + executions.register('child', ' audit \n the build ') + executions.register('child', 'later name') + executions.register('blank', ' ') + expect(executions.label('child')).toBe('audit the build') + expect(executions.label('blank')).toBeNull() }) it('corrects an unverifiable execution with its own terminal event and ignores stale starts', () => { const executions = new CodexSubagentExecutions() - executions.register('child', 'child', 'parent') + executions.register('child', 'child') executions.observeTurn('child', 'turn', 'working') - executions.settleSession() + executions.closeThread('child') expect(executions.workingChildren()).toEqual([]) expect(executions.observeTurn('child', 'turn', 'working')).toBeNull() expect(executions.observeTurn('child', 'turn', 'completed')?.execution.state).toBe('completed') diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 2bdcb3a35bf..bd9623821bb 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -4,6 +4,8 @@ import { canReplaceSubagentState } from '../../shared/native-chat-subagent-summary' +/** Children kept past this evict the oldest that ran and ended, else the oldest never started; a + * running child is always kept, however many run. */ const MAX_CHILDREN = 128 const MAX_SETTLED_TURNS = 256 /** Turn ordinals remembered per child; a row from an older run reads as its first. */ @@ -18,7 +20,6 @@ export type CodexExecutionChild = { agentThreadId: string registered: boolean label: string | null - parentTurnId: string | null /** The thread whose stream carried this child's `started` activity. Codex * emits that item on the spawning agent's own session, so it names the parent. */ spawnerThreadId: string | null @@ -28,34 +29,17 @@ export type CodexExecutionChild = { turnCount: number } -/** Told when a child's current execution changes, whichever frame changed it. */ -export type CodexExecutionListener = (child: Readonly) => void - /** Child turn events own execution; activity items only identify the child. */ export class CodexSubagentExecutions { private readonly children = new Map() private readonly settledTurns = new Map() - private readonly listeners = new Set() - - /** Every reader of a child's execution follows it here, so none needs the frames that end it. */ - onExecutionChanged(listener: CodexExecutionListener): () => void { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } register( agentThreadId: string, label: string | null, - parentTurnId: string | null | undefined, spawnerThreadId?: string - ): CodexExecutionChild | undefined { + ): CodexExecutionChild { const child = this.child(agentThreadId) - if (!child) { - return undefined - } - if (!child.registered || parentTurnId !== undefined) { - child.parentTurnId = parentTurnId ?? null - } // A child is spawned once; its announcement is delivered twice, never by another thread. child.spawnerThreadId ??= spawnerThreadId ?? null child.registered = true @@ -79,9 +63,6 @@ export class CodexSubagentExecutions { return null } const child = this.child(agentThreadId) - if (!child) { - return null - } this.numberTurn(child, turnId) if ( state === 'working' && @@ -107,7 +88,6 @@ export class CodexSubagentExecutions { } if (state === 'working' || !child.execution || child.execution.turnId === turnId) { child.execution = execution - this.changed(child) } return { child, execution } } @@ -168,15 +148,6 @@ export class CodexSubagentExecutions { ) } - settleSession(): void { - for (const child of this.children.values()) { - if (child.execution?.state === 'working') { - child.execution = { ...child.execution, state: 'unverifiable' } - this.changed(child) - } - } - } - clear(): void { this.children.clear() this.settledTurns.clear() @@ -187,29 +158,26 @@ export class CodexSubagentExecutions { return { children: this.children.size, settledTurns: this.settledTurns.size } } - private changed(child: CodexExecutionChild): void { - for (const listener of this.listeners) { - listener(child) - } - } - - private child(agentThreadId: string): CodexExecutionChild | undefined { + private child(agentThreadId: string): CodexExecutionChild { const existing = this.children.get(agentThreadId) if (existing) { return existing } if (this.children.size >= MAX_CHILDREN) { - const settled = [...this.children].find(([, child]) => child.execution?.state !== 'working') - if (!settled) { - return undefined + // Refusing a running child would hide it while it runs. One announced but not started is + // about to run, so it goes only when no child has ended; a restored history announces many. + const children = [...this.children.values()] + const evicted = + children.find((child) => child.execution !== null && child.execution.state !== 'working') ?? + children.find((child) => child.execution === null) + if (evicted) { + this.children.delete(evicted.agentThreadId) } - this.children.delete(settled[0]) } const child: CodexExecutionChild = { agentThreadId, registered: false, label: null, - parentTurnId: null, spawnerThreadId: null, execution: null, turnOrdinals: new Map(), diff --git a/src/main/codex/codex-subagent-roster-characterization.test.ts b/src/main/codex/codex-subagent-roster-characterization.test.ts deleted file mode 100644 index b0a86bace27..00000000000 --- a/src/main/codex/codex-subagent-roster-characterization.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -// Golden row writes for the Codex subagent roster: every append and publish it makes, and every -// admission it hands back, for scripted event sequences, so moving the roster onto shared code -// cannot change a saved byte. - -import { describe, expect, it } from 'vitest' -import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support' -import type { CodexThreadItem } from './codex-structured-item-translation' -import { CodexSubagentRoster } from './codex-subagent-roster' - -const PARENT = 'thread-parent' - -/** The clock moves once per delivered event, never per read, so a row's times do not depend on how - * often the roster happens to read it. */ -function harness() { - const recorder = recordingSubagentRowSink() - let clock = 1_000 - let parentTurn: string | null = 'turn-1' - const roster = new CodexSubagentRoster({ - sink: recorder.sink, - primaryThreadId: () => PARENT, - activeTurn: (threadId) => (threadId === PARENT ? parentTurn : null), - turnScopeFor: (threadId, turnId): AgentJournalTurnScope => - turnId === null ? { kind: 'thread' } : { kind: 'turn', turnItemId: `${threadId}/${turnId}` }, - now: () => clock - }) - const admissions: boolean[] = [] - const note = (admission: { accepted: boolean } | null) => { - admissions.push(admission?.accepted ?? true) - } - const tick = (deliver: () => T): T => { - clock += 10 - return deliver() - } - const activity = (kind: string, child: string, path: string | null): CodexThreadItem => ({ - type: 'subAgentActivity', - id: `item-${child}-${kind}`, - kind, - agentThreadId: child, - agentPath: path - }) - return { - ...recorder, - roster, - admissions, - parentTurn: (next: string | null) => { - parentTurn = next - }, - tick, - announce: (kind: string, child: string, path: string | null) => { - clock += 10 - note( - roster.handleItem({ - threadId: PARENT, - turnId: parentTurn, - item: activity(kind, child, path) - }) - ) - }, - turnStarted: (child: string, turnId: string) => { - clock += 10 - note(roster.handleTurnEvent({ method: 'turn/started', threadId: child, params: { turnId } })) - }, - turnCompleted: (child: string, turnId: string, status: string) => { - clock += 10 - note( - roster.handleTurnEvent({ - method: 'turn/completed', - threadId: child, - params: { turn: { id: turnId, status } } - }) - ) - }, - tokens: (child: string, totalTokens: number) => { - clock += 10 - note(roster.handleTokenUsage({ threadId: child, tokenUsage: { total: { totalTokens } } })) - } - } -} - -describe('Codex subagent roster rows (characterization)', () => { - it('one child: announced, runs a turn, reports tokens, completes', () => { - const h = harness() - h.announce('started', 'c1', '/root/reader') - h.turnStarted('c1', 'ct1') - h.announce('started', 'c1', '/root/reader') - h.tokens('c1', 120) - h.tokens('c1', 120) - h.tokens('c1', 340) - h.turnCompleted('c1', 'ct1', 'completed') - h.tokens('c1', 400) - expect({ log: h.log, admissions: h.admissions }).toMatchSnapshot() - }) - - it('two children sharing a path label, one failing and one interrupted', () => { - const h = harness() - h.turnStarted('c1', 'ct1') - h.announce('started', 'c1', '/root/worker') - h.turnStarted('c2', 'ct2') - h.announce('started', 'c2', '/root/team/worker') - h.turnStarted('c3', 'ct3') - h.announce('started', 'c3', '/root/worker 2') - h.turnCompleted('c1', 'ct1', 'failed') - h.turnCompleted('c2', 'ct2', 'interrupted') - h.turnCompleted('c3', 'ct3', 'mystery') - expect(h.log).toMatchSnapshot() - }) - - it('a child given new work in a later parent turn, and a late end of its earlier run', () => { - const h = harness() - h.turnStarted('c1', 'ct1') - h.announce('started', 'c1', '/root/reader') - h.turnCompleted('c1', 'ct1', 'completed') - h.parentTurn('turn-2') - h.announce('interacted', 'c1', '/root/reader') - h.turnStarted('c1', 'ct2') - h.turnCompleted('c1', 'ct1', 'failed') - h.turnStarted('c1', 'ct1') - h.turnCompleted('c1', 'ct2', 'completed') - expect(h.log).toMatchSnapshot() - }) - - it('a child running when the session ends, then a late verdict', () => { - const h = harness() - h.turnStarted('c1', 'ct1') - h.announce('started', 'c1', '/root/a') - h.turnStarted('c2', 'ct2') - h.announce('started', 'c2', '/root/b') - h.turnCompleted('c2', 'ct2', 'completed') - note(h.tick(() => h.roster.settleSession())) - h.turnCompleted('c1', 'ct1', 'completed') - expect(h.log).toMatchSnapshot() - - function note(admission: { accepted: boolean }) { - h.admissions.push(admission.accepted) - } - }) - - it('a child thread closing mid-turn, then its turn completing', () => { - const h = harness() - h.turnStarted('c1', 'ct1') - h.announce('started', 'c1', '/root/a') - h.tick(() => h.roster.executions.closeThread('c1')) - h.turnCompleted('c1', 'ct1', 'completed') - expect(h.log).toMatchSnapshot() - }) - - it('a child announced outside any parent turn', () => { - const h = harness() - h.parentTurn(null) - h.turnStarted('c1', 'ct1') - h.announce('started', 'c1', '/root/loose') - h.parentTurn('turn-5') - h.turnCompleted('c1', 'ct1', 'completed') - expect(h.log).toMatchSnapshot() - }) - - it('more than 32 settled groups, then a late frame for an evicted run', () => { - const h = harness() - for (let index = 0; index < 34; index++) { - h.parentTurn(`turn-${index}`) - h.turnStarted(`c${index}`, `ct${index}`) - h.announce('started', `c${index}`, `/root/job${index}`) - h.turnCompleted(`c${index}`, `ct${index}`, 'completed') - } - h.parentTurn('turn-live') - h.turnStarted('live', 'ctl') - h.announce('started', 'live', '/root/live') - h.turnCompleted('c0', 'ct0', 'failed') - h.turnStarted('c0', 'ct0') - h.announce('started', 'c0', '/root/job0') - expect(h.log.length).toMatchSnapshot() - expect(h.log.slice(-6)).toMatchSnapshot() - expect(h.roster.retentionSizes()).toMatchSnapshot() - }) - - it('a refused append or publish is handed back, and the redelivered frame writes it', () => { - const h = harness() - h.turnStarted('c1', 'ct1') - h.refuseNextAppend() - h.announce('started', 'c1', '/root/a') - h.announce('started', 'c1', '/root/a') - h.refuseNextPublish() - h.turnCompleted('c1', 'ct1', 'completed') - h.turnCompleted('c1', 'ct1', 'completed') - h.tokens('c1', 50) - expect({ log: h.log, admissions: h.admissions }).toMatchSnapshot() - }) -}) diff --git a/src/main/codex/codex-subagent-roster-restart.test.ts b/src/main/codex/codex-subagent-roster-restart.test.ts deleted file mode 100644 index 6808820713f..00000000000 --- a/src/main/codex/codex-subagent-roster-restart.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { isSubagentGroupBlock } from '../../shared/native-chat-types' -import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' -import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support' -import { - CodexSubagentRoster, - codexSubagentGroupId, - codexSubagentGroupIdentity -} from './codex-subagent-roster' - -const PARENT = 'thread-parent' -const OUTSIDE = codexSubagentGroupId(PARENT, null) - -describe('Codex subagent roster after a provider restart', () => { - it('continues the row no turn owns instead of rewriting it from one child', () => { - const recorder = recordingSubagentRowSink() - const earlier = subagentGroupJournalBody(OUTSIDE, [ - { id: 'c1', label: 'one', state: 'completed', startedAt: 1, settledAt: 2 }, - { id: 'c2', label: 'two', state: 'unverifiable', startedAt: 1 } - ]) - const roster = new CodexSubagentRoster({ - sink: { - ...recorder.sink, - journalLinkage: () => ({ - epoch: 'e1', - visitItemsWithLinkage: (visit) => - visit(agentJournalItemKey(codexSubagentGroupIdentity(OUTSIDE)), 1, earlier, { - turnScope: { kind: 'thread' } - }) - }) - }, - primaryThreadId: () => PARENT, - activeTurn: () => null, - turnScopeFor: () => ({ kind: 'thread' }) - }) - roster.handleTurn({ threadId: 'c3', turnId: 't3', state: 'working' }) - roster.handleItem({ - threadId: PARENT, - turnId: null, - item: { - type: 'subAgentActivity', - id: 'i3', - kind: 'started', - agentThreadId: 'c3', - agentPath: '/root/three' - } - }) - - const last = recorder.log.findLast((write) => write.op === 'append') - const agents = - last?.op === 'append' && last.body.kind === 'message' - ? last.body.blocks.find(isSubagentGroupBlock)?.agents - : undefined - expect(last?.op === 'append' && last.identity).toEqual(codexSubagentGroupIdentity(OUTSIDE)) - expect(agents?.map((agent) => [agent.id, agent.label, agent.state])).toEqual([ - ['c1', 'one', 'completed'], - ['c2', 'two', 'unverifiable'], - ['c3', 'three', 'working'] - ]) - }) -}) diff --git a/src/main/codex/codex-subagent-roster-retention.test.ts b/src/main/codex/codex-subagent-roster-retention.test.ts deleted file mode 100644 index 92de0fc2aea..00000000000 --- a/src/main/codex/codex-subagent-roster-retention.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { isSubagentGroupBlock, type NativeChatSubagentState } from '../../shared/native-chat-types' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { - createDeferredStructuredAgentSessionEventSink, - type StructuredAgentSessionEventSink -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { CodexSubagentRoster } from './codex-subagent-roster' -import { MAX_CODEX_SUBAGENT_GROUPS } from './codex-structured-journal-limits' - -const THREAD = 'parent' -const ORIGINAL = 'original' -const journals = createTrackedJournalOpener() -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - for (const cleanup of cleanups.splice(0)) { - await cleanup() - } - await journals.closeAll() -}) - -async function session(refuse?: 'append' | 'publish') { - const root = await mkdtemp(join(tmpdir(), 'orca-codex-roster-retention-')) - const journal = await journals.open({ - identity: { - sessionId: 'session-codex-roster-retention', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: codexProviderHandle(THREAD) - }, - stateDirectory: root, - now: () => 1_000 - }) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind({ journal, fence: 1, publish: () => {} }) - const append = deferred.sink.tryAppendItem - const publish = deferred.sink.tryPublish - if (!append || !publish) { - throw new Error('the deferred sink must expose append and publish admission') - } - let refusing = false - let writingOriginal = false - const refusal = { accepted: false, reason: 'backpressure' } as const - const sink: StructuredAgentSessionEventSink = { - ...deferred.sink, - tryAppendItem: (identity, body, options) => { - writingOriginal = - identity.provider === 'orca' && - identity.clientMessageId === `codex-subagents:${THREAD}:${ORIGINAL}` - return refusing && writingOriginal && refuse === 'append' - ? refusal - : append(identity, body, options) - }, - tryPublish: () => (refusing && writingOriginal && refuse === 'publish' ? refusal : publish()) - } - let parentTurn = ORIGINAL - let clock = 1_000 - const roster = new CodexSubagentRoster({ - sink, - primaryThreadId: () => THREAD, - activeTurn: () => parentTurn, - turnScopeFor: (_thread, turnId) => ({ kind: 'turn', turnItemId: turnId ?? 'outside-turn' }), - now: () => ++clock - }) - cleanups.push(async () => { - roster.dispose() - deferred.close() - await journal.close() - await rm(root, { recursive: true, force: true }) - }) - const turn = (id: string, state: NativeChatSubagentState, turnId = `execution:${id}`) => - roster.handleTurn({ threadId: id, turnId, state }) - const announce = (id: string, kind = 'started') => - roster.handleItem({ - threadId: THREAD, - turnId: parentTurn, - item: { - type: 'subAgentActivity', - id: `activity:${id}:${kind}`, - agentThreadId: id, - agentPath: '/root/task', - kind - } - }) - function spawn(id: string, parent = ORIGINAL) { - parentTurn = parent - expect(turn(id, 'working')).toEqual({ accepted: true }) - expect(announce(id)).toEqual({ accepted: true }) - } - async function rows() { - await deferred.drained() - return journal - .snapshot() - .items.flatMap((row) => - row.body.kind === 'message' - ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) - : [] - ) - } - return { - roster, - turn, - announce, - spawn, - rows, - original: async () => - (await rows()).find(({ group }) => group.groupId === `${THREAD}:${ORIGINAL}`), - parent: (turnId: string) => { - parentTurn = turnId - }, - refuse: (value: boolean) => { - refusing = value - } - } -} - -type Session = Awaited> -async function laterGroups(s: Session, settled: boolean, count = MAX_CODEX_SUBAGENT_GROUPS + 2) { - for (let index = 0; index < count; index++) { - const id = `later-child-${index}` - s.spawn(id, `later-parent-${index}`) - if (settled) { - expect(s.turn(id, 'completed')).toEqual({ accepted: true }) - } - if (index % 32 === 31) { - await s.rows() - } - } - await s.rows() -} - -function states(s: Awaited>) { - return s?.group.agents.map(({ id, state }) => ({ id, state })) -} - -describe('Codex roster retention through the real sink and journal', () => { - it.each([ - [false, 'completed'], - [true, 'completed'], - [false, 'sweep'], - [true, 'sweep'] - ] as const)( - 'preserves all oldest live children when later groups settled=%s and final event=%s', - async (settled, final) => { - const s = await session() - for (const id of ['old-a', 'old-b', 'old-c']) { - s.spawn(id) - } - const before = await s.original() - expect(before?.group.agents).toHaveLength(3) - await laterGroups(s, settled) - s.parent('current') - s.announce('old-a', 'interacted') - expect(states(await s.original())).toEqual( - ['old-a', 'old-b', 'old-c'].map((id) => ({ id, state: 'working' })) - ) - if (final === 'sweep') { - expect(s.roster.settleSession()).toEqual({ accepted: true }) - } else { - for (const id of ['old-a', 'old-b', 'old-c']) { - s.turn(id, 'completed') - } - } - const after = await s.original() - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(after?.row.turnScope).toEqual(before?.row.turnScope) - expect(states(after)).toEqual( - ['old-a', 'old-b', 'old-c'].map((id) => ({ - id, - state: final === 'sweep' ? 'unverifiable' : 'completed' - })) - ) - expect(after?.group.agents.every((entry) => entry.settledAt !== undefined)).toBe(true) - expect( - (await s.rows()).flatMap(({ group }) => - group.agents.filter((entry) => entry.id === 'old-a') - ) - ).toHaveLength(1) - } - ) - - it('ignores late reports for evicted settled executions even after the execution cache has pruned them', async () => { - const s = await session() - s.spawn('old-a') - s.spawn('old-b') - s.turn('old-a', 'stopped') - s.turn('old-b', 'completed') - const before = await s.original() - await laterGroups(s, true, 270) - expect(s.roster.executions.find('old-a')).toBeUndefined() - expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) - s.parent('current') - s.turn('old-a', 'working') - s.announce('old-a') - s.announce('old-a', 'interacted') - s.turn('old-a', 'completed') - s.announce('old-a', 'completed') - s.roster.handleTokenUsage({ threadId: 'old-a', tokenUsage: { total: { totalTokens: 123 } } }) - expect(await s.original()).toEqual(before) - expect(s.roster.executions.find('old-a')?.execution).toBeNull() - expect( - (await s.rows()).flatMap(({ group }) => group.agents.filter((entry) => entry.id === 'old-a')) - ).toHaveLength(1) - }) - - it.each([ - ['before', false], - ['after', false], - ['before', true], - ['after', true] - ] as const)( - 'admits a genuine new child turn when the announcement is %s its start and execution cache pruned=%s', - async (order, pruned) => { - const s = await session() - s.spawn('old-a') - s.turn('old-a', 'completed') - const before = await s.original() - await laterGroups(s, true, pruned ? 270 : MAX_CODEX_SUBAGENT_GROUPS + 2) - s.parent('follow-up') - if (order === 'before') { - s.announce('old-a', 'interacted') - } - s.turn('old-a', 'working', 'new-execution') - if (order === 'after') { - s.announce('old-a', 'interacted') - } - const followup = () => - s.rows().then((rows) => rows.find(({ group }) => group.groupId === `${THREAD}:follow-up`)) - expect(states(await followup())).toEqual([{ id: 'old-a', state: 'working' }]) - s.turn('old-a', 'working') - s.turn('old-a', 'completed') - expect(states(await followup())).toEqual([{ id: 'old-a', state: 'working' }]) - s.turn('old-a', 'completed', 'new-execution') - expect(states(await followup())).toEqual([{ id: 'old-a', state: 'completed' }]) - expect(await s.original()).toEqual(before) - } - ) - - it('suppresses both settled executions after same-group reuse, eviction and execution-cache churn', async () => { - const s = await session() - s.spawn('old-a') - s.spawn('old-b') - s.turn('old-a', 'completed') - s.announce('old-a', 'interacted') - s.turn('old-a', 'working', 'second-execution') - s.turn('old-a', 'completed', 'second-execution') - s.turn('old-b', 'completed') - const original = await s.original() - await laterGroups(s, true, 270) - expect(s.roster.executions.find('old-a')).toBeUndefined() - expect(s.roster.retentionSizes().settledIdentities).toBeLessThan(2048) - const before = await s.rows() - s.parent('current') - for (const turnId of ['execution:old-a', 'second-execution']) { - s.turn('old-a', 'working', turnId) - s.announce('old-a') - s.turn('old-a', 'completed', turnId) - s.announce('old-a', 'completed') - } - expect(await s.rows()).toEqual(before) - s.announce('old-a', 'interacted') - s.turn('old-a', 'working', 'third-execution') - const current = (await s.rows()).find(({ group }) => group.groupId === `${THREAD}:current`) - expect(states(current)).toEqual([{ id: 'old-a', state: 'working' }]) - s.turn('old-a', 'completed', 'third-execution') - expect(await s.original()).toEqual(original) - }) - - it.each(['append', 'publish'] as const)( - 'retains final retry ownership after %s refuses settlement under group pressure', - async (refuse) => { - const s = await session(refuse) - s.spawn('old-a') - s.spawn('old-b') - const before = await s.original() - await laterGroups(s, false) - s.turn('old-b', 'completed') - s.refuse(true) - expect(s.turn('old-a', 'completed')).toEqual({ accepted: false, reason: 'backpressure' }) - s.spawn('more-live', 'current') - expect(s.roster.retentionSizes().groups).toBeGreaterThan(MAX_CODEX_SUBAGENT_GROUPS) - s.refuse(false) - expect(s.roster.settleSession()).toEqual({ accepted: true }) - const after = await s.original() - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(states(after)).toEqual([ - { id: 'old-a', state: 'completed' }, - { id: 'old-b', state: 'completed' } - ]) - expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) - } - ) - - it.each(['append', 'publish'] as const)( - 'retries a refused final sweep %s after more live groups arrive', - async (refuse) => { - const s = await session(refuse) - s.spawn('old-a') - s.spawn('old-b') - const before = await s.original() - await laterGroups(s, false) - s.refuse(true) - expect(s.roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' }) - s.spawn('more-live', 'current') - s.refuse(false) - expect(s.roster.settleSession()).toEqual({ accepted: true }) - const after = await s.original() - expect(after?.row.itemId).toBe(before?.row.itemId) - expect(states(after)).toEqual([ - { id: 'old-a', state: 'unverifiable' }, - { id: 'old-b', state: 'unverifiable' } - ]) - expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) - } - ) -}) diff --git a/src/main/codex/codex-subagent-roster-state.ts b/src/main/codex/codex-subagent-roster-state.ts deleted file mode 100644 index 531a5e38195..00000000000 --- a/src/main/codex/codex-subagent-roster-state.ts +++ /dev/null @@ -1,23 +0,0 @@ -// The ids a Codex spawn group's journal row is keyed on. - -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' - -const OUTSIDE_TURN = 'outside-turn' - -/** Group identity: the parent turn that spawned the children. `agentPath` is a - * tree rooted at the parent thread, so every child of one turn shares a row - * no matter which thread's stream carried its activity item. */ -export function codexSubagentGroupId(threadId: string, turnId: string | null): string { - return `${threadId}:${turnId ?? OUTSIDE_TURN}` -} - -/** The group of children reported outside any of the parent's turns. */ -export function isCodexOutsideTurnGroup(groupId: string): boolean { - return groupId.endsWith(`:${OUTSIDE_TURN}`) -} - -/** Durable journal identity for the group's row — stable across revisions and - * across a restart, so replay finds the same row instead of appending a new one. */ -export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } -} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts deleted file mode 100644 index ac56c042ba2..00000000000 --- a/src/main/codex/codex-subagent-roster.test.ts +++ /dev/null @@ -1,779 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import { describe, expect, it } from 'vitest' -import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' -import { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types' -import { - createDeferredStructuredAgentSessionEventSink, - type StructuredAgentSessionEventSink, - type StructuredAgentSessionEventTarget -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' -import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { - CodexSubagentRoster, - codexSubagentGroupIdentity, - codexSubagentGroupId -} from './codex-subagent-roster' -import type { CodexThreadItem } from './codex-structured-item-translation' -import { - MAX_CODEX_SUBAGENT_GROUPS, - MAX_CODEX_SUBAGENTS_PER_GROUP, - MAX_CODEX_TOKEN_USAGE_THREADS -} from './codex-structured-journal-limits' - -const THREAD = 'thread-parent' -const TURN = 'turn-1' - -type Appended = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } - -function createHarness(options: { threadId?: string | null } = {}): { - roster: CodexSubagentRoster - appended: Appended[] - agents: () => NativeChatSubagentEntry[] - latest: () => Appended | undefined -} { - const appended: Appended[] = [] - let clock = 1_000 - const sink: StructuredAgentSessionEventSink = { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body) => { - appended.push({ identity, body }) - return { accepted: true } - }, - tryPublish: () => ({ accepted: true }) - } - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink, - primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId), - activeTurn: () => TURN, - now: () => (clock += 1) - }) - const agents = (): NativeChatSubagentEntry[] => { - const body = appended.at(-1)?.body - if (!body || body.kind !== 'message') { - return [] - } - const block = body.blocks.find(isSubagentGroupBlock) - return block ? block.agents : [] - } - return { roster, appended, agents, latest: () => appended.at(-1) } -} - -function latestIdentity(appended: Appended[]): AgentJournalItemIdentity | undefined { - return appended.at(-1)?.identity -} - -function activity(input: { - id?: string - kind: string - agentThreadId: string - agentPath: string | null -}): CodexThreadItem { - return { - type: 'subAgentActivity', - id: input.id ?? `item-${input.agentThreadId}-${input.kind}`, - kind: input.kind, - agentThreadId: input.agentThreadId, - agentPath: input.agentPath - } -} - -function deliver( - roster: CodexSubagentRoster, - item: CodexThreadItem, - turnId: string | null = TURN -): void { - // The fixture includes the child's owner event separately from its activity metadata. - const state = - item.kind === 'started' - ? 'working' - : item.kind === 'completed' - ? 'completed' - : item.kind === 'interrupted' - ? 'stopped' - : null - if (state && typeof item.agentThreadId === 'string') { - roster.handleTurn({ - threadId: item.agentThreadId, - turnId: `execution:${item.agentThreadId}`, - state - }) - } - // Every activity item reaches the wire twice: item/started, then item/completed. - roster.handleItem({ threadId: THREAD, turnId, item }) - roster.handleItem({ threadId: THREAD, turnId, item }) -} - -/** The real event sink, unbound until `drain`, over a journal double that records appends. */ -function createQueuedHarness(): { - roster: CodexSubagentRoster - appended: Appended[] - drain: () => void -} { - const appended: Appended[] = [] - let clock = 1_000 - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - const journal = withJournalQueueMembers({ - appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - appended.push({ identity, body }) - return { cursor: { epoch: 'e', sequence: appended.length } } - } - }) - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink: deferred.sink, - primaryThreadId: () => THREAD, - activeTurn: () => TURN, - now: () => (clock += 1) - }) - return { - roster, - appended, - drain: () => - deferred.bind({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the roster's rows reach only appendItem and the in-order members the helper adds. - journal: journal as unknown as StructuredAgentSessionEventTarget['journal'], - fence: 1, - publish: () => {} - }) - } -} - -describe('CodexSubagentRoster', () => { - it('lands the roster append its own publish was buffered beside', () => { - const { roster, appended, drain } = createQueuedHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - drain() - - // A publish once shared the append's coalescing key and spliced it out of the queue, and - // `lastSerialized` then suppressed every retry. - expect(appended).toHaveLength(1) - }) - - it('counts a /morpheus agent as a child — only /root is the turn itself', () => { - const { roster, agents } = createHarness() - - deliver(roster, activity({ kind: 'started', agentThreadId: 'child-m', agentPath: '/morpheus' })) - - expect(agents()).toMatchObject([{ id: 'child-m', label: 'morpheus', state: 'working' }]) - }) - - // `codexSubagentPathSegments` already defines what a path means for the label, - // and the root check has to agree with it: a path that normalizes to the same - // node must classify the same way, or one string is both the turn itself and a - // child of it — a phantom row labelled `root` inflating the group by one. - it('reads a root path with a trailing or doubled separator as the turn itself', () => { - for (const agentPath of ['/root/', '/root//', '//root']) { - const { roster, appended } = createHarness() - - deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath })) - - expect(appended).toEqual([]) - } - }) - - it('keeps a doubled separator inside a child path off the label', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root//read/' }) - ) - - expect(agents()).toMatchObject([{ id: 'child-1', label: 'read' }]) - }) - - // An all-whitespace trailing segment survives the empty-segment filter and - // would draw a row with no visible name at all. - it('falls back to the placeholder when the trailing segment has nothing to show', () => { - const { roster, agents } = createHarness() - - deliver(roster, activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/ ' })) - - expect(agents()).toMatchObject([{ id: 'child-1', label: 'subagent' }]) - }) - - // The collision ordinal keys on the label, so two segments that render - // identically must collide rather than both draw as `read`. - it('collides labels that differ only in surrounding whitespace', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/ read ' }) - ) - - expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2']) - }) - - it('ignores the root node so a turn is not its own subagent', () => { - const { roster, appended } = createHarness() - - deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath: '/root' })) - - expect(appended).toEqual([]) - }) - - it('writes an admissible journal body carrying a plain-text fallback block', () => { - const { roster, latest } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/list_directory' }) - ) - - const body = latest()?.body - expect(body?.kind).toBe('message') - expect(isAdmissibleAgentJournalItemBody(body)).toBe(true) - expect(body?.kind === 'message' ? body.blocks.map((block) => block.type) : []).toEqual([ - 'text', - 'subagent-group' - ]) - expect( - body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' - ).toBe('Kicked off 1 subagent') - }) - - it('keys the durable identity by the parent turn so a revision lands on one row', () => { - const { roster, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - const expected = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, TURN)) - expect(new Set(appended.map((entry) => JSON.stringify(entry.identity)))).toEqual( - new Set([JSON.stringify(expected)]) - ) - }) - - it('rule 1 — a duplicate delivery writes no second revision', () => { - const { roster, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(appended).toHaveLength(1) - }) - - it('registers a child after its owner already reported completion', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-late', agentPath: '/root/search' }) - ) - - expect(agents()).toMatchObject([{ id: 'child-late', label: 'search', state: 'completed' }]) - }) - - it('rule 3 — a terminal state latches against a late or duplicate start', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(agents()).toMatchObject([{ state: 'completed' }]) - }) - - it('rule 4 — the session sweep settles a lost child as unverifiable, not exited', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-2', agentPath: '/root/search' }) - ) - roster.settleSession() - - expect(agents()).toMatchObject([ - { id: 'child-1', state: 'unverifiable' }, - { id: 'child-2', state: 'completed' } - ]) - }) - - it('lets a swept child still report what it actually did', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.settleSession() - expect(agents()[0]?.state).toBe('unverifiable') - - // Contact can return — a reconnected provider replays the child's own - // verdict. Latching the sweep would report a child that finished as one we - // never saw finish. - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - expect(agents()[0]?.state).toBe('completed') - }) - - it('refuses to put a swept child back to working', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.settleSession() - // A straggler progress tick after we gave up must not re-light the row. - deliver( - roster, - activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - expect(agents()[0]?.state).toBe('unverifiable') - }) - - it('keeps a real verdict when a later frame disagrees', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'interrupted', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - expect(agents()[0]?.state).toBe('completed') - }) - - it('rule 4 — the session sweep settles every group and never un-terminals one', () => { - const { roster, agents, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.settleSession() - const afterFirstSweep = appended.length - roster.settleSession() - - expect(agents()).toMatchObject([{ state: 'unverifiable' }]) - expect(appended).toHaveLength(afterFirstSweep) - }) - - it('rule 5 — the whole roster is persisted in the carrier, not just a count', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 40661 } } }) - - expect(agents()).toMatchObject([ - { id: 'child-1', label: 'read', state: 'working', tokens: 40661 } - ]) - }) - - it('rule 6 — the group id names the parent turn, or says there was none', () => { - const { roster, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/search' }), - null - ) - - expect(appended.map((entry) => entry.identity)).toEqual([ - { provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:${TURN}` }, - { provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:outside-turn` } - ]) - }) - - it('disambiguates two children that share a trailing path segment', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/read' }) - ) - - expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2']) - }) - - it('takes the latest token snapshot per child and never accumulates updates', () => { - const { roster, agents } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 100 } } }) - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 250 } } }) - - expect(agents()).toMatchObject([{ tokens: 250 }]) - }) - - it('retains a usage frame that arrives before the child is known', () => { - const { roster, agents } = createHarness() - - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } }) - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(agents()).toMatchObject([{ tokens: 900 }]) - }) - - it('never attributes the parent thread its own usage', () => { - const { roster, agents, appended } = createHarness() - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - const beforeParentUsage = appended.length - roster.handleTokenUsage({ threadId: THREAD, tokenUsage: { total: { totalTokens: 26099 } } }) - - expect(appended).toHaveLength(beforeParentUsage) - expect(agents()).toHaveLength(1) - expect(agents()[0]).not.toHaveProperty('tokens') - }) - - // The row is durable and both readers clip these fields to the same cap, so - // writing more than that is bytes replayed on every reconnect and then thrown - // away. The marker is an ellipsis, not the tool-output truncation sentence: - // `id` is the roster key and the renderer's React key. - it('bounds the provider strings the roster row carries into the journal', () => { - const { roster, agents, latest } = createHarness() - const oversized = 'a'.repeat(20 * 1024) - - deliver( - roster, - activity({ kind: 'started', agentThreadId: oversized, agentPath: `/root/${oversized}` }) - ) - - const entry = agents()[0] - expect(entry?.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - expect(entry?.label).toMatch(/…~0$/) - expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - expect(entry?.id).toMatch(/…~0$/) - expect(JSON.stringify(latest()?.body)).not.toContain('output truncated') - expect(isAdmissibleAgentJournalItemBody(latest()?.body)).toBe(true) - }) - - // The clip cuts UTF-16 code units, so a boundary landing inside a surrogate - // pair left a LONE high surrogate in a durable row — malformed, and replaced - // with U+FFFD through any non-JSON UTF-8 hop. - it('never clips a provider string mid surrogate pair', () => { - const { roster, agents } = createHarness() - const astral = '😀'.repeat(400) - - deliver( - roster, - activity({ kind: 'started', agentThreadId: astral, agentPath: `/root/${astral}` }) - ) - - const entry = agents()[0] - expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - expect(Buffer.from(entry?.id ?? '', 'utf8').toString('utf8')).toBe(entry?.id) - expect(Buffer.from(entry?.label ?? '', 'utf8').toString('utf8')).toBe(entry?.label) - }) - - // The clip removes exactly the tail that told two children apart: `id` is the - // renderer's React key, and `claimLabel` writes its repeat ordinal at the end. - // Two clipped children collapsing to one key drew two rows under one identity. - it('keeps clipped ids and labels distinct between children', () => { - const { roster, agents } = createHarness() - const prefix = 'p'.repeat(MAX_SUBAGENT_FIELD_CHARS) - const sharedPath = `/root/${'q'.repeat(640)}` - - deliver( - roster, - activity({ kind: 'started', agentThreadId: `${prefix}AAAA`, agentPath: sharedPath }) - ) - deliver( - roster, - activity({ kind: 'started', agentThreadId: `${prefix}BBBB`, agentPath: sharedPath }) - ) - - const entries = agents() - expect(entries).toHaveLength(2) - expect(new Set(entries.map((agent) => agent.id)).size).toBe(2) - expect(new Set(entries.map((agent) => agent.label)).size).toBe(2) - for (const agent of entries) { - expect(agent.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - expect(agent.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) - } - }) - - it('caps the children one spawn group admits', () => { - const { roster, agents, appended } = createHarness() - for (let index = 0; index < MAX_CODEX_SUBAGENTS_PER_GROUP; index++) { - deliver( - roster, - activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }) - ) - } - const atCap = appended.length - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-over-cap', agentPath: '/root/read' }) - ) - - expect(agents()).toHaveLength(MAX_CODEX_SUBAGENTS_PER_GROUP) - expect(agents().map((agent) => agent.id)).not.toContain('child-over-cap') - // Refusing the child must not burn a revision either. - expect(appended).toHaveLength(atCap) - }) - - it('retains live spawn groups beyond the settled history budget', () => { - const { roster, appended, agents } = createHarness() - for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) { - deliver( - roster, - activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }), - `turn-${index}` - ) - } - const original = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0')) - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-late', agentPath: '/root/search' }), - 'turn-0' - ) - - expect(latestIdentity(appended)).toEqual(original) - expect(agents().map((agent) => agent.id)).toEqual(['child-0', 'child-late']) - expect(roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS + 1) - }) - - it('keeps a token count a later thread-map eviction would otherwise retract', () => { - const { roster, agents } = createHarness() - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 4242 } } }) - expect(agents()).toMatchObject([{ tokens: 4242 }]) - - for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) { - roster.handleTokenUsage({ - threadId: `other-${index}`, - tokenUsage: { total: { totalTokens: index } } - }) - } - deliver( - roster, - activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(agents()).toMatchObject([{ state: 'completed', tokens: 4242 }]) - }) - - it('caps retained usage threads, so a frame evicted before its child is dropped', () => { - const { roster, agents } = createHarness() - roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } }) - for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) { - roster.handleTokenUsage({ - threadId: `other-${index}`, - tokenUsage: { total: { totalTokens: index } } - }) - } - - deliver( - roster, - activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - ) - - expect(agents()[0]).not.toHaveProperty('tokens') - }) - - it('declines a payload that is not a subagent item or a usage frame', () => { - const { roster } = createHarness() - - expect( - roster.handleItem({ - threadId: THREAD, - turnId: TURN, - item: { type: 'commandExecution', id: 'item-9' } - }) - ).toBeNull() - expect(roster.handleTokenUsage({ threadId: 'child-1' })).toBeNull() - }) - - // A refusal must never advance the duplicate-suppression state: an identical - // replay would short-circuit and the revision would never be retried. The - // append and the publish are the two ways to be refused, so both are covered. - it.each([{ refuse: 'append' as const }, { refuse: 'publish' as const }])( - 'retries the same revision after the $refuse is refused', - ({ refuse }) => { - let refusing = true - const appended: Appended[] = [] - const published: number[] = [] - const refusal = { accepted: false, reason: 'backpressure' } as const - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink: { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body) => { - if (refusing && refuse === 'append') { - return refusal - } - appended.push({ identity, body }) - return { accepted: true } - }, - tryPublish: () => { - if (refusing && refuse === 'publish') { - return refusal - } - published.push(1) - return { accepted: true } - } - }, - primaryThreadId: () => THREAD, - activeTurn: () => TURN, - now: () => 1_000 - }) - const item = activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) - - expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual(refusal) - - // The wire redelivers the very same item; nothing about the roster changed, - // so only a cleared suppression state can get the revision out. - refusing = false - expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual({ - accepted: true - }) - // The retry re-appends when the publish was the half that failed; the real - // sink writes both, as revisions of the one group row. What must not happen - // is the revision never being published at all. - expect(published).toHaveLength(1) - const body = appended.at(-1)?.body - expect( - body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : [] - ).toMatchObject([{ agents: [{ id: 'child-1', state: 'working' }] }]) - } - ) - - // The sweep is the last event a group ever gets. A refusal there, left - // unretried, strands the settled roster's final revision — the exact "row - // stays stale forever" this row exists to prevent. - it('republishes the settled roster when the sweep publish was refused', () => { - let refusing = false - const appended: Appended[] = [] - const published: number[] = [] - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink: { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body) => { - appended.push({ identity, body }) - return { accepted: true } - }, - tryPublish: () => { - if (refusing) { - return { accepted: false, reason: 'backpressure' } - } - published.push(1) - return { accepted: true } - } - }, - primaryThreadId: () => THREAD, - activeTurn: () => TURN, - now: () => 1_000 - }) - roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) - roster.handleItem({ - threadId: THREAD, - turnId: TURN, - item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - }) - const publishedBeforeSweep = published.length - - refusing = true - expect(roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' }) - - // The retry sweep flips no state — every child already latched — so only a - // cleared suppression state can carry the unverifiable roster out. - refusing = false - expect(roster.settleSession()).toEqual({ accepted: true }) - expect(published.length).toBe(publishedBeforeSweep + 1) - const body = appended.at(-1)?.body - expect(body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []).toMatchObject([ - { agents: [{ id: 'child-1', state: 'unverifiable' }] } - ]) - }) - - it('propagates sink backpressure instead of reporting the row as written', () => { - const roster = new CodexSubagentRoster({ - turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink: { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: () => ({ accepted: false, reason: 'backpressure' }), - tryPublish: () => ({ accepted: true }) - }, - primaryThreadId: () => THREAD, - activeTurn: () => TURN - }) - - roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) - expect( - roster.handleItem({ - threadId: THREAD, - turnId: TURN, - item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) - }) - ).toEqual({ accepted: false, reason: 'backpressure' }) - }) -}) diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts deleted file mode 100644 index ac00a0cafbc..00000000000 --- a/src/main/codex/codex-subagent-roster.ts +++ /dev/null @@ -1,241 +0,0 @@ -// The Codex subagent roster: one journal row per spawn group, revised in place. -// -// An announcement supplies membership — a `subAgentActivity` item, or in -// Codex's default multi-agent mode any collab call naming the helper — and -// child turn events supply execution state: each child turn is one run of the -// child. Everything the row says follows the shared subagent tracker's rules; -// this module only reads Codex's events. Rows earlier provider runs journaled -// are inherited, so a restart neither splits a child nor rewrites the row no -// turn owns from one child. - -import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import type { NativeChatSubagentState } from '../../shared/native-chat-types' -import type { - StructuredAgentSessionEventSink, - StructuredAgentSessionSinkAdmission -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { JournaledSubagentGroups } from '../native-chat/subagent-tracker/journaled-subagent-groups' -import { subagentRowSinkPort } from '../native-chat/subagent-tracker/subagent-row-sink-port' -import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker' -import type { SubagentReport } from '../native-chat/subagent-tracker/subagent-tracker-types' -import { - readCodexSubagentActivity, - readCodexSubagentAnnouncements, - type CodexSubagentAnnouncement, - readCodexThreadTokenTotal -} from './codex-subagent-activity' -import { - CodexSubagentExecutions, - codexChildTurnState, - type CodexChildExecution, - type CodexExecutionChild -} from './codex-subagent-executions' -import { readRecord } from './codex-item-field-readers' -import { readCodexTurnId } from './codex-structured-thread-facts' -import { CodexSubagentLinkage } from './codex-subagent-linkage' -import { - codexSubagentGroupId, - codexSubagentGroupIdentity, - isCodexOutsideTurnGroup -} from './codex-subagent-roster-state' -import type { CodexThreadItem } from './codex-structured-item-translation' -import { CodexThreadTokenTotals } from './codex-thread-token-totals' -export { subagentGroupJournalBody as codexSubagentGroupBody } from '../native-chat/agent-session-journal/journal-subagent-group-body' -export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state' - -const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } - -export type CodexSubagentRosterDeps = { - sink: StructuredAgentSessionEventSink - /** The thread that owns the agent tree; falls back to the event's thread. */ - primaryThreadId: () => string | null - activeTurn: (threadId: string) => string | null - turnScopeFor: (threadId: string, turnId: string | null) => AgentJournalTurnScope - now?: () => number - executions?: CodexSubagentExecutions -} - -export class CodexSubagentRoster { - private readonly tracker: SubagentTracker - /** Every thread's total, members or not; a child shows its total once it is listed. */ - private readonly tokensByThread = new CodexThreadTokenTotals() - /** The one owner of child membership and turn state; the rows of calls on a helper read it too. */ - readonly executions: CodexSubagentExecutions - private readonly unfollow: () => void - /** Who produced a row, from what this roster learned about each child thread. */ - readonly linkage: CodexSubagentLinkage - - constructor(private readonly deps: CodexSubagentRosterDeps) { - this.tracker = new SubagentTracker({ - port: subagentRowSinkPort(deps.sink, codexSubagentGroupIdentity), - outsideTurn: isCodexOutsideTurnGroup, - journaled: new JournaledSubagentGroups( - () => deps.sink.journalLinkage?.() ?? null, - codexSubagentGroupIdentity - ), - ...(deps.now ? { now: deps.now } : {}) - }) - this.executions = deps.executions ?? new CodexSubagentExecutions() - // The row follows the executions, so every frame that ends a child's turn — its own - // `turn/completed` (a failed one included) or its thread closing — settles it. - this.unfollow = this.executions.onExecutionChanged( - (child) => child.execution && this.follow(child, child.execution) - ) - this.linkage = new CodexSubagentLinkage({ - primaryThreadId: deps.primaryThreadId, - executions: this.executions - }) - } - - /** Consume an item that announces children. Null means the item is not this roster's to render: - * a `subAgentActivity` item renders as the roster row alone, while a collab call keeps its own - * row, so it is claimed only to hand back a refused write. */ - handleItem(input: { - threadId: string - turnId: string | null - item: CodexThreadItem - }): StructuredAgentSessionSinkAdmission | null { - const refused = readCodexSubagentAnnouncements(input.item, this.deps.primaryThreadId()) - .map((announcement) => this.announce(input, announcement)) - .find((admission) => !admission.accepted) - return refused ?? (readCodexSubagentActivity(input.item) !== null ? ADMITTED : null) - } - - private announce( - input: { threadId: string; turnId: string | null }, - announcement: CodexSubagentAnnouncement - ): StructuredAgentSessionSinkAdmission { - const child = this.executions.register( - announcement.agentThreadId, - announcement.label, - announcement.namesParentTurn ? input.turnId : undefined, - // Only a spawn names the spawner: other announcements ride whichever agent acted. - announcement.spawned ? input.threadId : undefined - ) - return child?.execution - ? this.report(child, child.execution, true, this.groupFor(input.threadId, input.turnId)) - : ADMITTED - } - - handleTurnEvent(event: { - method: string - threadId: string - params: unknown - }): StructuredAgentSessionSinkAdmission { - const turnId = readCodexTurnId(event.params) - return turnId - ? this.handleTurn({ - threadId: event.threadId, - turnId, - state: - event.method === 'turn/started' - ? 'working' - : codexChildTurnState(readRecord(readRecord(event.params).turn).status) - }) - : ADMITTED - } - - handleTurn(input: { - threadId: string - turnId: string - state: NativeChatSubagentState - }): StructuredAgentSessionSinkAdmission { - if ( - input.threadId === this.deps.primaryThreadId() || - this.tracker.hasSettled(input.threadId, input.turnId) - ) { - return ADMITTED - } - const observed = this.executions.observeTurn(input.threadId, input.turnId, input.state) - // Followed already if the execution changed; re-derived (idempotently) for its admission. - return observed ? this.follow(observed.child, observed.execution) : ADMITTED - } - - /** A child turn starting is a run starting; one ending only revises the row listing it. */ - private follow( - child: Readonly, - execution: CodexChildExecution - ): StructuredAgentSessionSinkAdmission { - if (!child.registered) { - return ADMITTED - } - const parent = this.deps.primaryThreadId() ?? child.agentThreadId - const group = this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId) - return this.report(child, execution, execution.state === 'working', group) - } - - /** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */ - handleTokenUsage(params: unknown): StructuredAgentSessionSinkAdmission | null { - const usage = readCodexThreadTokenTotal(params) - if (!usage) { - return null - } - this.tokensByThread.record(usage.threadId, usage.totalTokens) - const located = this.tracker.locate(usage.threadId) - return located - ? this.tracker.report({ - id: usage.threadId, - group: { id: located.group.groupId, placement: () => located.group.placement }, - announces: false, - tokens: usage.totalTokens - }) - : ADMITTED - } - - /** - * The provider is gone, so any child still reported as working will never be - * settled by an event: it becomes `unverifiable` — contact was lost, which is - * NOT evidence the child exited. - * - * This is the ONLY sweep. A turn ending is not one: `spawn_agent` children - * routinely outlive their turn and keep reporting into the same group. - */ - settleSession(): StructuredAgentSessionSinkAdmission { - this.executions.settleSession() - return this.tracker.settleSession() - } - - dispose(): void { - this.unfollow() - this.tracker.dispose() - this.tokensByThread.clear() - } - - retentionSizes(): { groups: number; settledIdentities: number } { - return this.tracker.sizes() - } - - private report( - child: Readonly, - execution: CodexChildExecution, - announces: boolean, - group: SubagentReport['group'] - ): StructuredAgentSessionSinkAdmission { - const tokens = this.tokensByThread.get(child.agentThreadId) - return this.tracker.report({ - id: child.agentThreadId, - run: execution.turnId, - group, - announces, - label: child.label, - state: execution.state, - ...(typeof tokens === 'number' ? { tokens } : {}), - // A spawned child may outlive the turn that spawned it. - backgrounded: true - }) - } - - /** The group a child spawned now belongs to: the session's own agent's current turn. */ - private groupFor( - threadId: string, - turnId: string | null - ): SubagentReport['group'] { - const ownerThreadId = this.deps.primaryThreadId() ?? threadId - const ownerTurnId = - ownerThreadId === threadId ? turnId : (this.deps.activeTurn(ownerThreadId) ?? turnId) - return { - id: codexSubagentGroupId(ownerThreadId, ownerTurnId), - placement: () => this.deps.turnScopeFor(ownerThreadId, ownerTurnId) - } - } -} diff --git a/src/main/codex/codex-thread-token-totals.test.ts b/src/main/codex/codex-thread-token-totals.test.ts deleted file mode 100644 index 1dab7dc6f6e..00000000000 --- a/src/main/codex/codex-thread-token-totals.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits' -import { CodexThreadTokenTotals } from './codex-thread-token-totals' - -describe('CodexThreadTokenTotals', () => { - it("replaces a thread's total with its newest report instead of summing reports", () => { - const totals = new CodexThreadTokenTotals() - totals.record('child-1', 100) - totals.record('child-1', 250) - expect(totals.get('child-1')).toBe(250) - }) - - it('evicts by recency of report, so a thread still reporting outlives quieter ones', () => { - const totals = new CodexThreadTokenTotals() - totals.record('child-1', 1) - for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS - 1; index++) { - totals.record(`other-${index}`, index) - } - totals.record('child-1', 2) - totals.record('newest', 3) - expect(totals.get('child-1')).toBe(2) - expect(totals.get('other-0')).toBeUndefined() - }) -}) diff --git a/src/main/codex/codex-thread-token-totals.ts b/src/main/codex/codex-thread-token-totals.ts deleted file mode 100644 index 23bc6a9d199..00000000000 --- a/src/main/codex/codex-thread-token-totals.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits' - -/** Latest reported token total per thread, kept regardless of roster membership: a - * usage frame can arrive before the child's first activity item, and filtering at - * receipt would lose it permanently. LRU-capped at MAX_CODEX_TOKEN_USAGE_THREADS. */ -export class CodexThreadTokenTotals { - private readonly totals = new Map() - - /** A running total: the newest frame REPLACES the previous one. Summing updates - * would multiply a single child's usage by its frame count. */ - record(threadId: string, totalTokens: number): void { - // Re-insert so eviction sees recency: `set` on an existing key keeps its - // original position, which would age out an active thread. - this.totals.delete(threadId) - this.totals.set(threadId, totalTokens) - while (this.totals.size > MAX_CODEX_TOKEN_USAGE_THREADS) { - const oldest = this.totals.keys().next().value - if (typeof oldest !== 'string') { - break - } - this.totals.delete(oldest) - } - } - - get(threadId: string): number | undefined { - return this.totals.get(threadId) - } - - clear(): void { - this.totals.clear() - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts deleted file mode 100644 index e59e882e915..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-subagent-group-body-fallback.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' -import { subagentGroupJournalBody } from './journal-subagent-group-body' - -function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry { - return { id, label: id, state, startedAt: 1 } -} - -/** The fallback sentence is the WHOLE row on mobile and paired web, which have - * no roster renderer, so these assertions are the entire contract there. */ -function sentence(agents: readonly NativeChatSubagentEntry[]): string { - const body = subagentGroupJournalBody('turn-1', agents) - const block = body.kind === 'message' ? body.blocks[0] : undefined - return block && block.type === 'text' ? block.text : '' -} - -describe('subagentGroupJournalBody fallback sentence', () => { - it('reads as a plain completion when every child completed', () => { - expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents') - }) - - it('keeps the singular noun for a lone child', () => { - expect(sentence([entry('a', 'completed')])).toBe('Ran 1 subagent') - expect(sentence([entry('a', 'working')])).toBe('Kicked off 1 subagent') - }) - - it('names an unverifiable child instead of claiming the group ran', () => { - expect(sentence([entry('a', 'completed'), entry('b', 'unverifiable')])).toBe( - 'Ran 2 subagents (1 unverifiable)' - ) - }) - - it('ranks the adverse outcome worst-first', () => { - expect( - sentence([entry('a', 'failed'), entry('b', 'unverifiable'), entry('c', 'completed')]) - ).toBe('Ran 3 subagents (1 failed)') - expect(sentence([entry('a', 'stopped'), entry('b', 'unverifiable')])).toBe( - 'Ran 2 subagents (1 stopped)' - ) - }) - - it('shows the adverse outcome while a sibling still works', () => { - expect( - sentence([entry('a', 'working'), entry('b', 'working'), entry('c', 'unverifiable')]) - ).toBe('Kicked off 3 subagents (1 unverifiable)') - }) - - it('leaves a benign settled state out of the sentence', () => { - expect(sentence([entry('a', 'idle'), entry('b', 'completed')])).toBe('Ran 2 subagents') - }) - - it('counts every child holding the worst adverse state', () => { - expect(sentence([entry('a', 'failed'), entry('b', 'failed'), entry('c', 'stopped')])).toBe( - 'Ran 3 subagents (2 failed)' - ) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-group-body.ts b/src/main/native-chat/agent-session-journal/journal-subagent-group-body.ts deleted file mode 100644 index 6261f3919cd..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-subagent-group-body.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' -import { - MAX_SUBAGENT_FIELD_CHARS, - subagentGroupFallbackText -} from '../../../shared/native-chat-subagent-summary' - -/** The roster row: the structured block plus the plain sentence an older client - * renders in its place. A message whose only block is the new variant would - * reach such a client with nothing it can draw. */ -export function subagentGroupJournalBody( - groupId: string, - agents: readonly NativeChatSubagentEntry[] -): AgentJournalMessageItem { - const bounded = agents.map((agent, index) => ({ - ...agent, - id: boundSubagentField(agent.id, index), - label: boundSubagentField(agent.label, index) - })) - return { - kind: 'message', - role: 'system', - blocks: [ - { type: 'text', text: subagentGroupFallbackText(bounded) }, - { type: 'subagent-group', groupId, agents: bounded } - ] - } -} - -/** `id` and `label` are provider strings, so they take the bound both readers of - * this row already clip them to. A plain length check, not the tool-output - * bound: that one digests the whole value before it checks the length, and this - * runs twice per child on every streamed token-usage frame. - * - * A clip is not identity-preserving, so a clipped value carries the child's - * index: two ids sharing a long prefix collapse to one React key, and - * `claimLabel` writes its ordinal at the very tail the clip removes. The index - * is reserved out of the bound, not appended to it, because both readers - * re-clip to the same cap and would cut a suffix that overflowed it. */ -export function boundSubagentField(value: string, index: number): string { - if (value.length <= MAX_SUBAGENT_FIELD_CHARS) { - return value - } - const suffix = `…~${index}` - const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length - // Slicing UTF-16 units can split a surrogate pair; a lone surrogate is - // malformed in a durable row and lossy through any non-JSON UTF-8 hop. - const last = value.charCodeAt(keep - 1) - const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep - return `${value.slice(0, end)}${suffix}` -} diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-item.ts b/src/main/native-chat/agent-session-journal/journal-subagent-item.ts index 4c416ec35d9..05596e8353a 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-item.ts @@ -25,7 +25,7 @@ export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJourna const bounded: NativeChatSubagentBlock = { ...block, id: boundSubagentEntryId(block.id), - label: boundLabel(block.label), + label: boundSubagentLabel(block.label), groupId: boundSubagentEntryId(block.groupId), ...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}), ...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {}) @@ -48,7 +48,7 @@ export function journaledSubagent( /** The bound every reader clips a label to. A clip never splits a surrogate pair: a lone one is * malformed in a durable row. */ -function boundLabel(label: string): string { +export function boundSubagentLabel(label: string): string { if (label.length <= MAX_SUBAGENT_FIELD_CHARS) { return label } diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index d9362e6008a..47d689fc13f 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -15,10 +15,7 @@ import type { NativeChatBackgroundTaskBlock, NativeChatSubagentEntry } from '../../../shared/native-chat-types' -import { - codexSubagentGroupBody, - codexSubagentGroupIdentity -} from '../../codex/codex-subagent-roster' +import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary' import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener } from './journal-host-database-test-support' import { lostLiveWorkJournalBody, staleSubagentRosterRevisions } from './journal-subagent-liveness' @@ -55,11 +52,18 @@ async function open(overrides: Partial { expect(tail.hasOlder).toBe(true) }) + it("loads an older page without renumbering a turn's subagents or moving their row", async () => { + const audit = async (id: string): Promise => { + await journal.appendItem( + subagentJournalItemIdentity(id), + subagentJournalBody({ + type: 'subagent', + id, + label: 'Audit', + state: 'working', + groupId: 't' + }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await append(named('own-', 1, 3)) + await audit('a') + await append(named('own-', 4, 5)) + await audit('b') + await append(named('own-', 9, 2)) + await audit('c') + await append(['own-11']) + const folded = (pages: AgentSessionHistoryPage[]) => + foldSubagentItems( + pages + .flatMap((page) => page.items) + .map((entry) => ({ + id: entry.itemId, + role: 'system' as const, + timestamp: null, + source: 'transcript' as const, + blocks: entry.body.kind === 'message' ? entry.body.blocks : [] + })) + ).flatMap((row) => + row.blocks.flatMap((block) => + block.type === 'subagent-group' + ? [{ row: row.id, labels: block.agents.map((agent) => `${agent.id}=${agent.label}`) }] + : [] + ) + ) + + const tail = read({ direction: 'tail', limit: 5 }) + const older = read({ direction: 'before', cursor: tail.window.nextCursor, limit: 5 }) + + expect(textsOf(tail)[0]).toBe('Kicked off subagent Audit') + expect(textsOf(older)).toEqual(named('own-', 1, 3)) + expect(folded([tail])).toEqual([ + { row: 'orca:subagent%3Aa', labels: ['a=Audit', 'b=Audit 2', 'c=Audit 3'] } + ]) + expect(folded([older, tail])).toEqual(folded([tail])) + }) + + it('reaches back for a first sibling no further than one more page of conversation', async () => { + for (const [id, own] of [ + ['a', named('own-', 1, 10)], + ['b', ['own-11']] + ] as const) { + await journal.appendItem( + subagentJournalItemIdentity(id), + subagentJournalBody({ + type: 'subagent', + id, + label: id, + state: 'working', + groupId: 'thread' + }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await append([...own]) + } + + const tail = read({ direction: 'tail', limit: 2 }) + + expect(textsOf(tail)).toEqual(['own-8', 'own-9', 'own-10', 'Kicked off subagent b', 'own-11']) + }) + it('still bounds a page by bytes, and paging back still reaches every row', async () => { const large = 'x'.repeat(250 * 1024) await append(['own-1']) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts index 3e1c574f9cd..7157f1799b1 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts @@ -8,6 +8,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { agentSessionHostStatusBody } from '../../../shared/agent-session-host-status-rows' +import { subagentFoldKey, subagentItemBlock } from '../../../shared/native-chat-subagent-items' export const AGENT_SESSION_HISTORY_MAX_PAGE_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES / 2 @@ -126,3 +127,29 @@ export function newestWholeSequenceGroups( } return selected.toReversed().flat() } + +/** `start` moved back to the first sibling of every subagent group the window holds part of, but + * never past `floor`: a group spread over a long turn, or helpers outside any turn, may still + * split rather than grow the page without bound. */ +export function wholeSubagentGroupsStart( + items: readonly AgentJournalRenderItem[], + start: number, + floor: number +): number { + const firstOf = new Map() + for (const item of items) { + const block = item.body.kind === 'message' ? subagentItemBlock(item.body) : null + if (block !== null && !firstOf.has(subagentFoldKey(block))) { + firstOf.set(subagentFoldKey(block), item.sequence) + } + } + let whole = start + for (let index = items.length - 1; index >= 0 && items[index]!.sequence >= whole; index--) { + const item = items[index]! + const block = item.body.kind === 'message' ? subagentItemBlock(item.body) : null + if (block !== null) { + whole = Math.max(floor, Math.min(whole, firstOf.get(subagentFoldKey(block))!)) + } + } + return whole +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index acd0240c23f..64e9070d93c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -33,6 +33,7 @@ import { HISTORY_PAGE_CONTENT_BUDGET_BYTES, historyEntryBytes, newestWholeSequenceGroups, + wholeSubagentGroupsStart, oversizedHistoryItem, submissionBytesByItemId } from './agent-session-history-page-bounds' @@ -61,7 +62,8 @@ export type AgentSessionHistoryScope = 'every-agent' | 'own-agent' * the conversation off the page. The page stays contiguous and starts at its first * item, as the cursor means; the byte budget still bounds it. Fewer own rows than * `limit` reach back to the start. With no subagent rows this is the newest `limit` - * rows, as before. + * rows, as before. A page reaches back to the first sibling of each subagent group it holds + * part of, so loading an older page does not renumber a group's ordinals or move its row. */ function conversationWindow( items: readonly AgentJournalRenderItem[], @@ -73,7 +75,13 @@ function conversationWindow( } const ownWindow = newestWholeSequenceGroups(own, limit) const start = ownWindow.length < own.length ? ownWindow[0]?.sequence : undefined - return start === undefined ? [...items] : items.filter((item) => item.sequence >= start) + if (start === undefined) { + return [...items] + } + const reach = newestWholeSequenceGroups(own, 2 * limit) + const floor = reach.length < own.length ? reach[0]!.sequence : Number.NEGATIVE_INFINITY + const whole = wholeSubagentGroupsStart(items, start, floor) + return items.filter((item) => item.sequence >= whole) } function isSubagentRow(item: AgentJournalRenderItem): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 958c71d80fc..bddcd06c964 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -14,7 +14,10 @@ import type { // take this?" — and it answers `unknown` rather than guessing, because the // journal renders that as delivery unconfirmed instead of as failure. -import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' +import type { + AgentChildWorkStopAcknowledgement, + AgentSessionBackgroundTaskStops +} from '../../../shared/agent-child-work-stop-targets' import type { AgentJournalAnsweredTurnIdentity, AgentJournalItemIdentity, @@ -362,11 +365,14 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & }): Promise<{ ok: true } | { ok: false; rejected: string }> /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. * `stillRunning` is the provider's own answer that one survived its stop; a throw leaves the - * effect unknown. */ + * effect unknown. `acknowledge` names each task the provider accepted the stop of as its answer + * lands, so a later failure in the batch keeps it; the host alone turns it into that run's + * provisional ending. */ stopBackgroundTasks?(input: { sessionId: string fence: number taskIds: readonly string[] + acknowledge?: (acknowledgement: AgentChildWorkStopAcknowledgement) => void }): Promise<{ cancelled: boolean; stillRunning?: true }> /** The stops this provider honours for a live session's background work; undefined when the * adapter holds no live session for it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-stop.ts new file mode 100644 index 00000000000..0cde17fc81c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-stop.ts @@ -0,0 +1,62 @@ +// A background Stop: the strip's Stop of one task, or of every task the records offer one. + +import { + agentChildWorkStopEvidence, + agentChildWorkStopTargets, + type AgentChildWorkStopAcknowledgement +} from '../../../shared/agent-child-work-stop-targets' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +export type StructuredAgentSessionBackgroundStopInput = { + taskId?: string + /** The session's child records: a background Stop reaches the tasks they offer a stop. */ + childWork?: () => readonly AgentChildWorkView[] | undefined + /** Hands the host the stops the provider accepted, as evidence about the runs it sent them to. + * Never throws: recording it is bookkeeping, and cannot fail the Stop. */ + acknowledgeStops?: (evidence: AgentChildWorkEvidence[]) => void + /** Work the host could not list, read inside the Stop's step, and the provider child's end that + * reaches it; for an untargeted Stop only. */ + unlisted?: { present: () => boolean; stop: () => Promise } +} + +/** `atRest`: no turn runs, so ending the agent ends only the work no record lists. */ +export async function stopStructuredAgentSessionBackgroundWork( + ctx: Pick, + input: StructuredAgentSessionBackgroundStopInput, + atRest: () => boolean +): Promise<{ cancelled: boolean; stillRunning?: true }> { + // Captured before the stop goes out: its acknowledgement may end only these runs. + const targets = agentChildWorkStopTargets(input.childWork?.(), input.taskId) + const acknowledged: AgentChildWorkStopAcknowledgement[] = [] + // Nothing listed has a stop to send; an agent without a stop route would refuse even none. + const background = + targets.length === 0 + ? undefined + : await ctx.adapter + .stopBackgroundTasks?.({ + sessionId: ctx.sessionId, + fence: ctx.fence, + taskIds: targets.map((target) => target.providerId), + acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement) + }) + // A batch that fails part way still ended each run it was answered for first. + .finally(() => { + if (acknowledged.length > 0) { + input.acknowledgeStops?.( + agentChildWorkStopEvidence(targets, acknowledged, Date.now()) + ) + } + }) + if (background?.stillRunning) { + return background + } + // Work no record lists is reached only by ending the agent, never under a turn it runs. Read after + // the listed stops: one ending can wake the agent into a turn of its own. + if (input.unlisted?.present() && atRest()) { + await input.unlisted.stop() + return { cancelled: true } + } + return { cancelled: background?.cancelled === true } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts index 79d6850c559..67bea7e7971 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -28,7 +28,8 @@ type Stops = { supportsTaskStop: boolean; supportsStopAll: boolean } | undefined function channelOver( children: () => AgentChildWorkView[] = () => [child], - stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true }) + stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true }), + incomplete: () => boolean = () => false ) { const sessions = new StructuredAgentSessionConversations({ deliver: () => {}, @@ -48,7 +49,8 @@ function channelOver( async () => { throw new Error('not opened here') }, - children + children, + incomplete ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider. const session = { @@ -72,6 +74,24 @@ describe('the background-task channel', () => { // A task that finished while no pane listened, then an idle sweep that stopped the provider: // a pane resuming must read "none", not silence it would take as "unchanged" (#24227). + // An idle parent whose live background child the store had no room for: no row lists it, so + // the strip must stay and say so rather than read as nothing running. + it('keeps the strip, with its Stop, for work the host could not record', () => { + const { sessions, channel, session } = channelOver( + () => [], + () => ({ supportsTaskStop: true, supportsStopAll: true }), + () => true + ) + sessions.set('session-1', session) + + expect(channel.read('session-1')).toEqual({ + state: 'monitoring', + supportsTaskStop: true, + children: [], + incomplete: true + }) + }) + it('answers "none" for a conversation no provider holds, not "unknown"', () => { const { sessions, channel, session } = channelOver( () => [], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index cf44fc3d66b..1675a8d5a75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -25,8 +25,8 @@ import { structuredAgentSessionConversationFence } from './structured-agent-sess /** The chat strip's roster: the host's running child records for one session, the same selection * the session list carries, with the legacy task rows an older client reads derived from them. No - * running child is `null`, and the strip hides. Each subscriber's frames carry it; see - * `agent-session-subscriber-frame-fields`. */ + * running child, and none the host failed to record, is `null`, and the strip hides. Each + * subscriber's frames carry it; see `agent-session-subscriber-frame-fields`. */ export class StructuredAgentSessionBackgroundTaskChannel { constructor( private readonly deps: StructuredAgentSessionHostDeps, @@ -36,7 +36,8 @@ export class StructuredAgentSessionBackgroundTaskChannel { private readonly conversation: ( sessionId: string ) => Promise, - private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined + private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined, + private readonly childWorkIncomplete: (sessionId: string) => boolean = () => false ) {} /** `scope` is for in-process readers; a wire request reads every agent's rows. */ @@ -101,7 +102,9 @@ export class StructuredAgentSessionBackgroundTaskChannel { read(sessionId: string): AgentSessionBackgroundTaskState | null { const session = this.sessions.get(sessionId) const views = structuredRunningChildWork((session && this.readChildWork(sessionId)) ?? []) - if (!session || views.length === 0) { + // Untracked work keeps the strip, and its Stop, even with nothing listed. + const incomplete = this.childWorkIncomplete(sessionId) + if (!session || (views.length === 0 && !incomplete)) { return null } const stops = this.deps.adapter.backgroundTaskStops?.(sessionId) @@ -113,7 +116,8 @@ export class StructuredAgentSessionBackgroundTaskChannel { ...(stops?.supportsTaskStop ? { supportsTaskStop: true } : {}), // A session no live provider holds has nothing a stop could reach. ...(stops?.supportsStopAll ? {} : { supportsStopAll: false }), - children: views + children: views, + ...(incomplete ? { incomplete: true } : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 9e55397a38f..7664fe3c548 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -493,8 +493,8 @@ it('ends background work Claude runs when the Stop ends the child', async () => ...background, state: 'done', membership: 'settled', - // Stopped with the chat, as a task's own stop reads: Interrupted, not an unknown ending. - outcome: 'cancelled' + // Claude accepted no Stop of this task: ending its process is loss of contact. + outcome: 'unknown' }) ]) expect(await agentStatus()).toEqual({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts new file mode 100644 index 00000000000..eadb81b9812 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts @@ -0,0 +1,300 @@ +// A Claude background helper through the shipping adapter, host and child records: its saved row +// stays working past its spawn result and its turn's end until its own end frame; and a helper the +// store had no room for keeps the strip incomplete until the strip's Stop ends the agent. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { AGENT_STATUS_STORE_LIMITS } from '../../../shared/agent-status-store-contract' +import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' +import { isSubagentBlock } from '../../../shared/native-chat-types' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' +import { RESUMED_BY_MESSAGE } from '../../claude/claude-captured-task-frames.test-fixture' +import { WAKES_AFTER_ITS_SHELL } from '../../claude/claude-captured-wake-frames.test-fixture' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + claudeStartupSettled, + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let records: ReturnType +/** The host's clock, which stamps every child's start and end. */ +let clock = NOW + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-subagent-row-')) + clock = NOW + resetHostTestOperationIds() + const log = recordingStructuredAgentSessionLogger() + claude = fakeClaude({ replayUuid: null }) + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + persistHandle: async () => undefined, + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence) => + host.publishChildWorkEvidence(sessionId, evidence), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => clock + }) + store = await openTestAgentSessionRecordStore(root) + records = acpChildWorkStatusSink() + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + // The host's child records live in a real hook server's store, as in production. + statusSink: records.sink, + now: () => clock + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await claudeStartupSettled(adapter, SESSION) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: Fields) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +type Fields = Parameters[0]['fields'] + +async function sendAndEcho(connection: FakeConnection, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + expect(sent.ok).toBe(true) + await eventually(() => + expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true) + ) + frame(connection, { type: 'system', subtype: 'init', uuid: 'init-1', model: 'claude-sonnet-5' }) + frame(connection, { ...connection.sent.at(-1)! }) +} + +async function savedRows() { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'message' ? item.body.blocks.filter(isSubagentBlock) : [] + ) +} + +it("keeps a background helper's row working past its spawn result and its turn's end, until its own end", async () => { + const connection = claude.connections[0]! + await sendAndEcho(connection, 'Run it in the background.') + // Spawn, roster, task start, the spawn's "launched" result, and the parent turn's result. + for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 4_765).entries()) { + frame(connection, { uuid: `launch-${index}`, ...captured.frame }) + } + await eventually(async () => expect(await savedRows()).toHaveLength(1)) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(await savedRows()).toEqual([ + expect.objectContaining({ id: 'agent-1', state: 'working', background: true }) + ]) + const { items } = await host.journalSnapshot(SESSION) + expect(activeStructuredAgentSessionTurnId(items)).toBeNull() + expect(items.map((item) => readAgentJournalTurn(item.body)?.state)).toContain('completed') + + // Its own progress, then its own completion. + for (const [index, captured] of RESUMED_BY_MESSAGE.filter( + ({ at }) => at > 4_765 && at <= 7_427 + ).entries()) { + frame(connection, { uuid: `run-${index}`, ...captured.frame }) + } + await eventually(async () => + expect(await savedRows()).toEqual([ + expect.objectContaining({ id: 'agent-1', state: 'completed', tokens: 15_971 }) + ]) + ) +}) + +it("ends the agent from the strip's Stop when the store had no room for its helper, never under a running turn", async () => { + // Another session holds every child record the store has room for. + const busy = makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'busy', workspaceKind: 'folder' }, + 'busy-session' + ) + records.sink.publish( + { + sessionId: 'busy-session', + workspaceId: 'busy', + agent: 'claude', + status: 'working', + latestPrompt: 'Busy', + updatedAt: 1 + }, + busy + ) + records.sink.publishChildWork?.( + busy, + Array.from({ length: AGENT_STATUS_STORE_LIMITS.children }, (_unused, index) => ({ + type: 'live' as const, + observedAt: 1, + child: { + handle: { idKind: 'task_id' as const, id: `busy-${index}` }, + kind: 'agent' as const, + residency: 'background' as const, + state: 'working' as const, + stoppable: true + } + })), + 'claude' + ) + const connection = claude.connections[0]! + await sendAndEcho(connection, 'Run it in the background.') + for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 3_349).entries()) { + frame(connection, { uuid: `launch-${index}`, ...captured.frame }) + } + const strip = () => host['backgroundTasks'].read(SESSION) + await eventually(() => expect(strip()).toMatchObject({ children: [], incomplete: true })) + const stopStrip = () => { + const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) + } + + // The turn that spawned it still runs: the strip's Stop leaves the agent and its turn alone. + await stopStrip() + expect(connection.closed).toBe(false) + expect(strip()).toMatchObject({ incomplete: true }) + + frame(connection, { + uuid: 'turn-end', + ...RESUMED_BY_MESSAGE.find(({ at }) => at === 4_765)!.frame + }) + await host.flushStreamedEvents(SESSION) + await expect(stopStrip()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + expect(connection.closed).toBe(true) + await eventually(() => expect(strip()?.incomplete).toBeUndefined()) +}) + +it('keeps a helper working while its own shell runs and through its wake-up, ending on its answer', async () => { + const connection = claude.connections[0]! + await sendAndEcho(connection, 'Sleep in the background.') + const start = clock + let next = 0 + const replay = async (until: number) => { + for (; WAKES_AFTER_ITS_SHELL[next] && WAKES_AFTER_ITS_SHELL[next]!.at <= until; next++) { + const captured = WAKES_AFTER_ITS_SHELL[next]! + clock = start + captured.at + frame(connection, { uuid: `wake-${next}`, ...captured.frame }) + } + await host.flushStreamedEvents(SESSION) + } + const helper = async () => (await savedRows()).find(({ id }) => id === 'agent-1') + const strip = () => + host['backgroundTasks'].read(SESSION)?.children?.map((child) => child.providerId) ?? [] + + // The CLI calls the helper completed while the shell it started runs on. + await replay(11_913) + await eventually(async () => expect(await helper()).toMatchObject({ state: 'working' })) + expect(strip()).toEqual(['agent-1', 'shell-1']) + + // The shell ends, and the CLI starts the same run again under its spawn call. + await replay(44_460) + await eventually(async () => expect(await helper()).toMatchObject({ state: 'working' })) + expect(strip()).toEqual(['agent-1']) + + await replay(46_199) + await eventually(async () => + expect(await helper()).toMatchObject({ + state: 'completed', + tokens: 18_181, + startedAt: start + 15, + // Its own end, not its first turn's, sets how long it ran. + settledAt: start + 46_198 + }) + ) + expect(strip()).toEqual([]) + // One saved row for the helper, revised in place across both runs. + expect((await savedRows()).filter(({ id }) => id === 'agent-1')).toHaveLength(1) +}) + +it('settles a helper still running when its session closes as unverifiable, claiming no Stop', async () => { + const connection = claude.connections[0]! + await sendAndEcho(connection, 'Run it in the background.') + for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 4_765).entries()) { + frame(connection, { uuid: `launch-${index}`, ...captured.frame }) + } + await eventually(async () => + expect(await savedRows()).toEqual([expect.objectContaining({ state: 'working' })]) + ) + await adapter.closeSession(SESSION) + expect(connection.closed).toBe(true) + await eventually(async () => + expect(await savedRows()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + ) + expect((await savedRows())[0]).not.toHaveProperty('outcomeBasis') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index c369ce1a560..b6b66744f66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -106,6 +106,9 @@ export class StructuredAgentSessionClientDelivery { readChildWork = (sessionId: string): AgentChildWorkView[] | undefined => this.statusFeed.readChildWork(sessionId) + childWorkIncomplete = (sessionId: string): boolean => + this.statusFeed.childWorkIncomplete(sessionId) + publishStatusAndSettlement = (sessionId: string): void => { this.statusFeed.publish(sessionId) const journal = this.sessions.get(sessionId)?.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index b44909d01d7..78002aa6f6c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -106,11 +106,25 @@ export function cancelStructuredAgentSessionTurn( } ): Promise> { if (params.scope) { + const { sessionId } = params.envelope return mutateStructuredAgentSession( context, caller, params.envelope, - cancelPlan({ ...params, childWork: () => context.readChildWork(params.envelope.sessionId) }), + cancelPlan({ + ...params, + childWork: () => context.readChildWork(sessionId), + acknowledgeStops: (evidence) => context.publishChildWork(sessionId, evidence), + // Untargeted, with work no record lists: the idle sweep's stop ends it with the agent. + ...(params.taskId === undefined + ? { + unlisted: { + present: () => context.childWorkIncomplete(sessionId), + stop: () => context.stopAgent(sessionId, { cause: 'evict', resting: true }) + } + } + : {}) + }), openForProviderWrite(context, params.envelope) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts index 25d475abb46..7e1c4927693 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts @@ -8,8 +8,11 @@ import { type StructuredAgentSessionStatusFeedDeps, type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { StructuredAgentSessionSubagentRows } from './structured-agent-session-subagent-rows' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -/** Wire the host's own deps into a feed; keeps the host at one call site. +/** Wire the host's own deps into a feed, and each subagent's saved row onto its child records; + * keeps the host at one call site. * `deps` is a thunk because the host builds the feed in a field initializer, * before its constructor parameters are assigned. */ export function createStructuredAgentSessionHostStatusFeed(args: { @@ -24,7 +27,31 @@ export function createStructuredAgentSessionHostStatusFeed(args: { onAgentStarted?: (sessionId: string) => void onChildWorkChanged?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { - return new StructuredAgentSessionStatusFeed({ + const subagentRows = new StructuredAgentSessionSubagentRows({ + journal: (sessionId) => args.sessions.get(sessionId)?.journal, + fence: (sessionId) => structuredAgentSessionConversationFence(args.deps().store, sessionId), + // The views, with the two host-only facts a saved row also keeps from their records. + readChildWork: (sessionId) => { + const records = new Map( + feed.readChildWorkRecords(sessionId)?.map((record) => [record.childWorkId, record]) + ) + return feed.readChildWork(sessionId)?.map((view) => { + const { residency, outcomeBasis } = records.get(view.id) ?? {} + return { + ...view, + ...(residency ? { residency } : {}), + ...(outcomeBasis ? { outcomeBasis } : {}) + } + }) + }, + onWriteFailed: (sessionId, error) => + args.deps().logger.warn('writing a subagent row failed', { + scope: 'subagent-rows', + sessionId, + error + }) + }) + const feed = new StructuredAgentSessionStatusFeed({ sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), now: args.now, @@ -34,6 +61,10 @@ export function createStructuredAgentSessionHostStatusFeed(args: { // field initializer, before its constructor parameters are assigned. statusSink: () => args.deps().statusSink, ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}), - ...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {}) + onChildWorkChanged: (sessionId) => { + subagentRows.changed(sessionId) + args.onChildWorkChanged?.(sessionId) + } }) + return feed } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 1b66064df5f..62568e18a18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -101,7 +101,8 @@ export class StructuredAgentSessionHost { this.sessions, this.subscribers, (sessionId) => this.lifetime.conversation(sessionId), - this.clientDelivery.readChildWork + this.clientDelivery.readChildWork, + this.clientDelivery.childWorkIncomplete ) this.runtimeState = new StructuredAgentSessionHostRuntimeState( deps, @@ -278,6 +279,8 @@ export class StructuredAgentSessionHost { publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), conversation: this.lifetime.conversation, readChildWork: this.clientDelivery.readChildWork, + publishChildWork: this.clientDelivery.publishChildWork, + childWorkIncomplete: this.clientDelivery.childWorkIncomplete, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index f9b83c3b4f3..f384fa866ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -1,6 +1,7 @@ // The context every client mutation of a session runs with, and the one path each takes: admit the // envelope against the lease, then run its plan inside the session's serialize. +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentSessionMutationEnvelope, AgentSessionMutationResult @@ -29,6 +30,9 @@ export type StructuredAgentSessionMutationContext = { conversation: (sessionId: string) => Promise /** The session's child records, as the strip reads them; what command admission decides on. */ readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined + publishChildWork: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void + /** Some of the session's running child work has no record. */ + childWorkIncomplete: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise /** The session's conversation, opened when closed; inside the caller's serialize. */ openConversation: (sessionId: string) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 04688a8c491..2725533af41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -5,6 +5,7 @@ // prompt answer's replay answers from the resolution its receipt keeps; the journal projects the // others' current answer. +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { AGENT_MESSAGE_SOURCE, @@ -23,6 +24,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import type { StructuredAgentSessionBackgroundStopInput } from './structured-agent-session-background-stop' import { agentSessionMessagePayload, agentSessionSendBodyFingerprint @@ -281,6 +283,8 @@ export function cancelPlan(params: { prompt?: { itemId: string; expectedRevision: number } /** The session's child records, which name the tasks a background Stop reaches. */ childWork?: () => readonly AgentChildWorkView[] | undefined + acknowledgeStops?: (evidence: AgentChildWorkEvidence[]) => void + unlisted?: StructuredAgentSessionBackgroundStopInput['unlisted'] }): MutationPlan { return { method: 'agentSession.cancel', @@ -301,7 +305,9 @@ export function cancelPlan(params: { ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), ...(params.prompt ? { prompt: params.prompt } : {}), - ...(params.childWork ? { childWork: params.childWork } : {}) + ...(params.childWork ? { childWork: params.childWork } : {}), + ...(params.acknowledgeStops ? { acknowledgeStops: params.acknowledgeStops } : {}), + ...(params.unlisted ? { unlisted: params.unlisted } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 24546a97bcc..41c830ecc4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -73,6 +73,24 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', + // One live background task, which the background Stops below reach. + statusSink: { + publish: () => {}, + forget: () => {}, + readChildWork: () => [ + { + id: 'child-task-1', + providerId: 'task-1', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: true, + invocation: { invocationId: 'spawn-task-1', generation: 1 } + } + ] + }, now: () => NOW }) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 79efe4a0bc5..f0ec3c5dabc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -72,7 +72,9 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') const sink: StructuredAgentSessionStatusSink = { publish: () => {}, forget: () => {}, - publishChildWork: (_subject, evidence) => admitted.push(evidence), + publishChildWork: (_subject, evidence) => { + admitted.push(evidence) + }, readChildWork: () => views.current() } let now = 1_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 2a26af994dc..48d2cfb0cd8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -17,6 +17,7 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' @@ -327,6 +328,14 @@ export class StructuredAgentSessionStatusFeed { } } + childWorkIncomplete = (sessionId: string): boolean => + this.ownership.childWorkIncomplete(sessionId) + + /** The records the views project. Read only from the child-work observer, which already + * reports a failure. */ + readChildWorkRecords = (sessionId: string): AgentChildWorkRecord[] | undefined => + this.ownership.readChildWorkRecords(sessionId) + private admitChildWork( sessionId: string, session: StatusFeedSession, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts index 09fcb481e0e..629a59979fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import { AGENT_STATUS_STORE_LIMITS } from '../../../shared/agent-status-store-contract' import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' +import { AgentHookServer } from '../../agent-hooks/server' import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership' const location: AgentSessionExecutionLocation = { @@ -120,3 +123,115 @@ describe('structured status owner address retention', () => { expect(sink.publishChildWork).toHaveBeenCalledOnce() }) }) + +describe('child work the host had no room to record', () => { + const agentLive = (id: string): AgentChildWorkEvidence => ({ + type: 'live', + observedAt: 200, + child: { + handle: { idKind: 'task_id', id }, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + }) + + function owners() { + const server = new AgentHookServer() + const owner = new StructuredAgentSessionStatusOwnership(() => ({ + publish: (published, subject) => server.ingestStructuredStatus(published, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + })) + return { owner } + } + + const ended = (id: string): AgentChildWorkEvidence => ({ + type: 'ended', + observedAt: 250, + handle: { idKind: 'task_id', id }, + outcome: 'succeeded' + }) + + /** Another session holds every child record the store has room for; idle parent beside it. */ + function storeFull() { + const { owner } = owners() + owner.publish({ ...summary, sessionId: 'structured-busy' }, location) + owner.publishChildWork( + 'structured-busy', + Array.from({ length: AGENT_STATUS_STORE_LIMITS.children }, (_unused, index) => + agentLive(`busy-${index}`) + ), + 'claude' + ) + owner.publish({ ...summary, status: 'idle' }, location) + return owner + } + + it('marks an idle parent incomplete when the store refuses its live background child', () => { + const owner = storeFull() + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + + expect(owner.readChildWork(summary.sessionId)).toEqual([]) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + // Refused again, still untracked. + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + owner.publishChildWork( + summary.sessionId, + [{ type: 'session-ended', observedAt: 300 }], + 'claude' + ) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) + + it('is whole again once the refused child is admitted on a later report', () => { + const owner = storeFull() + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + // The other session goes, and with it the records that filled the store. + owner.forget('structured-busy') + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + expect(owner.readChildWork(summary.sessionId)).toHaveLength(1) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) + + it('is whole again once every refused child reports its own end', () => { + const owner = storeFull() + owner.publishChildWork(summary.sessionId, [agentLive('first'), agentLive('second')], 'claude') + owner.publishChildWork(summary.sessionId, [ended('first')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + owner.publishChildWork(summary.sessionId, [ended('second')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) + + it('marks a parent the store refused incomplete, since none of its children is recorded', () => { + const { owner } = owners() + for (let index = 0; index < AGENT_STATUS_STORE_LIMITS.parents; index++) { + owner.publish({ ...summary, sessionId: `structured-other-${index}` }, location) + } + owner.publish({ ...summary, status: 'idle' }, location) + owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') + expect(owner.readChildWork(summary.sessionId)).toEqual([]) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + owner.publishChildWork(summary.sessionId, [ended('background-agent')], 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) + + it('past its bound, holds incomplete until the provider session ends', () => { + const owner = storeFull() + const many = Array.from({ length: 513 }, (_unused, index) => `refused-${index}`) + owner.publishChildWork(summary.sessionId, many.map(agentLive), 'claude') + owner.publishChildWork(summary.sessionId, many.map(ended), 'claude') + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) + owner.publishChildWork( + summary.sessionId, + [{ type: 'session-ended', observedAt: 300 }], + 'claude' + ) + expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index faf9022f586..0bae091ff8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -4,6 +4,8 @@ import type { } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkReconcileOutcome } from '../../../shared/agent-status-child-work-evidence-admission' +import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { parseAgentStatusSubject, @@ -22,11 +24,20 @@ export type StructuredAgentSessionStatusSink = { subject: AgentStatusStructuredSessionSubject, evidence: AgentChildWorkEvidence[], provider: AgentSessionRecord['provider'] - ) => void + ) => AgentChildWorkReconcileOutcome | null | void /** The child records the sink holds for that subject, as the views every surface reads. */ readChildWork?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkView[] + /** The records those views project, with the host-only facts no view carries. */ + readChildWorkRecords?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkRecord[] } +/** The store's limits refusing a record: the per-session one, and the store's own. */ +const ROOM_REFUSALS: ReadonlySet = new Set(['ingestion-limit', 'store-rejected']) +/** Refused children remembered per session; past it only the session's end clears the flag. */ +const MAX_REFUSED_CHILDREN = 512 + +type RefusedChildren = { handles: Set; overflowed: boolean } + /** Retain the owner address because record removal may precede the final status callback. */ export class StructuredAgentSessionStatusOwnership { private readonly subjects = new Map() @@ -34,6 +45,10 @@ export class StructuredAgentSessionStatusOwnership { // forget a row that did land, but "we hold an address" is not evidence the row is there. Only a // publish that returned proves that, and only that proof may suppress the re-offer below. private readonly landed = new Set() + /** Per session, the children the store refused for room, by handle. Each leaves once a later + * report of it is admitted or it reports its own end; the provider session's end, which ends + * every child it ran, clears them all. */ + private readonly refused = new Map() constructor(private readonly sink: () => StructuredAgentSessionStatusSink | undefined) {} @@ -84,8 +99,49 @@ export class StructuredAgentSessionStatusOwnership { provider: AgentSessionRecord['provider'] ): void { const subject = this.subjects.get(sessionId) - if (subject && this.landed.has(sessionId)) { - this.sink()?.publishChildWork?.(subject, evidence, provider) + if (!subject || !this.landed.has(sessionId)) { + return + } + const outcome = this.sink()?.publishChildWork?.(subject, evidence, provider) + if (evidence.some((edge) => edge.type === 'session-ended')) { + this.refused.delete(sessionId) + } else if (typeof outcome === 'object') { + this.followRefusals(sessionId, evidence, outcome) + } + } + + /** Whether some of the session's running child work has no record. */ + childWorkIncomplete(sessionId: string): boolean { + return this.refused.has(sessionId) + } + + /** `null`: the sink holds no parent row, so it recorded none of these children. */ + private followRefusals( + sessionId: string, + evidence: readonly AgentChildWorkEvidence[], + outcome: AgentChildWorkReconcileOutcome | null + ): void { + const refusedNow = new Set( + outcome?.rejected.filter(({ reason }) => ROOM_REFUSALS.has(reason)).map((r) => r.handleId) + ) + const refused = this.refused.get(sessionId) ?? { handles: new Set(), overflowed: false } + for (const edge of evidence) { + if (edge.type === 'live' && (outcome === null || refusedNow.has(edge.child.handle.id))) { + if (refused.handles.size < MAX_REFUSED_CHILDREN) { + refused.handles.add(edge.child.handle.id) + } else { + refused.overflowed = true + } + } else if (edge.type === 'live') { + refused.handles.delete(edge.child.handle.id) + } else if (edge.type === 'ended' || edge.type === 'removed') { + refused.handles.delete(edge.handle.id) + } + } + if (refused.handles.size > 0 || refused.overflowed) { + this.refused.set(sessionId, refused) + } else { + this.refused.delete(sessionId) } } @@ -95,12 +151,20 @@ export class StructuredAgentSessionStatusOwnership { return subject && this.landed.has(sessionId) ? this.sink()?.readChildWork?.(subject) : undefined } + readChildWorkRecords(sessionId: string): AgentChildWorkRecord[] | undefined { + const subject = this.subjects.get(sessionId) + return subject && this.landed.has(sessionId) + ? this.sink()?.readChildWorkRecords?.(subject) + : undefined + } + forget(sessionId: string): void { const subject = this.subjects.get(sessionId) if (!subject) { return } this.landed.delete(sessionId) + this.refused.delete(sessionId) this.sink()?.forget(subject) this.subjects.delete(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index 3dcc899b081..2c1f3b0373b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -17,6 +17,10 @@ import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + subagentJournalBody, + subagentJournalItemIdentity +} from '../agent-session-journal/journal-subagent-item' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -185,6 +189,20 @@ describe("a subagent's work and the recency of the session that spawned it", () const translator = createClaudeJournalTranslator({ sink: session.sink }) const handle = (event: ReturnType): void => translator.handle({ ...event, observedAt: session.tick() }) + /** The child's row as the host's writer revises it from the child's record. */ + const hostRow = (label: string, state: 'working' | 'completed') => + session.journal.appendItem( + subagentJournalItemIdentity('task-1'), + subagentJournalBody({ + type: 'subagent', + id: 'task-1', + label, + state, + groupId: 'thread', + run: 'toolu_1' + }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await session.prompt('prompt-1', 'fan out') handle(claudeUserTurn('user-1', 'fan out')) handle( @@ -230,10 +248,11 @@ describe("a subagent's work and the recency of the session that spawned it", () const sequence = session.journal.cursor().sequence // The session's own agent has settled. Its backgrounded child renames itself and then - // finishes, and each edge revises the session's roster row. + // finishes, and the host revises the child's own saved row for each edge. handle( claudeTask('task_updated', { task_id: 'task-1', patch: { description: 'Build watched' } }) ) + await hostRow('Build watched', 'working') handle( claudeTask('task_notification', { task_id: 'task-1', @@ -242,6 +261,7 @@ describe("a subagent's work and the recency of the session that spawned it", () summary: 'green' }) ) + await hostRow('Build watched', 'completed') await session.drain() // Controls, so the holds below are not vacuous: the child's edges DID reach the journal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts new file mode 100644 index 00000000000..40d1e2d2284 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts @@ -0,0 +1,279 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { isSubagentBlock } from '../../../shared/native-chat-types' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + StructuredAgentSessionSubagentRows, + type AgentChildWorkRowView +} from './structured-agent-session-subagent-rows' + +const journals = createTrackedJournalOpener() +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-subagent-rows-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function child(overrides: Partial = {}): AgentChildWorkRowView { + return { + id: 'record-1', + providerId: 'agent-1', + kind: 'agent', + description: 'Audit the build', + state: 'working', + membership: 'live', + firstObservedAt: 100, + observedAt: 100, + stoppable: true, + invocation: { invocationId: 'toolu_1', generation: 1 }, + residency: 'background', + ...overrides + } +} + +async function rig() { + const journal: AgentSessionJournal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: claudeProviderHandle('claude-session', null) + }, + now: () => 9_000, + stateDirectory: root + }) + let children: AgentChildWorkRowView[] = [] + const failed = vi.fn() + const rows = new StructuredAgentSessionSubagentRows({ + journal: () => journal, + fence: () => 1, + readChildWork: () => children, + onWriteFailed: failed + }) + const report = async (next: AgentChildWorkRowView[]) => { + children = next + rows.changed('session-1') + // The writer is a queued loop; one macrotask lets its writes land. + await new Promise((resolve) => setTimeout(resolve, 0)) + } + const saved = () => + journal + .snapshot() + .items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.filter(isSubagentBlock).map((block) => ({ item, block })) + : [] + ) + const openTurn = (turnId: string) => + journal.appendItem( + { provider: 'legacy', agent: 'claude', sessionId: 'session-1', recordId: `turn:${turnId}` }, + agentJournalTurnBody({ turnId, state: 'running', startedAt: 50 }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { + journal, + report, + saved, + openTurn, + failed, + fail: (value: boolean) => { + if (value) { + vi.spyOn(journal, 'appendItem').mockRejectedValue(new Error('disk full')) + } else { + vi.mocked(journal.appendItem).mockRestore() + } + } + } +} + +describe("each subagent's saved row", () => { + it('writes one row per subagent, where it was spawned, and none for other work', async () => { + const r = await rig() + await r.openTurn('turn-1') + await r.report([ + child(), + child({ id: 'record-2', providerId: 'agent-2', description: 'Read the docs' }), + child({ id: 'record-3', providerId: 'shell-1', kind: 'command' }) + ]) + const rows = r.saved() + expect(rows.map(({ block }) => [block.id, block.label, block.state])).toEqual([ + ['agent-1', 'Audit the build', 'working'], + ['agent-2', 'Read the docs', 'working'] + ]) + expect(rows.map(({ item }) => item.turnScope?.kind)).toEqual(['turn', 'turn']) + expect(rows[0]?.block).toMatchObject({ run: 'toolu_1', background: true, startedAt: 100 }) + }) + + it('names a subagent the provider gave no name or description as any unnamed one', async () => { + const r = await rig() + await r.report([child({ description: undefined })]) + const [row] = r.saved() + expect(row?.block.label).toBe('subagent') + expect(row?.item.body).toMatchObject({ + blocks: [{ type: 'text', text: 'Kicked off subagent subagent' }, { label: 'subagent' }] + }) + }) + + it("names a nested helper's parent by the parent's own id, and keeps it once that record is gone", async () => { + const r = await rig() + const parent = child({ id: 'record-parent', providerId: 'agent-parent' }) + const nested = child({ + id: 'record-nested', + providerId: 'agent-nested', + parentChildWorkId: 'record-parent' + }) + await r.report([parent, nested]) + const parentOf = (id: string) => r.saved().find(({ block }) => block.id === id)?.block.parentId + expect(parentOf('agent-nested')).toBe('agent-parent') + expect(parentOf('agent-parent')).toBeUndefined() + + // The parent's record retired; the nested helper's next change keeps the parent its row names. + await r.report([{ ...nested, parentChildWorkId: undefined, totalTokens: 30 }]) + expect(r.saved().find(({ block }) => block.id === 'agent-nested')?.block).toMatchObject({ + parentId: 'agent-parent', + tokens: 30 + }) + }) + + it('reads a helper that finished while its own shell runs as working, as the strip does', async () => { + const r = await rig() + const finished = child({ + membership: 'settled', + state: 'done', + outcome: 'succeeded', + settledAt: 150 + }) + const shell = (overrides: Partial) => + child({ + id: 'record-shell', + providerId: 'shell-1', + kind: 'command', + parentChildWorkId: 'record-1', + ...overrides + }) + const helper = () => r.saved().find(({ block }) => block.id === 'agent-1')?.block + await r.report([finished, shell({})]) + expect(helper()).toMatchObject({ state: 'working' }) + expect(helper()).not.toHaveProperty('settledAt') + + // The shell ends and no wake-up follows: the helper reads its own ending again. + await r.report([ + finished, + shell({ membership: 'settled', state: 'done', outcome: 'succeeded', settledAt: 400 }) + ]) + expect(helper()).toMatchObject({ state: 'completed', settledAt: 150 }) + + // The session ends while the shell runs: the shell's unknown ending is not the helper's. + await r.report([finished, shell({})]) + expect(helper()).toMatchObject({ state: 'working' }) + await r.report([ + finished, + shell({ membership: 'settled', state: 'done', outcome: 'unknown', settledAt: 500 }) + ]) + expect(helper()).toMatchObject({ state: 'completed', settledAt: 150 }) + }) + + it('writes nothing for a record its row already says', async () => { + const r = await rig() + await r.report([child()]) + const [first] = r.saved() + await r.report([child({ observedAt: 150, stoppable: false })]) + expect(r.saved()).toEqual([first]) + }) + + it('revises the row in place: same item, same spawn place, the new ending', async () => { + const r = await rig() + await r.openTurn('turn-1') + await r.report([child()]) + const [before] = r.saved() + await r.openTurn('turn-2') + await r.report([ + child({ + membership: 'settled', + state: 'done', + outcome: 'cancelled', + outcomeBasis: 'stop-acknowledged', + settledAt: 300 + }) + ]) + const [after] = r.saved() + expect(after?.item.itemId).toBe(before?.item.itemId) + expect(after?.item.sequence).toBe(before?.item.sequence) + expect(after?.item.turnScope).toEqual(before?.item.turnScope) + expect(after?.block).toMatchObject({ + groupId: before?.block.groupId, + state: 'stopped', + settledAt: 300, + outcomeBasis: 'stop-acknowledged' + }) + }) + + it('starts a new run’s clock when the host first saw that run', async () => { + const r = await rig() + await r.report([child()]) + await r.report([ + child({ observedAt: 400, invocation: { invocationId: 'toolu_2', generation: 2 } }) + ]) + expect(r.saved()[0]?.block).toMatchObject({ run: 'toolu_2', startedAt: 400 }) + }) + + it('reports a failed write and writes the row on the next change', async () => { + const r = await rig() + r.fail(true) + await r.report([child()]) + expect(r.failed).toHaveBeenCalledWith('session-1', expect.any(Error)) + expect(r.saved()).toEqual([]) + r.fail(false) + await r.report([child({ totalTokens: 20 })]) + expect(r.saved().map(({ block }) => block.tokens)).toEqual([20]) + }) + + it('reads the live turn once per change, and never for subagents already saved', async () => { + const r = await rig() + await r.openTurn('turn-1') + const scan = vi.spyOn(r.journal, 'liveTurnScope') + const three = ['agent-1', 'agent-2', 'agent-3'] + await r.report(three.map((id) => child({ id: `record-${id}`, providerId: id }))) + expect(scan).toHaveBeenCalledTimes(1) + + scan.mockClear() + await r.report( + three.map((id) => child({ id: `record-${id}`, providerId: id, totalTokens: 40 })) + ) + expect(scan).not.toHaveBeenCalled() + expect(r.saved().map(({ block }) => block.tokens)).toEqual([40, 40, 40]) + }) + + it('keeps the spawn turn of a first row still being written when a turn opens', async () => { + const r = await rig() + await r.openTurn('turn-1') + const append = r.journal.appendItem.bind(r.journal) + let release = () => {} + const held = new Promise((resolve) => (release = resolve)) + vi.spyOn(r.journal, 'appendItem').mockImplementationOnce(async (...args) => { + await held + return append(...args) + }) + await r.report([child()]) + await r.openTurn('turn-2') + await r.report([child({ totalTokens: 40 })]) + release() + await new Promise((resolve) => setTimeout(resolve, 0)) + + const [row] = r.saved() + expect(row?.block.tokens).toBe(40) + expect(row?.item.turnScope).toMatchObject({ kind: 'turn', turnItemId: row?.block.groupId }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.ts new file mode 100644 index 00000000000..03236370e8e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.ts @@ -0,0 +1,180 @@ +// Each subagent's own saved row, written from the host's record of it. +// +// The host's child-work records are the one account of a subagent, for every agent: what started +// it, which run is current and how that run ended. Its journal row is their durable copy, written +// here each time a record changes, where the subagent was spawned. The saved row is also the +// dedupe: a record that reads as its row already does writes nothing, and a write that fails is +// tried again by the next change after its batch settles, so nothing here holds an obligation of +// its own. What a lost write leaves saying `working` the journal's reopen pass settles as +// unverifiable. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkInput } from '../../../shared/agent-status-child-work' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { + NativeChatSubagentBlock, + NativeChatSubagentState +} from '../../../shared/native-chat-types' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + journaledSubagent, + subagentJournalBody, + subagentJournalItemIdentity +} from '../agent-session-journal/journal-subagent-item' + +const UNNAMED = 'subagent' +const THREAD_GROUP = 'thread' + +/** A child's view, with the host-only facts its saved row also keeps. */ +export type AgentChildWorkRowView = AgentChildWorkView & + Pick + +type Row = { block: NativeChatSubagentBlock; turnScope: AgentJournalTurnScope } + +export type StructuredAgentSessionSubagentRowsDeps = { + journal: (sessionId: string) => AgentSessionJournal | undefined + fence: (sessionId: string) => number + readChildWork: (sessionId: string) => AgentChildWorkRowView[] | undefined + onWriteFailed: (sessionId: string, error: unknown) => void +} + +export class StructuredAgentSessionSubagentRows { + /** Rows waiting for the session's writer, newest per subagent. */ + private readonly queued = new Map>() + /** The batch each session's writer has in flight: written, but not yet readable by key. */ + private readonly writing = new Map>() + + constructor(private readonly deps: StructuredAgentSessionSubagentRowsDeps) {} + + /** The session's child records changed: queue each subagent whose row says otherwise. */ + changed(sessionId: string): void { + const journal = this.deps.journal(sessionId) + const children = this.deps.readChildWork(sessionId) + if (!journal || !children?.some((child) => child.kind === 'agent')) { + return + } + const queued = this.queued.get(sessionId) ?? new Map() + const providerIds = new Map(children.map((child) => [child.id, child.providerId])) + // The strip's rule: a child that finished while work it started runs is still running. + const running = new Set(structuredRunningChildWork(children).map((child) => child.id)) + // A full journal pass, so read once, and only for a first row: a revision keeps the scope + // its first write froze. + let live: AgentJournalTurnScope | undefined + for (const child of children) { + const id = child.providerId + if (child.kind !== 'agent' || id === undefined) { + continue + } + const pending = queued.get(id) ?? this.writing.get(sessionId)?.get(id) + const saved = pending?.block ?? journaledSubagent(journal, id) + const turnScope = + pending?.turnScope ?? + (saved ? AGENT_JOURNAL_THREAD_SCOPE : (live ??= journal.liveTurnScope())) + const parentId = + child.parentChildWorkId === undefined ? undefined : providerIds.get(child.parentChildWorkId) + const block = subagentRowBlock(child, id, parentId, saved, turnScope, running.has(child.id)) + if (!saved || rowText(saved) !== rowText(block)) { + queued.set(id, { block, turnScope }) + } + } + if (queued.size > 0) { + this.queued.set(sessionId, queued) + void this.write(sessionId) + } + } + + private async write(sessionId: string): Promise { + if (this.writing.has(sessionId)) { + return + } + try { + for (let rows = this.take(sessionId); rows; rows = this.take(sessionId)) { + const journal = this.deps.journal(sessionId) + // A closed conversation's unwritten rows are the reopen pass's to settle. + if (!journal) { + return + } + this.writing.set(sessionId, rows) + // The journal serializes its writes; these land in the order they were queued. + await Promise.all( + [...rows].map(([id, row]) => + journal + .appendItem(subagentJournalItemIdentity(id), subagentJournalBody(row.block), { + fence: this.deps.fence(sessionId), + turnScope: row.turnScope + }) + .catch((error: unknown) => this.deps.onWriteFailed(sessionId, error)) + ) + ) + } + } finally { + this.writing.delete(sessionId) + } + } + + private take(sessionId: string): Map | undefined { + const rows = this.queued.get(sessionId) + this.queued.delete(sessionId) + return rows + } +} + +/** The record as its row says it. A saved row keeps where it was spawned, and what a record of a + * later run no longer carries: its name, its tokens, its parent. */ +function subagentRowBlock( + child: AgentChildWorkRowView, + id: string, + parentId: string | undefined, + saved: NativeChatSubagentBlock | null, + scope: AgentJournalTurnScope, + running: boolean +): NativeChatSubagentBlock { + const run = child.invocation.invocationId + const state = running ? (child.state === 'idle' ? 'idle' : 'working') : subagentEnding(child) + const tokens = child.totalTokens ?? saved?.tokens + const parent = parentId ?? saved?.parentId + // A new run starts its clock when the host first saw it; the first run when the child was. + const startedAt = + saved === null + ? child.firstObservedAt + : saved.run === run + ? (saved.startedAt ?? child.firstObservedAt) + : child.observedAt + return { + type: 'subagent', + id, + label: child.description ?? child.name ?? saved?.label ?? UNNAMED, + state, + ...(tokens !== undefined ? { tokens } : {}), + startedAt, + ...(state !== 'working' && child.settledAt !== undefined ? { settledAt: child.settledAt } : {}), + groupId: saved?.groupId ?? (scope.kind === 'turn' ? scope.turnItemId : THREAD_GROUP), + ...(parent !== undefined ? { parentId: parent } : {}), + run, + ...(!running && child.outcomeBasis !== undefined ? { outcomeBasis: child.outcomeBasis } : {}), + ...(child.residency === 'background' ? { background: true as const } : {}) + } +} + +function subagentEnding(child: AgentChildWorkRowView): NativeChatSubagentState { + switch (child.outcome) { + case 'succeeded': + return 'completed' + case 'failed': + return 'failed' + case 'cancelled': + return 'stopped' + case 'unknown': + case undefined: + return 'unverifiable' + } +} + +/** Compared as written, so a row the journal bounded still matches the record it came from. */ +function rowText(block: NativeChatSubagentBlock): string { + return JSON.stringify(subagentJournalBody(block).blocks[1]) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index a588fa7168a..b8151213595 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -1,6 +1,4 @@ -import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop-targets' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalStatusItem, @@ -32,6 +30,10 @@ import { structuredAgentSessionFailedStopMark } from './structured-agent-session import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import { settleTakenStop } from './structured-agent-session-taken-stop-settle' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { + stopStructuredAgentSessionBackgroundWork, + type StructuredAgentSessionBackgroundStopInput +} from './structured-agent-session-background-stop' /** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it * without waiting on the write queue. */ @@ -123,12 +125,11 @@ export async function performCancel( } } -type PerformCancelInput = { +type PerformCancelInput = StructuredAgentSessionBackgroundStopInput & { clientOperationId: string /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ turnId?: string scope?: 'background-tasks' - taskId?: string prompt?: { itemId: string; expectedRevision: number } /** Ends the provider child, for a running command the provider did not take the Stop on, or a * turn whose interrupt failed. */ @@ -144,8 +145,6 @@ type PerformCancelInput = { /** Whether the host's withdrawal of queued messages for this Stop withdrew any; awaited only * after the interrupt. */ withdrewQueued?: Promise - /** The session's child records: a background Stop reaches the tasks they offer a stop. */ - childWork?: () => readonly AgentChildWorkView[] | undefined /** The latest Stop event is this press's own, or the in-force one it repeats: its settle binds. */ opensSettle?: true } @@ -197,11 +196,11 @@ async function cancelAndNote( const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) const background = input.scope && !stoppedBefore - ? await ctx.adapter.stopBackgroundTasks?.({ - sessionId: ctx.sessionId, - fence: ctx.fence, - taskIds: agentChildWorkStopTargets(input.childWork?.(), input.taskId) - }) + ? await stopStructuredAgentSessionBackgroundWork( + ctx, + input, + () => ctx.journal.activeTurnId() === null && !isMainAgentWorking(ctx) + ) : undefined // A background Stop writes no row, so a task the agent shows still running after it goes back // to the client that pressed it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 3fe9542e7b6..65c21b5e1bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -5,6 +5,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -222,7 +224,8 @@ describe('performCancel', () => { expect(stopBackgroundTasks).toHaveBeenCalledWith({ sessionId: 'session-1', fence: 1, - taskIds: ['task-1'] + taskIds: ['task-1'], + acknowledge: expect.any(Function) }) expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) @@ -262,7 +265,8 @@ describe('performCancel', () => { expect(stopBackgroundTasks).toHaveBeenCalledWith({ sessionId: 'session-1', fence: 1, - taskIds: ['task-2'] + taskIds: ['task-2'], + acknowledge: expect.any(Function) }) expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) @@ -270,10 +274,18 @@ describe('performCancel', () => { async function backgroundStopWith( stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'], - operation: string + operation: string, + acknowledgeStops?: (evidence: AgentChildWorkEvidence[]) => void, + /** An untargeted press, as production sends with unlisted work: `listed` is what the records list. */ + untargeted?: { + unlisted: { present: () => boolean; stop: () => Promise } + listed: AgentChildWorkView[] + onJournal?: (journal: AgentSessionJournal) => void + } ) { root = await mkdtemp(join(tmpdir(), 'orca-background-task-failed-cancel-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + untargeted?.onJournal?.(journal) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { logger: createStructuredAgentSessionLogger(), @@ -292,11 +304,14 @@ describe('performCancel', () => { clientOperationId: operation, turnId: 'background-tasks', scope: 'background-tasks', - taskId: 'task-1', - childWork: () => [liveTask('task-1')] + ...(untargeted + ? { unlisted: untargeted.unlisted, childWork: () => untargeted.listed } + : { taskId: 'task-1', childWork: () => [liveTask('task-1')] }), + ...(acknowledgeStops ? { acknowledgeStops } : {}) }) expect(cancelTurn).not.toHaveBeenCalled() - expect(journal.snapshot().items).toEqual([]) + // A background Stop writes no row; only what the agent itself wrote is there. + expect(journal.snapshot().items).toHaveLength(untargeted?.onJournal ? 1 : 0) return result } @@ -327,6 +342,94 @@ describe('performCancel', () => { }) expect(result.ok ? undefined : result.refusal.details).toBeUndefined() }) + + it('ends the agent for unlisted work only when, after the listed stops, no turn runs', async () => { + const stop = vi.fn(async () => {}) + // Unlisted work is read inside the Stop's step: here it appears while the listed stop is out. + let incomplete = false + const atRest = await backgroundStopWith( + async () => { + incomplete = true + return { cancelled: false } + }, + 'cancel-background-unlisted', + undefined, + { unlisted: { present: () => incomplete, stop }, listed: [liveTask('task-1')] } + ) + expect(atRest).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: true } }) + expect(stop).toHaveBeenCalledOnce() + + // A turn the agent opens while the listed stops are out is not the Stop's to end. + let journal: AgentSessionJournal | undefined + const woke = await backgroundStopWith( + async () => { + await journal!.appendItem( + { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:woke' + }, + { + kind: 'status', + text: 'Agent is working…', + turnLifecycle: { turnId: 'woke', state: 'running' } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { cancelled: false } + }, + 'cancel-background-unlisted-woke', + undefined, + { + unlisted: { present: () => true, stop }, + listed: [liveTask('task-1')], + onJournal: (opened) => (journal = opened) + } + ) + expect(woke).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: false } }) + expect(stop).toHaveBeenCalledOnce() + }) + + it('ends the agent for unlisted work with nothing listed, never asking an agent that has no stop', async () => { + const stop = vi.fn(async () => {}) + // As an ACP agent with no cancellation route answers any stop. + const stopBackgroundTasks = vi.fn(async () => { + throw new Error('ACP child cancellation is unavailable') + }) + const result = await backgroundStopWith( + stopBackgroundTasks, + 'cancel-background-unlisted-acp', + undefined, + { + unlisted: { present: () => true, stop }, + listed: [] + } + ) + expect(result).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: true } }) + expect(stopBackgroundTasks).not.toHaveBeenCalled() + expect(stop).toHaveBeenCalledOnce() + }) + + it('ends a run the agent accepted the stop of, even when a later stop in the batch fails', async () => { + const acknowledgeStops = vi.fn() + const result = await backgroundStopWith( + async ({ acknowledge }) => { + acknowledge?.({ taskId: 'task-1' }) + throw new Error('claude control request timed out') + }, + 'cancel-background-task-partial', + acknowledgeStops + ) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(acknowledgeStops).toHaveBeenCalledWith([ + expect.objectContaining({ type: 'stop-acknowledged', childWorkId: 'child-task-1' }) + ]) + }) }) /** A live child record the strip would offer a stop, named by the provider as `providerId`. */ diff --git a/src/main/native-chat/subagent-roster-retention.test.ts b/src/main/native-chat/subagent-roster-retention.test.ts deleted file mode 100644 index c89831e2263..00000000000 --- a/src/main/native-chat/subagent-roster-retention.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - NativeChatSubagentEntry, - NativeChatSubagentState -} from '../../shared/native-chat-types' -import { SubagentRosterRetention } from './subagent-roster-retention' - -type Group = { - groupId: string - entries: Map - lastSerialized: string | null -} - -function group(groupId: string, state: NativeChatSubagentState, children = 64): Group { - return { - groupId, - lastSerialized: 'admitted', - entries: new Map( - Array.from({ length: children }, (_, index) => { - const id = `${groupId}:${index}` - return [id, { id, label: id, state }] - }) - ) - } -} - -describe('host subagent roster settled retention', () => { - it('shares one bound between superseded executions and evicted group identities', () => { - const groups = new Map() - const retention = new SubagentRosterRetention(groups, { - maxGroups: 1, - maxSettledIdentities: 2048, - entries: (group) => group.entries.values(), - identities: (roster) => roster.entries.keys() - }) - const live = group('live', 'working', 1) - groups.set(live.groupId, live) - for (let index = 0; index < 2100; index++) { - retention.rememberSettled(`superseded-${index}`) - } - const settled = group('settled', 'completed', 1) - groups.set(settled.groupId, settled) - retention.trim([settled]) - expect(retention.sizes()).toEqual({ groups: 1, settledIdentities: 2048 }) - expect(retention.hasSettled('superseded-52')).toBe(false) - expect(retention.hasSettled('superseded-2099')).toBe(true) - expect(retention.hasSettled('settled:0')).toBe(true) - retention.clear() - expect(retention.sizes().settledIdentities).toBe(0) - }) - - it('bounds settled groups and recent identities while retaining every live group', () => { - const groups = new Map() - const retention = new SubagentRosterRetention(groups, { - maxGroups: 32, - maxSettledIdentities: 32 * 64, - entries: (group) => group.entries.values(), - identities: (roster) => roster.entries.keys() - }) - for (let index = 0; index < 35; index++) { - const live = group(`live-${index}`, 'working') - groups.set(live.groupId, live) - retention.trim([live]) - } - for (let index = 0; index < 70; index++) { - const settled = group(`settled-${index}`, 'completed') - groups.set(settled.groupId, settled) - retention.trim([settled]) - } - expect(retention.sizes()).toEqual({ groups: 35, settledIdentities: 32 * 64 }) - expect(retention.hasSettled('settled-0:0')).toBe(false) - expect(retention.hasSettled('settled-69:0')).toBe(true) - expect([...groups.keys()].every((id) => id.startsWith('live-'))).toBe(true) - for (let index = 0; index < 70; index++) { - const settled = group(`more-settled-${index}`, 'completed') - const live = groups.get(`live-${index}`) - if (live) { - for (const entry of live.entries.values()) { - entry.state = 'completed' - } - retention.trim([live]) - } - groups.set(settled.groupId, settled) - retention.trim([settled]) - } - expect(retention.sizes()).toEqual({ groups: 32, settledIdentities: 32 * 64 }) - }) - - it('re-derives settled eligibility when a retained child runs again or its write is refused', () => { - const groups = new Map() - const retention = new SubagentRosterRetention(groups, { - maxGroups: 1, - maxSettledIdentities: 64, - entries: (group) => group.entries.values(), - identities: (roster) => roster.entries.keys() - }) - const old = group('old', 'completed', 1) - groups.set(old.groupId, old) - retention.trim([old]) - old.entries.set('old:0', { id: 'old:0', label: 'old', state: 'working' }) - retention.trim([old]) - const newGroup = group('new', 'working', 1) - groups.set(newGroup.groupId, newGroup) - retention.trim([newGroup]) - expect(groups.has('old')).toBe(true) - old.entries.set('old:0', { id: 'old:0', label: 'old', state: 'completed' }) - old.lastSerialized = null - retention.trim([old]) - expect(groups.has('old')).toBe(true) - old.lastSerialized = 'admitted' - retention.trim([old]) - expect(groups.has('old')).toBe(false) - expect(retention.hasSettled('old:0')).toBe(true) - groups.clear() - retention.clear() - expect(retention.sizes()).toEqual({ groups: 0, settledIdentities: 0 }) - }) -}) - -it('keeps only the accessed inherited row during a trim and re-derives it on the next event', () => { - const groups = new Map() - const retention = new SubagentRosterRetention(groups, { - maxGroups: 32, - maxSettledIdentities: 2048, - entries: (roster) => roster.entries.values(), - identities: (roster) => roster.entries.keys() - }) - for (let index = 0; index < 32; index++) { - const live = group(`live-${index}`, 'working', 1) - groups.set(live.groupId, live) - retention.trim([live]) - } - for (let index = 0; index < 70; index++) { - const inherited = group(`inherited-${index}`, 'completed', 1) - groups.set(inherited.groupId, inherited) - retention.trim([inherited], (groupId) => groupId === inherited.groupId) - expect(groups.size).toBe(33) - expect(groups.has(inherited.groupId)).toBe(true) - } - retention.trim([]) - expect(groups.size).toBe(32) - expect(retention.hasSettled('inherited-69:0')).toBe(true) - const resumed = group('resumed', 'completed', 1) - groups.set(resumed.groupId, resumed) - retention.trim([resumed], (groupId) => groupId === resumed.groupId) - resumed.entries.set('resumed:0', { id: 'resumed:0', label: 'resumed', state: 'working' }) - retention.trim([resumed]) - expect(groups.has(resumed.groupId)).toBe(true) - expect(groups.size).toBe(33) -}) diff --git a/src/main/native-chat/subagent-roster-retention.ts b/src/main/native-chat/subagent-roster-retention.ts deleted file mode 100644 index 3e3584a4efb..00000000000 --- a/src/main/native-chat/subagent-roster-retention.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { BoundedMap } from '../../shared/bounded-map' -import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' - -type RetainedRosterGroup = { - groupId: string - lastSerialized: string | null -} - -/** Bounds settled history; live children and refused final writes keep their ownership. */ -export class SubagentRosterRetention { - private readonly settledGroups = new Set() - private readonly settledIdentities: BoundedMap - - constructor( - private readonly groups: Map, - private readonly options: { - maxGroups: number - maxSettledIdentities: number - entries: (group: Group) => Iterable - identities: (group: Group) => Iterable - onEvict?: (group: Group) => void - } - ) { - this.settledIdentities = new BoundedMap({ maxEntries: options.maxSettledIdentities }) - } - - hasSettled(identity: string): boolean { - return this.settledIdentities.has(identity) - } - - /** Superseded executions share the evicted history's single bounded budget. */ - rememberSettled(identity: string): void { - this.settledIdentities.set(identity, true) - } - - /** `kept` names groups that are never released: rows a later report can name again. */ - trim(changed: Iterable, kept?: (groupId: string) => boolean): void { - for (const group of changed) { - if ( - group.lastSerialized !== null && - [...this.options.entries(group)].every((entry) => isTerminalSubagentState(entry.state)) - ) { - this.settledGroups.add(group.groupId) - } else { - this.settledGroups.delete(group.groupId) - } - } - for (const groupId of this.settledGroups) { - if (this.groups.size <= this.options.maxGroups) { - break - } - if (kept?.(groupId)) { - continue - } - const group = this.groups.get(groupId) - if (group) { - for (const identity of this.options.identities(group)) { - this.rememberSettled(identity) - } - this.options.onEvict?.(group) - } - this.groups.delete(groupId) - this.settledGroups.delete(groupId) - } - } - - sizes(): { groups: number; settledIdentities: number } { - return { groups: this.groups.size, settledIdentities: this.settledIdentities.size } - } - - clear(): void { - this.settledGroups.clear() - this.settledIdentities.clear() - } -} diff --git a/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts b/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts deleted file mode 100644 index 32ea45200b9..00000000000 --- a/src/main/native-chat/subagent-tracker/journaled-subagent-groups.ts +++ /dev/null @@ -1,112 +0,0 @@ -// What earlier provider runs of a session left of its subagent rows, re-derived from the rows they -// journaled. -// -// The tracker lives in one provider process, but every row it writes outlives that process under a -// restart-stable identity. A run that started from nothing would rewrite a row no turn owns from -// empty and list a resumed child twice, so a run reads what earlier runs left instead: derived, -// never stored, so it cannot disagree with the rows it came from. - -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity, - type AgentJournalProducerLinkage, - type AgentJournalTurnScope -} from '../../../shared/agent-session-journal-types' -import { - isSubagentGroupBlock, - type NativeChatSubagentEntry -} from '../../../shared/native-chat-types' -import type { StructuredAgentSessionLinkageJournal } from '../agent-session-wire/structured-agent-session-event-sink' -import type { JournaledSubagentSource } from './subagent-tracker-types' - -type JournaledGroup = { - entries: readonly NativeChatSubagentEntry[] - placement: AgentJournalTurnScope -} - -/** More an agent reads from the same pass, from who produced each row. */ -export type JournaledSubagentExtension = { - create: () => Extra - visit: (extra: Extra, linkage: AgentJournalProducerLinkage) => void -} - -type Reading = { - rowsByGroup: Map - groupByEntry: Map - extra: Extra | null -} - -/** Read once per bound journal epoch. Before the journal is bound nothing is read and nothing is - * kept, so the first read after bind still sees every row. */ -export class JournaledSubagentGroups< - Extra = never -> implements JournaledSubagentSource { - private journal: StructuredAgentSessionLinkageJournal | null = null - private epoch: string | null = null - private reading: Reading | null = null - - constructor( - private readonly bound: () => StructuredAgentSessionLinkageJournal | null, - private readonly identityFor: (groupId: string) => AgentJournalItemIdentity, - private readonly extension?: JournaledSubagentExtension - ) {} - - groupOf = (id: string): string | null => this.current()?.groupByEntry.get(id) ?? null - - claimGroup = (groupId: string): JournaledGroup | null => { - const reading = this.current() - const row = reading?.rowsByGroup.get(groupId) ?? null - reading?.rowsByGroup.delete(groupId) - return row - } - - /** The extension's reading of the bound journal; null before bind. */ - extra(): Extra | null { - return this.current()?.extra ?? null - } - - private current(): Reading | null { - const journal = this.bound() - if (!journal) { - return null - } - if (!this.reading || journal !== this.journal || journal.epoch !== this.epoch) { - this.journal = journal - this.epoch = journal.epoch - this.reading = this.read(journal) - } - return this.reading - } - - private read(journal: StructuredAgentSessionLinkageJournal): Reading { - const reading: Reading = { - rowsByGroup: new Map(), - groupByEntry: new Map(), - extra: this.extension?.create() ?? null - } - // A child two rows list (only an older build wrote that) is the later-created row's: a turn's - // row is created with its turn, so that is where it last ran. - const listedAt = new Map() - journal.visitItemsWithLinkage((itemId, sequence, body, attribution) => { - if (this.extension && reading.extra !== null) { - this.extension.visit(reading.extra, attribution) - } - const group = body.kind === 'message' ? body.blocks.find(isSubagentGroupBlock) : undefined - if (!group || itemId !== agentJournalItemKey(this.identityFor(group.groupId))) { - return - } - reading.rowsByGroup.set(group.groupId, { - entries: group.agents, - placement: attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE - }) - for (const entry of group.agents) { - if ((listedAt.get(entry.id) ?? -1) < sequence) { - listedAt.set(entry.id, sequence) - reading.groupByEntry.set(entry.id, group.groupId) - } - } - }) - return reading - } -} diff --git a/src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts b/src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts deleted file mode 100644 index c596e367d5d..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-row-sink-port.ts +++ /dev/null @@ -1,37 +0,0 @@ -// A subagent group's row written straight to the session's journal sink. - -import type { - AgentJournalItemIdentity, - AgentJournalTurnScope -} from '../../../shared/agent-session-journal-types' -import type { - StructuredAgentSessionEventSink, - StructuredAgentSessionSinkAdmission -} from '../agent-session-wire/structured-agent-session-event-sink' -import type { SubagentRowPort } from './subagent-tracker-types' - -const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } - -/** - * Deliberately ROOT: the row is written from a child's frame but describes the PARENT's children, - * so it is the session's own agent reporting what it spawned. Stamping it as a child's would hide - * the roster from the very row that owns it. - */ -export function subagentRowSinkPort( - sink: StructuredAgentSessionEventSink, - identityFor: (groupId: string) => AgentJournalItemIdentity -): SubagentRowPort { - return { - write: (group, { body }) => { - const identity = identityFor(group.groupId) - const admission = body - ? (sink.tryAppendItem?.(identity, body, { turnScope: group.placement }) ?? - (sink.appendItem(identity, body, { turnScope: group.placement }), ADMITTED)) - : (sink.tryAppendTombstone?.(identity) ?? (sink.appendTombstone(identity), ADMITTED)) - if (!admission.accepted) { - return admission - } - return sink.tryPublish?.() ?? (sink.publish(), ADMITTED) - } - } -} diff --git a/src/main/native-chat/subagent-tracker/subagent-row-writes.ts b/src/main/native-chat/subagent-tracker/subagent-row-writes.ts deleted file mode 100644 index e752b23c4d7..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-row-writes.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Writing a group's row: one dedupe and one refusal rule for every agent. - -import { subagentGroupJournalBody } from '../agent-session-journal/journal-subagent-group-body' -import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { SubagentGroup, SubagentRowPort } from './subagent-tracker-types' - -/** Writes each group whose row changed. The dedupe advances only once a revision is both queued and - * published, so a refused one is written again by the next write of that group; the first refusal - * is handed back after every group was tried. */ -export function writeSubagentRows( - groups: Iterable>, - port: SubagentRowPort -): StructuredAgentSessionSinkAdmission | null { - let refused: StructuredAgentSessionSinkAdmission | null = null - for (const group of groups) { - const agents = [...group.entries.values()].map((tracked) => tracked.entry) - const body = subagentGroupJournalBody(group.groupId, agents) - const serialized = JSON.stringify(body) - if (serialized === group.lastSerialized) { - // A duplicate delivery must not burn a revision. - continue - } - // A group that lost its last child removes its row. - const admission = port.write(group, { body: agents.length > 0 ? body : null, serialized }) - group.lastSerialized = admission.accepted ? serialized : null - refused ??= admission.accepted ? null : admission - } - return refused -} diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts b/src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts deleted file mode 100644 index 3efe65091d1..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker-entries.ts +++ /dev/null @@ -1,69 +0,0 @@ -// How one listed child changes: its name, how it starts a run, and which states may replace which. - -import { - MAX_SUBAGENT_FIELD_CHARS, - canReplaceSubagentState, - isTerminalSubagentState -} from '../../../shared/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' -import { ownRetainedString } from '../../../shared/own-retained-string' -import type { SubagentGroup, SubagentReport, TrackedSubagent } from './subagent-tracker-types' - -export const UNLABELLED_SUBAGENT = 'subagent' - -/** Provider strings are kept to one unit past the row's bound, so the row still marks the clip. */ -export function boundedSubagentLabel(label: string | null | undefined): string | null { - const trimmed = label?.trim() - return trimmed ? ownRetainedString(trimmed.slice(0, MAX_SUBAGENT_FIELD_CHARS + 1)) : null -} - -/** Two children can share a name; the ordinal keeps their rows apart without inventing a name the - * provider never sent. The probe is over the labels actually claimed, so a generated `Audit 2` - * never collides with a child the provider itself named `Audit 2`. */ -export function claimSubagentLabel(group: SubagentGroup, base: string): string { - let candidate = base - for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) { - candidate = `${base} ${ordinal}` - } - group.claimedLabels.add(candidate) - return candidate -} - -/** A run starting now. A new run keeps the token count its child already showed. */ -export function startedSubagentEntry( - report: SubagentReport, - label: string, - now: number, - previous?: NativeChatSubagentEntry -): NativeChatSubagentEntry { - const state = report.state ?? 'working' - const tokens = report.tokens ?? previous?.tokens - return { - id: report.id, - label, - state, - startedAt: now, - ...(isTerminalSubagentState(state) ? { settledAt: now } : {}), - ...(tokens !== undefined ? { tokens } : {}) - } -} - -/** Proven outcomes latch; lost contact can still receive a later verdict. Null when `state` may not - * replace the current one. */ -export function withSubagentState( - tracked: TrackedSubagent, - state: NativeChatSubagentEntry['state'], - now: number -): TrackedSubagent | null { - if (tracked.entry.state === state || !canReplaceSubagentState(tracked.entry.state, state)) { - return null - } - // Only a terminal state replaces another. A child an earlier provider process left ended with - // that process: a verdict says how, not when. - const settledAt = tracked.inherited ? tracked.entry.settledAt : now - const { settledAt: _previous, ...rest } = tracked.entry - return { - ...tracked, - entry: { ...rest, state, ...(settledAt !== undefined ? { settledAt } : {}) } - } -} diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts b/src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts deleted file mode 100644 index f7ea43906a0..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker-groups.ts +++ /dev/null @@ -1,167 +0,0 @@ -// Which group lists each subagent: the groups this provider run wrote, and the ones earlier runs of -// the session journaled, inherited one at a time as this run's own reports reach them. Every lookup -// is read from the rows themselves, so no second index can disagree with what a row lists. - -import { SubagentRosterRetention } from '../subagent-roster-retention' -import { subagentGroupJournalBody } from '../agent-session-journal/journal-subagent-group-body' -import { UNLABELLED_SUBAGENT } from './subagent-tracker-entries' -import type { - JournaledSubagentSource, - SubagentGroup, - SubagentReport, - TrackedSubagent -} from './subagent-tracker-types' - -/** Settled history target; groups with unfinished children remain owned. */ -export const MAX_SUBAGENT_GROUPS = 32 -const MAX_SETTLED_IDENTITIES = 2048 - -export type LocatedSubagent = { - group: SubagentGroup - tracked: TrackedSubagent -} - -export class SubagentTrackerGroups { - private readonly groups = new Map>() - private readonly retention = new SubagentRosterRetention(this.groups, { - maxGroups: MAX_SUBAGENT_GROUPS, - maxSettledIdentities: MAX_SETTLED_IDENTITIES, - entries: (group) => [...group.entries.values()].map((tracked) => tracked.entry), - // A released child's every run stays settled, so no replay of any of them lists it again. - identities: (group) => - [...group.entries.values()].flatMap(({ entry, runs }) => [ - settledIdentity(entry.id, null), - ...runs.map((run) => settledIdentity(entry.id, run)) - ]), - onEvict: (group) => this.deps.onEvict?.(group) - }) - - constructor( - private readonly deps: { - journaled?: JournaledSubagentSource - /** A group no turn owns: later reports name it again, so its row is never released, or a - * later child would rewrite it from empty. */ - outsideTurn: (groupId: string) => boolean - onEvict?: (group: SubagentGroup) => void - } - ) {} - - private get journaled(): JournaledSubagentSource | undefined { - return this.deps.journaled - } - - values(): Iterable> { - return this.groups.values() - } - - get(groupId: string): SubagentGroup | undefined { - return this.groups.get(groupId) - } - - /** The child's entry, inheriting the group an earlier provider run last listed it in. */ - locate(id: string): LocatedSubagent | null { - const listed = this.listed(id) - if (listed) { - return listed - } - const groupId = this.journaled?.groupOf(id) ?? null - return groupId !== null && !this.groups.has(groupId) && this.inherit(groupId) - ? this.listed(id) - : null - } - - /** This run's group for a key, else the earlier run's row it continues, else a new one. */ - groupFor(group: SubagentReport['group']): SubagentGroup { - const existing = this.groups.get(group.id) ?? this.inherit(group.id) - if (existing) { - return existing - } - const created: SubagentGroup = { - groupId: group.id, - placement: group.placement(), - entries: new Map(), - admittedEntries: 0, - claimedLabels: new Set(), - lastSerialized: null - } - this.groups.set(group.id, created) - return created - } - - hasSettled(id: string, run: string | null): boolean { - return this.retention.hasSettled(settledIdentity(id, run)) - } - - /** A run an entry no longer remembers is settled for good. */ - rememberSettledRun(id: string, run: string): void { - this.retention.rememberSettled(settledIdentity(id, run)) - } - - trim(changed: Iterable>, retainedGroupId?: string): void { - this.retention.trim( - changed, - (groupId) => groupId === retainedGroupId || this.deps.outsideTurn(groupId) - ) - } - - sizes(): { groups: number; settledIdentities: number } { - return this.retention.sizes() - } - - clear(): void { - this.retention.clear() - this.groups.clear() - } - - /** An older build could list one child in two rows: the inherited copy that counts is the one - * the journal reading says it last ran in; the other is history. */ - private listed(id: string): LocatedSubagent | null { - const chosen = this.journaled?.groupOf(id) ?? null - for (const group of this.groups.values()) { - const tracked = group.entries.get(id) - if (tracked && !(tracked.inherited && chosen !== null && chosen !== group.groupId)) { - return { group, tracked } - } - } - return null - } - - /** Once per group: after that this run's copy is the newer one. */ - private inherit(groupId: string): SubagentGroup | null { - const row = this.journaled?.claimGroup(groupId) ?? null - if (!row) { - return null - } - const group: SubagentGroup = { - groupId, - // The row keeps the turn it was created beside; a later run's writes never move it. - placement: row.placement, - entries: new Map(), - admittedEntries: row.entries.length, - claimedLabels: new Set(row.entries.map((entry) => entry.label)), - lastSerialized: JSON.stringify(subagentGroupJournalBody(groupId, row.entries)) - } - for (const entry of row.entries) { - group.entries.set(entry.id, { - entry: { ...entry }, - run: null, - runs: [], - attempt: this.journaled?.attempt?.(entry.id) ?? 1, - turn: null, - // Each child outlived the run that spawned it, so none is tied to this run's turns. - backgrounded: true, - labelBase: entry.label, - provisional: entry.label === UNLABELLED_SUBAGENT, - inherited: true - }) - } - this.groups.set(groupId, group) - // Counted as settled history now, even if only a lookup reached it; released by a later trim. - this.retention.trim([group], () => true) - return group - } -} - -function settledIdentity(id: string, run: string | null): string { - return JSON.stringify([id, run]) -} diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker-types.ts b/src/main/native-chat/subagent-tracker/subagent-tracker-types.ts deleted file mode 100644 index e53fd11a9b0..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker-types.ts +++ /dev/null @@ -1,88 +0,0 @@ -// The one shape every agent reports its subagents in, and the state the shared tracker keeps. - -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { - NativeChatSubagentEntry, - NativeChatSubagentState -} from '../../../shared/native-chat-types' -import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' - -/** What an agent observed about one of its subagents. Started, updated and finished are the state it - * carries; the tracker decides what the row says. */ -export type SubagentReport = { - /** The child's canonical id: the entry key, stable across its runs. */ - id: string - /** One execution of the child: Claude's spawn or resume call, Codex's child turn. Absent when the - * agent cannot tell, which means whichever run is current. */ - run?: string - /** The group of the turn this report happens in: where a new child is listed. A known child - * keeps its one entry, run after run. */ - group: { id: string; placement: () => Placement } - /** Only an announcement creates a child or starts a run; anything else revises a known one. */ - announces: boolean - label?: string | null - state?: NativeChatSubagentState | null - /** Latest total the provider counted for this child. */ - tokens?: number - /** Told to outlive the turn that spawned it, so that turn's end is no evidence about it. */ - backgrounded?: boolean -} - -export type TrackedSubagent = { - entry: NativeChatSubagentEntry - /** The run this entry shows; null when unknown (a provisional child, or one an earlier provider - * run journaled). */ - run: string | null - /** Runs this entry has shown, oldest first and bounded; a report for one of them is stale. */ - runs: string[] - /** Which run of the child this is: 1, then one more per run started. */ - attempt: number - /** The group of the turn that started the current run, wherever the entry is listed: that turn's - * end is what a foreground run cannot outlive. Null for a run an earlier provider process left. */ - turn: string | null - backgrounded: boolean - /** The name before its ordinal suffix; a placeholder until an announcement names the child. */ - labelBase: string - provisional: boolean - /** Listed by an earlier provider run and not run again here: a verdict says how it ended, not when. */ - inherited: boolean -} - -export type SubagentGroup = { - groupId: string - placement: Placement - /** Insertion order is the display order. */ - entries: Map - /** Lifetime admissions: bounds the labels a row retains even after removals. */ - admittedEntries: number - /** Labels stay reserved after removal or renaming, so an ordinal is never reused. */ - claimedLabels: Set - /** Last body written and accepted, so an identical revision is not written twice. */ - lastSerialized: string | null -} - -/** Where a group's row goes: the journal sink, or a provider timeline the host admits later. */ -export type SubagentRowPort = { - write: ( - group: SubagentGroup, - body: SubagentRowBody - ) => StructuredAgentSessionSinkAdmission -} - -export type SubagentRowBody = { - /** Null when the group lost its last child and its row is removed. */ - body: AgentJournalMessageItem | null - serialized: string -} - -/** What earlier provider runs of the session journaled, inherited one group at a time. */ -export type JournaledSubagentSource = { - /** The group row an earlier run last listed this child in. */ - groupOf: (id: string) => string | null - /** That group's row, handed over once: after that the tracker's copy is the newer one. */ - claimGroup: ( - groupId: string - ) => { entries: readonly NativeChatSubagentEntry[]; placement: Placement } | null - /** The latest run number any row records for this child; 1 when none says more. */ - attempt?: (id: string) => number -} diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts b/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts deleted file mode 100644 index 995fd0e49e0..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' -import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import { SubagentTracker } from './subagent-tracker' -import type { JournaledSubagentSource, SubagentReport } from './subagent-tracker-types' - -type Write = { groupId: string; agents: NativeChatSubagentEntry[] | null } - -function harness(journaled?: JournaledSubagentSource) { - const writes: Write[] = [] - let refuse = false - let clock = 100 - const tracker = new SubagentTracker({ - port: { - write: (group, { body }): StructuredAgentSessionSinkAdmission => { - if (refuse) { - return { accepted: false, reason: 'backpressure' } - } - const block = body?.blocks.find((candidate) => candidate.type === 'subagent-group') - writes.push({ - groupId: group.groupId, - agents: block?.type === 'subagent-group' ? block.agents : null - }) - return { accepted: true } - } - }, - ...(journaled ? { journaled } : {}), - outsideTurn: (groupId) => groupId.startsWith('outside'), - now: () => clock - }) - const report = ( - group: string, - fields: Omit, 'group' | 'announces'> & { announces?: boolean } - ) => { - clock += 10 - return tracker.report({ - announces: true, - ...fields, - group: { id: group, placement: () => group } - }) - } - const row = (groupId: string) => writes.findLast((write) => write.groupId === groupId)?.agents - return { - tracker, - writes, - report, - row, - refuse: (value: boolean) => { - refuse = value - }, - tick: () => (clock += 10) - } -} - -describe('SubagentTracker', () => { - it('lists only an announced child, in the group its report names', () => { - const h = harness() - h.report('turn-1', { id: 'a', announces: false, state: 'completed' }) - expect(h.writes).toEqual([]) - h.report('turn-1', { id: 'a', label: 'Audit' }) - h.report('turn-2', { id: 'a', state: 'completed', announces: false }) - expect(h.row('turn-1')).toEqual([ - { id: 'a', label: 'Audit', state: 'completed', startedAt: 120, settledAt: 130 } - ]) - expect(h.row('turn-2')).toBeUndefined() - }) - - it('numbers shared names over the labels already claimed, provider-named ones included', () => { - const h = harness() - for (const [id, label] of [ - ['a', 'Audit'], - ['b', 'Audit 2'], - ['c', 'Audit'], - ['d', null] - ] as const) { - h.report('turn', { id, label }) - } - expect(h.row('turn')?.map((entry) => entry.label)).toEqual([ - 'Audit', - 'Audit 2', - 'Audit 3', - 'subagent' - ]) - h.report('turn', { id: 'd', label: 'Named later', announces: false }) - expect(h.row('turn')?.at(-1)?.label).toBe('Named later') - }) - - it('latches proven outcomes but lets a verdict correct lost contact', () => { - const h = harness() - h.report('turn', { id: 'a' }) - h.report('turn', { id: 'b' }) - h.tracker.settleSession() - expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable']) - h.report('turn', { id: 'a', state: 'completed', announces: false }) - h.report('turn', { id: 'a', state: 'failed', announces: false }) - h.report('turn', { id: 'b', state: 'working', announces: false }) - expect(h.row('turn')).toEqual([ - expect.objectContaining({ id: 'a', state: 'completed', settledAt: 130 }), - expect.objectContaining({ id: 'b', state: 'unverifiable' }) - ]) - }) - - it('reopens a resumed child in the one row that lists it, and ignores its earlier runs', () => { - const h = harness() - h.report('turn-1', { id: 'a', run: 'r1', label: 'Audit', tokens: 5 }) - h.report('turn-1', { id: 'a', run: 'r1', state: 'completed', announces: false }) - h.report('turn-2', { id: 'a', run: 'r2', label: 'Audit' }) - expect(h.row('turn-2')).toBeUndefined() - expect(h.row('turn-1')).toEqual([ - { id: 'a', label: 'Audit', state: 'working', startedAt: 130, tokens: 5 } - ]) - // A late verdict or a replayed announcement of the earlier run changes nothing. - h.report('turn-2', { id: 'a', run: 'r1', state: 'failed', announces: false }) - h.report('turn-2', { id: 'a', run: 'r1' }) - expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'working' })]) - expect(h.tracker.attempt('a')).toBe(2) - }) - - it('names a placeholder child when a later run is announced with a name', () => { - const h = harness() - h.report('turn', { id: 'a', run: 'r1' }) - h.report('turn', { id: 'a', run: 'r2', label: 'Audit' }) - expect(h.row('turn')).toEqual([expect.objectContaining({ label: 'Audit', state: 'working' })]) - }) - - it('never releases the row no turn owns, so a later child cannot rewrite it from empty', () => { - const h = harness() - h.report('outside', { id: 'loose', state: 'completed' }) - for (let turn = 0; turn < 40; turn++) { - h.report(`turn-${turn}`, { id: `c${turn}`, state: 'completed' }) - } - h.report('outside', { id: 'later' }) - expect(h.row('outside')?.map((entry) => entry.id)).toEqual(['loose', 'later']) - }) - - it('reopens a run started again in the same turn, and never applies an old run to it', () => { - const h = harness() - h.report('turn', { id: 'a', run: 'r1' }) - h.report('turn', { id: 'a', run: 'r1', state: 'completed', announces: false }) - h.report('turn', { id: 'a', run: 'r2' }) - h.report('turn', { id: 'a', run: 'r1', state: 'failed', announces: false }) - expect(h.row('turn')).toEqual([ - { id: 'a', label: 'subagent', state: 'working', startedAt: 130 } - ]) - }) - - it('remembers runs aged out of a busy child, so none is revived', () => { - const h = harness() - for (let run = 0; run < 20; run++) { - h.report('turn', { id: 'a', run: `r${run}` }) - h.report('turn', { id: 'a', run: `r${run}`, state: 'completed', announces: false }) - } - h.report('turn', { id: 'a', run: 'r0' }) - expect(h.row('turn')).toEqual([expect.objectContaining({ state: 'completed' })]) - expect(h.tracker.attempt('a')).toBe(20) - }) - - it('ends a foreground run with the turn that started it, wherever its child is listed', () => { - const h = harness() - h.report('turn-1', { id: 'x', run: 'r1' }) - h.report('turn-1', { id: 'x', run: 'r1', state: 'completed', announces: false }) - h.report('turn-5', { id: 'x', run: 'r2' }) - h.tracker.settleTurn('turn-1') - expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'working' })]) - h.tracker.settleTurn('turn-5') - expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) - }) - - it('keeps an inherited no-turn row first reached through one of its children', () => { - let claimed = false - const h = harness({ - groupOf: (id) => (id === 'a' ? 'outside-turn' : null), - claimGroup: (groupId) => { - if (groupId !== 'outside-turn' || claimed) { - return null - } - claimed = true - return { - entries: [{ id: 'a', label: 'One', state: 'completed', startedAt: 1, settledAt: 2 }], - placement: 'outside-turn' - } - } - }) - h.report('outside-turn', { id: 'a', tokens: 3, announces: false }) - for (let turn = 0; turn < 40; turn++) { - h.report(`turn-${turn}`, { id: `c${turn}`, state: 'completed' }) - } - h.report('outside-turn', { id: 'b' }) - expect(h.row('outside-turn')?.map((entry) => entry.id)).toEqual(['a', 'b']) - }) - - it('releases settled rows an earlier run journaled even when only a lookup reached them', () => { - const h = harness({ - groupOf: (id) => (id.startsWith('old-') ? `old-turn-${id.slice(4)}` : null), - claimGroup: (groupId) => ({ - entries: [ - { - id: `old-${groupId.slice(9)}`, - label: 'Old', - state: 'completed', - startedAt: 1, - settledAt: 2 - } - ], - placement: groupId - }) - }) - for (let index = 0; index < 100; index++) { - h.tracker.locate(`old-${index}`) - } - h.report('turn-new', { id: 'fresh', state: 'completed' }) - expect(h.tracker.locate('fresh')).not.toBeNull() - expect(h.tracker.sizes().groups).toBeLessThanOrEqual(32) - }) - - it('ends only foreground children with their turn, and every child with the session', () => { - const h = harness() - h.report('turn', { id: 'fg' }) - h.report('turn', { id: 'bg', backgrounded: true }) - h.tracker.settleTurn('turn') - expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'working']) - h.tracker.dispose() - expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable']) - }) - - it('writes a refused revision again on the next write, and hands the refusal back', () => { - const h = harness() - h.refuse(true) - expect(h.report('turn', { id: 'a' }).accepted).toBe(false) - expect(h.report('turn', { id: 'a' }).accepted).toBe(false) - h.refuse(false) - h.report('turn', { id: 'a' }) - expect(h.writes).toHaveLength(1) - h.report('turn', { id: 'a' }) - expect(h.writes).toHaveLength(1) - }) - - it('tries every group at session end before handing back the first refusal', () => { - const h = harness() - h.report('one', { id: 'a' }) - h.report('two', { id: 'b' }) - h.refuse(true) - expect(h.tracker.settleSession().accepted).toBe(false) - h.refuse(false) - expect(h.tracker.settleSession().accepted).toBe(true) - expect([h.row('one'), h.row('two')]).toEqual([ - [expect.objectContaining({ state: 'unverifiable' })], - [expect.objectContaining({ state: 'unverifiable' })] - ]) - }) - - it('writes each group a batch changed once', () => { - const h = harness() - h.tracker.batch(() => { - h.report('turn', { id: 'a' }) - h.report('turn', { id: 'b' }) - h.report('turn', { id: 'a', tokens: 0, announces: false }) - }) - expect(h.writes).toEqual([ - { - groupId: 'turn', - agents: [ - expect.objectContaining({ id: 'a', tokens: 0 }), - expect.objectContaining({ id: 'b' }) - ] - } - ]) - }) - - it('removes a child that turned out not to be one, and the row with its last child', () => { - const h = harness() - h.report('turn', { id: 'a', label: 'Audit' }) - h.tracker.remove('a') - expect(h.writes.at(-1)).toEqual({ groupId: 'turn', agents: null }) - h.report('turn', { id: 'b', label: 'Audit' }) - // A removed child's name stays claimed. - expect(h.row('turn')).toEqual([expect.objectContaining({ label: 'Audit 2' })]) - }) - - it('continues the row an earlier provider run journaled instead of rewriting it from empty', () => { - let claimed = false - const earlier: NativeChatSubagentEntry[] = [ - { id: 'a', label: 'One', state: 'completed', startedAt: 1, settledAt: 2 }, - { id: 'b', label: 'Two', state: 'unverifiable', startedAt: 1 } - ] - const h = harness({ - groupOf: (id) => (id === 'a' || id === 'b' ? 'outside-turn' : null), - claimGroup: (groupId) => { - if (groupId !== 'outside-turn' || claimed) { - return null - } - claimed = true - return { entries: earlier, placement: 'outside-turn' } - }, - attempt: (id) => (id === 'b' ? 3 : 1) - }) - h.report('outside-turn', { id: 'c', label: 'Three' }) - expect(h.row('outside-turn')?.map((entry) => entry.id)).toEqual(['a', 'b', 'c']) - // A verdict on a run the earlier process left says how it ended, not when. - h.report('outside-turn', { id: 'b', run: 'old-call', state: 'stopped', announces: false }) - expect(h.row('outside-turn')?.[1]).toEqual({ - id: 'b', - label: 'Two', - state: 'stopped', - startedAt: 1 - }) - h.report('turn-9', { id: 'b', run: 'resume' }) - expect(h.tracker.attempt('b')).toBe(4) - expect(h.row('turn-9')).toBeUndefined() - expect(h.row('outside-turn')?.[1]).toEqual( - expect.objectContaining({ id: 'b', label: 'Two', state: 'working' }) - ) - }) -}) diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker.ts b/src/main/native-chat/subagent-tracker/subagent-tracker.ts deleted file mode 100644 index cedbda67266..00000000000 --- a/src/main/native-chat/subagent-tracker/subagent-tracker.ts +++ /dev/null @@ -1,308 +0,0 @@ -// The one subagent tracker every native-chat agent reports into. -// -// An agent's own code only reads its provider's events and reports what it saw about a child in -// one shape. This tracker decides everything the user sees from that: which turn's row lists a -// child, its name, how its state may change, what a turn's or the session's end means for it, and -// how the row is written. A child keeps one entry, in the row of the turn that first spawned it; -// a later run reopens that entry. A child is released only when it settles (bounded settled -// history) or the chat ends — never by count while it may still run. - -import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' -import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import { writeSubagentRows } from './subagent-row-writes' -import { - UNLABELLED_SUBAGENT, - boundedSubagentLabel, - claimSubagentLabel, - startedSubagentEntry, - withSubagentState -} from './subagent-tracker-entries' -import { SubagentTrackerGroups, type LocatedSubagent } from './subagent-tracker-groups' -import type { - JournaledSubagentSource, - SubagentGroup, - SubagentReport, - SubagentRowPort, - TrackedSubagent -} from './subagent-tracker-types' - -/** Children one row lists. Bounds the row, which is rewritten on every change. */ -export const MAX_SUBAGENTS_PER_GROUP = 64 -/** Runs one entry remembers, so a late report for one cannot pass for a new run. */ -const MAX_RUNS_PER_SUBAGENT = 16 -const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } - -export class SubagentTracker { - private readonly groups: SubagentTrackerGroups - /** Groups changed inside `batch`, written once when it ends. */ - private pending: Set> | null = null - private readonly now: () => number - - constructor( - private readonly deps: { - port: SubagentRowPort - journaled?: JournaledSubagentSource - /** Whether a group is the one no turn owns. */ - outsideTurn: (groupId: string) => boolean - onEvict?: (group: SubagentGroup) => void - now?: () => number - } - ) { - this.now = deps.now ?? (() => Date.now()) - this.groups = new SubagentTrackerGroups(deps) - } - - report(report: SubagentReport): StructuredAgentSessionSinkAdmission { - const run = report.run ?? null - const current = this.groups.locate(report.id) - if (this.groups.hasSettled(report.id, run) && (run !== null || !current)) { - return ADMITTED - } - if (!current) { - return report.announces ? this.create(report, run) : ADMITTED - } - const { tracked } = current - if (run === null || run === tracked.run) { - return this.revise(current, report) - } - if (tracked.runs.includes(run)) { - // A run since superseded: nothing it says changes the run shown now. - return ADMITTED - } - if (tracked.inherited && !report.announces) { - // A verdict on the run an earlier provider process left, under one of its calls. - return this.revise(current, report) - } - if (tracked.run === null && !tracked.inherited) { - // A child listed before its run was known learns it. - const learned = { ...tracked, run, runs: [run] } - current.group.entries.set(report.id, learned) - return this.revise({ group: current.group, tracked: learned }, report) - } - return report.announces ? this.startRun(current, report, run) : ADMITTED - } - - /** Re-keys a provisional child onto the canonical id the provider finally named. */ - rekey(from: string, to: string): void { - const located = this.groups.locate(from) - if (!located || from === to) { - return - } - const entries = [...located.group.entries].map(([id, tracked]): [string, TrackedSubagent] => - id === from ? [to, { ...tracked, entry: { ...tracked.entry, id: to } }] : [id, tracked] - ) - located.group.entries = new Map(entries) - } - - /** The child turned out not to be a subagent at all; its row forgets it. */ - remove(id: string): StructuredAgentSessionSinkAdmission { - const located = this.groups.locate(id) - if (!located) { - return ADMITTED - } - located.group.entries.delete(id) - return this.write(located.group) - } - - locate(id: string): LocatedSubagent | null { - return this.groups.locate(id) - } - - /** Known now, or settled and released: either way not some other kind of work. */ - has(id: string): boolean { - return this.groups.locate(id) !== null || this.groups.hasSettled(id, null) - } - - hasSettled(id: string, run: string | null): boolean { - return this.groups.hasSettled(id, run) - } - - /** Which run of the child its current one is. */ - attempt(id: string): number { - return this.groups.locate(id)?.tracked.attempt ?? 1 - } - - /** Keeps a group a child's own traffic just reached, whatever retention would release. */ - retain(group: SubagentGroup): void { - this.groups.trim([group], group.groupId) - } - - /** The turn ended: a run it started in the foreground can no longer report, wherever its child is - * listed, and contact with it is lost — not evidence it exited. A backgrounded run was told to - * outlive the turn. */ - settleTurn(groupId: string): StructuredAgentSessionSinkAdmission { - const swept: SubagentGroup[] = [] - for (const group of this.groups.values()) { - let changed = false - for (const [id, tracked] of group.entries) { - if (tracked.turn === groupId && !tracked.backgrounded) { - changed = this.setState(group, id, tracked, 'unverifiable') || changed - } - } - if (changed || group.groupId === groupId) { - swept.push(group) - } - } - return this.write(...swept) - } - - /** The provider is gone: nothing more arrives for any child. Every group is swept; the first - * refusal is handed back after all were tried. */ - settleSession(): StructuredAgentSessionSinkAdmission { - let refused: StructuredAgentSessionSinkAdmission | null = null - for (const group of this.groups.values()) { - const admission = this.sweep(group) - refused ??= admission.accepted ? null : admission - } - return refused ?? ADMITTED - } - - /** Writes each group the reports inside `apply` changed once, after all of them. */ - batch(apply: () => void): StructuredAgentSessionSinkAdmission { - if (this.pending) { - apply() - return ADMITTED - } - const changed = new Set>() - this.pending = changed - try { - apply() - } finally { - this.pending = null - } - return this.flush(changed) - } - - sizes(): { groups: number; settledIdentities: number } { - return this.groups.sizes() - } - - dispose(): void { - this.settleSession() - this.groups.clear() - } - - private create( - report: SubagentReport, - run: string | null - ): StructuredAgentSessionSinkAdmission { - const group = this.groups.groupFor(report.group) - if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) { - return ADMITTED - } - group.admittedEntries += 1 - const label = boundedSubagentLabel(report.label) - group.entries.set(report.id, { - entry: startedSubagentEntry( - report, - claimSubagentLabel(group, label ?? UNLABELLED_SUBAGENT), - this.now() - ), - run, - runs: run === null ? [] : [run], - attempt: 1, - turn: report.group.id, - backgrounded: report.backgrounded ?? false, - labelBase: label ?? UNLABELLED_SUBAGENT, - provisional: label === null, - inherited: false - }) - return this.write(group) - } - - /** A new run of a known child reopens its one entry, wherever the row listing it is: its clock - * starts again, and it is the next attempt. */ - private startRun( - current: LocatedSubagent, - report: SubagentReport, - run: string - ): StructuredAgentSessionSinkAdmission { - const { group, tracked } = current - const runs = [...tracked.runs, run] - for (const forgotten of runs.splice(0, runs.length - MAX_RUNS_PER_SUBAGENT)) { - this.groups.rememberSettledRun(report.id, forgotten) - } - const label = boundedSubagentLabel(report.label) - const named = tracked.provisional && label !== null && label !== UNLABELLED_SUBAGENT - group.entries.set(report.id, { - entry: startedSubagentEntry( - report, - named ? claimSubagentLabel(group, label) : tracked.entry.label, - this.now(), - tracked.entry - ), - run, - runs, - attempt: tracked.attempt + 1, - turn: report.group.id, - backgrounded: report.backgrounded ?? false, - labelBase: named ? label : tracked.labelBase, - provisional: tracked.provisional && !named, - inherited: false - }) - return this.write(group) - } - - private revise( - located: LocatedSubagent, - report: SubagentReport - ): StructuredAgentSessionSinkAdmission { - const { group, tracked } = located - const id = tracked.entry.id - const next: TrackedSubagent = { - ...tracked, - backgrounded: report.backgrounded ?? tracked.backgrounded, - entry: { ...tracked.entry } - } - const label = boundedSubagentLabel(report.label) - if (label !== null && tracked.provisional && label !== UNLABELLED_SUBAGENT) { - next.provisional = false - next.labelBase = label - next.entry.label = claimSubagentLabel(group, label) - } - if (typeof report.tokens === 'number') { - next.entry.tokens = report.tokens - } - group.entries.set(id, next) - if (report.state) { - this.setState(group, id, next, report.state) - } - return this.write(group) - } - - private setState( - group: SubagentGroup, - id: string, - tracked: TrackedSubagent, - state: NativeChatSubagentEntry['state'] - ): boolean { - const next = withSubagentState(tracked, state, this.now()) - if (next) { - group.entries.set(id, next) - } - return next !== null - } - - private sweep(group: SubagentGroup): StructuredAgentSessionSinkAdmission { - for (const [id, tracked] of group.entries) { - this.setState(group, id, tracked, 'unverifiable') - } - // A null `lastSerialized` is a refused earlier write that nothing else may retry. - return this.write(group) - } - - private write(...groups: SubagentGroup[]): StructuredAgentSessionSinkAdmission { - if (this.pending) { - groups.forEach((group) => this.pending?.add(group)) - return ADMITTED - } - return this.flush(groups) - } - - private flush(groups: Iterable>): StructuredAgentSessionSinkAdmission { - const written = [...groups] - const refused = writeSubagentRows(written, this.deps.port) - this.groups.trim(written) - return refused ?? ADMITTED - } -} diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index d2b1523a96c..7288af52a76 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { MAX_CODEX_SUBAGENTS_PER_GROUP } from '../../codex/codex-structured-journal-limits' import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' import { boundWorkerTranscriptMessages, @@ -87,9 +86,7 @@ describe('worker transcript wire bounds', () => { const block = result.messages[0]?.blocks[0] expect(block?.type).toBe('subagent-group') - expect(block?.type === 'subagent-group' ? block.agents : []).toHaveLength( - MAX_CODEX_SUBAGENTS_PER_GROUP - ) + expect(block?.type === 'subagent-group' ? block.agents : []).toHaveLength(64) expect(JSON.stringify(result.messages)).not.toContain('dcap_') expect(result.limited).toBe(true) expect(result.warnings).toEqual( diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index bd29562320e..e292c271e3e 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -93,7 +93,9 @@ describe('structured Codex child work through the production runtime', () => { statusSink: { publish: () => {}, forget: () => {}, - publishChildWork: (...args) => childWork.push(args) + publishChildWork: (...args) => { + childWork.push(args) + } } }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx index 5c49e83efab..87603115dc6 100644 --- a/src/renderer/src/components/agent-child-row-parity.test.tsx +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -153,7 +153,7 @@ function stripRows( childViews={sent} childRowContext={childRowContext} supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} indicatorActive @@ -583,7 +583,7 @@ describe('the chat strip from views', () => { settledTasks={[]} childViews={children} supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set(['task-shell'])} stoppingAll={false} indicatorActive diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx index e3da66df444..5d885534707 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -37,7 +37,7 @@ const TASKS: AgentSessionBackgroundTask[] = [ { id: 'codex-command:exec-1', kind: 'command', description: 'sleep 90' } ] -function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolean }): { +function renderStrip(props: { supportsTaskStop: boolean; offersStopAll: boolean }): { onStop: ReturnType } { const onStop = vi.fn() @@ -48,7 +48,7 @@ function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolea settledTasks={[]} indicatorActive supportsTaskStop={props.supportsTaskStop} - supportsStopAll={props.supportsStopAll} + offersStopAll={props.offersStopAll} stoppingTaskIds={new Set()} stoppingAll={false} onStop={onStop} @@ -60,13 +60,13 @@ function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolea describe('NativeChatBackgroundTasksStatus stop affordances', () => { it('offers a per-task stop on a host that accepts targeted stops', () => { - renderStrip({ supportsTaskStop: true, supportsStopAll: true }) + renderStrip({ supportsTaskStop: true, offersStopAll: false }) expect(screen.getByLabelText('Stop count_a')).toBeInTheDocument() expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() }) it('falls back to a stop-all on a host that only accepts an untargeted stop', () => { - renderStrip({ supportsTaskStop: false, supportsStopAll: true }) + renderStrip({ supportsTaskStop: false, offersStopAll: true }) expect(screen.getByLabelText('Stop background tasks')).toBeInTheDocument() }) @@ -84,7 +84,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { settledTasks={[]} indicatorActive supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={vi.fn()} @@ -131,7 +131,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { ]} indicatorActive supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={onStop} @@ -173,7 +173,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { ]} indicatorActive supportsTaskStop - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={onStop} @@ -189,7 +189,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { it('offers no stop at all when the provider exposes none', () => { // An older Codex: a Stop button here would be a control that cannot act. - renderStrip({ supportsTaskStop: false, supportsStopAll: false }) + renderStrip({ supportsTaskStop: false, offersStopAll: false }) expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() expect(screen.queryByLabelText('Stop count_a')).not.toBeInTheDocument() expect(screen.getByText('count_a')).toBeInTheDocument() @@ -206,7 +206,7 @@ describe('background-tasks strip header', () => { settledTasks={[]} indicatorActive supportsTaskStop={false} - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -253,7 +253,7 @@ describe('background-tasks strip header', () => { settledTasks={[]} indicatorActive={false} supportsTaskStop={false} - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -360,7 +360,7 @@ describe('settled rows beside their live siblings', () => { ]} indicatorActive supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -387,7 +387,7 @@ describe('background-task row reasons', () => { settledTasks={[]} indicatorActive supportsTaskStop={false} - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -429,7 +429,7 @@ it('stops elapsed renders in a hidden pane and catches up on reveal', () => { settledTasks={[]} indicatorActive supportsTaskStop={false} - supportsStopAll={false} + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} @@ -481,7 +481,7 @@ it('lets the 1 Hz tick sleep while every row has settled, with each run frozen', childViews={[finished('a', 400_000, 520_000), finished('b', 700_000, 760_000)]} indicatorActive supportsTaskStop - supportsStopAll + offersStopAll={false} stoppingTaskIds={new Set()} stoppingAll={false} onStop={() => {}} diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index cd08ca546b9..fd22fa01a22 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -145,9 +145,11 @@ export function NativeChatBackgroundTasksStatus(props: { * it every live claim stands as reported. */ childRowContext?: AgentChildRowContext supportsTaskStop: boolean - /** False when the provider exposes no honest stop at all; the fallback - * control is hidden rather than offering a button that cannot act. */ - supportsStopAll: boolean + /** The shared view's rule for the untargeted Stop; hidden when the provider exposes no honest + * stop at all, rather than offering a button that cannot act. */ + offersStopAll: boolean + /** Some running work is not listed, so the rows are not the whole roster. */ + incomplete?: boolean stoppingTaskIds: ReadonlySet stoppingAll: boolean /** True while the session is idle: only then may the strip speak as the @@ -270,7 +272,10 @@ export function NativeChatBackgroundTasksStatus(props: { ) : ( {say('detailsUnavailable')} )} - {!props.supportsTaskStop && props.supportsStopAll ? ( + {props.incomplete && groups.length > 0 ? ( + {say('detailsUnavailable')} + ) : null} + {props.offersStopAll ? ( 0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}> agentChildWorkViewOffersStop(view) && view.providerId !== undefined && (taskId === undefined || view.providerId === taskId) - ? [view.providerId] + ? [{ ...view, providerId: view.providerId }] : [] ) } + +/** A child the provider accepted a Stop for. `outcome` is the run's own ending when the answer + * named one, as a stop of a child that had already finished does. */ +export type AgentChildWorkStopAcknowledgement = { + taskId: string + outcome?: AgentChildWorkOutcome +} + +/** Each accepted Stop as evidence about the run the host sent it to, captured before it was sent, + * so an acknowledgement that lands after a new run started cannot end that one. */ +export function agentChildWorkStopEvidence( + targets: readonly (AgentChildWorkView & { providerId: string })[], + acknowledged: readonly AgentChildWorkStopAcknowledgement[] | undefined, + observedAt: number +): AgentChildWorkEvidence[] { + return (acknowledged ?? []).flatMap((ack) => { + const target = targets.find((view) => view.providerId === ack.taskId) + return target + ? [ + { + type: 'stop-acknowledged' as const, + observedAt, + childWorkId: target.id, + invocation: { ...target.invocation }, + ...(ack.outcome !== undefined ? { outcome: ack.outcome } : {}) + } + ] + : [] + }) +} diff --git a/src/shared/agent-session-background-task-state-admission.test.ts b/src/shared/agent-session-background-task-state-admission.test.ts new file mode 100644 index 00000000000..d956d2da80d --- /dev/null +++ b/src/shared/agent-session-background-task-state-admission.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { admitAgentSessionBackgroundTaskState } from './agent-session-background-task-state-admission' + +describe('a background-task frame a client admits', () => { + it('takes a frame whose only change is incomplete tracking, both ways', () => { + const listed = admitAgentSessionBackgroundTaskState({ state: 'monitoring', children: [] }) + const incomplete = admitAgentSessionBackgroundTaskState( + { state: 'monitoring', children: [], incomplete: true }, + listed + ) + expect(incomplete).not.toBe(listed) + expect(incomplete?.incomplete).toBe(true) + expect( + admitAgentSessionBackgroundTaskState({ state: 'monitoring', children: [] }, incomplete) + ).not.toBe(incomplete) + }) +}) diff --git a/src/shared/agent-session-background-task-state-equality.ts b/src/shared/agent-session-background-task-state-equality.ts index 2748375d501..b1f2f757fb6 100644 --- a/src/shared/agent-session-background-task-state-equality.ts +++ b/src/shared/agent-session-background-task-state-equality.ts @@ -17,7 +17,8 @@ export function backgroundTaskStatesEqual( !right || left.state !== right.state || left.supportsTaskStop !== right.supportsTaskStop || - left.supportsStopAll !== right.supportsStopAll + left.supportsStopAll !== right.supportsStopAll || + left.incomplete !== right.incomplete ) { return false } diff --git a/src/shared/agent-session-background-task-wire.ts b/src/shared/agent-session-background-task-wire.ts index a6f2dc846e8..100c674544d 100644 --- a/src/shared/agent-session-background-task-wire.ts +++ b/src/shared/agent-session-background-task-wire.ts @@ -55,6 +55,11 @@ export type AgentSessionBackgroundTaskState = { * advertise `agent-session.background-task-child-views.v1` never receives a state whose only * rows are settled: to it, as before, the roster is live work. */ children?: AgentChildWorkView[] + /** The host refused some of this session's child work for room, so what is listed is not all + * that runs; no row's absence means it ended. A refused child stops counting once a later + * report of it is admitted or it reports its own end; past the host's bound, and in every case + * once the provider session ends, the whole flag goes. */ + incomplete?: true } function backgroundTaskFieldsEqual( diff --git a/src/shared/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts index 24d06c0cdb3..c3a49136014 100644 --- a/src/shared/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -23,7 +23,8 @@ import { type AgentChildWorkEvidenceScope } from './agent-status-child-work-evidence-resolution' -/** Live children admitted per session, sized to the provider trackers' own retention. */ +/** Live children other than agents admitted per session. A subagent has no such bound: refusing + * one would cost its saved row while it runs, so only the store's global limits bound them. */ export const STRUCTURED_CHILD_WORK_MAX_LIVE = 256 /** A child's first run, matching the provider roster's first attempt. */ const FIRST_GENERATION = 1 @@ -134,8 +135,12 @@ export function applyAgentChildWorkLive( const request = { parent: ctx.parent, provider: ctx.provider } const aliases = agentChildWorkHandleAliases(handle) if (!existing) { - const live = ownedStructuredChildWork(ctx).filter((record) => record.membership === 'live') - if (live.length >= STRUCTURED_CHILD_WORK_MAX_LIVE) { + if ( + child.kind !== 'agent' && + ownedStructuredChildWork(ctx).filter( + (record) => record.membership === 'live' && record.kind !== 'agent' + ).length >= STRUCTURED_CHILD_WORK_MAX_LIVE + ) { ctx.outcome.rejected.push({ handleId: handle.id, reason: 'ingestion-limit' }) return } diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index f05be66c3d0..a87d27d771e 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -7,10 +7,11 @@ import type { AgentChildWorkAliasKind } from './agent-status-child-work-alias' import type { + AgentChildWorkId, + AgentChildWorkInvocationFence, AgentChildWorkKind, AgentChildWorkOperation, AgentChildWorkOutcome, - AgentChildWorkOutcomeBasis, AgentChildWorkResidency, AgentChildWorkState } from './agent-status-child-work' @@ -62,19 +63,27 @@ export type AgentChildWorkOperationEvidence = { operation: AgentChildWorkOperation | null } -/** The child's own terminal frame. `unknown` is an ending whose status the provider did not say. - * With `basis`, the ending is the provider acknowledging a Stop, which the child's own terminal - * frame of the same run still replaces. */ +/** The child's own terminal frame. `unknown` is an ending whose status the provider did not say. */ export type AgentChildWorkEndedEvidence = { type: 'ended' observedAt: number handle: AgentChildWorkEvidenceHandle outcome: AgentChildWorkOutcome - basis?: AgentChildWorkOutcomeBasis lastMessage?: string totalTokens?: number } +/** The provider accepted a Stop of the run the host captured when it sent it. Settles that run + * provisionally (`cancelled`, basis `stop-acknowledged`) unless it already ended; never a later + * run. `outcome` is the run's own ending when the provider's answer named one. */ +export type AgentChildWorkStopAcknowledgedEvidence = { + type: 'stop-acknowledged' + observedAt: number + childWorkId: AgentChildWorkId + invocation: AgentChildWorkInvocationFence + outcome?: AgentChildWorkOutcome +} + /** The user's next turn began: the provider accepted their next send. A turn the provider opens * on its own is not one. Settled children are kept, with their outcome, until then; a settled * child that still owns live work, at any depth, stays so that work keeps its owners. */ @@ -98,5 +107,6 @@ export type AgentChildWorkEvidence = | AgentChildWorkOperationEvidence | AgentChildWorkEndedEvidence | AgentChildWorkRemovedEvidence + | AgentChildWorkStopAcknowledgedEvidence | AgentChildWorkTurnStartedEvidence | AgentChildWorkSessionEndedEvidence diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 818f809638a..935b6a1c02e 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -385,17 +385,19 @@ describe('structured child-work reconciliation', () => { ]) }) - it('bounds the live children one session may admit', () => { + it('bounds the live tasks one session may admit, and never refuses a subagent for it', () => { const { store, apply } = harness() + const command = (id: string) => child(id, { kind: 'command' }) apply( ...Array.from({ length: STRUCTURED_CHILD_WORK_MAX_LIVE }, (_, index) => - live(child(`task-${index}`)) + live(command(`task-${index}`)) ) ) - expect(apply(live(child('one-too-many'))).rejected).toEqual([ + expect(apply(live(command('one-too-many'))).rejected).toEqual([ { handleId: 'one-too-many', reason: 'ingestion-limit' } ]) - expect(records(store)).toHaveLength(STRUCTURED_CHILD_WORK_MAX_LIVE) + expect(apply(live(child('agent-past-the-limit'))).rejected).toEqual([]) + expect(records(store)).toHaveLength(STRUCTURED_CHILD_WORK_MAX_LIVE + 1) }) it('keeps every settled child until the parent row goes', () => { @@ -516,17 +518,23 @@ describe('structured child-work reconciliation', () => { outcome, ...extra }) - const stopped = { basis: 'stop-acknowledged' as const } const { store, apply } = harness() apply(live(child('replaced')), live(child('kept')), live(child('same'))) - apply( - ended('replaced', 'cancelled', 101, stopped), - ended('replaced', 'succeeded', 102, { lastMessage: 'all tests passed' }), - ended('kept', 'failed', 101, { lastMessage: 'Exit code 1' }), - ended('kept', 'cancelled', 102, stopped), - ended('same', 'cancelled', 101, stopped), - ended('same', 'cancelled', 102) - ) + const stopped = (id: string, observedAt: number): AgentChildWorkEvidence => { + const record = records(store).find((candidate) => candidate.description === `Task ${id}`)! + return { + type: 'stop-acknowledged', + observedAt, + childWorkId: record.childWorkId, + invocation: record.invocation + } + } + apply(stopped('replaced', 101)) + apply(ended('replaced', 'succeeded', 102, { lastMessage: 'all tests passed' })) + apply(ended('kept', 'failed', 101, { lastMessage: 'Exit code 1' })) + apply(stopped('kept', 102)) + apply(stopped('same', 101)) + apply(ended('same', 'cancelled', 102)) expect( records(store).map((record) => [ record.description, @@ -540,6 +548,6 @@ describe('structured child-work reconciliation', () => { ['Task same', 'cancelled', 'reported', null] ]) // Once the child reported its ending, a later acknowledged Stop changes nothing either. - expect(apply(ended('same', 'failed', 103, stopped))).toMatchObject({ settled: 0 }) + expect(apply(stopped('same', 103))).toMatchObject({ settled: 0 }) }) }) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index c37b5b7422e..8a05e0aa25e 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -7,12 +7,14 @@ // own producer admitted, and never claims an outcome the evidence did not report. import type { AgentChildWorkAdmission } from './agent-status-child-work-admission' +import { agentChildWorkFencesEqual } from './agent-status-child-work' import { settledOwnersOfLiveWork } from './agent-status-child-work-liveness' import type { AgentChildWorkEndedEvidence, AgentChildWorkEvidence, AgentChildWorkOperationEvidence, - AgentChildWorkRemovedEvidence + AgentChildWorkRemovedEvidence, + AgentChildWorkStopAcknowledgedEvidence } from './agent-status-child-work-evidence' import { applyAgentChildWorkLive, @@ -56,11 +58,29 @@ function applyEnded(ctx: ReconcileContext, edge: AgentChildWorkEndedEvidence): v // lands its evidence; a conflicting definite one is refused as `stale-invocation`. settleAgentChildWork(ctx, existing, edge.outcome, edge.observedAt, { ...(edge.lastMessage !== undefined ? { lastMessage: edge.lastMessage } : {}), - ...(edge.totalTokens !== undefined ? { totalTokens: edge.totalTokens } : {}), - ...(edge.basis !== undefined ? { basis: edge.basis } : {}) + ...(edge.totalTokens !== undefined ? { totalTokens: edge.totalTokens } : {}) }) } +/** The one rule for an accepted Stop: it settles only the run captured when it was sent, keeps an + * ending that run already reported, and takes the provider's own verdict when the answer had one. */ +function applyStopAcknowledged( + ctx: ReconcileContext, + edge: AgentChildWorkStopAcknowledgedEvidence +): void { + const record = ownedStructuredChildWork(ctx).find( + (candidate) => candidate.childWorkId === edge.childWorkId + ) + if (!record || !agentChildWorkFencesEqual(record.invocation, edge.invocation)) { + return + } + if (edge.outcome !== undefined) { + settleAgentChildWork(ctx, record, edge.outcome, edge.observedAt) + } else if (record.membership === 'live') { + settleAgentChildWork(ctx, record, 'cancelled', edge.observedAt, { basis: 'stop-acknowledged' }) + } +} + /** A live child's current operation, and nothing else about it. */ function applyOperation(ctx: ReconcileContext, edge: AgentChildWorkOperationEvidence): void { const resolution = resolveAgentChildWorkHandle( @@ -157,6 +177,8 @@ export function reconcileAgentChildWorkEvidence( applyEnded(ctx, edge) } else if (edge.type === 'removed') { applyRemoved(ctx, edge) + } else if (edge.type === 'stop-acknowledged') { + applyStopAcknowledged(ctx, edge) } else if (edge.type === 'turn-started') { removeChildren(ctx, removableSettled(ctx)) } else { diff --git a/src/shared/agent-status-child-work-stop-acknowledged.test.ts b/src/shared/agent-status-child-work-stop-acknowledged.test.ts new file mode 100644 index 00000000000..9a3d7dccfaf --- /dev/null +++ b/src/shared/agent-status-child-work-stop-acknowledged.test.ts @@ -0,0 +1,117 @@ +// An accepted Stop, as the host records it: the run captured when the Stop was sent, and only it. + +import { describe, expect, it } from 'vitest' +import { createAgentChildWorkAdmission } from './agent-status-child-work-admission' +import type { AgentChildWorkEvidence } from './agent-status-child-work-evidence' +import { reconcileAgentChildWorkEvidence } from './agent-status-child-work-reconciliation' +import { createAgentStatusStore } from './agent-status-store' +import { projectAgentChildWorkViews } from './agent-status-child-work-view' +import { + agentChildWorkStopEvidence, + agentChildWorkStopTargets +} from './agent-child-work-stop-targets' +import { makeStructuredAgentStatusSubject } from './agent-status-subject' + +const parent = makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1', workspaceKind: 'folder' }, + 'session-1' +) + +function live(runId: string, observedAt = 100): AgentChildWorkEvidence { + return { + type: 'live', + observedAt, + child: { + handle: { idKind: 'task_id', id: 'agent-1', runId }, + kind: 'agent', + residency: 'background', + state: 'working', + description: 'Audit the build', + stoppable: true + } + } +} + +function ended(runId: string, outcome: 'succeeded' | 'failed'): AgentChildWorkEvidence { + return { + type: 'ended', + observedAt: 300, + handle: { idKind: 'task_id', id: 'agent-1', runId }, + outcome + } +} + +function harness() { + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + const apply = (...evidence: AgentChildWorkEvidence[]) => + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + const views = () => { + const records = store.getChildren(parent) + return projectAgentChildWorkViews( + records, + records.flatMap((record) => store.getAliasesForChild(record.childWorkId)) + ) + } + /** The Stop as the host sends it: targets captured first, the answer applied after. */ + const stopSent = () => agentChildWorkStopTargets(views(), 'agent-1') + const answered = ( + targets: ReturnType, + outcome?: 'succeeded' | 'failed' | 'cancelled' + ) => + apply( + ...agentChildWorkStopEvidence( + targets, + [{ taskId: 'agent-1', ...(outcome ? { outcome } : {}) }], + 200 + ) + ) + return { apply, views, stopSent, answered } +} + +describe('an accepted Stop', () => { + it('settles its run as cancelled until the run reports how it ended', () => { + const h = harness() + h.apply(live('run-1')) + h.answered(h.stopSent()) + expect(h.views()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + h.apply(ended('run-1', 'succeeded')) + expect(h.views()).toMatchObject([{ membership: 'settled', outcome: 'succeeded' }]) + }) + + it('keeps the ending the run already reported', () => { + const h = harness() + h.apply(live('run-1')) + const targets = h.stopSent() + h.apply(ended('run-1', 'failed')) + h.answered(targets) + expect(h.views()).toMatchObject([{ membership: 'settled', outcome: 'failed' }]) + }) + + it("takes the run's own ending when the answer names one", () => { + const h = harness() + h.apply(live('run-1')) + h.answered(h.stopSent(), 'succeeded') + expect(h.views()).toMatchObject([{ membership: 'settled', outcome: 'succeeded' }]) + }) + + it('never settles a run that started after the Stop was sent', () => { + const h = harness() + h.apply(live('run-1')) + const targets = h.stopSent() + h.apply(ended('run-1', 'succeeded'), live('run-2', 250)) + h.answered(targets) + expect(h.views()).toMatchObject([{ membership: 'live', state: 'working' }]) + }) + + it('settles nothing for a Stop the provider did not accept', () => { + const h = harness() + h.apply(live('run-1')) + h.apply(...agentChildWorkStopEvidence(h.stopSent(), [], 200)) + expect(h.views()).toMatchObject([{ membership: 'live' }]) + }) +}) diff --git a/src/shared/codex-child-command-description.ts b/src/shared/codex-child-command-description.ts index 212a420d705..c57348c6454 100644 --- a/src/shared/codex-child-command-description.ts +++ b/src/shared/codex-child-command-description.ts @@ -9,8 +9,8 @@ const MAX_DESCRIPTION_CHARS = 512 * qualification exists to remove, in the other direction. */ const MAX_LABEL_CHARS = 96 -/** Every cut goes through here, clipped the way `boundSubagentField` clips the same provider - * string on the agent row: never mid surrogate pair, since a lone surrogate is lossy through any +/** Every cut goes through here, clipped the way a subagent's saved row clips the same provider + * string: never mid surrogate pair, since a lone surrogate is lossy through any * non-JSON UTF-8 hop. A composed row is cut a SECOND time, so a clip that is safe only where the * label is bounded is not safe. */ function boundText(value: string, max: number): string { diff --git a/src/shared/native-chat-subagent-items.ts b/src/shared/native-chat-subagent-items.ts index 0425e32550d..729dcd6598c 100644 --- a/src/shared/native-chat-subagent-items.ts +++ b/src/shared/native-chat-subagent-items.ts @@ -52,8 +52,18 @@ export function namedSubagents(block: NativeChatBlock): readonly NativeChatSubag return isSubagentBlock(block) ? [subagentEntryOf(block)] : [] } +/** The siblings a block folds with, and the folded row's groupId: a helper's own helpers keep + * open state apart from the turn's. */ +export function subagentFoldKey(block: NativeChatSubagentBlock): string { + return block.parentId === undefined + ? block.groupId + : JSON.stringify([block.groupId, block.parentId]) +} + /** The subagent a row is, when it is one: its block, beside nothing but its twin sentence. */ -function subagentItemBlock(message: NativeChatMessage): NativeChatSubagentBlock | null { +export function subagentItemBlock( + message: Pick +): NativeChatSubagentBlock | null { let found: NativeChatSubagentBlock | null = null for (const block of message.blocks) { if (isSubagentBlock(block) && found === null) { @@ -85,9 +95,7 @@ export function foldSubagentItems( continue } any = true - // Also the folded row's groupId: a helper's own helpers keep open state apart from the turn's. - const key = - block.parentId === undefined ? block.groupId : JSON.stringify([block.groupId, block.parentId]) + const key = subagentFoldKey(block) const siblings = groups.get(key) if (siblings) { siblings.push(message) diff --git a/src/shared/native-chat-subagent-summary.ts b/src/shared/native-chat-subagent-summary.ts index 81601d2ba80..95cd648eca5 100644 --- a/src/shared/native-chat-subagent-summary.ts +++ b/src/shared/native-chat-subagent-summary.ts @@ -43,15 +43,9 @@ export function normalizeSubagentState(state: string): NativeChatSubagentState { return TERMINAL_SUBAGENT_STATES.has(state) ? (state as NativeChatSubagentState) : 'unverifiable' } -/** Bound on the per-child provider strings a roster row carries — `id` and - * `label`. One constant because the producer writes a durable row and both - * readers clip it again: a larger producer bound is bytes every consumer throws - * away, replayed on every reconnect. - * - * `groupId` is deliberately NOT bounded by the producer: the row's durable - * identity is `codex-subagents:${groupId}` and cannot be clipped without - * changing which row a replay finds, so bounding only the block field would - * save nothing and make the two disagree. Both readers still clip it. */ +/** Bound on the per-child provider strings a subagent row carries — `id`, `label` and + * `groupId`. One constant because the host writes a durable row and both readers clip it again: + * a larger write bound is bytes every consumer throws away, replayed on every reconnect. */ export const MAX_SUBAGENT_FIELD_CHARS = 512 export function isTerminalSubagentState(state: string): boolean { diff --git a/src/shared/structured-session-background-tasks-view.test.ts b/src/shared/structured-session-background-tasks-view.test.ts index ebe217218e4..3bd79843e6f 100644 --- a/src/shared/structured-session-background-tasks-view.test.ts +++ b/src/shared/structured-session-background-tasks-view.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { AgentSessionBackgroundTaskState } from './agent-session-wire' import type { AgentChildWorkView } from './agent-status-child-work-view' import { structuredSessionBackgroundTasksView, @@ -91,6 +92,36 @@ describe('structuredSessionBackgroundTasksView', () => { } }) + // Absence of a row never reads as idle when the host says some work went unrecorded; commands + // and rewind still follow the records, as the host's own admission does. + it('keeps the strip and its Stop when the host could not record some work', () => { + expect( + structuredSessionBackgroundTasksView( + { state: 'monitoring', children: [], supportsTaskStop: true, incomplete: true }, + null + ) + ).toMatchObject({ show: true, isMonitoring: false, incomplete: true, offersStopAll: true }) + }) + + it('offers the untargeted Stop only where no row stop reaches the work, and the host has one', () => { + const offers = ( + state: Partial, + turnId: string | null = null + ) => + structuredSessionBackgroundTasksView({ state: 'monitoring', ...state }, turnId).offersStopAll + expect(offers({ supportsTaskStop: true })).toBe(false) + expect(offers({ supportsTaskStop: false })).toBe(true) + expect(offers({ supportsTaskStop: false }, 'turn-1')).toBe(true) + expect(offers({ supportsTaskStop: false, supportsStopAll: false })).toBe(false) + expect(offers({ supportsTaskStop: true, incomplete: true })).toBe(true) + // Unlisted work is reached by ending the agent: no untargeted provider stop is needed, as on an + // ACP agent, and none is offered under a running turn the agent would end with it. + const acp = { supportsTaskStop: true, supportsStopAll: false, incomplete: true } as const + expect(offers(acp)).toBe(true) + expect(offers(acp, 'turn-1')).toBe(false) + expect(offers({ ...acp, supportsTaskStop: false })).toBe(true) + }) + it('reads live work beneath an idle session as monitoring, and a running turn as its own', () => { const roster = { state: 'monitoring' as const, @@ -121,7 +152,8 @@ describe('structuredSessionBackgroundTasksView', () => { tasks, settledTasks: [], supportsStop: false, - supportsStopAll: true + offersStopAll: true, + incomplete: false }) expect(structuredSessionBackgroundTasksView(null, null)).toMatchObject({ show: false, diff --git a/src/shared/structured-session-background-tasks-view.ts b/src/shared/structured-session-background-tasks-view.ts index 859ebf8c1ca..39175871adf 100644 --- a/src/shared/structured-session-background-tasks-view.ts +++ b/src/shared/structured-session-background-tasks-view.ts @@ -21,7 +21,12 @@ export type StructuredSessionBackgroundTasksView = { /** The host's child records, when it publishes them; the rows then read these. */ children?: AgentChildWorkView[] supportsStop: boolean - supportsStopAll: boolean + /** The strip's untargeted Stop: offered when no row has a Stop of its own and the host has an + * untargeted stop, or, with no turn running, for work no row lists, which the host reaches by + * ending the agent whatever stops its provider offers. */ + offersStopAll: boolean + /** Some running work is not listed: the strip stays, and says details are unavailable. */ + incomplete: boolean } /** One shared empty list: a roster with no rows of a kind keeps the strip's memo on every render. */ @@ -61,8 +66,11 @@ export function structuredSessionBackgroundTasksView( !children?.length && !tasks.length && Boolean(backgroundTasks?.children?.length || backgroundTasks?.tasks?.length) - const show = monitoring && !onlyFinished && !onlyForeground + const incomplete = monitoring && backgroundTasks.incomplete === true + const show = monitoring && (incomplete || (!onlyFinished && !onlyForeground)) + // Commands and rewind follow the listed records, as the host's own admission does. const liveWork = children ? agentChildWorkLiveness(children) !== null : show + const supportsStop = backgroundTasks?.supportsTaskStop === true return { show, isMonitoring: turnId === null && liveWork, @@ -70,10 +78,13 @@ export function structuredSessionBackgroundTasksView( // Running work only, from any host: an older host's finished rows are not shown either. settledTasks: NO_TASKS, ...(children ? { children } : {}), - supportsStop: backgroundTasks?.supportsTaskStop === true, + supportsStop, // Absent means the host predates the field and does accept an untargeted // stop; only a host that says `false` has none to offer. - supportsStopAll: backgroundTasks?.supportsStopAll !== false + offersStopAll: + (!supportsStop && backgroundTasks?.supportsStopAll !== false) || + (incomplete && turnId === null), + incomplete } }
{say('detailsUnavailable')}