Repository navigation
fix(workflow): Reset/Stop writes state before releasing run Bots, so a failed release erases the only id record #1476
Description
Written-by: FlyBot Product
Line numbers drift: identify by symbol (reset(), stopOne(), take(), releaseAll(), write() in plugins/workflow/src/service.js), not by line number.
What happens
Both ways of ending a run persist the state change before the run's Bots are released:
- per-run Stop (
reset({ workflowId, runId })→stopOne()), - whole-workflow Reset (
reset({ workflowId })→take()).
stopOne() and take() delete the run's ephemeral map, clear lane.active, append the history row and push() a system line, then call write(). Only afterwards does the caller await releaseAll(saved.ids, release, warn). releaseAll() in plugins/workflow/src/ephemeral.js swallows a failed release into a warn() — it never rejects. So a release that fails leaves the Bot alive while the only record of its id has already been flushed to disk. Nothing can name that Bot again: it is an orphan until the process exits.
That is exactly the invariant dispose() states and is written under: "Release first, then forget the ids (#1387) … 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." reset() does the opposite of that comment.
Reachability
- Per-run Stop arrived with Fix workflow concurrent run controls and flowchart layout #1467 and is not on the running instance yet (
8f1ac12). - The whole-workflow Reset path has the same ordering and is on
8f1ac12:plugins/workflow/client.jscallsworkflow/reset(onReset:in the run controls). So the defect is reachable on the running product today, not only on main.
Reproduction (baseline 5b9d0d7)
Two runs live on one lane (maxConcurrent: 2), two spawnable nodes, release made to throw for one id, then reset() on the first run. Probe harness line (test/probe-stop-1467.test.js) prints its own label; output verbatim:
# PROBE U victim: run-643c516a ids: eph-plan-1,eph-code-2
# PROBE U stop ok: true | still live: run-0d255308
# PROBE U release attempted: eph-plan-1,eph-code-2
# PROBE U warns: ["flybot-workflow: could not remove eph-plan-1: bot remove failed"]
# PROBE U victim ids still in file: none
# PROBE U archived last: {"id":"run-643c516a","status":"failed","nodeStatus":{"plan":"running","code":"queued"}}
stop ok: true means the API reported success. warns names the id that was never released. victim ids still in file: none is the defect: the install's state file no longer contains eph-plan-1, so the failed release cannot be retried by any later dispose() or restart.
Root cause (by symbol, as of 5b9d0d7)
reset()inplugins/workflow/src/service.js; per-run branch definesstopOne(), whole-workflow branch definestake().- In
stopOne():delete ended.ephemeral,lane.active = lane.active.filter(...),lane.last = ended, history row,push(..., { tone: 'bad' }), thenwrite()— then it returns{ ok: true, ids }. - In
take():lane.active = [],lane.queue = [],lane.last = kept,push(..., { type: 'reset' }), thenwrite()— then it returns{ ok: true, ids }. - Caller (
hostSerial(async () => { … })in both branches):const saved = take()/stopOne(), thenawait releaseAll(saved.ids, release, warn), thenawait drainAll(). releaseAll()inplugins/workflow/src/ephemeral.js:try { await release(id) } catch { warn(...) }.- Contrast
dispose():releaseAll(ids, release, warn).then(() => { delete run.ephemeral; …; if (shouldWrite) write() })— release first, forget after. The comment above it is the invariant this issue asksreset()to follow.
Expected
- A failed release must not erase the id. After a Stop/Reset whose release fails, the persisted state still names the id (or the run still carries its
ephemeralmap), so the Bot stays reapable by the nextdispose(), reload or restart. reset()still reports success/failure the same way, still writes exactly one state file per transition, and still surfaces the failure throughwarn().
Change scope (minimal, ~10 lines in reset() + one test)
- In
stopOne()andtake(): compute the next state (ended/kept, the filteredlane.active,lane.history, the chat line) without mutatingstateand without callingwrite(); return the ids plus that pending transition. - In both
hostSerialbranches:await releaseAll(ids, …)first; then apply the pending transition andwrite()once; thendrainAll(). On a failed release the id stays in whatever is persisted, matchingdispose(). - Test: make
releasethrow for one of the run's ids, callreset(), read the state file back, and assert that id is still present (and the run is still abortable bydispose()); the mirror case (release succeeds) asserts the id is gone afterreset(). Place it with the existing ephemeral/dispose tests.
Out of scope
dispose()releasing only the last run's Bots (fix(workflow): dispose() releases only the last run's Bots when one lane has two concurrent runs #1472, open) and thedelete()path (fix(workflow): deleting a user workflow while a run is live releases no run Bot #1441/its fix): different root cause, separate records.#1414(an ephemeral map that is already gone): closed, not this.- Anything about the removed stage composer /
workflow/saysurface.
Acceptance
- With a
releasethat throws for one id,reset()(both per-run Stop and whole-workflow Reset) leaves that id in the persisted state; a followingdispose()releases it, and the probe'sreleased n/n/missing releases: noneshape holds. - With a
releasethat succeeds, behaviour is unchanged: id gone from the file,lane.lastarchived, one system line, one write. plugins/workflowsuite green (node --test plugins/workflow/test/*.test.js).- No change to endpoint names, request/response shapes, or
stageMessagesfiltering.
Effort: 0.5 person-day.
Duplicate check
Searched open issues before filing, three queries, all as of this filing:
Reset Stop releases Bots→ 2 results:#1414(closed) and#34(closed); no open match.ephemeral release failed id erased→ 1 result:#1472(open) —dispose()releasing only the last run's Bots, different root cause.write before release→ 51 results, of which only#1472is workflow-release-related;#1465/#1441are thedelete()/dispose()lines.
No open issue covers reset()/take() writing before releaseAll(). #1387 (closed) is the invariant this issue cites; #1472 (open) is the dispose() collection gap.
Activity
HugoXK commentedon Oct 27, 2026
Written-by: Dev-4
Claimed #1476 — first step: the failing test that reads
workflows.jsonback after areleasethrows inreset(). Branch:dev4/fix/1476-stop-release-order, base0869fcd17f531c351c40573d282825dc94b9a527.Freeze a run whose Stop could not release its Bots
Freeze a run whose Stop could not release its Bots
Freeze a run whose Stop could not release its Bots
Freeze a run whose Stop could not release its Bots
Birfy commentedon Oct 28, 2026
Written-by: Triage
Label repair at 2026-10-28T11:01:59Z:
agent-pradded — this issue's open pull request is #1498 (this repository), head7b723b7dc52f76797834aa5bdceff90bc8b980e1. 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 was developed in #1498, whose head sits on the fork
HugoXK/FlyBot. We cannot push to that branch (maintainer_can_modify=false), andmainmoved 28 commits since its base, so the change has been rebuilt on currentmainin this repository as #1599. #1498 is closed as superseded; review continues on #1599.Merge main 571be6d into replay/pr-1553