@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/code-refs.mjs
CHANGED
|
@@ -506,12 +506,55 @@ const REF_CONFIDENCE = { claim: "high", action: "high", view: "medium", mention:
|
|
|
506
506
|
|
|
507
507
|
/** URL rules carry a `/issues/`, `/pull/`, or `/merge_requests/` path segment. */
|
|
508
508
|
const URL_RULE_RE = /issues\/|pull\/|merge_requests\//;
|
|
509
|
-
/**
|
|
510
|
-
|
|
509
|
+
/**
|
|
510
|
+
* Back-link inside an explicit `pr create` command body: closing keywords
|
|
511
|
+
* (optionally followed by "issue") or the legacy bare `issue #N` form.
|
|
512
|
+
* Word boundaries keep embedded keywords (prefix/disclose/unresolved) out;
|
|
513
|
+
* `(?!\w)` rejects longer numbers instead of truncating them to 7 digits.
|
|
514
|
+
*/
|
|
515
|
+
const PR_CREATE_BACKLINK_RE =
|
|
516
|
+
/\b(?:(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+(?:issue\s+)?|issue\s+)#(\d{1,7})(?!\w)/i;
|
|
517
|
+
/**
|
|
518
|
+
* Back-link in later assistant evidence: canonical closing-keyword syntax
|
|
519
|
+
* only (`closes #N` etc.). A bare `issue #N` mention — e.g. a code-review
|
|
520
|
+
* report's finding number — never matches here (issue #65).
|
|
521
|
+
*/
|
|
522
|
+
const PR_FOLLOWUP_BACKLINK_RE =
|
|
523
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+#(\d{1,7})(?!\w)/i;
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Match a PR→issue back-link in the text of an explicit `pr create` command
|
|
527
|
+
* (closing keywords or the legacy bare `issue #N` body form).
|
|
528
|
+
* @param {string} text
|
|
529
|
+
* @returns {number|null}
|
|
530
|
+
*/
|
|
531
|
+
function matchPrCreateBacklink(text) {
|
|
532
|
+
const m = PR_CREATE_BACKLINK_RE.exec(text);
|
|
533
|
+
return m ? Number(m[1]) : null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Match a PR→issue back-link in later assistant evidence — canonical
|
|
538
|
+
* closing-keyword syntax only, never a bare `issue #N` (issue #65).
|
|
539
|
+
* @param {string} text
|
|
540
|
+
* @returns {number|null}
|
|
541
|
+
*/
|
|
542
|
+
function matchPrFollowupBacklink(text) {
|
|
543
|
+
const m = PR_FOLLOWUP_BACKLINK_RE.exec(text);
|
|
544
|
+
return m ? Number(m[1]) : null;
|
|
545
|
+
}
|
|
511
546
|
/** `issue-<N>-...` worktree/branch naming convention (engine-builtin). */
|
|
512
547
|
const WORKTREE_RE = /(?:^|[/\\])issue-(\d{1,7})(?:-|$)/;
|
|
513
|
-
/**
|
|
514
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Bare `#N` mentions. The lookbehind rejects a preceding `#` (markdown
|
|
550
|
+
* headings, `##N` typos) or word character (`a#1`) — those are not issue
|
|
551
|
+
* references (issue #61).
|
|
552
|
+
*/
|
|
553
|
+
const MENTION_RE = /(?<![#\w])#(\d{1,7})/g;
|
|
554
|
+
/** Inline code span (single-line) — excluded from mention counting. */
|
|
555
|
+
const INLINE_CODE_SPAN_RE = /`[^`\n]*`/g;
|
|
556
|
+
/** Explicit PR-context tokens immediately before a `#N` match. */
|
|
557
|
+
const PR_CONTEXT_RE = /(?:^|\W)(?:pr|pull|mr|merge)s?(?:\W|$)/i;
|
|
515
558
|
|
|
516
559
|
/** Worktree/branch naming is session metadata, treated as before the transcript. */
|
|
517
560
|
const WORKTREE_INDEX = -1;
|
|
@@ -588,23 +631,25 @@ export function extractCodeRefs(input, provider) {
|
|
|
588
631
|
});
|
|
589
632
|
|
|
590
633
|
// Rule 3: resolve pending create markers against subsequent evidence.
|
|
591
|
-
for (
|
|
592
|
-
const marker = pendingCreates[mi];
|
|
634
|
+
for (const marker of pendingCreates) {
|
|
593
635
|
const resolved = resolveCreate(marker, commands, assistantTexts, urlRules);
|
|
594
636
|
if (resolved) {
|
|
595
637
|
addCandidate(candidates, marker.kind, resolved.number, "action", "create-url", resolved.index);
|
|
596
638
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
639
|
+
}
|
|
640
|
+
// Rule 4b: the issue back-link of a created PR may live in a later
|
|
641
|
+
// assistant message ("This PR closes #40") or in --body-file content that
|
|
642
|
+
// never appears in the command string. The flattened evidence input keeps
|
|
643
|
+
// no interleaving timestamps, so a follow-up back-link can be attributed
|
|
644
|
+
// only when exactly one distinct PR-create command exists; with zero or
|
|
645
|
+
// multiple PR creates the assistant scan is skipped rather than guessing.
|
|
646
|
+
// Later commands are never scanned: `gh issue close #N` or
|
|
647
|
+
// `gh pr comment ... fixes #N` are their own signals, not this PR's
|
|
648
|
+
// back-link (issue #65).
|
|
649
|
+
const prCreateIndexes = new Set(pendingCreates.filter((m) => m.kind === "pr").map((m) => m.index));
|
|
650
|
+
if (prCreateIndexes.size === 1) {
|
|
651
|
+
const backlink = resolveBacklinkAfter(assistantTexts, commands.length);
|
|
652
|
+
if (backlink) addCandidate(candidates, "issue", backlink.number, "claim", "pr-backlink", backlink.index);
|
|
608
653
|
}
|
|
609
654
|
|
|
610
655
|
// Rule 5: worktree/branch naming (engine-builtin, not configurable).
|
|
@@ -635,26 +680,26 @@ export function extractCodeRefs(input, provider) {
|
|
|
635
680
|
* @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
|
|
636
681
|
*/
|
|
637
682
|
function applyPrBacklink(text, index, candidates) {
|
|
638
|
-
const
|
|
639
|
-
if (
|
|
683
|
+
const number = matchPrCreateBacklink(text);
|
|
684
|
+
if (number !== null) addCandidate(candidates, "issue", number, "claim", "pr-body", index);
|
|
640
685
|
}
|
|
641
686
|
|
|
642
687
|
/**
|
|
643
|
-
* Find the first PR→issue back-link
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
688
|
+
* Find the first PR→issue back-link in later assistant texts — canonical
|
|
689
|
+
* closing-keyword syntax only ("Closes #N" etc., see
|
|
690
|
+
* PR_FOLLOWUP_BACKLINK_RE). Commands are never scanned. `baseIndex` (the
|
|
691
|
+
* command count) keeps the existing ordering contract; it is not a real
|
|
692
|
+
* timestamp.
|
|
648
693
|
* @param {string[]} assistantTexts
|
|
649
|
-
* @param {number}
|
|
694
|
+
* @param {number} baseIndex
|
|
695
|
+
* @returns {{number: number, index: number}|null}
|
|
650
696
|
*/
|
|
651
|
-
function resolveBacklinkAfter(
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if (m) return { number: Number(m[1]), index };
|
|
697
|
+
function resolveBacklinkAfter(assistantTexts, baseIndex) {
|
|
698
|
+
for (let i = 0; i < assistantTexts.length; i++) {
|
|
699
|
+
const text = assistantTexts[i];
|
|
700
|
+
if (typeof text !== "string" || !text) continue;
|
|
701
|
+
const number = matchPrFollowupBacklink(text);
|
|
702
|
+
if (number !== null) return { number, index: baseIndex + i };
|
|
658
703
|
}
|
|
659
704
|
return null;
|
|
660
705
|
}
|
|
@@ -734,7 +779,14 @@ function mentionFallback(assistantTexts, baseIndex) {
|
|
|
734
779
|
for (let i = start; i < assistantTexts.length; i++) {
|
|
735
780
|
const text = assistantTexts[i];
|
|
736
781
|
if (typeof text !== "string") continue;
|
|
737
|
-
|
|
782
|
+
// Doc examples inside inline code spans are not real references; strip
|
|
783
|
+
// them before counting (issue #61).
|
|
784
|
+
const stripped = text.replace(INLINE_CODE_SPAN_RE, " ");
|
|
785
|
+
for (const m of stripped.matchAll(MENTION_RE)) {
|
|
786
|
+
// `monitor pr #N` is explicitly PR context: do not count it toward
|
|
787
|
+
// the issue fallback (issue #61).
|
|
788
|
+
const before = stripped.slice(Math.max(0, m.index - 16), m.index);
|
|
789
|
+
if (PR_CONTEXT_RE.test(before)) continue;
|
|
738
790
|
const n = Number(m[1]);
|
|
739
791
|
counts.set(n, (counts.get(n) ?? 0) + 1);
|
|
740
792
|
lastIndex.set(n, baseIndex + i);
|
package/src/core/evidence.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Review evidence extraction and persistence helpers. */
|
|
2
|
-
import { assistantText, classifyCommand, toolFileOperation, truncate } from "./heuristics.mjs";
|
|
2
|
+
import { assistantText, classifyCommand, toolFileOperation, toolResultText, truncate } from "./heuristics.mjs";
|
|
3
3
|
import { atomicWriteJson, readJson } from "./atomic.mjs";
|
|
4
4
|
import * as P from "./paths.mjs";
|
|
5
5
|
|
|
@@ -195,7 +195,7 @@ export function reduceEvidence(snapshot, event, now = Date.now()) {
|
|
|
195
195
|
command: event.args?.command ?? "",
|
|
196
196
|
status: event.isError ? "failed" : "passed",
|
|
197
197
|
exitCode: event.isError ? 1 : 0,
|
|
198
|
-
outputPreview: truncate(
|
|
198
|
+
outputPreview: truncate(toolResultText(event.result), 500),
|
|
199
199
|
});
|
|
200
200
|
changed = true;
|
|
201
201
|
}
|
package/src/core/heuristics.mjs
CHANGED
|
@@ -22,6 +22,27 @@ export function assistantText(message) {
|
|
|
22
22
|
.trim();
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Extract text from a pi AgentToolResult object
|
|
27
|
+
* ({ content: [{ type: "text", text }, ...], details, ... }), or, defensively,
|
|
28
|
+
* a plain string. Unknown-shaped objects yield "" — never "[object Object]"
|
|
29
|
+
* (issue #41; mirrors pi's own convertToolResultOutput join("\n") semantics).
|
|
30
|
+
* @param {any} result
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function toolResultText(result) {
|
|
34
|
+
if (result == null) return "";
|
|
35
|
+
if (typeof result === "string") return result;
|
|
36
|
+
if (typeof result !== "object") return String(result);
|
|
37
|
+
const content = result.content;
|
|
38
|
+
if (!Array.isArray(content)) return "";
|
|
39
|
+
return content
|
|
40
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
41
|
+
.map((b) => b.text)
|
|
42
|
+
.join("\n")
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
/**
|
|
26
47
|
* Collect tool-call blocks from an assistant message.
|
|
27
48
|
* @param {any} message
|
|
@@ -209,16 +230,33 @@ function capitalize(s) {
|
|
|
209
230
|
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
210
231
|
}
|
|
211
232
|
|
|
233
|
+
const LONE_HIGH_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g;
|
|
234
|
+
const LONE_LOW_SURROGATE_RE = /(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
235
|
+
|
|
236
|
+
/** @param {string} s */
|
|
237
|
+
function stripLoneSurrogates(s) {
|
|
238
|
+
if (!s) return s;
|
|
239
|
+
return s.replace(LONE_HIGH_SURROGATE_RE, "").replace(LONE_LOW_SURROGATE_RE, "");
|
|
240
|
+
}
|
|
241
|
+
|
|
212
242
|
/**
|
|
213
243
|
* Truncate to `n` chars with an ellipsis (counts characters, not display width).
|
|
244
|
+
* The budget `n` is in UTF-16 units. The cut never splits a surrogate pair
|
|
245
|
+
* (it backs off one unit when it would), and lone surrogates in the input are
|
|
246
|
+
* stripped from the result — a lone surrogate renders as U+FFFD, whose
|
|
247
|
+
* terminal width can disagree with the computed width and misalign rows.
|
|
214
248
|
* @param {string} s
|
|
215
249
|
* @param {number} n
|
|
216
250
|
* @returns {string}
|
|
217
251
|
*/
|
|
218
252
|
export function truncate(s, n) {
|
|
219
253
|
const str = String(s ?? "");
|
|
220
|
-
if (str.length <= n) return str;
|
|
221
|
-
|
|
254
|
+
if (str.length <= n) return stripLoneSurrogates(str);
|
|
255
|
+
let end = Math.max(0, n - 1);
|
|
256
|
+
const lastUnit = str.charCodeAt(end - 1);
|
|
257
|
+
const nextUnit = str.charCodeAt(end);
|
|
258
|
+
if (lastUnit >= 0xd800 && lastUnit <= 0xdbff && nextUnit >= 0xdc00 && nextUnit <= 0xdfff) end -= 1;
|
|
259
|
+
return `${stripLoneSurrogates(str.slice(0, end))}…`;
|
|
222
260
|
}
|
|
223
261
|
|
|
224
262
|
/**
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure host-lifecycle decision functions (issue #70).
|
|
3
|
+
*
|
|
4
|
+
* Every function here takes plain snapshots (HostStatus-shaped objects, probe
|
|
5
|
+
* results, process observations) and returns a decision — no fs/net/process
|
|
6
|
+
* I/O. Callers (locks, store, service, runner) own all side effects; this
|
|
7
|
+
* module is the single source of truth for the coordination rules so they can
|
|
8
|
+
* be unit-tested exhaustively.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {import("./types.mjs").HostStatus} HostStatus
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether `host` is owned by the given fencing token. A null/missing
|
|
15
|
+
* instanceId never matches — legacy hosts have no owner token.
|
|
16
|
+
* @param {HostStatus|null|undefined} host
|
|
17
|
+
* @param {string|null|undefined} expectedInstanceId
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
export function sameHostOwner(host, expectedInstanceId) {
|
|
21
|
+
if (!host || !expectedInstanceId) return false;
|
|
22
|
+
return host.instanceId === expectedInstanceId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether a `starting` claim is still inside the launch grace window.
|
|
27
|
+
* A missing runnerPid or socket does NOT make a fresh claim stale (issue #70:
|
|
28
|
+
* provisional claims are normal while the runner boots).
|
|
29
|
+
* @param {HostStatus|null|undefined} host
|
|
30
|
+
* @param {number} now Epoch ms.
|
|
31
|
+
* @param {number} graceMs
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isStartingWithinGrace(host, now, graceMs) {
|
|
35
|
+
if (!host || host.state !== "starting" || host.claimAt == null) return false;
|
|
36
|
+
return now - host.claimAt < graceMs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Classify a recorded process identity against a live observation.
|
|
41
|
+
* Five states — `not_started` (never spawned) must never be conflated with
|
|
42
|
+
* `dead`, and a missing stable token must never be guessed as alive-owned:
|
|
43
|
+
* it degrades to `unknown` so recovery can only refuse, never misfire.
|
|
44
|
+
* @param {{pid:number,startToken:string|null}|null|undefined} identity The recorded identity.
|
|
45
|
+
* @param {{alive:boolean,startToken:string|null}|null|undefined} observed The observation.
|
|
46
|
+
* @param {number|null|undefined} spawnedAt Non-null only after spawn was confirmed.
|
|
47
|
+
* @returns {"not_started"|"dead"|"owned"|"foreign"|"unknown"}
|
|
48
|
+
*/
|
|
49
|
+
export function processIdentityState(identity, observed, spawnedAt) {
|
|
50
|
+
if (spawnedAt == null) return "not_started";
|
|
51
|
+
if (observed?.alive === false) return "dead";
|
|
52
|
+
if (identity?.startToken == null || observed?.startToken == null) return "unknown";
|
|
53
|
+
return identity.startToken === observed.startToken ? "owned" : "foreign";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Role observations that mean "no live process can belong to the old instance". */
|
|
57
|
+
const SAFE_TO_RELEASE = new Set(["not_started", "dead", "foreign"]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether an exited/failed host can be replaced by a new claim. Every role
|
|
61
|
+
* (runner, child, provisional-claim launcher) must be provably gone; any
|
|
62
|
+
* `unknown` observation or an active launch lease blocks replacement.
|
|
63
|
+
* @param {{
|
|
64
|
+
* host: HostStatus|null|undefined,
|
|
65
|
+
* runnerObservation: string,
|
|
66
|
+
* childObservation: string,
|
|
67
|
+
* claimObservation: string,
|
|
68
|
+
* launchLeaseActive: boolean,
|
|
69
|
+
* }} input
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function canReplaceHost({ host, runnerObservation, childObservation, claimObservation, launchLeaseActive }) {
|
|
73
|
+
if (!host || (host.state !== "exited" && host.state !== "failed")) return false;
|
|
74
|
+
if (launchLeaseActive) return false;
|
|
75
|
+
return (
|
|
76
|
+
SAFE_TO_RELEASE.has(runnerObservation) &&
|
|
77
|
+
SAFE_TO_RELEASE.has(childObservation) &&
|
|
78
|
+
SAFE_TO_RELEASE.has(claimObservation)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the endpoint file at `current` is the one this instance bound.
|
|
84
|
+
* Compares dev+ino so a replaced (rebound) socket path is never unlinked by
|
|
85
|
+
* its former owner.
|
|
86
|
+
* @param {{dev:number,ino:number}|null|undefined} bound Identity recorded at bind time.
|
|
87
|
+
* @param {{dev:number,ino:number}|null|undefined} current Identity observed at cleanup time.
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function ownsEndpoint(bound, current) {
|
|
91
|
+
return Boolean(bound && current && bound.dev === current.dev && bound.ino === current.ino);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Classify a connect+hello probe snapshot into the coordination enum.
|
|
96
|
+
* Order: connection errors first (they carry the errorCode), then protocol
|
|
97
|
+
* validity, then ownership mismatch (`occupied` — never touch), then
|
|
98
|
+
* readiness. A valid matching host that is not ready yet is `starting`, which
|
|
99
|
+
* callers treat as "wait", never as stale.
|
|
100
|
+
* @param {{
|
|
101
|
+
* connected?: boolean,
|
|
102
|
+
* protocolValid?: boolean,
|
|
103
|
+
* viewMatch?: boolean,
|
|
104
|
+
* instanceMatch?: boolean,
|
|
105
|
+
* state?: string|null,
|
|
106
|
+
* readyAt?: number|null,
|
|
107
|
+
* errorCode?: string|null,
|
|
108
|
+
* isSocket?: boolean,
|
|
109
|
+
* }} result Probe snapshot from host-probe.
|
|
110
|
+
* @returns {"ready"|"starting"|"stale"|"occupied"|"missing"|"unknown"}
|
|
111
|
+
*/
|
|
112
|
+
export function classifyProbeResult(result) {
|
|
113
|
+
if (!result) return "unknown";
|
|
114
|
+
if (!result.connected) {
|
|
115
|
+
if (result.errorCode === "ENOENT") return "missing";
|
|
116
|
+
if (result.errorCode === "ECONNREFUSED" && result.isSocket) return "stale";
|
|
117
|
+
return "unknown";
|
|
118
|
+
}
|
|
119
|
+
if (!result.protocolValid) return "unknown";
|
|
120
|
+
if (result.viewMatch === false || result.instanceMatch === false) return "occupied";
|
|
121
|
+
if (result.state === "alive" && result.readyAt != null) return "ready";
|
|
122
|
+
return "starting";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Host states in which a claim exists and must not be duplicated. */
|
|
126
|
+
const ACTIVE_STATES = new Set(["starting", "alive", "stopping"]);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a runner starting for `instanceId` must yield because the host
|
|
130
|
+
* record belongs to another still-active instance.
|
|
131
|
+
* @param {{host: HostStatus|null|undefined, instanceId: string|null|undefined}} input
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
export function shouldYieldRunner({ host, instanceId }) {
|
|
135
|
+
if (!host || host.instanceId === instanceId) return false;
|
|
136
|
+
return ACTIVE_STATES.has(host.state);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a host is ready to accept service-generated input: alive, ready,
|
|
141
|
+
* and not revoked.
|
|
142
|
+
* @param {HostStatus|null|undefined} host
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function shouldAcceptInput(host) {
|
|
146
|
+
if (!host || host.state !== "alive" || host.readyAt == null) return false;
|
|
147
|
+
return host.stopRequestedAt == null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a failed `listen` with this error may be retried (only EADDRINUSE,
|
|
152
|
+
* only the single configured retry).
|
|
153
|
+
* @param {string|null|undefined} errorCode
|
|
154
|
+
* @param {number} attempt Zero-based retry attempt already made.
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
export function shouldRetryBind(errorCode, attempt) {
|
|
158
|
+
return errorCode === "EADDRINUSE" && attempt < 1;
|
|
159
|
+
}
|
package/src/core/host-crash.mjs
CHANGED
|
@@ -4,16 +4,22 @@
|
|
|
4
4
|
* Never throws — this runs on the crash path.
|
|
5
5
|
*/
|
|
6
6
|
import { appendDiagnostic } from "./diagnostics.mjs";
|
|
7
|
-
import { writeHost } from "./store.mjs";
|
|
7
|
+
import { updateOwnedHost, writeHost } from "./store.mjs";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @param {string} root
|
|
11
11
|
* @param {string} viewId
|
|
12
12
|
* @param {object|null} host
|
|
13
13
|
* @param {unknown} error
|
|
14
|
+
* @param {{ expectedInstanceId?: string|null }} [opts] When `expectedInstanceId`
|
|
15
|
+
* is set (new ownership protocol, issue #70), the failed state is written via
|
|
16
|
+
* the owner-fenced `updateOwnedHost` — a superseded instance can never
|
|
17
|
+
* clobber the replacement's live record; the skip is recorded as a
|
|
18
|
+
* `host_crash_owner_changed` diagnostic. Without it the legacy wholesale
|
|
19
|
+
* `writeHost` behavior is preserved exactly.
|
|
14
20
|
* @returns {object}
|
|
15
21
|
*/
|
|
16
|
-
export function finalizeHostCrash(root, viewId, host, error) {
|
|
22
|
+
export function finalizeHostCrash(root, viewId, host, error, opts = {}) {
|
|
17
23
|
const message = error instanceof Error ? error.message : String(error);
|
|
18
24
|
const failed = {
|
|
19
25
|
...(host ?? { version: 1, viewId, mode: "pty", socketPath: null, startedAt: Date.now() }),
|
|
@@ -23,9 +29,44 @@ export function finalizeHostCrash(root, viewId, host, error) {
|
|
|
23
29
|
exitCode: 1,
|
|
24
30
|
error: message,
|
|
25
31
|
};
|
|
32
|
+
if (opts.expectedInstanceId != null) {
|
|
33
|
+
try {
|
|
34
|
+
// Build the terminal state from the LIVE record inside the host-meta
|
|
35
|
+
// lock — never from the possibly-stale `host` snapshot.
|
|
36
|
+
const result = updateOwnedHost(root, viewId, opts.expectedInstanceId, (cur) => ({
|
|
37
|
+
...cur,
|
|
38
|
+
state: "failed",
|
|
39
|
+
endedAt: Date.now(),
|
|
40
|
+
lastSeenAt: Date.now(),
|
|
41
|
+
exitCode: 1,
|
|
42
|
+
error: message,
|
|
43
|
+
}));
|
|
44
|
+
if (result.updated && result.host) {
|
|
45
|
+
appendCrashDiagnostic(root, viewId, message);
|
|
46
|
+
return result.host;
|
|
47
|
+
}
|
|
48
|
+
// ownerChanged, or the meta lock was briefly busy: never write unfenced.
|
|
49
|
+
try {
|
|
50
|
+
appendDiagnostic(root, viewId, {
|
|
51
|
+
source: "runner",
|
|
52
|
+
level: "warn",
|
|
53
|
+
code: "host_crash_owner_changed",
|
|
54
|
+
message: `Skipped crash finalize: host record no longer belongs to this instance (${message})`,
|
|
55
|
+
details: { error: message, ownerChanged: result.ownerChanged },
|
|
56
|
+
});
|
|
57
|
+
} catch { /* best effort */ }
|
|
58
|
+
} catch { /* best effort */ }
|
|
59
|
+
return failed;
|
|
60
|
+
}
|
|
26
61
|
try {
|
|
27
62
|
writeHost(root, failed);
|
|
28
63
|
} catch { /* best effort */ }
|
|
64
|
+
appendCrashDiagnostic(root, viewId, message);
|
|
65
|
+
return failed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string} root @param {string} viewId @param {string} message */
|
|
69
|
+
function appendCrashDiagnostic(root, viewId, message) {
|
|
29
70
|
try {
|
|
30
71
|
appendDiagnostic(root, viewId, {
|
|
31
72
|
source: "runner",
|
|
@@ -35,5 +76,4 @@ export function finalizeHostCrash(root, viewId, host, error) {
|
|
|
35
76
|
details: { error: message },
|
|
36
77
|
});
|
|
37
78
|
} catch { /* best effort */ }
|
|
38
|
-
return failed;
|
|
39
79
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real control-endpoint probe (issue #70).
|
|
3
|
+
*
|
|
4
|
+
* Connects to a PTY host control socket/pipe, performs the JSONL hello
|
|
5
|
+
* handshake, and classifies the outcome via `classifyProbeResult`. This is
|
|
6
|
+
* the ONLY "is this endpoint actually usable" authority — path existence and
|
|
7
|
+
* host.json state are hints, never proof (spec §7.1).
|
|
8
|
+
*
|
|
9
|
+
* Side-effectful by design (net + fs); all pure decisions live in
|
|
10
|
+
* host-coordination.mjs.
|
|
11
|
+
*/
|
|
12
|
+
import { createConnection } from "node:net";
|
|
13
|
+
import { lstatSync } from "node:fs";
|
|
14
|
+
import { classifyProbeResult } from "./host-coordination.mjs";
|
|
15
|
+
|
|
16
|
+
/** Default probe timeout. */
|
|
17
|
+
export const HOST_PROBE_TIMEOUT_MS = 250;
|
|
18
|
+
/** Poll cadence between attach-resolver probes / retries (issue #70 Task 12). */
|
|
19
|
+
export const HOST_PROBE_RETRY_MS = 150;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} ProbeResult
|
|
23
|
+
* @property {"ready"|"starting"|"stale"|"occupied"|"missing"|"unknown"} classification
|
|
24
|
+
* @property {boolean} connected
|
|
25
|
+
* @property {boolean} protocolValid
|
|
26
|
+
* @property {boolean} ready
|
|
27
|
+
* @property {string|null} viewId
|
|
28
|
+
* @property {string|null} instanceId
|
|
29
|
+
* @property {string|null} state
|
|
30
|
+
* @property {string|null} errorCode
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Probe a host control endpoint via a real connection + JSONL hello.
|
|
35
|
+
*
|
|
36
|
+
* `connect` is injectable for tests; the default accepts anything
|
|
37
|
+
* `net.createConnection` accepts (a socket path / pipe name, or a
|
|
38
|
+
* `{ host, port }` address object) and must return a socket-like object with
|
|
39
|
+
* the usual `'connect' | 'data' | 'error'` events and a `.destroy()`.
|
|
40
|
+
*
|
|
41
|
+
* The probe socket is ALWAYS destroyed before the promise resolves.
|
|
42
|
+
*
|
|
43
|
+
* @param {string|{host:string,port:number}} socketPath
|
|
44
|
+
* @param {{ timeoutMs?: number, expectedViewId?: string|null, expectedInstanceId?: string|null, connect?: (target: any) => any }} [opts]
|
|
45
|
+
* @returns {Promise<ProbeResult>}
|
|
46
|
+
*/
|
|
47
|
+
export function probeHost(socketPath, opts = {}) {
|
|
48
|
+
const timeoutMs = opts.timeoutMs ?? HOST_PROBE_TIMEOUT_MS;
|
|
49
|
+
const expectedViewId = opts.expectedViewId ?? null;
|
|
50
|
+
const expectedInstanceId = opts.expectedInstanceId ?? null;
|
|
51
|
+
const connect = opts.connect ?? createConnection;
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
/** @type {any} */ let socket;
|
|
55
|
+
/** @type {NodeJS.Timeout|null} */ let timer = null;
|
|
56
|
+
let settled = false;
|
|
57
|
+
let buffer = "";
|
|
58
|
+
/** @type {ProbeResult} */ let result;
|
|
59
|
+
|
|
60
|
+
const finish = () => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
if (timer) clearTimeout(timer);
|
|
64
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
65
|
+
resolve(result);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** @param {string} errorCode */ const finishError = (errorCode) => {
|
|
69
|
+
const snapshot = {
|
|
70
|
+
connected: false,
|
|
71
|
+
protocolValid: false,
|
|
72
|
+
errorCode,
|
|
73
|
+
isSocket: false,
|
|
74
|
+
};
|
|
75
|
+
// Only a refused Unix-domain socket path can be considered stale, and
|
|
76
|
+
// even then only when the path really is a socket file (a plain file
|
|
77
|
+
// or directory at the same path must stay "unknown" — never unlink it).
|
|
78
|
+
if (errorCode === "ECONNREFUSED" && process.platform !== "win32" && typeof socketPath === "string") {
|
|
79
|
+
try {
|
|
80
|
+
snapshot.isSocket = lstatSync(socketPath).isSocket() === true;
|
|
81
|
+
} catch {
|
|
82
|
+
snapshot.isSocket = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
result = buildResult(snapshot, null);
|
|
86
|
+
finish();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
socket = connect(socketPath);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
result = buildResult({ connected: false, protocolValid: false, errorCode: errCode(err), isSocket: false }, null);
|
|
93
|
+
finish();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
timer = setTimeout(() => finishError("TIMEOUT"), timeoutMs);
|
|
98
|
+
|
|
99
|
+
socket.on("error", (err) => {
|
|
100
|
+
finishError(errCode(err));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
socket.on("connect", () => {
|
|
104
|
+
try {
|
|
105
|
+
socket.write(JSON.stringify({ type: "hello", clientId: "probe", wantOutput: false }) + "\n");
|
|
106
|
+
} catch (err) {
|
|
107
|
+
finishError(errCode(err));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
socket.on("data", (chunk) => {
|
|
112
|
+
if (settled) return;
|
|
113
|
+
buffer += chunk.toString("utf8");
|
|
114
|
+
const newline = buffer.indexOf("\n");
|
|
115
|
+
if (newline < 0) return;
|
|
116
|
+
const line = buffer.slice(0, newline).trim();
|
|
117
|
+
const parsed = parseReply(line);
|
|
118
|
+
result = buildResultFromReply(parsed, expectedViewId, expectedInstanceId);
|
|
119
|
+
finish();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// A close before any data settles the probe as an error-path unknown.
|
|
123
|
+
socket.on("close", () => {
|
|
124
|
+
if (!settled) finishError("CLOSED");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} line
|
|
131
|
+
* @returns {{ ok: boolean, status: any }}
|
|
132
|
+
*/
|
|
133
|
+
function parseReply(line) {
|
|
134
|
+
if (!line) return { ok: false, status: null };
|
|
135
|
+
try {
|
|
136
|
+
const msg = JSON.parse(line);
|
|
137
|
+
if (!msg || typeof msg !== "object") return { ok: false, status: null };
|
|
138
|
+
if (msg.type !== "hello" && msg.type !== "status") return { ok: false, status: null };
|
|
139
|
+
if (!msg.status || typeof msg.status !== "object") return { ok: false, status: null };
|
|
140
|
+
return { ok: true, status: msg.status };
|
|
141
|
+
} catch {
|
|
142
|
+
return { ok: false, status: null };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Assemble the classify snapshot from a parsed reply.
|
|
148
|
+
* @param {{ ok: boolean, status: any }} parsed
|
|
149
|
+
* @param {string|null} expectedViewId
|
|
150
|
+
* @param {string|null} expectedInstanceId
|
|
151
|
+
*/
|
|
152
|
+
function buildResultFromReply(parsed, expectedViewId, expectedInstanceId) {
|
|
153
|
+
const status = parsed.ok ? parsed.status : null;
|
|
154
|
+
const viewMatch = expectedViewId == null ? true : status?.viewId === expectedViewId;
|
|
155
|
+
const instanceMatch = expectedInstanceId == null ? true : status?.instanceId === expectedInstanceId;
|
|
156
|
+
// Legacy hosts (pre-instanceId protocol) report alive without a readyAt
|
|
157
|
+
// field; a legacy probe (no expectedInstanceId) must still classify them
|
|
158
|
+
// ready so upgrade-window attach keeps working (spec §10.1 / Task 2 ruling).
|
|
159
|
+
const readyAt = status?.readyAt ?? (expectedInstanceId == null && status?.state === "alive" ? 1 : null);
|
|
160
|
+
return buildResult(
|
|
161
|
+
{
|
|
162
|
+
connected: true,
|
|
163
|
+
protocolValid: parsed.ok,
|
|
164
|
+
viewMatch,
|
|
165
|
+
instanceMatch,
|
|
166
|
+
state: status?.state ?? null,
|
|
167
|
+
readyAt,
|
|
168
|
+
},
|
|
169
|
+
status,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {{ connected: boolean, protocolValid: boolean, viewMatch?: boolean, instanceMatch?: boolean, state?: string|null, readyAt?: number|null, errorCode?: string|null, isSocket?: boolean }} snapshot
|
|
175
|
+
* @param {any} status
|
|
176
|
+
* @returns {ProbeResult}
|
|
177
|
+
*/
|
|
178
|
+
function buildResult(snapshot, status) {
|
|
179
|
+
const classification = classifyProbeResult(snapshot);
|
|
180
|
+
return {
|
|
181
|
+
classification,
|
|
182
|
+
connected: snapshot.connected === true,
|
|
183
|
+
protocolValid: snapshot.protocolValid === true,
|
|
184
|
+
ready: classification === "ready",
|
|
185
|
+
viewId: status?.viewId ?? null,
|
|
186
|
+
instanceId: status?.instanceId ?? null,
|
|
187
|
+
state: snapshot.state ?? null,
|
|
188
|
+
errorCode: snapshot.errorCode ?? null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @param {unknown} err */
|
|
193
|
+
function errCode(err) {
|
|
194
|
+
if (err && typeof err === "object" && typeof /** @type {any} */ (err).code === "string") return /** @type {any} */ (err).code;
|
|
195
|
+
return "UNKNOWN";
|
|
196
|
+
}
|