Repository navigation
fix: settle a finished agent, and pin the dashboard markup to one bundle - #102
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
workingSpawn 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 secondswas still
workingafterwards, andsessions[]carried a row withstate: Noneandid: Nonenext torunning: falseandtasks: 0.The cause is not where the report assumed.
this.agents.delete()does runon three paths, but the observed row was still in the map — it was just
stuck. Nothing ever settles an agent:
status = 'working'is written intwo places, and the only terminal writes are in the success branches of the
tools themselves. Their outer
catchcleaned nothing up. Whenctx.session.waitrejects — a child session that was aborted or evicted,the common case —
delegatewent straight there, printed "Delegate failed",and left the row
workingfor the life of the process. The timeout caseshared that
catchand reported "may still be running" honestly; the deadpoll did not.
sessions[]was a separate gap.sessionViews()skips agents with nosessionID, but passes 2 and 3 over the collection ledgers do no suchcheck, so a record keyed on an empty string could build a row whose
idwasempty. Both are now rejected.
What changed
settleAgent(id, 'completed' | 'failed')is the single door to a terminalstatus. It does not delete the row: the only reason
SessionStateViewcarries
owned: falseandagentId: nullis thatterminateAgentdrops asession that is still running and spending, and deleting here would
manufacture exactly the phantom that design refuses to invent.
try, so the outercatchcan settle it asfailed.working, becausethe session may genuinely still be spending. A dead poll settles at the
exit point:
completedif the session produced output,failedif not.applyCollectionRecordrejects an emptysessionID, and the publisherfilters empty ids.
The
owned: false/agentId: nulldesign, the cost accounting, thedashboard markup and the DAG's
finally(existing tests pin "a successfultask 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 predatedthe fix, so the first live attempt could not have shown anything and did
not. After rebuilding and restarting: a newly spawned agent reports
completedand its session reportssettled. The two agents spawnedagainst the old build are still
working, which is the same bug in aninstance already carrying the broken state.
Tests — 13 new, in
test/state-sessions.test.tsandtest/spawn-subagent-tool.test.ts. The plugin-level ones readgetState()through the
dashboardtool, so they exercise the same payload/api/stateserves rather than a parallel path. Mutation-checked four ways; note that
the two
sessions[]guards mask each other by design, so their evidence isgiven with both removed.
#101 — the duplicated parser was not the problem
The report said two
parseDashboardTargetcopies need reconciling. Thereare two, and the duplication is load-bearing: both sites already carry a
comment naming the other, and
test/dashboard-entrypoints.test.ts:917already 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 indist/tui.js, and the TUIbundle 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, soa source checkout that has not been built does not fail on an absent
artifact.
Checks
typecheckclean,lintunchanged (3 pre-existing infos),bun test1337 → 1353 passing, 0 failing.