Skip to content

fix(subagents): stop resurrecting finished subagents as new spawns - #142

Merged
devsuitup merged 1 commit into
mainfrom
fix/subagent-resurrection
Aug 22, 2026
Merged

fix(subagents): stop resurrecting finished subagents as new spawns#142
devsuitup merged 1 commit into
mainfrom
fix/subagent-resurrection

Conversation

@devsuitup

Copy link
Copy Markdown
Owner

The bug

Reported from real use: the moment a new subagent starts, every historical subagent row in the sidebar lights up as "running" — except the one that had just finished. It settles again after 30–60s.

Mechanism: session.knownSubagents garbage-collected finished agents 5 minutes after completion, but their agent-<id>.jsonl files stay on disk. On the next scan they were "unknown" again, and the post-bootstrap spawn branch had no age filter — so every historical file was announced as a fresh spawn. The agent that had just completed was the only one spared, because its entry was still in the map marked completed. Hence "all of them except the real one".

Side effect beyond the cosmetics: readSubagentMeta was re-read for every historical file, in a burst, on the main thread, each time a subagent started.

A test reproduces it exactly on the old code: expected 1 spawn, got 31.

The fix

  • Age filter on every first sighting, bootstrap or not: an unknown file whose mtime is already stale is registered silently as completed — no meta read, no IPC.
  • The filter is reversible. A finished agent's file never grows again; a live one does. A stale-looking file discovered outside bootstrap keeps a recheck window, and if its mtime advances it is rehabilitated and the withheld spawn is emitted. This matters because scan flushes are driven by a shared, unbounded debounce: a genuinely new agent can be discovered late during a burst of parallel writes, and freezing that verdict would silently kill its indicator for the rest of the session.
  • GC only forgets what left the disk, instead of dropping entries whose files persist. The map is now bounded by the directory contents, and the old GC saved nothing anyway — the next scan re-stat'd and re-read everything it had just discarded.
  • Subagent scan state is reset when a session's real id switches (fork/resume), instead of scanning a new directory through the old cache.
  • Renderer, defence in depth: a heartbeat now refreshes only an entry that is already tracked, and never resurrects one. Both sidebar and grid view.

Also fixed here

grid-view.js subscribed with (event, data) while preload.js invokes callbacks with the payload alone — data was always undefined and the guard returned immediately, so the grid's live subagent pills have never worked since they were introduced. Fixed and covered by tests; the heartbeat guard is applied there too, since the handler now actually runs.

Tests

11 new tests. On the base commit, 10 of them fail — including the 30+1 reproduction and the renderer inversion test (three rows, one active, none left running after completion and a full re-render). A pre-existing flaky test (concurrent monitoring, busy-waiting on Windows filesystem mtime granularity) was made deterministic; coverage is unchanged.

task check: 601 tests, 594 pass, 0 fail, 7 pre-existing skips, 0 lint errors.

Rationale in .ai/contexts/subagent-observability.md, including an explicit "what is guaranteed and what is not" section — the 30s stability window remains the only completion signal, so a subagent silent longer than that can still be called finished while alive. Real liveness would have to come from the parent process, not from file mtime.

Known residual (follow-up, not blocking)

If a batch of historical files is discovered late, after bootstrap has already been consumed on an empty directory, each gets a recheck window — and an accidental touch on one of them within 30s would emit a single spurious spawn. Far narrower than the original defect (which fired unconditionally, for every agent, forever) and self-correcting. Worth extending the mass-spawn test to the post-bootstrap case.

An agent-<id>.jsonl is never deleted, but knownSubagents dropped completed
entries five minutes after completion. The next time the subagents dir mtime
moved — which is exactly when a new subagent starts — the whole directory was
rescanned, every dropped file came back as unknown, and each one was announced
as a fresh spawn. The running dot lit up on every historical subagent except
the one that had just finished, and readSubagentMeta re-read every transcript
on the main thread.

Record an unknown file whose mtime is already stale as finished, silently, and
forget an entry only once its file has left the disk, which bounds the map by
the directory's own size. Post-bootstrap that staleness verdict is only an
assumption: flushChanges' debounce is shared across PROJECTS_DIR and unbounded,
so a burst of parallel subagents can delay the first sighting of a live agent
past the threshold. Such an entry therefore keeps a recheck window and is
rehabilitated — with the spawn that was withheld — as soon as its file grows;
the window closes once the file has been seen motionless for a full stability
window. Historical files never move, so they never take that path.

A fork/resume re-key now also clears the subagent scan state, since it switches
to another directory.

Renderer side, a _heartbeat refreshes an agent already tracked but never
creates one, in sidebar.js and grid-view.js alike. Fix the grid-view IPC
handlers' arity while there: preload passes the payload as the callback's only
argument, so (event, data) saw data undefined and the grid pills never
rendered at all.
@devsuitup
devsuitup merged commit ed78962 into main Aug 22, 2026
7 checks passed
@devsuitup
devsuitup deleted the fix/subagent-resurrection branch August 22, 2026 15:20
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.

1 participant