Skip to content

workflow: a run whose ephemeral map is gone leaves its hidden run Bots unreapable #1414

Closed

Description

@Birfy

Written-by: Dev4

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).

  1. 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.
  2. 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.

Activity

  1. Birfy commented on Oct 27, 2026

    @Birfy
    CollaboratorAuthor

    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: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).

  2. added
    area:automationScheduled tasks and automation
    bugSomething isn't working
    needs-infoWaiting for the reporter or a maintainer decision
    on Oct 27, 2026
  3. Birfy commented on Oct 27, 2026

    @Birfy
    CollaboratorAuthor

    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.

    Measured on this host, 2026-10-27T14:50Z restart

    Consequences seen today (two user-visible ones)

    1. Capacity: runningOf() counts the dead runs, so hasRoom() (:533) answers no. Lane wf-2d79a70a (PR → 合入, maxConcurrent: 2) is at 2/2 with both slots held by dead runs; no new PR run can start at all. Lane wf-a85c4250 holds 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.
    2. Replies: findEphemeral() (:241) returns null for those runs, so ownsBot() (:788) is false and the interception in index.js:139 lets a node's reply fall through to a seated Bot's chat instead of advancing the run. Observed twice: Reply from Inspect c633 at 14:50:27Z (route: rework for fix(palette): read a stored line's words in a ⌘K row, not its fence markers or trace #1395) and Reply from Enqueue 3583 at 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 said inspect/enqueue = running.

    Fix proposal

    1. 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.
    2. 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.
    3. 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)

    1. 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.
    2. 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.
    3. 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.
    4. No regression: existing workflow tests green; a reaped run that still had its map ends failed (workflow: reap() skips running runs, 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.

  4. Birfy commented on Oct 27, 2026

    @Birfy
    CollaboratorAuthor

    Live repro on prod (2026-10-27, tip 952d6358)

    FlyBotDebugger measured Workflow wf-2d79a70a 「FlyBot PR → 合入」, run run-0ca151a7 (task #1422):

    • 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):

    1. dispose() must fail/end active live runs before clearing ephemeral, or
    2. 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).

  5. added a commit that references this issue on Oct 27, 2026
    8f1ac12
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

    area:automationScheduled tasks and automationbugSomething isn't workingneeds-infoWaiting for the reporter or a maintainer decision

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions