@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,98 @@
1
+ /**
2
+ * Reader-side revision consistency for the (state.json, status.json) pair
3
+ * (issue #91, spec D3 — 根治条件 5 read side).
4
+ *
5
+ * The View State Coordinator materializes both artifacts under one shared
6
+ * `materializedRevision` inside a single view lock. Disagreeing stamps
7
+ * therefore mean a coordinator crashed between its paired writes; readers
8
+ * must not combine the mismatched halves into one decision — skip the
9
+ * combination and request coordinator repair (a fresh coordinator's boot
10
+ * replay re-materializes the half-written pair from the journal).
11
+ *
12
+ * Legacy rows (written before revisions existed, or never touched by a
13
+ * command) may lack the field on either side; per the spec's legacy-migration
14
+ * clause the check is skipped for them — a missing stamp is never a desync.
15
+ */
16
+ import { readState as readStateImpl, readStatus as readStatusImpl } from "./store.mjs";
17
+
18
+ /**
19
+ * Whether the (state, status) pair is revision-desynced and must not be
20
+ * combined into one decision.
21
+ * @param {{ materializedRevision?: number|null }|null|undefined} state ViewState as read from state.json.
22
+ * @param {{ materializedRevision?: number|null }|null|undefined} status RunStatus as read from status.json.
23
+ * @returns {boolean}
24
+ */
25
+ export function statusRevisionDesynced(state, status) {
26
+ if (state?.materializedRevision == null || status?.materializedRevision == null) return false;
27
+ return state.materializedRevision !== status.materializedRevision;
28
+ }
29
+
30
+ /**
31
+ * Re-read BOTH halves of the pair fresh and re-check the desync verdict.
32
+ *
33
+ * TOCTOU guard for long-lived readers (reconcile iterates a listRows snapshot;
34
+ * earlier rows' awaited commands let the on-disk pair advance past it): a
35
+ * suspicion raised against the snapshot must be confirmed against fresh reads
36
+ * before acting on it. Under the coordinator's view-lock pairing invariant
37
+ * both files move together, so a fresh consistent pair clears the suspicion;
38
+ * the residual window between the two fresh reads is sub-ms.
39
+ *
40
+ * @param {string} root
41
+ * @param {string} viewId
42
+ * @param {string|null} runId
43
+ * @param {{ readState?: typeof readStateImpl, readStatus?: typeof readStatusImpl }} [readers] injection for tests.
44
+ * @returns {{ state: object|null, status: object|null, desynced: boolean }}
45
+ */
46
+ export function rereadPair(root, viewId, runId, readers = {}) {
47
+ const readState = readers.readState ?? readStateImpl;
48
+ const readStatus = readers.readStatus ?? readStatusImpl;
49
+ const state = readState(root, viewId);
50
+ const status = readStatus(root, viewId, runId);
51
+ return { state, status, desynced: statusRevisionDesynced(state, status) };
52
+ }
53
+
54
+ /**
55
+ * Per-view episode throttle for desync reporting (issue #111 CR r1).
56
+ *
57
+ * A desync is a persistent CONDITION, not a one-shot event: reconcile runs on
58
+ * every dashboard poll (~700ms), and for unrepairable pairs (a torn transient
59
+ * beat has no journal record; or the coordinator is off) the condition never
60
+ * clears. Logging unthrottled would grow diagnostics.jsonl without bound.
61
+ * The throttle logs each distinct (stateRev:statusRev) pair once per viewer;
62
+ * a changed pair (progression, or a fresh crash at new revisions) logs again,
63
+ * and a repaired pair simply stops firing — no reset bookkeeping needed.
64
+ *
65
+ * Kick-retry tracking (issue #111 CR r2): a failed repair kick must not
66
+ * consume the recovery path — an idle dashboard would otherwise never run
67
+ * boot replay for a repairable pair. `markKickFailed`/`shouldRetryKick`
68
+ * track that independently of the diagnostic episode, so retries stay
69
+ * unbounded while the kick keeps failing while diagnostics stay once per
70
+ * episode. A successful kick clears the flag (the coordinator is up: boot
71
+ * replay already repaired a repairable pair; an unrepairable-torn pair
72
+ * gains nothing from re-kicking).
73
+ *
74
+ * @returns {{ shouldLog(viewId: string, stateRevision: number|null, statusRevision: number|null): boolean, markKickFailed(viewId: string): void, shouldRetryKick(viewId: string): boolean, clearKickFailed(viewId: string): void }}
75
+ */
76
+ export function createDesyncEpisodeThrottle() {
77
+ /** @type {Map<string, string>} */
78
+ const lastLogged = new Map();
79
+ /** @type {Set<string>} */
80
+ const kickFailed = new Set();
81
+ return {
82
+ shouldLog(viewId, stateRevision, statusRevision) {
83
+ const pair = `${stateRevision ?? "null"}:${statusRevision ?? "null"}`;
84
+ if (lastLogged.get(viewId) === pair) return false;
85
+ lastLogged.set(viewId, pair);
86
+ return true;
87
+ },
88
+ markKickFailed(viewId) {
89
+ kickFailed.add(viewId);
90
+ },
91
+ shouldRetryKick(viewId) {
92
+ return kickFailed.has(viewId);
93
+ },
94
+ clearKickFailed(viewId) {
95
+ kickFailed.delete(viewId);
96
+ },
97
+ };
98
+ }
@@ -8,9 +8,9 @@ import { atomicWriteJson, ensureDir, readJson } from "./atomic.mjs";
8
8
  import { sameHostOwner } from "./host-coordination.mjs";
9
9
  import { tryAcquireOwnedViewLock } from "./locks.mjs";
10
10
  import * as P from "./paths.mjs";
11
- import { isAlive } from "./pid.mjs";
11
+ import { currentProcessIdentity, isAlive } from "./pid.mjs";
12
12
  import { readCodeRefs, summarizeCodeRefs } from "./code-refs-store.mjs";
13
- import { readDiagnosticSummary } from "./diagnostics.mjs";
13
+ import { appendDiagnostic, readDiagnosticSummary } from "./diagnostics.mjs";
14
14
  import { readEvidence, summarizeEvidence } from "./evidence.mjs";
15
15
  import { readFollowUpQueue, summarizeFollowUpQueue } from "./follow-up-queue.mjs";
16
16
  import { readSteering, summarizeSteering } from "./steering.mjs";
@@ -141,6 +141,39 @@ function hostClaimActive(host) {
141
141
  return Boolean(host && (host.state === "starting" || host.state === "alive" || host.state === "stopping"));
142
142
  }
143
143
 
144
+ /**
145
+ * Views with an unrecovered host-meta contention report on record. The
146
+ * heartbeat path calls updateOwnedHost once per second, so without this a
147
+ * sustained contention episode would flood diagnostics.jsonl (issue #112).
148
+ */
149
+ const hostMetaContentionReported = new Set();
150
+
151
+ /** Test hook: clear the per-process contention report throttle. */
152
+ export function clearHostMetaThrottleForTests() {
153
+ hostMetaContentionReported.clear();
154
+ }
155
+
156
+ /**
157
+ * Best-effort single warn per view per contention episode; diagnostics must
158
+ * never break the caller (appendDiagnostic throws on fs failure).
159
+ */
160
+ function reportHostMetaContention(root, viewId, code, message, details) {
161
+ if (hostMetaContentionReported.has(viewId)) return;
162
+ hostMetaContentionReported.add(viewId);
163
+ try {
164
+ appendDiagnostic(root, viewId, { source: "store", level: "warn", code, message, details });
165
+ } catch { /* best effort */ }
166
+ }
167
+
168
+ /**
169
+ * Identity stamped on host-meta acquisitions so a holder that dies mid-hold
170
+ * leaves a reclaimable record (issue #112).
171
+ * @returns {{pid: number, startToken: string|null}}
172
+ */
173
+ function hostMetaIdentity() {
174
+ return currentProcessIdentity();
175
+ }
176
+
144
177
  /**
145
178
  * Atomically create the provisional `starting` host record for a new instance.
146
179
  * The ONLY entry point allowed to move "no claim / reclaimable terminal state"
@@ -149,13 +182,23 @@ function hostClaimActive(host) {
149
182
  * ready/stop fields so no stale owner data survives the handover.
150
183
  * @param {string} root
151
184
  * @param {Partial<HostStatus> & { viewId: string, instanceId: string }} provisionalHost
152
- * @param {{ heldStartLease?: unknown }} [opts] reserved for host-start lease nesting;
153
- * host-meta is always acquired independently here (short critical section).
185
+ * @param {{ heldStartLease?: unknown, lockImpl?: typeof tryAcquireOwnedViewLock }} [opts]
186
+ * heldStartLease is reserved for host-start lease nesting; lockImpl injects
187
+ * the host-meta acquisition for deterministic contention/identity tests.
154
188
  * @returns {{ claimed: boolean, host: HostStatus|null }}
155
189
  */
156
190
  export function claimHost(root, provisionalHost, opts = {}) {
157
- const lock = tryAcquireOwnedViewLock(root, provisionalHost.viewId, "host-meta");
158
- if (!lock.acquired) return { claimed: false, host: null };
191
+ const acquireHostMeta = opts.lockImpl ?? tryAcquireOwnedViewLock;
192
+ const lock = acquireHostMeta(root, provisionalHost.viewId, "host-meta", { identity: hostMetaIdentity() });
193
+ if (!lock.acquired) {
194
+ // busy is ordinary millisecond-scale contention; blocked (identity-less
195
+ // holder) is the orphan-lock signature worth a diagnostic (issue #112).
196
+ if (lock.reason === "blocked") {
197
+ reportHostMetaContention(root, provisionalHost.viewId, "host_meta_claim_contended", "host-meta lease blocked; host claim not established", { reason: lock.reason });
198
+ }
199
+ return { claimed: false, host: null };
200
+ }
201
+ hostMetaContentionReported.delete(provisionalHost.viewId);
159
202
  try {
160
203
  const existing = readHost(root, provisionalHost.viewId);
161
204
  if (hostClaimActive(existing)) return { claimed: false, host: existing };
@@ -213,12 +256,11 @@ export function claimHost(root, provisionalHost, opts = {}) {
213
256
  * few milliseconds, but a one-shot acquire can land inside that window and
214
257
  * return busy — a revoke or recovery write that silently no-ops is a real
215
258
  * reliability bug, not just a test race. Both `busy` (live owner) and
216
- * `blocked` (identity-less short hold — updateOwnedHost itself acquires
217
- * host-meta without a reclaimable identity, so concurrent fenced writes look
218
- * blocked to each other) are transient here: retry a few times with a short
219
- * synchronous sleep before giving up. A genuinely orphaned host-meta lock
220
- * (holder SIGKILLed mid-hold) survives the window and surfaces as retryable
221
- * not-updated — never as ownership loss.
259
+ * `blocked` (identity-less holder) are transient here: retry a few times with
260
+ * a short synchronous sleep before giving up, and record a warn diagnostic on
261
+ * a sustained episode (issue #112). Acquisitions stamp a full process identity
262
+ * (issue #112), so a holder that dies mid-hold leaves a reclaimable record;
263
+ * legacy identity-less residue is reclaimed past the orphan age gate.
222
264
  */
223
265
  const UPDATE_LOCK_BUSY_ATTEMPTS = 3;
224
266
  const UPDATE_LOCK_BUSY_SLEEP_MS = 20;
@@ -243,17 +285,21 @@ const UPDATE_LOCK_BUSY_SLEEP_MS = 20;
243
285
  export function updateOwnedHost(root, viewId, expectedInstanceId, mutate, opts = {}) {
244
286
  const acquireHostMeta = opts.lockImpl ?? tryAcquireOwnedViewLock;
245
287
  let lock;
288
+ let lastReason = null;
246
289
  for (let attempt = 0; ; attempt++) {
247
- lock = acquireHostMeta(root, viewId, "host-meta");
290
+ lock = acquireHostMeta(root, viewId, "host-meta", { identity: hostMetaIdentity() });
248
291
  if (lock.acquired) break;
292
+ lastReason = lock.reason;
249
293
  // busy and blocked are both millisecond-scale holds for host-meta;
250
294
  // neither is ownership information — only the fenced read below is.
251
295
  if (attempt >= UPDATE_LOCK_BUSY_ATTEMPTS - 1) {
296
+ reportHostMetaContention(root, viewId, "host_meta_lease_contended", "host-meta lease contended; fenced write not applied", { attempts: UPDATE_LOCK_BUSY_ATTEMPTS, lastReason });
252
297
  return { updated: false, ownerChanged: false, host: null };
253
298
  }
254
299
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, UPDATE_LOCK_BUSY_SLEEP_MS);
255
300
  }
256
301
  try {
302
+ hostMetaContentionReported.delete(viewId);
257
303
  const host = readHost(root, viewId);
258
304
  if (!host || !sameHostOwner(host, expectedInstanceId)) {
259
305
  return { updated: false, ownerChanged: true, host };