@aeondave/pi-persona 1.17.3 → 1.17.5
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/AGENTS.md +57 -36
- package/CHANGELOG.md +106 -1
- package/README.md +32 -16
- package/docs/ARCHITECTURE.md +199 -48
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/docs/STRATEGIES.md +6 -0
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +33 -23
- package/src/bus/broker/client.ts +151 -30
- package/src/bus/broker/host.ts +130 -11
- package/src/bus/broker/messages.ts +35 -5
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +118 -40
- package/src/bus/peers.ts +23 -6
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/companion.ts +64 -0
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +100 -20
- package/src/engine/async.ts +53 -11
- package/src/engine/child.ts +11 -2
- package/src/engine/inproc.ts +98 -47
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/companion.ts +38 -0
- package/src/extension/engine.ts +40 -29
- package/src/extension/hooks.ts +954 -931
- package/src/extension/identity.ts +41 -75
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +138 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2922 -2849
- package/src/loader.ts +11 -3
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +125 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -19
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +6 -7
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +23 -13
- package/src/ui/agent-tree.ts +67 -6
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
- package/src/ui/strategy-leg.ts +40 -0
package/src/bus/broker/client.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Broker client — the child-side half of the relay (spec B3/B6): connects to the
|
|
3
3
|
* session-scoped endpoint (`paths.ts`), performs the `register`/`registered` handshake,
|
|
4
4
|
* and exposes the same shape a local `contact_supervisor`/`contact_peer` binding needs —
|
|
5
|
-
* `send` (
|
|
5
|
+
* `send` (one-way with a bounded host-admission receipt), `ask` (a blocking `send{expectsReply}` correlated to a
|
|
6
6
|
* `replied{askId}`, 10-minute cap, mirrors `bus.ask`, and sends `cancel{msgId}` when
|
|
7
7
|
* aborted), `reply` (answers a host-issued
|
|
8
8
|
* blocking `deliver`), `list` (the engine-scoped peer roster, B7), and `onDeliver`/`onSteer`
|
|
@@ -14,19 +14,22 @@
|
|
|
14
14
|
* exponential backoff (250ms–5s) on the INITIAL connect only (comtac's presence tracking,
|
|
15
15
|
* roster cache, and endless reconnect-after-disconnect "storm" are dropped — YAGNI here,
|
|
16
16
|
* see spec B6/Task 4 brief). An unexpected disconnect (host died
|
|
17
|
-
* mid-run) is NOT auto-reconnected: sends
|
|
18
|
-
* reject promptly and clean up their timers
|
|
17
|
+
* mid-run) is NOT auto-reconnected: sends resolve false, while in-flight asks/lists
|
|
18
|
+
* reject promptly and clean up their timers. Authenticated result readers are stricter:
|
|
19
|
+
* one connect attempt, bounded handshake/reads, closed failure replies, never mute success.
|
|
19
20
|
*
|
|
20
21
|
* Pure over an injected socket factory (`net`), mirroring `host.ts`'s `net` param — same
|
|
21
22
|
* transport-injection seam, opposite side of the same fake in tests.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
25
26
|
import nodeNet from "node:net";
|
|
26
27
|
import type net from "node:net";
|
|
27
28
|
|
|
28
29
|
import { createFrameReader, encodeFrame } from "./framing.ts";
|
|
29
|
-
import { type Frame, isFrame } from "./messages.ts";
|
|
30
|
+
import { type Frame, isFrame, isReaderToken } from "./messages.ts";
|
|
31
|
+
import { isReadRequest, isReadReplyFor, readFailure, RESULT_READ_MAX_PENDING, RESULT_READ_TIMEOUT_MS,
|
|
32
|
+
type ReadReply, type ResultReadRequest } from "../result-reader.ts";
|
|
30
33
|
import type { MsgKind } from "../inproc.ts";
|
|
31
34
|
|
|
32
35
|
export interface DeliverEvent {
|
|
@@ -39,10 +42,13 @@ export interface DeliverEvent {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export interface BrokerClient {
|
|
42
|
-
/**
|
|
45
|
+
/** Register once. Ordinary comms back off on initial connect; reader grants never retry. */
|
|
43
46
|
register(): Promise<void>;
|
|
44
|
-
/**
|
|
45
|
-
|
|
47
|
+
/** Authenticated bounded read. Failures are fixed closed replies; never a mute success. */
|
|
48
|
+
read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply>;
|
|
49
|
+
/** One-way message; true only after host bus admission, never a recipient answer.
|
|
50
|
+
* Missing receipt, cancellation, saturation or disconnect resolve false without replay. */
|
|
51
|
+
send(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<boolean>;
|
|
46
52
|
/** Blocking request; resolves on a matching `replied`, rejects on `signal` abort or the
|
|
47
53
|
* 10-minute cap (mirrors `bus.ask`'s default). */
|
|
48
54
|
ask(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<string>;
|
|
@@ -63,6 +69,8 @@ export interface MakeBrokerClientDeps {
|
|
|
63
69
|
label?: string;
|
|
64
70
|
group?: string;
|
|
65
71
|
peers?: boolean;
|
|
72
|
+
/** Private, one-shot parent grant. Never log this or pass it to a model. */
|
|
73
|
+
readerToken?: string;
|
|
66
74
|
/** Injected transport (tests use a paired in-memory fake); defaults to real `node:net`. */
|
|
67
75
|
net?: typeof import("node:net");
|
|
68
76
|
}
|
|
@@ -74,13 +82,8 @@ const ASK_TIMEOUT_MS = 600_000;
|
|
|
74
82
|
// A roster lookup is one local socket/pipe round-trip, unlike a blocking supervisor ask. Do not
|
|
75
83
|
// let a half-open broker pin `contact_peer list/send` until the child idle watchdog fires.
|
|
76
84
|
const LIST_TIMEOUT_MS = 10_000;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return new Promise((resolve) => {
|
|
80
|
-
const t = setTimeout(resolve, ms);
|
|
81
|
-
t.unref?.();
|
|
82
|
-
});
|
|
83
|
-
}
|
|
85
|
+
export const SEND_TIMEOUT_MS = 10_000;
|
|
86
|
+
export const MAX_PENDING_SENDS = 64;
|
|
84
87
|
|
|
85
88
|
export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
86
89
|
const netImpl = deps.net ?? nodeNet;
|
|
@@ -88,6 +91,12 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
88
91
|
let socket: net.Socket | undefined;
|
|
89
92
|
let closed = false;
|
|
90
93
|
let disconnected = false;
|
|
94
|
+
let registerStarted = false;
|
|
95
|
+
let registered = false;
|
|
96
|
+
let cancelConnect: (() => void) | undefined;
|
|
97
|
+
let cancelBackoff: (() => void) | undefined;
|
|
98
|
+
const pendingReads = new Map<string, { request: ResultReadRequest; resolve: (result: ReadReply) => void }>();
|
|
99
|
+
const pendingSends = new Map<string, (delivered: boolean) => void>();
|
|
91
100
|
let registerSettle: { resolve: () => void; reject: (err: Error) => void } | undefined;
|
|
92
101
|
|
|
93
102
|
const deliverListeners = new Set<(evt: DeliverEvent) => void>();
|
|
@@ -96,9 +105,7 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
96
105
|
const pendingAsks = new Map<string, { resolve: (text: string) => void; reject: (err: Error) => void }>();
|
|
97
106
|
const pendingLists = new Map<string, { resolve: (peers: Array<{ handle: string; label: string }>) => void; reject: (err: Error) => void }>();
|
|
98
107
|
|
|
99
|
-
/** Best-effort write
|
|
100
|
-
* the design's "sends report undelivered" (no throw the caller could even act on,
|
|
101
|
-
* `send`/`reply` are `void`). */
|
|
108
|
+
/** Best-effort control frame write. Confirmed sends use their own guarded write and receipt. */
|
|
102
109
|
function write(frame: Frame): void {
|
|
103
110
|
if (!socket) return;
|
|
104
111
|
try {
|
|
@@ -121,20 +128,35 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
121
128
|
|
|
122
129
|
function handleFrame(frame: Frame): void {
|
|
123
130
|
switch (frame.t) {
|
|
131
|
+
case "sent":
|
|
132
|
+
pendingSends.get(frame.msgId)?.(frame.delivered);
|
|
133
|
+
return;
|
|
124
134
|
case "registered":
|
|
125
|
-
registerSettle
|
|
135
|
+
if (!registerSettle) return;
|
|
136
|
+
if (frame.handle !== deps.handle || (deps.readerToken !== undefined && (!frame.readerToken
|
|
137
|
+
|| !timingSafeEqual(Buffer.from(deps.readerToken, "hex"), Buffer.from(frame.readerToken, "hex"))))) {
|
|
138
|
+
failReaderProtocol();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
registered = true;
|
|
142
|
+
registerSettle.resolve();
|
|
126
143
|
registerSettle = undefined;
|
|
127
144
|
return;
|
|
145
|
+
case "result_read_reply": {
|
|
146
|
+
const pending = pendingReads.get(frame.reqId);
|
|
147
|
+
if (pending) pending.resolve(isReadReplyFor(frame.result, pending.request) ? frame.result : readFailure("protocol"));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
128
150
|
case "error":
|
|
129
151
|
// A correlated error belongs to one request on this connection. Do not let an
|
|
130
152
|
// unknown/stale correlation id affect the register handshake or another ask.
|
|
131
153
|
if (frame.msgId !== undefined) {
|
|
154
|
+
pendingSends.get(frame.msgId)?.(false);
|
|
132
155
|
pendingAsks.get(frame.msgId)?.reject(new Error(frame.reason));
|
|
133
156
|
return;
|
|
134
157
|
}
|
|
135
|
-
// A bare error
|
|
136
|
-
|
|
137
|
-
registerSettle?.reject(new Error(frame.reason));
|
|
158
|
+
// A bare error belongs to the register handshake, not an outstanding read.
|
|
159
|
+
registerSettle?.reject(new Error(deps.readerToken !== undefined ? "Result reader authentication failed." : frame.reason));
|
|
138
160
|
registerSettle = undefined;
|
|
139
161
|
return;
|
|
140
162
|
case "deliver": {
|
|
@@ -167,25 +189,43 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
167
189
|
}
|
|
168
190
|
}
|
|
169
191
|
|
|
192
|
+
function failReaderProtocol(): void {
|
|
193
|
+
registerSettle?.reject(new Error("Result reader protocol failure."));
|
|
194
|
+
registerSettle = undefined;
|
|
195
|
+
for (const pending of pendingReads.values()) pending.resolve(readFailure("protocol"));
|
|
196
|
+
socket?.destroy();
|
|
197
|
+
}
|
|
198
|
+
|
|
170
199
|
function connectOnce(): Promise<net.Socket> {
|
|
171
200
|
return new Promise((resolve, reject) => {
|
|
172
201
|
const s = netImpl.connect(deps.endpoint);
|
|
173
202
|
let settled = false;
|
|
203
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
204
|
+
const finish = (): void => { clearTimeout(timer); cancelConnect = undefined; };
|
|
174
205
|
const onConnect = (): void => {
|
|
175
206
|
if (settled) return;
|
|
176
207
|
settled = true;
|
|
208
|
+
finish();
|
|
177
209
|
s.removeListener("error", onError);
|
|
178
210
|
resolve(s);
|
|
179
211
|
};
|
|
180
212
|
const onError = (err: Error): void => {
|
|
181
213
|
if (settled) return;
|
|
182
214
|
settled = true;
|
|
215
|
+
finish();
|
|
183
216
|
s.removeListener("connect", onConnect);
|
|
184
217
|
s.destroy();
|
|
185
218
|
reject(err);
|
|
186
219
|
};
|
|
187
220
|
s.once("connect", onConnect);
|
|
188
221
|
s.once("error", onError);
|
|
222
|
+
// Permanent sink also covers cancellation before the eventual connect event.
|
|
223
|
+
s.on("error", () => {});
|
|
224
|
+
cancelConnect = () => onError(new Error("broker client closed"));
|
|
225
|
+
if (deps.readerToken !== undefined) {
|
|
226
|
+
timer = setTimeout(() => onError(new Error("Result reader connection deadline exceeded.")), RESULT_READ_TIMEOUT_MS);
|
|
227
|
+
timer.unref?.();
|
|
228
|
+
}
|
|
189
229
|
});
|
|
190
230
|
}
|
|
191
231
|
|
|
@@ -198,9 +238,21 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
198
238
|
try {
|
|
199
239
|
return await connectOnce();
|
|
200
240
|
} catch (err) {
|
|
241
|
+
// Local cancellation is terminal. Waiting on an unref'ed retry timer here
|
|
242
|
+
// can leave register() pending after the process has no live handles.
|
|
243
|
+
if (closed) throw new Error("broker client closed");
|
|
201
244
|
lastErr = err;
|
|
202
245
|
if (attempt < CONNECT_MAX_ATTEMPTS - 1) {
|
|
203
|
-
await
|
|
246
|
+
await new Promise<void>((resolve) => {
|
|
247
|
+
const finish = (): void => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
cancelBackoff = undefined;
|
|
250
|
+
resolve();
|
|
251
|
+
};
|
|
252
|
+
const timer = setTimeout(finish, Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS));
|
|
253
|
+
timer.unref?.();
|
|
254
|
+
cancelBackoff = finish;
|
|
255
|
+
});
|
|
204
256
|
}
|
|
205
257
|
}
|
|
206
258
|
}
|
|
@@ -208,6 +260,8 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
208
260
|
}
|
|
209
261
|
|
|
210
262
|
function rejectAllPending(reason: string): void {
|
|
263
|
+
for (const settle of pendingSends.values()) settle(false);
|
|
264
|
+
for (const p of pendingReads.values()) p.resolve(readFailure("unavailable"));
|
|
211
265
|
for (const p of pendingAsks.values()) p.reject(new Error(reason));
|
|
212
266
|
pendingAsks.clear();
|
|
213
267
|
for (const p of pendingLists.values()) p.reject(new Error(reason));
|
|
@@ -215,7 +269,11 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
215
269
|
}
|
|
216
270
|
|
|
217
271
|
async function register(): Promise<void> {
|
|
218
|
-
|
|
272
|
+
if (registerStarted || closed) throw new Error("broker register is one-shot or closed");
|
|
273
|
+
registerStarted = true;
|
|
274
|
+
if (deps.readerToken !== undefined && !isReaderToken(deps.readerToken)) throw new Error("Result reader protocol failure.");
|
|
275
|
+
// Ordinary comms retain initial-connect backoff. A result grant never reconnects/replays.
|
|
276
|
+
const s = await (deps.readerToken !== undefined ? connectOnce() : connectWithBackoff());
|
|
219
277
|
if (closed) {
|
|
220
278
|
s.destroy();
|
|
221
279
|
throw new Error("broker client closed");
|
|
@@ -226,16 +284,19 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
226
284
|
createFrameReader(
|
|
227
285
|
(raw) => {
|
|
228
286
|
if (isFrame(raw)) handleFrame(raw);
|
|
287
|
+
else if (deps.readerToken !== undefined) failReaderProtocol();
|
|
229
288
|
},
|
|
230
|
-
() => s.destroy(),
|
|
289
|
+
() => { if (deps.readerToken !== undefined) failReaderProtocol(); else s.destroy(); },
|
|
231
290
|
),
|
|
232
291
|
);
|
|
233
292
|
s.on("error", () => {
|
|
293
|
+
if (deps.readerToken !== undefined) s.destroy();
|
|
234
294
|
/* The close event rejects in-flight requests; this listener only prevents an
|
|
235
295
|
* unhandled socket error from taking down the child. */
|
|
236
296
|
});
|
|
237
297
|
s.once("close", () => {
|
|
238
|
-
socket = undefined;
|
|
298
|
+
if (socket === s) socket = undefined;
|
|
299
|
+
registered = false;
|
|
239
300
|
// A peer-initiated drop (host teardown/crash) between connect and the handshake
|
|
240
301
|
// reply would otherwise leave register() pending for the child's whole life.
|
|
241
302
|
registerSettle?.reject(new Error("connection closed during register"));
|
|
@@ -248,10 +309,20 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
248
309
|
});
|
|
249
310
|
|
|
250
311
|
return new Promise<void>((resolve, reject) => {
|
|
251
|
-
|
|
312
|
+
const timer = deps.readerToken !== undefined ? setTimeout(() => {
|
|
313
|
+
registerSettle?.reject(new Error("Result reader handshake deadline exceeded."));
|
|
314
|
+
registerSettle = undefined;
|
|
315
|
+
s.destroy();
|
|
316
|
+
}, RESULT_READ_TIMEOUT_MS) : undefined;
|
|
317
|
+
timer?.unref?.();
|
|
318
|
+
registerSettle = {
|
|
319
|
+
resolve: () => { clearTimeout(timer); resolve(); },
|
|
320
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
321
|
+
};
|
|
252
322
|
write({
|
|
253
323
|
t: "register",
|
|
254
324
|
handle: deps.handle,
|
|
325
|
+
...(deps.readerToken !== undefined ? { readerToken: deps.readerToken } : {}),
|
|
255
326
|
...(deps.label !== undefined ? { label: deps.label } : {}),
|
|
256
327
|
...(deps.group !== undefined ? { group: deps.group } : {}),
|
|
257
328
|
...(deps.peers !== undefined ? { peers: deps.peers } : {}),
|
|
@@ -259,8 +330,55 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
259
330
|
});
|
|
260
331
|
}
|
|
261
332
|
|
|
262
|
-
function
|
|
263
|
-
|
|
333
|
+
function read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply> {
|
|
334
|
+
if (signal?.aborted) return Promise.resolve(readFailure("cancelled"));
|
|
335
|
+
if (!isReadRequest(request)) return Promise.resolve(readFailure("invalid"));
|
|
336
|
+
if (deps.readerToken === undefined) return Promise.resolve(readFailure("denied"));
|
|
337
|
+
if (closed || disconnected || !registered || !socket) return Promise.resolve(readFailure("unavailable"));
|
|
338
|
+
if (pendingReads.size >= RESULT_READ_MAX_PENDING) return Promise.resolve(readFailure("busy"));
|
|
339
|
+
const reqId = randomUUID();
|
|
340
|
+
// Snapshot caller data so mutation after dispatch cannot alter correlation checks.
|
|
341
|
+
const snapshot = { ...request };
|
|
342
|
+
return new Promise((resolve) => {
|
|
343
|
+
const finish = (result: ReadReply): void => {
|
|
344
|
+
if (!pendingReads.delete(reqId)) return;
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
signal?.removeEventListener("abort", onAbort);
|
|
347
|
+
resolve(result);
|
|
348
|
+
};
|
|
349
|
+
const cancel = (result: ReadReply): void => {
|
|
350
|
+
finish(result);
|
|
351
|
+
write({ t: "result_read_cancel", reqId });
|
|
352
|
+
};
|
|
353
|
+
const onAbort = (): void => cancel(readFailure("cancelled"));
|
|
354
|
+
const timer = setTimeout(() => cancel(readFailure("timeout")), RESULT_READ_TIMEOUT_MS);
|
|
355
|
+
timer.unref?.();
|
|
356
|
+
pendingReads.set(reqId, { request: snapshot, resolve: finish });
|
|
357
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
358
|
+
try {
|
|
359
|
+
socket!.write(encodeFrame({ t: "result_read", reqId, ...snapshot }));
|
|
360
|
+
} catch { finish(readFailure("unavailable")); socket?.destroy(); }
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function send(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<boolean> {
|
|
365
|
+
if (signal?.aborted || closed || disconnected || !registered || !socket || pendingSends.size >= MAX_PENDING_SENDS) return Promise.resolve(false);
|
|
366
|
+
const msgId = randomUUID();
|
|
367
|
+
return new Promise((resolve) => {
|
|
368
|
+
const finish = (delivered: boolean): void => {
|
|
369
|
+
if (!pendingSends.delete(msgId)) return;
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
signal?.removeEventListener("abort", onAbort);
|
|
372
|
+
resolve(delivered);
|
|
373
|
+
};
|
|
374
|
+
const onAbort = (): void => finish(false);
|
|
375
|
+
const timer = setTimeout(() => finish(false), SEND_TIMEOUT_MS);
|
|
376
|
+
timer.unref?.();
|
|
377
|
+
pendingSends.set(msgId, finish);
|
|
378
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
379
|
+
try { socket!.write(encodeFrame({ t: "send", to, kind, text, msgId, expectsReply: false })); }
|
|
380
|
+
catch { finish(false); }
|
|
381
|
+
});
|
|
264
382
|
}
|
|
265
383
|
|
|
266
384
|
/** Why `ask`/`list` refuse to send right now — distinguishes a disposed client from one that
|
|
@@ -357,6 +475,9 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
357
475
|
function close(): void {
|
|
358
476
|
if (closed) return;
|
|
359
477
|
closed = true;
|
|
478
|
+
registered = false;
|
|
479
|
+
cancelConnect?.();
|
|
480
|
+
cancelBackoff?.();
|
|
360
481
|
const s = socket;
|
|
361
482
|
if (s) {
|
|
362
483
|
write({ t: "bye" });
|
|
@@ -375,5 +496,5 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
375
496
|
rejectAllPending("broker client closed");
|
|
376
497
|
}
|
|
377
498
|
|
|
378
|
-
return { register, send, ask, reply, list, onDeliver, onSteer, onClose, close };
|
|
499
|
+
return { register, read, send, ask, reply, list, onDeliver, onSteer, onClose, close };
|
|
379
500
|
}
|
package/src/bus/broker/host.ts
CHANGED
|
@@ -29,17 +29,23 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { existsSync, unlinkSync } from "node:fs";
|
|
32
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
32
33
|
import nodeNet from "node:net";
|
|
33
34
|
import type net from "node:net";
|
|
34
35
|
|
|
35
36
|
import { createFrameReader, encodeFrame } from "./framing.ts";
|
|
36
37
|
import { type Frame, isFrame } from "./messages.ts";
|
|
37
38
|
import type { InProcessBus } from "../inproc.ts";
|
|
39
|
+
import { isReadReplyFor, readFailure, RESULT_READ_MAX_FRAME_BYTES, RESULT_READ_MAX_PENDING,
|
|
40
|
+
RESULT_READ_MAX_REQUESTS, RESULT_READ_TIMEOUT_MS, type PreparedResultReader, type ReadReply,
|
|
41
|
+
type ScopedReader } from "../result-reader.ts";
|
|
38
42
|
|
|
39
43
|
export interface BrokerHost {
|
|
40
44
|
endpoint: string;
|
|
41
45
|
close(): Promise<void>;
|
|
42
46
|
connectedHandles(): string[];
|
|
47
|
+
/** Parent-only preflight. Reserve before bus/adapter registration or spawning. */
|
|
48
|
+
prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader>;
|
|
43
49
|
/** Pushes a `steer` frame directly to a connected handle (spec B4) — the supervisor's own
|
|
44
50
|
* live voice, NOT a bus envelope (it bypasses `bus.send`/the outbound drain entirely, so
|
|
45
51
|
* it can't be mistaken for a peer/child message on the wire). Returns false (a harmless
|
|
@@ -158,6 +164,39 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
158
164
|
const sockets = new Set<net.Socket>();
|
|
159
165
|
const expected = new Set<string>();
|
|
160
166
|
const pendingSteer = new Map<string, string[]>();
|
|
167
|
+
type Grant = { token: Buffer; reader: ScopedReader; active: boolean; bound: boolean; stop?: () => void; revoke: () => void };
|
|
168
|
+
const grants = new Map<string, Grant>();
|
|
169
|
+
let closed = false;
|
|
170
|
+
|
|
171
|
+
function revokeReader(reader: ScopedReader): void {
|
|
172
|
+
try { reader.revoke(); } catch { /* cleanup cannot leak callback errors or strand siblings */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader> {
|
|
176
|
+
if (closed || signal?.aborted || !handle || handle.length > 256 || handle === deps.supervisorHandle
|
|
177
|
+
|| connections.has(handle) || grants.has(handle) || expected.has(handle) || bus.participants().includes(handle)) {
|
|
178
|
+
revokeReader(reader);
|
|
179
|
+
throw new Error("Result reader preparation refused.");
|
|
180
|
+
}
|
|
181
|
+
const token = randomBytes(32);
|
|
182
|
+
const grant: Grant = { token, reader, active: true, bound: false, revoke: () => {
|
|
183
|
+
if (!grant.active) return;
|
|
184
|
+
grant.active = false;
|
|
185
|
+
signal?.removeEventListener("abort", grant.revoke);
|
|
186
|
+
if (grants.get(handle) === grant) {
|
|
187
|
+
grants.delete(handle);
|
|
188
|
+
expected.delete(handle);
|
|
189
|
+
pendingSteer.delete(handle);
|
|
190
|
+
}
|
|
191
|
+
token.fill(0);
|
|
192
|
+
grant.stop?.();
|
|
193
|
+
revokeReader(reader);
|
|
194
|
+
} };
|
|
195
|
+
grants.set(handle, grant);
|
|
196
|
+
expected.add(handle);
|
|
197
|
+
signal?.addEventListener("abort", grant.revoke, { once: true });
|
|
198
|
+
return { token: token.toString("hex"), revoke: grant.revoke, wasBound: () => grant.bound };
|
|
199
|
+
}
|
|
161
200
|
|
|
162
201
|
function flushPendingSteer(handle: string): void {
|
|
163
202
|
const conn = connections.get(handle);
|
|
@@ -207,10 +246,19 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
207
246
|
let handle: string | undefined;
|
|
208
247
|
let group: string | undefined;
|
|
209
248
|
const pendingAsks = new Map<string, AbortController>();
|
|
249
|
+
let ended = false;
|
|
250
|
+
let boundGrant: Grant | undefined;
|
|
251
|
+
const seenReads = new Set<string>();
|
|
252
|
+
const pendingReads = new Map<string, { ac: AbortController; finish: (result?: ReadReply) => void }>();
|
|
210
253
|
|
|
211
254
|
const write = (frame: Frame): void => {
|
|
212
255
|
try {
|
|
213
|
-
|
|
256
|
+
const encoded = encodeFrame(frame);
|
|
257
|
+
if (frame.t === "result_read_reply" && encoded.length > RESULT_READ_MAX_FRAME_BYTES) {
|
|
258
|
+
socket.write(encodeFrame({ t: "result_read_reply", reqId: frame.reqId, result: readFailure("protocol") }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
socket.write(encoded);
|
|
214
262
|
} catch {
|
|
215
263
|
/* peer went away mid-write */
|
|
216
264
|
}
|
|
@@ -220,6 +268,11 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
220
268
|
const conn = { write };
|
|
221
269
|
|
|
222
270
|
const cleanup = (): void => {
|
|
271
|
+
if (ended) return;
|
|
272
|
+
ended = true;
|
|
273
|
+
for (const pending of pendingReads.values()) { pending.finish(); pending.ac.abort(); }
|
|
274
|
+
seenReads.clear();
|
|
275
|
+
boundGrant?.revoke();
|
|
223
276
|
for (const ac of pendingAsks.values()) ac.abort();
|
|
224
277
|
pendingAsks.clear();
|
|
225
278
|
// `bye` releases the handle while `handle` stays set (the socket's own `close`
|
|
@@ -233,10 +286,55 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
233
286
|
const errorReason = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
|
234
287
|
|
|
235
288
|
const dispatch = (frame: Frame): void => {
|
|
289
|
+
if (ended) return;
|
|
236
290
|
switch (frame.t) {
|
|
291
|
+
case "result_read": {
|
|
292
|
+
const grant = boundGrant;
|
|
293
|
+
const respond = (result: ReadReply): void => write({ t: "result_read_reply", reqId: frame.reqId, result });
|
|
294
|
+
if (!handle || connections.get(handle) !== conn || !grant?.active) { respond(readFailure("denied")); return; }
|
|
295
|
+
if (seenReads.has(frame.reqId)) {
|
|
296
|
+
const pending = pendingReads.get(frame.reqId);
|
|
297
|
+
pending?.finish();
|
|
298
|
+
pending?.ac.abort();
|
|
299
|
+
respond(readFailure("protocol"));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (seenReads.size >= RESULT_READ_MAX_REQUESTS) { grant.revoke(); return; }
|
|
303
|
+
seenReads.add(frame.reqId);
|
|
304
|
+
if (pendingReads.size >= RESULT_READ_MAX_PENDING) { respond(readFailure("busy")); return; }
|
|
305
|
+
const { t: _t, reqId, ...request } = frame;
|
|
306
|
+
const ac = new AbortController();
|
|
307
|
+
const finish = (result?: ReadReply): void => {
|
|
308
|
+
if (pendingReads.get(reqId)?.ac !== ac) return;
|
|
309
|
+
pendingReads.delete(reqId);
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
if (result && !ended && grant.active && handle && connections.get(handle) === conn) respond(result);
|
|
312
|
+
};
|
|
313
|
+
const timer = setTimeout(() => { finish(readFailure("timeout")); ac.abort(); }, RESULT_READ_TIMEOUT_MS);
|
|
314
|
+
timer.unref?.();
|
|
315
|
+
pendingReads.set(reqId, { ac, finish });
|
|
316
|
+
void Promise.resolve().then(() => {
|
|
317
|
+
if (ac.signal.aborted || !grant.active) return readFailure("revoked");
|
|
318
|
+
return grant.reader.read(request, ac.signal);
|
|
319
|
+
}).then((result) => {
|
|
320
|
+
try { finish(isReadReplyFor(result, request) ? result : readFailure("protocol")); }
|
|
321
|
+
catch { finish(readFailure("protocol")); }
|
|
322
|
+
}, () => finish(readFailure("unavailable")));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
case "result_read_cancel": {
|
|
326
|
+
const pending = pendingReads.get(frame.reqId);
|
|
327
|
+
pending?.finish(readFailure("cancelled"));
|
|
328
|
+
pending?.ac.abort();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
237
331
|
case "register": {
|
|
332
|
+
const reject = (reason: string): void => {
|
|
333
|
+
write({ t: "error", reason });
|
|
334
|
+
if (frame.readerToken !== undefined || grants.has(frame.handle)) socket.destroy();
|
|
335
|
+
};
|
|
238
336
|
if (frame.handle === deps.supervisorHandle) {
|
|
239
|
-
|
|
337
|
+
reject("reserved handle");
|
|
240
338
|
return;
|
|
241
339
|
}
|
|
242
340
|
// Registration is one-shot per socket and exclusive per handle: a
|
|
@@ -244,26 +342,38 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
244
342
|
// would then unregister a handle it no longer owns (or leak the one it
|
|
245
343
|
// silently abandoned).
|
|
246
344
|
if (handle) {
|
|
247
|
-
|
|
345
|
+
reject("already registered");
|
|
248
346
|
return;
|
|
249
347
|
}
|
|
250
348
|
if (connections.has(frame.handle)) {
|
|
251
|
-
|
|
349
|
+
reject("handle already connected");
|
|
252
350
|
return;
|
|
253
351
|
}
|
|
352
|
+
const grant = grants.get(frame.handle);
|
|
353
|
+
// A supplied unknown/expired token never falls through to ordinary registration.
|
|
354
|
+
if (grant || frame.readerToken !== undefined) {
|
|
355
|
+
if (!grant?.active || grant.bound || !frame.readerToken
|
|
356
|
+
|| !timingSafeEqual(grant.token, Buffer.from(frame.readerToken, "hex"))) {
|
|
357
|
+
reject("result reader authentication failed");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
grant.bound = true;
|
|
361
|
+
boundGrant = grant;
|
|
362
|
+
grant.stop = () => { if (!ended) { cleanup(); socket.destroy(); } };
|
|
363
|
+
}
|
|
254
364
|
handle = frame.handle;
|
|
255
365
|
group = frame.group;
|
|
256
366
|
bus.register(handle);
|
|
257
367
|
connections.set(handle, conn);
|
|
258
368
|
if (frame.peers) peerRegistry.set(handle, { handle, label: frame.label ?? handle, group: frame.group ?? "" });
|
|
259
|
-
write({ t: "registered", handle });
|
|
369
|
+
write({ t: "registered", handle, ...(frame.readerToken ? { readerToken: frame.readerToken } : {}) });
|
|
260
370
|
drain(handle);
|
|
261
371
|
flushPendingSteer(handle);
|
|
262
372
|
return;
|
|
263
373
|
}
|
|
264
374
|
case "send": {
|
|
265
375
|
if (!handle) {
|
|
266
|
-
write({ t: "error", reason: "send before register" });
|
|
376
|
+
write({ t: "error", reason: "send before register", msgId: frame.msgId });
|
|
267
377
|
return;
|
|
268
378
|
}
|
|
269
379
|
const from = handle;
|
|
@@ -285,8 +395,12 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
285
395
|
forgetAsk(frame.msgId, ac);
|
|
286
396
|
write({ t: "error", reason: errorReason(error), msgId: frame.msgId });
|
|
287
397
|
}
|
|
288
|
-
} else
|
|
289
|
-
|
|
398
|
+
} else {
|
|
399
|
+
// Admission is not an answer. Unknown/full inboxes both return false;
|
|
400
|
+
// never label a full inbox "unknown peer" or a socket write "delivered".
|
|
401
|
+
let delivered = false;
|
|
402
|
+
try { delivered = bus.send(from, frame.to, frame.text, frame.kind); } catch { /* no confirmation */ }
|
|
403
|
+
write({ t: "sent", msgId: frame.msgId, delivered });
|
|
290
404
|
}
|
|
291
405
|
return;
|
|
292
406
|
}
|
|
@@ -324,6 +438,8 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
324
438
|
const handleFrame = (raw: unknown): void => {
|
|
325
439
|
if (!isFrame(raw)) {
|
|
326
440
|
write({ t: "error", reason: "invalid frame" });
|
|
441
|
+
if (boundGrant || (typeof raw === "object" && raw !== null
|
|
442
|
+
&& ("readerToken" in raw || ("t" in raw && String(raw.t).startsWith("result_read"))))) socket.destroy();
|
|
327
443
|
return;
|
|
328
444
|
}
|
|
329
445
|
try {
|
|
@@ -337,8 +453,9 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
337
453
|
}
|
|
338
454
|
};
|
|
339
455
|
|
|
340
|
-
socket.on("data", createFrameReader(handleFrame, (
|
|
341
|
-
|
|
456
|
+
socket.on("data", createFrameReader(handleFrame, () => {
|
|
457
|
+
// JSON parser diagnostics can contain the private registration token.
|
|
458
|
+
write({ t: "error", reason: "invalid frame" });
|
|
342
459
|
socket.destroy();
|
|
343
460
|
}));
|
|
344
461
|
socket.on("error", () => socket.destroy());
|
|
@@ -350,9 +467,9 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
350
467
|
|
|
351
468
|
const server = await bindServer(netImpl, deps.endpoint, handleConnection);
|
|
352
469
|
|
|
353
|
-
let closed = false;
|
|
354
470
|
return {
|
|
355
471
|
endpoint: deps.endpoint,
|
|
472
|
+
prepareResultReader,
|
|
356
473
|
connectedHandles: () => [...connections.keys()],
|
|
357
474
|
steer(handle: string, text: string): boolean {
|
|
358
475
|
const conn = connections.get(handle);
|
|
@@ -370,12 +487,14 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
370
487
|
expected.add(handle);
|
|
371
488
|
},
|
|
372
489
|
forget(handle: string): void {
|
|
490
|
+
grants.get(handle)?.revoke();
|
|
373
491
|
expected.delete(handle);
|
|
374
492
|
pendingSteer.delete(handle);
|
|
375
493
|
},
|
|
376
494
|
async close(): Promise<void> {
|
|
377
495
|
if (closed) return;
|
|
378
496
|
closed = true;
|
|
497
|
+
for (const grant of grants.values()) grant.revoke();
|
|
379
498
|
unsubBus();
|
|
380
499
|
expected.clear();
|
|
381
500
|
pendingSteer.clear();
|