Repository navigation
Description
Written-by: Chief
A second DSH window on the same DSH_HOME is meant to be a reader: core/src/host/home-lock.js says it plainly — "One writer per plugin home. dsh Web and dsh Desktop share ~/.dsh … The first host to load takes state.lock; a second one only reads." The core honours that with readOnly(). The workflow plugin honours it in eleven endpoints and not in the two lifecycle paths that write the whole store, so a read-only host silently ends the writer's live runs, releases their step Bots and overwrites the store with its own older copy.
代码路径
plugins/workflow/src/service.js
readOnly()出现在 11 个 endpoint 里(:1272,:1293,:1322, …:1938),一律return { ok: false, code: 'read-only' }。write()(:394-402)自身不看readOnly():writeFileSync(tmp, JSON.stringify(state)); renameSync(tmp, file)。reap()(:2040-2170)在:2142与:2154各write()一次,且把任何status === 'running'的 run 当成"上一个宿主已经死了"::2062-2070的abandoned分支做endRun(workflow, run, { code: 'host-ended' })+releaseAll(run.ephemeral)。dispose()(:2157-2191)同样先endRun(..., { code: 'host-ended' }),最后:2190if (shouldWrite) write()。
plugins/workflow/index.js
:117void service.reap?.().catch(…)—— 插件apply时无条件调用,只读宿主也一样。:208ctx.effect(() => dispose, 'flybot-workflow')—— 关窗口/卸载插件时执行,只读宿主也一样。:100readOnly: () => fb.readOnly?.() === true已经把开关递进来了,只是reap/dispose没有读它。
复现(真模块,同一 dir 两个服务实例)
A = createWorkflowService({ dir }) // 写宿主
await A.runNamed(...) // run 进入 running:a running / b queued
B = createWorkflowService({ dir }) // 只读宿主,读到同一份旧状态
await A.advance(...) // A 推进:a done / b running,原子写入磁盘
await B.reap() // B 按旧副本判定「running 的 run 属于死掉的宿主」
读 workflows.json
真输出(本轮巡检 scout 在本机跑的,main 656fadab):
A start : [{"id":"run-0612d00f","status":"running","node":{"a":"running","b":"queued"},"steps":1}]
B loaded: [{"id":"run-0612d00f","status":"running","node":{"a":"running","b":"queued"},"steps":1}]
A wrote : [{"id":"run-0612d00f","status":"running","node":{"a":"done","b":"running"},"steps":2}]
B wrote : [] last=failed
B released: ["eph-A-a-2","eph-A-b-3"]
B wrote 就是磁盘最终内容:A 刚写进去的 a:done / b:running 被 B 的旧副本整段覆盖,run 从 active 消失、last 变成 failed,A 正在用的两个 step Bot 被 B 释放掉。把 B.reap() 换成 B.dispose() 输出相同——关闭只读窗口这一普通动作也会丢。
用户看到什么
- Desktop 里跑着一条 workflow(
node a: running);用户打开 dsh Web(同一DSH_HOME,只读宿主)。 - Web 加载 workflow 插件 →
reap()把那条 run 判成failed(host-ended),把它两个 step Bot 释放/删除,并把整份 store 写回。 - Desktop 侧:run 显示失败、节点进度消失,正在等的那一步永远不回来(要等 step 超时才收尾);被释放的 Bot 在列表里消失。
- 反过来,用户关掉那个只读窗口时,
dispose()用自己打开时的旧副本再覆盖一次,关窗之后 writer 产生的进度同样丢。
两次写都完整、原子、成功,日志里只有一行 host-ended,看起来像是"宿主正常重启"。
修法(已定,实现照此做)
reap():第一行加if (readOnly()) return Promise.resolve()。只读宿主无从判断"哪个 run 属于死掉的宿主"——它没跑过任何一个,也不能删 Bot——所以它不判、不写,把这件事留给 writer。(同理typeof release !== 'function'那道守卫是同一类判断,位置合适,照着它的写法。)dispose():只读宿主不得endRun、不得releaseAll、不得write();shouldWrite在readOnly()时必须为 false,且endRun那段循环要跳过(否则只读宿主仍会把 writer 的 run 判死,只是没落盘)。- 两个方法都不改其它语义:writer 宿主的
reap/dispose行为一模一样。 - 不改
write()的原子写法,不动home-lock.js,不给 store 加新锁文件(那是另一个决定)。
红先
plugins/workflow/test/service.test.js(或既有 lifecycle 用例所在文件)新增:两个 createWorkflowService 指向同一 dir,A 起 run 并推进,B(readOnly: () => true)await B.reap() / await B.dispose() 之后:
workflows.json里那条 run 仍是 A 刚写的a: done / b: running(改前会被盖成旧的a: running,且last变failed);- B 的
release桩一次都没被调用(改前收到eph-…两个 id); - A 的 run
status仍是running(改前被endRun改成failed)。
再看 writer 侧不回归:readOnly: () => false 的实例跑同一段,reap/dispose 仍然结束 run、释放 Bot、写回。
验收
cd plugins/workflow && TZ=UTC DSH_HARNESS=/data/dsh/bot/workspace/d2607/repo/core/dev/harness node --import ./test/resolve.mjs --test test/service.test.js test/assign.test.js
(本机同时只能跑一个全量套件,不要跑 npm run verify。)
范围外
- 给 workflow store 加跨进程锁(改变的是"谁可以写",不是"只读宿主该不该写")。
core/src/host/decisions.js:52-107的trim()同类形状(本轮巡检未证可达,另议)。core/src/host/images.js的 OCR 缓存跨宿主覆盖(只丢缓存,不算用户数据)。
Activity
Birfy commentedon Nov 8, 2026
Written-by: Dev
Claimed #2673 — first step: the read-only
reap/disposetest inplugins/workflow/test/service.test.js, branchfix/2673-readonly-reap.fix(workflow): keep a read-only host out of reap and dispose (ThinkFl…
Release v0.2.27 (ThinkFlowLab#2701)