@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/runner/pty-runner.mjs
CHANGED
|
@@ -10,16 +10,18 @@
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { createServer } from "node:net";
|
|
13
|
-
import { existsSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { appendLine, readJson } from "../src/core/atomic.mjs";
|
|
17
17
|
import { appendDiagnostic } from "../src/core/diagnostics.mjs";
|
|
18
18
|
import { finalizeHostCrash } from "../src/core/host-crash.mjs";
|
|
19
|
+
import { ownsEndpoint, shouldYieldRunner } from "../src/core/host-coordination.mjs";
|
|
20
|
+
import { acquireOwnedViewLock } from "../src/core/locks.mjs";
|
|
19
21
|
import * as P from "../src/core/paths.mjs";
|
|
20
22
|
import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
|
|
21
23
|
import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
|
|
22
|
-
import { readState, writeHost, writeState } from "../src/core/store.mjs";
|
|
24
|
+
import { readHost, readState, updateOwnedHost, writeHost, writeState } from "../src/core/store.mjs";
|
|
23
25
|
import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
|
|
24
26
|
|
|
25
27
|
const requireForPty = createRequire(import.meta.url);
|
|
@@ -33,6 +35,10 @@ try {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const HEARTBEAT_MS = 1000;
|
|
38
|
+
/** Service-input ack dedup bound (issue #70 Task 8): requestId → true, FIFO evict. */
|
|
39
|
+
const HOST_ACK_DEDUP_MAX = 1000;
|
|
40
|
+
/** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
|
|
41
|
+
const HOST_RUNNER_LOCK_WAIT_MS = 5_000;
|
|
36
42
|
|
|
37
43
|
function main() {
|
|
38
44
|
const configPath = process.argv[2];
|
|
@@ -40,7 +46,18 @@ function main() {
|
|
|
40
46
|
/** @type {import("../src/core/types.mjs").HostConfig|null} */
|
|
41
47
|
const config = readJson(configPath, null);
|
|
42
48
|
if (!config) failEarly(`pty-runner: cannot read config ${configPath}`);
|
|
49
|
+
// New ownership protocol (issue #70): instance-scoped config selects the fenced
|
|
50
|
+
// path. Legacy configs (no instanceId) keep the historical behavior unchanged.
|
|
51
|
+
if (config.instanceId) {
|
|
52
|
+
// A mid-flight throw in ownedMain must still terminate the process — the
|
|
53
|
+
// runner is detached with ignored stdio; an unhandled rejection would hang it.
|
|
54
|
+
ownedMain(config).catch(() => process.exit(1));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
legacyMain(config);
|
|
58
|
+
}
|
|
43
59
|
|
|
60
|
+
function legacyMain(config) {
|
|
44
61
|
const socketPath = P.controlSocketPath(config.root, config.viewId);
|
|
45
62
|
const screenLog = P.screenLogPath(config.root, config.viewId);
|
|
46
63
|
// Optional per-install cap override from launch prefs (screenLogMaxSize).
|
|
@@ -323,6 +340,526 @@ function main() {
|
|
|
323
340
|
process.on("SIGINT", () => { void shutdown(); });
|
|
324
341
|
}
|
|
325
342
|
|
|
343
|
+
/**
|
|
344
|
+
* New-protocol host runner (issue #70).
|
|
345
|
+
*
|
|
346
|
+
* Runs only for instance-scoped configs. The lifecycle is fenced by an
|
|
347
|
+
* `instanceId` owner token and the per-view `host-start` lease:
|
|
348
|
+
* lease → ownership decision → bind UNIQUE endpoint → publish runner identity
|
|
349
|
+
* → spawn child → publish ready → release lease → heartbeat.
|
|
350
|
+
* All exits funnel through finishHost(), which writes only while the instance
|
|
351
|
+
* still owns host.json and unlinks only the exact socket inode it bound.
|
|
352
|
+
* @param {import("../src/core/types.mjs").HostConfig & { instanceId: string }} config
|
|
353
|
+
*/
|
|
354
|
+
async function ownedMain(config) {
|
|
355
|
+
if (!config.socketPath) failEarly("pty-runner: owned config missing socketPath");
|
|
356
|
+
const socketPath = config.socketPath;
|
|
357
|
+
const screenLog = P.screenLogPath(config.root, config.viewId);
|
|
358
|
+
const screenLogMaxBytes =
|
|
359
|
+
Number.isFinite(config.screenLogMaxBytes) && config.screenLogMaxBytes > 0
|
|
360
|
+
? Math.floor(config.screenLogMaxBytes)
|
|
361
|
+
: undefined;
|
|
362
|
+
const screenLogLimits = { maxBytes: screenLogMaxBytes };
|
|
363
|
+
let screenLogBytes = reconcileScreenLog(screenLog, screenLogLimits);
|
|
364
|
+
|
|
365
|
+
/** @type {Set<import("node:net").Socket>} */
|
|
366
|
+
const clients = new Set();
|
|
367
|
+
let childPid = null;
|
|
368
|
+
let child = null;
|
|
369
|
+
let exitCode = null;
|
|
370
|
+
let childExited = false;
|
|
371
|
+
let resolveChildExit;
|
|
372
|
+
const childExitPromise = new Promise((resolve) => {
|
|
373
|
+
resolveChildExit = resolve;
|
|
374
|
+
});
|
|
375
|
+
/** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
|
|
376
|
+
let boundSocketIdentity = null;
|
|
377
|
+
/** Set once THIS process published its runner identity into the record —
|
|
378
|
+
* only then may finishHost write host state. A same-instance duplicate that
|
|
379
|
+
* dies at listen (EADDRINUSE) shares the fencing token but never took the
|
|
380
|
+
* record, so it must leave the winner's state untouched (issue #70). */
|
|
381
|
+
let claimedRecord = false;
|
|
382
|
+
let shutdownStarted = false;
|
|
383
|
+
/** Authoritative child editor emptiness (issue #68); null = unknown. */
|
|
384
|
+
let editorEmpty = null;
|
|
385
|
+
/** In-memory snapshot of the owned host record, refreshed on every owned write. */
|
|
386
|
+
let host = readHost(config.root, config.viewId);
|
|
387
|
+
/** @type {{ token: string, touch(): boolean, isOwner(): boolean, release(): boolean } | null} */
|
|
388
|
+
let startLease = null;
|
|
389
|
+
let startTouchTimer = null;
|
|
390
|
+
let heartbeatTimer = null;
|
|
391
|
+
let server = null;
|
|
392
|
+
/** Service-input ack dedup: requestId → true. FIFO-capped at
|
|
393
|
+
* HOST_ACK_DEDUP_MAX. Interactive UI keystrokes (no requestId) bypass it. */
|
|
394
|
+
const ackedRequestIds = new Map();
|
|
395
|
+
/** Last {cols, rows} held while the child does not exist yet; applied when
|
|
396
|
+
* ready is published (issue #70 §6.3 starting protocol). */
|
|
397
|
+
let cachedResize = null;
|
|
398
|
+
|
|
399
|
+
const diag = (code, message, details) => {
|
|
400
|
+
try {
|
|
401
|
+
appendDiagnostic(config.root, config.viewId, {
|
|
402
|
+
source: "runner",
|
|
403
|
+
code,
|
|
404
|
+
message,
|
|
405
|
+
...(details ? { details } : {}),
|
|
406
|
+
});
|
|
407
|
+
} catch { /* best effort */ }
|
|
408
|
+
};
|
|
409
|
+
const broadcast = (msg) => {
|
|
410
|
+
const line = JSON.stringify(msg) + "\n";
|
|
411
|
+
for (const c of clients) {
|
|
412
|
+
try { c.write(line); } catch { /* best effort */ }
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
/** Owner-fenced host write; refreshes the in-memory snapshot. */
|
|
416
|
+
const ownedUpdate = (mutate) => {
|
|
417
|
+
const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
|
|
418
|
+
const next = mutate(cur);
|
|
419
|
+
return { ...next, lastSeenAt: Date.now(), attachedClients: clients.size };
|
|
420
|
+
});
|
|
421
|
+
if (result.updated && result.host) host = result.host;
|
|
422
|
+
return result;
|
|
423
|
+
};
|
|
424
|
+
const isOwnerNow = () => {
|
|
425
|
+
const h = readHost(config.root, config.viewId);
|
|
426
|
+
return Boolean(h && h.instanceId === config.instanceId);
|
|
427
|
+
};
|
|
428
|
+
const releaseStartLease = () => {
|
|
429
|
+
if (startTouchTimer) {
|
|
430
|
+
clearInterval(startTouchTimer);
|
|
431
|
+
startTouchTimer = null;
|
|
432
|
+
}
|
|
433
|
+
const lease = startLease;
|
|
434
|
+
startLease = null;
|
|
435
|
+
try { lease?.release(); } catch { /* best effort */ }
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
function waitForChildExit(timeoutMs) {
|
|
439
|
+
if (childExited) return Promise.resolve(true);
|
|
440
|
+
return new Promise((resolve) => {
|
|
441
|
+
let settled = false;
|
|
442
|
+
let timer;
|
|
443
|
+
const finish = (exited) => {
|
|
444
|
+
if (settled) return;
|
|
445
|
+
settled = true;
|
|
446
|
+
clearTimeout(timer);
|
|
447
|
+
resolve(exited);
|
|
448
|
+
};
|
|
449
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
450
|
+
childExitPromise.then(() => finish(true));
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Reasons that finalize as `failed` rather than `exited`. */
|
|
455
|
+
const FAILED_REASONS = new Set(["server_error", "child_error", "crash", "child_spawn_failed", "endpoint_busy"]);
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Idempotent unified finish: fenced terminal write, bounded server close,
|
|
459
|
+
* child exit with escalation, inode-owned socket cleanup, config cleanup.
|
|
460
|
+
* @param {string} reason
|
|
461
|
+
* @param {number|null} [requestedExitCode]
|
|
462
|
+
*/
|
|
463
|
+
async function finishHost(reason, requestedExitCode = null) {
|
|
464
|
+
if (shutdownStarted) return;
|
|
465
|
+
shutdownStarted = true;
|
|
466
|
+
if (heartbeatTimer) {
|
|
467
|
+
clearInterval(heartbeatTimer);
|
|
468
|
+
heartbeatTimer = null;
|
|
469
|
+
}
|
|
470
|
+
// Only the runner that actually claimed the record writes terminal state; a
|
|
471
|
+
// superseded owner (or a duplicate that never claimed) never writes.
|
|
472
|
+
if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
|
|
473
|
+
ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
|
|
474
|
+
broadcast({ type: "status", status: host });
|
|
475
|
+
}
|
|
476
|
+
try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
|
|
477
|
+
for (const c of clients) {
|
|
478
|
+
try { c.destroy(); } catch { /* best effort */ }
|
|
479
|
+
}
|
|
480
|
+
clients.clear();
|
|
481
|
+
// Bounded server close (1s): never let a stuck client block cleanup.
|
|
482
|
+
await new Promise((resolve) => {
|
|
483
|
+
if (!server) return resolve();
|
|
484
|
+
let settled = false;
|
|
485
|
+
const done = () => {
|
|
486
|
+
if (!settled) {
|
|
487
|
+
settled = true;
|
|
488
|
+
resolve();
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const timer = setTimeout(done, 1000);
|
|
492
|
+
server.close(() => {
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
done();
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
if (child && !childExited) {
|
|
498
|
+
killChild(child, childPid, "SIGTERM");
|
|
499
|
+
if (!(await waitForChildExit(4000)) && !childExited) {
|
|
500
|
+
killChild(child, childPid, "SIGKILL");
|
|
501
|
+
await waitForChildExit(1000);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
|
|
505
|
+
const failed = FAILED_REASONS.has(reason);
|
|
506
|
+
ownedUpdate((cur) => ({
|
|
507
|
+
...cur,
|
|
508
|
+
state: failed ? "failed" : "exited",
|
|
509
|
+
endedAt: Date.now(),
|
|
510
|
+
exitCode: requestedExitCode ?? exitCode ?? 0,
|
|
511
|
+
childPid: null,
|
|
512
|
+
readyAt: null,
|
|
513
|
+
stopRequestedAt: null,
|
|
514
|
+
stopReason: reason,
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
// Endpoint cleanup: only the exact inode this instance bound.
|
|
518
|
+
if (process.platform !== "win32" && boundSocketIdentity) {
|
|
519
|
+
try {
|
|
520
|
+
const st = statSync(socketPath);
|
|
521
|
+
if (ownsEndpoint(boundSocketIdentity, { dev: st.dev, ino: st.ino })) {
|
|
522
|
+
try { unlinkSync(socketPath); } catch { /* best effort */ }
|
|
523
|
+
}
|
|
524
|
+
} catch { /* path gone — nothing to clean */ }
|
|
525
|
+
}
|
|
526
|
+
// Best-effort config cleanup (instance-scoped file only).
|
|
527
|
+
try {
|
|
528
|
+
if (config.configPath && config.instanceId && config.configPath.includes(config.instanceId)) {
|
|
529
|
+
unlinkSync(config.configPath);
|
|
530
|
+
}
|
|
531
|
+
} catch { /* best effort */ }
|
|
532
|
+
releaseStartLease();
|
|
533
|
+
process.exit(requestedExitCode ?? exitCode ?? 0);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Bounded finish: a mid-finish throw must still exit the process (the runner
|
|
537
|
+
// is detached; an unhandled rejection inside finishHost would otherwise hang).
|
|
538
|
+
const finish = (reason, requestedExitCode = null) => {
|
|
539
|
+
finishHost(reason, requestedExitCode).catch(() => process.exit(1));
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
process.on("SIGTERM", () => { finish("signal"); });
|
|
543
|
+
process.on("SIGINT", () => { finish("signal"); });
|
|
544
|
+
process.on("uncaughtException", (err) => {
|
|
545
|
+
process.removeAllListeners("uncaughtException");
|
|
546
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
547
|
+
diag("runner_crash", message, { stack: err instanceof Error ? err.stack : undefined });
|
|
548
|
+
// Crash finalize is owner-fenced via the expected-instance path in
|
|
549
|
+
// finalizeHostCrash: a superseded owner must not clobber the replacement's
|
|
550
|
+
// record (it records host_crash_owner_changed instead of writing).
|
|
551
|
+
host = finalizeHostCrash(config.root, config.viewId, host, err, { expectedInstanceId: config.instanceId });
|
|
552
|
+
finish("crash", 1);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// 1. Take the per-view host-start lease — the launch transaction boundary.
|
|
556
|
+
try {
|
|
557
|
+
startLease = acquireOwnedViewLock(config.root, config.viewId, "host-start", {
|
|
558
|
+
waitMs: HOST_RUNNER_LOCK_WAIT_MS,
|
|
559
|
+
identity: { pid: process.pid, startToken: captureStartToken(process.pid) },
|
|
560
|
+
});
|
|
561
|
+
} catch (err) {
|
|
562
|
+
diag("host_start_lock_timeout", err instanceof Error ? err.message : String(err));
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
startTouchTimer = setInterval(() => {
|
|
566
|
+
try { startLease?.touch(); } catch { /* best effort */ }
|
|
567
|
+
}, HEARTBEAT_MS);
|
|
568
|
+
startTouchTimer.unref?.();
|
|
569
|
+
|
|
570
|
+
// 2. Ownership decision inside the lease: never write host.json on the yield paths.
|
|
571
|
+
const current = readHost(config.root, config.viewId);
|
|
572
|
+
if (shouldYieldRunner({ host: current, instanceId: config.instanceId })) {
|
|
573
|
+
diag("host_start_yielded", "host record belongs to another active instance", { recordInstance: current?.instanceId ?? null });
|
|
574
|
+
releaseStartLease();
|
|
575
|
+
process.exit(0);
|
|
576
|
+
}
|
|
577
|
+
if (!current || current.instanceId !== config.instanceId) {
|
|
578
|
+
diag("host_start_stale_record", "no matching host claim for this instance", { recordInstance: current?.instanceId ?? null });
|
|
579
|
+
releaseStartLease();
|
|
580
|
+
process.exit(0);
|
|
581
|
+
}
|
|
582
|
+
// Own-terminal record: this instance already finished once (e.g. the prior
|
|
583
|
+
// runner of the SAME instance exited after claiming). Continuing would
|
|
584
|
+
// re-publish exited→starting and re-deliver initialPrompt — exit instead.
|
|
585
|
+
if (current.state === "exited" || current.state === "failed") {
|
|
586
|
+
diag("host_start_own_terminal", "host record for this instance is already terminal");
|
|
587
|
+
releaseStartLease();
|
|
588
|
+
process.exit(0);
|
|
589
|
+
}
|
|
590
|
+
if (current.state === "stopping" || current.stopRequestedAt != null) {
|
|
591
|
+
diag("host_start_revoked", "host claim was revoked before this runner started");
|
|
592
|
+
releaseStartLease();
|
|
593
|
+
process.exit(0);
|
|
594
|
+
}
|
|
595
|
+
host = current;
|
|
596
|
+
|
|
597
|
+
// 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
|
|
598
|
+
// instance; an occupied path means someone else owns it.
|
|
599
|
+
const listenOutcome = await new Promise((resolveListen) => {
|
|
600
|
+
const onError = (err) => resolveListen({ ok: false, error: err });
|
|
601
|
+
// Probe connections (clientId:"probe" hello) must not write host.json: the
|
|
602
|
+
// attach resolver's 150ms probe loop would amplify fenced writes and flip
|
|
603
|
+
// attachedEver with no client ever attached (CR round-1 finding 3).
|
|
604
|
+
const probeSockets = new WeakSet();
|
|
605
|
+
server = createServer((socket) => {
|
|
606
|
+
clients.add(socket);
|
|
607
|
+
socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
|
|
608
|
+
broadcast({ type: "status", status: host });
|
|
609
|
+
let buffer = "";
|
|
610
|
+
socket.on("data", (chunk) => {
|
|
611
|
+
buffer += chunk.toString("utf8");
|
|
612
|
+
const lines = buffer.split("\n");
|
|
613
|
+
buffer = lines.pop() ?? "";
|
|
614
|
+
for (const line of lines) handleClientLine(line, socket);
|
|
615
|
+
});
|
|
616
|
+
socket.on("close", () => {
|
|
617
|
+
clients.delete(socket);
|
|
618
|
+
if (probeSockets.has(socket)) return;
|
|
619
|
+
// Merge into the live record (a stale closure spread here erases a
|
|
620
|
+
// concurrent revoke — final review finding 2).
|
|
621
|
+
ownedUpdate((cur) => ({ ...cur }));
|
|
622
|
+
});
|
|
623
|
+
socket.on("error", () => {
|
|
624
|
+
clients.delete(socket);
|
|
625
|
+
if (probeSockets.has(socket)) return;
|
|
626
|
+
ownedUpdate((cur) => ({ ...cur }));
|
|
627
|
+
});
|
|
628
|
+
socket.markProbe = () => probeSockets.add(socket);
|
|
629
|
+
});
|
|
630
|
+
server.once("error", onError);
|
|
631
|
+
server.listen(socketPath, () => {
|
|
632
|
+
server?.removeListener("error", onError);
|
|
633
|
+
resolveListen({ ok: true });
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
if (!listenOutcome.ok) {
|
|
637
|
+
diag("host_endpoint_busy", listenOutcome.error instanceof Error ? listenOutcome.error.message : String(listenOutcome.error));
|
|
638
|
+
await finishHost("endpoint_busy", 1);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
server.on("error", (err) => {
|
|
642
|
+
diag("server_error", err instanceof Error ? err.message : String(err));
|
|
643
|
+
finish("server_error", 1);
|
|
644
|
+
});
|
|
645
|
+
// POSIX: record the exact inode we bound — cleanup matches dev+ino.
|
|
646
|
+
if (process.platform !== "win32") {
|
|
647
|
+
try {
|
|
648
|
+
const st = statSync(socketPath);
|
|
649
|
+
boundSocketIdentity = { dev: st.dev, ino: st.ino };
|
|
650
|
+
} catch { /* without the identity, cleanup degrades to no-op */ }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// 4. Publish runner identity while still holding the lease.
|
|
654
|
+
const claimedSelf = ownedUpdate((cur) => ({
|
|
655
|
+
...cur,
|
|
656
|
+
runnerPid: process.pid,
|
|
657
|
+
runnerIdentity: { pid: process.pid, startToken: captureStartToken(process.pid) },
|
|
658
|
+
runnerSpawnedAt: cur.runnerSpawnedAt ?? Date.now(),
|
|
659
|
+
state: cur.state === "alive" ? "alive" : "starting",
|
|
660
|
+
}));
|
|
661
|
+
if (!claimedSelf.updated) {
|
|
662
|
+
await finishHost("owner_lost", 0);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
claimedRecord = true;
|
|
666
|
+
broadcast({ type: "status", status: host });
|
|
667
|
+
|
|
668
|
+
// Test seam (issue #70 Task 8): a config-level delay between publishing the
|
|
669
|
+
// runner identity and spawning the child creates a REAL starting window
|
|
670
|
+
// (endpoint accepting, child null) so the starting protocol is testable.
|
|
671
|
+
// Only host configs written by tests set this env; the service never does.
|
|
672
|
+
const spawnDelayMs = Number(config.env?.AGENT_BOARD_TEST_SPAWN_DELAY_MS || 0);
|
|
673
|
+
if (spawnDelayMs > 0) await new Promise((r) => { setTimeout(r, spawnDelayMs).unref?.(); });
|
|
674
|
+
|
|
675
|
+
// 5. Spawn the child — only after the endpoint is bound and owned.
|
|
676
|
+
const args = [...config.piArgsPrefix, "--session", config.sessionFile];
|
|
677
|
+
if (config.model) args.push("--model", config.model);
|
|
678
|
+
if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
|
|
679
|
+
if (config.tools) args.push("--tools", config.tools);
|
|
680
|
+
if (config.initialPrompt) args.push(encodePromptForCliArg(config.initialPrompt));
|
|
681
|
+
const env = {
|
|
682
|
+
...process.env,
|
|
683
|
+
...(config.env || {}),
|
|
684
|
+
AGENT_BOARD_ROOT: config.root,
|
|
685
|
+
AGENT_BOARD_VIEW_ID: config.viewId,
|
|
686
|
+
AGENT_BOARD_CHILD: "1",
|
|
687
|
+
AGENT_BOARD_HOSTED: "pty",
|
|
688
|
+
// Legacy names are exported too so older child extension builds still behave.
|
|
689
|
+
AGENT_VIEW_ROOT: config.root,
|
|
690
|
+
AGENT_VIEW_VIEW_ID: config.viewId,
|
|
691
|
+
AGENT_VIEW_CHILD: "1",
|
|
692
|
+
AGENT_VIEW_HOSTED: "pty",
|
|
693
|
+
};
|
|
694
|
+
try {
|
|
695
|
+
child = spawnInteractive(config.piCommand, args, {
|
|
696
|
+
cwd: config.cwd,
|
|
697
|
+
env,
|
|
698
|
+
cols: host.cols,
|
|
699
|
+
rows: host.rows,
|
|
700
|
+
allowPipeFallback: config.env?.AGENT_BOARD_ALLOW_PIPE_FALLBACK === "1" || config.env?.AGENT_VIEW_ALLOW_PIPE_FALLBACK === "1",
|
|
701
|
+
});
|
|
702
|
+
} catch (err) {
|
|
703
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
704
|
+
diag("child_spawn_failed", message);
|
|
705
|
+
markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
|
|
706
|
+
await finishHost("child_spawn_failed", 1);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
childPid = child.pid ?? null;
|
|
710
|
+
child.onData((data) => {
|
|
711
|
+
screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
|
|
712
|
+
broadcast({ type: "output", data });
|
|
713
|
+
});
|
|
714
|
+
child.onExit((code) => {
|
|
715
|
+
childExited = true;
|
|
716
|
+
resolveChildExit?.();
|
|
717
|
+
exitCode = code ?? 0;
|
|
718
|
+
editorEmpty = null;
|
|
719
|
+
broadcast({ type: "editor_state", empty: null });
|
|
720
|
+
finish("child_exit", exitCode);
|
|
721
|
+
});
|
|
722
|
+
child.onError((err) => {
|
|
723
|
+
diag("child_error", err instanceof Error ? err.message : String(err));
|
|
724
|
+
finish("child_error", 1);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// 6. Re-check ownership after the child exists, then publish ready.
|
|
728
|
+
const ready = ownedUpdate((cur) => ({
|
|
729
|
+
...cur,
|
|
730
|
+
childPid,
|
|
731
|
+
childIdentity: { pid: childPid, startToken: captureStartToken(childPid) },
|
|
732
|
+
childSpawnedAt: Date.now(),
|
|
733
|
+
state: "alive",
|
|
734
|
+
readyAt: Date.now(),
|
|
735
|
+
}));
|
|
736
|
+
if (!ready.updated) {
|
|
737
|
+
// Superseded between spawn and ready: never publish; take the child down.
|
|
738
|
+
killChild(child, childPid, "SIGTERM");
|
|
739
|
+
const revoked = readHost(config.root, config.viewId)?.stopRequestedAt != null;
|
|
740
|
+
await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
broadcast({ type: "status", status: host });
|
|
744
|
+
|
|
745
|
+
// A starting-window resize cannot be applied at spawn-return nor at first
|
|
746
|
+
// output: the child node process is still bootstrapping and a SIGWINCH
|
|
747
|
+
// landing in that window is silently lost (pending-signal race, observed
|
|
748
|
+
// empirically). Defer the apply until the child has had time to finish
|
|
749
|
+
// bootstrapping; one-shot, best-effort, superseded by any newer direct
|
|
750
|
+
// resize (which clears the cache).
|
|
751
|
+
if (cachedResize) {
|
|
752
|
+
const applyHeld = setTimeout(() => {
|
|
753
|
+
if (!cachedResize || !child || shutdownStarted) return;
|
|
754
|
+
try { child.resize(cachedResize.cols, cachedResize.rows); } catch { /* best effort */ }
|
|
755
|
+
notifyChildResize(childPid);
|
|
756
|
+
cachedResize = null;
|
|
757
|
+
}, 500);
|
|
758
|
+
applyHeld.unref?.();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 7. Launch transaction complete — hand the start lease back.
|
|
762
|
+
releaseStartLease();
|
|
763
|
+
|
|
764
|
+
// 8. Heartbeat: watch the owner record; loss or revoke routes to finishHost.
|
|
765
|
+
heartbeatTimer = setInterval(() => {
|
|
766
|
+
const h = readHost(config.root, config.viewId);
|
|
767
|
+
if (!h || h.instanceId !== config.instanceId) {
|
|
768
|
+
finish("owner_lost", 0);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (h.stopRequestedAt != null) {
|
|
772
|
+
finish("revoked", 0);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const hb = ownedUpdate((cur) => ({ ...cur }));
|
|
776
|
+
// Plain host-meta lease contention returns {updated:false, ownerChanged:false}
|
|
777
|
+
// — transient, retry next tick (store.mjs contract). Only a confirmed
|
|
778
|
+
// ownership change tears the session down (CR round-1 finding 1).
|
|
779
|
+
if (hb.ownerChanged) finish("owner_lost", 0);
|
|
780
|
+
}, HEARTBEAT_MS);
|
|
781
|
+
heartbeatTimer.unref?.();
|
|
782
|
+
|
|
783
|
+
/** Client commands with the starting protocol (issue #70 §6.3): while the
|
|
784
|
+
* child does not exist, service inputs answer host_starting, resizes are
|
|
785
|
+
* held, and interactive keystrokes are dropped safely; once ready,
|
|
786
|
+
* requestId-tagged inputs ack with an in-process dedup table. */
|
|
787
|
+
function handleClientLine(line, socket) {
|
|
788
|
+
if (!line.trim()) return;
|
|
789
|
+
let msg;
|
|
790
|
+
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
791
|
+
switch (msg.type) {
|
|
792
|
+
case "hello":
|
|
793
|
+
// Probe handshakes (host-probe.mjs) are read-only: mark the socket so
|
|
794
|
+
// close/error skip the merge write, and never flip attachedEver
|
|
795
|
+
// (CR round-1 finding 3). Real clients record attachedEver here —
|
|
796
|
+
// deferred from connect time, which cannot distinguish them yet.
|
|
797
|
+
if (msg.clientId === "probe") {
|
|
798
|
+
socket.markProbe?.();
|
|
799
|
+
} else {
|
|
800
|
+
ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
|
|
801
|
+
}
|
|
802
|
+
send(socket, { type: "hello", status: host, editorEmpty });
|
|
803
|
+
break;
|
|
804
|
+
case "input": {
|
|
805
|
+
if (typeof msg.data !== "string") break;
|
|
806
|
+
if (typeof msg.requestId !== "string" || !msg.requestId) {
|
|
807
|
+
// Interactive UI keystrokes: fire-and-forget, no ack protocol.
|
|
808
|
+
if (child) child.write(msg.data);
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
if (!child) {
|
|
812
|
+
send(socket, { type: "error", code: "host_starting", requestId: msg.requestId });
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
if (ackedRequestIds.has(msg.requestId)) {
|
|
816
|
+
send(socket, { type: "input_ack", requestId: msg.requestId });
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
child.write(msg.data);
|
|
820
|
+
ackedRequestIds.set(msg.requestId, true);
|
|
821
|
+
if (ackedRequestIds.size > HOST_ACK_DEDUP_MAX) {
|
|
822
|
+
ackedRequestIds.delete(ackedRequestIds.keys().next().value);
|
|
823
|
+
}
|
|
824
|
+
send(socket, { type: "input_ack", requestId: msg.requestId });
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
case "resize": {
|
|
828
|
+
const cols = clampInt(msg.cols, 20, 300, host.cols);
|
|
829
|
+
const rows = clampInt(msg.rows, 5, 120, host.rows);
|
|
830
|
+
if (child) {
|
|
831
|
+
child.resize(cols, rows);
|
|
832
|
+
notifyChildResize(childPid);
|
|
833
|
+
cachedResize = null;
|
|
834
|
+
} else {
|
|
835
|
+
cachedResize = { cols, rows };
|
|
836
|
+
}
|
|
837
|
+
ownedUpdate((cur) => ({ ...cur, cols, rows }));
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
case "interrupt":
|
|
841
|
+
if (child) child.write("\x1b");
|
|
842
|
+
break;
|
|
843
|
+
case "terminate":
|
|
844
|
+
// Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
|
|
845
|
+
// so a SIGTERM-immune child still terminates within a bounded window.
|
|
846
|
+
finish("terminated", 0);
|
|
847
|
+
break;
|
|
848
|
+
case "detach":
|
|
849
|
+
socket.end();
|
|
850
|
+
break;
|
|
851
|
+
case "get_status":
|
|
852
|
+
send(socket, { type: "status", status: host });
|
|
853
|
+
break;
|
|
854
|
+
case "editor_state": {
|
|
855
|
+
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
856
|
+
broadcast({ type: "editor_state", empty: editorEmpty });
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
326
863
|
function spawnInteractive(command, args, opts) {
|
|
327
864
|
ensureNodePtySpawnHelperExecutable(requireForPty);
|
|
328
865
|
if (pty?.spawn) {
|
|
@@ -388,12 +925,46 @@ function killChild(child, pid, signal = "SIGTERM") {
|
|
|
388
925
|
try { child.kill(signal); } catch {}
|
|
389
926
|
}
|
|
390
927
|
|
|
928
|
+
/**
|
|
929
|
+
* node-pty's TIOCSWINSZ updates the kernel tty size, but the automatic
|
|
930
|
+
* SIGWINCH to the pty's foreground group is NOT reliably delivered when the
|
|
931
|
+
* runner itself is a detached session (empirically verified on Linux: the
|
|
932
|
+
* child's kernel size is correct, its signal handler never fires, and a
|
|
933
|
+
* direct kill delivers instantly). Real TUI children poll or handle SIGWINCH
|
|
934
|
+
* — an explicit best-effort signal is harmless for them and makes resize
|
|
935
|
+
* deterministic for every child. POSIX only.
|
|
936
|
+
* @param {number|null} pid
|
|
937
|
+
*/
|
|
938
|
+
function notifyChildResize(pid) {
|
|
939
|
+
if (process.platform === "win32" || !pid) return;
|
|
940
|
+
try { process.kill(pid, "SIGWINCH"); } catch { /* best effort */ }
|
|
941
|
+
}
|
|
942
|
+
|
|
391
943
|
function clampInt(value, min, max, fallback) {
|
|
392
944
|
const n = Number(value);
|
|
393
945
|
if (!Number.isFinite(n)) return fallback;
|
|
394
946
|
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
395
947
|
}
|
|
396
948
|
|
|
949
|
+
/** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
|
|
950
|
+
* across exec(2). Recorded in published identities so service-side recovery
|
|
951
|
+
* (issue #70 Task 11) can tell an owned-live pid from a reused one; null on
|
|
952
|
+
* failure or non-Linux platforms. */
|
|
953
|
+
function captureStartToken(pid) {
|
|
954
|
+
if (process.platform !== "linux" || !pid) return null;
|
|
955
|
+
try {
|
|
956
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
957
|
+
// comm (field 2) may contain spaces and parens — fields resume AFTER the
|
|
958
|
+
// last ')'. fields[0] is state (field 3) → starttime (field 22) is [19].
|
|
959
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trimStart();
|
|
960
|
+
const fields = afterComm.split(/\s+/);
|
|
961
|
+
const startTime = fields[19];
|
|
962
|
+
return startTime ?? null;
|
|
963
|
+
} catch {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
397
968
|
|
|
398
969
|
function markRowFailed(root, viewId, message) {
|
|
399
970
|
const now = Date.now();
|
package/runner/state-runner.mjs
CHANGED
|
@@ -79,6 +79,9 @@ function sanitizedEnv() {
|
|
|
79
79
|
delete env.AGENT_VIEW_VIEW_ID;
|
|
80
80
|
delete env.AGENT_BOARD_HOSTED;
|
|
81
81
|
delete env.AGENT_VIEW_HOSTED;
|
|
82
|
+
// Board-spawned classifier workers must not arm the warm-host sweeper
|
|
83
|
+
// (they share the board root with real hosts).
|
|
84
|
+
env.AGENT_BOARD_NO_SWEEP = "1";
|
|
82
85
|
return env;
|
|
83
86
|
}
|
|
84
87
|
|
package/runner/title-runner.mjs
CHANGED
|
@@ -45,7 +45,7 @@ async function maybeGenerateTitle(config) {
|
|
|
45
45
|
function runOneShot(command, args, timeoutMs = 20000) {
|
|
46
46
|
return new Promise((resolve) => {
|
|
47
47
|
let out = "";
|
|
48
|
-
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
48
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, env: { ...process.env, AGENT_BOARD_NO_SWEEP: "1" } });
|
|
49
49
|
let buf = "";
|
|
50
50
|
child.stdout.on("data", (c) => {
|
|
51
51
|
buf += c.toString();
|