feat(agent-status): host-owned child producer for structured sessions - #21279
brennanb2025 wants to merge 1 commit into
Conversation
Admit the full structured background-task roster into the execution host's canonical child-work collection, so one logical child task has one identity and one lifecycle regardless of which surface renders it. The collection, its admission API and both legacy projections already shipped; nothing in production called them. This adds the missing producer and the read-only egress views, plus a parity gate measuring those views against the renderer bridge's own projection code. The renderer bridge remains the live writer. Delivery-on, reader-on and old-writer-off stay one atomic follow-up change.
📝 WalkthroughWalkthroughThe change adds structured child-work evidence decoding, canonical reconciliation, and legacy projections. It publishes child-work updates through session status ownership and runtime sinks. Remote ingestion now supports structured children. The renderer uses the shared subagent projection. New tests cover lifecycle behavior, projection parity, publication ordering, ownership, and sink wiring. Priority: ⬇️ Low Merge Risk: 🟡 Moderate · up to Large or partially rejected task rosters can omit valid tasks or make active tasks disappear. Correct reconciliation before merging. 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Full details: Description checkExplanation The description is detailed and covers the required change summary, rationale, testing, visual proof, AI disclosure, review notes, compatibility considerations, and checklist. However, the required Linked Issue section is incomplete because it contains only “Fixes #” without an issue number or link.
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 149573bf-10e2-4226-bc7e-7b16ef089695
📒 Files selected for processing (23)
src/main/agent-hooks/server/server-ingest-remote.tssrc/main/agent-hooks/server/server-ingest-structured-children.tssrc/main/agent-hooks/structured-child-work-bridge-parity.test.tssrc/main/agent-hooks/structured-child-work-producer.test.tssrc/main/agent-hooks/structured-child-work-reader-agreement.test.tssrc/main/claude/claude-background-task-journal-admission.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-feed.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.tssrc/main/orcad/orcad-entry.tssrc/main/runtime/orca-runtime-structured-status-sink-wiring.test.tssrc/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.tssrc/main/startup/main-process-runtime-service.tssrc/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsxsrc/shared/agent-status-child-work-reconciliation.tssrc/shared/agent-status-child-work-structured-egress.tssrc/shared/agent-status-child-work-structured-evidence.tssrc/shared/agent-status-child-work-structured-producer.test-fixture.tssrc/shared/structured-session-legacy-subagent-projection.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.
| if (resolution.ambiguous) { | ||
| outcome.rejected.push({ | ||
| providerTaskId: observation.providerTaskId, | ||
| reason: 'ambiguous' | ||
| }) | ||
| continue | ||
| } | ||
| if (!child && owned.length + outcome.announced >= STRUCTURED_CHILD_WORK_MAX_TASKS) { | ||
| outcome.rejected.push({ | ||
| providerTaskId: observation.providerTaskId, | ||
| reason: 'ingestion-limit' | ||
| }) | ||
| continue | ||
| } | ||
| const { result, operation } = admitObservation(input, observation, resolution) | ||
| if (!result.accepted) { | ||
| outcome.rejected.push({ | ||
| ...(child ? { childWorkId: child.childWorkId } : {}), | ||
| providerTaskId: observation.providerTaskId, | ||
| reason: result.reason | ||
| }) | ||
| continue |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '120,333p' src/shared/agent-status-child-work-reconciliation.ts
rg -n "ambiguous|rejected|settleAbsent|accepted: false|admitObservation" src/shared src/main/agent-hooks/structured-child-work-producer.test.tsRepository: stablyai/orca
Length of output: 47308
Keep provider-listed children matched when resolution is ambiguous or admission is refused.
resolveExisting marks an observation as ambiguous when one provider ID resolves to multiple distinct owned children. The ambiguous branch does not add any candidate to matched. Likewise, a refused admission does not add its resolved child to matched. The settle pass then calls settleAbsent for each live candidate, even though the provider roster listed that candidate. This can incorrectly remove a live child from the projections.
Add every resolved candidate ID to matched in the ambiguous branch. Add the resolved child ID before continuing after a refused admission. Return the candidate IDs from resolveExisting so the ambiguous branch marks only those candidates as observed; do not mark all owned children.
| if (!child && owned.length + outcome.announced >= STRUCTURED_CHILD_WORK_MAX_TASKS) { | ||
| outcome.rejected.push({ | ||
| providerTaskId: observation.providerTaskId, | ||
| reason: 'ingestion-limit' | ||
| }) | ||
| continue |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Count created children, not announcements, in the ingestion bound.
outcome.announced also increments when an existing child is re-announced (line 206). owned.length already includes those children. The gate therefore charges one budget unit per existing child per publication. A parent holding about half the bound rejects every new task with ingestion-limit while the real child count stays below STRUCTURED_CHILD_WORK_MAX_TASKS.
Track creations separately. AgentChildWorkAdmissionResult reports created.
🐛 Proposed fix
const owned = input.store.getChildren(input.parent).filter(ownedByProducer)
const matched = new Set<string>()
+ let created = 0
for (const observation of observations) {
@@
- if (!child && owned.length + outcome.announced >= STRUCTURED_CHILD_WORK_MAX_TASKS) {
+ if (!child && owned.length + created >= STRUCTURED_CHILD_WORK_MAX_TASKS) {
outcome.rejected.push({
providerTaskId: observation.providerTaskId,
reason: 'ingestion-limit'
})
continue
}
@@
matched.add(result.childWorkId)
+ if (result.created) {
+ created += 1
+ }
outcome[operation] += 1There was a problem hiding this comment.
Important
Two correctness edge cases and one hot-path cost, all inline. Nothing user-visible is broken today because the producer is not yet wired to a surface, but the canonical collection is the source of truth later PRs will switch onto, so these are worth resolving before it becomes load-bearing.
Reviewed changes
- Host child producer —
reconcileStructuredChildWorkreconciles one structured session's full adapter roster against the canonical child collection through the existingannounce/adopt/resumeadmission API, settling rows the roster stopped listing and never claiming an outcome the channel cannot carry. - Evidence decoder —
decodeStructuredChildWorkEvidencedecodes live + settled tasks, usage and stop capability into canonical observations;nullis authoritative emptiness,undefinedis absence of evidence. - Read-only egress —
projectStructuredChildWorkSubagents/projectStructuredChildWorkBackgroundTaskStatebuild the sidebar and strip shapes from canonical rows, with stop capability stored as session facts. - Ingest and wiring — a new
AgentHookServerIngestStructuredChildrenlayer and a requiredpublishChildrensink method, wired in bothmain-process-runtime-serviceandorcad-entry. - Bridge parity —
subagentSnapshotsFromTasksis lifted out of the renderer component into shared code so the host projection is measured against the bridge's own implementation rather than a restatement. - Tests — parity gate, producer adoption conditions, reader agreement with no renderer in the process, and Claude journal-admission ordering.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| }) | ||
| continue | ||
| } | ||
| if (!child && owned.length + outcome.announced >= STRUCTURED_CHILD_WORK_MAX_TASKS) { |
There was a problem hiding this comment.
The ingestion cap double-counts existing children. outcome.announced is incremented for every accepted announced operation, and admitObservation returns announced for a same-kind update of an existing child too (line 205-208), while owned is a pre-loop snapshot. So with 128 existing children re-listed first, 128 + 128 hits the cap and the first genuinely new child is refused with ingestion-limit even though the collection would hold 129 records.
Technical details
# Ingestion limit double-counts re-announced children
## Affected sites
- `src/shared/agent-status-child-work-reconciliation.ts:308` — `owned.length + outcome.announced >= STRUCTURED_CHILD_WORK_MAX_TASKS`.
- `src/shared/agent-status-child-work-reconciliation.ts:205-208` — same-kind update of an existing child returns `operation: 'announced'`.
- `src/shared/agent-status-child-work-reconciliation.ts:296` — `owned` is snapshotted once before the loop.
## Required outcome
- New children are admitted while the total record count is below the cap, regardless of how many existing children the roster re-lists.
## Suggested approach
- Count only creations: `AgentChildWorkAdmissionResult` already carries `created`, so track a `createdCount` alongside `outcome` and compare `owned.length + createdCount` against the cap.
## Open questions for the human
- Is the cap meant to bound total retained rows (my reading) or only new mints? The name and the PR description suggest total retained rows.| if (existing.kind !== observation.kind) { | ||
| return { | ||
| operation: 'adopted', | ||
| result: admission.adopt({ | ||
| ...common, | ||
| childWorkId: existing.childWorkId, | ||
| expectedFence: existing.invocation | ||
| }) | ||
| } |
There was a problem hiding this comment.
A kind change routes to admission.adopt, and adoptAgentChildWork rejects with ambiguous when resolveChildAliasRecords returns a binding whose childWorkId differs — but that resolution includes retired alias tombstones. After the PR's own re-admission fix mints a new child past a retired generation, a later kind reclassification back to the retired kind collides with the tombstone, and the reconciler then settles a child the roster still lists live.
Technical details
# Retained alias tombstone blocks `adopt` and settles a live child
## Affected sites
- `src/shared/agent-status-child-work-reconciliation.ts:195-203` — kind change goes to `admission.adopt`.
- `src/shared/agent-status-child-work-admission-operations.ts:144-149,167-172` — `adopt` rejects `ambiguous` on any resolved binding with a different `childWorkId`.
- `src/shared/agent-status-store-child-queries.ts:24-32` — `resolveChildAliases` returns retired alias tombstones.
- `src/shared/agent-status-child-work-reconciliation.ts:316-322,327-331` — a rejected observation is not added to `matched`, so the owned live child is settled by `settleAbsent`.
## Reachable sequence
1. Publish `{ id: 'task-1', kind: 'unknown' }` → child A, alias `(unknown, task-1)`.
2. Forget the session (`removeParent`) → A and its alias removed, tombstone `(unknown, task-1) → A` retained.
3. Re-publish the parent and re-admit `task-1` as kind `agent` → child B at generation 1.
4. Provider reports `task-1` as kind `unknown` again → resolution returns B, kind differs → `adopt` resolves the `(unknown, task-1)` tombstone bound to A ≠ B → `ambiguous`; B is settled while the roster lists it live, and the subsequent `resume` is rejected the same way until the tombstone is compacted.
## Required outcome
- A retired invocation's tombstone fences that invocation's late observations without blocking a re-admitted child from being reclassified.
## Suggested approach
- Scope the `adopt`/`resume` collision check to live bindings (or to bindings whose child still exists), or key the retired generation so a re-admitted child supersedes it. This is the same retired-binding hazard the PR fixes for re-admission, one step later.
## Open questions for the human
- Confirm whether the sequence above is considered reachable (it requires forget + re-attach + a kind flip-flop back to the retired kind).| store.applyMutation({ | ||
| facts: [ | ||
| { | ||
| subject: parent, | ||
| key: STRUCTURED_SUPPORTS_TASK_STOP_FACT, | ||
| value: evidence.supportsTaskStop | ||
| }, | ||
| { subject: parent, key: STRUCTURED_SUPPORTS_STOP_ALL_FACT, value: evidence.supportsStopAll } | ||
| ] | ||
| }) |
There was a problem hiding this comment.
These facts are written on every projection, and sinkChildren runs on every journal publication (both the equal-summary and changed-summary branches), so an unchanged roster still advances the canonical store revision. applyMutation has no no-op short-circuit, so each publication clones every map and validates the whole store, once for the facts plus once per re-committed child.
Technical details
# Unchanged evidence still mutates the store on every projection
## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts:199,204,281` — `sinkChildren` runs on every `publish`, gated only on `readBackgroundTasks` returning `undefined`; a live adapter with no tasks returns `null`, which decodes to empty evidence.
- `src/main/agent-hooks/server/server-ingest-structured-children.ts:39-48` — stop facts written unconditionally.
- `src/shared/agent-status-child-work-admission-core.ts:126-151` — `updateExistingAgentChildWork` re-commits every existing child.
- `src/shared/agent-status-store-mutation.ts:220-262` and `src/shared/agent-status-store.ts:107-128` — no mutation is a no-op; `applyMutation` clones all five maps and validates the whole store.
## Required outcome
- A projection carrying the same evidence does not advance the canonical store revision.
## Suggested approach
- Cache the last decoded evidence per session (or compare the fact values) and skip the facts write and reconcile when unchanged. The egress code already flags a keyed fact reader as a cutover follow-up; this is the write-side equivalent.
## Open questions for the human
- Is the per-publication mutation cost acceptable while the store is local-only? If a replica is wired later, its `revision === previousRevision + 1` envelope guard would require forwarding every one of these no-op revisions or the chain breaks.|
Superseded by #22536, part of a fresh stack for subagent status:
#22536 ports this PR's host-owned child producer onto the new contract. Beyond this PR, it adds what each subagent is doing now (from task progress, including foreground children, which the tracker used to drop), how each one ended, and each one's own clock. It also settles a subagent the roster drops before its outcome arrives, then refines that to the real outcome, so no timer is needed. Closing in favour of #22536. |

ELI5
When a chat agent kicks off work in the background — a sub-agent, a long-running
command, a monitor — that work shows up in two places: a strip inside the chat, and an
indented row in the sidebar. Today those two places learn about it separately, from
different sources, and describe it differently. This teaches the part of the app that
actually runs the work to keep one list of those background children, so both places can
read the same list instead of each keeping their own.
Nothing looks different yet. The old path is still the one driving the screen.
What Changed
Before: the only thing producing native-chat child rows is a React component in the
renderer. It reads the host's status summary, converts live tasks into the sidebar's
subagentsvocabulary, and writes them into the renderer store. The chat strip,meanwhile, reads a richer roster straight off the provider adapter. One task therefore has
two representations, two identity conventions, different information, and two publication
lifecycles. If no renderer is mounted —
orca serve,orcad, the CLI, mobile — nobodyproduces child rows at all.
After: the execution host admits the full roster into the canonical child-work
collection that already ships in
src/shared/agent-status-child-work*. That collection,its admission API (
announce/adopt/resume/reparent/authorizeStop) and both legacyprojections were already on main with tests — but a search of non-test TypeScript under
src/mainandsrc/sharedfound no production caller. The producer was the missingpiece.
The mechanism. A background-task change already reaches the canonical store — for the
parent row only. Every hop:
src/main/codex/codex-structured-session-adapter.ts:159/src/main/claude/claude-structured-session-adapter.ts:186→onBackgroundTasksChangedsrc/main/runtime/structured-agent-session-runtime.ts:277,322→host.publishBackgroundTaskStatesrc/main/native-chat/agent-session-wire/structured-agent-session-host.ts:326→ the channelstructured-agent-session-background-task-channel.ts:65→onPublishedstructured-agent-session-host.ts:87wires that toclientDelivery.publishStatusstructured-agent-session-client-delivery.ts:37→statusFeed.publishstructured-agent-session-status-feed.ts→this.sink(summary, location)structured-agent-session-status-ownership.ts:62→sink.publish(summary, subject)main-process-runtime-service.ts/orcad-entry.ts→ingestStructuredStatusserver-ingest-structured.ts:73→canonicalStatusStore.applyMutation({ parent })The children now ride that same publication, under the same trusted subject, at the same
seam.
StructuredAgentSessionStatusSinkgainspublishChildren; the ownership class onlyoffers children once the parent row has landed, which is also the store's own
precondition for admitting a child.
New pieces:
agent-status-child-work-structured-evidence.ts— decodes the full adapter roster(live + settled + usage + stop capability) into child observations.
agent-status-child-work-reconciliation.ts— reconciles that roster against the storethrough the existing admission API. No new executor, no second cache.
agent-status-child-work-structured-egress.ts— read-only selectors producing thesidebar's
subagentsand the strip'sbackgroundTasksfrom canonical rows.server-ingest-structured-children.ts— a new hook-server layer beside the store.Both hosts are wired: desktop (
main-process-runtime-service.ts) and headless(
orcad-entry.ts). MakingpublishChildrenrequired rather than optional is deliberate —it turns "this host is unwired" into a typecheck failure.
The renderer bridge is still live and still the writer that drives the screen. The two
delivery filters (
main-window-agent-status.ts:54,agent-hooks.ts:56) are untouched.Delivery-on, reader-on and old-writer-off remain one atomic follow-up change.
Why
The bug class is independent materialization of current child truth across the execution
and presentation boundary. A renderer converting a reduced feed into a second status
vocabulary, while the chat view reads richer provider status, means mounting, reconnect,
alias changes and late observations act on the two materializations differently. Fixing
the conversion would not help: a renderer cannot be the authority for a headless host,
the CLI, or mobile.
Why not ingest the status summary.
structured-agent-session-status-feed.tskeeps onlylive
tasks, stripstotalTokens, and omits an empty list, while the wire separatelycarries
settledTasksand stop support. Summary ingest cannot recreate the originalevidence even with a perfect decoder, so the producer reads the adapter roster directly.
Why publication is not gated on summary equality. The feed skips re-publishing an equal
summary. A usage-only child change is always equal, because the summary strips usage on
purpose. Children are therefore offered on every projection, not behind that gate.
Why five kinds are classification, not five policies. Provider evidence decides every
transition. Neither
agentnormonitorsettles, deletes or stops anything.Decisions worth a reviewer's attention
stoppableis a positive assertion. Only the Claude adapter implementsstopBackgroundTasks; Codex publishes nosupportsTaskStop. Canonical Codex rows aretherefore
stoppable: false— honest, and required by the fail-closed stop rule. Atcutover this changes the legacy background wire for Codex from absent (meaning
supported) to explicit
false. That is a deliberate correction, not a regression, but itis a cutover-visible change and should be re-confirmed then.
outcome: 'unknown'. This channel has no failure or cancellationvocabulary, so the producer never claims success.
full inventory; a row that leaves it becomes
settled/idle/outcome: unknown. Therecord and its history survive.
state === nullvsundefined.nullis the provider's authoritative "no livework".
undefinedis a session the adapter does not hold — absence of evidence, which isdecoded at all. Contact loss never settles a child.
is forgotten cascade-deletes its children, but their alias tombstones still resolve, so
every previously-known task id was refused
ambiguousforever — a re-attached sessionwould have shown no children at all. The reconciler now re-admits under a generation past
the retired one, so the old binding still fences its own late observations. Pinned by a
test.
Divergence from the governing plan
Per the negotiated scope note, two things to flag:
its aliases per mutation, so a multi-child roster lands as several revisions. Each commit
is individually consistent. Batching would mean a new mutation path rather than reuse.
(
STRUCTURED_CHILD_WORK_MAX_TASKS = 256) and by parent removal, not by per-rowretirement. Retiring rows individually creates the tombstone hazard described above, so
it is deliberately left to the cutover PR.
The Claude journal-admission question
The handoff flagged this as unverified and told me to demonstrate it rather than copy
callback ordering. I did, and the answer is more nuanced than assumed
(
claude-background-task-journal-admission.test.ts):onBackgroundTasksChangedfires only aftertranslator.handlehas returnedfor that frame. The collection never leads the journal.
isForwardedParentTooldeliberately declines a row for a task whose spawning tool neverreached the transcript, while the background channel still reports it. A producer fed by
journal items would silently lose those children. This is a concrete reason the canonical
collection is keyed on the background-task channel and stays separate from transcript
history.
claude-structured-session-journal-control.ts:16-28— a task-row journal failure rejectsacquisition before publication, or closes the connection and settles the session as
exited. Claude's equivalent of Codex's admission refusal.
Linked Issue
Fixes #
Visual Proof
N/A— no user-visible change. The renderer bridge is still the only writer driving childrows on screen; this PR adds a host-side producer and read-only selectors that nothing
renders yet. Visual proof belongs to the cutover PR, where the surfaces switch sources.
Testing
pnpm tcclean across all three projects (node, web, cli).oxlint src/clean.Changed-code quality gate: 0 new findings across 23 changed files.
structured-child-work-bridge-parity.test.ts(18) — the parity gate. It importssubagentSnapshotsFromTasksfrom the bridge's own module and compares it against thecanonical path over a shared case table, so this is a differential test, not a
restatement. Covers the four bridge semantics (agent-kind only; the state mapping; trim /
non-blank / ≤64 ids; the 32 cap at 31/32/33 with invalid rows interleaved), the intended
startedAtimprovement, and two declared deviations.structured-child-work-producer.test.ts(15) — adoption conditions: one child acrossre-announcement and duplicate producer evidence, provisional-kind adoption, revival with
retained invocation history, no joining across parents or execution scopes, another
producer's children left alone, restore without reminting, survival across turns and
/clear, invocation-fenced stop refusal, and full evidence retention.structured-child-work-reader-agreement.test.ts(6) — both surfaces from one committedrevision with no renderer in the process, plus the retired-id fix.
claude-background-task-journal-admission.test.ts(3) — the ordering guarantee, thenegative case, and channel-snapshot agreement, against the real adapter.
Regression runs:
src/main/agent-hooks+src/main/native-chat/agent-session-wire(177 files, 1557 tests) green;
src/main/claude+src/main/codex+src/main/runtimegreen except one pre-existing environment-dependent failure noted below; the shared
child-work and store suites green; the renderer bridge, sidebar subagent-row and
background-task-roster suites green.
Pre-existing failure, unrelated:
claude-structured-real-cli.test.ts > proves a pre-minted session before the first user messagefails on my machine. It spawns the realclaudebinary and asserts a command in that CLI's init catalog. It fails deterministicallyin isolation, and none of its imports is a file this PR touches.
Platforms: exercised on macOS. The change is pure host-side TypeScript with no
platform-dependent behaviour — no paths, shells, process spawning or shortcuts. SSH, WSL
and folder workspaces are covered by construction: the parent subject already carries
executionHostId,wslDistro,workspaceIdandworkspaceKind, the producer reuses thatexact trusted subject, and a test asserts the same provider id under two execution hosts
stays two children.
AI Disclosure
Claude Opus 5 via Claude Code.
Review
Start with
reconcileStructuredChildWorkand the settle-on-absence rule, then thestoppabledecision and the retired-binding fix — those are the three judgement calls.Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Remote/SSH: the producer runs on the execution host and writes to the store the relay
already mirrors; no new wire opcode and no new field crossing a paired connection, so mixed
versions are unaffected by this PR. Backwards compatibility: the legacy projections keep
their closed vocabulary, and the old writer is untouched. Performance: the reconciler does
one bounded alias resolution per publication; the egress reads a snapshot to find two
capability facts, which is noted in the code as something cutover should replace with a
keyed fact reader before it is called on every publish. Mobile: unaffected until cutover.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)