@zhuxixi/pi-agent-board 0.5.2 → 0.6.1
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 +44 -0
- package/README.md +43 -5
- 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/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
- package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
- package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
- package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
- package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -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/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
- package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
- package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
- package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
- package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
- package/package.json +83 -81
- package/runner/job-runner.mjs +2 -2
- package/runner/pty-runner.mjs +626 -3
- 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 +47 -35
- package/src/commands/attach-decision.mjs +66 -0
- package/src/commands/attach-flow.ts +45 -39
- package/src/commands/bg.ts +9 -0
- package/src/core/code-refs.mjs +85 -33
- package/src/core/evidence.mjs +2 -2
- package/src/core/heuristics.mjs +75 -2
- package/src/core/host-coordination.mjs +182 -0
- package/src/core/host-crash.mjs +43 -3
- package/src/core/host-probe.mjs +196 -0
- package/src/core/launch-options.mjs +17 -0
- package/src/core/launch.mjs +35 -35
- 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 +40 -3
- package/src/runtime/service.mjs +1071 -108
- package/src/ui/dashboard-decisions.mjs +55 -0
- package/src/ui/dashboard.ts +67 -13
- package/src/ui/pty-attach.ts +23 -5
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,68 @@ 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))}…`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const OSC_SEQUENCE_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
263
|
+
const CSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
264
|
+
const OTHER_ESCAPE_RE = /\x1b./g;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Last non-empty visible line of a raw terminal log chunk: strips OSC/CSI/ESC
|
|
268
|
+
* escape sequences, resolves per-line carriage-return overwrites (progress
|
|
269
|
+
* bars / boot spinners), and returns the final remaining line truncated to
|
|
270
|
+
* `maxLen`. Used to attribute a failed child's exit reason from screen.log
|
|
271
|
+
* into host.json (issue #90). Returns null for empty/all-invisible input.
|
|
272
|
+
* @param {string|null|undefined} text
|
|
273
|
+
* @param {number} [maxLen]
|
|
274
|
+
* @returns {string|null}
|
|
275
|
+
*/
|
|
276
|
+
export function lastVisibleLogLine(text, maxLen = 200) {
|
|
277
|
+
if (!text) return null;
|
|
278
|
+
const visible = String(text)
|
|
279
|
+
.replace(OSC_SEQUENCE_RE, "")
|
|
280
|
+
.replace(CSI_SEQUENCE_RE, "")
|
|
281
|
+
.replace(OTHER_ESCAPE_RE, "")
|
|
282
|
+
// A \r directly before \n is a line terminator (CRLF), not an overwrite
|
|
283
|
+
// marker — normalize it away so the line content survives.
|
|
284
|
+
.replace(/\r\n/g, "\n");
|
|
285
|
+
const lines = visible.split("\n");
|
|
286
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
287
|
+
// Trailing \r chars are line-terminator junk (PTY ONLCR + the program's own
|
|
288
|
+
// CRLF stack up as \r\r\n) — drop them, then resolve interior \r overwrites.
|
|
289
|
+
const cleaned = lines[i].replace(/\r+$/, "");
|
|
290
|
+
const cr = cleaned.lastIndexOf("\r");
|
|
291
|
+
const line = (cr >= 0 ? cleaned.slice(cr + 1) : cleaned).trim();
|
|
292
|
+
if (line) return truncate(line, maxLen);
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
222
295
|
}
|
|
223
296
|
|
|
224
297
|
/**
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
/**
|
|
126
|
+
* Whether a legacy (pre-instanceId) host record can be safely finalized as
|
|
127
|
+
* `exited` so the next attach claims a fresh host. Spec §10.1 keeps legacy
|
|
128
|
+
* hosts unrecovered because their identity is unverifiable — but a provably
|
|
129
|
+
* dead runner pid IS a certain identity (a reused pid reads alive, which
|
|
130
|
+
* conservatively lands here as false), and an unreachable endpoint (missing
|
|
131
|
+
* socket file / refused socket) rules out a merely slow host. All four
|
|
132
|
+
* conditions must hold at once (issue #87).
|
|
133
|
+
* @param {{
|
|
134
|
+
* host: HostStatus|null|undefined,
|
|
135
|
+
* hostPid: number|null|undefined,
|
|
136
|
+
* hostPidAlive: boolean,
|
|
137
|
+
* probeClassification: string,
|
|
138
|
+
* }} input
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
|
|
142
|
+
if (!host || host.instanceId != null) return false;
|
|
143
|
+
if (host.state !== "starting" && host.state !== "alive") return false;
|
|
144
|
+
if (hostPid == null || hostPidAlive) return false;
|
|
145
|
+
return probeClassification === "missing" || probeClassification === "stale";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Host states in which a claim exists and must not be duplicated. */
|
|
149
|
+
const ACTIVE_STATES = new Set(["starting", "alive", "stopping"]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether a runner starting for `instanceId` must yield because the host
|
|
153
|
+
* record belongs to another still-active instance.
|
|
154
|
+
* @param {{host: HostStatus|null|undefined, instanceId: string|null|undefined}} input
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
export function shouldYieldRunner({ host, instanceId }) {
|
|
158
|
+
if (!host || host.instanceId === instanceId) return false;
|
|
159
|
+
return ACTIVE_STATES.has(host.state);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether a host is ready to accept service-generated input: alive, ready,
|
|
164
|
+
* and not revoked.
|
|
165
|
+
* @param {HostStatus|null|undefined} host
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function shouldAcceptInput(host) {
|
|
169
|
+
if (!host || host.state !== "alive" || host.readyAt == null) return false;
|
|
170
|
+
return host.stopRequestedAt == null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether a failed `listen` with this error may be retried (only EADDRINUSE,
|
|
175
|
+
* only the single configured retry).
|
|
176
|
+
* @param {string|null|undefined} errorCode
|
|
177
|
+
* @param {number} attempt Zero-based retry attempt already made.
|
|
178
|
+
* @returns {boolean}
|
|
179
|
+
*/
|
|
180
|
+
export function shouldRetryBind(errorCode, attempt) {
|
|
181
|
+
return errorCode === "EADDRINUSE" && attempt < 1;
|
|
182
|
+
}
|
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
|
}
|