Skip to content

feat(server): background wakes say which subagent, command, or monitor finished - #13948

Merged
juliusmarminge merged 10 commits into
t3code/codex-turn-mappingfrom
v2/specific-background-work
Sep 28, 2026
Merged

juliusmarminge merged 10 commits into
t3code/codex-turn-mappingfrom
v2/specific-background-work

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

When a background task, monitor or subagent finished after a turn, every provider's wake landed in the timeline as "Background activity updated". The Waiting roster only carried a free-form taskType, so clients could not tell a subagent from a shell command either.

What changed

Adapters now name what they saw end, through one summary builder in Notification.ts. The source column is the decoded member clients switch on. See "Older and newer data still decode" for what goes over the wire:

Provider What finished Summary source
Claude Background Agent (subagent) Subagent "Agent B" was stopped { kind: "subagent", childThreadId }
Claude Background Bash (local_bash) Command "Background sleep test" finished { kind: "command" }
Claude Both ended by one TaskStop Subagent "Agent B" and command "Sleep 60 seconds then echo B_DONE" were stopped { kind: "background_task" } (mixed kinds)
Grok spawn_subagent (background) Subagent "Sleep then reply done" finished { kind: "subagent", childThreadId }
Grok Monitor Monitor "Watch three tick echoes" finished { kind: "monitor" }
Grok Background shell Command "Run three tock echoes in the background" finished (exit 0) { kind: "command" }
Codex Background command Command "sleep 20 && echo …" finished (exit 0) (was "Background command finished") { kind: "command" }
T3 delegated tasks One or some of a run's tasks Delegated task "Review src/math.ts" finished, or 2 of 3 delegated tasks finished: A, B { kind: "delegated_task", taskIds, childThreadId? }

The kind is part of the type

Both unions in packages/contracts/src/orchestrationV2.ts are discriminated on kind, and each member carries only its own fields:

  • OrchestrationV2NotificationSource: delegated_task { taskIds, childThreadId? }, subagent { childThreadId? }, command, monitor, and a generic background_task for mixed or unnamed work. childThreadId is set only when the notification reports one subagent or one delegated task.
  • OrchestrationV2PendingBackgroundTask: subagent { taskId, description?, childThreadId? }, command, monitor and background_task, each with { taskId, description? }. taskType is gone.

Adapters build the member directly from data they already have. Provider strings are interpreted only inside each adapter:

  • Claude: CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS maps SDK task_type to a roster kind (local_bash becomes command) at the place that already filtered on it. task_notification carries only the task id and status, and the wake's result carries no task id at all, so the adapter records a report per notification (the roster entry's kind and description, or the registered subagent's title and child thread) and names them on the next wake offer. Reports survive one user turn, so a wake that runs after a queued prompt is still named. They are dropped when a user turn runs the wake itself. An empty background_tasks_changed level can arrive before the notification, so the last roster entry per task is kept for naming (bounded).
  • Grok: task_completed.task_snapshot.kind (bash/monitor, TaskSnapshot in crates/common/xai-tool-runtime/src/notification.rs at f0e3be1), with description, command, display_command and exit_code, becomes a command or monitor report. subagent_finished gives the child session, which maps to the tracked subagent's title and child thread. Reports are recorded only after the prompt settled, and cleared with the wake buffer.
  • Codex: the completed commandExecution item is a command.
  • Turn items (derivePendingBackgroundWork): the item type picks the member (subagent, command_execution becomes command, anything else background_task). The provider taskType string sets that the shared module used to match on are deleted.
  • OpenCode, Cursor, Pi, ACP registry, Antigravity: none of these adapters offers a post-settle wake today, so there is nothing to name. Their in-turn subagents already render as subagent rows.

Older and newer data still decode

Clients from before this PR. Their notification source union is strict and predates the #13941 fallback, so a source.kind they don't know fails the whole timeline item. Since notifications are persisted, that would break loading every thread holding one, including messages stored before an upgrade. So subagent and command never go over the wire or into storage:

  • a command is stored and sent as { kind: "background_command" }
  • a subagent is stored and sent as { kind: "background_task", work: "subagent", childThreadId? }. Old clients drop work and childThreadId and see the generic member they already render.
  • delegated_task, monitor and background_task are unchanged. childThreadId on delegated_task is a new optional field that old clients strip.

The schema does the mapping, not the adapters. The union lists the wire forms first as decodeTo members: background_command decodes to command, and background_task with work: "subagent" decodes to subagent. Because a union encodes with the first member that fits, a server value encodes back to the legacy form. The plain subagent and command members come after them, so a value that was already decoded once still decodes. New clients keep a typed kind and switch on it exhaustively, with no string matching. I picked this over "legacy kind plus a side field that new clients read" because it keeps adapters and clients on the typed union, and the compat shape stays inside one schema.

Roster entries (OrchestrationV2PendingBackgroundTask) need nothing extra. Old clients decode them as { taskId, description?, taskType? } and ignore the new kind, and every member still carries taskId.

Newer servers and old rows. A small kindUnionWithFallback helper in contracts adds a decode-only arm after the known members, following #13941. An object whose encoded kind this build does not know, or that has none, decodes to a known member instead of failing. A known kind whose fields do not decode still fails. Encoding the arm is forbidden, and the server never builds it. Both unions use it. So:

  • Old stored rows decode without a migration. Notification sources { kind: "background_task", nativeRef } and { kind: "monitor" } decode as themselves (the unused nativeRef is dropped), and { kind: "background_command" } decodes as command. Rosters persisted with taskType and no kind decode as background_task, so a Claude background Bash from before this change reads "background task" rather than "command" until it ends.
  • Any client with this PR tolerates kinds a later server adds.

The provider-facing continuation text is unchanged.

The client changes that show these (composer strip grouping, row icons, opening the child thread) follow in #13949.

Wake report bookkeeping

  • Grok/ACP: a continuation turn used to clear every wake report when it started, including work that ended after its offer went out. The offer's sticky flag blocked a second offer until then, so that work was never named. The offer now records which reports it named, and its turn drops only those.
  • Claude: the user turn count and the reports now share one Ref, and each change is a single update, so a report can't be stamped with a turn that has already been superseded. This has no test: the race needs a fiber to be preempted between two synchronous Ref operations, and no test input controls that.
  • Claude: the turn count goes up when the prompt is handed to Claude, not before the prompt is built, so a turn that fails to start (for example on a missing attachment) no longer uses up a queued wake's grace turn.
  • Claude: a backgrounded subagent's task id is dropped once it ends. Before, the set only grew, and a later foreground re-run of that subagent was named in an unrelated wake.

Verification

  • Replay fixtures, through the whole orchestrator: OrchestratorReplayFixtures.integration.test.ts and .contract.test.ts: 98 passed. ClaudeReplayFixtures.integration.test.ts passes. The assertions pin summary, outcome and the decoded source member in grok_background_subagent, grok_monitor, grok_background_bash, claude_background_subagent_after_root, claude_background_subagent_lifecycle (three wakes: A finished, B stopped, A finished) and claude_background_task_wake, which also pins the roster entry { taskId, description, kind: "command" } built from Claude's task_type. claude_background_wake_before_queued_prompt pins the two-task stop wake as background_task.
  • packages/contracts orchestrationV2.test.ts: 31 passed. New wire-compat test: the pre-feat(server): background wakes say which subagent, command, or monitor finished #13948 notification schema is copied in as a fixture. Every source the server builds (subagent with and without a child thread, command, monitor, background_task, delegated_task with a child thread) is encoded through both the storage codec and the RPC JSON codec. The old schema decodes each result, and the current schema decodes it back to the same typed member. The roster test checks that the old { taskId, description?, taskType? } struct reads every encoded entry. Older tests still cover stored sources, unknown kinds from a newer server, and known kinds with bad fields failing.
  • apps/server: AcpAdapterV2.test.ts and GrokAdapterV2.test.ts: 132 passed. ClaudeAdapterV2.test.ts: 123 passed. Notification.test.ts, FoundationPersistence.test.ts, ProjectionRecovery.test.ts, ProjectionSettlement.test.ts, SubagentProjection.test.ts, CodexAdapterV2.test.ts: 177 passed. Each bookkeeping fix except the Claude single-Ref change has a test that fails with the fix reverted: a wake names work that ended while the previous wake was queued (ACP), a subagent re-run in the foreground does not join a later wake and a turn that fails to start does not expire a queued wake's report (Claude).
  • tsc --noEmit passes for contracts, shared, server, client-runtime, web and mobile. vp lint on touched files: no errors. vp run knip shows nothing in touched files.
  • Not run: the full server suite, live provider runs. No Codex recording exists for a post-settle background command, so that path is covered by the adapter test above.

Model: Claude Opus 5.5 (Claude Code)

🤖 Generated with Claude Code

…r finished

A background wake used to land as "Background activity updated" for every
provider, and the Waiting roster only carried a free-form taskType. Adapters
now name what they saw end:

- Claude records each task_notification with the roster description or the
  subagent's title and child thread, and names them on the next wake.
- Grok (ACP) reads kind, description, command and exit code from
  task_completed, and the title and child thread from subagent_finished.
- Codex background commands use the same wording, with the exit code.
- Delegated tasks name the task and say "2 of 3" when only some finished.

Contracts gain optional fields only: `kind` and `childThreadId` on pending
background tasks, `workKind` and `childThreadId` on notifications. No new
source literal, so older clients keep decoding. The text sent to the
provider is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 27, 2026
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts Outdated
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 4.9 KiB — 6.8 KiB ✅
Codex Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Codex Live turn WebSocket wire — 1.1 KiB — 2.0 KiB ✅
Codex Live turn WebSocket decoded — 20.4 KiB — 29.3 KiB ✅
Codex Live turn messages — 1 — 8 ✅
Claude Total thread wire — 4.9 KiB — 6.8 KiB ✅
Claude Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Claude Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Claude Live turn WebSocket decoded — 20.8 KiB — 29.3 KiB ✅
Claude Live turn messages — 2 — 8 ✅

Baseline: unavailable · PR result: 2d7f32b · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 106.1 KiB
  • Claude decoded thread snapshot: 106.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@macroscopeapp

macroscopeapp Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is a substantial cross-provider feature that changes wake scheduling state, persisted notification contracts, background-work classification, and user-visible timeline behavior. The unresolved high-severity compatibility concern around notification wire formats further warrants human verification.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

…ing-matched task types

Notifications and pending background tasks carry their kind as a
discriminated union: subagent (with its child thread), command, monitor,
delegated_task, and a generic background_task. Adapters build the member
from structured data they already have, so shared code no longer guesses a
kind from provider taskType strings.

A decode-only fallback arm maps kinds a build does not know, and rows
stored before kinds existed, to background_task (a stored
background_command decodes as command).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
// One subagent is the thing the row opens; several open nothing.
return rest.length === 0 && first.childThreadId !== undefined
? { kind: "subagent", childThreadId: first.childThreadId }
: { kind: "subagent" };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/Notification.ts:121

reportsSource emits source.kind: "command" and source.kind: "subagent", which pre-change clients cannot decode; those clients reject the entire timeline item/projection instead of ignoring the new metadata. Preserve the legacy background_command and delegated_task wire literals, adding optional work details such as childThreadId rather than introducing incompatible source kinds.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Notification.ts around line 121:

`reportsSource` emits `source.kind: "command"` and `source.kind: "subagent"`, which pre-change clients cannot decode; those clients reject the entire timeline item/projection instead of ignoring the new metadata. Preserve the legacy `background_command` and `delegated_task` wire literals, adding optional work details such as `childThreadId` rather than introducing incompatible source kinds.

juliusmarminge and others added 2 commits September 28, 2026 12:39
…ications

Clients from before specific background work kinds reject a notification
source kind they do not know, which fails the whole thread load. Store and
send commands as `background_command` and subagents as `background_task`
with `work: "subagent"`, and decode those back into the typed `command` and
`subagent` members, so current clients still switch on `kind`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
juliusmarminge and others added 6 commits September 28, 2026 13:08
…was queued

A continuation turn cleared every wake report when it started, including
ones recorded after its offer went out. Its sticky offer blocked another
offer until then, so the next wake no longer named that work. The offer now
remembers which reports it named, and its turn drops only those.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Recording a wake report read the user turn count and wrote the report in
separate Ref operations, so a user turn starting in between stamped the
report with the previous turn and it expired one turn early. The turn count
and the reports now live in one Ref and change in one update.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The set of backgrounded subagent task ids only grew. A task id stayed in it
after its subagent ended, so a later foreground re-run of that subagent was
named in the next unrelated wake. Its terminal notification now removes it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eports

The user turn count that ages wake reports went up before the prompt was
built, so a turn that failed on a missing attachment still used up a queued
wake's grace turn. It now goes up when the prompt is handed to Claude.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It string-matched provider taskType values, which this change removes from the
pending-task shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@juliusmarminge
juliusmarminge merged commit b76f762 into t3code/codex-turn-mapping Sep 28, 2026
23 checks passed
@juliusmarminge
juliusmarminge deleted the v2/specific-background-work branch September 28, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant