@zhuxixi/pi-agent-board 0.6.2 → 0.8.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 (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -0,0 +1,771 @@
1
+ # host-meta 租约孤锁回收 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:** 让 host-meta 租约锁在持有者暴毙后可被回收(存量 identity-less 孤锁走超龄兜底、新锁走完整身份判死),并在锁阻塞时留下 diagnostics(issue #112)。
6
+
7
+ **Architecture:** 三处改动——(1) `pid.mjs` 提供共享的进程身份函数;(2) `locks.mjs` 把「锁可回收性」抽成纯函数 `classifyLeaseOwner`,新增 identity-less 超龄兜底;(3) `store.mjs` 的两个 host-meta 获取点带上 identity,失败路径写节流 diagnostics。
8
+
9
+ **Tech Stack:** Node.js ESM、`node:test`、无第三方依赖。
10
+
11
+ ## Global Constraints
12
+
13
+ - **Work from:** `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-112-host-meta-orphan-lock` —— 所有路径均相对该目录;禁止回主 checkout(`/home/elling/git-repo/github/pi-agent-board`)作业。
14
+ - 全部命令以 `cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-112-host-meta-orphan-lock && …` 开头。
15
+ - 提交粒度:每 task 一个 commit,conventional commits,英文。
16
+ - `git add` 按文件 stage,禁止 `git add -A`。
17
+ - 阈值常量:`ORPHAN_LEASE_AGE_MS = 5 * 60_000`(5 分钟),仅经 `opts.orphanAgeMs` 注入覆盖(测试用)。
18
+ - 诊断 code(精确字符串):`host_meta_lease_contended`(updateOwnedHost 重试耗尽)、`host_meta_claim_contended`(claimHost 仅 reason==="blocked")。
19
+ - 平台契约:`captureStartToken` 非 Linux 返回 `null`——`startToken:null` 视为 identity 不完整,走超龄兜底(spec §2.1 平台差异)。
20
+ - 测试文件 import 风格:`pid.test.mjs` / `locks.test.mjs` 用 `import test from "node:test"`;`host-owner-store.test.mjs` 用 `import { test } from "node:test"`。
21
+ - 完整设计见 `docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md`。
22
+
23
+ ---
24
+
25
+ ### Task 1: pid.mjs 共享进程身份函数
26
+
27
+ **验收归属:** F3(A3 前置)· spec §2.2
28
+
29
+ **Files:**
30
+ - Modify: `src/core/pid.mjs`
31
+ - Test: `test/pid.test.mjs`
32
+
33
+ **Interfaces:**
34
+ - Consumes: 无(实现取自 pty-runner.mjs:1011 `captureStartToken` 与 service.mjs:2258 `readProcStartToken`,二者实现一致)。
35
+ - Produces: `captureStartToken(pid: number|null|undefined): string|null`、`currentProcessIdentity(): {pid: number, startToken: string|null}` —— Task 3 依赖。
36
+
37
+ - [ ] **Step 1: Write the failing test**
38
+
39
+ 在 `test/pid.test.mjs` 顶部把 import 改为:
40
+ ```js
41
+ import { captureStartToken, currentProcessIdentity, isAlive, killProcess } from "../src/core/pid.mjs";
42
+ ```
43
+ 文件末尾追加:
44
+ ```js
45
+ test("captureStartToken is stable for a live pid and null otherwise", () => {
46
+ if (process.platform === "linux") {
47
+ const token = captureStartToken(process.pid);
48
+ assert.equal(typeof token, "string");
49
+ assert.ok(token.length > 0, "starttime token must be non-empty on Linux");
50
+ assert.equal(captureStartToken(process.pid), token, "stable across calls");
51
+ assert.equal(captureStartToken(99999999), null, "dead pid has no token");
52
+ } else {
53
+ assert.equal(captureStartToken(process.pid), null, "non-Linux platforms cannot capture a start token");
54
+ }
55
+ assert.equal(captureStartToken(0), null);
56
+ assert.equal(captureStartToken(null), null);
57
+ assert.equal(captureStartToken(undefined), null);
58
+ });
59
+
60
+ test("currentProcessIdentity stamps this process", () => {
61
+ const identity = currentProcessIdentity();
62
+ assert.equal(identity.pid, process.pid);
63
+ assert.equal(identity.startToken, captureStartToken(process.pid));
64
+ });
65
+ ```
66
+
67
+ - [ ] **Step 2: Run test to verify it fails**
68
+
69
+ Run: `node --test test/pid.test.mjs`
70
+ Expected: FAIL —— `captureStartToken is not a function`(SyntaxError/TypeError)。
71
+
72
+ - [ ] **Step 3: Write minimal implementation**
73
+
74
+ `src/core/pid.mjs` 顶部加 import,文件末尾追加两个函数:
75
+ ```js
76
+ import { readFileSync } from "node:fs";
77
+ ```
78
+ ```js
79
+ /**
80
+ * POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
81
+ * across exec(2). Distinguishes an owned-live pid from a reused one; null on
82
+ * failure or non-Linux platforms. Mirrors the runner's captureStartToken
83
+ * (issue #70); shared here for host-meta lease identity (issue #112).
84
+ * @param {number|null|undefined} pid
85
+ * @returns {string|null}
86
+ */
87
+ export function captureStartToken(pid) {
88
+ if (process.platform !== "linux" || !pid) return null;
89
+ try {
90
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
91
+ // comm (field 2) may contain spaces and parens — fields resume AFTER the
92
+ // last ')'. fields[0] is state (field 3) → starttime (field 22) is [19].
93
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trimStart();
94
+ return afterComm.split(/\s+/)[19] ?? null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Launch-time identity for the current process, as stamped on host-meta
102
+ * acquisitions (issue #112).
103
+ * @returns {{pid: number, startToken: string|null}}
104
+ */
105
+ export function currentProcessIdentity() {
106
+ return { pid: process.pid, startToken: captureStartToken(process.pid) };
107
+ }
108
+ ```
109
+
110
+ - [ ] **Step 4: Run test to verify it passes**
111
+
112
+ Run: `node --test test/pid.test.mjs`
113
+ Expected: PASS(全部用例)。
114
+
115
+ - [ ] **Step 5: Commit**
116
+
117
+ ```bash
118
+ git add src/core/pid.mjs test/pid.test.mjs
119
+ git commit -m "feat(core): shared process start-token identity helpers (issue #112)"
120
+ ```
121
+
122
+ ---
123
+
124
+ ### Task 2: classifyLeaseOwner 纯函数 + identity-less 超龄兜底
125
+
126
+ **验收归属:** A1(F1+F2)· spec §2.1
127
+
128
+ **Files:**
129
+ - Modify: `src/core/locks.mjs`(新增常量与纯函数;`reclaimOrBlock` 改为委托)
130
+ - Test: `test/locks.test.mjs`
131
+
132
+ **Interfaces:**
133
+ - Consumes: 无。
134
+ - Produces: `classifyLeaseOwner(owner: any, now: number, isProcessDead: (pid: number) => boolean, opts?: {orphanAgeMs?: number}): "reclaim" | "busy" | "blocked"` —— Task 5 的端到端测试间接依赖。
135
+
136
+ - [ ] **Step 1: Write the failing tests**
137
+
138
+ `test/locks.test.mjs` 的 import 中加入 `classifyLeaseOwner`:
139
+ ```js
140
+ import { acquireOwnedViewLock, classifyLeaseOwner, defaultLocksFs, releaseWithToken, tryAcquireOwnedViewLock, withFileLockSync, withViewLockSync } from "../src/core/locks.mjs";
141
+ ```
142
+ 文件末尾追加(纯函数契约表 + 集成):
143
+ ```js
144
+ // ---- identity-less orphan reclaim (issue #112) ------------------------------
145
+
146
+ const DEAD_PID = 99999999;
147
+ const FIXED_NOW = 1_800_000_000_000;
148
+ const livePid = () => false;
149
+ const deadPid = () => true;
150
+
151
+ test("classifyLeaseOwner: a full identity reclaims exactly when its pid is dead", () => {
152
+ const owner = { token: "t", pid: 1, identity: { pid: 1, startToken: "s" }, startedAt: FIXED_NOW };
153
+ assert.equal(classifyLeaseOwner(owner, FIXED_NOW, livePid), "busy");
154
+ assert.equal(classifyLeaseOwner(owner, FIXED_NOW, deadPid), "reclaim");
155
+ });
156
+
157
+ test("classifyLeaseOwner: identity-less owners need age AND a dead pid", () => {
158
+ const fresh = { token: "t", pid: DEAD_PID, identity: null, startedAt: FIXED_NOW - 60_000 };
159
+ assert.equal(classifyLeaseOwner(fresh, FIXED_NOW, deadPid), "blocked", "fresh identity-less lock stays blocked");
160
+ const stale = { token: "t", pid: DEAD_PID, identity: null, startedAt: FIXED_NOW - 10 * 60_000 };
161
+ assert.equal(classifyLeaseOwner(stale, FIXED_NOW, deadPid), "reclaim");
162
+ assert.equal(classifyLeaseOwner(stale, FIXED_NOW, livePid), "busy");
163
+ });
164
+
165
+ test("classifyLeaseOwner: the age threshold is inclusive and injectable", () => {
166
+ const atThreshold = { token: "t", pid: DEAD_PID, identity: null, startedAt: FIXED_NOW - 1000 };
167
+ assert.equal(classifyLeaseOwner(atThreshold, FIXED_NOW, deadPid, { orphanAgeMs: 1000 }), "reclaim");
168
+ assert.equal(classifyLeaseOwner(atThreshold, FIXED_NOW, deadPid, { orphanAgeMs: 1001 }), "blocked");
169
+ });
170
+
171
+ test("classifyLeaseOwner: unparseable, token-less, and pid-less owners never reclaim", () => {
172
+ assert.equal(classifyLeaseOwner(null, FIXED_NOW, deadPid), "blocked");
173
+ assert.equal(classifyLeaseOwner("nope", FIXED_NOW, deadPid), "blocked");
174
+ assert.equal(classifyLeaseOwner({ pid: DEAD_PID, identity: null, startedAt: 0 }, FIXED_NOW, deadPid), "blocked", "no token → no reclaim");
175
+ assert.equal(classifyLeaseOwner({ token: "t", identity: null, startedAt: 0 }, FIXED_NOW, deadPid), "blocked", "no pid → nothing to judge");
176
+ assert.equal(classifyLeaseOwner({ token: "t", identity: { pid: 1, startToken: "s" }, startedAt: 0 }, FIXED_NOW, deadPid), "blocked", "live-holder path keeps busy, dead pid without token must not reclaim");
177
+ });
178
+
179
+ test("classifyLeaseOwner: null startToken (non-Linux) uses the identity-less fallback", () => {
180
+ const stale = { token: "t", pid: DEAD_PID, identity: { pid: DEAD_PID, startToken: null }, startedAt: FIXED_NOW - 10 * 60_000 };
181
+ assert.equal(classifyLeaseOwner(stale, FIXED_NOW, deadPid), "reclaim");
182
+ assert.equal(classifyLeaseOwner({ ...stale, startedAt: FIXED_NOW }, FIXED_NOW, deadPid), "blocked");
183
+ });
184
+
185
+ test("stale identity-less lock (issue #112 residue) is reclaimed via quarantine", () => {
186
+ const root = freshRoot();
187
+ try {
188
+ const lockPath = P.viewLockPath(root, "v1", "host-meta");
189
+ mkdirSync(lockPath, { recursive: true });
190
+ writeFileSync(join(lockPath, "owner.json"), JSON.stringify({ token: "orphan", pid: 99999999, identity: null, startedAt: Date.now() - 10 * 60_000 }));
191
+ const got = tryAcquireOwnedViewLock(root, "v1", "host-meta", { identity: { pid: process.pid, startToken: "me" } });
192
+ assert.equal(got.acquired, true, "stale identity-less lock must be recoverable");
193
+ got.lease.release();
194
+ } finally {
195
+ rmSync(root, { recursive: true, force: true });
196
+ }
197
+ });
198
+
199
+ test("fresh identity-less lock is still blocked (short-hold contract preserved)", () => {
200
+ const root = freshRoot();
201
+ try {
202
+ const lockPath = P.viewLockPath(root, "v1", "host-meta");
203
+ mkdirSync(lockPath, { recursive: true });
204
+ writeFileSync(join(lockPath, "owner.json"), JSON.stringify({ token: "unk", pid: process.pid, identity: null, startedAt: Date.now() }));
205
+ const blocked = tryAcquireOwnedViewLock(root, "v1", "host-meta", { identity: { pid: process.pid, startToken: "me" } });
206
+ assert.equal(blocked.acquired, false);
207
+ assert.equal(blocked.reason, "blocked");
208
+ } finally {
209
+ rmSync(root, { recursive: true, force: true });
210
+ }
211
+ });
212
+ ```
213
+
214
+ - [ ] **Step 2: Run tests to verify they fail**
215
+
216
+ Run: `node --test test/locks.test.mjs`
217
+ Expected: FAIL —— `classifyLeaseOwner is not a function`;两条集成用例的 `acquired`/`reason` 断言不成立(超龄 identity-less 仍 blocked)。
218
+
219
+ - [ ] **Step 3: Write minimal implementation**
220
+
221
+ `src/core/locks.mjs`:在 `MAX_LEASE_RECLAIM_ATTEMPTS` 之后加常量,在 `reclaimOrBlock` 之前加纯函数:
222
+ ```js
223
+ /**
224
+ * Age past which an identity-less lock is treated as an orphan candidate.
225
+ * Host-meta holds are millisecond-scale critical sections, so no legitimate
226
+ * holder reaches this (issue #112).
227
+ */
228
+ const ORPHAN_LEASE_AGE_MS = 5 * 60_000;
229
+
230
+ /**
231
+ * Decide what to do with an inspected lease owner. Pure: the caller supplies
232
+ * `now` and a pid-liveness probe.
233
+ *
234
+ * A full identity (pid + startToken) reclaims exactly when its pid is dead.
235
+ * An identity-less owner — legacy short-hold locks, or platforms where
236
+ * startToken cannot be captured — is only reclaimable past `orphanAgeMs`
237
+ * with a provably dead top-level pid: fresh identity-less locks stay blocked,
238
+ * preserving the short-critical-section contract (issue #112).
239
+ * @param {any} owner parsed owner.json
240
+ * @param {number} now
241
+ * @param {(pid: number) => boolean} isProcessDead
242
+ * @param {{ orphanAgeMs?: number }} [opts]
243
+ * @returns {"reclaim" | "busy" | "blocked"}
244
+ */
245
+ export function classifyLeaseOwner(owner, now, isProcessDead, opts = {}) {
246
+ if (!owner || typeof owner !== "object") return "blocked";
247
+ const ownPid = Number(owner?.identity?.pid ?? 0);
248
+ const hasIdentity = Number.isFinite(ownPid) && ownPid > 0 && typeof owner?.identity?.startToken === "string";
249
+ if (hasIdentity) {
250
+ if (!isProcessDead(ownPid)) return "busy";
251
+ // Quarantine-mode reclaim verifies by token that it renamed the lock it
252
+ // inspected — without one nothing may be deleted.
253
+ return typeof owner.token === "string" ? "reclaim" : "blocked";
254
+ }
255
+ const pid = Number(owner?.pid ?? 0);
256
+ if (!Number.isFinite(pid) || pid <= 0) return "blocked";
257
+ const orphanAgeMs = Number(opts.orphanAgeMs ?? ORPHAN_LEASE_AGE_MS);
258
+ if (!(Number(now) - Number(owner?.startedAt ?? 0) >= orphanAgeMs)) return "blocked";
259
+ if (!isProcessDead(pid)) return "busy";
260
+ return typeof owner.token === "string" ? "reclaim" : "blocked";
261
+ }
262
+ ```
263
+ `reclaimOrBlock`:签名加 `now`,判定段替换为委托(quarantine 段原样保留):
264
+ ```js
265
+ function reclaimOrBlock(lockPath, token, fs, isProcessDead, now) {
266
+ let owner;
267
+ try {
268
+ owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf8"));
269
+ } catch {
270
+ return "blocked";
271
+ }
272
+ const verdict = classifyLeaseOwner(owner, now(), isProcessDead);
273
+ if (verdict !== "reclaim") return verdict;
274
+ const inspectedToken = owner.token;
275
+ const quarantine = `${lockPath}.reclaim.${token}`;
276
+ // …以下 quarantine 段(rename → token 核对 → restore/rmSync)保持原样不动…
277
+ }
278
+ ```
279
+ 注意:`classifyLeaseOwner` 返回 `"reclaim"` 时保证 `owner.token` 是 string(函数契约),因此后续 quarantine 段的 `inspectedToken` 可直接取自 `owner.token`。
280
+ `attemptAcquireLease` 的调用处改为传入 clock:`const verdict = reclaimOrBlock(lockPath, token, fs, isProcessDead, now);`
281
+
282
+ - [ ] **Step 4: Run tests to verify they pass**
283
+
284
+ Run: `node --test test/locks.test.mjs`
285
+ Expected: PASS —— 新增用例全绿,且既有用例(含 `dead-owner lock is reclaimed via quarantine, unknown identity is blocked`:新鲜 identity-less 仍 blocked)不回归。
286
+
287
+ - [ ] **Step 5: Commit**
288
+
289
+ ```bash
290
+ git add src/core/locks.mjs test/locks.test.mjs
291
+ git commit -m "fix(locks): reclaim stale identity-less lease orphans past an age gate (issue #112)"
292
+ ```
293
+
294
+ ---
295
+
296
+ ### Task 3: store.mjs 获取点带 identity + claimHost 注入点
297
+
298
+ **验收归属:** A3(F4+F5a+F5b)· spec §2.2
299
+
300
+ **Files:**
301
+ - Modify: `src/core/store.mjs`(import、`hostMetaIdentity` helper、`claimHost`、`updateOwnedHost`)
302
+ - Test: `test/host-owner-store.test.mjs`
303
+
304
+ **Interfaces:**
305
+ - Consumes: Task 1 的 `currentProcessIdentity()`。
306
+ - Produces: `claimHost(root, provisionalHost, opts?: {heldStartLease?: unknown, lockImpl?: typeof tryAcquireOwnedViewLock})` —— Task 4 的诊断测试复用 `lockImpl`。
307
+
308
+ - [ ] **Step 1: Write the failing tests**
309
+
310
+ `test/host-owner-store.test.mjs` 顶部 import 调整:
311
+ ```js
312
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
313
+ ```
314
+ ```js
315
+ import { captureStartToken } from "../src/core/pid.mjs";
316
+ ```
317
+ 文件末尾追加:
318
+ ```js
319
+ // ---- host-meta lease identity (issue #112) ---------------------------------
320
+
321
+ test("updateOwnedHost stamps a full identity on the held host-meta lease", () => {
322
+ const root = freshRoot();
323
+ try {
324
+ createView(root, { id: "v1", name: "a", cwd: "/r" });
325
+ writeHost(root, hostFixture(root, "v1"));
326
+ let held = null;
327
+ const res = updateOwnedHost(root, "v1", "inst-b", (host) => {
328
+ // mutate runs inside the host-meta critical section: the lock file on
329
+ // disk is the very lease this call holds.
330
+ held = JSON.parse(readFileSync(join(P.viewLockPath(root, "v1", "host-meta"), "owner.json"), "utf8"));
331
+ return { ...host, state: "stopping", stopRequestedAt: Date.now() };
332
+ });
333
+ assert.equal(res.updated, true);
334
+ assert.equal(held?.identity?.pid, process.pid);
335
+ assert.equal(held?.identity?.startToken, captureStartToken(process.pid));
336
+ } finally {
337
+ rmSync(root, { recursive: true, force: true });
338
+ }
339
+ });
340
+
341
+ test("claimHost acquires host-meta through the injected impl with a full identity", () => {
342
+ const root = freshRoot();
343
+ try {
344
+ createView(root, { id: "v1", name: "a", cwd: "/r" });
345
+ const seen = [];
346
+ const lockImpl = (r, viewId, name, opts) => {
347
+ seen.push({ name, opts });
348
+ return tryAcquireOwnedViewLock(r, viewId, name, opts);
349
+ };
350
+ const claimed = claimHost(root, provisionalFixture(root, "v1"), { lockImpl });
351
+ assert.equal(claimed.claimed, true);
352
+ assert.equal(seen.length, 1);
353
+ assert.equal(seen[0].name, "host-meta");
354
+ assert.equal(seen[0].opts?.identity?.pid, process.pid);
355
+ assert.equal(seen[0].opts?.identity?.startToken, captureStartToken(process.pid));
356
+ } finally {
357
+ rmSync(root, { recursive: true, force: true });
358
+ }
359
+ });
360
+ ```
361
+ (`provisionalFixture` 是文件内既有 helper。)
362
+
363
+ - [ ] **Step 2: Run tests to verify they fail**
364
+
365
+ Run: `node --test test/host-owner-store.test.mjs`
366
+ Expected: FAIL —— 第一条 `held` 为 null(identity 未写入 → `held.identity` 为 null);第二条 `seen.length` 为 0(claimHost 不识别 lockImpl,走真实获取)。
367
+
368
+ - [ ] **Step 3: Write minimal implementation**
369
+
370
+ `src/core/store.mjs`:
371
+ ```js
372
+ import { currentProcessIdentity, isAlive } from "./pid.mjs";
373
+ ```
374
+ 在 `hostClaimActive` 附近加:
375
+ ```js
376
+ /**
377
+ * Identity stamped on host-meta acquisitions so a holder that dies mid-hold
378
+ * leaves a reclaimable record (issue #112).
379
+ * @returns {{pid: number, startToken: string|null}}
380
+ */
381
+ function hostMetaIdentity() {
382
+ return currentProcessIdentity();
383
+ }
384
+ ```
385
+ `claimHost`:支持注入 + 传 identity,JSDoc 的 opts 补 `lockImpl`:
386
+ ```js
387
+ export function claimHost(root, provisionalHost, opts = {}) {
388
+ const acquireHostMeta = opts.lockImpl ?? tryAcquireOwnedViewLock;
389
+ const lock = acquireHostMeta(root, provisionalHost.viewId, "host-meta", { identity: hostMetaIdentity() });
390
+ ```
391
+ `updateOwnedHost` 的获取行:
392
+ ```js
393
+ lock = acquireHostMeta(root, viewId, "host-meta", { identity: hostMetaIdentity() });
394
+ ```
395
+
396
+ - [ ] **Step 4: Run tests to verify they pass**
397
+
398
+ Run: `node --test test/host-owner-store.test.mjs`
399
+ Expected: PASS —— 新增两条通过;既有 4 条(claimHost contended / updateOwnedHost busy 重试 / 耗尽 / blocked 重试)不回归。
400
+
401
+ - [ ] **Step 5: Commit**
402
+
403
+ ```bash
404
+ git add src/core/store.mjs test/host-owner-store.test.mjs
405
+ git commit -m "fix(store): stamp reclaimable identity on host-meta lease acquisitions (issue #112)"
406
+ ```
407
+
408
+ ---
409
+
410
+ ### Task 4: 失败路径 diagnostics + 节流
411
+
412
+ **验收归属:** A4(F6)· spec §2.3
413
+
414
+ **Files:**
415
+ - Modify: `src/core/store.mjs`(import、节流集合、report helper、两个获取点、成功清除)
416
+ - Test: `test/host-owner-store.test.mjs`
417
+
418
+ **Interfaces:**
419
+ - Consumes: Task 3 的 `opts.lockImpl` 注入点与 `scriptLock` 测试 helper。
420
+ - Produces: `clearHostMetaThrottleForTests(): void` —— Task 5 复用;诊断 code `host_meta_lease_contended` / `host_meta_claim_contended`。
421
+
422
+ - [ ] **Step 1: Write the failing tests**
423
+
424
+ `test/host-owner-store.test.mjs` 顶部 import 追加:
425
+ ```js
426
+ import { readDiagnostics } from "../src/core/diagnostics.mjs";
427
+ ```
428
+ store import 块追加 `clearHostMetaThrottleForTests`:
429
+ ```js
430
+ import {
431
+ claimHost,
432
+ clearHostMetaThrottleForTests,
433
+ createView,
434
+ loadRow,
435
+ readHost,
436
+ updateOwnedHost,
437
+ writeHost,
438
+ writeHostPid,
439
+ } from "../src/core/store.mjs";
440
+ ```
441
+ `scriptLock` 增强为记录并透传 opts:
442
+ ```js
443
+ function scriptLock(scripted) {
444
+ const calls = [];
445
+ const impl = (root, viewId, name, opts) => {
446
+ calls.push({ root, viewId, name, opts });
447
+ const next = scripted.shift();
448
+ return next ? next(root, viewId, name) : tryAcquireOwnedViewLock(root, viewId, name, opts);
449
+ };
450
+ return { impl, calls };
451
+ }
452
+ ```
453
+ 文件末尾追加:
454
+ ```js
455
+ test("updateOwnedHost reports sustained host-meta contention once per view", () => {
456
+ clearHostMetaThrottleForTests();
457
+ const root = freshRoot();
458
+ try {
459
+ createView(root, { id: "v1", name: "a", cwd: "/r" });
460
+ writeHost(root, hostFixture(root, "v1"));
461
+ const busy = () => ({ acquired: false, reason: "busy" });
462
+
463
+ const first = scriptLock([busy, busy, busy, busy]);
464
+ const res = updateOwnedHost(root, "v1", "inst-b", (h) => h, { lockImpl: first.impl });
465
+ assert.equal(res.updated, false);
466
+ let reports = readDiagnostics(root, "v1").filter((d) => d.code === "host_meta_lease_contended");
467
+ assert.equal(reports.length, 1);
468
+ assert.equal(reports[0].level, "warn");
469
+ assert.equal(reports[0].details?.lastReason, "busy");
470
+
471
+ // A second episode without an intervening success stays throttled.
472
+ const second = scriptLock([busy, busy, busy]);
473
+ updateOwnedHost(root, "v1", "inst-b", (h) => h, { lockImpl: second.impl });
474
+ reports = readDiagnostics(root, "v1").filter((d) => d.code === "host_meta_lease_contended");
475
+ assert.equal(reports.length, 1, "one report per contention episode");
476
+
477
+ // A successful write clears the throttle: the next episode reports again.
478
+ const third = scriptLock([]);
479
+ const ok = updateOwnedHost(root, "v1", "inst-b", (h) => ({ ...h, state: "stopping" }), { lockImpl: third.impl });
480
+ assert.equal(ok.updated, true);
481
+ const fourth = scriptLock([busy, busy, busy]);
482
+ updateOwnedHost(root, "v1", "inst-b", (h) => h, { lockImpl: fourth.impl });
483
+ reports = readDiagnostics(root, "v1").filter((d) => d.code === "host_meta_lease_contended");
484
+ assert.equal(reports.length, 2, "reports again after recovery");
485
+ } finally {
486
+ rmSync(root, { recursive: true, force: true });
487
+ }
488
+ });
489
+
490
+ test("claimHost reports blocked host-meta contention but not busy", () => {
491
+ clearHostMetaThrottleForTests();
492
+ const root = freshRoot();
493
+ try {
494
+ createView(root, { id: "v1", name: "a", cwd: "/r" });
495
+ const blockedImpl = () => ({ acquired: false, reason: "blocked" });
496
+ const res = claimHost(root, provisionalFixture(root, "v1"), { lockImpl: blockedImpl });
497
+ assert.equal(res.claimed, false);
498
+ let reports = readDiagnostics(root, "v1").filter((d) => d.code === "host_meta_claim_contended");
499
+ assert.equal(reports.length, 1);
500
+ assert.equal(reports[0].details?.reason, "blocked");
501
+
502
+ clearHostMetaThrottleForTests();
503
+ const busyImpl = () => ({ acquired: false, reason: "busy" });
504
+ claimHost(root, provisionalFixture(root, "v1"), { lockImpl: busyImpl });
505
+ reports = readDiagnostics(root, "v1").filter((d) => d.code === "host_meta_claim_contended");
506
+ assert.equal(reports.length, 0, "busy is ordinary contention: no warning");
507
+ } finally {
508
+ rmSync(root, { recursive: true, force: true });
509
+ }
510
+ });
511
+ ```
512
+
513
+ - [ ] **Step 2: Run tests to verify they fail**
514
+
515
+ Run: `node --test test/host-owner-store.test.mjs`
516
+ Expected: FAIL —— `clearHostMetaThrottleForTests` 未导出;诊断断言为 0 条。
517
+
518
+ - [ ] **Step 3: Write minimal implementation**
519
+
520
+ `src/core/store.mjs` import 合并:
521
+ ```js
522
+ import { appendDiagnostic, readDiagnosticSummary } from "./diagnostics.mjs";
523
+ ```
524
+ 节流状态与 helper(放在 `hostMetaIdentity` 附近):
525
+ ```js
526
+ /**
527
+ * Views with an unrecovered host-meta contention report on record. The
528
+ * heartbeat path calls updateOwnedHost once per second, so without this a
529
+ * sustained contention episode would flood diagnostics.jsonl (issue #112).
530
+ */
531
+ const hostMetaContentionReported = new Set();
532
+
533
+ /** Test hook: clear the per-process contention report throttle. */
534
+ export function clearHostMetaThrottleForTests() {
535
+ hostMetaContentionReported.clear();
536
+ }
537
+
538
+ /**
539
+ * Best-effort single warn per view per contention episode; diagnostics must
540
+ * never break the caller (appendDiagnostic throws on fs failure).
541
+ */
542
+ function reportHostMetaContention(root, viewId, code, message, details) {
543
+ if (hostMetaContentionReported.has(viewId)) return;
544
+ hostMetaContentionReported.add(viewId);
545
+ try {
546
+ appendDiagnostic(root, viewId, { source: "store", level: "warn", code, message, details });
547
+ } catch { /* best effort */ }
548
+ }
549
+ ```
550
+ `claimHost` 获取段:
551
+ ```js
552
+ const lock = acquireHostMeta(root, provisionalHost.viewId, "host-meta", { identity: hostMetaIdentity() });
553
+ if (!lock.acquired) {
554
+ // busy is ordinary millisecond-scale contention; blocked (identity-less
555
+ // holder) is the orphan-lock signature worth a diagnostic (issue #112).
556
+ if (lock.reason === "blocked") {
557
+ reportHostMetaContention(root, provisionalHost.viewId, "host_meta_claim_contended", "host-meta lease blocked; host claim not established", { reason: lock.reason });
558
+ }
559
+ return { claimed: false, host: null };
560
+ }
561
+ hostMetaContentionReported.delete(provisionalHost.viewId);
562
+ ```
563
+ `updateOwnedHost` 重试循环与成功清除:
564
+ ```js
565
+ let lastReason = null;
566
+ for (let attempt = 0; ; attempt++) {
567
+ lock = acquireHostMeta(root, viewId, "host-meta", { identity: hostMetaIdentity() });
568
+ if (lock.acquired) break;
569
+ lastReason = lock.reason;
570
+ // busy and blocked are both millisecond-scale holds for host-meta;
571
+ // neither is ownership information — only the fenced read below is.
572
+ if (attempt >= UPDATE_LOCK_BUSY_ATTEMPTS - 1) {
573
+ reportHostMetaContention(root, viewId, "host_meta_lease_contended", "host-meta lease contended; fenced write not applied", { attempts: UPDATE_LOCK_BUSY_ATTEMPTS, lastReason });
574
+ return { updated: false, ownerChanged: false, host: null };
575
+ }
576
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, UPDATE_LOCK_BUSY_SLEEP_MS);
577
+ }
578
+ try {
579
+ hostMetaContentionReported.delete(viewId);
580
+ const host = readHost(root, viewId);
581
+ // …余下逻辑不变…
582
+ ```
583
+
584
+ - [ ] **Step 4: Run tests to verify they pass**
585
+
586
+ Run: `node --test test/host-owner-store.test.mjs`
587
+ Expected: PASS —— 新增两条通过;既有用例不回归。
588
+
589
+ - [ ] **Step 5: Commit**
590
+
591
+ ```bash
592
+ git add src/core/store.mjs test/host-owner-store.test.mjs
593
+ git commit -m "feat(store): diagnostic warn on sustained host-meta lease contention (issue #112)"
594
+ ```
595
+
596
+ ---
597
+
598
+ ### Task 5: 端到端复现(存量孤锁自愈)+ 注释更新
599
+
600
+ **验收归属:** A2a / A2b / A6 · spec §2.2 文档更新 + §4 矩阵
601
+
602
+ **Files:**
603
+ - Modify: `test/host-owner-store.test.mjs`(新增 3 条端到端用例;更新 line ~313 测试注释)
604
+ - Modify: `src/core/store.mjs`(更新 212-219 行注释块)
605
+
606
+ **Interfaces:**
607
+ - Consumes: Task 2(超龄兜底)、Task 3(identity)、Task 4(`clearHostMetaThrottleForTests`)。
608
+ - Produces: 无新接口(端到端验收)。
609
+
610
+ - [ ] **Step 1: Write the failing tests**
611
+
612
+ `test/host-owner-store.test.mjs` 末尾追加:
613
+ ```js
614
+ // ---- orphan lock self-heal (issue #112 end-to-end) -------------------------
615
+
616
+ /** Write the exact residue from issue #112 into the view's host-meta lock. */
617
+ function writeOrphanLock(root, viewId, owner) {
618
+ const lockPath = P.viewLockPath(root, viewId, "host-meta");
619
+ mkdirSync(lockPath, { recursive: true });
620
+ writeFileSync(join(lockPath, "owner.json"), JSON.stringify(owner));
621
+ return lockPath;
622
+ }
623
+
624
+ test("a stale identity-less orphan lock is reclaimed by a real updateOwnedHost (issue #112 repro)", () => {
625
+ clearHostMetaThrottleForTests();
626
+ const root = freshRoot();
627
+ try {
628
+ createView(root, { id: "v1", name: "a", cwd: "/r" });
629
+ writeHost(root, hostFixture(root, "v1"));
630
+ const lockPath = writeOrphanLock(root, "v1", { token: "orphan", pid: 99999999, identity: null, startedAt: Date.now() - 10 * 60_000 });
631
+ const res = updateOwnedHost(root, "v1", "inst-b", (h) => ({ ...h, state: "stopping", stopRequestedAt: Date.now() }));
632
+ assert.equal(res.updated, true, "orphan lock is reclaimed and the fenced write lands");
633
+ assert.equal(res.ownerChanged, false);
634
+ assert.equal(readHost(root, "v1").state, "stopping");
635
+ let leftover = null;
636
+ try { leftover = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8")); } catch { leftover = null; }
637
+ assert.notEqual(leftover?.token, "orphan", "the orphan lease is no longer observable");
638
+ } finally {
639
+ rmSync(root, { recursive: true, force: true });
640
+ }
641
+ });
642
+
643
+ test("a stale orphan lock also no longer blocks claimHost (issue #112 repro)", () => {
644
+ clearHostMetaThrottleForTests();
645
+ const root = freshRoot();
646
+ try {
647
+ createView(root, { id: "v1", name: "a", cwd: "/r" });
648
+ writeOrphanLock(root, "v1", { token: "orphan", pid: 99999999, identity: null, startedAt: Date.now() - 10 * 60_000 });
649
+ const claimed = claimHost(root, provisionalFixture(root, "v1"));
650
+ assert.equal(claimed.claimed, true, "a fresh host can be claimed over the orphan residue");
651
+ assert.equal(claimed.host?.state, "starting");
652
+ } finally {
653
+ rmSync(root, { recursive: true, force: true });
654
+ }
655
+ });
656
+
657
+ test("a dead holder's identity-stamped lock reclaims immediately (new-protocol crash)", () => {
658
+ clearHostMetaThrottleForTests();
659
+ const root = freshRoot();
660
+ try {
661
+ createView(root, { id: "v1", name: "a", cwd: "/r" });
662
+ writeHost(root, hostFixture(root, "v1"));
663
+ writeOrphanLock(root, "v1", { token: "dead-inst", pid: 99999999, identity: { pid: 99999999, startToken: "tok" }, startedAt: Date.now() });
664
+ const res = updateOwnedHost(root, "v1", "inst-b", (h) => ({ ...h, state: "stopping" }));
665
+ assert.equal(res.updated, true, "identity-stamped dead holders reclaim without an age gate");
666
+ } finally {
667
+ rmSync(root, { recursive: true, force: true });
668
+ }
669
+ });
670
+
671
+ test("a fresh identity-less lock still defers to its short-hold window (no behavior regression)", () => {
672
+ clearHostMetaThrottleForTests();
673
+ const root = freshRoot();
674
+ try {
675
+ createView(root, { id: "v1", name: "a", cwd: "/r" });
676
+ writeHost(root, hostFixture(root, "v1"));
677
+ writeOrphanLock(root, "v1", { token: "live-ish", pid: process.pid, identity: null, startedAt: Date.now() });
678
+ const res = updateOwnedHost(root, "v1", "inst-b", (h) => ({ ...h, state: "stopping" }));
679
+ assert.equal(res.updated, false, "fresh identity-less locks must not be force-reclaimed");
680
+ assert.equal(readHost(root, "v1").state, "alive", "disk record untouched");
681
+ } finally {
682
+ rmSync(root, { recursive: true, force: true });
683
+ }
684
+ });
685
+ ```
686
+
687
+ - [ ] **Step 2: Run tests to verify they pass end-to-end**
688
+
689
+ Run: `node --test test/host-owner-store.test.mjs`
690
+ Expected: 前两条(超龄 identity-less 孤锁的 updateOwnedHost / claimHost 回收)此时应当 PASS——它们验证的是 Task 2 的超龄兜底在 store 集成层确实生效(Task 2 只做了 locks 层单测)。第三条(identity 完整死锁立即回收)依赖 Task 3 的 identity 传递。第四条(新鲜 identity-less 不回收)依赖既有行为。**若前两条 FAIL**,说明 locks 层修复未贯通到 store 集成路径,需先排查而非继续。
691
+
692
+ - [ ] **Step 3: Update the stale comments**
693
+
694
+ `src/core/store.mjs` 的注释块(现描述 "identity-less short hold ... surfaces as retryable not-updated")改为:
695
+ ```js
696
+ * Bounded contention retry for owner-fenced host writes (issue #70, PR #84 CI
697
+ * wave 2). Heartbeat/client-merge writes hold the host-meta lease for only a
698
+ * few milliseconds, but a one-shot acquire can land inside that window and
699
+ * return busy — a revoke or recovery write that silently no-ops is a real
700
+ * reliability bug, not just a test race. Both `busy` (live owner) and
701
+ * `blocked` (identity-less holder) are transient here: retry a few times with
702
+ * a short synchronous sleep before giving up, and record a warn diagnostic on
703
+ * a sustained episode (issue #112). Acquisitions stamp a full process identity
704
+ * (issue #112), so a holder that dies mid-hold leaves a reclaimable record;
705
+ * legacy identity-less residue is reclaimed past the orphan age gate.
706
+ ```
707
+ `test/host-owner-store.test.mjs` 中 `updateOwnedHost retries blocked contention the same bounded amount (identity-less short holds)` 的注释改为:
708
+ ```js
709
+ // A concurrent holder without a reclaimable identity (legacy residue or
710
+ // a non-Linux startToken) makes contenders see `blocked`, not `busy`.
711
+ // Acquisitions stamp a full identity as of issue #112; this path remains
712
+ // for legacy holders and is still a millisecond-scale hold: retry it,
713
+ // bounded, like busy.
714
+ ```
715
+
716
+ - [ ] **Step 4: Run tests to verify they pass**
717
+
718
+ Run: `node --test test/host-owner-store.test.mjs`
719
+ Expected: PASS(新增 4 条 + 全部既有)。
720
+
721
+ - [ ] **Step 5: Commit**
722
+
723
+ ```bash
724
+ git add src/core/store.mjs test/host-owner-store.test.mjs
725
+ git commit -m "test(store): orphan host-meta lock self-heal end-to-end + refreshed comments (issue #112)"
726
+ ```
727
+
728
+ ---
729
+
730
+ ### Task 6: 全量回归 + 验收对账
731
+
732
+ **验收归属:** A5 · spec §4 矩阵
733
+
734
+ **Files:**
735
+ - 无代码改动(仅验证与对账记录)。
736
+
737
+ **Interfaces:**
738
+ - Consumes: Task 1-5 全部。
739
+ - Produces: 验收对账表(写入 PR 描述,Step 9 使用)。
740
+
741
+ - [ ] **Step 1: Run the full suite**
742
+
743
+ Run: `npm test`
744
+ Expected: 0 失败(含 `test/host-concurrency.integration.test.mjs`、`test/pty-runner.integration.test.mjs` 等重测试)。
745
+
746
+ - [ ] **Step 2: Run typecheck**
747
+
748
+ Run: `npm run typecheck`
749
+ Expected: 0 错误。
750
+
751
+ - [ ] **Step 3: Cross-check anchors**
752
+
753
+ Run: `rg -n "unknown identity is blocked|identity-less short hold" src/ test/`
754
+ Expected: 注释已更新(A6);`unknown identity is blocked` 测试仍通过(新鲜 identity-less 契约保留)。
755
+
756
+ - [ ] **Step 4: Record the acceptance matrix**
757
+
758
+ 在 PR 描述中逐项记录:A1-A6 各命令与结果;U1 标记 `pending`(需用户实机执行,步骤见 spec §4)。**不得**以自动化通过替代 U1。
759
+
760
+ - [ ] **Step 5: Commit(若 Step 1-3 有修正)**
761
+
762
+ 仅当修正了代码/测试时才提交;纯验证不产生 commit。
763
+
764
+ ---
765
+
766
+ ## Self-Review 记录(写完 plan 后自查)
767
+
768
+ 1. **Spec 覆盖**:§2.1→Task 2;§2.2→Task 1+3;§2.3→Task 4;§2.4 非目标无任务(正确);A1→T2、A2a/A2b→T5、A3→T1+T3、A4→T4、A5→T6、A6→T5。无缺口。
769
+ 2. **占位符扫描**:无 TBD/TODO;每个代码步骤带完整代码。
770
+ 3. **类型一致性**:`classifyLeaseOwner(owner, now, isProcessDead, opts)` 在 Task 2 定义、Task 5 只经真实路径使用;`clearHostMetaThrottleForTests` 在 Task 4 定义并导出、Task 5 复用;`currentProcessIdentity` Task 1 定义、Task 3 使用;诊断 code 字符串与 Global Constraints 一致。
771
+ 4. **已知取舍**:Task 5 Step 2 的「失败态验证」在顺序执行时较难演示(Task 2 已使集成路径转绿)——保留该步并在其中写明了复核方式,避免「未验证即通过」。