Skip to content

A read-only host reaps the writer's live workflow: endRun + release its step Bots + whole-store writeback #2673

Closed

Description

@Birfy

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' }),最后 :2190 if (shouldWrite) write()。

plugins/workflow/index.js

  • :117 void service.reap?.().catch(…) —— 插件 apply 时无条件调用,只读宿主也一样。
  • :208 ctx.effect(() => dispose, 'flybot-workflow') —— 关窗口/卸载插件时执行,只读宿主也一样。
  • :100 readOnly: () => 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() 输出相同——关闭只读窗口这一普通动作也会丢。

用户看到什么

  1. Desktop 里跑着一条 workflow(node a: running);用户打开 dsh Web(同一 DSH_HOME,只读宿主)。
  2. Web 加载 workflow 插件 → reap() 把那条 run 判成 failed(host-ended),把它两个 step Bot 释放/删除,并把整份 store 写回。
  3. Desktop 侧:run 显示失败、节点进度消失,正在等的那一步永远不回来(要等 step 超时才收尾);被释放的 Bot 在列表里消失。
  4. 反过来,用户关掉那个只读窗口时,dispose() 用自己打开时的旧副本再覆盖一次,关窗之后 writer 产生的进度同样丢。

两次写都完整、原子、成功,日志里只有一行 host-ended,看起来像是"宿主正常重启"。

修法(已定,实现照此做)

  1. reap():第一行加 if (readOnly()) return Promise.resolve()。只读宿主无从判断"哪个 run 属于死掉的宿主"——它没跑过任何一个,也不能删 Bot——所以它不判、不写,把这件事留给 writer。(同理 typeof release !== 'function' 那道守卫是同一类判断,位置合适,照着它的写法。)
  2. dispose():只读宿主不得 endRun、不得 releaseAll、不得 write();shouldWrite 在 readOnly() 时必须为 false,且 endRun 那段循环要跳过(否则只读宿主仍会把 writer 的 run 判死,只是没落盘)。
  3. 两个方法都不改其它语义:writer 宿主的 reap/dispose 行为一模一样。
  4. 不改 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

  1. added
    bugSomething isn't working
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    area:automationScheduled tasks and automation
    on Nov 8, 2026
  2. Birfy commented on Nov 8, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev

    Claimed #2673 — first step: the read-only reap/dispose test in plugins/workflow/test/service.test.js, branch fix/2673-readonly-reap.

  3. self-assigned this
    on Nov 8, 2026
  4. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    agent-prPull request opened by an agent
    and removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Nov 8, 2026
  5. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    agent-prPull request opened by an agent
    on Nov 8, 2026
  6. added 2 commits that reference this issue on Nov 9, 2026
    a9f0646
    af9a30b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:automationScheduled tasks and automationbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions