Skip to content

feat(agent-status): host-owned child producer for structured sessions - #21279

Closed
brennanb2025 wants to merge 1 commit into
mainfrom
brennanb2025/c-child-producer
Closed

brennanb2025 wants to merge 1 commit into
mainfrom
brennanb2025/c-child-producer

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​784 $\color{#cf222e}{\Huge{\mathbf{−}}}$​11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​773
Prod 12 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​949 $\color{#cf222e}{\Huge{\mathbf{−}}}$​67 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​882

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
subagents vocabulary, 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 — nobody
produces 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 legacy
projections were already on main with tests — but a search of non-test TypeScript under
src/main and src/shared found no production caller. The producer was the missing
piece.

The mechanism. A background-task change already reaches the canonical store — for the
parent row only. Every hop:

  1. src/main/codex/codex-structured-session-adapter.ts:159 / src/main/claude/claude-structured-session-adapter.ts:186 → onBackgroundTasksChanged
  2. src/main/runtime/structured-agent-session-runtime.ts:277,322 → host.publishBackgroundTaskState
  3. src/main/native-chat/agent-session-wire/structured-agent-session-host.ts:326 → the channel
  4. structured-agent-session-background-task-channel.ts:65 → onPublished
  5. structured-agent-session-host.ts:87 wires that to clientDelivery.publishStatus
  6. structured-agent-session-client-delivery.ts:37 → statusFeed.publish
  7. structured-agent-session-status-feed.ts → this.sink(summary, location)
  8. structured-agent-session-status-ownership.ts:62 → sink.publish(summary, subject)
  9. main-process-runtime-service.ts / orcad-entry.ts → ingestStructuredStatus
  10. server-ingest-structured.ts:73 → canonicalStatusStore.applyMutation({ parent })

The children now ride that same publication, under the same trusted subject, at the same
seam. StructuredAgentSessionStatusSink gains publishChildren; the ownership class only
offers 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 store
    through the existing admission API. No new executor, no second cache.
  • agent-status-child-work-structured-egress.ts — read-only selectors producing the
    sidebar's subagents and the strip's backgroundTasks from 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). Making publishChildren required 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.ts keeps only
live tasks, strips totalTokens, and omits an empty list, while the wire separately
carries settledTasks and stop support. Summary ingest cannot recreate the original
evidence 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 agent nor monitor settles, deletes or stops anything.

Decisions worth a reviewer's attention

  • stoppable is a positive assertion. Only the Claude adapter implements
    stopBackgroundTasks; Codex publishes no supportsTaskStop. Canonical Codex rows are
    therefore stoppable: false — honest, and required by the fail-closed stop rule. At
    cutover this changes the legacy background wire for Codex from absent (meaning
    supported) to explicit false. That is a deliberate correction, not a regression, but it
    is a cutover-visible change and should be re-confirmed then.
  • Settled rows carry outcome: 'unknown'. This channel has no failure or cancellation
    vocabulary, so the producer never claims success.
  • Absence from the roster settles membership, not an outcome. The trackers publish a
    full inventory; a row that leaves it becomes settled / idle / outcome: unknown. The
    record and its history survive.
  • state === null vs undefined. null is the provider's authoritative "no live
    work". undefined is a session the adapter does not hold — absence of evidence, which is
    decoded at all. Contact loss never settles a child.
  • Retired bindings are not an eternal ban. Found and fixed while testing: a parent that
    is forgotten cascade-deletes its children, but their alias tombstones still resolve, so
    every previously-known task id was refused ambiguous forever — a re-attached session
    would 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:

  • Roster-wide atomicity is not provided. The shipped admission API commits one child with
    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.
  • Retention of settled children is bounded by a named per-parent ingestion limit
    (STRUCTURED_CHILD_WORK_MAX_TASKS = 256) and by parent removal, not by per-row
    retirement. 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):

  • Holds: onBackgroundTasksChanged fires only after translator.handle has returned
    for that frame. The collection never leads the journal.
  • Does not hold: "the host was told" does not imply a transcript row exists.
    isForwardedParentTool deliberately declines a row for a task whose spawning tool never
    reached 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.
  • Failure path (read from source, not run):
    claude-structured-session-journal-control.ts:16-28 — a task-row journal failure rejects
    acquisition 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 child
rows 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 tc clean 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 imports
    subagentSnapshotsFromTasks from the bridge's own module and compares it against the
    canonical 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
    startedAt improvement, and two declared deviations.
  • structured-child-work-producer.test.ts (15) — adoption conditions: one child across
    re-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 committed
    revision with no renderer in the process, plus the retired-id fix.
  • claude-background-task-journal-admission.test.ts (3) — the ordering guarantee, the
    negative 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/runtime
green 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 message fails on my machine. It spawns the real
claude binary and asserts a command in that CLI's init catalog. It fails deterministically
in 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, workspaceId and workspaceKind, the producer reuses that
exact trusted subject, and a test asserts the same provider id under two execution hosts
stays two children.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

AI Disclosure

Claude Opus 5 via Claude Code.

Review

Start with reconcileStructuredChildWork and the settle-on-absence rule, then the
stoppable decision and the retired-binding fix — those are the three judgement calls.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and 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

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

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.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The 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 05652

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)

Check name Status Explanation Resolution
Description check ⚠️ Warning 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 Li… Add the issue number or link after “Fixes #” in the Linked Issue section.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 23 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: adding a host-owned producer for structured-session child work.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

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.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 149573bf-10e2-4226-bc7e-7b16ef089695

📥 Commits

Reviewing files that changed from the base of the PR and between 09622f0 and 0565294.

📒 Files selected for processing (23)
  • src/main/agent-hooks/server/server-ingest-remote.ts
  • src/main/agent-hooks/server/server-ingest-structured-children.ts
  • src/main/agent-hooks/structured-child-work-bridge-parity.test.ts
  • src/main/agent-hooks/structured-child-work-producer.test.ts
  • src/main/agent-hooks/structured-child-work-reader-agreement.test.ts
  • src/main/claude/claude-background-task-journal-admission.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts
  • src/main/orcad/orcad-entry.ts
  • src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts
  • src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts
  • src/main/startup/main-process-runtime-service.ts
  • src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx
  • src/shared/agent-status-child-work-reconciliation.ts
  • src/shared/agent-status-child-work-structured-egress.ts
  • src/shared/agent-status-child-work-structured-evidence.ts
  • src/shared/agent-status-child-work-structured-producer.test-fixture.ts
  • src/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.

Comment on lines +301 to +322
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

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.

🎯 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.ts

Repository: 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.

Comment on lines +308 to +313
if (!child && owned.length + outcome.announced >= STRUCTURED_CHILD_WORK_MAX_TASKS) {
outcome.rejected.push({
providerTaskId: observation.providerTaskId,
reason: 'ingestion-limit'
})
continue

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.

🎯 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] += 1

@pullfrog pullfrog Bot left a comment

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.

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 — reconcileStructuredChildWork reconciles one structured session's full adapter roster against the canonical child collection through the existing announce/adopt/resume admission API, settling rows the roster stopped listing and never claiming an outcome the channel cannot carry.
  • Evidence decoder — decodeStructuredChildWorkEvidence decodes live + settled tasks, usage and stop capability into canonical observations; null is authoritative emptiness, undefined is absence of evidence.
  • Read-only egress — projectStructuredChildWorkSubagents / projectStructuredChildWorkBackgroundTaskState build the sidebar and strip shapes from canonical rows, with stop capability stored as session facts.
  • Ingest and wiring — a new AgentHookServerIngestStructuredChildren layer and a required publishChildren sink method, wired in both main-process-runtime-service and orcad-entry.
  • Bridge parity — subagentSnapshotsFromTasks is 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

})
continue
}
if (!child && owned.length + outcome.announced >= STRUCTURED_CHILD_WORK_MAX_TASKS) {

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.

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.

Comment on lines +195 to +203
if (existing.kind !== observation.kind) {
return {
operation: 'adopted',
result: admission.adopt({
...common,
childWorkId: existing.childWorkId,
expectedFence: existing.invocation
})
}

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.

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).

Comment on lines +39 to +48
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 }
]
})

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.

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.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant