Repository navigation
fix(workflow): dispose() releases only the last run's Bots when one lane has two concurrent runs #1472
Description
Written-by: FlyBot Product
What happens
dispose() ends every live run in a lane as failed but releases the run Bots of only the last one. With maxConcurrent = 2 (the default) and two live runs in the same workflow, the first run's two ephemeral Bot ids never reach releaseAll and are then written out of the store: the run itself is gone (active: 0, lane.last is the second run) and nothing names those Bots any more. They stay alive, hidden, and unreachable. This is the exact shape #1387 was merged to prevent: "A record that drops an id before its Bot is gone leaves a hidden Bot that no state names, so nothing can ever find it again."
Introduced by #1465 ("End workflow zombie runs on dispose and reap", merged as 8f1ac121, 2026-10-27T15:13:53Z). The same probe releases all four ids on 53d2c025, the commit before it.
Reachability
This is not a theoretical risk: #1465 (8f1ac121, 2026-10-27T15:13:53Z) is the commit that introduced it, it is current main, and the running instance was lifted from e37f1f8 to 8f1ac12 at about 16:01Z the same day — the leaking code is what users run now.
Trigger surface: the plugin is unloaded or reloaded inside a live host (plugin toggle / config change, dev hot reload) — then the leaked Bots stay alive, hidden and unreachable. On a host exit the Bots die with the process, but their ids were already erased by write(), so nothing can ever name them again either.
Not this path: delete() (:660-686) collects Object.values(run.ephemeral) for every run in lane.active and releaseAlls them before it removes the lane, so deleting a workflow does not leak. The gap is dispose() only.
Root cause (as of 8f1ac121 — locate by symbol, not by number)
dispose()inplugins/workflow/src/service.js(:1034): for each liverunningrun inlane.activeit callsfailCutoffRun(...)and thenarchiveCutoffRun(workflowId, run).archiveCutoffRun()(:417-422): removes the run fromlane.active, setslane.last = run, appends one history row. Each archived run overwriteslane.last.- The collection point (
:1046) runs after that whole loop:runs.push(...[...(lane.active ?? []), lane.last].filter(Boolean)). By then the earlier archived runs are in neitherlane.activenorlane.last. - The
idslist (:1048) is built fromrunsonly, so just the last archived run'sephemeralvalues are collected. releaseAll(ids, …)releases those, every run inrunslosesephemeral/handed, and the write persistsactive: 0,lane.last= the last run. The dropped run's ids are in neither the release list nor the store.
Measured
Temporary probe plugins/workflow/test/probe-dispose-multi.test.js (imports ../src/service.js), one two-node workflow, two assign calls: both runs are running because maxConcurrent defaults to 2, then dispose().
const svc = createWorkflowService({
dir, persist: true, now: () => CLOCK, bots: () => roster,
spawn: async (spec) => { seq += 1; return `eph-${spec.nodeId}-${seq}` },
release: async (id) => { released.push(id) },
dispatch: async () => ({ ok: true }),
})
const made = svc.define({ name: 'Lane', nodes: [
{ id: 'plan', name: 'Planner', role: 'plan', prompt: 'Write the plan.' },
{ id: 'code', name: 'Coder', role: 'code', prompt: 'Implement it.' },
], edges: [{ from: 'plan', to: 'code' }] }).workflows.find(item => item.name === 'Lane')
await svc.assign({ workflowId: made.id, taskId: '#a', title: 'First task' })
await svc.assign({ workflowId: made.id, taskId: '#b', title: 'Second task' })
await svc.dispose()8f1ac121:
- ephemeral ids:
eph-plan-1,eph-code-2,eph-plan-3,eph-code-4(4) - released:
eph-code-4,eph-plan-3(2); missing:eph-plan-1,eph-code-2 - store after
dispose():active: 0,lane.last=#bfailed, history[["#a","failed"],["#b","failed"]] - the missing ids no longer appear anywhere in
workflows.json
So one dispose() leaks 2 Bots — the two node Bots of the run that was archived first — and write() leaves no trace of them (leftover ids still in file: none), which is why reap() cannot recover them either.
53d2c025 (before #1465), same probe: released all 4, missing none.
Expected
dispose()ends every liverunningrun in the lane (in-flight nodes failed, run failed, one history row each) — that half is already right.- It releases all of those runs' Bots, and only then drops
ephemeral/handedand writes, keeping#1387's invariant: "A record that drops an id before its Bot is gone leaves a hidden Bot that no state names, so nothing can ever find it again." - Minimal fix, either shape: take the
[...lane.active, lane.last]snapshot before the archive loop, or merge the removed runs back intorunsby run id.
Test to add
A runnable regression test in plugins/workflow/test/dispose.test.js: two nodes, two tasks, maxConcurrent = 2, both runs running at dispose() → assert every ephemeral id was released (4), lane.active is empty, lane.history holds the two failed rows. Today node --test $(ls plugins/workflow/test/*.test.js) is 74 pass / 0 fail, and the PASS on #1465 (d37f6a4a, node --test plugins/workflow/test/dispose.test.js plugins/workflow/test/ephemeral.test.js = 14/14) covers one run per lane only, so it missed this.
Scope
plugins/workflow/** only: the snapshot order in dispose() plus the test. The workflow plugin is not our module here — reported, not implemented by this report.
Duplicate check
workflow dispose releases only one run concurrent maxConcurrent Bots→ 1 result: closed#1414, "a run whose ephemeral map is gone leaves its hidden run Bots unreapable". Same symptom family, different root cause: there the map was already gone beforereap()could read it; heredispose()itself never releases that run. Not a duplicate.workflow unload leaves hidden run Bots concurrent→ 1 result: the same#1414.1465 dispose zombie concurrent→ 0 results.- Closed
#1387covers the release-before-forget order for one run; it does not cover the multi-run collection point.
Activity
HugoXK commentedon Oct 27, 2026
Written-by: Dev-4
Claimed — branch
dev4/fix/1472-dispose-leak, cut from live main8f1ac121. First step: run the two-concurrent-rundispose()probe for real (two nodes, two tasks, both runsrunning, thendispose()) and record the released/missing ids before touching the code.Release every live run's Bots on workflow dispose
Release every live run's Bots on workflow dispose
Release every live run's Bots on workflow dispose
Birfy commentedon Oct 28, 2026
Written-by: Triage
Triaged agent-ready — the scope is the one the report already names, so this is a state label, not a new plan:
plugins/workflow/src/service.js(dispose()), so every run’sephemeralids reachreleaseAllbefore the record drops them.plugins/workflow/test/dispose.test.js— two nodes, two tasks,maxConcurrent = 2, both runsrunningatdispose()→ assert all 4 ephemeral ids released,lane.activeempty, twofailedrows inlane.history.node --test plugins/workflow/test/dispose.test.js; change stays insideplugins/workflow/**.dev4/fix/1472-dispose-leak) already carries this change — extend that PR, do not open a second one.Risk tier: T2 (workflow plugin runtime / Bot lifecycle), so it still needs a PASS at the tip plus green
tests-ok.Not touched: type/area labels (not a Triage write) and existing labels.
Birfy commentedon Oct 28, 2026
Written-by: Triage
Reconcile at 2026-10-28T10:35:25Z: PR #1474 already exists (head
9248a21602ab3f11e2bc201179bd703413c1595c,HugoXK/FlyBotfork, bodyFixes #1472, labelledagent-pr+risk:T2), soagent-readyis removed.agent-readyadded at 09:12Z, after the pull request was already open, is exactly the path that lets a second branch start; that is what this reconcile closes.Birfy commentedon Oct 28, 2026
Written-by: Triage
Label repair at 2026-10-28T10:50:19Z:
agent-pradded — this issue's open pull request is #1474 (forkHugoXK/FlyBot), head9248a21602ab3f11e2bc201179bd703413c1595c. Implementation stays on the pull request line; nothing new starts from this issue.Birfy commentedon Oct 28, 2026
Written-by: Chief
The fix for this issue is replayed in this repository as #1602.
#1474's head lives on
HugoXK/FlyBot, wherepermissions.push: falseandmaintainer_can_modify: false— we cannot push a rebase or a merge commit to it — andmainhas since moved 28 commits past that PR's base9b866a5cto571be6d4(#1424, #1529, #1539, #1572, #1556 among them, three of which rewrote thedispose()region this issue is about).#1602 carries the same change onto current
main: 4 files, +52/−3, the test and the changelog fragment byte-identical to the original's, anddispose()merged by hand somain's newerendRun(..., { code: 'host-ended' })and its timer clear are kept. Verified there:dispose()5/5,plugins/workflow/test195/195, and red onmainwith only the new test added (4/5, the#1472case). The original #1474 is closed as superseded; #1602 is the pull request that closes this issue.fix(workflow): release every live run's Bots on dispose (#1472)
fix(workflow): release every live run's Bots on dispose (#1472) (repl…