pi-crew 0.9.46 → 0.9.48
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 +83 -0
- package/README.md +16 -2
- package/dist/build-meta.json +289 -164
- package/dist/index.mjs +1744 -2732
- package/dist/index.mjs.map +4 -4
- package/docs/decisions/2026-07-21-broker-phase4-default-on.md +77 -0
- package/docs/decisions/2026-07-21-broker-windows-perms.md +91 -0
- package/docs/decisions/2026-07-22-broker-phase4-gated-on.md +99 -0
- package/docs/decisions/README.md +3 -0
- package/docs/publishing.md +29 -0
- package/package.json +3 -1
- package/scripts/build-bundle.mjs +7 -0
- package/scripts/postinstall.mjs +60 -1
- package/scripts/pty_probe.py +174 -0
- package/skills/real-test-pi-crew/SKILL.md +659 -0
- package/src/config/config.ts +42 -1
- package/src/config/defaults.ts +45 -1
- package/src/config/types.ts +19 -0
- package/src/extension/register.ts +6 -1
- package/src/extension/registration/context-builder.ts +4 -0
- package/src/extension/registration/lifecycle-handlers.ts +166 -3
- package/src/extension/registration/registration-types.ts +9 -0
- package/src/prompt/prompt-runtime.ts +108 -0
- package/src/runtime/broker-issuer.ts +37 -0
- package/src/runtime/child-pi-spawn.ts +53 -0
- package/src/runtime/child-pi.ts +42 -11
- package/src/runtime/crew-broker-child.ts +88 -0
- package/src/runtime/crew-broker-client.ts +673 -0
- package/src/runtime/crew-broker-tokens.ts +84 -0
- package/src/runtime/crew-broker.ts +1276 -0
- package/src/runtime/dynamic-workflow-context.ts +7 -3
- package/src/runtime/dynamic-workflow-runner.ts +1 -1
- package/src/runtime/plan-templates.ts +8 -6
- package/src/schema/config-schema.ts +14 -0
- package/src/state/mailbox.ts +43 -0
- package/src/ui/key-utils.ts +42 -0
- package/src/ui/keybinding-map.ts +29 -3
- package/src/ui/run-dashboard.ts +28 -0
- package/src/ui/settings-overlay.ts +42 -22
- package/src/utils/ndjson.ts +115 -0
- package/src/utils/session-utils.ts +30 -0
- package/src/utils/socket-path.ts +127 -0
- package/workflows/default.workflow.md +1 -1
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crew-broker-client.ts — Child-side connector for the local broker.
|
|
3
|
+
* PHASE 0 skeleton (sub-task 0.3).
|
|
4
|
+
*
|
|
5
|
+
* - Connects only on the first request/subscribe.
|
|
6
|
+
* - Sends `hello` first; any other method before ack is a protocol error.
|
|
7
|
+
* - Bounded backoff (50/100/200/400/800 ms + jitter, max 4 attempts).
|
|
8
|
+
* After exhaustion, no hot reconnect loop is scheduled. The only way back
|
|
9
|
+
* to the live path is an explicit `reconnect()` (or a new worker lifecycle).
|
|
10
|
+
* - On ANY connect/auth/timeout/close error, transitions to `fallback` mode
|
|
11
|
+
* and never lets an exception escape `request()` / `subscribe()`. The
|
|
12
|
+
* caller can read `client.mode` or the `{ok:false, fallback:true}` result
|
|
13
|
+
* and continue using today's file-based paths.
|
|
14
|
+
* - `close()` removes all listeners and pending requests.
|
|
15
|
+
* - All diagnostic strings pass through `redactSecretString` from
|
|
16
|
+
* `src/utils/redaction.ts`. Token and payload bytes are never logged.
|
|
17
|
+
*
|
|
18
|
+
* No process management. No children. No socket listening. Connect-only.
|
|
19
|
+
*
|
|
20
|
+
* See `reports/inter-pi-broker-impl-plan-2026-07-21.md` §"0.3" for the
|
|
21
|
+
* full contract and acceptance criteria.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import * as net from "node:net";
|
|
26
|
+
|
|
27
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
28
|
+
import { BrokerError, encodeBrokerFrame, NdjsonDecoder } from "../utils/ndjson.ts";
|
|
29
|
+
import { redactSecretString } from "../utils/redaction.ts";
|
|
30
|
+
|
|
31
|
+
/** Shape of an unsolicited event frame pushed by the broker (mailbox.message,
|
|
32
|
+
* team.event, etc.). */
|
|
33
|
+
export interface BrokerEventFrame {
|
|
34
|
+
event: string;
|
|
35
|
+
data: unknown;
|
|
36
|
+
seq?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Protocol version negotiated at hello. Must match the broker. */
|
|
40
|
+
const BROKER_PROTOCOL = 1;
|
|
41
|
+
|
|
42
|
+
/** Per-attempt timeout for connect + hello. */
|
|
43
|
+
const CONNECT_HELLO_TIMEOUT_MS = 5_000;
|
|
44
|
+
|
|
45
|
+
/** Bounded backoff schedule (ms). At most 4 attempts means 3 retries after
|
|
46
|
+
* the first failure. Jitter is ±25%. */
|
|
47
|
+
const BACKOFF_SCHEDULE_MS: readonly number[] = [50, 100, 200, 400, 800] as const;
|
|
48
|
+
/** Hard cap on the number of attempts. 4 total = 1 initial + 3 retries. */
|
|
49
|
+
const MAX_ATTEMPTS = 4;
|
|
50
|
+
|
|
51
|
+
export type BrokerClientMode = "unstarted" | "connected" | "fallback";
|
|
52
|
+
|
|
53
|
+
export type BrokerClientResult<T> = { ok: true; value: T } | { ok: false; fallback: true; errorCode?: string; error?: Error };
|
|
54
|
+
|
|
55
|
+
export interface CrewBrokerClientOptions {
|
|
56
|
+
runId: string;
|
|
57
|
+
taskId: string;
|
|
58
|
+
/** Pre-resolved socket path. If absent, the client is permanently
|
|
59
|
+
* fallback (caller forgot to wire spawn context). */
|
|
60
|
+
socketPath?: string;
|
|
61
|
+
/** Per-run token. If absent, the client is permanently fallback. */
|
|
62
|
+
token?: string;
|
|
63
|
+
/** Override `process.env` (used by tests). */
|
|
64
|
+
env?: NodeJS.ProcessEnv;
|
|
65
|
+
/** Test seam: override the `net` module. */
|
|
66
|
+
netModule?: typeof net;
|
|
67
|
+
/** Test seam: clock for backoff scheduling. */
|
|
68
|
+
now?: () => number;
|
|
69
|
+
/** Test seam: timer factory. */
|
|
70
|
+
setTimeoutFn?: (cb: () => void, ms: number) => NodeJS.Timeout;
|
|
71
|
+
/** Test seam: clear timer. */
|
|
72
|
+
clearTimeoutFn?: (timer: NodeJS.Timeout) => void;
|
|
73
|
+
/** Test seam: random jitter source (returns a multiplier in [0.75, 1.25]). */
|
|
74
|
+
jitter?: () => number;
|
|
75
|
+
/** Handler for unsolicited event frames pushed by the broker after hello
|
|
76
|
+
* (e.g. `mailbox.message`, `team.event`). When omitted, event frames are
|
|
77
|
+
* silently ignored (Phase 0 behavior). Never throws into the socket loop. */
|
|
78
|
+
onEvent?: (event: BrokerEventFrame) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface PendingRequest {
|
|
82
|
+
id: string;
|
|
83
|
+
method: string;
|
|
84
|
+
resolve: (value: unknown) => void;
|
|
85
|
+
reject: (err: Error) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class CrewBrokerClient {
|
|
89
|
+
private readonly options: Required<Pick<CrewBrokerClientOptions, "runId" | "taskId">> &
|
|
90
|
+
Pick<
|
|
91
|
+
CrewBrokerClientOptions,
|
|
92
|
+
"socketPath" | "token" | "env" | "netModule" | "now" | "setTimeoutFn" | "clearTimeoutFn" | "jitter" | "onEvent"
|
|
93
|
+
>;
|
|
94
|
+
private _mode: BrokerClientMode = "unstarted";
|
|
95
|
+
private socket: net.Socket | null = null;
|
|
96
|
+
private decoder: NdjsonDecoder | null = null;
|
|
97
|
+
private readonly pending = new Map<string, PendingRequest>();
|
|
98
|
+
private attempts = 0;
|
|
99
|
+
/** Listeners attached to the current socket; kept for explicit close(). */
|
|
100
|
+
private readonly socketListeners: Array<{
|
|
101
|
+
event: string;
|
|
102
|
+
listener: (...args: unknown[]) => void;
|
|
103
|
+
}> = [];
|
|
104
|
+
/** Set in close(); gates reconnect attempts and backoff loops. */
|
|
105
|
+
private closed = false;
|
|
106
|
+
/** Handle to the in-flight backoff timer so close() can cancel it. */
|
|
107
|
+
private backoffTimer: NodeJS.Timeout | null = null;
|
|
108
|
+
/** Resolver for the in-flight backoff await so close() can unblock it.
|
|
109
|
+
* Without this, cancelling the timer leaves the connectAndHello await
|
|
110
|
+
* hanging forever (resolve() never fires). */
|
|
111
|
+
private backoffResolver: (() => void) | null = null;
|
|
112
|
+
|
|
113
|
+
constructor(options: CrewBrokerClientOptions) {
|
|
114
|
+
if (!options || typeof options !== "object") {
|
|
115
|
+
throw new Error("CrewBrokerClient: options is required");
|
|
116
|
+
}
|
|
117
|
+
if (typeof options.runId !== "string" || options.runId.length === 0) {
|
|
118
|
+
throw new Error("CrewBrokerClient: runId must be a non-empty string");
|
|
119
|
+
}
|
|
120
|
+
if (typeof options.taskId !== "string" || options.taskId.length === 0) {
|
|
121
|
+
throw new Error("CrewBrokerClient: taskId must be a non-empty string");
|
|
122
|
+
}
|
|
123
|
+
this.options = {
|
|
124
|
+
runId: options.runId,
|
|
125
|
+
taskId: options.taskId,
|
|
126
|
+
socketPath: options.socketPath,
|
|
127
|
+
token: options.token,
|
|
128
|
+
env: options.env,
|
|
129
|
+
netModule: options.netModule,
|
|
130
|
+
now: options.now,
|
|
131
|
+
setTimeoutFn: options.setTimeoutFn,
|
|
132
|
+
clearTimeoutFn: options.clearTimeoutFn,
|
|
133
|
+
jitter: options.jitter,
|
|
134
|
+
onEvent: options.onEvent,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get mode(): BrokerClientMode {
|
|
139
|
+
return this._mode;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Diagnostic: number of currently-pending requests. */
|
|
143
|
+
get pendingCount(): number {
|
|
144
|
+
return this.pending.size;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Send a request. Returns:
|
|
149
|
+
* - `{ok:true, value}` on success.
|
|
150
|
+
* - `{ok:false, fallback:true, errorCode?}` on any connect/auth/
|
|
151
|
+
* timeout/close/protocol error. The client transitions to fallback
|
|
152
|
+
* for its lifetime; only `reconnect()` may move it back to "unstarted".
|
|
153
|
+
*
|
|
154
|
+
* Never throws. The caller can continue using file-based fallback paths
|
|
155
|
+
* without unwrapping anything.
|
|
156
|
+
*/
|
|
157
|
+
async request<T = unknown>(method: string, params: unknown): Promise<BrokerClientResult<T>> {
|
|
158
|
+
if (this._mode === "fallback") {
|
|
159
|
+
return { ok: false, fallback: true, errorCode: "fallback-sticky" };
|
|
160
|
+
}
|
|
161
|
+
if (typeof method !== "string" || method.length === 0) {
|
|
162
|
+
return { ok: false, fallback: true, errorCode: "bad-method" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Lazy connect + hello on first use.
|
|
166
|
+
if (this._mode === "unstarted" || !this.socket) {
|
|
167
|
+
const connectResult = await this.connectAndHello();
|
|
168
|
+
if (!connectResult.ok) {
|
|
169
|
+
return connectResult;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!this.socket) {
|
|
174
|
+
return { ok: false, fallback: true, errorCode: "no-socket" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Send the request. Send a frame FIRST so the server's hello gate
|
|
178
|
+
// cannot reject it as "method other than hello".
|
|
179
|
+
const id = `r-${randomUUID()}`;
|
|
180
|
+
const promise = new Promise<unknown>((resolve, reject) => {
|
|
181
|
+
this.pending.set(id, { id, method, resolve, reject });
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
const frame = encodeBrokerFrame({ id, method, params });
|
|
185
|
+
// Write may emit EPIPE etc. We don't await drain here — the response
|
|
186
|
+
// promise handles the rest.
|
|
187
|
+
this.socket.write(frame);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// encodeBrokerFrame can throw on oversize. The socket is still
|
|
190
|
+
// alive but the request is bad.
|
|
191
|
+
this.pending.delete(id);
|
|
192
|
+
return { ok: false, fallback: true, errorCode: "encode-failed" };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const value = await promise;
|
|
197
|
+
// Detect the tagged broker-error envelope: per-request errors
|
|
198
|
+
// (bad-params, no-manifest, wait-timeout, etc.) resolve with
|
|
199
|
+
// `{__brokerError:true, code, message}` and do NOT enter fallback.
|
|
200
|
+
if (value && typeof value === "object" && (value as { __brokerError?: boolean }).__brokerError === true) {
|
|
201
|
+
const e = value as { code: string; message: string };
|
|
202
|
+
return { ok: false, fallback: true, errorCode: e.code };
|
|
203
|
+
}
|
|
204
|
+
return { ok: true, value: value as T };
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// Reject from pending handlers → the typed error code.
|
|
207
|
+
const code = err instanceof BrokerError ? err.code : "request-failed";
|
|
208
|
+
this.enterFallbackOnce(code, err);
|
|
209
|
+
return { ok: false, fallback: true, errorCode: code };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Subscribe to a per-run event stream. Phase 0 does not implement
|
|
215
|
+
* events.since — the subscription returns a no-op unsubscribe and
|
|
216
|
+
* transitions to fallback if the connection is lost. The caller can
|
|
217
|
+
* continue using the file poll path.
|
|
218
|
+
*/
|
|
219
|
+
subscribe(options: { runId: string; sinceSeq: number; onEvent: (event: unknown) => void }): () => void {
|
|
220
|
+
// Phase 0: subscription is a typed not-implemented. We register a
|
|
221
|
+
// no-op unsubscribe so the caller can call it without errors.
|
|
222
|
+
void options;
|
|
223
|
+
return () => {
|
|
224
|
+
/* no-op */
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Explicit reconnect. Resets `mode` to `unstarted` and clears the
|
|
230
|
+
* backoff counter. Returns true if a fresh connection succeeded,
|
|
231
|
+
* false otherwise (mode becomes `fallback` on failure). Use this
|
|
232
|
+
* after a known broker restart.
|
|
233
|
+
*/
|
|
234
|
+
async reconnect(): Promise<boolean> {
|
|
235
|
+
// Close the current socket if any.
|
|
236
|
+
this.teardownSocket();
|
|
237
|
+
this.attempts = 0;
|
|
238
|
+
this._mode = "unstarted";
|
|
239
|
+
const res = await this.request("ping", null);
|
|
240
|
+
return res.ok;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Close the client. Removes all socket listeners, rejects all pending
|
|
245
|
+
* requests with a clean fallback result, and transitions to fallback
|
|
246
|
+
* (so a subsequent request() returns a typed error rather than
|
|
247
|
+
* reconnecting in the background).
|
|
248
|
+
*/
|
|
249
|
+
async close(): Promise<void> {
|
|
250
|
+
// Set the closed flag FIRST so any backoff-loop iteration that
|
|
251
|
+
// resumes after teardownSocket (e.g. after a queued backoff timer
|
|
252
|
+
// fires) sees it and returns immediately with a typed error.
|
|
253
|
+
this.closed = true;
|
|
254
|
+
// Cancel any queued backoff timer. Without this, the in-flight
|
|
255
|
+
// connectAndHello would resume its retry loop after close() and
|
|
256
|
+
// create new sockets against a torn-down client.
|
|
257
|
+
if (this.backoffTimer !== null) {
|
|
258
|
+
try {
|
|
259
|
+
(this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t)))(this.backoffTimer);
|
|
260
|
+
} catch {
|
|
261
|
+
/* ignore — best-effort cancel */
|
|
262
|
+
}
|
|
263
|
+
this.backoffTimer = null;
|
|
264
|
+
}
|
|
265
|
+
// Unblock the in-flight backoff await. Cancelling the timer alone
|
|
266
|
+
// leaves the connectAndHello Promise stuck on resolve(); this lets
|
|
267
|
+
// the loop unblock, observe this.closed, and return typed 'closed'.
|
|
268
|
+
if (this.backoffResolver !== null) {
|
|
269
|
+
const resolveBackoff = this.backoffResolver;
|
|
270
|
+
this.backoffResolver = null;
|
|
271
|
+
try {
|
|
272
|
+
resolveBackoff();
|
|
273
|
+
} catch {
|
|
274
|
+
/* ignore — best-effort unblock */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
this.teardownSocket();
|
|
278
|
+
// Reject every pending request. Using reject (not resolve) so the
|
|
279
|
+
// request() catch block kicks in and returns {ok:false, fallback:true}.
|
|
280
|
+
// Resolving with undefined would have made request() return
|
|
281
|
+
// {ok:true, value:undefined}, which is misleading.
|
|
282
|
+
for (const [, p] of this.pending) {
|
|
283
|
+
p.reject(new BrokerError("close", "client closed"));
|
|
284
|
+
}
|
|
285
|
+
this.pending.clear();
|
|
286
|
+
this._mode = "fallback";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ------------------------------------------------------------------------
|
|
290
|
+
// Connect + hello with bounded backoff
|
|
291
|
+
// ------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
private async connectAndHello(): Promise<BrokerClientResult<true>> {
|
|
294
|
+
if (!this.options.socketPath || !this.options.token) {
|
|
295
|
+
this.enterFallbackOnce("missing-credentials", new Error("socket or token not provided"));
|
|
296
|
+
return { ok: false, fallback: true, errorCode: "missing-credentials" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const netModule = this.options.netModule ?? net;
|
|
300
|
+
const setTimeoutFn = this.options.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
|
|
301
|
+
const clearTimeoutFn = this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t));
|
|
302
|
+
const jitter = this.options.jitter ?? (() => 0.75 + Math.random() * 0.5); // [0.75, 1.25]
|
|
303
|
+
|
|
304
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
305
|
+
// Closed gate at the top of each iteration: catches the case
|
|
306
|
+
// where close() was called while we were awaiting attemptHello()
|
|
307
|
+
// or while a previous backoff timer was firing. After close(),
|
|
308
|
+
// the client must not open any new sockets.
|
|
309
|
+
if (this.closed) {
|
|
310
|
+
return { ok: false, fallback: true, errorCode: "closed" };
|
|
311
|
+
}
|
|
312
|
+
this.attempts = attempt + 1;
|
|
313
|
+
const result = await this.attemptHello(netModule);
|
|
314
|
+
if (result.ok) {
|
|
315
|
+
// Belt-and-suspenders: a successful hello can still race
|
|
316
|
+
// with close() (e.g. close() called between the hello ack
|
|
317
|
+
// being processed and this return). If we are now closed,
|
|
318
|
+
// tear down the socket we just opened and return the typed
|
|
319
|
+
// error rather than handing back a connected client.
|
|
320
|
+
if (this.closed) {
|
|
321
|
+
this.teardownSocket();
|
|
322
|
+
this._mode = "fallback";
|
|
323
|
+
return { ok: false, fallback: true, errorCode: "closed" };
|
|
324
|
+
}
|
|
325
|
+
this._mode = "connected";
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
// Failure. If we are out of attempts, give up.
|
|
329
|
+
if (attempt + 1 >= MAX_ATTEMPTS) {
|
|
330
|
+
this.enterFallbackOnce(result.errorCode ?? "connect-failed", result.error);
|
|
331
|
+
return { ok: false, fallback: true, errorCode: result.errorCode ?? "connect-failed" };
|
|
332
|
+
}
|
|
333
|
+
// Wait the next backoff slot (with jitter), unless this was a
|
|
334
|
+
// auth failure (no point retrying — server rejected hello).
|
|
335
|
+
if (result.errorCode === "auth") {
|
|
336
|
+
this.enterFallbackOnce("auth", result.error);
|
|
337
|
+
return { ok: false, fallback: true, errorCode: "auth" };
|
|
338
|
+
}
|
|
339
|
+
const base = BACKOFF_SCHEDULE_MS[attempt] ?? 800;
|
|
340
|
+
const delay = Math.max(1, Math.floor(base * jitter()));
|
|
341
|
+
await new Promise<void>((resolve) => {
|
|
342
|
+
// Capture the resolver so close() can unblock the await
|
|
343
|
+
// even after cancelling the timer (timer cancellation alone
|
|
344
|
+
// leaves resolve() unfired). We also clear the resolver
|
|
345
|
+
// when the timer fires so a stale handle never leaks.
|
|
346
|
+
this.backoffResolver = resolve;
|
|
347
|
+
const t = setTimeoutFn(() => {
|
|
348
|
+
if (this.backoffResolver === resolve) this.backoffResolver = null;
|
|
349
|
+
this.backoffTimer = null;
|
|
350
|
+
resolve();
|
|
351
|
+
}, delay);
|
|
352
|
+
this.backoffTimer = t;
|
|
353
|
+
if (t && typeof (t as { unref?: () => void }).unref === "function") {
|
|
354
|
+
(t as { unref?: () => void }).unref?.();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
// Unused but kept for symmetry with the future test seam.
|
|
358
|
+
void clearTimeoutFn;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Defensive: the loop always returns.
|
|
362
|
+
this.enterFallbackOnce("exhausted", new Error("backoff exhausted"));
|
|
363
|
+
return { ok: false, fallback: true, errorCode: "exhausted" };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private attemptHello(netModule: typeof net): Promise<BrokerClientResult<true>> {
|
|
367
|
+
return new Promise<BrokerClientResult<true>>((resolve) => {
|
|
368
|
+
// Belt-and-suspenders closed gate: the backoff loop in
|
|
369
|
+
// connectAndHello already checks this.closed, but attemptHello
|
|
370
|
+
// can also be entered via reconnect() after the flag is set.
|
|
371
|
+
if (this.closed) {
|
|
372
|
+
resolve({ ok: false, fallback: true, errorCode: "closed" });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const sock = netModule.createConnection(this.options.socketPath!);
|
|
376
|
+
let settled = false;
|
|
377
|
+
const finish = (v: BrokerClientResult<true>) => {
|
|
378
|
+
if (settled) return;
|
|
379
|
+
settled = true;
|
|
380
|
+
// Cancel the per-attempt deadline timer so it cannot fire after
|
|
381
|
+
// a successful handshake and destroy the healthy connection.
|
|
382
|
+
try {
|
|
383
|
+
clearTimeoutFn(timer);
|
|
384
|
+
} catch {
|
|
385
|
+
/* ignore */
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
sock.removeAllListeners();
|
|
389
|
+
} catch {
|
|
390
|
+
/* ignore */
|
|
391
|
+
}
|
|
392
|
+
resolve(v);
|
|
393
|
+
};
|
|
394
|
+
this.socket = sock;
|
|
395
|
+
this.decoder = new NdjsonDecoder();
|
|
396
|
+
|
|
397
|
+
// Per-attempt deadline.
|
|
398
|
+
const setTimeoutFn = this.options.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
|
|
399
|
+
const clearTimeoutFn = this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t));
|
|
400
|
+
const timer = setTimeoutFn(() => {
|
|
401
|
+
finish({ ok: false, fallback: true, errorCode: "timeout" });
|
|
402
|
+
try {
|
|
403
|
+
sock.destroy();
|
|
404
|
+
} catch {
|
|
405
|
+
/* ignore */
|
|
406
|
+
}
|
|
407
|
+
}, CONNECT_HELLO_TIMEOUT_MS);
|
|
408
|
+
if (timer && typeof (timer as { unref?: () => void }).unref === "function") {
|
|
409
|
+
(timer as { unref?: () => void }).unref?.();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const onConnect = () => {
|
|
413
|
+
// Send hello immediately on connect.
|
|
414
|
+
const hello = {
|
|
415
|
+
id: `hello-${randomUUID()}`,
|
|
416
|
+
method: "hello",
|
|
417
|
+
params: {
|
|
418
|
+
protocol: BROKER_PROTOCOL,
|
|
419
|
+
runId: this.options.runId,
|
|
420
|
+
taskId: this.options.taskId,
|
|
421
|
+
token: this.options.token,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
try {
|
|
425
|
+
sock.write(encodeBrokerFrame(hello));
|
|
426
|
+
} catch (err) {
|
|
427
|
+
finish({ ok: false, fallback: true, errorCode: "encode-failed" });
|
|
428
|
+
try {
|
|
429
|
+
sock.destroy();
|
|
430
|
+
} catch {
|
|
431
|
+
/* ignore */
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const onData = (chunk: Buffer | string) => {
|
|
437
|
+
if (!this.decoder) return;
|
|
438
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
439
|
+
let frames: unknown[];
|
|
440
|
+
try {
|
|
441
|
+
frames = this.decoder.push(buf);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
const code = err instanceof BrokerError ? err.code : "protocol";
|
|
444
|
+
finish({ ok: false, fallback: true, errorCode: code });
|
|
445
|
+
try {
|
|
446
|
+
sock.destroy();
|
|
447
|
+
} catch {
|
|
448
|
+
/* ignore */
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
for (const frame of frames) {
|
|
453
|
+
if (!isResponseObject(frame)) {
|
|
454
|
+
finish({ ok: false, fallback: true, errorCode: "protocol" });
|
|
455
|
+
try {
|
|
456
|
+
sock.destroy();
|
|
457
|
+
} catch {
|
|
458
|
+
/* ignore */
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
// Hello ack: must include `result.ok === true` and matching id.
|
|
463
|
+
if (frame.id && frame.id.startsWith("hello-")) {
|
|
464
|
+
if (frame.error) {
|
|
465
|
+
const code = (frame.error as { code?: string }).code ?? "auth";
|
|
466
|
+
finish({ ok: false, fallback: true, errorCode: code });
|
|
467
|
+
try {
|
|
468
|
+
sock.destroy();
|
|
469
|
+
} catch {
|
|
470
|
+
/* ignore */
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// Hello succeeded — wire up the response handler.
|
|
475
|
+
finish({ ok: true, value: true });
|
|
476
|
+
this.wireSocketHandlers(sock);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// Otherwise it's a response to a request we sent.
|
|
480
|
+
const pending = this.pending.get(frame.id);
|
|
481
|
+
if (pending) {
|
|
482
|
+
this.pending.delete(frame.id);
|
|
483
|
+
if (frame.error) {
|
|
484
|
+
const code = (frame.error as { code?: string }).code ?? "request-failed";
|
|
485
|
+
pending.reject(new BrokerError(code as never, "request error"));
|
|
486
|
+
} else {
|
|
487
|
+
pending.resolve(frame.result);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
const onError = (err: Error & { code?: string }) => {
|
|
493
|
+
// ECONNREFUSED, EMFILE, ENOSPC, EPERM, ENOENT, EPIPE, etc.
|
|
494
|
+
const code = err.code ?? "connect-failed";
|
|
495
|
+
finish({ ok: false, fallback: true, errorCode: code });
|
|
496
|
+
};
|
|
497
|
+
const onClose = () => {
|
|
498
|
+
finish({ ok: false, fallback: true, errorCode: "close" });
|
|
499
|
+
};
|
|
500
|
+
sock.once("connect", onConnect);
|
|
501
|
+
sock.on("data", onData);
|
|
502
|
+
sock.once("error", onError);
|
|
503
|
+
sock.once("close", onClose);
|
|
504
|
+
// Keep timer for explicit cleanup.
|
|
505
|
+
this.socketListeners.push(
|
|
506
|
+
{ event: "connect", listener: onConnect as (...args: unknown[]) => void },
|
|
507
|
+
{ event: "data", listener: onData as (...args: unknown[]) => void },
|
|
508
|
+
{ event: "error", listener: onError as (...args: unknown[]) => void },
|
|
509
|
+
{ event: "close", listener: onClose as (...args: unknown[]) => void },
|
|
510
|
+
);
|
|
511
|
+
void clearTimeoutFn;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private wireSocketHandlers(sock: net.Socket): void {
|
|
516
|
+
// After hello, attach persistent handlers. The once-handlers from
|
|
517
|
+
// attemptHello are already removed in finish().
|
|
518
|
+
// Unref the socket so a still-open broker connection never keeps a
|
|
519
|
+
// finished child worker's event loop alive (mirrors the file-poll
|
|
520
|
+
// steering timer's unref). Pushes still arrive while the worker runs.
|
|
521
|
+
(sock as { unref?: () => void }).unref?.();
|
|
522
|
+
const onData = (chunk: Buffer | string) => {
|
|
523
|
+
if (!this.decoder) return;
|
|
524
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
525
|
+
let frames: unknown[];
|
|
526
|
+
try {
|
|
527
|
+
frames = this.decoder.push(buf);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
// Malformed frame — fall back.
|
|
530
|
+
this.enterFallbackOnce("protocol", err);
|
|
531
|
+
try {
|
|
532
|
+
sock.destroy();
|
|
533
|
+
} catch {
|
|
534
|
+
/* ignore */
|
|
535
|
+
}
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
for (const frame of frames) {
|
|
539
|
+
// Distinguish unsolicited event frames (e.g. mailbox.message
|
|
540
|
+
// pushed by the broker's observer-driven live fanout) from
|
|
541
|
+
// request-response frames. Events have `event` + `data` (+ optional
|
|
542
|
+
// `seq`); responses have `id` + `result` or `error`. Event frames
|
|
543
|
+
// are routed to the optional `onEvent` handler; a missing handler
|
|
544
|
+
// silently drops them (the durable path remains authoritative).
|
|
545
|
+
if (isEventFrame(frame)) {
|
|
546
|
+
const handler = this.options.onEvent;
|
|
547
|
+
if (handler) {
|
|
548
|
+
const ev = frame as Record<string, unknown>;
|
|
549
|
+
try {
|
|
550
|
+
handler({ event: ev.event as string, data: ev.data, seq: typeof ev.seq === "number" ? ev.seq : undefined });
|
|
551
|
+
} catch {
|
|
552
|
+
/* an onEvent handler fault must never break the socket loop */
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (!isResponseObject(frame)) {
|
|
558
|
+
this.enterFallbackOnce("protocol", new Error("malformed response"));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const pending = this.pending.get(frame.id);
|
|
562
|
+
if (!pending) continue;
|
|
563
|
+
this.pending.delete(frame.id);
|
|
564
|
+
if (frame.error) {
|
|
565
|
+
// Per-request error response from the broker: NOT a protocol
|
|
566
|
+
// error, NOT a fallback condition. The broker explicitly rejected
|
|
567
|
+
// this call (e.g. bad-params, no-manifest, wait-timeout).
|
|
568
|
+
// Resolve with a tagged envelope so request() can return
|
|
569
|
+
// ok:false WITHOUT triggering fallback mode.
|
|
570
|
+
const errCode = (frame.error as { code?: string }).code ?? "request-failed";
|
|
571
|
+
pending.resolve({
|
|
572
|
+
__brokerError: true,
|
|
573
|
+
code: errCode,
|
|
574
|
+
message: (frame.error as { message?: string }).message ?? "",
|
|
575
|
+
} as never);
|
|
576
|
+
} else {
|
|
577
|
+
pending.resolve(frame.result);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
const onError = (err: Error & { code?: string }) => {
|
|
582
|
+
this.enterFallbackOnce(err.code ?? "socket-error", err);
|
|
583
|
+
};
|
|
584
|
+
const onClose = () => {
|
|
585
|
+
// Reject every pending with a close error so request() returns
|
|
586
|
+
// {ok:false, fallback:true}. (Resolving with undefined previously
|
|
587
|
+
// returned {ok:true, value:undefined}, which is misleading.)
|
|
588
|
+
for (const [, p] of this.pending) {
|
|
589
|
+
p.reject(new BrokerError("close", "socket closed"));
|
|
590
|
+
}
|
|
591
|
+
this.pending.clear();
|
|
592
|
+
this.enterFallbackOnce("close", new Error("socket closed"));
|
|
593
|
+
};
|
|
594
|
+
sock.on("data", onData);
|
|
595
|
+
sock.once("error", onError);
|
|
596
|
+
sock.once("close", onClose);
|
|
597
|
+
this.socketListeners.push(
|
|
598
|
+
{ event: "data", listener: onData as (...args: unknown[]) => void },
|
|
599
|
+
{ event: "error", listener: onError as (...args: unknown[]) => void },
|
|
600
|
+
{ event: "close", listener: onClose as (...args: unknown[]) => void },
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private teardownSocket(): void {
|
|
605
|
+
if (!this.socket) {
|
|
606
|
+
this.socket = null;
|
|
607
|
+
this.decoder = null;
|
|
608
|
+
this.socketListeners.length = 0;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const sock = this.socket;
|
|
612
|
+
for (const l of this.socketListeners) {
|
|
613
|
+
try {
|
|
614
|
+
sock.removeListener(l.event, l.listener);
|
|
615
|
+
} catch {
|
|
616
|
+
/* ignore */
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
this.socketListeners.length = 0;
|
|
620
|
+
try {
|
|
621
|
+
sock.destroy();
|
|
622
|
+
} catch {
|
|
623
|
+
/* ignore */
|
|
624
|
+
}
|
|
625
|
+
this.socket = null;
|
|
626
|
+
this.decoder = null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private enterFallbackOnce(code: string, cause?: unknown): void {
|
|
630
|
+
if (this._mode === "fallback") return;
|
|
631
|
+
this._mode = "fallback";
|
|
632
|
+
// Log exactly ONE diagnostic per transition. We redact any cause string
|
|
633
|
+
// so token-like bytes cannot leak via the error message.
|
|
634
|
+
const safeCause = cause instanceof Error ? cause.message : cause ? String(cause) : undefined;
|
|
635
|
+
const safe = safeCause ? redactSecretString(safeCause) : undefined;
|
|
636
|
+
logInternalError(
|
|
637
|
+
"crew-broker.client.fallback",
|
|
638
|
+
new Error(`fallback (${code})`),
|
|
639
|
+
`runId=${this.options.runId}${safe ? ` cause=${safe}` : ""}`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ============================================================================
|
|
645
|
+
// Type guards (no `any`)
|
|
646
|
+
// ============================================================================
|
|
647
|
+
|
|
648
|
+
function isResponseObject(value: unknown): value is { id: string; result?: unknown; error?: { code?: string; message?: string } } {
|
|
649
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
650
|
+
const v = value as Record<string, unknown>;
|
|
651
|
+
if (typeof v.id !== "string" || v.id.length === 0 || v.id.length > 256) return false;
|
|
652
|
+
if ("error" in v) {
|
|
653
|
+
const e = v.error;
|
|
654
|
+
if (!e || typeof e !== "object" || Array.isArray(e)) return false;
|
|
655
|
+
const errObj = e as Record<string, unknown>;
|
|
656
|
+
if (typeof errObj.code !== "string") return false;
|
|
657
|
+
if (typeof errObj.message !== "string") return false;
|
|
658
|
+
}
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Detect an unsolicited event frame (no `id`, has `event` + `data`).
|
|
664
|
+
* Event frames are pushed by the broker's live-fanout (e.g. mailbox.message)
|
|
665
|
+
* and must NOT be treated as request responses — otherwise the client's
|
|
666
|
+
* strict response validator would fall back on every push.
|
|
667
|
+
*/
|
|
668
|
+
function isEventFrame(value: unknown): boolean {
|
|
669
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
670
|
+
const v = value as Record<string, unknown>;
|
|
671
|
+
if (typeof v.event !== "string" || v.event.length === 0) return false;
|
|
672
|
+
return "data" in v;
|
|
673
|
+
}
|