Skip to content

fix: settle a finished agent, and pin the dashboard markup to one bundle - #102

Merged
serkanalgur merged 2 commits into
mainfrom
fix/agent-terminal-state
Sep 28, 2026
Merged

serkanalgur merged 2 commits into
mainfrom
fix/agent-terminal-state

Conversation

@serkanalgur

Copy link
Copy Markdown
Owner

Closes #100, addresses #101.

Found by spawning an agent against a live dashboard and watching it not
finish. Both problems were reported as one; they are two, and only the
first was a behaviour bug.

#100 — a finished agent never leaves working

Spawn an agent, wait for it to complete, and the dashboard still reports
working. Verified live on 2.13.2 — a task that returned in ~40 seconds
was still working afterwards, and sessions[] carried a row with
state: None and id: None next to running: false and tasks: 0.

The cause is not where the report assumed. this.agents.delete() does run
on three paths, but the observed row was still in the map — it was just
stuck. Nothing ever settles an agent: status = 'working' is written in
two places, and the only terminal writes are in the success branches of the
tools themselves. Their outer catch cleaned nothing up. When
ctx.session.wait rejects — a child session that was aborted or evicted,
the common case — delegate went straight there, printed "Delegate failed",
and left the row working for the life of the process. The timeout case
shared that catch and reported "may still be running" honestly; the dead
poll did not.

sessions[] was a separate gap. sessionViews() skips agents with no
sessionID, but passes 2 and 3 over the collection ledgers do no such
check, so a record keyed on an empty string could build a row whose id was
empty. Both are now rejected.

What changed

  • settleAgent(id, 'completed' | 'failed') is the single door to a terminal
    status. It does not delete the row: the only reason SessionStateView
    carries owned: false and agentId: null is that terminateAgent drops a
    session that is still running and spending, and deleting here would
    manufacture exactly the phantom that design refuses to invent.
  • Both tools hold the spawned agent outside their try, so the outer
    catch can settle it as failed.
  • Timeout and dead poll are separated. A timeout keeps working, because
    the session may genuinely still be spending. A dead poll settles at the
    exit point: completed if the session produced output, failed if not.
  • applyCollectionRecord rejects an empty sessionID, and the publisher
    filters empty ids.

The owned: false / agentId: null design, the cost accounting, the
dashboard markup and the DAG's finally (existing tests pin "a successful
task leaves its agent idle", which is deliberate) are all untouched.

Verified live, not just in tests. The dashboard turned out to be
running this repo's dist/index.js, which was built at 09:37 and predated
the fix, so the first live attempt could not have shown anything and did
not. After rebuilding and restarting: a newly spawned agent reports
completed and its session reports settled. The two agents spawned
against the old build are still working, which is the same bug in an
instance already carrying the broken state.

Tests — 13 new, in test/state-sessions.test.ts and
test/spawn-subagent-tool.test.ts. The plugin-level ones read getState()
through the dashboard tool, so they exercise the same payload /api/state
serves rather than a parallel path. Mutation-checked four ways; note that
the two sessions[] guards mask each other by design, so their evidence is
given with both removed.

#101 — the duplicated parser was not the problem

The report said two parseDashboardTarget copies need reconciling. There
are two, and the duplication is load-bearing: both sites already carry a
comment naming the other, and test/dashboard-entrypoints.test.ts:917
already pins the two to agree on every input. That half was done.

The real gap was one level down. Nothing held the bundles apart, and the
bundle is the only place the constraint is observable — a comment is not a
test. A reader who reads the duplication as an oversight merges it, and the
cost lands at build time where nothing fails.

New assertions on the built artifacts: the page is inlined into
dist/index.js, none of its markup appears in dist/tui.js, and the TUI
bundle is smaller than the page itself. The third is what keeps the first
two from being a coincidence about four strings — 72 KB against 170 KB,
where a bundle that had inlined the markup would be about 240 KB.
Mutation-checked in all three directions.

The tests read dist/ when it is there and return early when it is not, so
a source checkout that has not been built does not fail on an absent
artifact.

Checks

typecheck clean, lint unchanged (3 pre-existing infos), bun test
1337 → 1353 passing, 0 failing.

Nothing settled a spawned agent that stopped working outside a spawn
tool's own success branch. `working` was written the moment `spawnAgent`
returned and read back by nobody who could clear it, so every other exit
left the row claiming to be busy for the life of the process: a rejected
`session.wait` (an aborted or evicted child), a throw from a later read, a
failing `storage.set`. `getState().agents` reported a dead agent as
working and `sessionViews()` agreed with it, mapping `working` to
`running`, so both views of one session claimed spend that had stopped.

Settle through one door, `NexusOrchestrator.settleAgent`, which writes a
terminal status and keeps the row. Keeping the row is the point: an agent
dropped from `this.agents` takes its session row with it, and the only
reason a session outlives its agent is `terminateAgent` dropping one that
is still generating. Settling is not that case, and deleting here would
publish a fabricated `owned: false` orphan. First terminal outcome wins;
`lastActivity` is refreshed so `cleanupStaleData` owns the eviction.

The spawn tool also conflated its own timeout with a dead poll, and
treated both as "may still be running". A timeout is our deadline firing
and the session may genuinely still be generating; a rejection is the
poll dying, which is not a running session. They are tagged apart now,
and a dead poll settles at each exit — `completed` when the session
produced output, `failed` when it did not.

`sessions[]` is the one collection a reader may treat as fully populated.
Pass 1 already skipped an agent with no session; the collection-record
constructor did not, so a record keyed by an empty id built a row naming
no session. It is rejected there, and the publisher drops an empty id as
a backstop.
`parseWebDashboardTarget` and `parseDashboardTarget` are two functions
because `src/dashboard.ts:11` inlines `dashboard/index.html` with a text
import. Import it from `src/tui.tsx` and ~170 KB of page markup lands in
`dist/tui.js`, which serves no dashboard and reads none of it. Both sites
carry a comment saying so, and the parser test above already keeps the two
functions in agreement.

Neither of those holds the BUNDLES apart. A comment is not a test, and a
bundle test is the only place the constraint is observable, so a reader who
reads the duplication as an oversight merges it and the cost lands at build
time where nothing fails.

Three assertions on the built artifacts: the page is inlined into
`dist/index.js`, none of its markup appears in `dist/tui.js`, and the TUI
bundle is smaller than the page itself. The third is what keeps the first
two from being a coincidence about four strings — 72 KB against 170 KB,
where a bundle that had inlined the markup would be about 240 KB.

Mutation-checked: appending the page to `dist/tui.js` fails both the
markup and the size test; removing it from `dist/index.js` fails the
server-side one.

Reads dist/ when it is present and returns early when it is not, so a
source checkout that has not been built does not fail on an absent
artifact.
@serkanalgur
serkanalgur merged commit b97fc71 into main Sep 28, 2026
4 checks passed
@serkanalgur
serkanalgur deleted the fix/agent-terminal-state branch September 28, 2026 07:34
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.

fix: getState().agents never leaves 'working' — completed agents are deleted from the map instead of settled

1 participant