@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.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- 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
|
+
}
|
package/src/core/store.mjs
CHANGED
|
@@ -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]
|
|
153
|
-
*
|
|
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
|
|
158
|
-
|
|
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
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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 };
|