Skip to content

fix(subagent): give the liveness signal a clock and make completion reversible - #153

Merged
devsuitup merged 1 commit into
mainfrom
fix/subagent-completion-tick
Aug 23, 2026
Merged

fix(subagent): give the liveness signal a clock and make completion reversible#153
devsuitup merged 1 commit into
mainfrom
fix/subagent-completion-tick

Conversation

@devsuitup

Copy link
Copy Markdown
Owner

detectSubagentTransitions() owns spawn, heartbeat and completion, but it only ran from the projects watcher's debounced flush. A function that runs when a file changes cannot see that a file stopped changing.

Measured in the app's own main.log, both failure directions:

Too late. Agent a9be19fb0a7e0e504 last wrote at 23:42:18; its completion was logged at 23:52:55 — 10 min 37 s for a 30 s stability window. The parent was idle (Claude is waiting for your input at 23:43:35), so nothing wrote in the folder and no flush ran. The event only fired when activity resumed, which is also what a user sees: the indicator clears the moment you start typing again, not when the agent finished.

Too early, and permanently. Three agents were declared complete while still working:

23:54:43 spawn a8e8c25f42a65b026 → 23:56:53 complete   (still writing at 00:02)
23:55:16 spawn a19dcbbb23270de85 → 00:00:27 complete   (still writing at 00:01:45)
00:11:29 spawn a412bb1bce6ecd952 → 00:14:18 complete   (still running)

A tool call longer than STABLE_MS is indistinguishable from a finished agent — that limitation was known and documented. What was not: the verdict was irreversible. The completion branch set completed = true with no recheck window, so the entry hit the completed fast path forever and the agent could never be announced again. That is why a session with several live subagents ends up showing no running indicator at all.

This is also why the tick alone would have made things worse: giving the stability window a clock makes false completions more frequent, not less. The two had to be fixed together.

Fix

  • Main process: a self-arming settle tick (SETTLE_TICK_MS = 5 s) that only arms while an entry is uncompleted, re-arms from its own sweep, stops on its own, and is unref()d. No cost when no subagent is running.
  • Main process: a stability completion now keeps a recheck window (LIVE_RECHECK_MS), so renewed growth rehabilitates the entry and re-emits the spawn — the same machinery already used for an assumed-finished file, now applied to this path too.
  • Renderer: both safety nets had the same defect — pruneStaleGridSubagents() ran only from wrapInGridCard() and pruneStaleSubagents() only from renderProjects(), so a stale entry survived exactly in the idle case the TTL exists to cover. Each view now arms a one-shot timer at the oldest entry's deadline (not an interval), refreshes only the entries the prune actually removed, and stops when its map is empty — ADR 0002, no steady-state cost. These are a backstop; with the main-process signal repaired they should almost never fire.

Not changed

The watcher debounce has no maxWait. Starvation under sustained write load was the working hypothesis and it is refuted: the log shows completions emitted while three transcripts were being written. The real starvation is silence, not noise. HEARTBEAT_MS and the renderer TTL are untouched — widening them would only make the underlying defect rarer and harder to find.

Tests

test/subagent-settle-tick.test.js and test/dom-subagent-ttl-tick.test.js. Mock timers on both sides — no wall-clock waits, per docs/activity-trace.md. Every assertion checked by reverting the fix:

drop the settle tick arming     → completion must fire from the settle tick alone
drop the recheck window         → the withheld spawn must be re-emitted
drop the renderer timer arming  → each view arms its own TTL timer on the spawn — 0 !== 2
drop the targeted refresh       → the TTL tick must clear the row without waiting for a render

691 tests, 684 pass, 0 fail, 7 pre-existing skips. ESLint: 0 errors, 265 warnings before and after.

Reasoning and measurements in .ai/contexts/subagent-observability.md.

@devsuitup
devsuitup force-pushed the fix/subagent-completion-tick branch 2 times, most recently from 15c980d to 1e85d00 Compare August 23, 2026 23:08
…eversible

detectSubagentTransitions() owns spawn, heartbeat and completion, but it only
ran from the projects watcher's debounced flush. A function that runs when a
file changes cannot see that a file stopped changing: agent a9be19fb0a7e0e504
last wrote at 23:42:18 and its completion was logged at 23:52:55 — 10 min 37 s
for a 30 s stability window — because the folder went quiet and no flush ran.

The same window also fires wrongly. Agents a8e8c25f42a65b026 and
a19dcbbb23270de85 were declared complete at 23:56:53 and 00:00:27 while still
writing minutes later: a tool call longer than the window reads as a finished
agent. That verdict was irreversible — the entry carried no recheck window, so
it hit the completed fast path forever and the agent could never light up
again. A stability completion now keeps a recheck window, so renewed growth
rehabilitates it and re-emits the spawn. That is what makes the tick safe:
adding a clock makes false completions more frequent, not less.

Rather than let such an agent oscillate, its stability window widens on each
rehabilitation along a capped ladder (30 s, 2 min, MAX_STABLE_MS) so it
converges. An assumed-finished entry is deliberately excluded: it carries no
evidence of long silences, and widening it would slow the normal case.

Widening needs one companion change. A wider window lets an agent stay silent
past the renderer's TTL without completing, and the renderer refuses a
heartbeat for an agent it no longer tracks — on its own the widening would
trade a blink for a permanent blackout. The growth branch therefore emits a
real spawn instead of a heartbeat once the agent has been unseen that long.

That agreement between the main process and the two views was three separate
60000 literals held together by a comment; shortening one alone silently
recreates the blackout. public/subagent-timing.js is now the single definition,
using the dual-mode pattern of public/shortcuts.js: classic script for the
renderer, module.exports for require().

Both renderer safety nets had the same shape of bug — pruneStaleGridSubagents()
ran only from wrapInGridCard() and pruneStaleSubagents() only from
renderProjects(), so a stale entry survived exactly in the idle case the TTL
exists to cover. Each view now arms a one-shot timer at the oldest entry's
deadline, refreshes only the entries the prune removed, and stops when its map
is empty (ADR 0002: no steady-state cost).

Not changed: the watcher debounce has no maxWait, but the log shows completions
emitted while three transcripts were being written, so starvation under write
load is refuted and nothing was added for it.
@devsuitup
devsuitup force-pushed the fix/subagent-completion-tick branch from 1e85d00 to a6783ac Compare August 23, 2026 23:18
@devsuitup
devsuitup merged commit af304fd into main Aug 23, 2026
7 checks passed
@devsuitup
devsuitup deleted the fix/subagent-completion-tick branch August 23, 2026 23:24
devsuitup added a commit that referenced this pull request Aug 23, 2026
The stability clock in detectSubagentTransitions only advances when the
scan runs. PR #153 gave it a 5 s settle tick so a silent folder can no
longer stall it; the remaining lateness is up to one tick plus the rest
of the stability window.

The Claude CLI already publishes a per-session state file at
~/.claude/sessions/<pid>.json whose status flips to idle when a turn
ends. Watching that directory gives an earlier, more precise trigger for
the same scan, at no cost while nothing changes state.

The idle edge is a trigger, never a verdict: nothing here marks a
subagent complete or emits subagent-completed, and every guard failure
degrades to doing nothing so the settle tick stays the safety net. The
file is not a documented interface, so a canary test pins its shape and
skips itself where the CLI is absent.
devsuitup added a commit that referenced this pull request Aug 23, 2026
The stability clock in detectSubagentTransitions only advances when the
scan runs. PR #153 gave it a 5 s settle tick so a silent folder can no
longer stall it; the remaining lateness is up to one tick plus the rest
of the stability window.

The Claude CLI already publishes a per-session state file at
~/.claude/sessions/<pid>.json whose status flips to idle when a turn
ends. Watching that directory gives an earlier, more precise trigger for
the same scan, at no cost while nothing changes state.

The idle edge is a trigger, never a verdict: nothing here marks a
subagent complete or emits subagent-completed, and every guard failure
degrades to doing nothing so the settle tick stays the safety net. The
file is not a documented interface, so a canary test pins its shape and
skips itself where the CLI is absent.
devsuitup added a commit that referenced this pull request Aug 23, 2026
The stability clock in detectSubagentTransitions only advances when the
scan runs. PR #153 gave it a 5 s settle tick so a silent folder can no
longer stall it; the remaining lateness is up to one tick plus the rest
of the stability window.

The Claude CLI already publishes a per-session state file at
~/.claude/sessions/<pid>.json whose status flips to idle when a turn
ends. Watching that directory gives an earlier, more precise trigger for
the same scan, at no cost while nothing changes state.

The idle edge is a trigger, never a verdict: nothing here marks a
subagent complete or emits subagent-completed, and every guard failure
degrades to doing nothing so the settle tick stays the safety net. The
file is not a documented interface, so a canary test pins its shape and
skips itself where the CLI is absent.
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