You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
workflow: a run whose ephemeral map is gone leaves its hidden run Bots unreapable #1414
Found while writing the reap() fix for #1388; it is not fixed by that PR and not covered by its scope.
What happens. A run can be persisted with status: 'running' and no ephemeral map at all. reap() starts with if (!run?.ephemeral) continue (plugins/workflow/src/service.js), so such a run is skipped for good: the run stays running in the store, nothing resumes it, and the run Bots it created before the map went missing are hidden with no record that names them.
SelfTest measured this shape on 2026-10-27 (main@0542cfac, two isolated instances, real Chromium), comment 6438296793 on #1388: after killing the host mid-run and reloading, the run record still said running, ephemeral was gone, flybot/bots held only the local Chief, and bot/state.json had accumulated 30 records { hidden: true, ephemeral: { plugin: 'flybot-workflow' } } (5 interrupted runs × 6 nodes) that never go away. Clean runs are unaffected: a run that reaches done releases its instances and the ephemeral count returns to its baseline.
Why it is a separate issue.#1388's fix ends a run that still carries its ephemeral map, and its tested scope keeps if (!run?.ephemeral) continue unchanged (a run without the map must not be rewritten). With the map already lost, there are no ids to release, so the Bots above stay orphaned no matter what reap() does.
What is not known yet (needed to fix it).
Which write path loses ephemeral. The candidates are the window between bot.create for a node and the store write that records the id (assign/runNamed spawn path in src/service.js), and a restart that loads an older workflows.json. The reading above shows the symptom, not the moment.
Whether the orphaned records can be found again from the run: they carry ephemeral.plugin: 'flybot-workflow' and fields.nodeId, not the run id, so a repair pass would have to be written carefully or those Bots stay unattributable.
Expected behaviour (proposal, needs a decision). Either the run id is recorded on each run Bot (fields.runId) so an unattributed Bot can be reclaimed, or the spawn path writes the id to the run before the first Bot exists. Which one is the right shape is a design call, not something this report settles.
Scope.plugins/workflow/ only. No core/ change is implied unless the Bot record needs a new field.
Labels applied: bug, area:automation, needs-info — it was the only open workflow issue left with no type or area label, and it is parked on a decision rather than on a repro.
Verified on main 26657c8a: plugins/workflow/src/service.js:549 is if (!run?.ephemeral) continue, so a run persisted without the map is skipped for good — by design of #1388's scope, which is why this needs its own shape rather than a change to reap(). The two shapes in the body are a design call (fields.runId on each run Bot so an orphan can be reclaimed vs. the spawn path writing the id to the run before the first Bot exists); both touch core/ if the Bot record gains a field, so whoever decides should say which, since that decides the risk tier and the test surface.
Not agent-ready until that call is made: an agent would have to invent the record shape, and #1388's accepted behaviour ("a run without the map must not be rewritten") is exactly what an implementer would be tempted to relax. Related, not duplicates: #1387 (dispose() drops the records before the release finishes), #1390 (concurrent assign()), #1424/#1425 (fresh follow-ups from #1411).
Root cause found for the unknown in point 1: the ephemeral map is not lost by a spawn-path race — dispose() deletes it at host shutdown while leaving the run running.
Code (main)
dispose() (plugins/workflow/src/service.js:998) calls releaseAll(ids).then(() => { for (const run of runs) { delete run.ephemeral; delete run.handed } ; write() }). Nothing sets status, so every live run is persisted as live: true, status: "running" with no map.
reap() (:963) starts with if (!run?.ephemeral || seen.has(run)) continue, so the run written that way is skipped forever. There is no other path to end it: runningOf() (:199) counts it (live === true && status === "running"), and reset/pause are RPC-only (index.js:102/103), so nothing in the product can free the slot.
bot/logs/diagnostics.jsonl at epoch 1793112215664 (≈14:43:35Z, shutdown): Bot Claim + Implement c351 not archived: DomainError: domain 'workspace' is closed, same for Bot Verify f710 — the release itself fails because the workspace domain is already closed, and dispose() drops the ids anyway.
dispose(): end live runs before dropping ids — mark each status: "failed", set updatedAt, push the same history line the other failed transitions push. The store must be self-describing after shutdown; today it is not.
reap(): also treat run.live === true && run.status === "running" && !run.ephemeral as abandoned (no ids to release — just end it and move it to history), so stores already written by older builds heal on the next load.
Point 2 of the original body (fields.runId on each run Bot) is still worth it for reclaiming the orphaned records, but it is not needed to stop the lane being held.
Acceptance (tests, plugins/workflow)
Start a live run with a spawn stub, call dispose(), then build a new service on the same dir: lane.active empty, lane.history has the run with failed, and the persisted record has no ephemeral and does not say running.
Hand-written legacy store (live: true, status: "running", no ephemeral): after load + reap(), runningOf() is empty (capacity free) and the run appears in history as failed.
A reply from an old node Bot of such a run is dropped (ownsBot() false) and does not open a turn in a seated Bot's chat.
After instance restart at 14:49:55Z (upgrade boot), run is still status=running · live=true with no ephemeral / no handed.
nodeStatus: … review done · enqueue=running · landcheck/escalate queued; never advanced past Enqueue (PR had already merged at 14:44:39Z).
Matches the dispose→reap hole: unload dispose() strips ephemeral without ending the run; boot reap() skips !run.ephemeral, so the zombie persists.
Fix direction (either):
dispose() must fail/end active live runs before clearing ephemeral, or
reap() must treat live && status==='running' && !ephemeral as abandoned (mark failed, clear from lane.active).
Also related: #1424 (nodeStatus left running after a successful reap). Owner separately filed #1463 for chip/list mismatch + per-run controls (zombie inflates activeCount).
Activity
Birfy commentedon Oct 27, 2026
Written-by: Triage
Labels applied:
bug,area:automation,needs-info— it was the only open workflow issue left with no type or area label, and it is parked on a decision rather than on a repro.Verified on main
26657c8a:plugins/workflow/src/service.js:549isif (!run?.ephemeral) continue, so a run persisted without the map is skipped for good — by design of#1388's scope, which is why this needs its own shape rather than a change toreap(). The two shapes in the body are a design call (fields.runIdon each run Bot so an orphan can be reclaimed vs. the spawn path writing the id to the run before the first Bot exists); both touchcore/if the Bot record gains a field, so whoever decides should say which, since that decides the risk tier and the test surface.Not
agent-readyuntil that call is made: an agent would have to invent the record shape, and#1388's accepted behaviour ("a run without the map must not be rewritten") is exactly what an implementer would be tempted to relax. Related, not duplicates:#1387(dispose()drops the records before the release finishes),#1390(concurrentassign()),#1424/#1425(fresh follow-ups from#1411).Birfy commentedon Oct 27, 2026
Root cause found for the unknown in point 1: the
ephemeralmap is not lost by a spawn-path race —dispose()deletes it at host shutdown while leaving the runrunning.Code (main)
dispose()(plugins/workflow/src/service.js:998) callsreleaseAll(ids).then(() => { for (const run of runs) { delete run.ephemeral; delete run.handed } ; write() }). Nothing setsstatus, so every live run is persisted aslive: true, status: "running"with no map.reap()(:963) starts withif (!run?.ephemeral || seen.has(run)) continue, so the run written that way is skipped forever. There is no other path to end it:runningOf()(:199) counts it (live === true && status === "running"), andreset/pauseare RPC-only (index.js:102/103), so nothing in the product can free the slot.Measured on this host, 2026-10-27T14:50Z restart
/data/dsh/bot/flybot-workflow/workflows.json(written 14:50Z):run-3fd2406e(fix(workflow): every stage chat line is clipped at 240 characters with no way to read the rest (introduced by #1456) #1460),run-0ca151a7(label-hygiene: isolate a refused label instead of abandoning the sweep (#1418) #1422),run-2f460b96(fix(palette): read a stored line's words in a ⌘K row, not its fence markers or trace #1395) all carrylive: true, status: "running", each withnodeStatus.<node> = "running"and noephemeralkey.run-ffb574ee(fix(host): a Workflow run Bot keeps the team tools, whatever the node's tools list says #1445), started 14:50:59Z after the restart, has the full 7-node map. So: runs that were in flight at shutdown lose the map; runs started after the restart keep it.bot/logs/diagnostics.jsonlat epoch1793112215664(≈14:43:35Z, shutdown):Bot Claim + Implement c351 not archived: DomainError: domain 'workspace' is closed, same forBot Verify f710— the release itself fails because the workspace domain is already closed, anddispose()drops the ids anyway.Consequences seen today (two user-visible ones)
runningOf()counts the dead runs, sohasRoom()(:533) answers no. Lanewf-2d79a70a(PR → 合入,maxConcurrent: 2) is at 2/2 with both slots held by dead runs; no new PR run can start at all. Lanewf-a85c4250holds one dead run (fix(workflow): every stage chat line is clipped at 240 characters with no way to read the rest (introduced by #1456) #1460) out of 2.findEphemeral()(:241) returns null for those runs, soownsBot()(:788) is false and the interception inindex.js:139lets a node's reply fall through to a seated Bot's chat instead of advancing the run. Observed twice:Reply from Inspect c633at 14:50:27Z (route: reworkfor fix(palette): read a stored line's words in a ⌘K row, not its fence markers or trace #1395) andReply from Enqueue 3583at 14:52Z (PR label-hygiene: isolate a refused label instead of abandoning the sweep (#1418) #1422 enqueue/landing confirmation) both landed in the Chief chat while their runs still saidinspect/enqueue=running.Fix proposal
dispose(): end live runs before dropping ids — mark eachstatus: "failed", setupdatedAt, push the same history line the other failed transitions push. The store must be self-describing after shutdown; today it is not.reap(): also treatrun.live === true && run.status === "running" && !run.ephemeralas abandoned (no ids to release — just end it and move it to history), so stores already written by older builds heal on the next load.fields.runIdon each run Bot) is still worth it for reclaiming the orphaned records, but it is not needed to stop the lane being held.Acceptance (tests,
plugins/workflow)dispose(), then build a new service on the same dir:lane.activeempty,lane.historyhas the run withfailed, and the persisted record has noephemeraland does not sayrunning.live: true,status: "running", noephemeral): after load +reap(),runningOf()is empty (capacity free) and the run appears in history as failed.ownsBot()false) and does not open a turn in a seated Bot's chat.failed(workflow:reap()skipsrunningruns, and it only runs once at plugin load #1388), an already ended run keeps its status, release-before-forget stays (workflow:dispose()drops the ephemeral Bot records before the release finishes #1387).This is ready to be re-triaged off
needs-info; I am not changing labels myself.Birfy commentedon Oct 27, 2026
Live repro on prod (2026-10-27, tip
952d6358)FlyBotDebugger measured Workflow
wf-2d79a70a「FlyBot PR → 合入」, runrun-0ca151a7(task #1422):status=running·live=truewith noephemeral/ nohanded.dispose()strips ephemeral without ending the run; bootreap()skips!run.ephemeral, so the zombie persists.Fix direction (either):
dispose()must fail/end active live runs before clearing ephemeral, orreap()must treatlive && status==='running' && !ephemeralas abandoned (mark failed, clear fromlane.active).Also related: #1424 (nodeStatus left running after a successful reap). Owner separately filed #1463 for chip/list mismatch + per-run controls (zombie inflates
activeCount).End workflow zombie runs on dispose and reap (ThinkFlowLab#1465)