fix(agent-status): stop a child's work re-dating and un-reading its idle parent - #21399
brennanb2025 wants to merge 13 commits into
Conversation
… and plan the fix
…angs under A structured session's journal holds its own agent's rows and every subagent's, and the session's recency clock was a scan over all of them. So while a backgrounded child worked, the parent's row was restamped to "now" on every frame the child emitted: the settled row read as if the agent had just spoken, and the live child beside it read stale. The stamp it moved is `stateStartedAt`, which is also the acknowledgement clock, so the parent went unread again each time too. The producer marker this branch already lands is the fix. The journal's `lastActivityAt` now advances only for rows the session's own agent produced, and the Claude subagent roster row — written from the parent's context, with no `parent_tool_use_id` near it, yet holding nothing but children's state and rewritten on every child transition — is marked child-produced, because unmarked it kept moving the clock on its own. A parent whose only outstanding work is a child must still read busy, so the status feed rolls the children's status up, and only the status: a settled session with a working subagent publishes `working` while its clock, prompt, tool line and assistant prose stay strictly its own. `attention` outranks the rollup and a session with no turn stays unlisted. The renderer bridge stops passing `stateStartedAt`. The store's entry builder already held that rule, identical to the canonical host-side writer, and the bridge was overriding it with a second, divergent one whose `done` special case is what let a settled row's stamp move. The bridge stays, and so do the publication filters that keep one pane key to one writer; what leaves is write-time policy the renderer should never have owned. Three existing tests changed. One asserted that an idle row's stamp advances on an identical republication — that was the defect written down as intent, and it is now inverted, with the behaviour it was really protecting (two completions in a row) covered directly. One asserted the roster row stays root, on the reasoning that stamping it would hide the subagent list; it does not — the transcript renders every producer's rows and the sidebar's child list comes from the live roster. One asserted a corrected older journal clock also moves the completion stamp; the row was already `done` and stays `done`, which is what the canonical writer does with its own copy of the same session.
There was a problem hiding this comment.
Important
The root directory guard check is failing: this PR adds ATTRIBUTION-parent-recency.md at the repo root, which the guard rejects for any new root-level entry, and verify fails with it. Move the file under an existing tracked directory or drop it before merging.
Reviewed changes
- Journal recency is now the session's own —
applyJournalRowadvanceslastActivityAtonly whenisRootAgentJournalItem(row)(absence of the marker means root), so a child's rows no longer move the parent's clock. - Claude's roster row is stamped child-produced — the "Ran 3 agents" row is written from the parent's context but holds only children's state, and now carries
producedBySubagent: true; the empty-group tombstone stays root. - Status rolls up, and only status — the host status feed publishes
workingfor a session whose own agent settled while an agent-kind background task is still working. - The bridge stops writing
stateStartedAt— the store entry builder's same-state-keeps / state-change-restamps rule applies instead of the bridge'sdonespecial case. - Shared helper + test-bed refactor —
isWorkingSubagentBackgroundTaskon the wire, and the feed test setup consolidated intostructured-agent-session-status-feed-test-bed.ts.
ℹ️ The clock fix and the rollup only cover Claude today
The reducer scoping and the status rollup both key off producedBySubagent, which only the Claude translator stamps. Codex journals child-thread items into the parent session and writes its roster row with no marker, so an idle Codex parent can still be re-dated and re-marked unread by a child — the exact symptom this PR fixes for Claude. The PR body discloses this; flagging it so the scope of the bug being closed is explicit.
Technical details
# Codex child rows remain unattributed
## Affected sites
- `src/main/codex/codex-structured-journal-items.ts:259` — `items.handle(event)` runs for any `threadId`, appending child-thread items to the session journal with no `producedBySubagent`.
- `src/main/codex/codex-subagent-roster.ts:353-356` — the roster row is appended with `options` that carry no marker.
- `src/main/codex/` — `grep producedBySubagent` returns no matches.
## Required outcome
- Either accept the Claude-only scope explicitly where the bug is tracked, or stamp Codex child rows before the clock scoping can claim to cover Codex.
## Open questions for the human (optional)
- Does the issue this fixes reproduce on Codex? If so, is a follow-up planned?ℹ️ Nitpicks
journal-reducer.test.ts: the new "a lifecycle batch a subagent did not produce, and holds on one it did" case builds alifecycle-batchrow withproducedBySubagent: true, but no production builder can emit one —journalLifecycleBatchRowBuilderhas no such option (journal-row-builders.ts:104-108) and the sink's lifecycle append drops it. The reducer handles it correctly; the case just guards a shape that cannot reach the reducer today.structured-agent-session-status-feed-test-bed.ts:StatusFeedSessionsViewextendsMapbut overrides onlygetand[Symbol.iterator], sosize,values, andhasstill read the empty super-map. A future feed read through one of those would silently see zero sessions.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| @@ -0,0 +1,658 @@ | |||
| # Defect A — an idle parent row is stamped "now" by its child's work | |||
There was a problem hiding this comment.
This new root-level file fails the root directory guard job, which blocks any entry not present at the PR base and not in REVIEWED_ROOT_ENTRIES; verify fails with it.
Technical details
# New root-level file fails the root directory guard
## Affected sites
- `ATTRIBUTION-parent-recency.md:1` — new root-level file, not present at base `brennanb2025/attr-parent-label`.
- `.github/scripts/check-root-directory-entries.mjs` — compares base and head trees, rejects every new root entry except `cloud`.
## Required outcome
- No new root-level entry in the PR.
## Suggested approach (optional)
- Move the diagnosis doc under an existing tracked directory (it would need an allow-list entry if placed in `docs/reference/`, since `docs/**` is gitignored), or drop it from the PR if it is a scratch planning artifact.| export function isWorkingSubagentBackgroundTask(task: AgentSessionBackgroundTask): boolean { | ||
| return ( | ||
| task.kind === 'agent' && | ||
| (task.state === undefined || task.state === 'working' || task.state === 'monitoring') |
There was a problem hiding this comment.
This branch is the only reason a Codex subagent rolls up — Codex agent tasks are built with kind: 'agent' and never set state — yet every rollup test passes an explicit state, so a regression here would break Codex silently.
Technical details
# Untested branch: `task.state === undefined` is the Codex shape
## Affected sites
- `src/shared/agent-session-background-task-wire.ts:64` — the absent-state arm.
- `src/main/codex/codex-background-task-tracker.ts:63-67` — Codex agent tasks carry no `state`.
- `structured-agent-session-status-feed-subagents.test.ts:72,99` — the only rollup tests, both with explicit states.
## Required outcome
- A feed test that drives the rollup with the Codex task shape (`kind: 'agent'`, no `state`) so the absent-state arm is pinned.There was a problem hiding this comment.
ℹ️ No new changes since the prior review. The only new commit (
d9269ee) merges basebrennanb2025/attr-parent-labelinto the head, and the PR diff is byte-identical to the one reviewed at5fdaf62. The findings from the previous review still stand unchanged: theroot directory guardfailure onATTRIBUTION-parent-recency.md, the Claude-only scope of the attribution and rollup, and the untestedstate === undefinedarm ofisWorkingSubagentBackgroundTask.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ No critical issues in this delta — one scope note below.
Reviewed changes
- Codex roster rows are now child-produced —
CodexSubagentRoster.writestampsproducedBySubagent: true, so Codex roster churn no longer moves the parent's recency clock. - Claude's roster removal is child-produced too — the empty-group tombstone now carries the marker alongside the roster row it removes.
- Tombstone attribution is threaded end to end —
JournalTombstoneInput,journalTombstoneRowBuilder/buildJournalTombstoneRow,AgentSessionJournal.appendTombstone, both deferred-sink tombstone paths, and the resolvedappendItempath forwardproducedBySubagent. - Tests added — a Codex roster-marker test and a sink test proving attribution survives a tombstone append; the Codex harness now captures the append
optionsit previously discarded. - Docs updated — the PR body and its checked-in copy now describe both providers' roster stamping.
ℹ️ Codex roster rows are stamped, but Codex child-thread items still are not
The Codex change stops the roster row from moving the parent clock, but Codex journals child-thread items into the same session journal with no marker, so an idle Codex parent's updatedAt still advances on a child's prose and tool rows. The doc change in fef2626 removed the earlier disclosure that only the Claude translator attributes today and now presents coverage as provider-complete, which oversells what actually landed — either stamp Codex child-thread rows at the producer (the shared isRootAgentJournalItem predicate is already provider-neutral) or keep the caveat.
Technical details
# Codex child-thread rows remain unattributed
## Affected sites
- `src/main/codex/codex-structured-journal-translation.ts:259` — `items.handle(event)` runs inside the main `handle` for any `event.threadId`, appending child-thread items with no `producedBySubagent`.
- `src/main/codex/codex-structured-journal-translation.ts:288` — the generic-frame fallback (`genericFrames.appendUnhandled(..., event.threadId)`) does the same for any thread.
- `src/main/codex/codex-subagent-roster.ts:353-356` — the new marker covers only the roster row.
- `PR-BODY.md:33-36` — now claims both providers are stamped, with the previous Claude-only caveat removed.
## Required outcome
- Either mark Codex child-thread rows at their producer (the reducer's `isRootAgentJournalItem` scoping is shared and provider-neutral), or restore the removed disclosure that this change covers roster churn and Claude items, not Codex items.
## Open questions for the human (optional)
- Is the Codex child-item gap tracked as a follow-up? If so, the PR body can point at it instead of dropping the caveat.DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…tablyai/orca into brennanb2025/attr-parent-recency
There was a problem hiding this comment.
ℹ️ No critical issues — the delta is a clean merge of
origin/main; one reviewability note below.
Reviewed changes
Since the prior pullfrog review at fef2626, the authored delta is small; the bulk of this run's diff is a merge of origin/main.
- Merged
origin/main(259 commits) into the branch — the only manual conflict resolution isStructuredAgentSessionStatusBridge.tsx, which now importsgetStructuredAgentSessionTabsfrom main's extractedstructured-agent-session-tabs.tsmodule while keeping this PR'sstateStartedAtremoval. Every other file merged cleanly; the branch is a strict superset oforigin/main(only 39 files differ from it). - Added a deferred-sink test —
structured-agent-session-event-sink.test.tsnow pinsproducedBySubagentriding through bothtryAppendResolvedItemandtryAppendResolvedItemAndPublish. - Base reconciliation —
81995c8/4918c1afolded the moved base and the const/observedAttweaks with no net content change to the reviewed files.
ℹ️ The merge folds 259 origin/main commits into this PR's diff
The declared base brennanb2025/attr-parent-label (1136af7) does not contain the origin/main commits this branch just merged. Because 1136af7 is an ancestor of the head, git diff base..head now includes every upstream commit pulled in, and GitHub reports 3,253 changed files — so "Files changed" no longer shows the PR's own ~24-file surface. The merged code is correct (main's changes are all present, and the only resolution is the bridge); this is purely a reviewability cost.
Technical details
# Stack base is behind the merged main
## Affected sites
- Branch topology: head `98d5244` parents `4918c1a` (PR) + `663d670` (`origin/main`); base `1136af7` is an ancestor of the head and predates the 259 merged `origin/main` commits.
- `git diff 1136af7 HEAD --name-only` spans ~3,000 files, almost all of them from `origin/main`; `git diff 663d670 HEAD --name-only` is only the 39-file authored surface.
## Required outcome
- The PR's "Files changed" shows only the PR's own work again.
## Suggested approach (optional)
- Merge `origin/main` into the base branch #21398 as well, so the shared base tip carries main and the stack's diff excludes it, or retarget this PR to `main` once #21398 lands (GitHub retargets automatically when the stack base branch is deleted).DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
|
Superseded by #22520, which fixes the same symptom — a subagent's activity re-dating its idle parent session and marking it unread again — against the producer linkage now on main (#22299), instead of the reverted |

ELI5
When you ask an agent to do something, it sometimes hands part of the job to a helper agent and
finishes its own turn. In the sidebar, the agent that had finished kept jumping to the top of the
list and saying it had just done something — every single time the helper did anything. Its blue
"unread" dot came back each time too, so a row you had already read went unread over and over for
work you never saw.
Now the finished agent's row shows when it actually finished, stays read once you have read it,
and simply shows as still busy for as long as its helper is still working.
What Changed
Before. A session that spawns a helper agent writes both agents' output into one timeline. The
row's "last activity" time was read off that whole timeline, so the helper's output counted as the
parent's. A user watching the sidebar saw the parent row stamped "now" while it sat idle, and the
helper row — the thing actually working — stamped "14m" beside it. The idle row looked live and the
live row looked stale. The same timestamp is what Orca compares against to decide whether you have
read a row, so the finished row also went unread again on every helper frame, and the notification
it produces changed identity with it.
After. The finished row shows the time the session's own agent finished, and holds it. It stays
read once read. While the helper is still working, the parent row reads as busy rather than
finished — so nothing goes quiet just because the work moved to a child.
The mechanism.
The clock stops counting other agents' rows. The journal reducer advanced
lastActivityAtforevery row appended. It now advances only for rows the session's own agent produced, using the
producer marker and the shared
isRootAgentJournalItempredicate that already ship on thisbranch. Two consumers read that clock, and both are the session's status summary.
Subagent roster rows are marked as what they are. Claude and Codex both write child-only roster
rows from the parent's code. Those rows are rewritten on child transitions, so leaving either
provider unmarked still moved the parent clock. Both providers now stamp roster revisions as
child-produced, and Claude preserves that attribution when removing its roster row. Nothing is
hidden by that: the transcript deliberately renders every producer's rows, and the sidebar's
child list comes from the live roster the host publishes, not from this row.
Status, and only status, rolls up. The host's status feed now publishes
workingfor a sessionwhose own agent has settled but whose subagent is still running. The recency clock, the prompt,
the running-tool line and the last assistant message all stay strictly the parent's own. The
rollup is deliberately narrow: only agent-kind children in a working state (a backgrounded shell
is not a subagent),
attentionstill outranks it, and a session with no turn at all is not madelistable by a child.
The renderer stops holding a second copy of a rule.
StructuredAgentSessionStatusBridgepassedits own
stateStartedAtwhen writing the row. The store's entry builder already computes one —same state keeps the stamp, a state change restamps it — and that rule is identical to the
canonical host-side writer's. The bridge's version added a
donespecial case that restampedevery settled row on every publication, which is what let the acknowledgement clock move. The
property is gone; the builder's rule applies. The bridge itself stays, and so do its publication
filters.
Why
The failure was not a formatting bug. One timeline carries two agents' rows, and every "what is this
agent doing right now" answer was a scan over that timeline with nothing on a row saying who wrote
it. Attribution at the producer — already landed for the prose and tool-line readers on this branch
— is what removes the premise, rather than adding a check in front of it.
Alternatives considered:
stateStartedAt. One line, and it does fix the reported screen. Butthe session's published
updatedAtstays contaminated, soworktree ps, mobile and the canonicalhost row still report the parent as freshly active, and the host still re-broadcasts the whole
summary to every subscriber once per child frame. It hides the symptom from one reader.
that. Exact, and cheap, because turns are already root-only. Rejected: it adds a second recency
concept beside
updatedAtinstead of makingupdatedAtcorrect, and attribution yields the sameanswer for free — the parent's last own row is its turn record.
user: a parent whose only outstanding work is a child is not finished. Hence the status rollup,
which is the one thing a child is allowed to say about the row it hangs under.
Disclosure, because it is the honest shape of this change rather than a footnote: Orca keeps two
writers of one status row apart with publication filters; this change does not remove that
structure. A renderer component still writes structured pane keys, which the store's own guidance
says a reader should not do. What this change removes is the renderer's policy — after it, the
structured path has one
stateStartedAtrule instead of two, and they agree. Consolidating thewriters is a separate, already-planned change, and it gets easier for this one having landed, not
harder.
One further limit worth stating: attribution here is a convention, not a type. A future append site
that forgets the marker re-opens the hole, and the mitigation is the producer-boundary tests below
rather than something the compiler can enforce.
Linked Issue
Fixes #
Visual Proof
Validated in the exact PR worktree with an isolated background Electron session. The first image
shows the parent row still
Workingwhile its subagent is active; the second shows the same rowsettled to
Doneafter the child reports back.Testing
pnpm tcclean.pnpm exec oxlintclean on every changed file.pnpm run check:code-quality:changedpasses — 0 new findings across 37 changed files.Suites run green:
src/main/native-chat,src/main/claude,src/main/codex,src/main/runtime,src/shared,src/renderer/src/store,src/renderer/src/attention,src/renderer/src/components/sidebar,src/renderer/src/components/native-chat,mobile.Electron validation launched this branch with
ORCA_BACKGROUND_LAUNCH=1, attached over CDP, andverified the app identity before interaction. A real Claude session spawned a real subagent from
the UI. While the child worked, the visible parent row stayed
Working, the backing entry exposedthe child as
working, and itsstateStartedAtheld at1789973127195while later child activityadvanced
updatedAtto1789973140619. The row then visibly settled toDoneafter the childcompleted. The renderer console had no errors.
Every new test was proven red by removing the corresponding production change at the final head and
green with it restored:
journal-reducer.test.ts— the session's recency clock (own rows advance it, a child's do not, on both the item and lifecycle-batch paths)lastActivityAtstructured-agent-session-status-feed-subagents.test.ts— an idle session's clock and publication count hold still across five child rowsstructured-agent-session-status-feed-subagents.test.ts— a session with a working subagent readsworking, claims no tool of its own, and ignores a backgrounded shell or a settled childclaude-subagent-roster.test.ts— every roster row is child-produced, first write and each revisioncodex-subagent-roster.test.ts— Codex roster rows are child-producedstructured-agent-session-event-sink.test.ts— child attribution survives a tombstone appendclaude-structured-journal-translation-subagents.test.ts— the roster row is stampedStructuredAgentSessionStatusBridge.test.tsx— a settled row holds its completion stamp, its attention timestamp, and its read state as the host clock advancesstateStartedAtStructuredAgentSessionStatusBridge.test.tsx— a restored completion is stamped with host journal time and the bridge sends nostateStartedAtTwo harness details worth a reviewer's eye, because both would have produced a test that passes
either way:
and the attribution assertion would have passed against the unfixed code. It now captures options.
ts, soMath.maxmoved nothing and the test passed under ablation on a re-run. It now drives an explicitadvancing journal clock and fails under ablation on every run.
Three existing tests changed, which is worth saying plainly rather than leaving in the diff:
StructuredAgentSessionStatusBridge.test.tsx, "sorts restored completions by host time andadvances identical turns" — split. The restore half is a real fix from another change and is kept
verbatim. The half asserting that an idle row's stamp advances on an identical republication is
inverted, because it is the defect written down as intent: it contradicts the canonical
host-side writer, it contradicts the documented invariant in the timestamp's own module, and its
final assertion is a same-state ping re-triggering attention, which the acknowledgement module
states outright cannot be allowed to happen. What it was really protecting — two completions in a
row must not leave the row frozen on the first — now has its own test that drives the turn
directly.
claude-structured-journal-translation-subagents.test.ts— asserted the roster row stays root, onthe stated reasoning that stamping it would hide the subagent list from the parent. It does not:
the transcript projection is deliberately unscoped and the sidebar's children come from the live
roster. Inverted, with the reason recorded next to it.
StructuredAgentSessionStatusBridge.test.tsx, "accepts an authoritative older journal age aftera host upgrade reconnect" — the corrected clock still lands on
updatedAt, which is the point ofthe test. The completion stamp no longer moves with it, because the row was already
doneandstayed
done— which is exactly what the canonical writer does with its own copy of the samesession. One assertion updated.
Also refactored for the line budget rather than suppressing it: the status feed's test setup moved
into a shared
-test-bedmodule used by both feed test files, and the old-test-sessionhelper folded into it. That removed a pre-existing double type assertion in theprocess — the fake session map is now a real
Mapsubclass.Platforms: the change is platform-independent (journal reducer, host status projection, renderer
store write). Verified on macOS.
AI Disclosure
Claude Opus 4.5.
Review
Worth a second opinion on two judgement calls:
workingormonitoring, andfor a child whose state is absent (an older host's live task).
waitingandblockedaredeliberately excluded: a child that wants something is not a parent that is busy, and carrying
them would be claiming the child's status rather than reporting outstanding work.
worktree ps, mobile and thesidebar cannot disagree about one session. The alternative was the renderer, which would have
meant adding renderer policy in the same change that removes some.
Agent skill upstream boundary
Notes
published values change meaning, which the wire-compatibility guidance treats as a wire change
even with no codec movement: a session's
updatedAtnow derives from its own agent's rows only,and its
statuscan beworkingbecause of a child. Both are values every existing clientalready renders, and the new meaning is the corrected one, so an older paired client reads it
without a capability gate and simply shows the fixed behaviour.
as the session's own. This is forward-only and self-heals on the session's next turn; nothing is
backfilled.
so it can render "40m" while its current invocation is seconds old. That is a separate latch in
the roster, it needs its own evidence, and the apparent version of it in the original report — an
idle parent reading "now" beside a child reading "14m" — is resolved by this change, because the
two now read coherently.
its whole status summary to every local and remote subscriber once per child frame.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)