@zhuxixi/pi-agent-board 0.5.2 → 0.6.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 +32 -0
- package/README.md +41 -3
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
- package/package.json +83 -81
- package/runner/job-runner.mjs +2 -2
- package/runner/pty-runner.mjs +573 -2
- package/runner/state-runner.mjs +3 -0
- package/runner/title-runner.mjs +1 -1
- package/scripts/release_helper.mjs +277 -0
- package/src/commands/agent-board.ts +38 -35
- package/src/commands/attach-decision.mjs +66 -0
- package/src/commands/attach-flow.ts +45 -39
- package/src/core/code-refs.mjs +85 -33
- package/src/core/evidence.mjs +2 -2
- package/src/core/heuristics.mjs +40 -2
- package/src/core/host-coordination.mjs +159 -0
- package/src/core/host-crash.mjs +43 -3
- package/src/core/host-probe.mjs +196 -0
- package/src/core/launch.mjs +3 -1
- package/src/core/locks.mjs +196 -1
- package/src/core/paths.mjs +24 -0
- package/src/core/store.mjs +164 -5
- package/src/core/types.mjs +17 -1
- package/src/core/warm-host-sweeper.mjs +150 -0
- package/src/index.ts +29 -1
- package/src/runtime/service.mjs +967 -109
- package/src/ui/dashboard-decisions.mjs +55 -0
- package/src/ui/dashboard.ts +26 -12
package/src/core/launch.mjs
CHANGED
|
@@ -55,7 +55,9 @@ export function launchRun(root, config, opts) {
|
|
|
55
55
|
* @returns {{ pid: number|null, configPath: string }}
|
|
56
56
|
*/
|
|
57
57
|
export function launchHost(root, config, opts) {
|
|
58
|
-
|
|
58
|
+
// Instance-specific config path wins when present (issue #70: concurrent
|
|
59
|
+
// launches must not share the fixed host-config.json); legacy callers keep it.
|
|
60
|
+
const configPath = config.configPath ?? P.hostConfigPath(root, config.viewId);
|
|
59
61
|
atomicWriteJson(configPath, config);
|
|
60
62
|
|
|
61
63
|
const node = opts.node ?? resolveNode();
|
package/src/core/locks.mjs
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* MAX_ENV_ATTEMPTS quick retries — each retry re-runs ensureDir so a parent
|
|
11
11
|
* deleted mid-acquisition self-heals — then throw LOCK_TIMEOUT. Never spin.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
14
15
|
import * as path from "node:path";
|
|
15
16
|
import { ensureDir } from "./atomic.mjs";
|
|
17
|
+
import { isAlive } from "./pid.mjs";
|
|
16
18
|
import * as P from "./paths.mjs";
|
|
17
19
|
|
|
18
20
|
const DEFAULT_STALE_MS = 30_000;
|
|
@@ -134,3 +136,196 @@ function releaseLock(lockPath, fs) {
|
|
|
134
136
|
/* best effort */
|
|
135
137
|
}
|
|
136
138
|
}
|
|
139
|
+
|
|
140
|
+
// ---- owner-safe leases (issue #70) ----------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Lease fs = lock fs + rename (candidate publish + quarantine reclaim). */
|
|
143
|
+
const defaultLeaseFs = Object.freeze({ ...defaultLocksFs, renameSync });
|
|
144
|
+
/** Max stale-reclaim → republish rounds inside a single acquire attempt. */
|
|
145
|
+
const MAX_LEASE_RECLAIM_ATTEMPTS = 3;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A token-fenced lease over the view-lock directory. Every operation re-reads
|
|
149
|
+
* owner.json and only acts while it still carries this lease's token, so a
|
|
150
|
+
* superseded owner can never touch — heartbeat or delete — the current owner's
|
|
151
|
+
* lock. Unlike withFileLockSync, a held lock is never stolen by age alone; it
|
|
152
|
+
* can only be reclaimed when its recorded owner process is provably dead.
|
|
153
|
+
* @typedef {Object} Lease
|
|
154
|
+
* @property {string} token
|
|
155
|
+
* @property {() => boolean} touch
|
|
156
|
+
* @property {() => boolean} isOwner
|
|
157
|
+
* @property {() => boolean} release
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {string} root
|
|
162
|
+
* @param {string} viewId
|
|
163
|
+
* @param {string} name
|
|
164
|
+
* @param {{ waitMs?: number, identity?: object|null, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} [opts]
|
|
165
|
+
* @returns {Lease}
|
|
166
|
+
* @throws {Error & { code: "LOCK_TIMEOUT" }} when the lock is still held after waitMs
|
|
167
|
+
*/
|
|
168
|
+
export function acquireOwnedViewLock(root, viewId, name, opts = {}) {
|
|
169
|
+
const lockPath = P.viewLockPath(root, viewId, name);
|
|
170
|
+
const deadline = Date.now() + Math.max(0, Number(opts.waitMs ?? 0));
|
|
171
|
+
for (;;) {
|
|
172
|
+
const attempt = attemptAcquireLease(lockPath, opts);
|
|
173
|
+
if (typeof attempt !== "string") return attempt;
|
|
174
|
+
if (Date.now() >= deadline) throw lockError(lockPath, `lease not acquired (${attempt})`);
|
|
175
|
+
sleep(WAIT_TICK_MS);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} root
|
|
181
|
+
* @param {string} viewId
|
|
182
|
+
* @param {string} name
|
|
183
|
+
* @param {{ identity?: object|null, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} [opts]
|
|
184
|
+
* @returns {{ acquired: true, lease: Lease } | { acquired: false, reason: "busy"|"blocked" }}
|
|
185
|
+
*/
|
|
186
|
+
export function tryAcquireOwnedViewLock(root, viewId, name, opts = {}) {
|
|
187
|
+
const attempt = attemptAcquireLease(P.viewLockPath(root, viewId, name), opts);
|
|
188
|
+
if (typeof attempt === "string") return { acquired: false, reason: attempt };
|
|
189
|
+
return { acquired: true, lease: attempt };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Single-shot acquire round: publish a complete candidate lock (owner.json
|
|
194
|
+
* written BEFORE the lock path exists) via atomic rename, and on contention
|
|
195
|
+
* either reclaim a provably-dead owner's lock via quarantine or report.
|
|
196
|
+
* @param {string} lockPath
|
|
197
|
+
* @param {{ identity?: object|null, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} opts
|
|
198
|
+
* @returns {Lease | "busy" | "blocked"}
|
|
199
|
+
*/
|
|
200
|
+
function attemptAcquireLease(lockPath, opts) {
|
|
201
|
+
const fs = { ...defaultLeaseFs, ...(opts.fs ?? {}) };
|
|
202
|
+
const now = opts.clock ?? Date.now;
|
|
203
|
+
const isProcessDead = opts.isProcessDead ?? ((pid) => !isAlive(pid));
|
|
204
|
+
const identity = opts.identity ?? null;
|
|
205
|
+
for (let attempt = 0; attempt < MAX_LEASE_RECLAIM_ATTEMPTS; attempt++) {
|
|
206
|
+
const token = randomBytes(16).toString("hex");
|
|
207
|
+
const candidate = `${lockPath}.candidate.${token}`;
|
|
208
|
+
ensureDir(path.dirname(lockPath));
|
|
209
|
+
try {
|
|
210
|
+
// Candidate dir is unique per token; a rename publishes the COMPLETE
|
|
211
|
+
// record atomically — no observer can see a lock without owner.json.
|
|
212
|
+
fs.mkdirSync(candidate);
|
|
213
|
+
fs.writeFileSync(
|
|
214
|
+
path.join(candidate, "owner.json"),
|
|
215
|
+
JSON.stringify({ token, pid: process.pid, identity, startedAt: now() }),
|
|
216
|
+
"utf8",
|
|
217
|
+
);
|
|
218
|
+
fs.renameSync(candidate, lockPath);
|
|
219
|
+
return makeLease(lockPath, token, fs, now);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
try { fs.rmSync(candidate, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
222
|
+
const code = err && err.code;
|
|
223
|
+
if (code !== "EEXIST" && code !== "ENOTEMPTY") throw err;
|
|
224
|
+
const verdict = reclaimOrBlock(lockPath, token, fs, isProcessDead);
|
|
225
|
+
if (verdict !== true) return verdict;
|
|
226
|
+
// Reclaimed a dead owner's lock — retry the publish on the next round.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return "busy";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Decide whether an existing lock may be reclaimed. Only a lock whose
|
|
234
|
+
* owner.json carries a usable identity (pid + startToken) AND whose pid is
|
|
235
|
+
* provably dead is reclaimable; anything corrupt, unreadable, or live is
|
|
236
|
+
* never deleted. Reclaim quarantines via rename first so a concurrent winner
|
|
237
|
+
* can only ever delete the directory it itself renamed.
|
|
238
|
+
* @param {string} lockPath
|
|
239
|
+
* @param {string} token the caller's own token (names the quarantine dir)
|
|
240
|
+
* @param {typeof defaultLeaseFs} fs
|
|
241
|
+
* @param {(pid: number) => boolean} isProcessDead
|
|
242
|
+
* @returns {true | "busy" | "blocked"}
|
|
243
|
+
*/
|
|
244
|
+
function reclaimOrBlock(lockPath, token, fs, isProcessDead) {
|
|
245
|
+
let owner;
|
|
246
|
+
try {
|
|
247
|
+
owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf8"));
|
|
248
|
+
} catch {
|
|
249
|
+
return "blocked";
|
|
250
|
+
}
|
|
251
|
+
const pid = Number(owner?.identity?.pid ?? 0);
|
|
252
|
+
if (!Number.isFinite(pid) || pid <= 0 || typeof owner?.identity?.startToken !== "string") return "blocked";
|
|
253
|
+
if (!isProcessDead(pid)) return "busy";
|
|
254
|
+
const inspectedToken = typeof owner?.token === "string" ? owner.token : null;
|
|
255
|
+
if (inspectedToken === null) return "blocked";
|
|
256
|
+
const quarantine = `${lockPath}.reclaim.${token}`;
|
|
257
|
+
try {
|
|
258
|
+
fs.renameSync(lockPath, quarantine);
|
|
259
|
+
} catch {
|
|
260
|
+
// Someone else released/reclaimed concurrently — plain contention.
|
|
261
|
+
return "busy";
|
|
262
|
+
}
|
|
263
|
+
// Verify we quarantined exactly the lock we inspected: a concurrent winner
|
|
264
|
+
// may have reclaimed the dead owner and published a fresh lock at lockPath
|
|
265
|
+
// between our read and this rename. If so, restore it and report contention
|
|
266
|
+
// instead of deleting a successor's lock.
|
|
267
|
+
let quarantinedToken = null;
|
|
268
|
+
try {
|
|
269
|
+
quarantinedToken = JSON.parse(fs.readFileSync(path.join(quarantine, "owner.json"), "utf8"))?.token ?? null;
|
|
270
|
+
} catch { /* unreadable quarantine content */ }
|
|
271
|
+
if (quarantinedToken !== inspectedToken) {
|
|
272
|
+
try { fs.renameSync(quarantine, lockPath); } catch { /* best effort restore */ }
|
|
273
|
+
return "busy";
|
|
274
|
+
}
|
|
275
|
+
try { fs.rmSync(quarantine, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {string} lockPath
|
|
281
|
+
* @param {string} token
|
|
282
|
+
* @param {typeof defaultLeaseFs} fs
|
|
283
|
+
* @param {() => number} now
|
|
284
|
+
* @returns {Lease}
|
|
285
|
+
*/
|
|
286
|
+
function makeLease(lockPath, token, fs, now) {
|
|
287
|
+
const ownerPath = path.join(lockPath, "owner.json");
|
|
288
|
+
const heartbeatPath = path.join(lockPath, `heartbeat.${token}`);
|
|
289
|
+
const stillOwns = () => {
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(fs.readFileSync(ownerPath, "utf8"))?.token === token;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
return {
|
|
297
|
+
token,
|
|
298
|
+
isOwner: () => stillOwns(),
|
|
299
|
+
touch() {
|
|
300
|
+
if (!stillOwns()) return false;
|
|
301
|
+
try {
|
|
302
|
+
// Heartbeat lives in a token-named file: old tokens can never
|
|
303
|
+
// overwrite the canonical owner record or a successor's heartbeat.
|
|
304
|
+
fs.writeFileSync(heartbeatPath, JSON.stringify({ at: now() }), "utf8");
|
|
305
|
+
return true;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
release() {
|
|
311
|
+
if (!stillOwns()) return false;
|
|
312
|
+
try {
|
|
313
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
314
|
+
return true;
|
|
315
|
+
} catch {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Simulates a late release() from a superseded token. Exported so tests can
|
|
324
|
+
* prove old tokens can never delete a successor's lock.
|
|
325
|
+
* @visibleForTesting
|
|
326
|
+
* @param {string} root @param {string} viewId @param {string} name @param {string} token
|
|
327
|
+
* @returns {boolean}
|
|
328
|
+
*/
|
|
329
|
+
export function releaseWithToken(root, viewId, name, token) {
|
|
330
|
+
return makeLease(P.viewLockPath(root, viewId, name), token, defaultLeaseFs, Date.now).release();
|
|
331
|
+
}
|
package/src/core/paths.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* The live default is `~/.pi/agent/agent-board/` (override with $AGENT_BOARD_ROOT;
|
|
6
6
|
* legacy $AGENT_VIEW_ROOT is also honored for migration).
|
|
7
7
|
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
8
9
|
import * as os from "node:os";
|
|
9
10
|
import * as path from "node:path";
|
|
10
11
|
|
|
@@ -38,6 +39,13 @@ export const statePath = (root, viewId) => path.join(viewDir(root, viewId), "sta
|
|
|
38
39
|
export const hostPath = (root, viewId) => path.join(viewDir(root, viewId), "host.json");
|
|
39
40
|
/** @param {string} root @param {string} viewId */
|
|
40
41
|
export const hostConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "host-config.json");
|
|
42
|
+
/**
|
|
43
|
+
* Per-instance host config path. Each host launch owns its own config file so a
|
|
44
|
+
* concurrent launch can never overwrite another instance's prompt/cwd/model (issue #70).
|
|
45
|
+
* @param {string} root @param {string} viewId @param {string} instanceId
|
|
46
|
+
*/
|
|
47
|
+
export const hostConfigPathFor = (root, viewId, instanceId) =>
|
|
48
|
+
path.join(viewDir(root, viewId), `host-config.${instanceId}.json`);
|
|
41
49
|
/** @param {string} root @param {string} viewId */
|
|
42
50
|
export const titleConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "title-config.json");
|
|
43
51
|
/** @param {string} root @param {string} viewId */
|
|
@@ -63,6 +71,22 @@ export function controlSocketPathFor(platform, root, viewId) {
|
|
|
63
71
|
}
|
|
64
72
|
/** @param {string} root @param {string} viewId */
|
|
65
73
|
export const controlSocketPath = (root, viewId) => controlSocketPathFor(process.platform, root, viewId);
|
|
74
|
+
/**
|
|
75
|
+
* Per-instance control endpoint. Each new host instance binds its own socket/pipe,
|
|
76
|
+
* so a superseded runner can never unlink the current owner's endpoint (issue #70).
|
|
77
|
+
* win32 pipe names embed an 8-hex hash of the instanceId to stay under the 256-char limit.
|
|
78
|
+
* @param {"win32"|"linux"|"darwin"} platform
|
|
79
|
+
* @param {string} root
|
|
80
|
+
* @param {string} viewId
|
|
81
|
+
* @param {string} instanceId
|
|
82
|
+
*/
|
|
83
|
+
export function hostEndpointPathFor(platform, root, viewId, instanceId) {
|
|
84
|
+
if (platform === "win32") {
|
|
85
|
+
const hash = createHash("sha256").update(String(instanceId)).digest("hex").slice(0, 8);
|
|
86
|
+
return `\\\\.\\pipe\\pi-agent-board-${viewId}-${hash}`;
|
|
87
|
+
}
|
|
88
|
+
return path.join(viewDir(root, viewId), `control.${instanceId}.sock`);
|
|
89
|
+
}
|
|
66
90
|
/** @param {string} root @param {string} viewId */
|
|
67
91
|
export const screenLogPath = (root, viewId) => path.join(viewDir(root, viewId), "screen.log");
|
|
68
92
|
/** @param {string} root @param {string} viewId */
|
package/src/core/store.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
7
7
|
import { atomicWriteJson, ensureDir, readJson } from "./atomic.mjs";
|
|
8
|
+
import { sameHostOwner } from "./host-coordination.mjs";
|
|
9
|
+
import { tryAcquireOwnedViewLock } from "./locks.mjs";
|
|
8
10
|
import * as P from "./paths.mjs";
|
|
9
11
|
import { isAlive } from "./pid.mjs";
|
|
10
12
|
import { readCodeRefs, summarizeCodeRefs } from "./code-refs-store.mjs";
|
|
@@ -120,9 +122,159 @@ export function writeHost(root, host) {
|
|
|
120
122
|
atomicWriteJson(P.hostPath(root, host.viewId), host);
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} root
|
|
127
|
+
* @param {string} viewId
|
|
128
|
+
* @param {number|null} pid
|
|
129
|
+
* @param {{ instanceId?: string, identity?: object|null }} [extra] merged into the mirror record
|
|
130
|
+
*/
|
|
131
|
+
export function writeHostPid(root, viewId, pid, extra) {
|
|
132
|
+
atomicWriteJson(P.hostPidPath(root, viewId), { pid, at: Date.now(), ...(extra ?? {}) });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Host states that represent an unreplaced claim: no replacement may be
|
|
137
|
+
* started while a host record is in one of these states (issue #70).
|
|
138
|
+
* @param {HostStatus|null} host
|
|
139
|
+
*/
|
|
140
|
+
function hostClaimActive(host) {
|
|
141
|
+
return Boolean(host && (host.state === "starting" || host.state === "alive" || host.state === "stopping"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Atomically create the provisional `starting` host record for a new instance.
|
|
146
|
+
* The ONLY entry point allowed to move "no claim / reclaimable terminal state"
|
|
147
|
+
* into `starting` (issue #70). Refuses when an active claim exists or the
|
|
148
|
+
* host-meta lease is contended; the fresh record always nulls runner/child/
|
|
149
|
+
* ready/stop fields so no stale owner data survives the handover.
|
|
150
|
+
* @param {string} root
|
|
151
|
+
* @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).
|
|
154
|
+
* @returns {{ claimed: boolean, host: HostStatus|null }}
|
|
155
|
+
*/
|
|
156
|
+
export function claimHost(root, provisionalHost, opts = {}) {
|
|
157
|
+
const lock = tryAcquireOwnedViewLock(root, provisionalHost.viewId, "host-meta");
|
|
158
|
+
if (!lock.acquired) return { claimed: false, host: null };
|
|
159
|
+
try {
|
|
160
|
+
const existing = readHost(root, provisionalHost.viewId);
|
|
161
|
+
if (hostClaimActive(existing)) return { claimed: false, host: existing };
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
/** @type {HostStatus} */
|
|
164
|
+
const record = {
|
|
165
|
+
version: 1,
|
|
166
|
+
viewId: provisionalHost.viewId,
|
|
167
|
+
mode: "pty",
|
|
168
|
+
instanceId: provisionalHost.instanceId,
|
|
169
|
+
configPath: provisionalHost.configPath ?? null,
|
|
170
|
+
socketPath: provisionalHost.socketPath ?? null,
|
|
171
|
+
claimAt: provisionalHost.claimAt ?? now,
|
|
172
|
+
claimPid: provisionalHost.claimPid ?? null,
|
|
173
|
+
claimIdentity: provisionalHost.claimIdentity ?? null,
|
|
174
|
+
// A fresh claim owns nothing yet: force-clear every field a superseded
|
|
175
|
+
// instance might have left behind.
|
|
176
|
+
runnerPid: null,
|
|
177
|
+
runnerIdentity: null,
|
|
178
|
+
runnerSpawnedAt: null,
|
|
179
|
+
childPid: null,
|
|
180
|
+
childIdentity: null,
|
|
181
|
+
childSpawnedAt: null,
|
|
182
|
+
readyAt: null,
|
|
183
|
+
stopRequestedAt: null,
|
|
184
|
+
revokeToken: null,
|
|
185
|
+
stopReason: null,
|
|
186
|
+
state: "starting",
|
|
187
|
+
startedAt: provisionalHost.claimAt ?? now,
|
|
188
|
+
lastSeenAt: now,
|
|
189
|
+
endedAt: null,
|
|
190
|
+
exitCode: null,
|
|
191
|
+
error: null,
|
|
192
|
+
cols: provisionalHost.cols ?? 120,
|
|
193
|
+
rows: provisionalHost.rows ?? 36,
|
|
194
|
+
attachedClients: 0,
|
|
195
|
+
};
|
|
196
|
+
writeHost(root, record);
|
|
197
|
+
// Mirror is best-effort; host.json is the authority for the new protocol.
|
|
198
|
+
try {
|
|
199
|
+
writeHostPid(root, provisionalHost.viewId, record.claimPid, {
|
|
200
|
+
instanceId: record.instanceId,
|
|
201
|
+
identity: record.claimIdentity,
|
|
202
|
+
});
|
|
203
|
+
} catch { /* best effort */ }
|
|
204
|
+
return { claimed: true, host: record };
|
|
205
|
+
} finally {
|
|
206
|
+
lock.lease.release();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Bounded contention retry for owner-fenced host writes (issue #70, PR #84 CI
|
|
212
|
+
* wave 2). Heartbeat/client-merge writes hold the host-meta lease for only a
|
|
213
|
+
* few milliseconds, but a one-shot acquire can land inside that window and
|
|
214
|
+
* return busy — a revoke or recovery write that silently no-ops is a real
|
|
215
|
+
* 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.
|
|
222
|
+
*/
|
|
223
|
+
const UPDATE_LOCK_BUSY_ATTEMPTS = 3;
|
|
224
|
+
const UPDATE_LOCK_BUSY_SLEEP_MS = 20;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Owner-fenced compare-and-write for host.json. Re-reads the view's record
|
|
228
|
+
* under the host-meta lease and only applies `mutate` while it still belongs
|
|
229
|
+
* to `expectedInstanceId`; a superseded instance's late heartbeat/crash/exit
|
|
230
|
+
* writes are rejected with `ownerChanged: true` and the current host (issue
|
|
231
|
+
* #70). Lease contention that survives the bounded retry returns not-updated
|
|
232
|
+
* with `ownerChanged: false` — callers treat that as retryable (heartbeats
|
|
233
|
+
* simply retry next tick; revokes/recovery re-attempt).
|
|
234
|
+
* @param {string} root
|
|
235
|
+
* @param {string} viewId
|
|
236
|
+
* @param {string} expectedInstanceId
|
|
237
|
+
* @param {(host: HostStatus) => HostStatus} mutate returns a new record
|
|
238
|
+
* @param {{ heldStartLease?: unknown, lockImpl?: typeof tryAcquireOwnedViewLock }} [opts]
|
|
239
|
+
* heldStartLease is reserved for host-start lease nesting; lockImpl injects
|
|
240
|
+
* the host-meta acquisition for deterministic contention tests.
|
|
241
|
+
* @returns {{ updated: boolean, ownerChanged: boolean, host: HostStatus|null }}
|
|
242
|
+
*/
|
|
243
|
+
export function updateOwnedHost(root, viewId, expectedInstanceId, mutate, opts = {}) {
|
|
244
|
+
const acquireHostMeta = opts.lockImpl ?? tryAcquireOwnedViewLock;
|
|
245
|
+
let lock;
|
|
246
|
+
for (let attempt = 0; ; attempt++) {
|
|
247
|
+
lock = acquireHostMeta(root, viewId, "host-meta");
|
|
248
|
+
if (lock.acquired) break;
|
|
249
|
+
// busy and blocked are both millisecond-scale holds for host-meta;
|
|
250
|
+
// neither is ownership information — only the fenced read below is.
|
|
251
|
+
if (attempt >= UPDATE_LOCK_BUSY_ATTEMPTS - 1) {
|
|
252
|
+
return { updated: false, ownerChanged: false, host: null };
|
|
253
|
+
}
|
|
254
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, UPDATE_LOCK_BUSY_SLEEP_MS);
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const host = readHost(root, viewId);
|
|
258
|
+
if (!host || !sameHostOwner(host, expectedInstanceId)) {
|
|
259
|
+
return { updated: false, ownerChanged: true, host };
|
|
260
|
+
}
|
|
261
|
+
const next = mutate(host);
|
|
262
|
+
// The mutate result must still belong to the expected instance: a buggy or
|
|
263
|
+
// hijacked callback that swaps the owner token is never written (issue #70).
|
|
264
|
+
if (!sameHostOwner(next, expectedInstanceId)) {
|
|
265
|
+
return { updated: false, ownerChanged: true, host };
|
|
266
|
+
}
|
|
267
|
+
writeHost(root, next);
|
|
268
|
+
try {
|
|
269
|
+
writeHostPid(root, viewId, next.runnerPid ?? null, {
|
|
270
|
+
instanceId: next.instanceId,
|
|
271
|
+
identity: next.runnerIdentity ?? null,
|
|
272
|
+
});
|
|
273
|
+
} catch { /* best effort */ }
|
|
274
|
+
return { updated: true, ownerChanged: false, host: next };
|
|
275
|
+
} finally {
|
|
276
|
+
lock.lease.release();
|
|
277
|
+
}
|
|
126
278
|
}
|
|
127
279
|
|
|
128
280
|
/** @param {string} root @param {string} viewId @returns {number|null} */
|
|
@@ -177,6 +329,8 @@ function mtime(dir) {
|
|
|
177
329
|
* @property {ViewState|null} state
|
|
178
330
|
* @property {boolean} alive Whether the row's current run pid/foreground activity is alive.
|
|
179
331
|
* @property {boolean} hostAlive Whether a PTY host/socket is alive and attachable.
|
|
332
|
+
* @property {boolean} hostActive Whether an unreplaced host claim exists (starting/alive/stopping).
|
|
333
|
+
* @property {boolean} hostReady Whether the host claims readiness (alive + readyAt); metadata hint only.
|
|
180
334
|
* @property {HostStatus|null} host
|
|
181
335
|
* @property {import("./types.mjs").ReviewSummary} [review]
|
|
182
336
|
* @property {import("./types.mjs").DiagnosticSummary} [diagnostics]
|
|
@@ -202,9 +356,14 @@ export function loadRow(root, viewId) {
|
|
|
202
356
|
// poll, but foreground extension events mirror processState into state.json.
|
|
203
357
|
if (!alive && enrichedState?.processState === "alive") alive = true;
|
|
204
358
|
const host = readHost(root, viewId);
|
|
205
|
-
|
|
359
|
+
// New-protocol records carry an explicit runnerPid (null until spawn
|
|
360
|
+
// confirms); only legacy records without the property fall back to the
|
|
361
|
+
// host-pid mirror (issue #70).
|
|
362
|
+
const hostPid = host && Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
|
|
206
363
|
const hostAlive = Boolean(host && (host.state === "alive" || host.state === "starting") && isAlive(hostPid));
|
|
207
|
-
|
|
364
|
+
const hostActive = Boolean(host && (host.state === "starting" || host.state === "alive" || host.state === "stopping"));
|
|
365
|
+
const hostReady = Boolean(hostActive && host.state === "alive" && host.readyAt != null);
|
|
366
|
+
return { meta, state: enrichedState, alive, hostAlive, hostActive, hostReady, host, ...summaries };
|
|
208
367
|
}
|
|
209
368
|
|
|
210
369
|
/** @param {string} root @param {string} viewId */
|
package/src/core/types.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
/** Semantic (task) state of a row. @typedef {"queued"|"working"|"needs_input"|"idle"|"completed"|"failed"|"stopped"} SemanticState */
|
|
7
7
|
/** Process/liveness state. @typedef {"alive"|"exited"} ProcessState */
|
|
8
8
|
/** PTY host mode. @typedef {"json-runner"|"pty"} HostMode */
|
|
9
|
-
/** PTY host liveness. @typedef {"starting"|"alive"|"exited"|"failed"} HostState */
|
|
9
|
+
/** PTY host liveness. @typedef {"starting"|"alive"|"stopping"|"exited"|"failed"} HostState */
|
|
10
10
|
/** How a run was kicked off. @typedef {"dispatch"|"reply"|"plan"|"plan_change"|"plan_approval"} RunKind */
|
|
11
11
|
/** Worktree isolation mode for a row. @typedef {"off"|"worktree"} WorktreeMode */
|
|
12
12
|
/** Diagnostic severity. @typedef {"info"|"warn"|"error"} DiagnosticLevel */
|
|
@@ -141,6 +141,19 @@ export const GROUP_LABELS = {
|
|
|
141
141
|
* @property {number} rows
|
|
142
142
|
* @property {number} attachedClients
|
|
143
143
|
* @property {boolean} [attachedEver] Whether any client attached to this host.
|
|
144
|
+
* @property {string|null} [instanceId] Owner/fencing token for this host launch (issue #70).
|
|
145
|
+
* @property {string|null} [configPath] Per-instance config file this host was launched with.
|
|
146
|
+
* @property {number|null} [claimAt] When the provisional claim was written (epoch ms).
|
|
147
|
+
* @property {number|null} [claimPid] PID of the service process that wrote the claim.
|
|
148
|
+
* @property {{pid:number,startToken:string|null}|null} [claimIdentity] Launch-time identity of the claiming service process.
|
|
149
|
+
* @property {{pid:number,startToken:string|null}|null} [runnerIdentity] Stable identity of the runner process.
|
|
150
|
+
* @property {number|null} [runnerSpawnedAt] When the runner spawn was confirmed (null = not yet spawned).
|
|
151
|
+
* @property {{pid:number,startToken:string|null}|null} [childIdentity] Stable identity of the child Pi process.
|
|
152
|
+
* @property {number|null} [childSpawnedAt] When the child spawn was confirmed (null = not yet spawned).
|
|
153
|
+
* @property {number|null} [readyAt] Set only after endpoint bind + child creation; alive implies readyAt != null.
|
|
154
|
+
* @property {number|null} [stopRequestedAt] When a revoke was requested via the host record.
|
|
155
|
+
* @property {string|null} [revokeToken] Random token written by requestHostStop to fence duplicate revokes.
|
|
156
|
+
* @property {string|null} [stopReason] Human-readable reason recorded with the revoke.
|
|
144
157
|
*/
|
|
145
158
|
|
|
146
159
|
/**
|
|
@@ -213,6 +226,9 @@ export const GROUP_LABELS = {
|
|
|
213
226
|
* @property {number} cols
|
|
214
227
|
* @property {number} rows
|
|
215
228
|
* @property {number|null} screenLogMaxBytes per-view screen.log write cap; null = runner default
|
|
229
|
+
* @property {string|null} [instanceId] Owner/fencing token for this host launch (issue #70).
|
|
230
|
+
* @property {string|null} [configPath] Per-instance config file path; each launch owns its own so concurrent launches never share a config.
|
|
231
|
+
* @property {string|null} [socketPath] Per-instance control endpoint the spawned runner must bind.
|
|
216
232
|
*/
|
|
217
233
|
|
|
218
234
|
/**
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warm-host sweep reclaim (issue #75).
|
|
3
|
+
*
|
|
4
|
+
* Idle PTY hosts must not live forever: after detach the runner is a detached
|
|
5
|
+
* orphan that nothing else will reap (Windows has no parent-death cascade, and
|
|
6
|
+
* the dashboard host may exit without a cleanup hook). The design intent
|
|
7
|
+
* (AGENT_BOARD_WARM_HOST_TTL_MS / MAX_WARM_HOSTS) already exists in
|
|
8
|
+
* service.mjs pruneWarmHosts but only fires lazily on attach/prewarm/dispatch.
|
|
9
|
+
* This module extracts the pure eviction decision plus a periodic sweeper so
|
|
10
|
+
* the TTL actually runs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @param {import("./store.mjs").Row} row */
|
|
14
|
+
export function hasPendingQuestions(row) {
|
|
15
|
+
return Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Busy = an agent run is active (queued/working) or the session is waiting on
|
|
20
|
+
* user questions. Idle/completed/failed/stopped are never busy.
|
|
21
|
+
* @param {import("./store.mjs").Row} row
|
|
22
|
+
*/
|
|
23
|
+
export function isAgentBusy(row) {
|
|
24
|
+
const st = row.state?.semanticState;
|
|
25
|
+
return Boolean(row.alive && (st === "queued" || st === "working" || hasPendingQuestions(row)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pure eviction decision for warm PTY hosts. No IO, no env: every threshold is
|
|
30
|
+
* passed in so the logic is directly unit-testable.
|
|
31
|
+
*
|
|
32
|
+
* idle = host alive && !busy && no attached clients && not keepViewId.
|
|
33
|
+
* graceMs exempts freshly started hosts (attach handoff race: ensureHost has
|
|
34
|
+
* started a host but the client has not connected yet).
|
|
35
|
+
* TTL eviction runs first; survivors over maxWarm are evicted oldest-first by
|
|
36
|
+
* idleSince = max(state.lastActivityAt, host.startedAt) || meta.updatedAt (host-level
|
|
37
|
+
* idle counts from host start, so a stale lastActivityAt from a previous host
|
|
38
|
+
* incarnation never makes a fresh host look idle longer than it has been up).
|
|
39
|
+
*
|
|
40
|
+
* @param {Array<import("./store.mjs").Row>} rows
|
|
41
|
+
* @param {{ now: number, maxWarm: number, ttlMs: number, graceMs?: number, keepViewId?: string|null }} o
|
|
42
|
+
* @returns {{ ttlEvicted: string[], excessEvicted: string[] }} viewIds, ttl group first
|
|
43
|
+
*/
|
|
44
|
+
export function selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs = 0, keepViewId = null }) {
|
|
45
|
+
// Both-zero = eviction disabled entirely. Callers (service.pruneWarmHosts)
|
|
46
|
+
// may additionally early-return for the same reason.
|
|
47
|
+
if (maxWarm === 0 && ttlMs === 0) return { ttlEvicted: [], excessEvicted: [] };
|
|
48
|
+
const idle = [];
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
if (keepViewId != null && row.meta.id === keepViewId) continue;
|
|
51
|
+
if (!row.hostAlive) continue;
|
|
52
|
+
if (isAgentBusy(row)) continue;
|
|
53
|
+
if ((row.host?.attachedClients ?? 0) !== 0) continue;
|
|
54
|
+
const startedAt = row.host?.startedAt;
|
|
55
|
+
if (graceMs > 0 && startedAt != null && now - startedAt < graceMs) continue;
|
|
56
|
+
// Host-level idle counts from host start: a stale lastActivityAt from a
|
|
57
|
+
// previous host incarnation must not make a freshly prewarmed host look
|
|
58
|
+
// idle for longer than it has actually been up (issue #75 review #1).
|
|
59
|
+
const idleSince = Math.max(row.state?.lastActivityAt ?? 0, startedAt ?? 0) || row.meta.updatedAt;
|
|
60
|
+
idle.push({ id: row.meta.id, idleSince });
|
|
61
|
+
}
|
|
62
|
+
const ttlEvicted = [];
|
|
63
|
+
const survivors = [];
|
|
64
|
+
for (const it of idle) {
|
|
65
|
+
// Keep the historical semantics: ttlMs === 0 disables the ttl branch
|
|
66
|
+
// (only the maxWarm cap applies); both zero disables eviction entirely.
|
|
67
|
+
if (ttlMs > 0 && now - it.idleSince > ttlMs) ttlEvicted.push(it.id);
|
|
68
|
+
else survivors.push(it);
|
|
69
|
+
}
|
|
70
|
+
survivors.sort((a, b) => a.idleSince - b.idleSince);
|
|
71
|
+
const excess = Math.max(0, survivors.length - maxWarm);
|
|
72
|
+
return { ttlEvicted, excessEvicted: survivors.slice(0, excess).map((it) => it.id) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Periodic sweeper for warm hosts. The interval timer is unref'd so it never
|
|
77
|
+
* holds the host pi's event loop open on exit. intervalMs <= 0 disables the
|
|
78
|
+
* periodic part; sweepNow() always works.
|
|
79
|
+
*
|
|
80
|
+
* stop() only stops the periodic timer: sweepNow() stays available afterwards
|
|
81
|
+
* (the lifecycle wiring calls sweepNow() again on shutdown/dispose). start()
|
|
82
|
+
* is idempotent and may restart the timer after a stop.
|
|
83
|
+
* @param {{ sweep: () => void, intervalMs: number }} o
|
|
84
|
+
*/
|
|
85
|
+
export function createWarmHostSweeper({ sweep, intervalMs }) {
|
|
86
|
+
let timer = null;
|
|
87
|
+
return {
|
|
88
|
+
active: true,
|
|
89
|
+
start() {
|
|
90
|
+
if (timer !== null) return;
|
|
91
|
+
if (intervalMs > 0) {
|
|
92
|
+
timer = setInterval(() => {
|
|
93
|
+
try { sweep(); } catch { /* best-effort */ }
|
|
94
|
+
}, intervalMs);
|
|
95
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
sweepNow() {
|
|
99
|
+
try { sweep(); } catch { /* best-effort */ }
|
|
100
|
+
},
|
|
101
|
+
stop() {
|
|
102
|
+
if (timer !== null) {
|
|
103
|
+
clearInterval(timer);
|
|
104
|
+
timer = null;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Wire the sweeper to a host pi extension lifetime: sweep once on attach
|
|
112
|
+
* (reclaims hosts leaked by a previous host that died without cleanup), run
|
|
113
|
+
* periodically, and sweep again on session_shutdown (host pi exiting or the
|
|
114
|
+
* extension instance being reloaded for a session switch).
|
|
115
|
+
*
|
|
116
|
+
* Child pi processes (AGENT_BOARD_CHILD=1 / AGENT_VIEW_CHILD=1) must never
|
|
117
|
+
* sweep: they share the same board root and would terminate their own runner
|
|
118
|
+
* (suicide chain). They get a strict no-op.
|
|
119
|
+
*
|
|
120
|
+
* @param {{ on?: (event: any, fn: () => void) => any }} pi — any, not a narrower
|
|
121
|
+
* string-keyed signature: pi's ExtensionAPI.on is a union of literal event-name
|
|
122
|
+
* overloads and would otherwise fail structural assignment from TypeScript
|
|
123
|
+
* callers (contravariant parameter check).
|
|
124
|
+
* @param {{ isHostedChild: boolean, sweep: () => void, intervalMs: number }} o
|
|
125
|
+
*/
|
|
126
|
+
export function attachWarmHostSweeper(pi, { isHostedChild, sweep, intervalMs }) {
|
|
127
|
+
// Child pi processes and board-spawned non-host workers must never sweep:
|
|
128
|
+
// children would terminate their own runner (suicide chain); workers
|
|
129
|
+
// (job/state runners set AGENT_BOARD_NO_SWEEP=1) would churn the shared root.
|
|
130
|
+
if (isHostedChild || process.env.AGENT_BOARD_NO_SWEEP === "1") {
|
|
131
|
+
return { active: false, dispose() {} };
|
|
132
|
+
}
|
|
133
|
+
const sweeper = createWarmHostSweeper({ sweep, intervalMs });
|
|
134
|
+
let shutdown = false;
|
|
135
|
+
const onShutdown = () => {
|
|
136
|
+
if (shutdown) return;
|
|
137
|
+
shutdown = true;
|
|
138
|
+
sweeper.sweepNow();
|
|
139
|
+
sweeper.stop();
|
|
140
|
+
};
|
|
141
|
+
pi.on?.("session_shutdown", onShutdown);
|
|
142
|
+
sweeper.start();
|
|
143
|
+
sweeper.sweepNow(); // 回收上一个宿主(可能非正常退出)遗留的 warm hosts
|
|
144
|
+
return {
|
|
145
|
+
active: true,
|
|
146
|
+
dispose() {
|
|
147
|
+
onShutdown();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|