@fitchmultz/pi-subagents 0.52.20 → 0.52.21
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 +30 -2
- package/dist/agent/contracts.js +12 -3
- package/dist/agent/index.js +9 -9
- package/dist/agent/legacy.js +5 -5
- package/dist/agent/notifications.js +21 -14
- package/dist/agent/parent-check.js +38 -0
- package/dist/agent/process.js +45 -52
- package/dist/agent/runner.js +59 -64
- package/dist/agent/storage.js +16 -4
- package/dist/agent/view.js +1 -3
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +4 -26
- package/dist/pi-intercom/broker/client-park.d.ts +1 -1
- package/dist/pi-intercom/broker/client-park.js +22 -6
- package/dist/pi-intercom/broker/client.d.ts +3 -2
- package/dist/pi-intercom/broker/client.js +88 -62
- package/dist/pi-intercom/broker/connection.d.ts +3 -5
- package/dist/pi-intercom/broker/connection.js +48 -33
- package/dist/pi-intercom/broker/framing.d.ts +1 -1
- package/dist/pi-intercom/broker/framing.js +19 -12
- package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
- package/dist/pi-intercom/connection.js +5 -8
- package/dist/pi-intercom/inbound-journal.js +5 -2
- package/dist/shared/journal-frames.js +19 -24
- package/docs/development.md +6 -0
- package/package.json +1 -1
- package/src/agent/contracts.ts +12 -3
- package/src/agent/index.ts +10 -10
- package/src/agent/legacy.ts +5 -5
- package/src/agent/notifications.ts +35 -15
- package/src/agent/parent-check.ts +40 -0
- package/src/agent/process.ts +53 -55
- package/src/agent/runner.ts +65 -65
- package/src/agent/storage.ts +18 -5
- package/src/agent/view.ts +1 -4
- package/src/pi-intercom/broker/broker.ts +5 -37
- package/src/pi-intercom/broker/client-park.ts +27 -6
- package/src/pi-intercom/broker/client.ts +91 -61
- package/src/pi-intercom/broker/connection.ts +58 -39
- package/src/pi-intercom/broker/framing.ts +24 -12
- package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
- package/src/pi-intercom/connection.ts +9 -9
- package/src/pi-intercom/inbound-journal.ts +5 -2
- package/src/shared/journal-frames.ts +22 -23
package/dist/agent/view.js
CHANGED
|
@@ -2,12 +2,10 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { readOutputPage } from "../shared/output-page.js";
|
|
4
4
|
import { actor, alive, observeAlive } from "./process.js";
|
|
5
|
-
import { admissionSchema,
|
|
5
|
+
import { admissionSchema, commandMetadataSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
|
|
6
6
|
import { conversation, read, runDirectory } from "./storage.js";
|
|
7
7
|
import { published } from "./notifications.js";
|
|
8
8
|
import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.js";
|
|
9
|
-
import { Type } from "./typebox.js";
|
|
10
|
-
const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
|
|
11
9
|
function toolResult(value, text) {
|
|
12
10
|
return { content: [{ type: "text", text }], details: value };
|
|
13
11
|
}
|
package/dist/build-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.21","sha256":"d0c9a96a0eca06a58dc73d77b64300d7e41a9eb5c75b1deee8751ae3af4b87ce"});
|
|
@@ -14,13 +14,13 @@ import { guardedDeliveryError } from "./guard.js";
|
|
|
14
14
|
import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
|
|
15
15
|
import { errorMessage, isRecord } from "../../shared/unknown.js";
|
|
16
16
|
import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, preparedPublication, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
|
|
17
|
+
import { ReplaceDeliveries } from "./replace-deliveries.js";
|
|
17
18
|
const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
|
|
18
|
-
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
19
19
|
class IntercomBroker {
|
|
20
20
|
sessions = new Map();
|
|
21
21
|
lastMessages = new LastMessageRelay(this.sessions);
|
|
22
22
|
parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
|
|
23
|
-
|
|
23
|
+
replacements = new ReplaceDeliveries(this.deliverMessage.bind(this));
|
|
24
24
|
server;
|
|
25
25
|
shutdownTimer = null;
|
|
26
26
|
socketPath;
|
|
@@ -153,7 +153,7 @@ class IntercomBroker {
|
|
|
153
153
|
this.lastMessages.disconnected(sessionId);
|
|
154
154
|
if (!this.parking.closed(session)) {
|
|
155
155
|
this.sessions.delete(sessionId);
|
|
156
|
-
this.
|
|
156
|
+
this.replacements.disconnected(sessionId);
|
|
157
157
|
this.broadcast({ type: "session_left", sessionId }, session.info);
|
|
158
158
|
}
|
|
159
159
|
this.scheduleShutdownCheck();
|
|
@@ -348,25 +348,12 @@ class IntercomBroker {
|
|
|
348
348
|
...(comms ? { lastIntercomActivity: now } : {}),
|
|
349
349
|
};
|
|
350
350
|
}
|
|
351
|
-
replaceKey(fromId, toId, threadId) {
|
|
352
|
-
return `${fromId}\0${toId}\0${threadId}`;
|
|
353
|
-
}
|
|
354
351
|
queueReplaceDelivery(fromId, toId, from, message) {
|
|
355
352
|
const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
|
|
356
353
|
if (validationError !== null) {
|
|
357
354
|
return { id: message.id, accepted: false, delivered: false, reason: validationError };
|
|
358
355
|
}
|
|
359
|
-
|
|
360
|
-
const existing = this.pendingReplaceDeliveries.get(key);
|
|
361
|
-
if (existing) {
|
|
362
|
-
clearTimeout(existing.timer);
|
|
363
|
-
}
|
|
364
|
-
const timer = setTimeout(() => {
|
|
365
|
-
this.pendingReplaceDeliveries.delete(key);
|
|
366
|
-
this.deliverMessage(toId, from, message);
|
|
367
|
-
}, REPLACE_DELIVERY_DELAY_MS);
|
|
368
|
-
timer.unref();
|
|
369
|
-
this.pendingReplaceDeliveries.set(key, { fromId, toId, message, timer });
|
|
356
|
+
this.replacements.stage(fromId, toId, from, message);
|
|
370
357
|
return {
|
|
371
358
|
id: message.id,
|
|
372
359
|
accepted: true,
|
|
@@ -375,15 +362,6 @@ class IntercomBroker {
|
|
|
375
362
|
reason: "Queued for replace-mode delivery",
|
|
376
363
|
};
|
|
377
364
|
}
|
|
378
|
-
clearPendingReplaceDeliveries(sessionId) {
|
|
379
|
-
for (const [key, pending] of this.pendingReplaceDeliveries) {
|
|
380
|
-
if (pending.toId === sessionId ||
|
|
381
|
-
(pending.fromId === sessionId && pending.message.expectsReply === true)) {
|
|
382
|
-
clearTimeout(pending.timer);
|
|
383
|
-
this.pendingReplaceDeliveries.delete(key);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
365
|
/** One admission rule; retained input is checked as the uncompacted envelope any later
|
|
388
366
|
* transport generation accepts. */
|
|
389
367
|
validateDeliveryPayload(from, message, target) {
|
|
@@ -2,7 +2,7 @@ import type net from "node:net";
|
|
|
2
2
|
/** Parking negotiation and its single outstanding broker acknowledgement. */
|
|
3
3
|
export declare class ParkRequests {
|
|
4
4
|
supported: boolean;
|
|
5
|
-
private
|
|
5
|
+
private state;
|
|
6
6
|
settle(parked: boolean): void;
|
|
7
7
|
/** True once the broker retains later input; false when it cannot or the socket closed. */
|
|
8
8
|
request(socket: () => net.Socket, on: boolean): Promise<boolean>;
|
|
@@ -2,20 +2,36 @@ import { writeMessage } from "./framing.js";
|
|
|
2
2
|
/** Parking negotiation and its single outstanding broker acknowledgement. */
|
|
3
3
|
export class ParkRequests {
|
|
4
4
|
supported = false;
|
|
5
|
-
|
|
5
|
+
state = { kind: "idle" };
|
|
6
6
|
settle(parked) {
|
|
7
|
-
this.
|
|
7
|
+
const state = this.state;
|
|
8
|
+
this.state = { kind: "idle" };
|
|
9
|
+
if (state.kind === "pending") {
|
|
10
|
+
state.resolve(parked);
|
|
11
|
+
}
|
|
8
12
|
}
|
|
9
13
|
/** True once the broker retains later input; false when it cannot or the socket closed. */
|
|
10
14
|
request(socket, on) {
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
if (on && this.state.kind === "pending") {
|
|
16
|
+
return this.state.promise;
|
|
17
|
+
}
|
|
18
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
19
|
+
this.settle(false);
|
|
20
|
+
if (on && this.supported) {
|
|
21
|
+
this.state = { kind: "pending", promise, resolve };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
13
24
|
if (this.supported) {
|
|
14
25
|
writeMessage(socket(), { type: on ? "park" : "unpark" });
|
|
15
26
|
}
|
|
16
|
-
if (this.
|
|
27
|
+
if (this.state.kind === "idle") {
|
|
17
28
|
resolve(false);
|
|
18
29
|
}
|
|
19
|
-
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
this.state = { kind: "idle" };
|
|
33
|
+
reject(error);
|
|
34
|
+
}
|
|
35
|
+
return promise;
|
|
20
36
|
}
|
|
21
37
|
}
|
|
@@ -18,8 +18,7 @@ export declare class IntercomClient extends EventEmitter {
|
|
|
18
18
|
private readonly lastRequests;
|
|
19
19
|
private readonly pendingSends;
|
|
20
20
|
private readonly pendingLists;
|
|
21
|
-
private
|
|
22
|
-
private disconnecting;
|
|
21
|
+
private phase;
|
|
23
22
|
private readonly sendTimeoutMs;
|
|
24
23
|
private readonly listTimeoutMs;
|
|
25
24
|
constructor(options?: IntercomClientOptions);
|
|
@@ -30,6 +29,7 @@ export declare class IntercomClient extends EventEmitter {
|
|
|
30
29
|
isConnected(): boolean;
|
|
31
30
|
private requireActiveSocket;
|
|
32
31
|
connect(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void>;
|
|
32
|
+
private establish;
|
|
33
33
|
private restoreTopics;
|
|
34
34
|
private handleBrokerMessage;
|
|
35
35
|
private receiveRegistration;
|
|
@@ -39,6 +39,7 @@ export declare class IntercomClient extends EventEmitter {
|
|
|
39
39
|
private receiveSessions;
|
|
40
40
|
park(on?: boolean): Promise<boolean>;
|
|
41
41
|
disconnect(): Promise<void>;
|
|
42
|
+
private close;
|
|
42
43
|
listSessions(): Promise<SessionInfo[]>;
|
|
43
44
|
last(to: string, query?: LastMessageQuery): Promise<LastMessageResult>;
|
|
44
45
|
receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult>;
|
|
@@ -39,8 +39,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
39
39
|
lastRequests;
|
|
40
40
|
pendingSends = new PendingRequests();
|
|
41
41
|
pendingLists = new PendingRequests();
|
|
42
|
-
|
|
43
|
-
disconnecting = false;
|
|
42
|
+
phase = { kind: "idle" };
|
|
44
43
|
sendTimeoutMs;
|
|
45
44
|
listTimeoutMs;
|
|
46
45
|
constructor(options = {}) {
|
|
@@ -65,11 +64,11 @@ export class IntercomClient extends EventEmitter {
|
|
|
65
64
|
}
|
|
66
65
|
isConnected() {
|
|
67
66
|
return ((this._sessionId ?? "").length > 0 &&
|
|
68
|
-
|
|
67
|
+
this.phase.kind !== "closing" &&
|
|
69
68
|
this.connection?.isActive() === true);
|
|
70
69
|
}
|
|
71
70
|
requireActiveSocket() {
|
|
72
|
-
if (this.
|
|
71
|
+
if (this.phase.kind === "closing") {
|
|
73
72
|
throw new Error("Client disconnecting");
|
|
74
73
|
}
|
|
75
74
|
const connection = this.connection;
|
|
@@ -82,62 +81,72 @@ export class IntercomClient extends EventEmitter {
|
|
|
82
81
|
return connection.socket;
|
|
83
82
|
}
|
|
84
83
|
async connect(session, requestedId) {
|
|
85
|
-
if (this.connection || this.
|
|
84
|
+
if (this.connection || this.phase.kind !== "idle") {
|
|
86
85
|
throw new Error("Already connected");
|
|
87
86
|
}
|
|
88
|
-
this.
|
|
87
|
+
const task = this.establish(session, requestedId);
|
|
88
|
+
this.phase = { kind: "connecting", task };
|
|
89
89
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
},
|
|
96
|
-
closed: (error, expected) => {
|
|
97
|
-
if (this.connection !== connection) {
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const established = this._sessionId !== null;
|
|
101
|
-
this.connection = null;
|
|
102
|
-
this._sessionId = null;
|
|
103
|
-
this._topicsSupported = false;
|
|
104
|
-
this.parking.settle(false);
|
|
105
|
-
this.guardedDeliverySupported = false;
|
|
106
|
-
this.wakes.supported = false;
|
|
107
|
-
this.disconnecting = false;
|
|
108
|
-
this.failPending(error);
|
|
109
|
-
if (established && !expected) {
|
|
110
|
-
this.emit("disconnected", error);
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
this.connection = connection;
|
|
115
|
-
const { topics, subscriptions, ...identity } = session;
|
|
116
|
-
try {
|
|
117
|
-
await connection.start({
|
|
118
|
-
type: "register",
|
|
119
|
-
session: identity,
|
|
120
|
-
requestedId,
|
|
121
|
-
topicFrames: true,
|
|
122
|
-
lastMessages: true,
|
|
123
|
-
parking: true,
|
|
124
|
-
receiptReads: 1,
|
|
125
|
-
guardedDelivery: 1,
|
|
126
|
-
...this.wakes.registration(),
|
|
127
|
-
});
|
|
90
|
+
await task;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (this.phase.kind === "connecting" && this.phase.task === task) {
|
|
94
|
+
this.phase = { kind: this.isConnected() ? "connected" : "idle" };
|
|
128
95
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async establish(session, requestedId) {
|
|
99
|
+
const socket = await connectBrokerSocket();
|
|
100
|
+
const connection = new BrokerConnection(socket, {
|
|
101
|
+
message: (message) => this.handleBrokerMessage(message),
|
|
102
|
+
error: (error) => {
|
|
103
|
+
this.emit("error", error);
|
|
104
|
+
},
|
|
105
|
+
closed: (error, expected) => {
|
|
106
|
+
if (this.connection !== connection) {
|
|
107
|
+
return;
|
|
132
108
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
109
|
+
const established = this._sessionId !== null;
|
|
110
|
+
this.connection = null;
|
|
111
|
+
this._sessionId = null;
|
|
112
|
+
this._topicsSupported = false;
|
|
113
|
+
this.parking.settle(false);
|
|
114
|
+
this.guardedDeliverySupported = false;
|
|
115
|
+
this.wakes.supported = false;
|
|
116
|
+
if (this.phase.kind === "connected") {
|
|
117
|
+
this.phase = { kind: "idle" };
|
|
118
|
+
}
|
|
119
|
+
this.failPending(error);
|
|
120
|
+
if (established && !expected) {
|
|
121
|
+
this.emit("disconnected", error);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
this.connection = connection;
|
|
126
|
+
if (this.phase.kind === "closing") {
|
|
127
|
+
await connection.disconnect();
|
|
128
|
+
throw new Error("Client disconnecting");
|
|
138
129
|
}
|
|
139
|
-
|
|
140
|
-
|
|
130
|
+
const { topics, subscriptions, ...identity } = session;
|
|
131
|
+
try {
|
|
132
|
+
await connection.start({
|
|
133
|
+
type: "register",
|
|
134
|
+
session: identity,
|
|
135
|
+
requestedId,
|
|
136
|
+
topicFrames: true,
|
|
137
|
+
lastMessages: true,
|
|
138
|
+
parking: true,
|
|
139
|
+
receiptReads: 1,
|
|
140
|
+
guardedDelivery: 1,
|
|
141
|
+
...this.wakes.registration(),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
await connection.disconnect();
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
if (this.supportsTopics) {
|
|
149
|
+
await this.restoreTopics(topics, subscriptions);
|
|
141
150
|
}
|
|
142
151
|
}
|
|
143
152
|
async restoreTopics(topics, subscriptions) {
|
|
@@ -271,14 +280,31 @@ export class IntercomClient extends EventEmitter {
|
|
|
271
280
|
park(on = true) {
|
|
272
281
|
return this.parking.request(() => this.requireActiveSocket(), on);
|
|
273
282
|
}
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
283
|
+
disconnect() {
|
|
284
|
+
const phase = this.phase;
|
|
285
|
+
if (phase.kind === "closing") {
|
|
286
|
+
return phase.task;
|
|
287
|
+
}
|
|
288
|
+
const connecting = phase.kind === "connecting" ? phase.task : undefined;
|
|
289
|
+
const task = Promise.resolve().then(() => this.close(connecting));
|
|
290
|
+
this.phase = { kind: "closing", task };
|
|
291
|
+
return task;
|
|
292
|
+
}
|
|
293
|
+
async close(connecting) {
|
|
280
294
|
this.failPending(new Error("Client disconnected"));
|
|
281
|
-
|
|
295
|
+
try {
|
|
296
|
+
await this.connection?.disconnect();
|
|
297
|
+
try {
|
|
298
|
+
await connecting;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// The connect caller owns acquisition failure; close still joins any acquired socket.
|
|
302
|
+
}
|
|
303
|
+
await this.connection?.disconnect();
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
this.phase = { kind: "idle" };
|
|
307
|
+
}
|
|
282
308
|
}
|
|
283
309
|
listSessions() {
|
|
284
310
|
return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
|
|
@@ -399,7 +425,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
399
425
|
});
|
|
400
426
|
}
|
|
401
427
|
updatePresence(updates) {
|
|
402
|
-
if (this.
|
|
428
|
+
if (this.phase.kind === "closing") {
|
|
403
429
|
return;
|
|
404
430
|
}
|
|
405
431
|
const socket = this.connection?.socket;
|
|
@@ -7,22 +7,20 @@ interface ConnectionHandlers {
|
|
|
7
7
|
}
|
|
8
8
|
/** One socket owns registration, read/error listeners, and graceful shutdown. */
|
|
9
9
|
export declare class BrokerConnection {
|
|
10
|
-
private
|
|
11
|
-
private closing;
|
|
10
|
+
private phase;
|
|
12
11
|
private failure;
|
|
13
|
-
private registration;
|
|
14
|
-
private registrationTimer;
|
|
15
12
|
private readonly reader;
|
|
16
13
|
readonly socket: net.Socket;
|
|
17
14
|
private readonly handlers;
|
|
18
15
|
constructor(socket: net.Socket, handlers: ConnectionHandlers);
|
|
19
16
|
start(registration: unknown): Promise<void>;
|
|
20
17
|
confirmRegistration(): void;
|
|
21
|
-
private
|
|
18
|
+
private takeRegistration;
|
|
22
19
|
private failRegistration;
|
|
23
20
|
private protocolError;
|
|
24
21
|
private readonly onError;
|
|
25
22
|
private readonly onClose;
|
|
23
|
+
private get registered();
|
|
26
24
|
isActive(): boolean;
|
|
27
25
|
disconnect(): Promise<void>;
|
|
28
26
|
}
|
|
@@ -34,11 +34,8 @@ export async function connectBrokerSocket() {
|
|
|
34
34
|
}
|
|
35
35
|
/** One socket owns registration, read/error listeners, and graceful shutdown. */
|
|
36
36
|
export class BrokerConnection {
|
|
37
|
-
|
|
38
|
-
closing = false;
|
|
37
|
+
phase = { kind: "acquired" };
|
|
39
38
|
failure = null;
|
|
40
|
-
registration = null;
|
|
41
|
-
registrationTimer = null;
|
|
42
39
|
reader;
|
|
43
40
|
socket;
|
|
44
41
|
handlers;
|
|
@@ -52,9 +49,9 @@ export class BrokerConnection {
|
|
|
52
49
|
}
|
|
53
50
|
start(registration) {
|
|
54
51
|
return new Promise((resolve, reject) => {
|
|
55
|
-
this.
|
|
56
|
-
|
|
57
|
-
this.
|
|
52
|
+
const timer = setTimeout(() => this.failRegistration(new Error("Connection timeout")), 10000);
|
|
53
|
+
timer.unref();
|
|
54
|
+
this.phase = { kind: "registering", resolve, reject, timer };
|
|
58
55
|
try {
|
|
59
56
|
writeMessage(this.socket, registration);
|
|
60
57
|
}
|
|
@@ -64,21 +61,26 @@ export class BrokerConnection {
|
|
|
64
61
|
});
|
|
65
62
|
}
|
|
66
63
|
confirmRegistration() {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const pending = this.takeRegistration();
|
|
65
|
+
if (pending !== undefined) {
|
|
66
|
+
this.phase = { kind: "active" };
|
|
67
|
+
pending.resolve();
|
|
68
|
+
}
|
|
71
69
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
takeRegistration() {
|
|
71
|
+
const pending = this.phase;
|
|
72
|
+
if (pending.kind !== "registering") {
|
|
73
|
+
return;
|
|
75
74
|
}
|
|
76
|
-
|
|
77
|
-
this.
|
|
75
|
+
clearTimeout(pending.timer);
|
|
76
|
+
this.phase = { kind: "acquired" };
|
|
77
|
+
return pending;
|
|
78
78
|
}
|
|
79
79
|
failRegistration(error) {
|
|
80
|
-
const pending = this.
|
|
81
|
-
this.
|
|
80
|
+
const pending = this.takeRegistration();
|
|
81
|
+
if (this.phase.kind !== "closing") {
|
|
82
|
+
this.phase = { kind: "failed" };
|
|
83
|
+
}
|
|
82
84
|
this.failure = error;
|
|
83
85
|
this.socket.destroy();
|
|
84
86
|
pending?.reject(error);
|
|
@@ -102,31 +104,37 @@ export class BrokerConnection {
|
|
|
102
104
|
this.handlers.error(error);
|
|
103
105
|
};
|
|
104
106
|
onClose = () => {
|
|
105
|
-
const
|
|
106
|
-
this.
|
|
107
|
+
const closing = this.phase.kind === "closing";
|
|
108
|
+
const pending = this.takeRegistration();
|
|
109
|
+
this.phase = { kind: "closed" };
|
|
107
110
|
this.socket.off("data", this.reader);
|
|
108
111
|
this.socket.off("error", this.onError);
|
|
109
112
|
this.socket.off("close", this.onClose);
|
|
110
113
|
pending?.reject(new Error("Connection closed before registration"));
|
|
111
|
-
this.handlers.closed(this.failure ?? new Error("Client disconnected"),
|
|
114
|
+
this.handlers.closed(this.failure ?? new Error("Client disconnected"), closing);
|
|
112
115
|
};
|
|
116
|
+
get registered() {
|
|
117
|
+
return this.phase.kind === "active" || (this.phase.kind === "closing" && this.phase.registered);
|
|
118
|
+
}
|
|
113
119
|
isActive() {
|
|
114
|
-
return (this.
|
|
115
|
-
!this.closing &&
|
|
120
|
+
return (this.phase.kind === "active" &&
|
|
116
121
|
!this.socket.destroyed &&
|
|
117
122
|
!this.socket.writableEnded &&
|
|
118
123
|
this.socket.writable);
|
|
119
124
|
}
|
|
120
125
|
disconnect() {
|
|
121
|
-
|
|
126
|
+
const phase = this.phase;
|
|
127
|
+
if (phase.kind === "closing") {
|
|
128
|
+
return phase.task;
|
|
129
|
+
}
|
|
130
|
+
if (phase.kind === "closed") {
|
|
131
|
+
return Promise.resolve();
|
|
132
|
+
}
|
|
133
|
+
const pending = this.takeRegistration();
|
|
134
|
+
pending?.reject(new Error("Client disconnected before registration"));
|
|
122
135
|
this.failure = null;
|
|
123
|
-
|
|
124
|
-
let settled = false;
|
|
136
|
+
const task = new Promise((resolve) => {
|
|
125
137
|
const finish = () => {
|
|
126
|
-
if (settled) {
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
settled = true;
|
|
130
138
|
clearTimeout(timeout);
|
|
131
139
|
this.socket.off("close", finish);
|
|
132
140
|
this.socket.off("error", onError);
|
|
@@ -140,13 +148,20 @@ export class BrokerConnection {
|
|
|
140
148
|
}, 2000);
|
|
141
149
|
this.socket.once("close", finish);
|
|
142
150
|
this.socket.once("error", onError);
|
|
143
|
-
|
|
151
|
+
});
|
|
152
|
+
this.phase = { kind: "closing", task, registered: phase.kind === "active" };
|
|
153
|
+
try {
|
|
154
|
+
if (phase.kind === "active" || phase.kind === "registering") {
|
|
144
155
|
writeMessage(this.socket, { type: "unregister" });
|
|
145
156
|
this.socket.end();
|
|
146
157
|
}
|
|
147
|
-
|
|
158
|
+
else {
|
|
148
159
|
this.socket.destroy();
|
|
149
160
|
}
|
|
150
|
-
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
this.socket.destroy();
|
|
164
|
+
}
|
|
165
|
+
return task;
|
|
151
166
|
}
|
|
152
167
|
}
|
|
@@ -12,6 +12,6 @@ export declare function writeFrame(socket: Socket, json: string, limit?: number)
|
|
|
12
12
|
/**
|
|
13
13
|
* Create a message reader that handles partial reads.
|
|
14
14
|
* Calls onMessage for each complete message received.
|
|
15
|
-
* Protocol or handler errors are reported
|
|
15
|
+
* Protocol or handler errors end reading and are reported so the caller can close the socket.
|
|
16
16
|
*/
|
|
17
17
|
export declare function createMessageReader(onMessage: (msg: unknown) => void, onError: (error: Readonly<Error>) => void, limit?: number): (data: Buffer) => void;
|
|
@@ -84,33 +84,40 @@ class MessageBuffer {
|
|
|
84
84
|
/**
|
|
85
85
|
* Create a message reader that handles partial reads.
|
|
86
86
|
* Calls onMessage for each complete message received.
|
|
87
|
-
* Protocol or handler errors are reported
|
|
87
|
+
* Protocol or handler errors end reading and are reported so the caller can close the socket.
|
|
88
88
|
*/
|
|
89
89
|
export function createMessageReader(onMessage, onError, limit = MAX_FRAME_SIZE_BYTES) {
|
|
90
90
|
const buffer = new MessageBuffer();
|
|
91
|
-
let
|
|
91
|
+
let state = { kind: "header" };
|
|
92
|
+
const fail = (error) => {
|
|
93
|
+
state = { kind: "failed" };
|
|
94
|
+
buffer.clear();
|
|
95
|
+
onError(error);
|
|
96
|
+
};
|
|
92
97
|
return (data) => {
|
|
98
|
+
if (state.kind === "failed") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
93
101
|
buffer.append(data);
|
|
94
|
-
while (buffer.length >= (length
|
|
95
|
-
if (
|
|
96
|
-
length = buffer.read(4).readUInt32BE(0);
|
|
102
|
+
while (buffer.length >= (state.kind === "payload" ? state.length : 4)) {
|
|
103
|
+
if (state.kind === "header") {
|
|
104
|
+
const length = buffer.read(4).readUInt32BE(0);
|
|
97
105
|
if (length > limit) {
|
|
98
|
-
|
|
99
|
-
buffer.clear();
|
|
100
|
-
length = undefined;
|
|
106
|
+
fail(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
|
|
101
107
|
return;
|
|
102
108
|
}
|
|
109
|
+
state = { kind: "payload", length };
|
|
103
110
|
continue;
|
|
104
111
|
}
|
|
105
|
-
const payload = buffer.read(length);
|
|
106
|
-
|
|
112
|
+
const payload = buffer.read(state.length);
|
|
113
|
+
state = { kind: "header" };
|
|
107
114
|
let msg;
|
|
108
115
|
try {
|
|
109
116
|
msg = JSON.parse(payload.toString("utf-8"));
|
|
110
117
|
}
|
|
111
118
|
catch (error) {
|
|
112
119
|
const message = errorMessage(error);
|
|
113
|
-
|
|
120
|
+
fail(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
|
|
114
121
|
return;
|
|
115
122
|
}
|
|
116
123
|
try {
|
|
@@ -118,7 +125,7 @@ export function createMessageReader(onMessage, onError, limit = MAX_FRAME_SIZE_B
|
|
|
118
125
|
}
|
|
119
126
|
catch (error) {
|
|
120
127
|
const message = errorMessage(error);
|
|
121
|
-
|
|
128
|
+
fail(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
|
|
122
129
|
return;
|
|
123
130
|
}
|
|
124
131
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
2
|
+
/** Only broker-staged messages remain replaceable; forwarding ends this owner's authority. */
|
|
3
|
+
export class ReplaceDeliveries {
|
|
4
|
+
pending = new Map();
|
|
5
|
+
deliver;
|
|
6
|
+
constructor(deliver) {
|
|
7
|
+
this.deliver = deliver;
|
|
8
|
+
}
|
|
9
|
+
stage(fromId, toId, from, message) {
|
|
10
|
+
const key = `${fromId}\0${toId}\0${message.threadId ?? ""}`;
|
|
11
|
+
this.take(key);
|
|
12
|
+
const timer = setTimeout(() => this.flush(key), REPLACE_DELIVERY_DELAY_MS);
|
|
13
|
+
timer.unref();
|
|
14
|
+
this.pending.set(key, { fromId, toId, from, message, timer });
|
|
15
|
+
}
|
|
16
|
+
take(key) {
|
|
17
|
+
const pending = this.pending.get(key);
|
|
18
|
+
if (pending !== undefined) {
|
|
19
|
+
this.pending.delete(key);
|
|
20
|
+
clearTimeout(pending.timer);
|
|
21
|
+
}
|
|
22
|
+
return pending;
|
|
23
|
+
}
|
|
24
|
+
flush(key) {
|
|
25
|
+
const pending = this.take(key);
|
|
26
|
+
if (pending !== undefined) {
|
|
27
|
+
this.deliver(pending.toId, pending.from, pending.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
disconnected(sessionId) {
|
|
31
|
+
for (const [key, pending] of this.pending) {
|
|
32
|
+
if (pending.toId === sessionId ||
|
|
33
|
+
(pending.fromId === sessionId && pending.message.expectsReply === true)) {
|
|
34
|
+
this.take(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|