@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +41 -3
  3. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
  4. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
  5. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
  6. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
  7. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
  8. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
  9. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
  10. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
  11. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
  12. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
  13. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
  14. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
  15. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
  16. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
  17. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
  18. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
  19. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
  20. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
  21. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
  22. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
  23. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
  24. package/package.json +83 -81
  25. package/runner/job-runner.mjs +2 -2
  26. package/runner/pty-runner.mjs +573 -2
  27. package/runner/state-runner.mjs +3 -0
  28. package/runner/title-runner.mjs +1 -1
  29. package/scripts/release_helper.mjs +277 -0
  30. package/src/commands/agent-board.ts +38 -35
  31. package/src/commands/attach-decision.mjs +66 -0
  32. package/src/commands/attach-flow.ts +45 -39
  33. package/src/core/code-refs.mjs +85 -33
  34. package/src/core/evidence.mjs +2 -2
  35. package/src/core/heuristics.mjs +40 -2
  36. package/src/core/host-coordination.mjs +159 -0
  37. package/src/core/host-crash.mjs +43 -3
  38. package/src/core/host-probe.mjs +196 -0
  39. package/src/core/launch.mjs +3 -1
  40. package/src/core/locks.mjs +196 -1
  41. package/src/core/paths.mjs +24 -0
  42. package/src/core/store.mjs +164 -5
  43. package/src/core/types.mjs +17 -1
  44. package/src/core/warm-host-sweeper.mjs +150 -0
  45. package/src/index.ts +29 -1
  46. package/src/runtime/service.mjs +967 -109
  47. package/src/ui/dashboard-decisions.mjs +55 -0
  48. package/src/ui/dashboard.ts +26 -12
@@ -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
- /** `closes #N` / `fixes #N` / `issue #N` back-link inside a `pr create` body. */
510
- const PR_BACKLINK_RE = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|issue)\s+#(\d{1,7})/i;
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
- /** Bare `#N` mentions. */
514
- const MENTION_RE = /#(\d{1,7})/g;
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 (let mi = 0; mi < pendingCreates.length; mi++) {
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
- // Rule 4b: the issue back-link of a created PR may live in a later
598
- // assistant message ("This PR closes #40") or in --body-file content
599
- // that never appears in the command string — scan subsequent evidence
600
- // for the back-link pattern as well (not just the command itself).
601
- // The scan stops at the NEXT pr-create marker so an earlier create
602
- // never absorbs a later PR's back-link.
603
- if (marker.kind === "pr") {
604
- const nextPr = pendingCreates.slice(mi + 1).find((m) => m.kind === "pr");
605
- const backlink = resolveBacklinkAfter(marker, commands, assistantTexts, nextPr?.index ?? Infinity);
606
- if (backlink) addCandidate(candidates, "issue", backlink.number, "claim", "pr-backlink", backlink.index);
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 m = PR_BACKLINK_RE.exec(text);
639
- if (m) addCandidate(candidates, "issue", Number(m[1]), "claim", "pr-body", index);
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 ("Closes #N" etc.) in evidence AFTER a
644
- * `pr create` marker — covers assistant messages and later commands alike.
645
- * The scan stops before `stopBefore` (typically the next pr-create marker).
646
- * @param {{kind: "issue"|"pr", index: number}} marker
647
- * @param {Array<{command: string}>} commands
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} [stopBefore]
694
+ * @param {number} baseIndex
695
+ * @returns {{number: number, index: number}|null}
650
696
  */
651
- function resolveBacklinkAfter(marker, commands, assistantTexts, stopBefore = Infinity) {
652
- const total = Math.min(commands.length + assistantTexts.length, stopBefore);
653
- for (let index = marker.index + 1; index < total; index++) {
654
- const text = evidenceTextAt(index, commands, assistantTexts);
655
- if (!text) continue;
656
- const m = PR_BACKLINK_RE.exec(text);
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
- for (const m of text.matchAll(MENTION_RE)) {
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);
@@ -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(String(event.result ?? ""), 500),
198
+ outputPreview: truncate(toolResultText(event.result), 500),
199
199
  });
200
200
  changed = true;
201
201
  }
@@ -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
- return `${str.slice(0, Math.max(0, n - 1))}…`;
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
+ }
@@ -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
+ }