@zhuxixi/pi-agent-board 0.7.0 → 0.9.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 +46 -0
- package/README.md +16 -12
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/RELEASE.md +148 -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/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
- package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
- package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
- package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
- package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
- package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -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/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
- package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
- package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
- package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
- package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
- package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
- package/package.json +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +633 -31
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/release.mjs +446 -0
- 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/core/control-protocol.mjs +482 -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 +62 -0
- package/src/core/launch-options.mjs +37 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +809 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-query-sequences.mjs +132 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +61 -41
- package/src/ui/pty-attach.ts +318 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Control-socket client identity helpers (issue #103).
|
|
2
|
+
*
|
|
3
|
+
* Client ids travel in the `hello` handshake. `helloBookkeeping` turns the
|
|
4
|
+
* classification into the explicit socket policy: probes and the resident
|
|
5
|
+
* editor-state reporter stay out of `attachedClients` / `attachedEver`, and
|
|
6
|
+
* probes additionally never trigger a host.json write — counting or writing
|
|
7
|
+
* either would pin every host against warm-host reclaim (issue #75 / #103 §C /
|
|
8
|
+
* #130).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Read-only endpoint probe (attach resolver); never counts as attached. */
|
|
12
|
+
export const CLIENT_ID_PROBE = "probe";
|
|
13
|
+
/** Resident editor-state reporter inside a hosted child; never counts as attached. */
|
|
14
|
+
export const CLIENT_ID_EDITOR_REPORTER = "editor-reporter";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ type?: string, clientId?: string } | null | undefined} msg
|
|
18
|
+
* @returns {"probe" | "editor-reporter" | "client"}
|
|
19
|
+
*/
|
|
20
|
+
export function classifyClientHello(msg) {
|
|
21
|
+
const clientId = msg && typeof msg.clientId === "string" ? msg.clientId : "";
|
|
22
|
+
if (clientId === CLIENT_ID_PROBE) return "probe";
|
|
23
|
+
if (clientId === CLIENT_ID_EDITOR_REPORTER) return "editor-reporter";
|
|
24
|
+
return "client";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Socket bookkeeping implied by a hello classification (issue #103 §C / #130).
|
|
29
|
+
*
|
|
30
|
+
* `probe` and `editor-reporter` are bookkeeping-only: neither may keep
|
|
31
|
+
* `attachedClients` — whose sole source is the runner's `clients` set —
|
|
32
|
+
* non-zero, or warm-host reclaim never fires and hosts leak (issue #75). Both
|
|
33
|
+
* therefore leave `clients` at hello time, while their socket stays writable so
|
|
34
|
+
* probe replies and editor_state keep flowing.
|
|
35
|
+
*
|
|
36
|
+
* `persist: false` for probes is deliberate: the attach resolver probes every
|
|
37
|
+
* HOST_PROBE_RETRY_MS (see host-probe.mjs), so one host.json write per probe
|
|
38
|
+
* would amplify fenced writes — the exact cost the runner's probe-socket write
|
|
39
|
+
* suppression exists to avoid. Dropping the socket from `clients` already makes
|
|
40
|
+
* the in-memory count right, and the next heartbeat (<=1s) persists it.
|
|
41
|
+
*
|
|
42
|
+
* `suppressCloseWrite: true` is the other half of that suppression: close is the
|
|
43
|
+
* wrong moment to flush a record a probe never changed.
|
|
44
|
+
*
|
|
45
|
+
* An unrecognized kind falls back to the real-client row on purpose — counting
|
|
46
|
+
* an unknown connection is the conservative direction (it can only delay
|
|
47
|
+
* reclaim, never risk killing a host with a live client).
|
|
48
|
+
*
|
|
49
|
+
* @param {"probe" | "editor-reporter" | "client"} kind
|
|
50
|
+
* @returns {{ keepInClients: boolean, registerReporter: boolean,
|
|
51
|
+
* flipAttachedEver: boolean, persist: boolean, suppressCloseWrite: boolean }}
|
|
52
|
+
*/
|
|
53
|
+
export function helloBookkeeping(kind) {
|
|
54
|
+
switch (kind) {
|
|
55
|
+
case "probe":
|
|
56
|
+
return { keepInClients: false, registerReporter: false, flipAttachedEver: false, persist: false, suppressCloseWrite: true };
|
|
57
|
+
case "editor-reporter":
|
|
58
|
+
return { keepInClients: false, registerReporter: true, flipAttachedEver: false, persist: true, suppressCloseWrite: false };
|
|
59
|
+
default:
|
|
60
|
+
return { keepInClients: true, registerReporter: false, flipAttachedEver: true, persist: true, suppressCloseWrite: false };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -361,6 +361,43 @@ export function filterCwdCandidates(candidates, query) {
|
|
|
361
361
|
return candidates.filter((entry) => entry.path.toLowerCase().includes(q));
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Whether two path strings resolve to the same directory. Blank inputs never
|
|
366
|
+
* match (path.resolve("") would silently equal process.cwd()).
|
|
367
|
+
* @param {string} a
|
|
368
|
+
* @param {string} b
|
|
369
|
+
* @returns {boolean}
|
|
370
|
+
*/
|
|
371
|
+
export function sameResolvedDir(a, b) {
|
|
372
|
+
const na = String(a ?? "").trim();
|
|
373
|
+
const nb = String(b ?? "").trim();
|
|
374
|
+
if (!na || !nb) return false;
|
|
375
|
+
return path.resolve(expandHome(na)) === path.resolve(expandHome(nb));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Browse-mode Tab completion for the cwd picker. Completes to the highlighted
|
|
380
|
+
* suggestion with a trailing separator (so the next recompute lists that
|
|
381
|
+
* directory's children); when the highlight already equals the current query
|
|
382
|
+
* (no progress), advances cyclically so repeated Tab drills downward. A lone
|
|
383
|
+
* self suggestion stays put, so drilling stops naturally at leaf directories.
|
|
384
|
+
* @param {string} query
|
|
385
|
+
* @param {string[]} suggestions
|
|
386
|
+
* @param {number} index
|
|
387
|
+
* @returns {{query: string, completed: string, usedIndex: number} | null}
|
|
388
|
+
*/
|
|
389
|
+
export function browseTabCompletion(query, suggestions, index) {
|
|
390
|
+
if (!suggestions || suggestions.length === 0) return null;
|
|
391
|
+
const len = suggestions.length;
|
|
392
|
+
let usedIndex = ((index % len) + len) % len;
|
|
393
|
+
let completed = suggestions[usedIndex];
|
|
394
|
+
if (len > 1 && sameResolvedDir(completed, query)) {
|
|
395
|
+
usedIndex = (usedIndex + 1) % len;
|
|
396
|
+
completed = suggestions[usedIndex];
|
|
397
|
+
}
|
|
398
|
+
return { query: completed + path.sep, completed, usedIndex };
|
|
399
|
+
}
|
|
400
|
+
|
|
364
401
|
/**
|
|
365
402
|
* Decide cwd picker mode + suggestions for a query: favorites when the query
|
|
366
403
|
* is empty or matches ranked candidates, filesystem browse otherwise.
|
package/src/core/locks.mjs
CHANGED
|
@@ -143,6 +143,12 @@ function releaseLock(lockPath, fs) {
|
|
|
143
143
|
const defaultLeaseFs = Object.freeze({ ...defaultLocksFs, renameSync });
|
|
144
144
|
/** Max stale-reclaim → republish rounds inside a single acquire attempt. */
|
|
145
145
|
const MAX_LEASE_RECLAIM_ATTEMPTS = 3;
|
|
146
|
+
/**
|
|
147
|
+
* Age past which an identity-less lock is treated as an orphan candidate.
|
|
148
|
+
* Host-meta holds are millisecond-scale critical sections, so no legitimate
|
|
149
|
+
* holder reaches this (issue #112).
|
|
150
|
+
*/
|
|
151
|
+
const ORPHAN_LEASE_AGE_MS = 5 * 60_000;
|
|
146
152
|
|
|
147
153
|
/**
|
|
148
154
|
* A token-fenced lease over the view-lock directory. Every operation re-reads
|
|
@@ -189,6 +195,23 @@ export function tryAcquireOwnedViewLock(root, viewId, name, opts = {}) {
|
|
|
189
195
|
return { acquired: true, lease: attempt };
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Whether a publish-rename failure means "the lock path already exists"
|
|
200
|
+
* (contention) rather than a genuine filesystem error.
|
|
201
|
+
*
|
|
202
|
+
* POSIX reports EEXIST/ENOTEMPTY when renaming a directory onto an existing
|
|
203
|
+
* one; Windows reports EPERM (errno -4048) for the same situation — this op's
|
|
204
|
+
* platform equivalent of EEXIST (issue #114: a crashed owner's lease could
|
|
205
|
+
* never be reclaimed because EPERM was rethrown before reclaimOrBlock).
|
|
206
|
+
* Routing a genuine permission error here stays safe: reclaimability is still
|
|
207
|
+
* decided solely by `classifyLeaseOwner`, so it resolves `blocked`/`busy`.
|
|
208
|
+
* @param {string|undefined|null} code
|
|
209
|
+
* @returns {boolean}
|
|
210
|
+
*/
|
|
211
|
+
export function isPublishConflictCode(code) {
|
|
212
|
+
return code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM";
|
|
213
|
+
}
|
|
214
|
+
|
|
192
215
|
/**
|
|
193
216
|
* Single-shot acquire round: publish a complete candidate lock (owner.json
|
|
194
217
|
* written BEFORE the lock path exists) via atomic rename, and on contention
|
|
@@ -219,9 +242,9 @@ function attemptAcquireLease(lockPath, opts) {
|
|
|
219
242
|
return makeLease(lockPath, token, fs, now);
|
|
220
243
|
} catch (err) {
|
|
221
244
|
try { fs.rmSync(candidate, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
222
|
-
const code =
|
|
223
|
-
if (code
|
|
224
|
-
const verdict = reclaimOrBlock(lockPath, token, fs, isProcessDead);
|
|
245
|
+
const code = /** @type {NodeJS.ErrnoException|undefined} */ (err)?.code;
|
|
246
|
+
if (!isPublishConflictCode(code)) throw err;
|
|
247
|
+
const verdict = reclaimOrBlock(lockPath, token, fs, isProcessDead, now);
|
|
225
248
|
if (verdict !== true) return verdict;
|
|
226
249
|
// Reclaimed a dead owner's lock — retry the publish on the next round.
|
|
227
250
|
}
|
|
@@ -230,29 +253,60 @@ function attemptAcquireLease(lockPath, opts) {
|
|
|
230
253
|
}
|
|
231
254
|
|
|
232
255
|
/**
|
|
233
|
-
* Decide
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
256
|
+
* Decide what to do with an inspected lease owner. Pure: the caller supplies
|
|
257
|
+
* `now` and a pid-liveness probe.
|
|
258
|
+
*
|
|
259
|
+
* A full identity (pid + startToken) reclaims exactly when its pid is dead.
|
|
260
|
+
* An identity-less owner — legacy short-hold locks, or platforms where
|
|
261
|
+
* startToken cannot be captured — is only reclaimable past `orphanAgeMs`
|
|
262
|
+
* with a provably dead top-level pid: fresh identity-less locks stay blocked,
|
|
263
|
+
* preserving the short-critical-section contract (issue #112).
|
|
264
|
+
* @param {any} owner parsed owner.json
|
|
265
|
+
* @param {number} now
|
|
266
|
+
* @param {(pid: number) => boolean} isProcessDead
|
|
267
|
+
* @param {{ orphanAgeMs?: number }} [opts]
|
|
268
|
+
* @returns {"reclaim" | "busy" | "blocked"}
|
|
269
|
+
*/
|
|
270
|
+
export function classifyLeaseOwner(owner, now, isProcessDead, opts = {}) {
|
|
271
|
+
if (!owner || typeof owner !== "object") return "blocked";
|
|
272
|
+
const ownPid = Number(owner?.identity?.pid ?? 0);
|
|
273
|
+
const hasIdentity = Number.isFinite(ownPid) && ownPid > 0 && typeof owner?.identity?.startToken === "string";
|
|
274
|
+
if (hasIdentity) {
|
|
275
|
+
if (!isProcessDead(ownPid)) return "busy";
|
|
276
|
+
// Quarantine-mode reclaim verifies by token that it renamed the lock it
|
|
277
|
+
// inspected — without one nothing may be deleted.
|
|
278
|
+
return typeof owner.token === "string" ? "reclaim" : "blocked";
|
|
279
|
+
}
|
|
280
|
+
const pid = Number(owner?.pid ?? 0);
|
|
281
|
+
if (!Number.isFinite(pid) || pid <= 0) return "blocked";
|
|
282
|
+
const orphanAgeMs = Number(opts.orphanAgeMs ?? ORPHAN_LEASE_AGE_MS);
|
|
283
|
+
if (!(Number(now) - Number(owner?.startedAt ?? 0) >= orphanAgeMs)) return "blocked";
|
|
284
|
+
if (!isProcessDead(pid)) return "busy";
|
|
285
|
+
return typeof owner.token === "string" ? "reclaim" : "blocked";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Decide whether an existing lock may be reclaimed. Verdicts come from the
|
|
290
|
+
* pure `classifyLeaseOwner`; reclaim itself quarantines via rename first so a
|
|
291
|
+
* concurrent winner can only ever delete the directory it itself renamed.
|
|
238
292
|
* @param {string} lockPath
|
|
239
293
|
* @param {string} token the caller's own token (names the quarantine dir)
|
|
240
294
|
* @param {typeof defaultLeaseFs} fs
|
|
241
295
|
* @param {(pid: number) => boolean} isProcessDead
|
|
296
|
+
* @param {() => number} now clock (matching the caller's candidate timestamps)
|
|
242
297
|
* @returns {true | "busy" | "blocked"}
|
|
243
298
|
*/
|
|
244
|
-
function reclaimOrBlock(lockPath, token, fs, isProcessDead) {
|
|
299
|
+
function reclaimOrBlock(lockPath, token, fs, isProcessDead, now) {
|
|
245
300
|
let owner;
|
|
246
301
|
try {
|
|
247
302
|
owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf8"));
|
|
248
303
|
} catch {
|
|
249
304
|
return "blocked";
|
|
250
305
|
}
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
const inspectedToken =
|
|
255
|
-
if (inspectedToken === null) return "blocked";
|
|
306
|
+
const verdict = classifyLeaseOwner(owner, now(), isProcessDead);
|
|
307
|
+
if (verdict !== "reclaim") return verdict;
|
|
308
|
+
// classifyLeaseOwner only returns "reclaim" when owner.token is a string.
|
|
309
|
+
const inspectedToken = owner.token;
|
|
256
310
|
const quarantine = `${lockPath}.reclaim.${token}`;
|
|
257
311
|
try {
|
|
258
312
|
fs.renameSync(lockPath, quarantine);
|
package/src/core/paths.mjs
CHANGED
|
@@ -37,6 +37,10 @@ export const metaPath = (root, viewId) => path.join(viewDir(root, viewId), "meta
|
|
|
37
37
|
export const statePath = (root, viewId) => path.join(viewDir(root, viewId), "state.json");
|
|
38
38
|
/** @param {string} root @param {string} viewId */
|
|
39
39
|
export const hostPath = (root, viewId) => path.join(viewDir(root, viewId), "host.json");
|
|
40
|
+
/** Durable control-command journal for a view's host (issue #91 phase 5, spec
|
|
41
|
+
* D4): one JSONL line per durable-command lifecycle transition
|
|
42
|
+
* (accepted/applied). Sibling of host.json in the view dir. */
|
|
43
|
+
export const controlJournalPath = (root, viewId) => path.join(viewDir(root, viewId), "control-journal.jsonl");
|
|
40
44
|
/** @param {string} root @param {string} viewId */
|
|
41
45
|
export const hostConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "host-config.json");
|
|
42
46
|
/**
|
|
@@ -71,6 +75,21 @@ export function controlSocketPathFor(platform, root, viewId) {
|
|
|
71
75
|
}
|
|
72
76
|
/** @param {string} root @param {string} viewId */
|
|
73
77
|
export const controlSocketPath = (root, viewId) => controlSocketPathFor(process.platform, root, viewId);
|
|
78
|
+
/**
|
|
79
|
+
* Endpoint a hosted child's editor-state reporter should connect to.
|
|
80
|
+
*
|
|
81
|
+
* The runner knows which endpoint it actually bound and injects it into the
|
|
82
|
+
* child env as AGENT_BOARD_CONTROL_SOCKET (issue #103): since #70 the owned
|
|
83
|
+
* runner binds a per-instance address, while the reporter kept dialing the
|
|
84
|
+
* stable per-view one, so it never connected and the attach gate lost its
|
|
85
|
+
* authoritative editor state. The injected value wins; the stable address
|
|
86
|
+
* remains the fallback for legacy hosts and older runners that predate the key.
|
|
87
|
+
* @param {{ envSocketPath?: string | null, platform: "win32"|"linux"|"darwin", root: string, viewId: string }} args
|
|
88
|
+
*/
|
|
89
|
+
export function resolveControlEndpoint({ envSocketPath, platform, root, viewId }) {
|
|
90
|
+
const injected = typeof envSocketPath === "string" ? envSocketPath.trim() : "";
|
|
91
|
+
return injected.length > 0 ? injected : controlSocketPathFor(platform, root, viewId);
|
|
92
|
+
}
|
|
74
93
|
/**
|
|
75
94
|
* Per-instance control endpoint. Each new host instance binds its own socket/pipe,
|
|
76
95
|
* so a superseded runner can never unlink the current owner's endpoint (issue #70).
|
|
@@ -93,14 +112,27 @@ export const screenLogPath = (root, viewId) => path.join(viewDir(root, viewId),
|
|
|
93
112
|
* Well-known endpoint for the board-root View State Coordinator (issue #91, spec D3).
|
|
94
113
|
* Exactly one coordinator may own a root (token-fenced lease), so one stable path
|
|
95
114
|
* suffices; a stale POSIX socket left by a crashed coordinator is unlinked by the
|
|
96
|
-
* new lease owner before bind. win32 pipe names embed a 16-hex hash of the
|
|
97
|
-
* stay under the 256-char limit and keep per-root isolation.
|
|
115
|
+
* new lease owner before bind. win32 pipe names embed a 16-hex hash of the
|
|
116
|
+
* *normalized* root to stay under the 256-char limit and keep per-root isolation.
|
|
98
117
|
* @param {"win32"|"linux"|"darwin"} platform
|
|
99
118
|
* @param {string} root
|
|
100
119
|
*/
|
|
101
120
|
export function coordinatorEndpointPathFor(platform, root) {
|
|
102
121
|
if (platform === "win32") {
|
|
103
|
-
|
|
122
|
+
// Normalize before hashing: the pipe name must be invariant to the root's
|
|
123
|
+
// spelling (C:/x vs C:\x, trailing separators, dot segments). The lock path
|
|
124
|
+
// derived from the same root already is (via path.join); a mismatch yields
|
|
125
|
+
// "same lock, two pipes" — the panel probes a pipe nobody bound, spawns
|
|
126
|
+
// replacements that cannot take the held lease, and locks itself out
|
|
127
|
+
// (issue #124). resolve() is idempotent on canonical roots, so coordinators
|
|
128
|
+
// already deployed keep their pipe name and need no restart.
|
|
129
|
+
// win32 semantics are named explicitly rather than using the ambient
|
|
130
|
+
// path.resolve: this branch must emit the same pipe name on every host OS
|
|
131
|
+
// (on POSIX, ambient resolve treats `C:\x` as a relative path: no
|
|
132
|
+
// drive-letter or backslash-separator semantics), so platform-injected
|
|
133
|
+
// tests and CI behave identically everywhere.
|
|
134
|
+
const normalized = path.win32.resolve(root);
|
|
135
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
104
136
|
return `\\\\.\\pipe\\agent-board-coordinator-${hash}`;
|
|
105
137
|
}
|
|
106
138
|
return path.join(root, "coordinator.sock");
|
package/src/core/pid.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** Process liveness checks. */
|
|
1
|
+
/** Process liveness checks and process identity capture. */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Whether `pid` refers to a live process.
|
|
@@ -40,3 +41,33 @@ export function killProcess(pid, graceMs = 4000) {
|
|
|
40
41
|
}
|
|
41
42
|
}, graceMs).unref?.();
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
|
|
47
|
+
* across exec(2). Distinguishes an owned-live pid from a reused one; null on
|
|
48
|
+
* failure or non-Linux platforms. Mirrors the runner's captureStartToken
|
|
49
|
+
* (issue #70); shared here for host-meta lease identity (issue #112).
|
|
50
|
+
* @param {number|null|undefined} pid
|
|
51
|
+
* @returns {string|null}
|
|
52
|
+
*/
|
|
53
|
+
export function captureStartToken(pid) {
|
|
54
|
+
if (process.platform !== "linux" || !pid) return null;
|
|
55
|
+
try {
|
|
56
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
57
|
+
// comm (field 2) may contain spaces and parens — fields resume AFTER the
|
|
58
|
+
// last ')'. fields[0] is state (field 3) → starttime (field 22) is [19].
|
|
59
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trimStart();
|
|
60
|
+
return afterComm.split(/\s+/)[19] ?? null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Launch-time identity for the current process, as stamped on host-meta
|
|
68
|
+
* acquisitions (issue #112).
|
|
69
|
+
* @returns {{pid: number, startToken: string|null}}
|
|
70
|
+
*/
|
|
71
|
+
export function currentProcessIdentity() {
|
|
72
|
+
return { pid: process.pid, startToken: captureStartToken(process.pid) };
|
|
73
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* LEGACY FALLBACK (issue #91 phase 6 marking): the shrink-and-hold jiggle is
|
|
3
|
+
* kept only for pre-protocol runners and the AGENT_BOARD_TERMINAL_SNAPSHOT=0
|
|
4
|
+
* kill switch. It is NOT a success condition of the snapshot+subscribe
|
|
5
|
+
* protocol path — the protocol re-baselines the view from runner-owned
|
|
6
|
+
* snapshots (phase 4), and jiggle arming is skipped once the probe resolves
|
|
7
|
+
* to the protocol attach mode. Removal condition: the installed runner fleet
|
|
8
|
+
* is on the snapshot protocol baseline (wire-detectable via hello protocol
|
|
9
|
+
* fields).
|
|
10
|
+
*
|
|
2
11
|
* Injectable orchestration for the attach shrink-and-hold jiggle protocol.
|
|
3
12
|
*
|
|
4
13
|
* Replaces the pulse-pair jiggle (shrink → 200ms → restore) with a
|
|
@@ -208,17 +217,32 @@ export function createJiggleRetryController(deps) {
|
|
|
208
217
|
* Feed one socket output chunk. A clear wins over the re-arm when both
|
|
209
218
|
* appear in one chunk — but frame cognition is still learned from that
|
|
210
219
|
* chunk: a clear only proves the child redraws, not that it isn't a TUI
|
|
211
|
-
* (
|
|
220
|
+
* (a live fullRender chunk bundles a frame start with its clear, issue #11).
|
|
212
221
|
* The first TUI frame restores the held size (the
|
|
213
222
|
* child is now rendering and will fullRender on the width delta) and
|
|
214
223
|
* does NOT reschedule the chain; if G1 already released the hold before
|
|
215
224
|
* the TUI booted, the frame instead re-arms a fresh hold so the running
|
|
216
225
|
* child still sees a width delta (F1 slow-boot probe).
|
|
226
|
+
* Terminal chain states still learn frame cognition from later output
|
|
227
|
+
* (issue #106) — cognition only; re-opening the protocol stays heal()'s job.
|
|
217
228
|
* @param {string} data
|
|
218
229
|
*/
|
|
219
230
|
function feed(data) {
|
|
220
|
-
|
|
221
|
-
|
|
231
|
+
// Terminal chain states: a clear was seen (chain done) or the chain ended
|
|
232
|
+
// (G2/G3/G4). Output no longer drives the retry protocol — but frame
|
|
233
|
+
// cognition must still be learned from it (issue #106): a TUI whose first
|
|
234
|
+
// frame lands after the chain settled must still open the runtime desync
|
|
235
|
+
// backstop's gate 2 (issue #11), otherwise heal() stays unreachable for the
|
|
236
|
+
// rest of this connection. Cognition only — no timers, no resizes, and no
|
|
237
|
+
// retry-state change: re-opening the protocol is heal()'s job (rate-limited
|
|
238
|
+
// and lifetime-capped), not an output chunk's.
|
|
239
|
+
if (state.clearDetected || state.stopped) {
|
|
240
|
+
if (tuiFrameSeen) return; // latched already — nothing left to learn
|
|
241
|
+
const terminal = feedOutput(state, data, carry);
|
|
242
|
+
carry = terminal.carry; // keep cross-chunk marker detection intact
|
|
243
|
+
if (terminal.frameStartFound) tuiFrameSeen = true;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
222
246
|
const result = feedOutput(state, data, carry);
|
|
223
247
|
state = result.state;
|
|
224
248
|
carry = result.carry;
|
|
@@ -31,13 +31,20 @@ export function evaluateAttachReconnect({ everConnected, disconnectedAt, connect
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Detach-key policy: while the socket is down a
|
|
35
|
-
* so treat ←
|
|
36
|
-
*
|
|
34
|
+
* Detach-key policy (issue #91 Phase 6, spec §D1): while the socket is down a
|
|
35
|
+
* key can never reach the child, so treat ← as "leave the view"
|
|
36
|
+
* unconditionally; otherwise detach only when the pushed editorEmpty side
|
|
37
|
+
* channel is exactly true. false and null/undefined mean "forward" — the
|
|
38
|
+
* explicit conservative policy; callers pass `editorEmpty === true` so an
|
|
39
|
+
* unknown state can never arm the detach, and no terminal-buffer heuristic is
|
|
40
|
+
* consulted (the deleted terminal-buffer heuristic family — issues
|
|
41
|
+
* #42/#66/#69/#103 — stays deleted).
|
|
37
42
|
* @param {boolean} connected
|
|
38
|
-
* @param {boolean}
|
|
43
|
+
* @param {boolean | null | undefined} editorEmpty
|
|
39
44
|
* @returns {boolean}
|
|
40
45
|
*/
|
|
41
|
-
export function shouldEscapeAttach(connected,
|
|
42
|
-
|
|
46
|
+
export function shouldEscapeAttach(connected, editorEmpty) {
|
|
47
|
+
// Normalize to a strict boolean: a null/undefined editorEmpty (unknown)
|
|
48
|
+
// forwards — never escapes — and must not leak through the return value.
|
|
49
|
+
return !connected || editorEmpty === true;
|
|
43
50
|
}
|
|
@@ -43,6 +43,26 @@ export function projectPtyCursor(buf, start, height) {
|
|
|
43
43
|
return { row, col: buf.cursorX };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Duck-typed read of the child terminal's DECTCEM visibility state.
|
|
48
|
+
*
|
|
49
|
+
* pi-tui hides the hardware cursor (ESC[?25l) on essentially every frame and
|
|
50
|
+
* still parks it for IME positioning, so the xterm cursor position outlives its
|
|
51
|
+
* visibility: it is a rendering byproduct, not a request to show a cursor. The
|
|
52
|
+
* attach projection must not resurrect that parked cell as a visible block, and
|
|
53
|
+
* must equally not hide a cursor the child wants shown (shells, vim,
|
|
54
|
+
* PI_HARDWARE_CURSOR=1). Only an explicit `true` from xterm's cursor service
|
|
55
|
+
* counts as hidden; anything unknown (renamed internals, another @xterm build)
|
|
56
|
+
* falls back to visible, which is the pre-#102 behavior.
|
|
57
|
+
*/
|
|
58
|
+
export function isPtyCursorHidden(term) {
|
|
59
|
+
try {
|
|
60
|
+
return term?._core?.coreService?.isCursorHidden === true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* Coalesce PTY parser callbacks into a bounded stream of repaint requests.
|
|
48
68
|
*
|
|
@@ -59,6 +59,45 @@ export const STATE_COMMAND_KINDS = Object.freeze([
|
|
|
59
59
|
*/
|
|
60
60
|
export const TRANSIENT_KINDS = Object.freeze(["run_progress"]);
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Decision-layer reject reasons: journaled, authoritative coordinator verdicts
|
|
64
|
+
* with NO recovery semantics (a retry would be decided the same way). The
|
|
65
|
+
* complement — transport ambiguity ("timeout"/"connection_reset"/
|
|
66
|
+
* "connection_failed"/"coordinator_unavailable") — is the only class where
|
|
67
|
+
* "outcome unknown, replay will recover" diagnostics are honest (issue #91
|
|
68
|
+
* hygiene). validateCommand envelope errors are first-party programmer errors
|
|
69
|
+
* and are not in this set.
|
|
70
|
+
*/
|
|
71
|
+
export const DECIDED_REJECT_REASONS = Object.freeze(new Set([
|
|
72
|
+
"manual_fence",
|
|
73
|
+
"stale_run",
|
|
74
|
+
"no_change",
|
|
75
|
+
"busy",
|
|
76
|
+
"revision_conflict",
|
|
77
|
+
"unknown_view",
|
|
78
|
+
"unknown_kind",
|
|
79
|
+
"field_not_allowed",
|
|
80
|
+
]));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Diagnostic fields for a non-applied command result (issue #91 hygiene):
|
|
84
|
+
* decided rejects are info-level skips — the coordinator's verdict is
|
|
85
|
+
* authoritative, so "outcome unknown, replay will recover" would be a lie.
|
|
86
|
+
* Transport ambiguity keeps the warn with the caller's recovery hint.
|
|
87
|
+
* Pure: shapes only, no I/O.
|
|
88
|
+
* @param {string} code diagnostic code stem (e.g. "run_started")
|
|
89
|
+
* @param {string} label human phrase for the message (e.g. "Run bootstrap")
|
|
90
|
+
* @param {string|null} reason rejected reason from the command result
|
|
91
|
+
* @param {string} ambiguousTail recovery hint appended ONLY for ambiguous reasons
|
|
92
|
+
* @returns {{ level: "info"|"warn", code: string, message: string }}
|
|
93
|
+
*/
|
|
94
|
+
export function commandRejectDiagnostic(code, label, reason, ambiguousTail) {
|
|
95
|
+
const decided = reason != null && DECIDED_REJECT_REASONS.has(reason);
|
|
96
|
+
return decided
|
|
97
|
+
? { level: "info", code: `${code}_skipped`, message: `${label} skipped by the coordinator (${reason}); its decision is authoritative` }
|
|
98
|
+
: { level: "warn", code: `${code}_ambiguous`, message: `${label} outcome unknown (${reason}); if the command was journaled, coordinator replay will recover it; ${ambiguousTail}` };
|
|
99
|
+
}
|
|
100
|
+
|
|
62
101
|
/**
|
|
63
102
|
* Per-source whitelist for `patch_fields` (metadata/evidence-mirror merges).
|
|
64
103
|
* Anything not listed here is rejected with "field_not_allowed" — semantic
|
|
@@ -141,7 +180,10 @@ export function validateCommand(raw) {
|
|
|
141
180
|
if (raw.payload.runId != null && (typeof raw.payload.runId !== "string" || !raw.payload.runId)) return { ok: false, error: "missing_runId" };
|
|
142
181
|
break;
|
|
143
182
|
case "run_started": {
|
|
144
|
-
|
|
183
|
+
// Non-empty string (not just != null): a degenerate runId passes the
|
|
184
|
+
// null guard, then the statusRunId binding silently drops the status
|
|
185
|
+
// half while the ack still says "applied" (Task 2 review P2-A).
|
|
186
|
+
if (typeof raw.runId !== "string" || !raw.runId) return { ok: false, error: "missing_runId" };
|
|
145
187
|
const status = raw.payload?.status;
|
|
146
188
|
if (!status || typeof status !== "object") return { ok: false, error: "missing_status" };
|
|
147
189
|
if (typeof status.runId !== "string" || status.runId !== raw.runId) return { ok: false, error: "bad_status" };
|
|
@@ -359,11 +401,32 @@ export function decideStateTransition(command, currentState, currentStatus, now
|
|
|
359
401
|
// Liveness semantics: only the row's current live run may move forward.
|
|
360
402
|
// Late/duplicate progress from a finished run is dropped (next run's
|
|
361
403
|
// progress supersedes it anyway — this kind is transient by design).
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
404
|
+
//
|
|
405
|
+
// A missing status file splits two ways (F2, split by the hard-down
|
|
406
|
+
// residual closure): a QUALIFIED beat from the row's current live run —
|
|
407
|
+
// full-shaped patch carrying processState/semanticState and the same
|
|
408
|
+
// runId — bootstraps the file, because the coordinator may have been
|
|
409
|
+
// down through the runner's boot window (mark_queued applied,
|
|
410
|
+
// run_started lost): without the bootstrap the row can never converge
|
|
411
|
+
// (beats and finalize both reject forever). Anything else — exited or
|
|
412
|
+
// re-pointed rows, sparse patches that would materialize an
|
|
413
|
+
// undefined-shaped status — stays stale_run (F2's original exposure).
|
|
414
|
+
if (!currentStatus) {
|
|
415
|
+
// P2 hardening: the bootstrap is keyed on command.runId (it becomes the
|
|
416
|
+
// status-file identity), so a degenerate null==null row match must never
|
|
417
|
+
// bootstrap — the shell would silently drop the status half. The check
|
|
418
|
+
// lives here beside its sibling liveness guards (runId semantics for
|
|
419
|
+
// run_progress are decision-layer; envelope validation stays minimal,
|
|
420
|
+
// matching the generic stale-run guard's decision-side read).
|
|
421
|
+
const rowMatches = typeof command.runId === "string" && command.runId.length > 0
|
|
422
|
+
&& currentState.currentRunId === command.runId
|
|
423
|
+
&& currentState.processState === "alive";
|
|
424
|
+
if (!rowMatches || !beatPatchQualifiesForBootstrap(command)) return reject("stale_run");
|
|
425
|
+
// The patch itself is the base: it is the runner's authoritative
|
|
426
|
+
// full in-memory status, so applyStatusProjection's merge-onto-empty
|
|
427
|
+
// materializes exactly the patch (validated full-shaped above).
|
|
428
|
+
return applyStatusProjection(command, currentState, null, now);
|
|
429
|
+
}
|
|
367
430
|
if (currentState.currentRunId !== command.runId || currentState.processState !== "alive") return reject("stale_run");
|
|
368
431
|
return applyStatusProjection(command, currentState, currentStatus, now);
|
|
369
432
|
}
|
|
@@ -580,6 +643,25 @@ function buildPatches(currentState, stateClone, stateChanged, currentStatus, sta
|
|
|
580
643
|
return mutate;
|
|
581
644
|
}
|
|
582
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Whether a run_progress beat may bootstrap a missing status file: the patch
|
|
648
|
+
* must be full-shaped — carrying processState and semanticState (so the
|
|
649
|
+
* materialized status never has an undefined shape) and pinned to the same
|
|
650
|
+
* runId as the command (so a foreign run's snapshot can never masquerade as
|
|
651
|
+
* this run's bootstrap). Sparse or mismatched patches stay stale_run.
|
|
652
|
+
* @param {object} command
|
|
653
|
+
*/
|
|
654
|
+
function beatPatchQualifiesForBootstrap(command) {
|
|
655
|
+
const patch = command.payload?.statusPatch;
|
|
656
|
+
return Boolean(
|
|
657
|
+
patch &&
|
|
658
|
+
typeof patch === "object" &&
|
|
659
|
+
typeof patch.processState === "string" &&
|
|
660
|
+
typeof patch.semanticState === "string" &&
|
|
661
|
+
patch.runId === command.runId,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
583
665
|
/**
|
|
584
666
|
* Shared body for the status-patch kinds (`run_progress`, `followup_started`):
|
|
585
667
|
* merge payload.statusPatch onto the materialized status, then let
|