pi-crew 0.11.8 → 0.11.10
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 +94 -0
- package/dist/index.mjs +466 -466
- package/docs/usage.md +1 -1
- package/install.mjs +4 -1
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +26 -0
- package/src/config/defaults.ts +8 -1
- package/src/extension/command-completions.ts +44 -22
- package/src/extension/registration/commands/shared.ts +4 -14
- package/src/extension/registration/render-loop.ts +51 -21
- package/src/extension/registration/viewers.ts +25 -1
- package/src/extension/team-tool/handle-settings.ts +4 -1
- package/src/ui/dashboard-panes/agents-pane.ts +3 -2
- package/src/ui/dashboard-panes/health-pane.ts +6 -1
- package/src/ui/dashboard-panes/mailbox-pane.ts +12 -7
- package/src/ui/dashboard-panes/plan-pane.ts +5 -2
- package/src/ui/dashboard-panes/progress-pane.ts +8 -2
- package/src/ui/live-conversation-overlay.ts +5 -3
- package/src/ui/live-duration.ts +5 -4
- package/src/ui/live-run-sidebar.ts +90 -20
- package/src/ui/overlays/mailbox-compose-overlay.ts +17 -1
- package/src/ui/run-action-dispatcher.ts +2 -1
- package/src/ui/run-dashboard.ts +62 -22
- package/src/ui/run-snapshot-cache.ts +32 -0
- package/src/ui/settings-overlay.ts +26 -2
- package/src/ui/tool-renderers/brief-mode.ts +2 -2
- package/src/ui/transcript-viewer.ts +4 -1
- package/src/ui/widget/index.ts +10 -2
- package/src/ui/widget/task-list.ts +12 -4
- package/src/ui/widget/widget-model.ts +5 -4
- package/src/ui/widget/widget-renderer.ts +164 -38
- package/src/utils/socket-path.ts +48 -8
|
@@ -51,28 +51,133 @@ const DOCK_WORD = "CREW";
|
|
|
51
51
|
const DOCK_HINT_RIGHT = "↓·enter";
|
|
52
52
|
const DOCK_HINT = `···· ${DOCK_HINT_RIGHT}`;
|
|
53
53
|
|
|
54
|
+
/** The ` · ` the dock row joins its canopy and segments with. */
|
|
55
|
+
const DOCK_SEPARATOR = " · ";
|
|
56
|
+
|
|
57
|
+
/** Content-budget floor: a degenerate width still paints a (clipped) rail row. */
|
|
58
|
+
const MIN_DOCK_WIDTH = 8;
|
|
59
|
+
|
|
60
|
+
/** One ` · `-separated piece of the dock row, with its budget priority. */
|
|
61
|
+
interface DockPiece {
|
|
62
|
+
text: string;
|
|
63
|
+
/**
|
|
64
|
+
* L9 (ui-instability review 2026-10-07, T13 evidence: the 40-column dock
|
|
65
|
+
* rendered `1 runnin…`): `status` pieces (`2 running`, `3/5 done`, `⚠
|
|
66
|
+
* plan:<id>`) are ATOMIC — they render whole or drop as a unit, never
|
|
67
|
+
* split mid-word; `meta` pieces (`⏰ …`, alerts badge) are dropped first
|
|
68
|
+
* under width pressure. The subject (run id / label) clips LAST, with `…`
|
|
69
|
+
* at its own boundary — never a blind truncate() across a concatenation
|
|
70
|
+
* that mixes a must-survive token with a droppable one.
|
|
71
|
+
*/
|
|
72
|
+
kind: "status" | "meta";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Clip `text` at WORD boundaries (trailing words drop, `…` marks the
|
|
76
|
+
* elision) — the finest clip the dock ever applies inside a piece. */
|
|
77
|
+
function clipWords(text: string, budget: number): string {
|
|
78
|
+
if (visibleWidth(text) <= budget) return text;
|
|
79
|
+
const words = text.split(" ");
|
|
80
|
+
let kept = words.length;
|
|
81
|
+
while (kept > 1 && visibleWidth(words.slice(0, kept).join(" ")) > budget - 1) kept--;
|
|
82
|
+
const base = words.slice(0, kept).join(" ");
|
|
83
|
+
return visibleWidth(base) <= budget - 1 ? `${base}…` : truncate(text, budget);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compose the dock row's left side under a width budget, by explicit
|
|
88
|
+
* priority (L9/T13): meta pieces drop first (right-to-left), then the subject
|
|
89
|
+
* (id) clips with `…` at its own boundary, then the `↓·enter` hint yields,
|
|
90
|
+
* and only then does a piece drop as a WHOLE unit. A status word is therefore
|
|
91
|
+
* never split — the ellipsis only ever lands at a segment boundary.
|
|
92
|
+
*/
|
|
93
|
+
function budgetDockLeft(
|
|
94
|
+
buildLeft: (subject: string, pieces: readonly DockPiece[]) => string,
|
|
95
|
+
subject: string,
|
|
96
|
+
pieces: readonly DockPiece[],
|
|
97
|
+
/** Left budget while the hint rides (hint + the 2-space collapsed gap). */
|
|
98
|
+
withHint: number,
|
|
99
|
+
/** Left budget once the hint is dropped. */
|
|
100
|
+
full: number,
|
|
101
|
+
): { left: string; hint: boolean } {
|
|
102
|
+
let subjectText = subject;
|
|
103
|
+
let kept = pieces;
|
|
104
|
+
const current = (): string => buildLeft(subjectText, kept);
|
|
105
|
+
const fits = (limit: number): boolean => visibleWidth(current()) <= limit;
|
|
106
|
+
|
|
107
|
+
if (fits(withHint)) return { left: current(), hint: true };
|
|
108
|
+
|
|
109
|
+
// (1) meta drops first, right-to-left — but never the row's LAST piece: an
|
|
110
|
+
// idle row whose only piece is the schedule must clip it, not lose it (a
|
|
111
|
+
// content-free row is indistinguishable from no row at all).
|
|
112
|
+
while (kept.length > 1 && kept.at(-1)?.kind === "meta" && !fits(withHint)) kept = kept.slice(0, -1);
|
|
113
|
+
if (fits(withHint)) return { left: current(), hint: true };
|
|
114
|
+
|
|
115
|
+
// (2) the subject (id) clips — the lowest-priority clippable piece. `…`
|
|
116
|
+
// lands at the subject's own boundary; the pieces stay untouched.
|
|
117
|
+
const room = withHint - visibleWidth(buildLeft("", kept));
|
|
118
|
+
subjectText = room >= 4 ? truncate(subject, room - 3) : "";
|
|
119
|
+
if (fits(withHint)) return { left: current(), hint: true };
|
|
120
|
+
|
|
121
|
+
// (3) the hint yields before ANY piece is dropped or mangled: status
|
|
122
|
+
// outranks the affordance — `↓·enter` may survive per budget, never at the
|
|
123
|
+
// cost of `1 running`.
|
|
124
|
+
if (fits(full)) return { left: current(), hint: false };
|
|
125
|
+
|
|
126
|
+
// (4) whole pieces drop right-to-left — status included, but as a UNIT.
|
|
127
|
+
while (kept.length > 1 && !fits(full)) kept = kept.slice(0, -1);
|
|
128
|
+
if (fits(full)) return { left: current(), hint: false };
|
|
129
|
+
|
|
130
|
+
// (5) one piece left: meta clips word-wise (the idle schedule); a lone
|
|
131
|
+
// status piece drops whole rather than split.
|
|
132
|
+
const last = kept.at(-1);
|
|
133
|
+
if (last?.kind === "meta") {
|
|
134
|
+
const pieceBudget = full - visibleWidth(buildLeft(subjectText, [])) - visibleWidth(DOCK_SEPARATOR);
|
|
135
|
+
kept = [{ kind: "meta", text: clipWords(last.text, Math.max(pieceBudget, 1)) }];
|
|
136
|
+
if (fits(full)) return { left: current(), hint: false };
|
|
137
|
+
}
|
|
138
|
+
// (6) pathological width (< ~12 cols): the width invariant wins.
|
|
139
|
+
return { left: truncate(buildLeft(subjectText, []), Math.max(full, 1)), hint: false };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The dock's one row, composed under the segment-priority budget and laid
|
|
144
|
+
* out with the `↓·enter` tail. The pin gives a fixed four-dot leader on a
|
|
145
|
+
* wide terminal (`railLeaders`); on a NARROW one the budget shrinks instead,
|
|
146
|
+
* so `budgetDockLeft` sacrifices meta → subject → hint → whole pieces — in
|
|
147
|
+
* that order — and the actionable hint never gets clipped mid-glyph (the
|
|
148
|
+
* live row used to end in `↓…` at 50 columns, and later in `1 runnin…` at
|
|
149
|
+
* 40). The exact-fit gap is laid out HERE rather than through `railLeaders`:
|
|
150
|
+
* its 0-slack branch cuts ONE visible char off the left side, which is
|
|
151
|
+
* exactly the mid-token regression this composer exists to kill.
|
|
152
|
+
*/
|
|
153
|
+
function dockRow(args: {
|
|
154
|
+
glyph: string;
|
|
155
|
+
subject: string;
|
|
156
|
+
subjectUrl: string | undefined;
|
|
157
|
+
pieces: readonly DockPiece[];
|
|
158
|
+
maxWidth?: number;
|
|
159
|
+
}): string {
|
|
160
|
+
const { glyph, subjectUrl, pieces } = args;
|
|
161
|
+
const buildLeft = (subjectText: string, kept: readonly DockPiece[]): string => {
|
|
162
|
+
const identity = dockIdentity(linkify(subjectText, subjectUrl));
|
|
163
|
+
const head = glyph ? `${glyph} ${identity}` : identity;
|
|
164
|
+
return kept.length > 0 ? `${head}${DOCK_SEPARATOR}${kept.map((piece) => piece.text).join(DOCK_SEPARATOR)}` : head;
|
|
165
|
+
};
|
|
166
|
+
const pinned = visibleWidth(buildLeft(args.subject, pieces)) + visibleWidth(DOCK_HINT_RIGHT) + 6;
|
|
167
|
+
const budget = args.maxWidth === undefined ? pinned : Math.min(pinned, Math.max(args.maxWidth, MIN_DOCK_WIDTH) - 2);
|
|
168
|
+
const outcome = budgetDockLeft(buildLeft, args.subject, pieces, budget - visibleWidth(DOCK_HINT_RIGHT) - 2, budget);
|
|
169
|
+
if (!outcome.hint) return outcome.left;
|
|
170
|
+
const slack = budget - visibleWidth(outcome.left) - visibleWidth(DOCK_HINT_RIGHT) - 2;
|
|
171
|
+
if (slack >= 3) return railLeaders(outcome.left, DOCK_HINT_RIGHT, budget, PLAIN_THEME);
|
|
172
|
+
return `${outcome.left} ${DOCK_HINT_RIGHT}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
54
175
|
/** The dock's FOCUS marker (RAIL §2.B: "focused row keeps the `❯ ` prefix").
|
|
55
176
|
* It is deliberately not `rail.ts`'s `CURSOR` (`›`) — that glyph marks the
|
|
56
177
|
* selected row of a LIST; the dock is a single line, and `❯` is the marker
|
|
57
178
|
* the prompt area has always acknowledged the ↓ keystroke with. */
|
|
58
179
|
const FOCUS_MARKER = "❯";
|
|
59
180
|
|
|
60
|
-
/**
|
|
61
|
-
* `···· ↓·enter` appended to a dock row. Composed through `railLeaders` with a
|
|
62
|
-
* budget that pins the leader run at exactly four dots — the dock is a
|
|
63
|
-
* left-aligned single row, so the leaders must NOT stretch to the terminal
|
|
64
|
-
* width the way a card's metrics→elapsed leaders do.
|
|
65
|
-
*/
|
|
66
|
-
function dockTail(left: string, theme: CrewTheme, maxWidth?: number): string {
|
|
67
|
-
const pinned = visibleWidth(left) + visibleWidth(DOCK_HINT_RIGHT) + 6;
|
|
68
|
-
// The pin gives a fixed four-dot leader on a wide terminal. On a NARROW one
|
|
69
|
-
// the budget shrinks instead, so `railLeaders` trims the LEFT segment (with
|
|
70
|
-
// `…`) and the actionable `↓·enter` hint never gets clipped away — the live
|
|
71
|
-
// row used to end in `↓…` at 50 columns.
|
|
72
|
-
const budget = maxWidth === undefined ? pinned : Math.min(pinned, maxWidth - 2);
|
|
73
|
-
return railLeaders(left, DOCK_HINT_RIGHT, budget, theme);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
181
|
/** `CREW ▸ <subject>` — the dock identity (colour is applied on the paint pass). */
|
|
77
182
|
function dockIdentity(subject: string): string {
|
|
78
183
|
return subject ? `${DOCK_WORD} ${ACTIVE} ${subject}` : DOCK_WORD;
|
|
@@ -96,14 +201,12 @@ export function widgetRailSlot(runs: readonly WidgetRun[]): RailSlot {
|
|
|
96
201
|
}
|
|
97
202
|
|
|
98
203
|
/** `<team>` (or `team/workflow` when they differ); several live runs collapse
|
|
99
|
-
* to `<n> runs` — the counts on the row stay aggregate.
|
|
100
|
-
|
|
204
|
+
* to `<n> runs` — the counts on the row stay aggregate. Returns the RAW
|
|
205
|
+
* label: the row budget clips it BEFORE the OSC-8 wrap, so a hyperlink
|
|
206
|
+
* sequence can never be sliced open (the link target survives whole). */
|
|
207
|
+
function dockSubjectLabel(runs: WidgetRun[]): string {
|
|
101
208
|
if (runs.length === 0) return "idle";
|
|
102
|
-
|
|
103
|
-
// R3-9: the dock's ONE clickable action — the subject opens the LATEST
|
|
104
|
-
// run's artifacts directory (`file://` OSC-8 link). Terminals without
|
|
105
|
-
// OSC-8 support ignore the escapes and render the plain label.
|
|
106
|
-
return linkify(label, latestArtifactsUrl(runs));
|
|
209
|
+
return runs.length > 1 ? `${runs.length} runs` : shortRunLabel(runs[0]!.run);
|
|
107
210
|
}
|
|
108
211
|
|
|
109
212
|
// ── R3-9: OSC-8 dock link ─────────────────────────────────────────────
|
|
@@ -155,8 +258,20 @@ function linkify(text: string, url: string | undefined): string {
|
|
|
155
258
|
*/
|
|
156
259
|
export function idleWidgetLine(schedLine: string | undefined, focused = false, maxWidth?: number): string | undefined {
|
|
157
260
|
if (!schedLine) return undefined;
|
|
158
|
-
const
|
|
159
|
-
|
|
261
|
+
const line = dockLine(
|
|
262
|
+
dockRow({
|
|
263
|
+
glyph: "",
|
|
264
|
+
subject: "idle",
|
|
265
|
+
subjectUrl: undefined,
|
|
266
|
+
pieces: [{ text: schedLine, kind: "meta" }],
|
|
267
|
+
// The focused ❯ prefix eats two columns of the same terminal row —
|
|
268
|
+
// budget for it here so the final truncate stays a safety net, not
|
|
269
|
+
// the clip.
|
|
270
|
+
maxWidth: maxWidth === undefined ? undefined : Math.max(maxWidth - (focused ? 2 : 0), MIN_DOCK_WIDTH),
|
|
271
|
+
}),
|
|
272
|
+
PLAIN_THEME,
|
|
273
|
+
"border",
|
|
274
|
+
);
|
|
160
275
|
return focused ? `${FOCUS_MARKER} ${line}` : line;
|
|
161
276
|
}
|
|
162
277
|
|
|
@@ -199,7 +314,7 @@ export function widgetHeader(
|
|
|
199
314
|
maxWidth?: number,
|
|
200
315
|
): string {
|
|
201
316
|
const agents = runs.flatMap((item) => item.agents);
|
|
202
|
-
const
|
|
317
|
+
const pieces: DockPiece[] = [];
|
|
203
318
|
if (runs.length > 0) {
|
|
204
319
|
const runningAgents = agents.filter((a) => a.status === "running").length;
|
|
205
320
|
const queuedAgents = agents.filter((a) => a.status === "queued").length;
|
|
@@ -207,28 +322,36 @@ export function widgetHeader(
|
|
|
207
322
|
const completedAgents = agents.filter((a) => a.status === "completed").length;
|
|
208
323
|
// Zero counts are noise on a one-line dock: a finished run reads
|
|
209
324
|
// `3/3 done`, not `0 running · 3/3 done`.
|
|
210
|
-
if (runningAgents)
|
|
211
|
-
if (queuedAgents)
|
|
212
|
-
if (waitingAgents)
|
|
213
|
-
if (completedAgents)
|
|
325
|
+
if (runningAgents) pieces.push({ text: `${runningAgents} running`, kind: "status" });
|
|
326
|
+
if (queuedAgents) pieces.push({ text: `${queuedAgents} queued`, kind: "status" });
|
|
327
|
+
if (waitingAgents) pieces.push({ text: `${waitingAgents} waiting`, kind: "status" });
|
|
328
|
+
if (completedAgents) pieces.push({ text: `${completedAgents}/${agents.length} done`, kind: "status" });
|
|
214
329
|
// WP-3 on the single line (2026-09-14 round 2): a run parked awaiting plan
|
|
215
330
|
// approval surfaces as a `⚠ plan:<run8>` segment — the row-level badge is
|
|
216
331
|
// gone with the run tree, so the count row carries the signal.
|
|
217
332
|
const planPending = runs.find((item) => isPlanApprovalStatePending(item.run.planApproval));
|
|
218
|
-
if (planPending)
|
|
333
|
+
if (planPending) pieces.push({ text: `⚠ plan:${shortId(planPending.run.runId)}`, kind: "status" });
|
|
219
334
|
}
|
|
220
335
|
// Tier C: `schedSegment` is the ALREADY-BUILT `⏰ …` string (jobs, hidden
|
|
221
336
|
// count and clock are all injected upstream — buildWidgetLines); undefined
|
|
222
337
|
// means nothing schedules-related paints.
|
|
223
|
-
if (schedSegment)
|
|
338
|
+
if (schedSegment) pieces.push({ text: schedSegment, kind: "meta" });
|
|
224
339
|
// Bug 021: the alerts badge is one more segment (no 🔔, capped at 99+).
|
|
225
340
|
const badge = notificationBadge(notificationCount)
|
|
226
341
|
.replace(/^\s*·\s*/, "")
|
|
227
342
|
.trim();
|
|
228
|
-
if (badge)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
343
|
+
if (badge) pieces.push({ text: badge, kind: "meta" });
|
|
344
|
+
return dockLine(
|
|
345
|
+
dockRow({
|
|
346
|
+
glyph: runningGlyph,
|
|
347
|
+
subject: dockSubjectLabel(runs),
|
|
348
|
+
subjectUrl: latestArtifactsUrl(runs),
|
|
349
|
+
pieces,
|
|
350
|
+
maxWidth,
|
|
351
|
+
}),
|
|
352
|
+
PLAIN_THEME,
|
|
353
|
+
"border",
|
|
354
|
+
);
|
|
232
355
|
}
|
|
233
356
|
|
|
234
357
|
// ── Agent ordering (shared with the inline panel) ──────────────────────
|
|
@@ -413,10 +536,13 @@ export function buildWidgetLines(
|
|
|
413
536
|
// interactive run is active. The ⏰ segment stands alone as the row; with
|
|
414
537
|
// no schedules either there is NOTHING to paint — `[]`, never a bare
|
|
415
538
|
// hint and never the literal `undefined — ↓·enter` (live bug 2026-09-16).
|
|
416
|
-
const idle = idleWidgetLine(schedLine, focused);
|
|
539
|
+
const idle = idleWidgetLine(schedLine, focused, width);
|
|
417
540
|
return idle ? [truncate(idle, width)] : [];
|
|
418
541
|
}
|
|
419
|
-
|
|
542
|
+
// The focused ❯ prefix eats two columns of the same terminal row: budget
|
|
543
|
+
// for it here so the final truncate stays a safety net, never the clip.
|
|
544
|
+
const headerWidth = focused ? Math.max(width - 2, MIN_DOCK_WIDTH) : width;
|
|
545
|
+
const base = widgetHeader(runs, widgetActivityGlyph(runs), maxLines, notificationCount, schedLine, headerWidth);
|
|
420
546
|
return [truncate(focused ? `${FOCUS_MARKER} ${base}` : base, width)];
|
|
421
547
|
}
|
|
422
548
|
|
package/src/utils/socket-path.ts
CHANGED
|
@@ -63,7 +63,18 @@ export function getPerUserSocketDir(platform: NodeJS.Platform = process.platform
|
|
|
63
63
|
if (platform === "win32") return "";
|
|
64
64
|
const base = process.env.XDG_RUNTIME_DIR || os.tmpdir();
|
|
65
65
|
const uid = getCurrentUid();
|
|
66
|
-
|
|
66
|
+
// F-BAT1 (2026-10-08, live-caught P1): when XDG_RUNTIME_DIR is ABSENT the
|
|
67
|
+
// base is /tmp — and THREE workspace sweeps scan /tmp for dirs named
|
|
68
|
+
// `pi-crew-*` (cleanupLegacyOrphanTempDirs, orphan temp reconciler, health
|
|
69
|
+
// zombie scan). A broker dir at /tmp/pi-crew-<uid> collides with that
|
|
70
|
+
// namespace and gets rmSync-ed as "debris" — killing the LIVE broker socket
|
|
71
|
+
// of every session launched without XDG_RUNTIME_DIR (live evidence:
|
|
72
|
+
// ask/message/delegate ENOENT in both sync and async runs while runs
|
|
73
|
+
// stayed green). The no-XDG fallback therefore uses a DOT-prefixed name
|
|
74
|
+
// (`.pi-crew-broker-<uid>`) which no `pi-crew-*` sweep matches. The XDG
|
|
75
|
+
// path keeps the original `pi-crew-<uid>` name (/run/user is never swept).
|
|
76
|
+
const leaf = process.env.XDG_RUNTIME_DIR ? `pi-crew-${uid}` : `.pi-crew-broker-${uid}`;
|
|
77
|
+
return path.join(base, leaf);
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
/** Resolve the broker endpoint for the given session.
|
|
@@ -122,6 +133,10 @@ export async function prepareBrokerSocketDir(sockPath: string): Promise<void> {
|
|
|
122
133
|
export async function removeStaleBrokerSocket(
|
|
123
134
|
sockPath: string,
|
|
124
135
|
probeTimeoutMs: number = 250,
|
|
136
|
+
// Injectable for deterministic tests (the CrewBroker options.netModule
|
|
137
|
+
// precedent): a fake whose createConnection() never connects/errors pins
|
|
138
|
+
// the timeout branch without racing a real localhost connect.
|
|
139
|
+
netModule?: { createConnection: (path: string) => { once: (ev: string, fn: (arg?: unknown) => void) => void; destroy: () => void } },
|
|
125
140
|
): Promise<"removed" | "kept" | "absent" | "refused"> {
|
|
126
141
|
// Reject symlinks outright.
|
|
127
142
|
let st: Awaited<ReturnType<typeof fsp.lstat>>;
|
|
@@ -133,11 +148,29 @@ export async function removeStaleBrokerSocket(
|
|
|
133
148
|
throw e;
|
|
134
149
|
}
|
|
135
150
|
if (st.isSymbolicLink()) return "refused";
|
|
151
|
+
// A REGULAR FILE (or any non-socket entry) can never be a live endpoint —
|
|
152
|
+
// deterministically stale, remove it WITHOUT the connect probe. This is
|
|
153
|
+
// cross-platform: connect() to a non-socket path is undefined (Linux yields
|
|
154
|
+
// ECONNREFUSED, macOS yields a different errno), so relying on the probe
|
|
155
|
+
// here broke the CrewBroker stale-replace flow on BSD/macOS (CI 2026-10-08:
|
|
156
|
+
// crew-broker-stale-socket "refused/nonexistent stale endpoint is replaced
|
|
157
|
+
// once" + "recording-owned-path unlink only on stop()" — both pre-create a
|
|
158
|
+
// plain file as the corpse). Sockets keep the probe path below.
|
|
159
|
+
if (!st.isSocket()) {
|
|
160
|
+
try {
|
|
161
|
+
await fsp.unlink(sockPath);
|
|
162
|
+
return "removed";
|
|
163
|
+
} catch (e) {
|
|
164
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
165
|
+
if (code === "ENOENT") return "absent";
|
|
166
|
+
throw e;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
136
169
|
// Bound the probe: connect with a short timeout. If anything answers, treat as live.
|
|
137
|
-
const live = await new Promise<boolean>((resolve) => {
|
|
170
|
+
const live = await new Promise<boolean | "refused">((resolve) => {
|
|
138
171
|
let settled = false;
|
|
139
|
-
const sock = net.createConnection(sockPath);
|
|
140
|
-
const finish = (v: boolean) => {
|
|
172
|
+
const sock = (netModule ?? net).createConnection(sockPath);
|
|
173
|
+
const finish = (v: boolean | "refused") => {
|
|
141
174
|
if (settled) return;
|
|
142
175
|
settled = true;
|
|
143
176
|
try {
|
|
@@ -148,11 +181,18 @@ export async function removeStaleBrokerSocket(
|
|
|
148
181
|
resolve(v);
|
|
149
182
|
};
|
|
150
183
|
sock.once("connect", () => finish(true));
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
// F-BAT1 companion (2026-10-08): unlink is safe ONLY on a definitive
|
|
185
|
+
// ECONNREFUSED — the kernel rejects connect() on a socket whose
|
|
186
|
+
// listener is gone (owner died => fd closed => refused), so "refused"
|
|
187
|
+
// reliably means stale. A TIMEOUT (or any other error) means the
|
|
188
|
+
// listener is alive but slow to accept (busy event loop, backlog) —
|
|
189
|
+
// the previous code treated that as stale and UNLINKED A LIVE BROKER,
|
|
190
|
+
// leaving every later client with ENOENT forever.
|
|
191
|
+
sock.once("error", (err) => finish((err as NodeJS.ErrnoException).code === "ECONNREFUSED" ? "refused" : true));
|
|
192
|
+
setTimeout(() => finish(true), probeTimeoutMs);
|
|
153
193
|
});
|
|
154
|
-
if (live) return "kept";
|
|
155
|
-
// Stale: remove.
|
|
194
|
+
if (live !== "refused") return "kept";
|
|
195
|
+
// Stale (definitive ECONNREFUSED): remove.
|
|
156
196
|
try {
|
|
157
197
|
await fsp.unlink(sockPath);
|
|
158
198
|
return "removed";
|