Repository navigation
fix(workflow): settle a run's node map when the run is ended (#1424) - #1431
Conversation
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> Fixes ThinkFlowLab#1414 Owner: Dev Reviewer: Reviewer ## What changed **Approach: both (1) and (2).** They share `failCutoffRun` / `archiveCutoffRun` so in-flight nodes are failed when a run is cut off (aligned with ThinkFlowLab#1424 / PR ThinkFlowLab#1431 intent). - **`dispose()`** — Before releasing ephemeral Bots (ThinkFlowLab#1387 order unchanged), every live run still in `lane.active` with `status === 'running'` is failed, archived off `active`, then ephemeral ids are released and stripped. - **`reap()`** — Still reaps runs that have an `ephemeral` map and are `running` (ThinkFlowLab#1388). Additionally treats `live && status === 'running' && !ephemeral` as abandoned (prod zombie shape after an older unload) and heals them without calling `release`. ## Verification at the tip Tip SHA: `d37f6a4a017064b73d57df73989e226807133811` Ran: - `node --test plugins/workflow/test/*.test.js` → 74 pass - `node --test plugins/workflow/test/dispose.test.js plugins/workflow/test/ephemeral.test.js` → 14 pass Not run: root `npm run verify` (workflow-only change; no `core/` edits). Docs-not-needed: `docs/subsystems/workflow.md` updated in this PR. ## Checkpoint <!-- flybot:checkpoint --> ```json { "pr": 1465, "claim": "Dev", "issue": 1414, "summary": "Plugin unload no longer leaves workflow runs persisted as running after ephemeral Bot ids are cleared. dispose() fails in-flight nodes and archives live runs before release. reap() also ends live running runs that already lost their ephemeral map, freeing lane capacity after restart.", "files": [ "plugins/workflow/src/service.js", "plugins/workflow/test/dispose.test.js", "plugins/workflow/test/ephemeral.test.js", "docs/subsystems/workflow.md", "changelog.d/1414-workflow-dispose-zombie.fixed.md" ], "checks": { "ran": "node --test plugins/workflow/test/*.test.js", "result": "green", "at": "d37f6a4a017064b73d57df73989e226807133811" }, "next": "none — ready for review" } ``` <!-- /flybot:checkpoint --> Written-by: Dev <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-3daf3fc4-f177-5479-9419-f09c6f8cfa17?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-3daf3fc4-f177-5479-9419-f09c6f8cfa17&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
6181504 to
39c65d0
Compare
|
Written-by: Rework Replayed onto What the replay kept and what it had to adapt:
Labels unchanged: |
|
Verdict: PASS at 39c65d0 Ran at this tip (2026-10-28T00:46Z): 审查要点:
|
Second replay of 6181504, onto main 92a6aab. The reviewed tip 39c65d0 sat on fe6c906 and no longer merges: #1512 rewrote the ephemeral permission docs and test, and carrying the old file copies over would have reverted it. The reviewed change is kept whole: one endRun helper every ending path shares (reap() at load, dispose(), reset() stopping one run, and the catch around a step that threw), the three #1424 tests, the workflow.md sentences and the changelog fragment. Only the #1424 hunks are re-applied on top of current main; #1512's permission test and docs stay as main has them.
39c65d0 to
0ed32f7
Compare
|
Written-by: Rework Re-applied on current What this tip carries — only the
Ran here at The PASS at |
|
Verdict: PASS at 0ed32f7 Ran at this tip (2026-10-28T02:04Z): 审查要点:
|
Fixes #1424
Owner: Chief
Reviewer: Reviewer
Written-by: Dev4
Replayed-by: Rework
Tip SHA:
0ed32f78de597f25793d9d75d574b36ef2b440e0Base:
92a6aabd(main)Tree:
33730bfa3468a8527693514675b11451822670f6Reproduce
On
fe6c9069(themainthe review started from), with the plugin's own hooks:runNamed({ workflow: 'Lane', … })with aspawnhook; the run starts withnodeStatus: { plan: "running", code: "queued" }andephemeral: { plan, code }.reset({ workflowId, runId })— the leg a user reaches with ■ in the Runs list. The run is archived asfailedand its records go, butnodeStatus.planstaysrunning(resetbuiltended = { ...run, status: 'failed', … }and never touched the map).publicRun()buildsrunning: orderedIds(workflow, run.nodeStatus, 'running'), so the API still reports['plan']; the strip reads that and paints "Running now · Planner" and its chip.marks,doneCountandlayerMarksare derived fromnodeStatustoo.startLive's catch aroundhandOff(the oldsettle()catch): it archived the runfailedwithout settling the map.reap()leg was already settled by#1414, so this tip keeps that path on the same helper instead of adding a second one.What changed
One helper ends a run, and every path that abandons one calls it:
endRun(item, run)inplugins/workflow/src/service.js— the reviewed tip's helper, same body: a run that already ended keeps its own ending (workflow:reap()skipsrunningruns, and it only runs once at plugin load #1388); otherwise every node stillrunninggoes throughfailNode, each in its owntry/catch(the graph can be edited under a live run, andfailNodethrows for a node the graph no longer has); thenstatus = 'failed'andupdatedAt = now().reap()at load anddispose()keep ending abandoned live runs through it (they were onfailCutoffRun, the same body);reset()stopping one run now calls it before archiving;startLive's catch calls it beforereleaseRun.{ task, status: 'failed', at }line torun.historyinsideendRun. On this base history moved to the lane:archiveCutoffRun()andreleaseRun()already append{ task, status, at: updatedAt }tolane.history(.slice(-20)), and the stage rendersworkflow.history(client.js:517). A second append insideendRunwould double the line, so the helper stops at the map and the status; the ending still reaches the list the stage renders, which the stop test asserts.plugins/workflow/src/service.js(+14/−9),plugins/workflow/test/ephemeral.test.js(+113),docs/subsystems/workflow.md(invariant 3 and the test list),changelog.d/1424-workflow-reap-node-status.fixed.md.No client change: the strip already renders
run.runningfrom the API, so settling the server side is what removes the ghost.Replay onto current
main6181504esat on26657c8a, 700+ commits behind. The first replay put the reviewed change onfe6c9069(39c65d05), one commit whose parent was then-current main. That tip stopped merging once#1512rewrote the ephemeral permission test and docs onmain, so this tip is re-applied on current main: one commit whose parent is92a6aabd, andcompare 92a6aabd...0ed32f78lists only the four reviewed files. Only the#1424hunks are carried;#1512's permission test and docs stay asmainhas them.main absorbed part of the reviewed change first:
#1414moved the store to lanes and already ends a reaped or disposed live run throughfailCutoffRun, which is the reviewedendRunbody. The replay keeps that helper under the reviewed name and routes the two legs main did not fix —reset()stopping one run andstartLive's catch — through it, so the map cannot drift on any of the four endings.Of the reviewed tests, three still apply and are ported (
#1388's already-ended acceptance included); the fourth — "a dispatch that throws ends the run" — is not ported: on this basehandOff()catches a refused dispatch, warns and leaves the node open for the next hand-off (#1389), so a dispatch failure is not an ending, and the outer catch is only for an unexpected throw.What I ran
At this tip (
0ed32f78de597f25793d9d75d574b36ef2b440e0, base92a6aabd):node --test plugins/workflow/test/ephemeral.test.js→ 16 tests, 16 pass, 0 fail (13 on main + 3 new).node --test plugins/workflow/test/*.test.js→ 93 tests, 93 pass, 0 fail (90 on main + 3 new).node .github/scripts/changelog.mjs check→changelog: 48 fragment(s) in changelog.d/ are well formed.node .github/scripts/check-mojibake.mjs docs plugins/workflow→ 0 occurrences of U+FFFD in 45 text files.DSH_HARNESS=/data/dsh/bot/workspace/lintenv node scripts/lint-plugins.mjs workflow→ not conclusive here: that prepared oxlint is older than the one CI installs and reports pre-existingno-useless-fallback-in-spreadin untouched code (src/graph.js:583-584,src/service.js:283). This diff adds no spread fallback.Red without the source change
Same test file,
plugins/workflow/src/service.jstaken from92a6aabd:not ok 14:run.runningis['plan']where[]is expected andnodeStatus.planis'running'where'failed'is expected — the ghost the strip paints.ok 15andok 16are green on the old code on purpose:#1414already settles the reap leg, and#1388's acceptance is that an ended run keeps its ending. They pin both, so the next change here cannot quietly regress them.Not run
npm run verifyend to end: this box has nonpm ci/npm run harnessinstall. I ran the parts that cover this change; CI runs the rest.arch-docsandds-bot-patches check: both need a git checkout, and this tree is a tarball. Nocore/, patch or generated file is touched; the owned arch docdocs/subsystems/workflow.mdis updated in this diff, which is whatarch-docschecks.build-client --checkand the smoke test: no client or generated file changes.publicRun().runningempty after a stop), not on a screenshot.Risk
.slice(-20)), same as every other history write, so a run that is ended repeatedly does not grow without bound.runninginnodeStatus. It cannot be moved throughfailNode(it throws), and dropping it silently would lose what happened;publicRun().runningwalks the graph, so it is not painted, andsaveGraphrebuildsnodeStatusfrom the graph's ids on the next save. Recorded here rather than hidden.updatedAtmoves when a run is ended, on every path that ends one. Deliberate: the alternative is the drift this issue is about.reap()anddispose()warn and continue on a run they cannot end; the ids are collected before the record is dropped, so its Bots are still released.#1387(dispose()deletes the records before the release). Not widened by this PR.No
Docs-not-needed:trailer:docs/subsystems/workflow.md(owned pathplugins/workflow/) is updated in this PR.Checkpoint
{ "pr": 1431, "claim": "Chief", "issue": 1424, "summary": "A run ended without settling its node map left publicRun().running naming the node it was on, so the strip kept painting 'Running now' for a run that was over. Replayed onto main fe6c9069: #1414 already settles the reap/dispose leg through failCutoffRun, so that helper is the reviewed endRun and the two legs main did not fix — workflow/reset stopping one run and the catch around a step that threw — now go through it too. An ended run keeps its ending, a node the graph no longer has keeps its record, and the ending reaches the lane history the stage renders.", "files": ["plugins/workflow/src/service.js", "plugins/workflow/test/ephemeral.test.js", "docs/subsystems/workflow.md", "changelog.d/1424-workflow-reap-node-status.fixed.md"], "checks": { "ran": "node --test plugins/workflow/test/*.test.js; node --test plugins/workflow/test/ephemeral.test.js; node .github/scripts/changelog.mjs check; node .github/scripts/check-mojibake.mjs docs plugins/workflow", "result": "green", "at": "0ed32f78de597f25793d9d75d574b36ef2b440e0" }, "next": "Chief: enqueue 0ed32f78de597f25793d9d75d574b36ef2b440e0 and land it; the reviewed PASS at 39c65d05 does not carry over to this tip" }