@zhuxixi/pi-agent-board 0.6.1 → 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.
Files changed (29) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  3. package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +267 -0
  4. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  5. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  6. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  7. package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +147 -0
  8. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  9. package/package.json +1 -1
  10. package/runner/job-runner-legacy.mjs +68 -0
  11. package/runner/job-runner.mjs +370 -67
  12. package/runner/pty-runner-legacy.mjs +50 -0
  13. package/runner/pty-runner.mjs +69 -31
  14. package/runner/state-coordinator.mjs +403 -0
  15. package/runner/state-runner.mjs +89 -15
  16. package/src/commands/bg.ts +2 -1
  17. package/src/core/coordinator-client.mjs +313 -0
  18. package/src/core/coordinator-journal.mjs +282 -0
  19. package/src/core/coordinator-protocol.mjs +12 -0
  20. package/src/core/host-coordination.mjs +9 -7
  21. package/src/core/launch.mjs +15 -0
  22. package/src/core/paths.mjs +16 -0
  23. package/src/core/pty-attach-jiggle-controller.mjs +57 -4
  24. package/src/core/pty-attach-render.mjs +30 -0
  25. package/src/core/state-commands.mjs +617 -0
  26. package/src/core/types.mjs +2 -0
  27. package/src/runtime/service.mjs +419 -114
  28. package/src/ui/dashboard.ts +77 -110
  29. package/src/ui/pty-attach.ts +63 -1
@@ -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. ✓