Repository navigation
fix(claude): a restarted provider continues the subagent roster earlier runs journaled - #23758
Conversation
A backgrounded Claude subagent resumed by a message after its session's provider restarted parents its frames to its ORIGINAL spawn call, while the announcement the new provider sees names only the resuming call. The spawn call's alias lived only in the old provider's memory, so every row the resumed child wrote fell through to its raw call id: one child shown as two, a named roster entry with no rows and an unnamed section holding them. The alias table now also recalls what an earlier run of the session resolved, re-derived from the agent rows it journaled (canonical id beside the call its frames arrived under), read once per bound journal epoch. Nothing new is persisted.
…er runs journaled The roster's state lived in one provider process while everything it writes is the session's. After a restart a resumed child was re-rostered in a second group row with its attempts restarted, its resumed frames lost the alias they still carry, and the one group row no turn owns was rewritten from empty, erasing the children an earlier run had listed there. A run now reads what earlier runs journaled, once per bound journal epoch: group rows give each child's entry and group, agent rows give the spawn-call aliases and the latest attempt. A group is inherited only when this run's own events reach it, and inheriting writes nothing. An inherited child is reopened by an announcement exactly as an in-process resume reopens it, and by nothing else. The alias recall is one facet of that read. Nothing new is persisted.
… in one row
At restart Claude reports each agent the previous session left running as
stopped ("didn't finish before the previous session ended"). The roster ignored
it, leaving the entry unverifiable, while the background-task lane, which never
saw that agent announced, opened a second, unnamed row for the same agent.
The roster now records the verdict on the inherited entry, keeping the time the
earlier run lost contact, and still reopens the entry on an announcement. The
background-task lane leaves any agent an earlier run rostered to the roster,
from the same journal-derived reading.
…stop time A journal reopened after its host died leaves a child unverifiable with no stop time. Stamping Claude's later restart verdict with the current time would show the whole outage as how long the child ran, so the earlier run's stamp, or its absence, is kept. The journal-liveness comment no longer describes the roster as unable to continue from the journal.
An older build could re-roster a resumed child in a later turn's row, so two group rows list it (67 children in 39 local sessions). Inheriting the second row re-pointed the child to that row's copy, so the copy the resume had reopened was left at working and swept to unverifiable, while the outcome landed on the stale copy. The first row reached keeps the child.
A subagent whose host died without sweeping it has no stop time. After a
restart, Claude's own verdict on it ("stopped") is now recorded, but the rule
that hides the group's duration only covered `unverifiable`, so a mixed group
showed the sibling's duration as the whole group's. The rule now keys on the
missing stop time, whatever the settled state.
…ing chose A child an older build listed in two rows was placed in whichever row this run's frames reached first, so a sibling's frame reaching the older row made the resumed child reopen there. Placement now follows the reading's one tie-break, and evicting a row drops only placements that point at it.
Review status: ready for reviewVerdict. The fix works and the review loop is clean. After Claude's process restarts mid-chat, a resumed helper keeps its name, its single roster entry, its real id and its run count; a helper Claude reports as lost reads "stopped" in its own entry with no extra unnamed row; and a resumed helper's clock counts its new run, never the idle time since its last one. Marked ready for review; not merged, auto-merge not enabled. Decision taken during review: a resumed helper is the same helper on a new run. QA found that after a restart the reopened roster row's clock counted the idle gap ("completed · 5m 19s" for about 5 s of work; real journals show gaps of about an hour typically, up to 22 hours). Resolved in this PR ( Intended changes outside a restart. A helper resumed in the same process shows only its new run's time. A row whose helpers (Claude or Codex) ran one after another with a gap shows the time they actually ran. Resuming a helper restarts its part of the row's clock (a one-helper row that read "5m" restarts from 0s), because the row shows each helper's latest run; keeping earlier runs would need a new saved field. Parallel helpers read exactly as before. A resumed helper's work stays under the roster row where it was first started; the turn that resumed it gets no second row, by design. Review loops (4, plus an architecture challenge).
Checks at Merged up to current main (2026-09-29), CI green. Two merge commits (no rebase, no force-push). Main's #23059 now files every roster row beside a turn; this branch had moved group creation, so a group it creates takes the open turn's scope and a group inherited from an earlier Claude process keeps the scope its journaled row was created with, carried through the journal reading (each of those three edits was run red with the change removed, green restored). Main's #23613 moved every chat's journal into one database per host; the restart test now opens its journals through the host-database test support. Root typecheck passes; 62 explicit test files (this PR's and main's own for every file both touched), 792 tests pass; CI at Electron QA (remote macOS, real Claude). Main reproduces the bug (duplicate roster row, "Subagent" captions, rows filed under the raw tool-call id). This branch fixes it, including a background helper when only Claude's process dies and when Orca itself is killed and relaunched, and the resumed helper's clock. Screenshots are embedded in the PR body. Open items decided. Recording Claude's "stopped" verdict: yes, neutral styling. The Not verified. A captured transcript of a real restart proving Claude's first messages always arrive after the journal loads (the journals' timing suggests they do; if not, only that notice falls back to main's behaviour). Mobile UI (no mobile code change; mobile shows counts only). Not caused by this PR, seen in QA. Killing Claude's process shows no notice in the chat. When Orca itself is force-killed, its Claude process and that process's shell keep running as orphans, and the relaunched Orca resumes the same chat in a second Claude process. |
A reopened Claude child now starts a new run: its startedAt is reset to the reopening frame's time on every reopen path (resume after a restart and a same-process reactivation). The group clock reads the union of the children's latest runs, so an idle gap between runs is never counted; an ordinary overlapping fan-out reads the same as before.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review. 📝 WalkthroughWalkthroughThe translator now reads journal linkage to restore Claude subagent aliases, groups, and attempts across provider runs. Resumed entries reopen with a new attempt and start time, while some earlier settlement times remain unchanged during updates. The shared subagent summary calculates elapsed duration from child run intervals, and the renderer hides duration when a non-working child has no recorded settlement time. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The change adds a journal visitor and restart tests for Claude subagent roster continuity. No merge-blocking issue was found in the reviewed changes. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change preserves helper identity within a conversation after restart. No new permission or external access path was identified. Simultaneous restarts and recovery after failed saves remain incompletely established. Retained concerns Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 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 |
|
| /** Once per group: after that this run's copy is the newer one. */ | ||
| private inherit(groupId: string): RosterGroup | null { | ||
| const journaled = this.deps.journaled | ||
| const entries = journaled?.claimGroup(groupId) ?? null |
There was a problem hiding this comment.
Inherited groups cannot be recovered When more than 32 groups are admitted, an inherited group can be evicted from memory. Its saved entries have already been consumed by
claimGroup, so a later task_started for one of its helpers creates a new entry in the current turn, resets the run count, and leaves the original entry behind. If the evicted group is outside-turn, a later admission can recreate it empty and overwrite its earlier helpers. The inherited group needs to remain recoverable after eviction.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run read the complete 27-file diff at 8f11ab7 and traced the roster, journal-reading, background-task and clock paths against the current tree.
- Journaled roster derivation —
ClaudeJournaledRosterre-reads the journal once per bound epoch to recover tool-call → agent id aliases, the latestattemptper child, and the group each child last appeared in.claimGrouphands a group over once. - Group inheritance —
ClaudeSubagentRosterGroupsowns the in-memory group map and inherits an earlier run's group on demand;inheritedClaudeSubagentGroupseeds entries asinvokedInEarlierRunwith the journaled attempt. - Resume semantics —
reopenadvances the attempt, resetsstartedAtto the new run's start and clearssettledAt;revisepreserves an earlier run's stop time when a verdict lands on an inherited child. - Restart-verdict routing —
ClaudeBackgroundTaskRowsdrops a non-started task already listed in a journaled roster row, so Claude's restart notice settles the roster child instead of opening a duplicate unnamed row. - Clock —
summarizeSubagentGroupreturns a syntheticclockStartedAtyielding the union of the children's latest runs (overlaps once, idle gaps excluded); the renderer hides the clock when any non-working child has nosettledAt. - Tests — journal-reading, group-placement and two-process resume suites, plus clock arithmetic tests.
Verification: subagentClockStart brute-forced against a union-of-intervals reference with zero mismatches (settled-only and working cases); the new/updated suites pass (journaled-roster, roster-groups, resume-after-restart, roster, shared summary, background-tasks, reading-control, renderer NativeChatSubagentRun); pnpm run check:code-quality:changed reports zero new findings.
ℹ️ Derived-state edges already documented in the PR
Two consequences of "derived, never stored" are worth a reader's awareness but need no change here. attempt is a lower bound — a run that reopens a child without journaling a row for it resets the next epoch's read to 1. And evicting an inherited group at MAX_SUBAGENT_GROUPS runs onEvicted → sweep(group, true), which can write unverifiable into an evicted row's copy of a child still working in a second (legacy two-row) group. Both are noted in the PR body.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Main (#23059) gave every roster group the turn it is written beside. This branch moved group creation into ClaudeSubagentRosterGroups, so a group this run creates takes the open turn's scope there, and a group inherited from an earlier run keeps the scope its journaled row was created with, which the journal reading now carries.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run re-reviewed the delta since the prior pullfrog review at 8f11ab7: a single merge of origin/main (59e31e2) that reconciles the branch with main's per-group turn scope (#23059). The branch's original eight commits are unchanged; the merge's conflict resolution is the only new code, and it is now part of the presented diff.
- Turn-scope inheritance for journaled rows —
JournaledClaudeSubagentGroupnow carries{ entries, turnScope }, andinheritedClaudeSubagentGroupkeeps the journaled row'sturnScope, so a group resumed after a restart is revised beside the turn that created it rather than the live turn. - Journal reading records the scope —
ClaudeJournaledRoster.readJournaledRosterreadsattribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPEfromvisitItemsWithLinkage, defaulting rows from hosts that predate turn scope to the thread. - Live group creation takes the open turn —
ClaudeSubagentRosterGroupsgainedcurrentTurnScope, wired fromClaudeSubagentRosterDeps.currentTurnScopethroughcreateClaudeJournalTranslator, so a group this run creates belongs to the turn open at creation. - Tests — the merge adaptation is pinned by a new case in
claude-subagent-roster-groups.test.ts("keeps an inherited row beside the turn that wrote it, and puts a new row beside the open turn") and turn-scope fixtures in the journaled-roster and resume-after-restart suites.
Verification: read the full 27-file diff and the merge's combined (--cc) resolution hunks; ran vitest run over the roster, journaled-roster, roster-groups, resume-after-restart and shared-summary suites — 5 files / 77 tests pass. The adaptation leaves the PR's resumed-roster semantics intact: inherited groups keep their journaled scope, the newly created outside-a-turn group stays thread-scoped, and no prior open threads remain.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Main (#23613) moved every chat's journal into one database per host and removed the per-chat test opener; the restart test opens its journals through the host-database test support instead.

ELI5
What broke. In Orca's Claude chat view, when the Claude process behind a chat restarts in the middle of a conversation (for example after a rate-limit error or a crash), Orca forgot which helper agents ("subagents") that chat had already started. If Claude then resumed one of those helpers, the chat showed that one helper as two: its named entry with nothing new in it, plus an unnamed "Subagent" section (or a second entry) holding the resumed work, with its run count reset. Helpers started outside a turn also lost their names, and a helper Claude reported as "didn't finish before the previous session ended" showed up as an extra unnamed background-task row.
Scope. Only Claude chats in Orca's structured chat view, and only after the Claude process restarts mid-conversation. Terminal Claude sessions, Codex, OpenCode and other agents are unaffected. Rows already written by older builds are left as they are.
Now. A restarted Claude process picks up its helper list from the chat's own saved history, so a resumed helper keeps its name, its single entry and its run count. A resumed helper also starts a fresh run, so its roster row's clock shows how long it actually ran, never the idle time since its last run.
What Changed
Before (mechanism). Orca keeps a "roster" of a chat's helpers: one entry per helper, grouped into one row per turn (plus one row for helpers started outside any turn), the link from the tool call that started a helper to the helper's own id, and how many times each helper has run. That roster lived only in the memory of one Claude process. When a new Claude process started for the same chat, it began empty:
claude-subagent-linkage.tsstamped the helper's rows with the tool call's id instead of the helper's id. That is the unnamed second section.After (mechanism). A new
ClaudeJournaledRoster(src/main/claude/claude-subagent-journaled-roster.ts) re-reads, from the chat's saved history (the session journal), what earlier Claude processes of the same chat left: the tool-call → helper-id links, the roster row that last listed each helper and that row's entries, and each helper's latest run count. It reads once per loaded journal (and again only if the journal is replaced), and never before the journal is loaded. Nothing new is saved: the roster derives this from rows the journal already holds, so it cannot disagree with them. The roster then uses that reading:A resumed helper is the same helper on a new run. Resuming keeps the same entry in its original roster row, adds one to its run count, clears its old stop time and resets its start time to the new run's start (
reopeninclaude-subagent-roster-state.ts). This applies to every resume, with or without a restart. The savedstartedAtfield is documented as the start of the helper's latest run.A resumed helper stays where it was started. Its new work shows under its original roster row, pinned where the helper was first started; the turn that resumed it gets no roster row of its own, so one helper is never listed twice.
The roster row's clock counts only running time. The row's duration is now the time covered by its helpers' current runs, with overlaps counted once and idle gaps not counted (
summarizeSubagentGroupinsrc/shared/native-chat-subagent-summary.ts). For helpers that ran in parallel it reads exactly as before. The live clock still ticks while any helper works.Renderer change.
NativeChatSubagentRun.tsxreads that clock and now hides the roster row's run-length clock when any finished helper has no stop time. It already did this for "unverifiable" helpers; a helper "stopped" by the restart notice after its process died is the new case. Without it, the row would show a sibling helper's duration as the whole row's.User-visible after. After a Claude restart mid-chat: a resumed helper keeps its name and one roster entry, its work sits with it, and its run count continues; helpers started outside a turn keep their names; a helper the previous process lost reads "stopped" in its own entry, with no extra unnamed row; and a resumed helper's clock counts its new run, not the hours since its last one.
Also changes outside a restart (intended).
Why
Evidence, from real journals on the development machine.
One failure class, one mechanism. A first version recalled only the tool-call links. That fixed the ids but left the duplicate roster rows, the reset run counts, the erased outside-a-turn row and the duplicate background-task rows, which all have the same cause. So the roster continues from one reading of the journal, and every part of the fix uses that reading rather than a separate guard.
Alternatives considered.
Why a fresh start on resume. A resumed helper is the same helper doing a new run, so it keeps its identity (entry, row, run count) and measures only that run. Orca's Codex roster already stamps a fresh start when a child runs again in a later turn (
codex-subagent-roster.ts:333); the Claude roster now does the same. The row clock follows from it: first start to last stop counted the idle gap as soon as any helper ran twice, while the time the helpers' current runs cover never does and reads the same for ordinary parallel helpers.Re-reading once per loaded journal follows the existing
ClaudeBackgroundTaskIdentityResolver, which also re-derives from the journal whenever it is replaced. Unlike that resolver, this reads the whole journal once rather than one id at a time.How this compares with other agent chat apps. Where the mechanism matches: Claude's "stopped" notice is recorded as a finished, neutral state (not an error); a resumed helper is the same helper on a new run, its time is its latest run only, and its work stays with the row that started it, which stays beside the turn that created it (a row inherited from an earlier process keeps that turn, taken from its journaled row). Where it differs, each difference is intended:
Contract change. The
providerParentReffield (the tool call a row's message named) was documented as "re-minted on every resume, never a join key". The first part is false for helper rows, so the comment is corrected. No reader joins on it; now its own producer reads it back to recall which helper an earlier process linked it to. Rows without it simply yield no link.Performance. Measured on all 1,316 local journals: the first read after a journal loads takes at most 5 ms (a 48 MB journal with 8,260 items); medians are under 2 ms.
Known limitations.
Linked Issue
Linear: STA-8138. Follow-up to #23605. Found while restructuring the transcript in #23752, where these rows landed in an unnamed section instead of with their helper.
Visual Proof
Electron QA on a remote macOS machine with real Claude, main (
bd5dca4406f) against this branch. The chat's Claude process was restarted by stopping its recorded process id; the next message started a new process resuming the same chat. The resume scenario was run on1a0e431cf09, re-run on166fd51fd2d, and re-run on the final head8f11ab7dcdfwith the same roster result; the background-helper screenshots are from166fd51fd2d(nothing on that path changed since).A helper resumed after a restart. Before: turn 2 lists the same helper in a second roster row, and its resumed work is captioned only "Subagent". After: one roster entry, and the resumed work is captioned with the helper's name.
A background helper still running when Claude restarts. Before: the entry reads "unverifiable", plus a separate unnamed "Background task · stopped" row. After: one named entry reading "stopped" (muted), no extra row.
Orca itself killed and relaunched while a background helper ran. After the next message: "stopped", with no run-length clock (the dead process recorded no stop time).
A resumed helper's clock no longer counts the idle gap (final head
8f11ab7dcdf, resumed 165 s after its first run). Before this change the reopened row read "working · 5m 14s" and settled at "completed · 5m 19s" for about 5 s of work; now it counts the new run from 0s and settles at "completed · 4s".Testing
New tests replay scrubbed real journal sequences with synthetic ids across two Claude processes:
Every new test was run red with the code it covers broken and green once restored; the clock tests that pin unchanged behaviour (parallel helpers) pass on both, by design. The clock arithmetic was also checked against a brute-force count on 20,000 random groups and for no jump at settle or at a new helper's start.
Gates: the root typecheck (node, CLI, web);
src/main/claude(real-CLI test excluded), the journal, session-wire and native-chat renderer suites: Claude and Codex, journal, session-wire, native-chat renderer and shared suites pass (real-CLI test excluded);oxfmt,oxlintand the changed-code quality gate.AI Disclosure
Review
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
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)