feat(native-chat): Claude sessions write their subagents into the host status store - #22536
brennanb2025 wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
Important
The reducer drops a task_updated-driven resume. The tracker revives a settled background child on an edge-only task_updated patch and queues a plain live edge, but the reducer treats it as late evidence and leaves the record settled — the legacy row and the record disagree on the same frame. It is conditional (only reachable before the first background_tasks_changed), so I have flagged it rather than blocked, but C5 will read these records and this reintroduces exactly the stale-settled class of bug this stack exists to remove.
Reviewed changes
- Claude producer —
claude-child-work-evidence.tsdecodes task frames into live / ended / inventory / turn-ended / session-ended / restart edges; the tracker queues one at each decision it already makes and drains them after the journal and the legacy republish. The journal translator gains read-onlychildToolOwner/childActivity. - Host delivery — a subject-gated
ingestStructuredChildWorkon a new server class link, reached through the feed'spublishChildWorkand the ownershiplandedgate from bothmain-process-runtime-service.tsandorcad-entry.ts. - Reducer —
reconcileAgentChildWorkEvidencefolds the edges into the canonical store through C2 admission, with 256-live / 32-settled caps and per-producer ownership. - Tests — 8 files; the producer test shadow-checks the parent state folded from records against the legacy
backgroundTasksDTO frame by frame.
ℹ️ The unproven-exit window has no child-record consequence
session-ended is only ever produced from backgroundTasks.clear(), which the close ladder reaches only after connection.close() proves the child gone. On an unexpected exit whose tree proof fails, no ended event is emitted, so no edge reaches the store and the run's children stay live under the session's subject. If a replacement is then acquired under the same session id, those records outlive the dead provider session until the replacement ends and removes them with its own session-ended. This may be the intended reading of "loss of contact is never evidence of process death", but C5 will render these records, so it is worth an explicit decision plus a test.
Technical details
# Children of an unproven-exit session stay live across a same-id reacquire
## Affected sites
- `src/main/claude/claude-structured-session-adapter.ts:130-161` — `settleUnexpectedExit` emits the `ended` event (and therefore `session-ended`) only after `close()` returns true.
- `src/main/claude/claude-structured-session-close.ts:100-115` — `finalizeClaudePublishedSession` calls `backgroundTasks.clear()` only when `connectionClosed === true`.
- `src/main/claude/claude-structured-session-recovery.test.ts:402-418` — pins that an unproven exit produces no `ended` event.
## Required outcome
A decision (and a test) on what the child records of an unproven-exit session should read once a replacement is acquired under the same session id: remain `live` until the replacement ends, or settle to `unknown`/be removed when the old provider session is superseded.
## Open questions for the human
- Is a stale-`live` record under a reacquired session id acceptable until the replacement ends, or should the host settle the old subject's children when the provider session is proven gone?DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| counted(ctx, handle.id, result, 'admitted') | ||
| return | ||
| } | ||
| if (existing.membership === 'settled') { |
There was a problem hiding this comment.
The tracker revives a settled background child on an edge-only task_updated live patch (observeTaskUpdated → upsert, which claude-background-task-resume.test.ts:77-91 pins as required) and queues a plain live edge, but that edge carries no runId (retention drops toolUseId), so agentChildWorkRunVerdict returns current and this branch drops it as "late evidence". The record stays settled while the same frame leaves the legacy row live, contradicting the shadow-check invariant the PR relies on.
Technical details
# Edge-only task_updated revival isn't mirrored into the record
## Affected sites
- `src/shared/agent-status-child-work-evidence-admission.ts:203` — a plain `live` edge on a settled record returns early as late evidence.
- `src/main/claude/claude-background-task-tracker.ts:239-248`, `:307` — `observeTaskUpdated` revives a retained task via `upsert`, queueing `pendingClaudeTaskLive` with no `runId`.
- `src/main/claude/claude-background-task-resume.test.ts:77-91` — pins the tracker's edge-only resume as required behaviour.
## Required outcome
A `task_updated` frame whose live patch revives a settled child leaves the record live, exactly as it leaves the legacy row live.
## Suggested approach
Either queue a resume-style edge (a distinct generation, like `pendingClaudeRestart`) when `upsert` revives an id absent from `this.tasks`, or treat a `live` edge on a settled record as a resume (generation + 1) here instead of dropping it. Do not regress the genuine late-edge case the early return also guards.
## Open question
The producer gate `!this.aggregateRosterObserved` in `observeTaskUpdated` means this is only reachable before the first `background_tasks_changed`. If the team considers it unreachable in practice, say so explicitly rather than leaving the record path silently divergent.| const existing = resolution.child | ||
| const request = { parent: ctx.parent, provider: ctx.provider } | ||
| const aliases = agentChildWorkHandleAliases(handle) | ||
| if (!existing) { |
There was a problem hiding this comment.
This is the only check of STRUCTURED_CHILD_WORK_MAX_LIVE, so the advertised 256-live bound holds only for brand-new children. The resume branch below (a new spawn call, or an inventory re-listing a settled child) moves a settled record back to live with no capacity check, so a session can run past 256 live records — e.g. settle 32 of 256 live children, admit 32 fresh ids, then resume the settled 32 under new spawn calls. The provider trackers never carry those resumed runs, so nothing else bounds the live count. Computing the live count before both branches, rather than only the create path, keeps the documented bound honest.
There was a problem hiding this comment.
ℹ️ No new issues in this incremental delta. The restarted-run fix is correct and its tests pass, but two findings from the previous review remain open and unaddressed, so this is not an approval.
Reviewed changes
Only fc7fd5d8ab ("a Claude child restarted under a new spawn call keeps reporting to its record") landed since the prior pullfrog review — 6 files.
- New
ClaudeTaskRestartsstore — a task that ended under one spawn call and starts again under another is held in a bounded (256-entry) map, withtake/get/clearwired into the tracker'sfinish,clear, roster replacement,task_progress, and spawn-result lookups. - Restarted-run evidence —
task_progressresolves a restarted run throughthis.restarts.get(id), and a foreground restart's own spawntool_resultresolves throughrestarts.tasks, so that run's operation, usage, and ending reach the record before any roster lists it. - Roster hand-back —
replaceClaudeAggregateRostertakes the restarted task so a listing resumes it under the new spawn call instead of the stale settled one. - Tests — two tracker tests (restart progress before a roster; a restarted foreground run ends on its own spawn result) and a producer run-count parity test asserting the record's
invocation.generationmatches the journal roster'sattempt.
I traced the restart path through task_progress, its own spawn result, repeated restarts, roster hand-back, and turn boundaries; the record tracks the legacy row as the PR body documents (the record hears the restart at once, the legacy row waits for a roster — a deliberate divergence). The two earlier inline findings live in code this delta does not touch and remain open.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
d5ad80a to
7cfb048
Compare
fc7fd5d to
6818421
Compare
3bb417f to
6de7f20
Compare
d5ffa7f to
eada18c
Compare
There was a problem hiding this comment.
ℹ️ No critical issues. The new commit is a retention refactor; one edge case is worth a decision inline. The two findings from the earlier review are in code this commit does not touch and remain open.
Reviewed changes
Only eada18c2 landed since the prior pullfrog review (the branch was replayed onto a newer c2-child-record-contract, so the earlier commits are unchanged in content).
- Retention moved into admission —
liveFieldsandsettleAgentChildWorknow send only the facts the edge observed, delegating labels, model, owner, residency, last message and monotonic tokens toretainedFacts. Verified equivalent on the announce, adopt and resume paths, each of which passes the stored record asprior. - Token source narrowed —
claudeTaskObservationnow reads onlyfacts.totalTokens, dropping the tracked task'stask.totalTokensfallback.
Verified locally: claude-child-work-evidence (8), agent-status-child-work-reconciliation (16) and claude-structured-child-work-producer (3) all pass.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| // The published row names a task's type as both its name and its agent type. | ||
| ...(task.name ? { name: task.name, agentType: task.name } : {}), | ||
| ...(task.description ? { description: task.description } : {}), | ||
| ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), |
There was a problem hiding this comment.
With the task.totalTokens fallback gone, a child's token count survives only while its record does — retainedFacts can retain from a prior record but cannot recreate one. The 32-settled cap in trimSettled can evict a settled record while the tracker still holds the task, so a child re-listed by a roster or restarted under a new spawn call is admitted proven-new with no prior and loses the count (these edges pass no facts). The tracked task still carries it here, so the legacy row and the record diverge until the next task_progress. Worth a decision: accept the gap, or carry the task's count on those edges.
Technical details
# An evicted-then-revived child loses its token count
## Affected sites
- `src/main/claude/claude-child-work-evidence.ts:80` — only `facts.totalTokens` is read now; the inventory, `upsert` and restart edges pass no `facts`.
- `src/shared/agent-status-child-work-reconciliation.ts:129-148` — `trimSettled` removes settled records past 32, leaving the tracker's task in `ClaudeSettledBackgroundTasks` / `ClaudeTaskRestarts`.
- `src/shared/agent-status-child-work-evidence-admission.ts:156-177` — a handle whose record is gone is admitted `proven-new`, calling `liveFields(..., null, null)`, so no prior exists to retain from.
## Required outcome
A revived child carries the same token count the legacy row does. Either keep the tracked task's `totalTokens` on the inventory / restart edge, or record a decision that the count may read absent until the next `task_progress`.
## Open question for the human
Is losing the count acceptable here, given #22614 renders tokens from these records?6de7f20 to
41d814b
Compare
eada18c to
503eb83
Compare
…bscriptions as they are subscribeStatus and subscribeTurnCompletions wrapped client delivery's bound methods in forwarding lambdas; they are now the same members, the way waitForSendSettlement already is. The host is at its size limit, and the next channel it hands out needs the line.
…t status store The Claude background-task tracker queues child-work evidence at each decision it already makes (start, update, progress, terminal frame, roster replacement, turn end, session end), plus the two facts its legacy row ignores: a foreground child's progress and a foreground spawn call's result. The adapter drains that evidence after the journal handled the frame and the parent row was republished, and the host folds it into one record per child in its canonical store. Nothing reads the records yet; the strip and sidebar keep their current sources.
…child record The adapter delivers evidence after the frame's journal rows and the parent's republished row; the frame script keeps the parent state today reads while the records add outcome and activity; the runtime hands the evidence to the status sink under the session's own address; both entry points wire the sink to the ingest.
…its record says it is doing A child's tool traffic reaches the parent stream only for a foreground child. Read after the journal handled the frame, the child's newest call still awaiting a result becomes its open operation, previewed the way a hook-reported row previews its own tool; the result closes it. The open call is derived from the journal's own bookkeeping, not held a second time.
…ps reporting to its record A task that ended and starts again stays hidden from the legacy row until a roster lists it, so the tracker held no run for it: the new run's progress reached nothing and a foreground re-run's own spawn result settled nothing. The run is now held beside the live map, where the legacy row never reads it, until a roster hands it back or it ends. A parity test pins the record's run count to the journal roster's attempt on a new spawn call, the one event both count.
…ontract get their own homes The translator's child-tool queries move into claude-child-tool-queries.ts and its contract type into claude-journal-translator-contract.ts. Brings the translator back under the size limit.
…e's facts Admission now keeps what a child's record already knows: labels, model, owner, residency, the last message within an invocation, and a token count that never shrinks. The evidence side copied all of those forward itself, a second owner of the same rule. It now sends only what this edge observed, and a task's token count comes from the frame that reported it.
…labels Admission now folds provider text to one line and drops a malformed fact instead of refusing the record, so the evidence side no longer folds labels itself. The description keeps admission's longer bound.
503eb83 to
fa71175
Compare
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 📝 WalkthroughWalkthroughThe change adds shared child-work evidence contracts and reconciliation for structured sessions. Claude task tracking now produces evidence for live work, progress, outcomes, inventories, and lifecycle boundaries. The structured-session runtime forwards that evidence to ingestion, which reconciles it only when the parent row exists. Tests cover reconciliation rules, Claude event handling, publication paths, and session isolation. Priority: ➖ Normal Merge Risk: 🔵 Low · up to Long sessions may incur slower child-work updates, and a delayed Claude notification can put an older run’s details on a newer child record. The impact is bounded, but these concerns warrant a fix or explicit acceptance before merge. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to Child-work records are gated on an existing parent session, but a failed status-store write can permanently lose an update. That matters most for completion and cleanup records. No verified security finding was supplied, and the current change does not add a reader for these records. Retained concerns
Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ 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.
🧹 Nitpick comments (1)
src/shared/agent-status-child-work-evidence-resolution.ts (1)
155-157: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoffAvoid full alias scans for child lookups.
getAliasesForChildscans every stored alias before filtering bychildWorkId. The lookup atsrc/shared/agent-status-child-work-evidence-resolution.ts:155-157therefore has linear cost in the alias count. If this path is hot, maintain an index keyed bychildWorkId.The alias-retention claim is not valid: removing a child also removes its aliases, and
AGENT_STATUS_STORE_LIMITS.aliasesbounds the total alias count.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: b2049632-a4c3-4743-961e-35f553080726
📒 Files selected for processing (32)
src/main/agent-hooks/server-ingest-structured-children.test.tssrc/main/agent-hooks/server/server-ingest-remote.tssrc/main/agent-hooks/server/server-ingest-structured-children.tssrc/main/claude/claude-background-task-aggregate-roster.tssrc/main/claude/claude-background-task-restarts.tssrc/main/claude/claude-background-task-tracker.tssrc/main/claude/claude-child-tool-queries.tssrc/main/claude/claude-child-work-evidence.test.tssrc/main/claude/claude-child-work-evidence.tssrc/main/claude/claude-journal-translator-contract.tssrc/main/claude/claude-settled-background-tasks.tssrc/main/claude/claude-structured-child-work-producer.test.tssrc/main/claude/claude-structured-journal-translation.tssrc/main/claude/claude-structured-session-adapter.tssrc/main/claude/claude-structured-session-state.tssrc/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.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/orcad/orcad-entry.tssrc/main/runtime/claude-structured-session-integration.test.tssrc/main/runtime/orca-runtime-structured-status-sink-wiring.test.tssrc/main/runtime/structured-agent-session-runtime.tssrc/main/runtime/structured-claude-runtime-adapter.tssrc/main/startup/main-process-runtime-service.tssrc/shared/agent-status-child-work-evidence-admission.tssrc/shared/agent-status-child-work-evidence-resolution.tssrc/shared/agent-status-child-work-evidence.tssrc/shared/agent-status-child-work-reconciliation.test.tssrc/shared/agent-status-child-work-reconciliation.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.
There was a problem hiding this comment.
ℹ️ No new issues in this incremental delta. The one new commit is a behavior-preserving refactor; the three earlier findings remain open, so this is not an approval.
Reviewed changes
Only fa71175a68 ("Claude child evidence hands admission its raw labels") landed since the prior pullfrog review — 2 files, +6/-27.
- Label folding moved into admission —
liveFieldsnow passeschild.name/description/agentTypethrough raw;parseObservationFactsfolds them withnormalizeChildWorkText(control chars and line separators to spaces, trimmed) before the codec sees them, so the producer no longer owns that rule. - Description keeps admission's longer bound — the removed
childWorkLabelcapped every label at 512;descriptionnow usesAGENT_CHILD_WORK_DESCRIPTION_MAX_LENGTH(8,000), whilename/agentTypestay at 512. - Dead helper removed —
childWorkLabel, itsnormalizeOptionalFieldimport, and the label-bound constants are gone. - Test renamed — the reconciliation test now states that admission owns the folding.
This also closes a latent edge the old helper left open: childWorkLabel folded only C0/DEL, so a label containing U+0085/U+2028/U+2029 would have failed the codec's isChildWorkText check and refused the whole record. Delegating to normalizeChildWorkText folds those too, so the value always satisfies the codec. Verified pnpm test on the reconciliation (16) and evidence (8) files and pnpm tc:node, all green.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…nding The reconciliation returned before admission whenever a record had already settled with a definite outcome. That dropped the evidence an `unknown` ending carries (its last message and tokens), which admission's refine-only rule keeps, so that rule never ran for the structured producers. The latch goes. Admission keeps the definite outcome, lands the late evidence, and refuses a conflicting definite ending as `stale-invocation`, which the host ingest already counts as the fence doing its job, not a fault. Pinned through the real Claude producer and the host's own ingest.
There was a problem hiding this comment.
ℹ️ No new issues in this incremental delta. The new commit is a small behavioral fix whose tests can fail; the three earlier findings remain open, so this is not an approval.
Reviewed changes
Only bf82728df6 ("admission alone decides a settled child's second ending") landed since the prior pullfrog review — 2 files.
- Second-ending ownership moved into admission —
applyEndedno longer returns early once a settled child has a definite outcome. Every current ending now reachessettleAgentChildWork, and admission'sconflictsWithSettleddecides: anunknownending keeps the stored definite outcome while still landing its last message and monotonic tokens, and a conflicting definite ending is refused asstale-invocation. - Tests — the producer harness can now route evidence through a real
AgentHookServer. A settled foreground child is sent an unclassifiedtask_notification(asserts its summary and usage land while the outcome stayssucceeded) and a conflictingfailednotification (assertssettled: 0, astale-invocationrefusal, and noconsole.warn/error).
I traced the removed latch through settleAgentChildWork → updateExistingAgentChildWork → conflictsWithSettled and mergeObservationFacts: the new comment matches the code, server-ingest-structured-children.ts already treats stale-invocation as an expected fence refusal, and both new tests would fail under the old early return. The three earlier inline findings (agent-status-child-work-evidence-admission.ts revive/cap and the claude-child-work-evidence.ts token gap) sit in files this commit does not touch and remain open.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: c506d7e3-3703-41b4-88cf-f4ab9b02e077
📒 Files selected for processing (2)
src/main/claude/claude-structured-child-work-producer.test.tssrc/shared/agent-status-child-work-reconciliation.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.
| if (agentChildWorkRunVerdict(ctx, existing, edge.handle.runId) !== 'current') { | ||
| return | ||
| } | ||
| // Admission owns what a second ending may change: an `unknown` one keeps a definite outcome and | ||
| // 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 } : {}) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '130,235p' src/main/claude/claude-background-task-tracker.ts
sed -n '185,230p' src/shared/agent-status-child-work-evidence-admission.ts
sed -n '240,270p' src/main/claude/claude-child-work-evidence.test.tsRepository: stablyai/orca
Length of output: 6991
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- tracker symbols and notification construction ---'
rg -n -C 8 'pendingClaudeNotification|restarts|terminalTaskIds|task_notification|observeSystemFrame|finish\(|spawnResult|runId' src/main/claude/claude-background-task-tracker.ts src/main/claude src/shared
printf '%s\n' '--- reconciliation implementation and classifier ---'
rg -n -C 12 'agentChildWorkRunVerdict|runId|settleAgentChildWork|reconcileAgentChildWorkEvidence|task_notification|unknown|current|stale-invocation' src/shared/agent-status-child-work-reconciliation.ts src/shared/agent-status-child-work-evidence-admission.ts
printf '%s\n' '--- relevant tests ---'
rg -n -C 12 'status-less|notification|restarted|restart|runId|toolu_fg|agent-fg|lastMessage|totalTokens' src/main/claude/claude-child-work-evidence.test.ts src/main/claude src/shared --glob '*test*'Repository: stablyai/orca
Length of output: 45659
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- tracker core ---'
sed -n '1,125p' src/main/claude/claude-background-task-tracker.ts
sed -n '175,325p' src/main/claude/claude-background-task-tracker.ts
printf '%s\n' '--- restart implementation ---'
cat -n src/main/claude/claude-background-task-restarts.ts
printf '%s\n' '--- notification producer ---'
rg -n -C 20 'function pendingClaudeNotification|pendingClaudeNotification|runId|invocation' src/main/claude/claude-child-work-evidence.ts
printf '%s\n' '--- reconciliation implementation ---'
rg -n -C 18 'agentChildWorkRunVerdict|reconcileAgentChildWorkEvidence|settleAgentChildWork|runId' src/shared/agent-status-child-work-reconciliation.ts
printf '%s\n' '--- producer restart test ---'
sed -n '330,430p' src/main/claude/claude-structured-child-work-producer.test.tsRepository: stablyai/orca
Length of output: 29223
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- run verdict and admission ---'
rg -n -C 25 'function agentChildWorkRunVerdict|agentChildWorkRunVerdict|lifetime|stale-invocation|outcome|lastMessage|totalTokens' src/shared/agent-status-child-work-evidence-admission.ts
printf '%s\n' '--- second-ending tests ---'
sed -n '400,560p' src/main/claude/claude-structured-child-work-producer.test.ts
printf '%s\n' '--- notification and run-id tests ---'
rg -n -C 18 'task_notification|pendingClaudeNotification|runId.*undefined|undefined.*current|second ending|stale-invocation|lastMessage.*totalTokens' src/main/claude/*test.ts src/shared/*test.tsRepository: stablyai/orca
Length of output: 45663
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- spawn result and handle construction ---'
sed -n '90,220p' src/main/claude/claude-child-work-evidence.ts
printf '%s\n' '--- tracker ordering tests ---'
rg -n -C 20 'task_started|task_notification|tool_use_id|delayed|late|notification' src/main/claude/claude-background-task-tracker.test.ts src/main/claude/claude-child-work-evidence.test.ts src/main/claude/claude-structured-child-work-producer.test.ts
printf '%s\n' '--- changed reconciliation diff ---'
git diff 58ba75b5a5fc762e64073b51e481f1832242aa09 bf82728df66ed17e1b0ebab9ae3f43de42ef7a80 -- src/shared/agent-status-child-work-reconciliation.ts src/shared/agent-status-child-work-evidence-admission.ts src/main/claude/claude-child-work-evidence.ts src/main/claude/claude-background-task-tracker.ts
printf '%s\n' '--- notification payload/tool-use fields ---'
rg -n -C 8 \"task_notification|tool_use_id|task_id.*summary|summary.*usage\" src/main/claude src/shared --glob '*.ts' --glob '*.json'Repository: stablyai/orca
Length of output: 40402
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- notification payloads with tool-use fields ---'
rg -n -C 5 'task_notification' src/main/claude src/shared --glob '*.ts' | rg -n -C 3 'task_notification|tool_use_id|summary|usage'
printf '%s\n' '--- tracker update path ---'
sed -n '140,215p' src/main/claude/claude-background-task-tracker.ts
printf '%s\n' '--- task-id/run-id helpers ---'
rg -n -C 10 'function taskId|function taskAliasId|const taskId|const taskAliasId' src/main/claude/claude-background-task-frames.tsRepository: stablyai/orca
Length of output: 41867
🏁 Script executed:
#!/bin/bash
set -e
sed -n '1,70p' src/main/claude/claude-child-work-evidence.ts
sed -n '136,150p' src/main/claude/claude-child-work-evidence.tsRepository: stablyai/orca
Length of output: 3540
Preserve the notification run ID.
Real Claude notifications can include tool_use_id, but pendingClaudeNotification drops it. A delayed notification from toolu_old is then classified as current and can overwrite the newer run's lastMessage and totalTokens.
Suggested fix
-import { record, taskText, taskUsageTotalTokens } from './claude-background-task-frames'
+import { record, taskAliasId, taskText, taskUsageTotalTokens } from './claude-background-task-frames'
...
return pendingClaudeTaskEnded(id, claudeChildWorkOutcome(message.status), {
lastMessage: taskText(message.summary),
- totalTokens: taskUsageTotalTokens(message)
+ totalTokens: taskUsageTotalTokens(message),
+ toolUseId: taskAliasId(message.tool_use_id)
})Notifications without tool_use_id remain unclassified and retain the existing same-run behavior.

ELI5
When a Claude chat starts a subagent or a background shell, Orca now keeps one record for it on the machine running the chat. The record says what kind of work it is, whether it is still running, what tool it is using right now, how it ended (finished, failed, stopped, or "ended, reason not reported"), and when. Nothing on screen reads these records yet, so this PR changes nothing you can see. The switch is #22614, at the top of this stack (this PR → #22553, the Codex producer → #22565, the shared row → #22614), which points the sidebar and the chat strip at these records and deletes the legacy background-task path this PR deliberately left byte-identical.
Supersedes #21279. #22521 (the child-record contract) has merged, so this PR's base is now
main.Merge order
What Changed
The path from a Claude frame to a record, hop by hop
emit(claude-structured-session-adapter.ts). The background-task tracker observes the frame as before. The journal translator writes the frame's rows. The legacy background-task state is republished (so the parent's own status row lands first). Only after all of that,drainClaudeChildWork(claude-child-work-evidence.ts) collects the evidence this frame produced and calls the newonChildWorkEvidence(sessionId, evidence)dependency. A close clears the tracker outsideemit, socloseSessionandreleaseAcquisitiondrain it afterwards too.structured-agent-session-runtime.ts→structured-claude-runtime-adapter.ts) routes it tohost.publishChildWorkEvidence.publishChildWork) looks up the session's provider.structured-agent-session-status-ownership.ts) forwards it only under the subject the session's parent row actually landed under. If that publish threw, it forwards nothing.orcad-entry.ts,main-process-runtime-service.ts):publishChildWork→agentHookServer.ingestStructuredChildWork.server-ingest-structured-children.ts, now in the server class chain) refuses a subject whose parent row the store does not hold. Otherwise it runs the reducer against the host's canonical store.agent-status-child-work-reconciliation.ts, plus…-evidence-admission.tsand…-evidence-resolution.ts) turns each edge into feat(agent-status): child work records say what the child is doing, how it ended, and when #22521's admission calls (announce/adopt/resume). Records live in memory only and are never persisted or transmitted.What the producer writes, and from which frame
task_started(any kind the tracker keeps)live(task id + spawn call, kind, residency fromis_backgrounded, name/agent type, description){spawn call, 1}, aliasestask_id+tool_use_idtask_updatedwith live contentliveadopt)task_progress(foreground or background)livewithoperation {toolName: last_tool_name, basis: 'reported'},summaryas last message, usage as tokenstool_use(frames carrying itsparent_tool_use_id)operation {toolName, input: hook-lane preview, basis: 'open'}tool_resultoperation: nulltool_resultended(succeeded, orfailedwhenis_error), result text as last messagetask_notificationended(completed→succeeded,failed→failed,killed/stopped→cancelled, unreadable→unknown), summary, usageunknownending refined to the reported outcometask_updatedendedwith the same mapping;patch.erroras last messagebackground_tasks_changedinventoryof the listed background tasksunknown. Foreground children are untouchedresult, or a new turn startingturn-endedunknown; background ones are untouchedtask_startedunder a new spawn call for a task that already endedlivewith the new spawn callchildWorkId, generation + 1, the prior run's outcome kept inpreviousInvocations. The new run's own progress and spawn result keep reaching the record before any roster lists itended/ closesession-endedOwner (
parentChildWorkId). A child-launched shell or a nested agent is owned by the agent whose own traffic made the launching call. The journal translator gains a read-onlychildToolOwner(toolUseId)query that answers through the same tool-origin registry and linkage that stamp journal rows.The eviction-before-outcome case. Claude drops a finished task from
background_tasks_changedbefore itstask_notificationarrives. The roster omission settles the childunknownright away, with no deferral and no timer. The outcome frame then refines it to the reported outcome under #22521's monotone-refinement rule, keeping the firstsettledAt. Admission alone decides a second ending: anunknownone keeps the definite outcome and lands its last message and tokens, and a different definite one is refused asstale-invocation, which the ingest counts as expected (nothing logged).The tracker (legacy row unchanged)
ClaudeBackgroundTaskTrackerqueues an evidence edge at each decision it already makes. The edges are stamped with the host clock when drained, so the tracker's own clock sequence is unchanged. Its publishedstate,settledTasksand fingerprints are byte-for-byte what they were: every existing tracker test passes unmodified.background_tasks_changedhandling moved intoclaude-background-task-aggregate-roster.tsunchanged, to stay under the line limit. The evidence path differs from the legacy row in four deliberate ways:task_progress. The legacy row still keeps usage only for background tasks.result.failed→blockedandkilled/stopped→idle.claude-background-task-restarts.ts) that the legacy row never reads. That keeps the run's progress and its foreground spawn result flowing to the record. A roster listing hands the run, and its spawn call, back to the live map.Bounds
At most 256 live children per session (#21279's bound, which is the tracker's). At most 32 settled children per session: the oldest are removed first, never one that owns live work. Labels go to admission raw: admission owns label clean-up (the one-line fold, the length bounds, dropping a malformed label).
Ported from #21279 (pin
0565294472)Tests for each are included.
Dropped:
invocationId;Why
A child is materialised three times today: the tracker's DTO, a renderer-side conversion, and the hook roster. Each hop loses a fact. This PR gives the host the one record per child that #22614 (the reader switch at the top of this stack) will make every surface read. The record is fed from provider frames, not from the lossy summary, so it keeps what the DTO drops: foreground progress, the tool a child is running, and
failedas distinct fromblocked.Settlement that no single child names (an inventory omission, a turn boundary) is decided in the reducer from the residency the record stores. Every provider lane therefore shares one rule.
Alternatives considered:
Linked Issue
None — part of the structured chat status/orchestration program.
Visual Proof
N/A. Nothing reads the records yet: the sidebar and the chat strip keep their current sources until #22614 (the reader switch at the top of this stack). A test asserts that ingesting child work leaves every published status row identical and fires no status-change listener.
Testing
pnpm tc:node,pnpm tc:webandpnpm tc:cliall exit 0.agent-status-child-work-reconciliation.test.ts(16): the reducer.claude-child-work-evidence.test.ts(8): the tracker's evidence, including a restarted run's progress, its roster hand-back, and a restarted foreground run's own spawn result.claude-structured-child-work-producer.test.ts(3): the real adapter end to end, including a run-count parity test. On a new spawn call, the record'sinvocation.generationequals theattemptthe journal roster stamps on that run's child rows (1 then 2). The same test checks that the restarted run's progress and failed spawn result reach the record while the legacy row stays unchanged.server-ingest-structured-children.test.ts(5): subject-gated ingest on a realAgentHookServer.structured-agent-session-status-feed-child-work.test.ts(2): the feed hop.structured-agent-session-status-ownership.test.ts(+1): the landed-address gate, including a publish that threw.claude-structured-session-integration.test.ts(+1): a real session overagentSession.*hands its subagent to the status sink under the session's own address.orca-runtime-structured-status-sink-wiring.test.ts(+2 assertions): both entry points wire the sink to the ingest.result, roster eviction before the outcome, notification, kill, and auto-resume. At every frame it asserts that the parent state folded from the records equals the one folded from today'sbackgroundTasks, and that raw liveness is equal whenever the main agent is done. Deleting the inventory-omission settle turns it red at frame 11: the records would readworking, while today readsworking/monitoring.task_startedframe, every journal row and the legacy republish precede the evidence delivery. Moving the delivery ahead of the journal turns this red.HEAD:backgroundedgate restoredfailedoutcome mappingunknown→ reported refinementunknownat turn endcloseSession)Bash: npm testopen operation)After the restart fix, the ablations covering the touched paths were re-run at the new head. They are: foreground progress gate, notification ending,
failedmapping, spawn-result ending, inventory omission, new-spawn resume (also reddens the parity test), inventory revival, close drain, owner enrichment, previous-run fence, child-operation decode, and the four above. All are red.Regression. Every test file the changed modules reach:
src/main/claude,src/main/native-chat/agent-session-wire,src/main/agent-hooks,src/main/orcad,src/shared/agent-status*,src/shared/structured-agent-session*, and the structured/agent-session/Claude runtime tests. 482 files, 4,514 tests: 4,489 passed, 1 expected-fail, 17 skipped, 7 failed. 5 of the 7 are the two real-Claude-binary files (claude-structured-real-cli,claude-tui-resume-real-binary), which fail identically (provider close unproven, timeouts) with the tree reverted to the base head. The other 2 (lease-renewerproduction interval,refusal-retryhost oracle) are timing tests that pass when rerun alone (8/8). After the restart fix: the Claude, agent-session-wire, agent-hooks and child-work suites plus the Claude runtime integration were re-run (339 files, 3,336 tests, real-binary files excluded). 3,320 passed. The single failure is the samerefusal-retry90-second timeout, which passes alone.pnpm run check:code-quality:changed: 0 new findings.oxlinton all changed files: clean, and it flaggedmax-lineson two of them during development.pnpm run audit:anti-slop: exit 0.pnpm-lock.yamlis absent from the branch range, and there are no new docs.Platforms: tests ran on macOS; the code is platform-independent (host-clock stamps, no paths, no shell).
I manually tested these changes locally
Automated tests added/updated, or explained why not below
Review
Not verified:
$electroncheck.task_notification.tool_use_idnames the current run is unverified. I key endings on the task id alone for that reason.task_updated.patch.erroron a live child is not mapped toblocked. Both readworking. Producing them belongs to this producer lane, not to the reader switch (feat(native-chat): the chat strip and the sidebar read the host's child records #22614), and is deferred to a follow-up PR at the top of the stack rather than this head: the SDK's routing of a subagent's permission request through the parent session's callback is unverified, so the state will be produced only once a captured transcript proves the attribution (the query it would use,childToolOwner(toolUseId), already ships here). Until then the child row reads working while the pane-level permission prompt — an untouched, separate path — still surfaces. This is not a regression at the switch: the legacy vocabulary has no waiting state, and its "blocked" is the mislabeled terminalfailed, which the records correctly report asfailed. The gap is missing parity with the CLI hook lane, whose children do show waiting.observedAt/firstObservedAtuse the adapter's host clock at drain, not the tracker'sstartedAt. The two differ by microseconds. feat(native-chat): the chat strip and the sidebar read the host's child records #22614's legacy projection from views will carry the record's value.task_progressper child, plus oneturn-endededge per turn. I did not measure it (plan risk 10); feat(native-chat): the chat strip and the sidebar read the host's child records #22614 must measure the publish rate before it publishes records.Decisions and deviations from the plan:
invocationIdis the first run's spawn call, or the task id when a roster names the child before its spawn call is known. The first spawn call seen after that joins the run rather than opening a new one.task_progress.descriptionactivity sentence ("Running Bash") is not stored. It restates the tool, the contract has no activity field, andoperation.inputis the tool-argument preview.task_progressgate is removed for the evidence path only. Removing it from the legacy row would make the chat strip start showing token counts on foreground rows, which is a visible change this PR must not make. feat(native-chat): the chat strip and the sidebar read the host's child records #22614 deletes the legacy row.resultframe from a nested child also sweeps foreground work in the tracker today. The evidence mirrors the tracker's decision so the parent state stays identical.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
main.unknownending's last message and tokens. Two new tests go through the real Claude adapter and the host's own ingest: a child settledsucceededthen given an unclassifiedtask_notificationkeepssucceededand records its summary and tokens; a conflictingfailednotification keepssucceeded, is refused asstale-invocation, and logs nothing. Putting the latch back turns both red. Deleting admission's refine-only outcome rule now turns the first red too, where before only admission's own tests caught it.interrupted(removed from the fold input by fix(agent-status): a cancel never hides live work #22476).orcad), under the parent's full execution scope. The ingest test uses an SSH host and a folder workspace.subagentsis unchanged.server-ingest-structured.ts,structured-agent-session-agent-status.ts, the fold and the renderer bridge are not touched.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)