@zhuxixi/pi-agent-board 0.6.1 → 0.6.2

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ conventional commits by `scripts/release_helper.mjs`. Entries are
5
5
  forward-only: they begin with the first release after this file landed —
6
6
  for earlier history, see the git log and the pull-request list.
7
7
 
8
+ ## [0.6.2] - 2026-09-09
9
+
10
+ ### Fixes
11
+
12
+ - **host**: claim role no longer blocks terminal host replacement (#99) (#100)
13
+
14
+ [0.6.2]: https://github.com/zhuxixi/pi-agent-board/compare/v0.6.1...v0.6.2
15
+
8
16
  ## [0.6.1] - 2026-09-08
9
17
 
10
18
  ### Fixes
@@ -0,0 +1,267 @@
1
+ # claimPid-Blocks-Replace Fix 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:** Fix issue #99 — an exited/failed host whose `claimPid` (the dashboard process) is still alive must be replaceable, so attach no longer pends to `host start timed out`.
6
+
7
+ **Architecture:** The fix relaxes one pure decision function (`canReplaceHost` in `src/core/host-coordination.mjs`): the claim role no longer participates in the replacement gate for terminal hosts, because claim protection (a launcher mid-transaction between claim and spawn) only matters while a host is `starting`. The observation helper (`observeHostForReplace` in `src/runtime/service.mjs`) drops its now-unused `claimObservation` field.
8
+
9
+ **Tech Stack:** Node.js (node:test, node:assert/strict), plain ESM modules, no new dependencies.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Change only the claim-role gate semantics; runner/child unknown observations must still block replacement (spec A2), `launchLeaseActive` must still block (spec A3), non-terminal hosts must still refuse (spec A4).
16
+ - `canReplaceHost` signature loses `claimObservation`; `observeHostForReplace` stops computing it (one fewer `process.kill(pid, 0)` syscall).
17
+ - All existing tests in `test/host-coordination.test.mjs` and `test/host-resolver.test.mjs` must keep passing (no behavioral regressions outside the claim-role gate).
18
+ - `npm run typecheck` must pass (service.mjs is .mjs but the repo runs tsc over TS sources; keep JSDoc types consistent).
19
+ - No changes to pty-runner.mjs, store.mjs, or host.json structure (spec non-goals).
20
+
21
+ ## Acceptance traceability
22
+
23
+ | Spec ID | Plan coverage |
24
+ |---------|---------------|
25
+ | A1 (exited/failed + live claimPid → replaceable) | Task 1 Step 1 test `canReplaceHost allows replacing a terminal host whose claimPid is still alive` |
26
+ | A2 (runner/child unknown still blocks) | Task 1 Step 1 test `canReplaceHost still refuses unknown runner/child observations` + legacy case 1 of `canReplaceHost refuses unknown observations` |
27
+ | A3 (launchLeaseActive still blocks) | Task 1 Step 1 legacy case 3 of `canReplaceHost refuses unknown observations` |
28
+ | A4 (non-terminal hosts refuse; host null) | Task 1 Step 1 test `canReplaceHost refuses non-terminal hosts and null host` |
29
+ | A5 (foreign/not_started releasable) | Task 1 Step 1 test `canReplaceHost SAFE_TO_RELEASE boundary regression` |
30
+ | A6 (resolver integration: exited + live claimPid → new host) | Task 2 |
31
+ | U1 (real dashboard attach) | Post-implementation manual task (Task 3) |
32
+
33
+ ---
34
+
35
+ ### Task 1: Relax `canReplaceHost` claim-role gate + drop `claimObservation` (A1-A5)
36
+
37
+ **Files:**
38
+ - Modify: `src/core/host-coordination.mjs:60-80` (canReplaceHost + its JSDoc)
39
+ - Modify: `src/runtime/service.mjs:2006-2013` (observeHostForReplace)
40
+ - Test: `test/host-coordination.test.mjs:30-35`
41
+
42
+ **Interfaces:**
43
+ - Consumes: `SAFE_TO_RELEASE` set (already defined at `host-coordination.mjs:57`).
44
+ - Produces: `canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) → boolean` — the `claimObservation` parameter is REMOVED. Sole caller `observeHostForReplace` (service.mjs, internal, not exported) stops passing it. Task 2's integration test relies on this behavior only indirectly (through `resolveAttachTarget`), so no signature dependency.
45
+
46
+ - [ ] **Step 1: Write the failing tests**
47
+
48
+ In `test/host-coordination.test.mjs`, first EDIT the existing `canReplaceHost refuses unknown observations` test (line 30-35) to drop the `claimObservation` argument from all three assertions (the parameter is being removed):
49
+
50
+ ```js
51
+ test("canReplaceHost refuses unknown observations", () => {
52
+ const host = { state: "failed" };
53
+ assert.equal(canReplaceHost({ host, runnerObservation: "unknown", childObservation: "dead", launchLeaseActive: false }), false);
54
+ assert.equal(canReplaceHost({ host, runnerObservation: "dead", childObservation: "not_started", launchLeaseActive: false }), true);
55
+ assert.equal(canReplaceHost({ host, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: true }), false);
56
+ });
57
+ ```
58
+
59
+ Then ADD these four tests right after it:
60
+
61
+ ```js
62
+ test("canReplaceHost allows replacing a terminal host whose claimPid is still alive (issue #99)", () => {
63
+ // The bug: an exited/failed host keeps its claimPid (the dashboard process
64
+ // that wrote the claim), and a live pid observed as "unknown" used to block
65
+ // replacement forever — attach pended to "host start timed out". Claim
66
+ // protection only matters while a claim is mid-transaction (state
67
+ // "starting"); a terminal host cannot still be being launched.
68
+ assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: false }), true);
69
+ assert.equal(canReplaceHost({ host: { state: "failed" }, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: false }), true);
70
+ });
71
+
72
+ test("canReplaceHost still refuses unknown runner/child observations (issue #99 conservatism)", () => {
73
+ assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "unknown", childObservation: "dead", launchLeaseActive: false }), false);
74
+ assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "dead", childObservation: "unknown", launchLeaseActive: false }), false);
75
+ assert.equal(canReplaceHost({ host: { state: "failed" }, runnerObservation: "unknown", childObservation: "unknown", launchLeaseActive: false }), false);
76
+ });
77
+
78
+ test("canReplaceHost refuses non-terminal hosts and null host (issue #99)", () => {
79
+ for (const state of ["starting", "alive", "stopping"]) {
80
+ assert.equal(canReplaceHost({ host: { state }, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: false }), false, `state ${state} must refuse`);
81
+ }
82
+ assert.equal(canReplaceHost({ host: null, runnerObservation: "dead", childObservation: "dead", launchLeaseActive: false }), false);
83
+ });
84
+
85
+ test("canReplaceHost SAFE_TO_RELEASE boundary regression (issue #99)", () => {
86
+ assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "foreign", childObservation: "dead", launchLeaseActive: false }), true, "foreign runner (pid reuse) is releasable");
87
+ assert.equal(canReplaceHost({ host: { state: "exited" }, runnerObservation: "not_started", childObservation: "not_started", launchLeaseActive: false }), true);
88
+ });
89
+ ```
90
+
91
+ - [ ] **Step 2: Run tests to verify they fail**
92
+
93
+ Run: `node --test test/host-coordination.test.mjs 2>&1 | tail -30`
94
+ Expected: FAIL — the first new test fails (`exited` + dead runner/child returns `false` under the old three-role gate). The edited legacy test fails too: the old implementation ignores the removed `claimObservation` key but still requires `claimObservation` in SAFE_TO_RELEASE via `undefined → not in set → false`… actually with `claimObservation` absent, `SAFE_TO_RELEASE.has(undefined)` is `false`, so case 2 of the legacy test (`"dead"` args → expected `true`) FAILS under the old code. Both failures prove the tests exercise the gate.
95
+
96
+ - [ ] **Step 3: Implement the relaxation**
97
+
98
+ In `src/core/host-coordination.mjs`, replace the `canReplaceHost` function (lines ~63-80) with:
99
+
100
+ ```js
101
+ /**
102
+ * Whether an exited/failed host can be replaced by a new claim. The runner and
103
+ * child roles must be provably gone; any `unknown` observation or an active
104
+ * launch lease blocks replacement. The claim role does NOT participate: claim
105
+ * protection (a launcher mid-transaction between claim and spawn) only matters
106
+ * while the host is `starting`, and this gate only ever sees terminal hosts —
107
+ * a terminal host cannot still be being launched (issue #99: a live claimPid —
108
+ * the dashboard process that wrote the claim — must not block re-attach).
109
+ * @param {{
110
+ * host: HostStatus|null|undefined,
111
+ * runnerObservation: string,
112
+ * childObservation: string,
113
+ * launchLeaseActive: boolean,
114
+ * }} input
115
+ * @returns {boolean}
116
+ */
117
+ export function canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) {
118
+ if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
119
+ if (launchLeaseActive) return false;
120
+ return (
121
+ SAFE_TO_RELEASE.has(runnerObservation) &&
122
+ SAFE_TO_RELEASE.has(childObservation)
123
+ );
124
+ }
125
+ ```
126
+
127
+ In `src/runtime/service.mjs`, edit `observeHostForReplace` (line ~2006) to drop the `claimObservation` line:
128
+
129
+ ```js
130
+ /** @param {import("../core/types.mjs").HostStatus|null} host */
131
+ function observeHostForReplace(host) {
132
+ return {
133
+ host,
134
+ runnerObservation: conservativeObservation(host?.runnerPid ?? null),
135
+ childObservation: conservativeObservation(host?.childPid ?? null),
136
+ launchLeaseActive: false,
137
+ };
138
+ }
139
+ ```
140
+
141
+ - [ ] **Step 4: Run tests to verify they pass**
142
+
143
+ Run: `node --test test/host-coordination.test.mjs 2>&1 | tail -10`
144
+ Expected: PASS — all tests in the file pass (4 new + edited legacy + all untouched).
145
+
146
+ Run: `node --test test/host-resolver.test.mjs test/host-recovery.test.mjs test/host-crash.test.mjs 2>&1 | tail -10`
147
+ Expected: PASS — no regressions in adjacent host suites.
148
+
149
+ Run: `npm run typecheck`
150
+ Expected: exit 0.
151
+
152
+ - [ ] **Step 5: Commit**
153
+
154
+ ```bash
155
+ git add src/core/host-coordination.mjs src/runtime/service.mjs test/host-coordination.test.mjs
156
+ git commit -m "fix(host): claim role no longer blocks terminal host replacement (#99)"
157
+ ```
158
+
159
+ ---
160
+
161
+ ### Task 2: Resolver integration test — exited host + live claimPid attaches via fresh spawn (A6)
162
+
163
+ **Files:**
164
+ - Test: `test/host-resolver.test.mjs` (add one test after the `resolver finalizes a provably-dead legacy alive host` test, ~line 163)
165
+
166
+ **Interfaces:**
167
+ - Consumes: existing helpers `freshRoot`, `resolverService`, `healServiceOverrides(probe, spawns)`, `scriptProbe(seq)`, `hostRecord(root, viewId, over)` (defaults `claimPid: process.pid` — exactly the live-claimer shape), `createView` (returns `{ sessionFile, ... }`), and `writeFileSync` (already imported). Task 1's relaxed `canReplaceHost` must be in place — this test verifies the full attach chain (resolver → ensureHost → startHostUnderLease → canReplaceHost → spawn → probe ready).
168
+ - Produces: nothing downstream (terminal verification task).
169
+
170
+ - [ ] **Step 1: Write the integration test**
171
+
172
+ Add to `test/host-resolver.test.mjs` after the issue #87 legacy-alive test (~line 163):
173
+
174
+ ```js
175
+ test("resolver replaces an exited host whose claimPid is still alive (issue #99)", async () => {
176
+ const root = freshRoot();
177
+ try {
178
+ const meta = createView(root, { id: "v1", name: "a", cwd: "/r" });
179
+ writeFileSync(meta.sessionFile, "");
180
+ // The bug's exact shape: the host ran to completion (exited, exitCode 0,
181
+ // stopReason child_exit) but its claimPid — the dashboard process that
182
+ // wrote the claim — is STILL ALIVE (hostRecord defaults claimPid to
183
+ // process.pid). Before the fix, canReplaceHost saw the live claim as
184
+ // "unknown" and the resolver pended to "host start timed out".
185
+ hostRecord(root, "v1", {
186
+ instanceId: "i1",
187
+ state: "exited",
188
+ runnerPid: 999999,
189
+ childPid: null,
190
+ endedAt: Date.now(),
191
+ exitCode: 0,
192
+ stopReason: "child_exit",
193
+ });
194
+ const probe = scriptProbe(["ready"]);
195
+ const spawns = [];
196
+ const svc = resolverService(root, healServiceOverrides(probe, spawns));
197
+ const result = await svc.resolveAttachTarget("v1", { timeoutMs: 2_000 });
198
+ assert.equal(result.kind, "pty", `must replace the exited host despite the live claimPid: ${JSON.stringify(result)}`);
199
+ assert.equal(spawns.length, 1, "exactly one fresh claim spawn");
200
+ assert.notEqual(result.instanceId, "i1", "attaches to the replacement instance");
201
+ } finally {
202
+ rmSync(root, { recursive: true, force: true });
203
+ }
204
+ });
205
+ ```
206
+
207
+ - [ ] **Step 2: Sanity-verify the test fails against the pre-fix gate (optional but recommended)**
208
+
209
+ Temporarily `git stash` the Task 1 commit (`git stash` won't work across commits — instead: `git checkout HEAD~1 -- src/core/host-coordination.mjs src/runtime/service.mjs`), then run:
210
+
211
+ Run: `node --test --test-name-pattern "issue #99" test/host-resolver.test.mjs 2>&1 | tail -15`
212
+ Expected: the new test FAILS or times out (resolver pends — the pre-fix behavior). Then restore: `git checkout HEAD -- src/core/host-coordination.mjs src/runtime/service.mjs`.
213
+
214
+ If the timeout makes the run slow, the 2_000 ms timeoutMs bounds it.
215
+
216
+ - [ ] **Step 3: Run the test against the fix**
217
+
218
+ Run: `node --test --test-name-pattern "issue #99" test/host-resolver.test.mjs 2>&1 | tail -10`
219
+ Expected: PASS — `kind: "pty"`, exactly one spawn, replacement instanceId.
220
+
221
+ - [ ] **Step 4: Run the full suite**
222
+
223
+ Run: `npm test 2>&1 | tail -15`
224
+ Expected: PASS — all suites green, no regressions.
225
+
226
+ Run: `npm run typecheck`
227
+ Expected: exit 0.
228
+
229
+ - [ ] **Step 5: Commit**
230
+
231
+ ```bash
232
+ git add test/host-resolver.test.mjs
233
+ git commit -m "test(resolver): exited host with live claimPid attaches via fresh spawn (#99)"
234
+ ```
235
+
236
+ ---
237
+
238
+ ### Task 3: U1 manual verification (post-implementation, user-executed)
239
+
240
+ **Files:** none (manual).
241
+
242
+ **Interfaces:** none.
243
+
244
+ - [ ] **Step 1: Restart dashboard process** (loads new code — the "immediately effective on existing bad records" property requires restart).
245
+
246
+ - [ ] **Step 2: Attach view_2472d82627 from the dashboard** — observe: attach enters the session, history renders, no `host start timed out`.
247
+
248
+ - [ ] **Step 3: Exit the session, attach again** — confirm repeatability.
249
+
250
+ - [ ] **Step 4: Same check on view_4b667ad75d / view_c038badb30** (the other two exited + live-claimPid views).
251
+
252
+ - [ ] **Step 5: Record results in the issue** (comment each view's outcome; mark U1 pass/pending in the final report).
253
+
254
+ ---
255
+
256
+ ## Self-Review
257
+
258
+ **1. Spec coverage:**
259
+ - A1-A5 → Task 1 Step 1 (three new tests + edited legacy test covers A2/A3 cases) ✓
260
+ - A6 → Task 2 ✓
261
+ - U1 → Task 3 ✓
262
+ - 改动文件清单 (spec) → Task 1 + Task 2 files match exactly (host-coordination.mjs, service.mjs, host-coordination.test.mjs, host-resolver.test.mjs) ✓
263
+ - 非目标: no pty-runner/store/host.json changes in any task ✓
264
+
265
+ **2. Placeholder scan:** no TBD/TODO; every code step has full code; verification commands concrete. ✓
266
+
267
+ **3. Type consistency:** `canReplaceHost` new signature `{host, runnerObservation, childObservation, launchLeaseActive}` used consistently in Task 1 tests, Task 1 implementation, and matches Task 2's indirect usage (no direct call). `healServiceOverrides(probe, spawns)` helper name matches file. ✓
@@ -0,0 +1,147 @@
1
+ # Spec: exited host 的 claimPid 存活导致 attach 永久 pending(issue #99)
2
+
3
+ ## 背景
4
+
5
+ dashboard 里对已退出的 session 点 attach,永远连不上:attach 一直转圈,最终超时提示 `host start timed out`。实测对象 `view_2472d82627`(host 已正常退出 `state: "exited"`,`exitCode: 0`),同机另有 `view_4b667ad75d`、`view_c038badb30` 两个 view 处于相同状态(exited + claimPid 存活),全部无法 attach。
6
+
7
+ ## 根因
8
+
9
+ ### 触发链条
10
+
11
+ 1. 用户从 dashboard(进程 P)attach session → `claimHost`(`src/core/store.mjs:172`)把 `claimPid` 记为 **dashboard 进程的 pid**(`claimPid: provisionalHost.claimPid ?? null`,即 service 进程 pid)
12
+ 2. pi 子进程正常退出(`pty-runner.mjs:262` `child.onExit` → `state: "exited"`)→ **退出路径不清除 claimPid**
13
+ 3. dashboard 进程 P 继续存活(用户一直开着 dashboard)
14
+ 4. 再次 attach → `startHostUnderLease`(`service.mjs:243`)替换 terminal host 前调 `canReplaceHost(observeHostForReplace(existing))`
15
+ 5. `observeHostForReplace`(`service.mjs:2006`)对 claim 角色用 `conservativeObservation(host.claimPid)`:**pid 活着 → `"unknown"`**
16
+ 6. `canReplaceHost`(`src/core/host-coordination.mjs:72`)要求 runner / child / claim 三角色都 `SAFE_TO_RELEASE`(`not_started | dead | foreign`),claim 为 `"unknown"` → **返回 false**
17
+ 7. → `pendingLaunchResult` → attach 循环等待直到 deadline → `pending(sessionFile, "host start timed out")`
18
+
19
+ 实机验证(node 直接调用 `canReplaceHost`):
20
+
21
+ ```
22
+ host.state: exited
23
+ runnerPid 1004724 → dead
24
+ childPid null → dead
25
+ claimPid 1003423 → unknown ← 阻塞点
26
+ canReplaceHost → false
27
+ ```
28
+
29
+ ### claimPid 的语义(代码注释 + 测试确认)
30
+
31
+ - **非 null = "launcher 可能还在 claim 和 spawn 之间"**:保护 mid-transaction,让 ensureHost/adopt 等 grace 窗口(`service.mjs:976` `withinGrace` 判定依赖 `claimPid != null`)
32
+ - **recovery claim 用 `claimPid: null`**(`service.mjs:723-736`):recovery 事务在 claim 落盘时已完成,spawning 是 adopter 的活;`host-recovery.test.mjs:317` 断言 `recovery claim must not carry a live claimPid`
33
+ - **spawn 失败路径清除 claimPid**(`service.mjs:832, 869`):failed fenced
34
+ - **正常退出路径不清除** ← 缺口:host 进入 `exited` 后 claimPid 残留,而 claim 保护语义(launcher mid-transaction)在 host 已 terminal 时**不可能成立**(runner 都跑完退出了)
35
+
36
+ ### 为什么是 bug
37
+
38
+ `canReplaceHost` 的 claim 角色检查在 host 已 terminal 的场景下过度保守。claim 保护只对 `starting` 状态有意义;host 为 `exited/failed` 时,claim 进程不可能还在启动它(启动要么成功——runner 跑过并退出,要么失败——failed fenced 已清 claimPid)。触发条件常见:**dashboard 进程存活 + host exited → 任何从 dashboard 启动又退出的 session 都无法再次 attach**。
39
+
40
+ ## 修复方案(选定:放宽 canReplaceHost 的 claim 角色判定)
41
+
42
+ ### 方案对比
43
+
44
+ | 方案 | 改动 | 对存量坏记录 | 评价 |
45
+ |------|------|-------------|------|
46
+ | **A. canReplaceHost 放宽 claim 判定** | 纯函数(`host-coordination.mjs`)+ 调用方(`service.mjs`) | **立即生效**(下次 attach 即可替换) | ✅ 选定 |
47
+ | B. terminal 时清除 claimPid | 改 pty-runner 退出路径多处 + recoverHost finalize | 无效(已存在的坏记录不会自动修复,需额外迁移机制) | 改动面大、覆盖不全 |
48
+ | C. conservativeObservation 增加 host 状态感知 | 改观测函数签名(传入 host 状态) | 立即生效 | 污染通用观测函数语义:`conservativeObservation` 的职责是"保守判断单个 pid 是否活着",让它感知 host 状态会把生命周期决策混进观测层,违背 service.mjs 里观测与决策分离的既有结构 |
49
+
50
+ ### 设计
51
+
52
+ **1. `canReplaceHost`(`src/core/host-coordination.mjs:72`)判定改为**:host 已 terminal(exited/failed)+ runner/child 均 provably gone + 无 launch lease → 可替换,claim 角色不参与判定。
53
+
54
+ ```js
55
+ export function canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) {
56
+ if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
57
+ if (launchLeaseActive) return false;
58
+ // claim 角色不参与判定:claim 保护语义(launcher mid-transaction)只在 host
59
+ // 处于 starting 时有意义;host 已 terminal 时 claim 进程不可能还在启动它
60
+ // (启动要么成功——runner 跑过并退出,要么失败——failed fenced 已清 claimPid)。
61
+ return (
62
+ SAFE_TO_RELEASE.has(runnerObservation) &&
63
+ SAFE_TO_RELEASE.has(childObservation)
64
+ );
65
+ }
66
+ ```
67
+
68
+ **2. 同步移除 `claimObservation` 参数**(而非保留 unused 参数):
69
+
70
+ - `canReplaceHost` 签名从 `{host, runnerObservation, childObservation, claimObservation, launchLeaseActive}` 改为 `{host, runnerObservation, childObservation, launchLeaseActive}`
71
+ - `observeHostForReplace`(`service.mjs:2006`,内部函数不导出)返回值移除 `claimObservation` 字段——少一次 `process.kill(pid, 0)` 系统调用
72
+ - 既有测试用例同步移除 `claimObservation` 参数
73
+
74
+ **取舍论证**(为什么移除而非保留 unused):
75
+
76
+ - `observeHostForReplace` 只有**一个**调用方(`service.mjs:243` 传给 `canReplaceHost`),且该函数是 service.mjs 内部函数不导出——无外部兼容性问题
77
+ - 保留一个不参与判定的参数会让测试产生误导(传 `claimObservation: "unknown"` 的用例看起来期望 false,实际被忽略)
78
+ - 移除后观测层少一次无用的 `isAlive` 系统调用
79
+
80
+ ### 改动文件
81
+
82
+ | 文件 | 改动 |
83
+ |------|------|
84
+ | `src/core/host-coordination.mjs` | `canReplaceHost` 判定放宽 + 签名移除 `claimObservation` + JSDoc 更新 |
85
+ | `src/runtime/service.mjs` | `observeHostForReplace` 返回值移除 `claimObservation` 字段 |
86
+ | `test/host-coordination.test.mjs` | 既有 3 用例移除 `claimObservation` 参数;新增 A1-A5 用例 |
87
+ | `test/host-resolver.test.mjs` | 新增 A6 集成测试 |
88
+
89
+ ### 安全性论证
90
+
91
+ | 场景 | 分析 | 结论 |
92
+ |------|------|------|
93
+ | exited + claimPid 活着 | 只可能来自 runner 跑完退出(claim 事务早已完成) | 安全,**修复目标** |
94
+ | failed + claimPid 活着 | runner 崩溃 / child spawn 失败(claim 事务已完成或 failed fenced 已清);child 存活时 childObservation 仍阻塞 | 安全 |
95
+ | runner 活着 | runnerObservation = "unknown" → 仍阻塞(不受影响) | 保守性保留 |
96
+ | child 活着 | childObservation = "unknown" → 仍阻塞(不受影响) | 保守性保留 |
97
+ | alive/starting host + claimPid 存活 | attach 直接到现有 host(probe ready → 返回 pty),**不经过 canReplaceHost** | 不受影响 |
98
+ | recoverHost 并发 | recoverHost 与 startHostUnderLease 都在 host-start lease 内执行,互斥 | 无竞争 |
99
+ | starting 状态 | canReplaceHost 对非 terminal 直接返回 false(不进入新判定) | 不受影响 |
100
+
101
+ ### 非目标
102
+
103
+ - 不改 pty-runner 退出路径(方案 B 不做)
104
+ - 不做存量 host.json 迁移/清理(方案 A 对存量记录天然生效)
105
+ - 不改 `conservativeObservation`(其保守语义在 starting 场景仍需要)
106
+ - 不改 dashboard UI、不改 host.json 结构(不加新字段)
107
+
108
+ ## 验收矩阵
109
+
110
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
111
+ |----|--------|----------|----------|----------|
112
+ | A1 | canReplaceHost:exited/failed + runner/child dead + **claimPid 存活** → 可替换(修复点) | 自动化验证(unit) | `node --test test/host-coordination.test.mjs` | 新增用例通过:`{host:{state:"exited"}, runner:"dead", child:"dead", lease:false}` → `true`;`{host:{state:"failed"}, ...}` 同 → `true` |
113
+ | A2 | canReplaceHost:runner/child 任一 unknown 仍阻塞(保守性不破坏) | 自动化验证(unit) | 同上 | `runnerObservation:"unknown"` 或 `childObservation:"unknown"` 时返回 `false` |
114
+ | A3 | canReplaceHost:launchLeaseActive 仍阻塞 | 自动化验证(unit) | 同上 | `launchLeaseActive: true` 时返回 `false`(既有用例回归) |
115
+ | A4 | canReplaceHost:非 terminal host(starting/alive/stopping)仍拒绝 | 自动化验证(unit) | 同上 | 三个状态均返回 `false`(新增断言) |
116
+ | A5 | canReplaceHost:SAFE_TO_RELEASE 边界回归——foreign/not_started → 可替换;host null → false | 自动化验证(unit) | 同上 | `runnerObservation:"foreign"` 或 `childObservation:"not_started"` 时返回 `true`;`host: null` 返回 `false` |
117
+ | A6 | resolver 集成:exited host + 存活 claimPid → attach 启动新 host | 自动化验证(integration) | `node --test test/host-resolver.test.mjs` | 新增用例:构造 exited host 记录(`claimPid: process.pid`,存活),`resolveAttachTarget` 返回 `{kind:"pty"}` 且 spawn 恰好 1 次 |
118
+ | U1 | 真实 dashboard 场景:attach 已退出 session(dashboard 进程存活) | 用户实测 | 见下方步骤 | attach 成功进入 session,历史消息正常显示,无超时提示 |
119
+
120
+ **U1 实测步骤**:
121
+
122
+ 1. **重启 dashboard 进程**(加载新代码——方案 A 对存量坏记录的"立即生效"以重启为前提)
123
+ 2. 打开 dashboard,找到已知坏记录 `view_2472d82627`(issue-225 session,host `state: "exited"` + claimPid 存活)
124
+ 3. 点 attach → 观察:成功进入 session、历史消息正常显示、无 `host start timed out` 提示
125
+ 4. 退出 session,再 attach,确认可重复
126
+ 5. 同法验证 `view_4b667ad75d` / `view_c038badb30`(另两个 exited + claimPid 存活的 view)
127
+
128
+ ## 可测性拆分设计
129
+
130
+ 修复集中在 `canReplaceHost` 一个纯函数(`src/core/host-coordination.mjs`),无副作用、无 I/O,天然可单测:
131
+
132
+ - **纯函数边界**:`canReplaceHost({host, runnerObservation, childObservation, launchLeaseActive})` → boolean。输入为纯数据快照,输出只依赖输入,不触碰 fs/进程/socket。
133
+ - **观测与决策分离**:`observeHostForReplace`(`service.mjs:2006`)负责进程观测(`conservativeObservation`),`canReplaceHost` 负责决策。修复只动决策层;观测层移除 `claimObservation` 字段是配套清理(少一次无用的 `isAlive` 调用),不改变观测语义。
134
+ - **测试边界**:
135
+ - `test/host-coordination.test.mjs`:A1-A5 全部在纯函数层覆盖(现有 `canReplaceHost refuses unknown observations` 用例扩展 + 新增用例)
136
+ - `test/host-resolver.test.mjs`:A6 走现有 `resolverService` + `healServiceOverrides` 基建(真实 ensureHostImpl claim + scripted probe),验证 attach 全链路(resolver → ensureHost → startHostUnderLease → canReplaceHost → spawn)
137
+
138
+ ## 风险与降级
139
+
140
+ - **行为变化面**:仅放宽"exited/failed host 的替换判定"一个点;starting/alive/stopping 的 host 不经过 `canReplaceHost`;runner/child 存活的 host 仍阻塞。
141
+ - **回归风险**:低。改动为纯函数内一个条件的放宽 + 配套签名清理,A2-A5 保证保守性不破坏。
142
+ - **降级路径**:若 U1 实测发现异常,**revert 本 issue 的 commit 恢复原判定**(claim 角色重新参与判定)。
143
+
144
+ ## 环境
145
+
146
+ - Linux x64,node v24.13.0,pi-agent-board main(0.6.1)
147
+ - 2026-09-09 实机排查,`view_2472d82627` 现场取证(host.json / diagnostics.jsonl / ps 进程树)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -57,25 +57,27 @@ export function processIdentityState(identity, observed, spawnedAt) {
57
57
  const SAFE_TO_RELEASE = new Set(["not_started", "dead", "foreign"]);
58
58
 
59
59
  /**
60
- * Whether an exited/failed host can be replaced by a new claim. Every role
61
- * (runner, child, provisional-claim launcher) must be provably gone; any
62
- * `unknown` observation or an active launch lease blocks replacement.
60
+ * Whether an exited/failed host can be replaced by a new claim. The runner and
61
+ * child roles must be provably gone; any `unknown` observation or an active
62
+ * launch lease blocks replacement. The claim role does NOT participate: claim
63
+ * protection (a launcher mid-transaction between claim and spawn) only matters
64
+ * while the host is `starting`, and this gate only ever sees terminal hosts —
65
+ * a terminal host cannot still be being launched (issue #99: a live claimPid —
66
+ * the dashboard process that wrote the claim — must not block re-attach).
63
67
  * @param {{
64
68
  * host: HostStatus|null|undefined,
65
69
  * runnerObservation: string,
66
70
  * childObservation: string,
67
- * claimObservation: string,
68
71
  * launchLeaseActive: boolean,
69
72
  * }} input
70
73
  * @returns {boolean}
71
74
  */
72
- export function canReplaceHost({ host, runnerObservation, childObservation, claimObservation, launchLeaseActive }) {
75
+ export function canReplaceHost({ host, runnerObservation, childObservation, launchLeaseActive }) {
73
76
  if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
74
77
  if (launchLeaseActive) return false;
75
78
  return (
76
79
  SAFE_TO_RELEASE.has(runnerObservation) &&
77
- SAFE_TO_RELEASE.has(childObservation) &&
78
- SAFE_TO_RELEASE.has(claimObservation)
80
+ SAFE_TO_RELEASE.has(childObservation)
79
81
  );
80
82
  }
81
83
 
@@ -864,8 +864,9 @@ export function createService(opts) {
864
864
  }
865
865
  if (pid == null) {
866
866
  const message = spawnError ?? "PTY host runner failed to spawn (adopted claim)";
867
- // Clear the dead claimer's fields so canReplaceHost sees the claim as
868
- // ended a retry (fresh ensure) must not pend on a gone claimer pid.
867
+ // Clear the dead claimer's fields (failed fenced keeps the record
868
+ // replaceable without depending on claimer liveness; canReplaceHost no
869
+ // longer consults claim fields, see issue #99).
869
870
  updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: message, claimPid: null, claimIdentity: null }));
870
871
  removeFile(configPath);
871
872
  return { ok: true, pending: true, socketPath: null, instanceId };
@@ -2008,7 +2009,6 @@ function observeHostForReplace(host) {
2008
2009
  host,
2009
2010
  runnerObservation: conservativeObservation(host?.runnerPid ?? null),
2010
2011
  childObservation: conservativeObservation(host?.childPid ?? null),
2011
- claimObservation: conservativeObservation(host?.claimPid ?? null),
2012
2012
  launchLeaseActive: false,
2013
2013
  };
2014
2014
  }