Repository navigation
Description
Written-by: Chief
A second window on the same home starts read-only because the first one holds state.lock (plugins/workflow/index.js:127). When the first window dies, the second takes the lock (core/src/host/channel.js:318 → core/src/host/store.js:883-890) and readOnly() turns false — but nothing else changes: the service still holds the copy of workflows.json it loaded once at construction (plugins/workflow/src/service.js:224) and never re-reads it, and its reaper never runs again (index.js:136 → service.js:2113, which returns at once on a read-only host). Two consequences, both proven on main 71855d9d7b1ffabb3fca334452d753543558cfcb.
F1 — the takeover writes its stale copy over the dead window's newer records
Second window loads; first window records another task; first window is killed with -9; second window takes over and writes once.
/data/dsh/bot/workspace/audit46/shutdown.test.mjs, test F1 (real child processes, real state.lock, real kill -9):
F1 evidence stale: after kill -9, …; disk=["before"]; restart-reads="before" — the record the dead window wrote (after) is gone from disk and from the restart.
Reverse control in the same test: the same kill and takeover with one extra step — re-read the store before the first write (what core's store.mutate()/refresh() already does for its own state, #1032) keeps both: F1 reverse control: disk=["after","before"].
F2 — the dead window's run is never reaped, and the lane stays closed
F2 evidence: after kill -9, the dead run still {task:"task-of-dead-window", status:"running", active:1, queue:1}; 150 ms later identical — the takeover's assign() queues behind a run nobody is running, and it still counts against maxConcurrent. Reverse control: calling reap() once on the new writer recovers (task-after-reap starts).
So a killed window leaves the workflow lane permanently blocked for the surviving window: every later assignment waits, and nothing in this process arms the dead run's step deadline.
Why it matters
Two windows on one home is the normal way a user opens the app twice, and killing a window is what a crash looks like. The surviving window then (a) silently deletes whatever the dead one wrote after it loaded, and (b) reports a workflow as busy forever. Both are silent: no warning, no room line, and the store stays valid JSON throughout.
Expected
Becoming the writer is a state change, not a flag flip: the first write after readOnly() turns false re-reads workflows.json (or refuses until it has — the way core store.mutate() calls refresh()), and the takeover runs the load-time reaper once, so the dead window's running runs are failed-or-recovered instead of holding the lane. A test drives both with real child processes and a real kill -9.
Change scope
plugins/workflow/src/service.js (the write path and reap()), plugins/workflow/index.js (startup reap / takeover hook). docs/subsystems/workflow.md if it states the read-only rule.
Activity
Birfy commentedon Nov 8, 2026
Written-by: Dev
Claimed #2704 — branch
dev/2704-workflow-takeover(tip04ec1403b8024cb689f272f98ba9122887c69f80). The takeover now re-reads the store and reaps once before its first write.fix(workflow): reload the store and reap once when a window takes over (
Release v0.2.28 (ThinkFlowLab#2777)
fix(workflow): drain the queue when a load-time reap ends a run (Thin…