Skip to content

feat(pi-subagent): keep idle agents across parent reload and restart - #121

Merged
zacheryph merged 11 commits into
mainfrom
claude/work-12-keep-agents-across-reload
Nov 10, 2026
Merged

zacheryph merged 11 commits into
mainfrom
claude/work-12-keep-agents-across-reload

Conversation

@zacheryph

Copy link
Copy Markdown
Owner

Step 12 of the subagent work roadmap. Stacked on #120 (step 11), then #119, #118, #117, #116, #115, #114 and #113. This is the last of steps 6 to 12.

What

A parent reload no longer stops waiting agents, and a restart reports what it lost.

  • Shutdown: session_shutdown used to run abortAll, which cancelled every waiting child's items. It now runs SubagentWork.dispose, then SubagentManager.suspend(), then dispose(reason). suspend cuts a queued or running run short, so the agent reads idle with outcome interrupted. abandonWork, workChanged and finish do nothing while suspended, so nothing is cancelled or reported. The parent's saved record keeps reading queued or running, which is how the next start recognizes the cut-short run. ESC and subagent_stop still go through abortAll and still cancel.
  • Per reason: on reload, children that hold open work are told reload, so pi-job keeps their jobs in its reload handoff. On quit, new, resume and fork, children are told quit and their jobs die. The next start finds the items open, reports jobs and subagents lost, and still adopts questions. A done agent, and every child of a delegated session, gets quit.
  • Persisted: the v2 record gains an optional waitingOn (labels of the idle agent's open items). Older records read as before. A running or queued agent is checked from its transcript instead.
  • Startup: StartupRestore is the last of pi-subagent's session_start handlers. It runs for root sessions on reload, startup and resume only. It adopts the parent's own items for candidate agents, reopens the sessions, settles, and writes one report.
  • Reopen without a run: a candidate agent's session is reopened through createSubagentSession with its transcriptPath, with no execution.admit and no turn loop, so it takes no concurrency slot and starts no model run. The child's session_start reason is reload after a reload, which pi-job needs to take back its jobs, and resume after a restart. pi-ask adopts its questions either way. At most four are reopened at once. Whatever nobody adopts is closed as lost by the child's host, which writes work:lost into the child's own transcript.
  • One parent message: lost work and interrupted runs go to the parent in a single subagent:lost message, sent with triggerTurn: false, so it wakes nothing. Interrupted records are corrected and saved so a second start doesn't report them again, and the parent's items for reported agents are cancelled silently.
  • Wake: a message that arrives during startup, such as a job that ended in the reload gap, wakes the agent through the limiter. Messages already in a transcript before a restart wake nothing. Nothing restarts on its own.
  • pi-job (vendored, has a CHANGELOG entry): Owner.parent is recorded at reload suspend, and the parent's session_start no longer warns "awaiting reload handoff; ownership is unknown" about its own children. pi-job's session_start runs before pi-subagent reopens them, so the manual TUI check showed that warning on every /reload.

Added in review

Two review rounds each found something worth fixing before this went up:

  • A hung child could block the parent's startup forever. Reopening an agent now has a 20-second bound (RESTORE_TIMEOUT_MS, injectable for tests, not a setting). On timeout the agent is reported as one that "could not be reopened at startup, so its work was not checked", with its record untouched and its adopted item cancelled. It occupies a lane for the bound at most.
  • A late session could corrupt a reopened transcript. If a timed-out child's session finally arrived after subagent_send had reopened the same transcript, it ran its host startup against the shared file. In the reviewer's probe it wrote a second work:lost and forked the entry tree, and the whole subagent_send exchange was missing from the reopened branch. createSubagentSession now takes an isStale check, with a second layer in ChildWork. A stale creation writes nothing, skips adoption and settleStartup, and publishes no disposed event. A restore that completes after shutdown disposes its session and registers nothing.
  • The subagent:lost message's details was unbounded (13.7 MB for 500 agents with 50 items each). It is now capped at 30 agents and 8 items per list with clipped text, keeping totals. Quotes and backslashes in labels are escaped in the report text.

Tests

  • Real sessions (restore-sdk.test.ts): reload with a child waiting on a real job_watch (still waiting, no model request, the job wakes it through the limiter, the parent hears once), a job that ends in the reload gap, a question across a reload, a nested subagent (the grandchild's item is lost, reported, and the child is told), a run interrupted by a reload, a double reload, a missing transcript, six agents with maxConcurrent: 2 (no run and no slot at startup, then all wake), a restart by copying session files (the lost job is reported in one message, the question is adopted, no model run, both agents take subagent_send), and quit, new, resume and fork shutdowns each followed by a restart. A child that hangs in session_start covers the bound and the late session in both arrival orders.
  • Unit tests: restore.test.ts (including four fake-timer tests for the bound), lost-report.test.ts, transcript-work.test.ts, the restore handler, and the spawner-side adoption.
  • Rewritten, each with a comment: the shutdown-order test in lifecycle.test.ts (it pinned abortAll; it now pins suspend and the child shutdown reason per session-end reason), the restart test in work-sdk.test.ts (it expected a host work:lost for an agent still running at the crash; it now expects an interrupted run in subagent:lost), and records-sdk (a question across a reload now reads waiting: answer). No .skip or .only was added.
  • Manual TUI check, done: with the tui-screenshot driver I ran a scripted scenario where a child runs job_watch, ends its turn, and the parent shows waiting: CI. After /reload the Subagents widget still showed waiting: CI and 1 waiting agent, with one "Reloaded" line and no handoff warning. With the watch set to match about 40 seconds after load, the capture afterwards showed the child woken (1 running agent, thinking…). The reviewer re-ran the reload half independently.

Verification

  • Tests: pi-subagent 2712, pi-work 111, pi-ask 90, pi-dashboard 113, pi-todo 298 and pi-job 169 (166 pass, 3 skipped as before) pass. Root scripts 33 pass, and the full root npm test exits 0, run twice by the reviewer.
  • Checks: npm run check and npm run verify:bundle pass on the final commit.
  • Mutations: about 40 across the review rounds. The core ones are all caught: reload still cancelling, lost not reported, the report sent twice, adoption missing, a reopen starting a run or taking a slot, the interrupted outcome missing, reload told as quit, waitingOn not persisted, the bound and late-session disposal removed, and each stale guard.
  • Flaky pi-job test: the root npm test failed intermittently on pi-job's job_list, job_read and job_kill. It is a pre-existing timing race in that test, unrelated to this stack: the test is byte-identical on main, and the reviewer reproduced the failure on a clean main checkout under CPU load. It passes in the runs recorded above. I'm opening a separate PR against main to fix the test.

Known gaps

None blocking:

  • A late session can still delete services other extensions registered for the live session. The late session's bind-time handlers call provide(sessionId, …) for their own services (pi-stash's stash:service, pi-git's), which overwrites the live session's entry, and the late session's withdraw then removes it. Lookups for those services by the child's id fail until the child restarts. The child's own tools keep working. It needs a hang of more than 20 seconds, a subagent_send in that window, and the hung session finishing later. work:v1 and work:parent are unaffected.
  • Two of the stale checks (after the loader reloads, and after the session is built) are untested, because the fixture only hangs inside the child's session_start. Extension writes made directly during bind, such as pi.appendEntry, aren't covered by the stale check. None of the bundled extensions do this.
  • Startup waits for reopening, a few agents at a time with the 20-second bound each, so a parent with many waiting agents starts more slowly.
  • A reload gap longer than pi-job's five-minute owner expiry still loses the job, and so do the jobs of a child that the new parent never reopens.
  • Records written before this step have no waitingOn. They are treated as done, so their open items are reported lost the next time the agent is reopened by subagent_send.
  • subagent:lost has no custom renderer. It shows as Pi's default custom message, like work:lost.
  • A fork's copied agents are reported lost by the root host's generic message, because a fork doesn't adopt them.
  • For step 13: the host's lost wording ("when this session started") for a reopened child, the Run.answering and abandonWork paths that steps 10 and 11 added, rebuildFromSaved (still the lazy path for non-candidates), and abortAll wording in older CHANGELOG entries.

Stack: #113 ← #114 ← #115 ← #116 ← #117 ← #118 ← #119 ← #120 ← this


Generated by Claude Code

claude added 11 commits November 9, 2026 16:44
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 6.

subagent_start always runs in the background, and a resume does too.
subagent_get has no wait. Remove claims, the superseded-outcome slot,
spawnAndWait and foreground-runner.ts. The completion message is the only
carrier of an outcome. Tool descriptions, docs, skills and child prompts say
to stash_put large results and name the handle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 7a.

Agent statuses become queued, running and idle. How the last run ended
(completed, turn limit, error, stopped or interrupted) and its result are
stored beside the status. Stop leaves the agent idle. Every run, including a
resume, goes through the concurrency limiter, and a current-run guard stops
a stopped run from overwriting the run that replaced it. Persisted records
get a v2 shape; the reader maps v1 statuses, and a saved queued or running
record reads as idle and interrupted. Tool, widget and event output keep
today's status words.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 7b.

Add subagent_send(agent_id, message). It steers a running agent, joins a
waiting run's prompt when the agent is queued, and queues a run for an idle
agent of any outcome, reopening its transcript if the session was released.
An idle agent that never had a session gets a first run with its original
task and the message. Remove subagent_steer and subagent_start's resume.
A run stopped while its session is being created now disposes the late
session, and a send in that window is refused until it unwinds. Roster
lines show idle with the last outcome, and subagent:queued is a new event.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Fixes found in review of the work contract (roadmap steps 4 and 5), needed
before subagent results flow through the host.

- complete() writes the close record only once the message has landed in the
  transcript. Pi holds a message sent mid-run until the turn ends, so a crash
  in that window no longer leaves a closed item with no message. The root
  host checks the branch after each send and at run boundaries, matching only
  entries after a mark taken at send time, and gives up on a wait once the run
  has settled or the leaf has moved off the branch.
- A disposed Delivery refuses the message, and complete() leaves the item
  open so the next start reports it lost.
- pi-job: adopt completes a job that ended in the reload gap instead of
  cancelling it silently. Tests added for /job kill cancelling its item and
  for the before_agent_start fallback release.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 8.

Spawning an agent, or sending it a message that starts a run, opens an item
in the spawning session's work host. The run ending completes the item with
the same completion message as before. parent_notify goes through notify,
stopping an agent sends its stopped message and cancels the item, and
shutdown leaves the item open so the next start reports it lost.
NotificationManager's queueing is removed; the host holds a message while
the parent runs and the parent answers it in that run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 9a.

Each child session gets a work:v1 host, offered before its extensions bind.
When a run ends with open work (a job, a watch, an agent of its own), the
agent goes idle and waiting instead of done, and its spawner hears nothing
until the work is finished. The work's completion wakes the child with a
run that goes through the concurrency limiter, started from a short fixed
prompt so the transcript stays the inbox. Stopping an agent cancels its open
work; an error or turn limit leaves it open, so a reopened agent is told it
was lost. Waiting is shown in the widget, status bar, roster, watch overlay,
subagent_get and the dashboard, with the items' labels and no clock. The
retention sweep and clearCompleted skip agents with open work. pi-work gains
an additive onChange and a runtime-only cancelOpen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Factor the check that decides whether a work message has landed in the
transcript out of ensureWorkHost into createLandingTracker, so a managed
child host can use the same polling points, marks and settle expiry as the
root host. The root's behaviour is unchanged, and only the instance that
provides a host holds a tracker. The tracker can also tell whether the
transcript ends with a delivered work message nobody answered. That check,
and the matching one in continuesRun, now skip other extensions' custom
messages and branch summaries instead of looking only at the last message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 9b.

A message that arrives while a managed child is running is held by Pi until
the turn ends and answered in the same run. The child host now confirms
landing, so a message Pi still holds does not close its item, and a crash
before the turn ends reports the item lost. At agent_settled a delivered
message nobody answered wakes the agent through the limiter instead of
leaving it done. A wake that meets a compaction waits for it to finish.
Tests cover mid-run delivery in real sessions and fail if any run in a
managed child starts outside the manager.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 10.

In a child, ask opens a question item in the child's work host, tells the
parent once through the agent's item, and ends the turn. The child is then
idle and waiting on the answer and keeps its session. The parent's next
subagent_send closes the child's question items and becomes the run's
prompt. pi-ask adopts its open question items on session start. This
removes the ask:route record path, pendingQuestion as an outcome field, the
roster's "waiting for your answer" case and the question carve-out in
parking. A new work:parent service lets pi-ask in a child reach its parent.
The root session's ask still asks the user.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 11.

One predicate, Subagent.isDone (idle with no open work), now gates every
retention path: the sweep, clearCompleted and releaseSession. A waiting
agent keeps its session whatever it waits on (a job, a watch, a subagent of
its own or a question), and a done agent is released a window after it
became done. subagent_send reopens a released agent from its transcript.
A failed, turn-limited or stopped run that still held work now cancels it
before the session is released, and tells the agent what was called off
with a work:called-off message in its own transcript, so a reopened agent is
not left waiting for work that died. The retention settings keep their keys
and defaults; their labels now say whether the result was read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Roadmap docs/roadmaps/subagent-work-lifecycle.md, step 12.

A parent reload no longer stops waiting agents. Shutdown suspends instead of
aborting: a run still in progress ends as interrupted, and an agent waiting
on a job or a question keeps its open items. At the parent's session_start,
agents that held open work have their sessions reopened without a model run
and without taking a concurrency slot, so pi-job can take back its jobs and
pi-ask its questions, and the parent's own items for them are adopted. A
reload tells such children reload so pi-job keeps their jobs; quit, new,
resume and fork tell them quit. After a restart, jobs and subagents cannot
be taken back and are reported lost, and questions stay open. Lost work and
interrupted runs are reported to the parent in one subagent:lost message,
and written into each agent's own transcript. Reopening an agent is bounded
at 20 seconds so one hung child cannot block the parent's startup, and a
late session from a timed-out reopen is discarded without writing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mbvfwx8mE8ErNuTBrBH9Q5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants