@zhuxixi/pi-agent-board 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,603 @@
1
+ # Runner Architecture Hardening — PR #1: View State Coordinator Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 建立 detached View State Coordinator,作为 `state.json`/`status.json` 的唯一逻辑写者,根治 #46 类 stale-write 覆盖(spec D3)。
6
+
7
+ **Architecture:** 新增一个 board-root 级 detached 协调器进程(复用 pty-runner 的 detached spawn + JSONL socket + issue #70 的 token-fenced lease 模式),所有语义状态 mutation 变成带 `commandId`/`runId`/`expectedRevision` 的命令,经 durable journal 串行应用后物化。人工完成建立 manual fence,迟到的 auto-state/finalization 结果被拒绝。
8
+
9
+ **Tech Stack:** Node.js (ESM, plain `.mjs` runners), node:net JSONL socket, 现有 `src/core/locks.mjs`(owned lease)、`src/core/atomic.mjs`(原子写)、`src/core/store.mjs`。
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md`(D3 节 + 验收 A7/A7b/A8 + 根治条件 1/2/5)
12
+
13
+ ## Scope:本 plan 只覆盖 PR #1
14
+
15
+ 本 spec 是分阶段 epic。本 plan 实现 **Phase 1(现有行为回归锁定)+ Phase 2a(Coordinator 基础设施 + 三类优先 mutation:markCompleted / auto-state / run finalization)**。后续 PR(不在本 plan):
16
+
17
+ - PR #2(Phase 2b):迁移剩余 writeState/writeStatus 调用点(job-runner 热路径、service 其余站点),白名单清零,A7 完全闭合。
18
+ - PR #3+(Phase 3–6):canonical terminal model、attach snapshot/subscribe、控制命令生命周期、删除 `childInputLooksEmpty()`。
19
+
20
+ **为什么三类优先 mutation 足以闭合 #46**:`markCompleted` 在 run 活跃时被 `isAgentBusy` 拒绝(该守卫保留并移入 coordinator),所以竞争只发生在 run 结束后的异步写者(job-runner post-exit pass、state-runner 分类器)与手动完成之间。迁移这三类即关闭 #46 窗口;during-run 热路径(250ms 节流写)不与 markCompleted 竞争,留到 PR #2。
21
+
22
+ ## Global Constraints
23
+
24
+ - 所有新 runner 进程必须是 plain ESM `.mjs`,不得依赖 Pi 的 jiti loader(参照 `runner/job-runner.mjs` 头注释)。
25
+ - Commit message 用英文,conventional commits 格式。
26
+ - 测试用 `node --test`;隔离环境必须同时设 `AGENT_BOARD_ROOT` 和 `PI_CODING_AGENT_DIR`(paths.mjs defaultRoot 不随后者,KB 已知坑)。
27
+ - 每个 task 结束提交一次;`git add <file>` 按文件 stage,禁止 `git add -A`。
28
+ - 所有文件操作使用 worktree 绝对路径,git 操作使用 `git -C $WT`。
29
+ - `$WT = /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-91-harden-runner-architecture`
30
+ - 可测性硬约束(spec §9):决策函数必须是纯函数,不碰 fs/socket;journal/socket/materialize 副作用全部集中在 coordinator 进程壳内。
31
+
32
+ ## Acceptance 映射(spec §7)
33
+
34
+ | Task | 验收 ID | 说明 |
35
+ |---|---|---|
36
+ | Task 1 | U2 回归基线(现状锁定) | 锁定 PR #1 之前的行为,供后续阶段对比 |
37
+ | Task 2 | A8(决策层) | stale rejection / manual fence 纯函数 |
38
+ | Task 3 | A7b(持久化层) | journal append/replay/GC |
39
+ | Task 4 | A7b(failover) | coordinator 进程、lease、重启重放 |
40
+ | Task 5 | A7b enabler | client ensure/spawn/send |
41
+ | Task 6 | A8(markCompleted 路径) | service.completeView → command |
42
+ | Task 7 | A8(auto-state 路径) | state-runner + job-runner heuristic → command |
43
+ | Task 8 | A8(finalization 路径) | job-runner finalize → command |
44
+ | Task 9 | A7(含白名单) | 架构边界静态测试 |
45
+ | Task 10 | A7 revision 子项 | materializedRevision + legacy 接管 |
46
+
47
+ A1–A6、A9、A11、U1/U3 属于后续阶段,本 plan 无对应 task(epic 拆分,已在 spec §8 声明)。
48
+
49
+ ---
50
+
51
+ ### Task 1: 回归基线锁定(Phase 1)
52
+
53
+ **Files:**
54
+ - Test: `test/control-baseline.test.mjs`(新建)
55
+
56
+ **Interfaces:**
57
+ - Consumes: 现有 `runner/pty-runner.mjs` 的 JSONL socket 协议、`test/pty-runner.integration.test.mjs` 的 `waitFor`/`send` 测试夹具模式。
58
+ - Produces: 基线测试文件,锁定「resize 无 ack」「无 requestId 的 input 无 ack」「hello 含 editorEmpty」三个现状行为。
59
+
60
+ - [ ] **Step 1: 跑现有四组基线套件并记录结果**
61
+
62
+ ```bash
63
+ cd $WT && node --test test/pty-attach-detach-gate.test.mjs test/editor-state-reporter.test.mjs test/host-input.test.mjs test/host-owner-store.test.mjs 2>&1 | tail -20
64
+ ```
65
+
66
+ Expected: 全部 PASS。若有失败,停下来修基线,不要继续。
67
+
68
+ - [ ] **Step 2: 写基线测试——锁定 D4 之前的 fire-and-forget 现状**
69
+
70
+ 新建 `test/control-baseline.test.mjs`,复用 `test/pty-runner.integration.test.mjs` 的 host 启动夹具模式(参考该文件 505-570 行的 editor_state 路由测试):
71
+
72
+ ```js
73
+ import test from "node:test";
74
+ import assert from "node:assert/strict";
75
+ // 夹具:createView + 启动 fake-child pty-runner(参照 test/pty-runner.integration.test.mjs 的 beforeEach 模式)
76
+
77
+ test("baseline: resize command receives no ack (pre-D4 fire-and-forget)", async (t) => {
78
+ // 连接 control socket,发 {"type":"resize","cols":100,"rows":30}
79
+ // 收集 500ms 内所有下行消息
80
+ // assert: 不存在 {type:"ack"} 或 {type:"resize_ack"} 消息
81
+ });
82
+
83
+ test("baseline: UI keystroke input without requestId receives no input_ack", async (t) => {
84
+ // 发 {"type":"input","data":"x"}(无 requestId)
85
+ // assert: 500ms 内无 input_ack
86
+ });
87
+
88
+ test("baseline: hello reply carries editorEmpty field", async (t) => {
89
+ // 发 {"type":"hello","clientId":"t","wantOutput":true}
90
+ // assert: 收到 hello 且 "editorEmpty" in msg
91
+ });
92
+ ```
93
+
94
+ - [ ] **Step 3: 运行新基线测试确认 PASS**
95
+
96
+ ```bash
97
+ cd $WT && node --test test/control-baseline.test.mjs
98
+ ```
99
+
100
+ Expected: 3/3 PASS(它们锁定的是现状,不是新行为)。
101
+
102
+ - [ ] **Step 4: Commit**
103
+
104
+ ```bash
105
+ git -C $WT add test/control-baseline.test.mjs
106
+ git -C $WT commit -m "test: lock pre-hardening control-socket baseline behavior (issue #91)"
107
+ ```
108
+
109
+ ---
110
+
111
+ ### Task 2: 命令决策纯函数模块 `src/core/state-commands.mjs`
112
+
113
+ **Files:**
114
+ - Create: `src/core/state-commands.mjs`
115
+ - Test: `test/state-commands.test.mjs`
116
+
117
+ **Interfaces:**
118
+ - Consumes: `isManualCompletion` from `src/core/auto-state.mjs`;`ViewState`/`RunStatus` typedefs from `src/core/types.mjs`。
119
+ - Produces(后续 task 依赖的精确签名):
120
+ - `STATE_COMMAND_KINDS` — `["mark_completed","auto_state_classified","run_finalized"]`(PR #1 范围)
121
+ - `COMMAND_SOURCES` — `["dashboard-user","service","job-runner","state-runner"]`
122
+ - `validateCommand(raw)` → `{ ok: true, command } | { ok: false, error }`
123
+ - `decideStateTransition(command, currentState, currentStatus)` → `{ action: "apply", mutate: { state?, status? }, reason } | { action: "reject", reason }`(纯函数,返回的 mutate 是字段补丁对象,不做 I/O)
124
+ - reject reasons: `"stale_run" | "manual_fence" | "revision_conflict" | "busy" | "unknown_view"`
125
+
126
+ - [ ] **Step 1: 写失败测试**
127
+
128
+ ```js
129
+ // test/state-commands.test.mjs
130
+ import test from "node:test";
131
+ import assert from "node:assert/strict";
132
+ import { validateCommand, decideStateTransition } from "../src/core/state-commands.mjs";
133
+
134
+ const baseCmd = {
135
+ type: "state_command", commandId: "cmd-1", viewId: "v1", runId: "r1",
136
+ source: "state-runner", expectedRevision: null, kind: "auto_state_classified",
137
+ payload: { classification: { version: 1, kind: "done", semanticState: "completed",
138
+ confidence: "high", source: "model", reason: "x", question: null,
139
+ classifiedAt: 1, lastAgentActivityAt: null, textHash: "h" } },
140
+ };
141
+ const manualCompletedState = { viewId: "v1", currentRunId: "r1", semanticState: "completed",
142
+ processState: "exited", autoState: null, updatedAt: 1 };
143
+
144
+ test("validateCommand rejects missing commandId", () => {
145
+ assert.equal(validateCommand({ ...baseCmd, commandId: "" }).ok, false);
146
+ });
147
+
148
+ test("auto_state_classified rejected when manual fence active", () => {
149
+ const d = decideStateTransition(baseCmd, manualCompletedState, null);
150
+ assert.deepEqual(d, { action: "reject", reason: "manual_fence" });
151
+ });
152
+
153
+ test("auto_state_classified rejected for stale runId", () => {
154
+ const d = decideStateTransition(baseCmd, { ...manualCompletedState, currentRunId: "r2", semanticState: "idle", autoState: {} }, null);
155
+ assert.equal(d.action, "reject");
156
+ assert.equal(d.reason, "stale_run");
157
+ });
158
+
159
+ test("mark_completed rejected while agent busy", () => {
160
+ const cmd = { ...baseCmd, source: "dashboard-user", kind: "mark_completed", payload: {} };
161
+ const d = decideStateTransition(cmd, { ...manualCompletedState, semanticState: "working", processState: "alive" }, null);
162
+ assert.equal(d.action, "reject");
163
+ assert.equal(d.reason, "busy");
164
+ });
165
+
166
+ test("mark_completed applies and clears autoState (fence signal)", () => {
167
+ const cmd = { ...baseCmd, source: "dashboard-user", kind: "mark_completed", payload: {} };
168
+ const d = decideStateTransition(cmd, { ...manualCompletedState, semanticState: "idle", autoState: { source: "model" } }, null);
169
+ assert.equal(d.action, "apply");
170
+ assert.equal(d.mutate.state.semanticState, "completed");
171
+ assert.equal(d.mutate.state.autoState, null);
172
+ });
173
+
174
+ test("revision_conflict when expectedRevision mismatches", () => {
175
+ const cmd = { ...baseCmd, expectedRevision: 5 };
176
+ const d = decideStateTransition(cmd, { ...manualCompletedState, materializedRevision: 7 }, null);
177
+ assert.deepEqual(d, { action: "reject", reason: "revision_conflict" });
178
+ });
179
+ ```
180
+
181
+ - [ ] **Step 2: 运行确认失败**
182
+
183
+ ```bash
184
+ cd $WT && node --test test/state-commands.test.mjs
185
+ ```
186
+
187
+ Expected: FAIL(模块不存在)。
188
+
189
+ - [ ] **Step 3: 实现 `src/core/state-commands.mjs`**
190
+
191
+ 核心结构(完整实现,含 JSDoc 类型标注):
192
+
193
+ ```js
194
+ /**
195
+ * Pure decision layer for View State Coordinator commands (issue #91, spec D3).
196
+ * No fs/net I/O — the coordinator shell owns all side effects.
197
+ */
198
+ import { isManualCompletion } from "./auto-state.mjs";
199
+
200
+ export const STATE_COMMAND_KINDS = Object.freeze(["mark_completed", "auto_state_classified", "run_finalized"]);
201
+ export const COMMAND_SOURCES = Object.freeze(["dashboard-user", "service", "job-runner", "state-runner"]);
202
+
203
+ /** @param {any} raw @returns {{ ok: true, command: object } | { ok: false, error: string }} */
204
+ export function validateCommand(raw) {
205
+ if (!raw || raw.type !== "state_command") return { ok: false, error: "bad_type" };
206
+ if (typeof raw.commandId !== "string" || !raw.commandId) return { ok: false, error: "missing_commandId" };
207
+ if (typeof raw.viewId !== "string" || !raw.viewId) return { ok: false, error: "missing_viewId" };
208
+ if (!STATE_COMMAND_KINDS.includes(raw.kind)) return { ok: false, error: "unknown_kind" };
209
+ if (!COMMAND_SOURCES.includes(raw.source)) return { ok: false, error: "unknown_source" };
210
+ if (raw.expectedRevision != null && typeof raw.expectedRevision !== "number") return { ok: false, error: "bad_expectedRevision" };
211
+ return { ok: true, command: raw };
212
+ }
213
+
214
+ /**
215
+ * @param {object} command @param {object|null} currentState @param {object|null} currentStatus
216
+ * @returns {{ action: "apply", mutate: { state?: object, status?: object }, reason: string }
217
+ * | { action: "reject", reason: string }}
218
+ */
219
+ export function decideStateTransition(command, currentState, currentStatus) {
220
+ if (!currentState) return { action: "reject", reason: "unknown_view" };
221
+ if (command.expectedRevision != null && command.expectedRevision !== (currentState.materializedRevision ?? 0)) {
222
+ return { action: "reject", reason: "revision_conflict" };
223
+ }
224
+ if (command.runId && currentState.currentRunId && command.runId !== currentState.currentRunId) {
225
+ return { action: "reject", reason: "stale_run" };
226
+ }
227
+ if (command.source !== "dashboard-user" && isManualCompletion(currentState)) {
228
+ return { action: "reject", reason: "manual_fence" };
229
+ }
230
+ switch (command.kind) {
231
+ case "mark_completed": {
232
+ if (currentState.processState === "alive") return { action: "reject", reason: "busy" };
233
+ return { action: "apply", reason: "manual_completion", mutate: {
234
+ state: { semanticState: "completed", processState: "exited", needsInput: false,
235
+ hasError: false, question: null, pendingQuestions: [], error: null, autoState: null },
236
+ status: { autoState: null },
237
+ } };
238
+ }
239
+ case "auto_state_classified": {
240
+ // 复用 applyAutoStateToViewState/applyAutoStateToStatus 的守卫(processState/semanticState/manual),
241
+ // 在本函数内把 classification 展开为 state/status 字段补丁,summary/question 逻辑与
242
+ // auto-state.mjs 保持一致(委托给它计算补丁,不在此复制规则)。
243
+ // ... 实现时调用 applyAutoStateToViewState 于副本上并 diff 出补丁。
244
+ break;
245
+ }
246
+ case "run_finalized": {
247
+ // payload: { endedAt, exitCode, semanticState, summary, latestAssistantPreview, ... }
248
+ // 仅在 currentState.processState === "alive" 且 runId 匹配时 apply;
249
+ // 补丁写入 processState:"exited"、endedAt 相关字段与 payload 提供的终态字段。
250
+ break;
251
+ }
252
+ }
253
+ // (完整 switch 的两个 case 分支在实现时按上面注释展开,保持纯函数)
254
+ }
255
+ ```
256
+
257
+ 注意:`auto_state_classified` 分支**必须**复用 `applyAutoStateToViewState` / `applyAutoStateToStatus`(在 state/status 的深拷贝上调用,然后提取变化字段作为补丁),不得复制其规则——保持单一事实来源。
258
+
259
+ - [ ] **Step 4: 运行测试确认 PASS**
260
+
261
+ ```bash
262
+ cd $WT && node --test test/state-commands.test.mjs
263
+ ```
264
+
265
+ - [ ] **Step 5: Commit**
266
+
267
+ ```bash
268
+ git -C $WT add src/core/state-commands.mjs test/state-commands.test.mjs
269
+ git -C $WT commit -m "feat(core): pure decision layer for view-state commands (issue #91)"
270
+ ```
271
+
272
+ ---
273
+
274
+ ### Task 3: Coordinator journal 持久化层 `src/core/coordinator-journal.mjs`
275
+
276
+ **Files:**
277
+ - Create: `src/core/coordinator-journal.mjs`
278
+ - Test: `test/coordinator-journal.test.mjs`
279
+
280
+ **Interfaces:**
281
+ - Consumes: `appendJsonl`/`readJsonl`/`atomicWriteJson` from `src/core/atomic.mjs`。
282
+ - Produces:
283
+ - `journalPath(root)` → `<root>/state-journal.jsonl`
284
+ - `checkpointPath(root)` → `<root>/state-journal.checkpoint.json`
285
+ - `appendCommand(root, record, fs?)` — 追加 `{ command, result, materializedRevision, at }` 并 `fsync`(用 `openSync`/`fsyncSync`/`closeSync`,参照 `src/core/screen-log.mjs` 的 fs 注入模式)
286
+ - `readJournal(root, fs?)` → 全部记录数组(容忍尾行损坏——参照 `readJsonl` 的 skip-corrupt 语义)
287
+ - `findProcessedCommand(root, commandId, fs?)` → 已处理结果或 null(重启幂等:已处理 commandId 返回原结果)
288
+ - `readCheckpoint(root, fs?)` / `writeCheckpoint(root, { materializedRevision, journalBytes }, fs?)`
289
+ - `gcJournal(root, fs?)` — 仅当 checkpoint 写成功后,截断 journal 中 `journalBytes` 之前的内容
290
+
291
+ - [ ] **Step 1: 写失败测试**(注入内存 fake fs,参照 `test/` 中 screen-log 相关测试的 fs 注入模式;无现成模式则用 `node:fs` 真实临时目录 + `t.after` 清理)
292
+
293
+ ```js
294
+ test("append + read round-trips records with increasing revisions", () => { /* 3 条记录,revision 1/2/3 */ });
295
+ test("findProcessedCommand returns the original result for a processed commandId", () => { /* 幂等语义 */ });
296
+ test("readJournal skips a corrupt tail line", () => { /* 尾部写半行 JSON */ });
297
+ test("gcJournal truncates only after checkpoint write succeeds", () => { /* 先 gc(无 checkpoint)→ 不截断;写 checkpoint → gc → 截断 */ });
298
+ ```
299
+
300
+ - [ ] **Step 2: 运行确认失败** → **Step 3: 实现** → **Step 4: 确认 PASS** → **Step 5: Commit**
301
+
302
+ ```bash
303
+ git -C $WT add src/core/coordinator-journal.mjs test/coordinator-journal.test.mjs
304
+ git -C $WT commit -m "feat(core): durable command journal with checkpoint GC (issue #91)"
305
+ ```
306
+
307
+ ---
308
+
309
+ ### Task 4: Coordinator 进程 `runner/state-coordinator.mjs`
310
+
311
+ **Files:**
312
+ - Create: `runner/state-coordinator.mjs`
313
+ - Modify: `src/core/paths.mjs`(加 `coordinatorEndpointPathFor(platform, root)`:POSIX → `<root>/coordinator.sock`,win32 → `\\.\pipe\agent-board-coordinator-<sha256(root).slice(0,16)>`,参照 `hostEndpointPathFor` 83 行)
314
+ - Test: `test/state-coordinator.integration.test.mjs`
315
+
316
+ **Interfaces:**
317
+ - Consumes: Task 2 的 `validateCommand`/`decideStateTransition`;Task 3 的 journal 函数;`src/core/store.mjs` 的 `readState`/`readStatus`/`writeState`/`writeStatus`(coordinator 是唯一合法 import 方);`acquireOwnedViewLock` from `src/core/locks.mjs`(168 行)。
318
+ - Produces:
319
+ - 进程入口:`node runner/state-coordinator.mjs <root>`(argv 直接传 root,不写 config 文件——coordinator 无 per-view 配置)
320
+ - socket 协议(JSONL,server 模式参照 `runner/pty-runner.mjs` 的 `net.createServer` + 行缓冲模式):
321
+ - 上行 `{"type":"state_command", ...}`(Task 2 定义)
322
+ - 下行 `{"type":"state_command_result","commandId":"...","status":"applied"|"rejected","reason":string|null,"materializedRevision":number}`
323
+ - 上行 `{"type":"ping"}` → 下行 `{"type":"pong","instanceId":"...","startedAt":...}`(ensureCoordinator 探活用)
324
+ - 环境变量 `AGENT_BOARD_COORDINATOR=off` 时进程立即退出(测试/降级用)
325
+
326
+ **行为规格(实现依据,逐条对应):**
327
+
328
+ 1. 启动时先抢 lease:`acquireOwnedViewLock(root, "_coordinator", "state-coordinator", { ... })`;抢不到 → 打印到 stderr 并 `process.exit(0)`(另一个 coordinator 已是 owner,幂等退出)。
329
+ 2. 启动时 replay journal:`findProcessedCommand` 依赖的已处理集合载入内存;对每条 journal 记录检查对应 view 的 state/status 是否已物化到该 `materializedRevision`——未物化则补写(修复崩溃窗口:journal 已写但物化未完成)。
330
+ 3. 每个 view 的 legacy 接管:首次处理某 view 的命令时,若 `state.json` 无 `materializedRevision` 字段,先写 `materializedRevision: 1` 再应用命令(A7 revision 子项 + spec 的 legacy 迁移规则)。
331
+ 4. 命令循环:`validateCommand` → 已在已处理集合 → 直接返回原结果(不重复副作用)→ 否则 `decideStateTransition` → apply 则 `appendCommand` + fsync → 物化 state(和 currentRunId 匹配的 status,如果存在且 runId 匹配)→ 写时给两份文件都打同一个 `materializedRevision` → 返回结果。
332
+ 5. `state.json` 与 status 的 revision 一致性只对 `currentRunId` 对应的 status 生效;无 currentRunId 或 status 文件不存在时只写 state.json(spec 根治条件 5 的适用范围)。
333
+ 6. materialize 用 `withFileLockSync`(locks.mjs 38 行)包裹每个 view 的写对,减少崩溃时的半物化窗口;coordinator 重启 replay 兜底(行为 2)。
334
+ 7. socket cleanup:正常退出/SIGTERM 时删除自己的 socket 文件(仅当 dev/ino 匹配自己 bind 的——参照 pty-runner 的 per-instance endpoint cleanup 语义)。
335
+
336
+ - [ ] **Step 1: 写失败 integration 测试**
337
+
338
+ ```js
339
+ // test/state-coordinator.integration.test.mjs
340
+ // 夹具:mkdtemp 隔离 root(设 AGENT_BOARD_ROOT + PI_CODING_AGENT_DIR),
341
+ // spawn process.execPath runner/state-coordinator.mjs <root>,
342
+ // 用 net.createConnection 连 socket 收发 JSONL(参照 test/pty-runner.integration.test.mjs 模式)。
343
+
344
+ test("coordinator applies mark_completed and materializes state with revision", async () => {
345
+ // createView 造 v1(idle)→ 发 mark_completed 命令
346
+ // assert: result.status === "applied",state.json 含 semanticState completed + materializedRevision ≥ 1
347
+ });
348
+
349
+ test("duplicate commandId returns the original result without re-applying", async () => {
350
+ // 同一 commandId 发两次 mark_completed
351
+ // assert: 两次 result 相同;state.json 的 updatedAt 未第二次变化(可用 journal 行数断言只 append 一次)
352
+ });
353
+
354
+ test("stale auto_state_classified after manual completion is rejected", async () => {
355
+ // mark_completed 应用后,发 state-runner 来源的 auto_state_classified
356
+ // assert: status "rejected", reason "manual_fence"(A8 核心场景)
357
+ });
358
+
359
+ test("coordinator restart replays journal and stays idempotent", async () => {
360
+ // kill coordinator(SIGTERM)→ 重新 spawn → 重发已处理 commandId
361
+ // assert: 返回原结果,无重复副作用(A7b 核心场景)
362
+ });
363
+
364
+ test("second coordinator instance exits immediately (lease held)", async () => {
365
+ // 第一个持有 lease 时 spawn 第二个 → assert 第二个进程在 2s 内退出且 state 未被破坏
366
+ });
367
+ ```
368
+
369
+ - [ ] **Step 2: 运行确认失败**(`runner/state-coordinator.mjs` 不存在)
370
+
371
+ - [ ] **Step 3: 实现**(进程骨架参照 `runner/state-runner.mjs` 的简洁度 + `runner/pty-runner.mjs` 的 socket server 模式;决策/持久化全部委托 Task 2/3 的模块,进程壳只做 socket、lease、调用顺序)
372
+
373
+ - [ ] **Step 4: 运行测试确认 5/5 PASS**
374
+
375
+ ```bash
376
+ cd $WT && node --test test/state-coordinator.integration.test.mjs
377
+ ```
378
+
379
+ - [ ] **Step 5: Commit**
380
+
381
+ ```bash
382
+ git -C $WT add runner/state-coordinator.mjs src/core/paths.mjs test/state-coordinator.integration.test.mjs
383
+ git -C $WT commit -m "feat(runner): detached view-state coordinator with lease, journal replay, idempotent commands (issue #91)"
384
+ ```
385
+
386
+ ---
387
+
388
+ ### Task 5: Coordinator client `src/core/coordinator-client.mjs`
389
+
390
+ **Files:**
391
+ - Create: `src/core/coordinator-client.mjs`
392
+ - Modify: `src/core/launch.mjs`(加 `launchCoordinator(root, opts)`,模式照抄 `launchAutoState`(114 行)但 argv 为 `[coordinatorScript, root]`,不写 config 文件)
393
+ - Test: `test/coordinator-client.test.mjs`
394
+
395
+ **Interfaces:**
396
+ - Consumes: `coordinatorEndpointPathFor`(Task 4)、`launchCoordinator`、Task 2 的命令形状。
397
+ - Produces:
398
+ - `sendStateCommand(root, command, opts?)` → `Promise<{ status: "applied"|"rejected", reason: string|null, materializedRevision: number }>`;内部:构造 commandId(`newRunId()` 复用 `src/core/ids.mjs`)→ `ensureCoordinator` → 连接 socket → 发送 → 等待匹配 commandId 的 result(超时 5s → `{ status: "rejected", reason: "timeout" }`)
399
+ - `ensureCoordinator(root, opts?)` — probe socket(`{"type":"ping"}`,1s 超时);失败则 `launchCoordinator` 并轮询 pong(10s 上限,100ms 间隔);重复调用幂等(多个 client 并发 ensure 只应最终有一个 owner——由 Task 4 的 lease 保证,client 不需要自己的锁)
400
+ - 降级:`AGENT_BOARD_COORDINATOR=off` 时 `sendStateCommand` 返回 `{ status: "rejected", reason: "coordinator_disabled" }`,调用方回退到旧直写路径(PR #1 期间保留的兼容逃生门)
401
+
402
+ - [ ] **Step 1: 写失败测试**(fake socket server 夹具:测试文件内 `net.createServer` 起临时 socket,断言 client 的消息形状与超时行为;ensureCoordinator 的 spawn 路径用真 coordinator + 隔离 root 测一个 happy path)
403
+
404
+ - [ ] **Step 2–5: 失败 → 实现 → PASS → Commit**
405
+
406
+ ```bash
407
+ git -C $WT add src/core/coordinator-client.mjs src/core/launch.mjs test/coordinator-client.test.mjs
408
+ git -C $WT commit -m "feat(core): coordinator client with ensure/spawn and idempotent command send (issue #91)"
409
+ ```
410
+
411
+ ---
412
+
413
+ ### Task 6: 迁移 markCompleted(A8 路径一)
414
+
415
+ **Files:**
416
+ - Modify: `src/runtime/service.mjs`(`completeView`,约 405-425 行)
417
+
418
+ **Interfaces:**
419
+ - Consumes: Task 5 的 `sendStateCommand`。
420
+ - Produces: `completeView(viewId)` 改为发送 `{ kind: "mark_completed", source: "dashboard-user", viewId, runId: state.currentRunId, expectedRevision: null, payload: {} }`;`isAgentBusy` 的前置 UI 检查保留(快速反馈),但权威判断在 coordinator。
421
+
422
+ - [ ] **Step 1: 写失败测试**
423
+
424
+ 修改/新增 `test/service.test.mjs` 用例(该文件已有 completeView 相关测试,找到它们):
425
+
426
+ ```js
427
+ test("completeView goes through the coordinator command path", async () => {
428
+ // 隔离 root + 起真 coordinator;service.createService({ root, ... })
429
+ // completeView 一个 idle view
430
+ // assert: journal 中存在 kind=mark_completed 的记录(而不是只检查 state.json)
431
+ });
432
+ ```
433
+
434
+ 现有 `completeView` 测试需保持通过(行为兼容:返回值形状 `{ ok, error? }` 不变;coordinator rejected(busy) 映射为原错误文案 `"Wait for the active run to finish before marking done"`)。
435
+
436
+ - [ ] **Step 2–5: 失败 → 实现 → PASS → Commit**
437
+
438
+ ```bash
439
+ git -C $WT add src/runtime/service.mjs test/service.test.mjs
440
+ git -C $WT commit -m "refactor(service): route markCompleted through view-state coordinator (issue #91)"
441
+ ```
442
+
443
+ ---
444
+
445
+ ### Task 7: 迁移 auto-state 写入(A8 路径二)
446
+
447
+ **Files:**
448
+ - Modify: `runner/state-runner.mjs`(54-63 行的 writeStatus/writeState)
449
+ - Modify: `runner/job-runner.mjs` 的 `persistUnlessManual`/heuristic auto-state 路径(121-123、225-237、265、290、380、406、444 行的 `isManualCompletion` 守卫区域中属于 auto-state 分类结果写入的部分)
450
+ - Test: `test/state-coordinator.integration.test.mjs`(追加端到端用例)
451
+
452
+ **Interfaces:**
453
+ - Consumes: Task 5 的 `sendStateCommand`。
454
+ - Produces: state-runner 与 job-runner 的分类结果写入改为 `{ kind: "auto_state_classified", source: "state-runner"|"job-runner", viewId, runId, expectedRevision: null, payload: { classification } }`;`applyAutoStateToViewState/Status` 的调用移到 coordinator 决策层(Task 2 已完成);runner 本地的 `isManualCompletion` 预检查**保留**(避免无意义命令),但作为优化而非正确性依赖。
455
+
456
+ - [ ] **Step 1: 写失败测试(A8 端到端)**
457
+
458
+ ```js
459
+ test("A8: manual completion fences a late model classification (end-to-end)", async () => {
460
+ // 隔离 root;起 coordinator;createView + 造一个 exited run 的 status
461
+ // 1. dashboard 路径 mark_completed(sendStateCommand, source dashboard-user)
462
+ // 2. 模拟 state-runner 迟到:sendStateCommand(auto_state_classified, source state-runner)
463
+ // assert: 第二条 rejected(manual_fence);state.json 仍是 completed 且 autoState 为 null
464
+ // kill coordinator 重启 → 再发一次同样的迟到命令 → 仍 rejected(journal 重放后 fence 仍在)
465
+ });
466
+ ```
467
+
468
+ - [ ] **Step 2–5: 失败 → 实现 → PASS → Commit**
469
+
470
+ ```bash
471
+ git -C $WT add runner/state-runner.mjs runner/job-runner.mjs test/state-coordinator.integration.test.mjs
472
+ git -C $WT commit -m "refactor(runner): route auto-state classification through coordinator (issue #91)"
473
+ ```
474
+
475
+ ---
476
+
477
+ ### Task 8: 迁移 run finalization(A8 路径三)
478
+
479
+ **Files:**
480
+ - Modify: `runner/job-runner.mjs`(`finalizeRun` 周边:约 279、305-306 行的终态 writeState/writeStatus)
481
+ - Modify: `src/runtime/service.mjs` 的 reconcile/final-state 写入(383-393、420-423、453、535、1497、1524、1745-1763 行中**仅与 run 终态相关的站点**;逐站点判断,属于 view 元数据/visited 等非终态语义的站点留在白名单,PR #2 迁移)
482
+ - Test: `test/runner.integration.test.mjs`(更新现有 #46 回归测试 `runner does not clobber a manual completion made during post-exit model passes`,约 339 行,断言路径从「直写 state.json」改为「coordinator journal 存在记录且 state.json 由 coordinator 物化」)
483
+
484
+ **Interfaces:**
485
+ - Consumes: Task 5 的 `sendStateCommand`;Task 2 的 `run_finalized` 分支。
486
+ - Produces: `run_finalized` 命令 payload:`{ endedAt, exitCode, semanticState, summary, latestAssistantPreview, lastAgentActivityAt }`;job-runner 的退出路径不再直接写终态,改为发命令并等待 applied(5s 超时,超时则落 diagnostic 并退出—— coordinator 重启后会从 journal 补物化,见 Task 4 行为 2)。
487
+
488
+ - [ ] **Step 1: 更新 #46 回归测试为 coordinator 断言** → **Step 2: 确认失败** → **Step 3: 实现** → **Step 4: PASS** → **Step 5: Commit**
489
+
490
+ ```bash
491
+ git -C $WT add runner/job-runner.mjs src/runtime/service.mjs test/runner.integration.test.mjs
492
+ git -C $WT commit -m "refactor(runner): route run finalization through coordinator (issue #91)"
493
+ ```
494
+
495
+ ---
496
+
497
+ ### Task 9: 架构边界静态测试(A7,含白名单)
498
+
499
+ **Files:**
500
+ - Create: `test/architecture-writer-boundary.test.mjs`
501
+
502
+ **Interfaces:**
503
+ - Consumes: `node:fs` 读源码文件。
504
+ - Produces: 静态扫描测试 + 白名单常量(测试文件顶部,每项附 justification 注释)。
505
+
506
+ - [ ] **Step 1: 写测试(一次写好,先失败)**
507
+
508
+ ```js
509
+ import test from "node:test";
510
+ import assert from "node:assert/strict";
511
+ import { readFileSync, readdirSync } from "node:fs";
512
+ import { join } from "node:path";
513
+
514
+ // PR #1 白名单:尚未迁移的既有写入点,PR #2 清零。
515
+ // 每项必须附 justification;新增条目视为架构倒退,必须 CR 讨论。
516
+ const WRITE_STATE_ALLOWLIST = new Map([
517
+ ["src/runtime/service.mjs", "PR #1: 非终态站点(markVisited/adopt/reconcile 元数据)待 PR #2 迁移"],
518
+ ["src/core/store.mjs", "createView bootstrap 初始化写;coordinator 接管前的建行路径,PR #2 迁移"],
519
+ ["runner/job-runner.mjs", "PR #1: during-run 热路径节流写(250ms)待 PR #2 迁移;与 markCompleted 无竞争(busy 守卫)"],
520
+ ]);
521
+ const ALLOWED_WRITER_MODULES = new Set(["runner/state-coordinator.mjs"]);
522
+
523
+ test("only the coordinator imports writeState/writeStatus in production code (allowlisted exceptions)", () => {
524
+ const files = ["src", "runner", "index.ts"].flatMap(function walk(p) { /* 递归收集 .mjs/.ts */ });
525
+ for (const file of files) {
526
+ const src = readFileSync(file, "utf8");
527
+ if (!/import \{[^}]*write(State|Status)/.test(src)) continue;
528
+ if (ALLOWED_WRITER_MODULES.has(file)) continue;
529
+ const justification = WRITE_STATE_ALLOWLIST.get(file);
530
+ assert.ok(justification, `${file} imports writeState/writeStatus without an allowlist justification`);
531
+ }
532
+ });
533
+
534
+ test("allowlist does not shrink silently (update the map when migrating)", () => {
535
+ // 断言白名单中的文件确实仍含写入 import——迁移完成后必须同步删条目,否则白名单腐化
536
+ for (const [file] of WRITE_STATE_ALLOWLIST) {
537
+ const src = readFileSync(file, "utf8");
538
+ assert.ok(/write(State|Status)/.test(src), `${file} no longer writes — remove its allowlist entry`);
539
+ }
540
+ });
541
+ ```
542
+
543
+ 注:第二个测试在 PR #2 迁移完成时会失败,迫使迁移者删白名单条目——这是设计意图(白名单只许缩不许腐)。
544
+
545
+ - [ ] **Step 2–5: 失败 → 调整到当前真实白名单 → PASS → Commit**
546
+
547
+ ```bash
548
+ git -C $WT add test/architecture-writer-boundary.test.mjs
549
+ git -C $WT commit -m "test(arch): writer-boundary static test with shrinking allowlist (issue #91)"
550
+ ```
551
+
552
+ ---
553
+
554
+ ### Task 10: materializedRevision 字段与 legacy 接管
555
+
556
+ **Files:**
557
+ - Modify: `src/core/types.mjs`(`ViewState`/`RunStatus` typedef 加 `@property {number} [materializedRevision]`)
558
+ - Modify: `src/runtime/service.mjs`(`loadRow`/读侧:容忍缺失 revision 字段——不强制 reconcile,读取兼容逻辑保留到 PR #2 再启用 revision 一致性检查)
559
+ - Test: `test/state-coordinator.integration.test.mjs`(追加用例)
560
+
561
+ - [ ] **Step 1: 写失败测试**
562
+
563
+ ```js
564
+ test("legacy view without materializedRevision gets revision 1 on first coordinator touch", async () => {
565
+ // createView 造行(无 revision)→ 发任意命令 → assert state.json.materializedRevision === 1(或 2,若接管与应用分开计)
566
+ });
567
+ ```
568
+
569
+ - [ ] **Step 2–5: 失败 → 实现 → PASS → Commit**
570
+
571
+ ```bash
572
+ git -C $WT add src/core/types.mjs src/runtime/service.mjs test/state-coordinator.integration.test.mjs
573
+ git -C $WT commit -m "feat(core): materializedRevision field with legacy adoption (issue #91)"
574
+ ```
575
+
576
+ ---
577
+
578
+ ### Task 11: 全量回归 + PR 准备
579
+
580
+ - [ ] **Step 1: 跑全量测试**
581
+
582
+ ```bash
583
+ cd $WT && node --test test/ 2>&1 | tail -30
584
+ ```
585
+
586
+ Expected: 全 PASS。flaky 参照仓库历史处理(本仓有 deflake 传统,见 git log 的 deflake commits)。
587
+
588
+ - [ ] **Step 2: 验收逐项对账**(github-issue-driven step 9)
589
+
590
+ | 验收 ID | 本 PR 状态 | 证据 |
591
+ |---|---|---|
592
+ | A7 | 部分(白名单内站点未迁移) | Task 9 测试通过,白名单仅 3 项且均有 justification |
593
+ | A7b | ✅ | Task 4 的 restart/failover 用例 |
594
+ | A8 | ✅ | Task 6/7/8 + 端到端用例 |
595
+ | 其余 | pending(后续阶段) | spec §8 分阶段声明 |
596
+
597
+ - [ ] **Step 3: 本地快速 CR**(requesting-code-review 或 pi workflow code-review)
598
+
599
+ ## Self-Review 记录
600
+
601
+ - Spec 覆盖:本 plan 只覆盖 D3 + Phase 1;D1/D2/D4/D5 属于后续 PR(spec §8 已声明分阶段,scope 节再次声明)。
602
+ - 占位符扫描:无 TBD/TODO;Task 2 的 `auto_state_classified`/`run_finalized` 分支给了实现策略(委托 auto-state.mjs 后 diff 补丁),非空泛占位。
603
+ - 类型一致性:`commandId`/`materializedRevision`/`state_command`/`state_command_result`/`decideStateTransition`/`sendStateCommand`/`ensureCoordinator`/`launchCoordinator`/`coordinatorEndpointPathFor` 在 task 间一致。