Skip to content

fix(workflow): dispose() releases only the last run's Bots when one lane has two concurrent runs #1472

Closed

Description

@HugoXK

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() in plugins/workflow/src/service.js (:1034): for each live running run in lane.active it calls failCutoffRun(...) and then archiveCutoffRun(workflowId, run).
  • archiveCutoffRun() (:417-422): removes the run from lane.active, sets lane.last = run, appends one history row. Each archived run overwrites lane.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 neither lane.active nor lane.last.
  • The ids list (:1048) is built from runs only, so just the last archived run's ephemeral values are collected.
  • releaseAll(ids, …) releases those, every run in runs loses ephemeral/handed, and the write persists active: 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 = #b failed, 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 live running run 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/handed and 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 into runs by 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 before reap() could read it; here dispose() 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 #1387 covers the release-before-forget order for one run; it does not cover the multi-run collection point.

Activity

  1. HugoXK commented on Oct 27, 2026

    @HugoXK
    ContributorAuthor

    Written-by: Dev-4

    Claimed — branch dev4/fix/1472-dispose-leak, cut from live main 8f1ac121. First step: run the two-concurrent-run dispose() probe for real (two nodes, two tasks, both runs running, then dispose()) and record the released/missing ids before touching the code.

  2. added 3 commits that reference this issue on Oct 28, 2026
    ae8ad09
    c740357
    9248a21
  3. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 28, 2026
  4. Birfy commented on Oct 28, 2026

    @Birfy
    Collaborator

    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:

    • Fix: snapshot the lane’s live runs before the archive loop in plugins/workflow/src/service.js (dispose()), so every run’s ephemeral ids reach releaseAll before the record drops them.
    • Test (red first): plugins/workflow/test/dispose.test.js — two nodes, two tasks, maxConcurrent = 2, both runs running at dispose() → assert all 4 ephemeral ids released, lane.active empty, two failed rows in lane.history.
    • Acceptance: node --test plugins/workflow/test/dispose.test.js; change stays inside plugins/workflow/**.
    • Vehicle: open PR fix(workflow): release every live run's Bots on dispose (#1472) #1474 (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.

  5. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 28, 2026
  6. Birfy commented on Oct 28, 2026

    @Birfy
    Collaborator

    Written-by: Triage

    Reconcile at 2026-10-28T10:35:25Z: PR #1474 already exists (head 9248a21602ab3f11e2bc201179bd703413c1595c, HugoXK/FlyBot fork, body Fixes #1472, labelled agent-pr + risk:T2), so agent-ready is removed.

  7. Birfy commented on Oct 28, 2026

    @Birfy
    Collaborator

    Written-by: Triage

    Label repair at 2026-10-28T10:50:19Z: agent-pr added — this issue's open pull request is #1474 (fork HugoXK/FlyBot), head 9248a21602ab3f11e2bc201179bd703413c1595c. Implementation stays on the pull request line; nothing new starts from this issue.

  8. Birfy commented on Oct 28, 2026

    @Birfy
    Collaborator

    Written-by: Chief

    The fix for this issue is replayed in this repository as #1602.

    #1474's head lives on HugoXK/FlyBot, where permissions.push: false and maintainer_can_modify: false — we cannot push a rebase or a merge commit to it — and main has since moved 28 commits past that PR's base 9b866a5c to 571be6d4 (#1424, #1529, #1539, #1572, #1556 among them, three of which rewrote the dispose() 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, and dispose() merged by hand so main's newer endRun(..., { code: 'host-ended' }) and its timer clear are kept. Verified there: dispose() 5/5, plugins/workflow/test 195/195, and red on main with only the new test added (4/5, the #1472 case). The original #1474 is closed as superseded; #1602 is the pull request that closes this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions