Resumed subagent loses completed status after app-server restart
Summary
When a persisted multi-agent v1 thread tree is resumed after the thread manager or app-server process restarts, descendant threads are reloaded from their rollouts but their in-memory AgentStatus starts at PendingInit.
A completed child therefore loses its persisted Completed(Some(message)) status. A subsequent wait_agent does not return the child's final reply and can time out even though the child completed before the restart.
Reproduction
- Start a root thread with SQLite persistence enabled.
- Spawn a v1 child agent.
- Let the child complete with a final message and flush its rollout.
- Shut down the thread manager/app-server cleanly.
- Create a new manager over the same state and resume the root rollout.
- Read or subscribe to the resumed child's status.
Expected: Completed(Some(final_message)).
Actual: PendingInit (or a wait blocks until timeout).
The child rollout may end with ShutdownComplete; that event represents the prior in-memory session lifecycle and should not erase the preceding turn outcome.
An unfinished persisted TurnStarted should resume as Interrupted, since that turn cannot still be running in a new process.
Proposed fix
Reconstruct the initial status for InitialHistory::Resumed from the latest meaningful status-bearing rollout event, ignoring ShutdownComplete, and convert a trailing Running state to Interrupted. Add restart coverage that asserts descendant final messages survive shutdown/resume.
4 Comments
I implemented and validated the proposed fix in commit
5daeddc5aonharjotgill:agent/restore-resumed-agent-status.The patch is ready for maintainer review, but GitHub currently rejects both CLI and GitHub App attempts to open the cross-repository PR because external PR creation is not permitted for this account. A maintainer invitation or an internal cherry-pick is required by the repository's contribution policy.
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
This sounds useful after a crash
Analysis (community)
Residual analysis for #34220 (resumed subagent loses
Completedstatus after restart) under docs/contributing.md — invitation-only. No unsolicited PR.Root-cause hypothesis
Your trace is exactly right, and it can be pinned to three lines: the
AgentStatuswatch channel is initialized unconditionally atPendingInitinsidespawn_internal(codex-rs/core/src/session/mod.rs:718) — a constructor every session type traverses, includingInitialHistory::Resumed— and the only writers are live-event paths (mod.rs:1914terminal-turn notify,mod.rs:2086deliver_event_rawviaagent_status_from_event). Nothing in the resume branch folds the rollout's terminal events back into the channel, so a child that completed before shutdown is observed asPendingInitforever. Verified at HEAD4c43465133:1. The status derivation already exists — it's just never applied on resume.
agent_status_from_event(core/src/agent/status.rs:6-21) mapsTurnComplete → Completed(last_agent_message),TurnAborted → Interrupted/Errored,Error → Errored,ShutdownComplete → Shutdown;is_final(:23-28) excludesPendingInit/Running/Interrupted. Reconstructing status on resume is therefore a fold of an existing pure function over the trailing rollout events — no new state machine.2. Your
ShutdownCompletecaveat is structurally correct, not just defensive. Becauseis_finaltreatsShutdownas final, a naive "replay the last status-bearing event" would let a trailingShutdownComplete(the prior process's lifecycle marker) overwrite the precedingTurnComplete, erasingCompleted(Some(final_message))— the exact failure your reproduction guards against. The fold must prefer the last turn-outcome event (TurnComplete/TurnAborted/Error) over a trailingShutdownComplete. Your branch5daeddc5agets this right.3. The
wait_agenttimeout is downstream, not a second bug. The completion watcher loopswhile !is_final(&status)(core/src/agent/control.rs:457, with the post-loop re-check at:468), so a resumed child stuck atPendingInitis indistinguishable from a child that never started —wait_agentblocks the full timeout even though the final message is sitting in the rollout. Seeding the channel on resume fixes the wait path with zero changes to those loops.4. One boundary the fix must respect (flagging because it wasn't spelled out in the issue): the seeding must apply only to a cold
InitialHistory::Resumed. When the app-server reconnects to a session that is still live in another process, the reconstructed history can end mid-turn; folding that to a terminal status would wrongly mark a running agentInterrupted. A cheap discriminator: a trailing unfinishedTurnStarted(no matchingTurnComplete/TurnAborted) maps toInterruptedonly when the rollout's owning process is gone — which is precisely your "unfinished persistedTurnStartedshould resume asInterrupted" proposal, stated as a liveness condition.Fix outline (all OSS, codex-rs): in the resume path after
reconstruct_history_from_rollout/replace_history, add a helper inagent/status.rsthat foldsagent_status_from_eventover the trailing rollout events with the turn-outcome-over-ShutdownCompleteprecedence above, andsend_replacethe result intoagent_status_tx. Fail-first restart coverage: (1) child completes → clean manager shutdown → resume root →agent_status()of the resumed child isCompleted(Some(msg))andwait_agentreturns immediately with the final message (today:PendingInit, blocks to timeout); (2) rollout endingTurnCompletethenShutdownCompletestill yieldsCompleted, notShutdown; (3) trailing unfinishedTurnStartedfrom a dead process yieldsInterrupted, notRunning. Happy to draft the test patch if a maintainer wants it — though reporterharjotgill's validated branch5daeddc5aalready implements the core, and per the invitation-only policy the realistic landing path is a maintainer cherry-pick.Adjacent note (same resume surface, distinct defect — not part of this fix): V2 agent metadata restore re-registers every persisted
Openspawn edge across each resume, and the V2 LRU eviction path (core/src/agent/control/residency.rs:117-151,try_unload_one_resident) unloads the resident thread without releasing the registry slot, so the three count surfaces (registrytotal_count,list_agents= tree ∩ live threads, env-context edges) can diverge and inflate monotonically over repeated resumes. The mainstream V2 spawn path is uncapped so this does not block spawns; it shows up as wrong logical counts inlist_agents/env-context. Mentioning for triage only — the reconciliation fix (derive model-visible counts from a single registry ∩ live-threads projection) belongs on its own issue. No unsolicited PR from me.