@fitchmultz/pi-subagents 0.52.20 → 0.52.22
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 +46 -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/bridge-wake-state.js +31 -6
- package/dist/pi-intercom/bridge-wake.js +4 -10
- 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/bridge-wake-state.ts +39 -8
- package/src/pi-intercom/bridge-wake.ts +4 -9
- 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
|
@@ -63,6 +63,10 @@ function messageDelivery(options: SendOptions): MessageDelivery | undefined {
|
|
|
63
63
|
return "steer";
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
type ClientPhase =
|
|
67
|
+
| { readonly kind: "idle" | "connected" }
|
|
68
|
+
| { readonly kind: "connecting" | "closing"; readonly task: Promise<void> };
|
|
69
|
+
|
|
66
70
|
export class IntercomClient extends EventEmitter {
|
|
67
71
|
private connection: BrokerConnection | null = null;
|
|
68
72
|
private _sessionId: string | null = null;
|
|
@@ -80,8 +84,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
80
84
|
readonly reject: (e: Readonly<Error>) => void;
|
|
81
85
|
readonly reader: SnapshotReader;
|
|
82
86
|
}>();
|
|
83
|
-
private
|
|
84
|
-
private disconnecting = false;
|
|
87
|
+
private phase: ClientPhase = { kind: "idle" };
|
|
85
88
|
private readonly sendTimeoutMs: number;
|
|
86
89
|
private readonly listTimeoutMs: number;
|
|
87
90
|
|
|
@@ -111,13 +114,13 @@ export class IntercomClient extends EventEmitter {
|
|
|
111
114
|
isConnected(): boolean {
|
|
112
115
|
return (
|
|
113
116
|
(this._sessionId ?? "").length > 0 &&
|
|
114
|
-
|
|
117
|
+
this.phase.kind !== "closing" &&
|
|
115
118
|
this.connection?.isActive() === true
|
|
116
119
|
);
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
private requireActiveSocket(): net.Socket {
|
|
120
|
-
if (this.
|
|
123
|
+
if (this.phase.kind === "closing") {
|
|
121
124
|
throw new Error("Client disconnecting");
|
|
122
125
|
}
|
|
123
126
|
const connection = this.connection;
|
|
@@ -131,60 +134,71 @@ export class IntercomClient extends EventEmitter {
|
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
async connect(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void> {
|
|
134
|
-
if (this.connection || this.
|
|
137
|
+
if (this.connection || this.phase.kind !== "idle") {
|
|
135
138
|
throw new Error("Already connected");
|
|
136
139
|
}
|
|
137
|
-
this.
|
|
140
|
+
const task = this.establish(session, requestedId);
|
|
141
|
+
this.phase = { kind: "connecting", task };
|
|
138
142
|
try {
|
|
139
|
-
|
|
140
|
-
const connection = new BrokerConnection(socket, {
|
|
141
|
-
message: (message) => this.handleBrokerMessage(message),
|
|
142
|
-
error: (error) => {
|
|
143
|
-
this.emit("error", error);
|
|
144
|
-
},
|
|
145
|
-
closed: (error, expected) => {
|
|
146
|
-
if (this.connection !== connection) {
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
const established = this._sessionId !== null;
|
|
150
|
-
this.connection = null;
|
|
151
|
-
this._sessionId = null;
|
|
152
|
-
this._topicsSupported = false;
|
|
153
|
-
this.parking.settle(false);
|
|
154
|
-
this.guardedDeliverySupported = false;
|
|
155
|
-
this.wakes.supported = false;
|
|
156
|
-
this.disconnecting = false;
|
|
157
|
-
this.failPending(error);
|
|
158
|
-
if (established && !expected) {
|
|
159
|
-
this.emit("disconnected", error);
|
|
160
|
-
}
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
this.connection = connection;
|
|
164
|
-
const { topics, subscriptions, ...identity } = session;
|
|
165
|
-
try {
|
|
166
|
-
await connection.start({
|
|
167
|
-
type: "register",
|
|
168
|
-
session: identity,
|
|
169
|
-
requestedId,
|
|
170
|
-
topicFrames: true,
|
|
171
|
-
lastMessages: true,
|
|
172
|
-
parking: true,
|
|
173
|
-
receiptReads: 1,
|
|
174
|
-
guardedDelivery: 1,
|
|
175
|
-
...this.wakes.registration(),
|
|
176
|
-
});
|
|
177
|
-
} catch (error) {
|
|
178
|
-
if (this.connection === connection) {
|
|
179
|
-
this.connection = null;
|
|
180
|
-
}
|
|
181
|
-
throw error;
|
|
182
|
-
}
|
|
183
|
-
if (this.supportsTopics) {
|
|
184
|
-
await this.restoreTopics(topics, subscriptions);
|
|
185
|
-
}
|
|
143
|
+
await task;
|
|
186
144
|
} finally {
|
|
187
|
-
this.connecting
|
|
145
|
+
if (this.phase.kind === "connecting" && this.phase.task === task) {
|
|
146
|
+
this.phase = { kind: this.isConnected() ? "connected" : "idle" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async establish(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void> {
|
|
152
|
+
const socket = await connectBrokerSocket();
|
|
153
|
+
const connection = new BrokerConnection(socket, {
|
|
154
|
+
message: (message) => this.handleBrokerMessage(message),
|
|
155
|
+
error: (error) => {
|
|
156
|
+
this.emit("error", error);
|
|
157
|
+
},
|
|
158
|
+
closed: (error, expected) => {
|
|
159
|
+
if (this.connection !== connection) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const established = this._sessionId !== null;
|
|
163
|
+
this.connection = null;
|
|
164
|
+
this._sessionId = null;
|
|
165
|
+
this._topicsSupported = false;
|
|
166
|
+
this.parking.settle(false);
|
|
167
|
+
this.guardedDeliverySupported = false;
|
|
168
|
+
this.wakes.supported = false;
|
|
169
|
+
if (this.phase.kind === "connected") {
|
|
170
|
+
this.phase = { kind: "idle" };
|
|
171
|
+
}
|
|
172
|
+
this.failPending(error);
|
|
173
|
+
if (established && !expected) {
|
|
174
|
+
this.emit("disconnected", error);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
this.connection = connection;
|
|
179
|
+
if (this.phase.kind === "closing") {
|
|
180
|
+
await connection.disconnect();
|
|
181
|
+
throw new Error("Client disconnecting");
|
|
182
|
+
}
|
|
183
|
+
const { topics, subscriptions, ...identity } = session;
|
|
184
|
+
try {
|
|
185
|
+
await connection.start({
|
|
186
|
+
type: "register",
|
|
187
|
+
session: identity,
|
|
188
|
+
requestedId,
|
|
189
|
+
topicFrames: true,
|
|
190
|
+
lastMessages: true,
|
|
191
|
+
parking: true,
|
|
192
|
+
receiptReads: 1,
|
|
193
|
+
guardedDelivery: 1,
|
|
194
|
+
...this.wakes.registration(),
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
await connection.disconnect();
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
if (this.supportsTopics) {
|
|
201
|
+
await this.restoreTopics(topics, subscriptions);
|
|
188
202
|
}
|
|
189
203
|
}
|
|
190
204
|
|
|
@@ -334,14 +348,30 @@ export class IntercomClient extends EventEmitter {
|
|
|
334
348
|
return this.parking.request(() => this.requireActiveSocket(), on);
|
|
335
349
|
}
|
|
336
350
|
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
340
|
-
return;
|
|
351
|
+
disconnect(): Promise<void> {
|
|
352
|
+
const phase = this.phase;
|
|
353
|
+
if (phase.kind === "closing") {
|
|
354
|
+
return phase.task;
|
|
341
355
|
}
|
|
342
|
-
|
|
356
|
+
const connecting = phase.kind === "connecting" ? phase.task : undefined;
|
|
357
|
+
const task = Promise.resolve().then(() => this.close(connecting));
|
|
358
|
+
this.phase = { kind: "closing", task };
|
|
359
|
+
return task;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private async close(connecting: Promise<void> | undefined): Promise<void> {
|
|
343
363
|
this.failPending(new Error("Client disconnected"));
|
|
344
|
-
|
|
364
|
+
try {
|
|
365
|
+
await this.connection?.disconnect();
|
|
366
|
+
try {
|
|
367
|
+
await connecting;
|
|
368
|
+
} catch {
|
|
369
|
+
// The connect caller owns acquisition failure; close still joins any acquired socket.
|
|
370
|
+
}
|
|
371
|
+
await this.connection?.disconnect();
|
|
372
|
+
} finally {
|
|
373
|
+
this.phase = { kind: "idle" };
|
|
374
|
+
}
|
|
345
375
|
}
|
|
346
376
|
|
|
347
377
|
listSessions(): Promise<SessionInfo[]> {
|
|
@@ -495,7 +525,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
495
525
|
readonly subscriptions?: SessionInfo["subscriptions"] | undefined;
|
|
496
526
|
readonly topics?: readonly TopicUpdate[] | undefined;
|
|
497
527
|
}): void {
|
|
498
|
-
if (this.
|
|
528
|
+
if (this.phase.kind === "closing") {
|
|
499
529
|
return;
|
|
500
530
|
}
|
|
501
531
|
|
|
@@ -43,16 +43,20 @@ interface ConnectionHandlers {
|
|
|
43
43
|
readonly closed: (error: Readonly<Error>, expected: boolean) => void;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
type ConnectionPhase =
|
|
47
|
+
| { readonly kind: "acquired" | "active" | "failed" | "closed" }
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: "registering";
|
|
50
|
+
readonly resolve: () => void;
|
|
51
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
52
|
+
readonly timer: NodeJS.Timeout;
|
|
53
|
+
}
|
|
54
|
+
| { readonly kind: "closing"; readonly task: Promise<void>; readonly registered: boolean };
|
|
55
|
+
|
|
46
56
|
/** One socket owns registration, read/error listeners, and graceful shutdown. */
|
|
47
57
|
export class BrokerConnection {
|
|
48
|
-
private
|
|
49
|
-
private closing = false;
|
|
58
|
+
private phase: ConnectionPhase = { kind: "acquired" };
|
|
50
59
|
private failure: Readonly<Error> | null = null;
|
|
51
|
-
private registration: {
|
|
52
|
-
readonly resolve: () => void;
|
|
53
|
-
readonly reject: (error: Readonly<Error>) => void;
|
|
54
|
-
} | null = null;
|
|
55
|
-
private registrationTimer: NodeJS.Timeout | null = null;
|
|
56
60
|
private readonly reader: (data: Buffer) => void;
|
|
57
61
|
|
|
58
62
|
readonly socket: net.Socket;
|
|
@@ -69,12 +73,9 @@ export class BrokerConnection {
|
|
|
69
73
|
|
|
70
74
|
start(registration: unknown): Promise<void> {
|
|
71
75
|
return new Promise((resolve, reject) => {
|
|
72
|
-
this.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
10000,
|
|
76
|
-
);
|
|
77
|
-
this.registrationTimer.unref();
|
|
76
|
+
const timer = setTimeout(() => this.failRegistration(new Error("Connection timeout")), 10000);
|
|
77
|
+
timer.unref();
|
|
78
|
+
this.phase = { kind: "registering", resolve, reject, timer };
|
|
78
79
|
try {
|
|
79
80
|
writeMessage(this.socket, registration);
|
|
80
81
|
} catch (error) {
|
|
@@ -84,23 +85,28 @@ export class BrokerConnection {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
confirmRegistration(): void {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
const pending = this.takeRegistration();
|
|
89
|
+
if (pending !== undefined) {
|
|
90
|
+
this.phase = { kind: "active" };
|
|
91
|
+
pending.resolve();
|
|
92
|
+
}
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
private
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
private takeRegistration() {
|
|
96
|
+
const pending = this.phase;
|
|
97
|
+
if (pending.kind !== "registering") {
|
|
98
|
+
return;
|
|
96
99
|
}
|
|
97
|
-
|
|
98
|
-
this.
|
|
100
|
+
clearTimeout(pending.timer);
|
|
101
|
+
this.phase = { kind: "acquired" };
|
|
102
|
+
return pending;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
private failRegistration(error: Readonly<Error>): void {
|
|
102
|
-
const pending = this.
|
|
103
|
-
this.
|
|
106
|
+
const pending = this.takeRegistration();
|
|
107
|
+
if (this.phase.kind !== "closing") {
|
|
108
|
+
this.phase = { kind: "failed" };
|
|
109
|
+
}
|
|
104
110
|
this.failure = error;
|
|
105
111
|
this.socket.destroy();
|
|
106
112
|
pending?.reject(error);
|
|
@@ -127,19 +133,23 @@ export class BrokerConnection {
|
|
|
127
133
|
};
|
|
128
134
|
|
|
129
135
|
private readonly onClose = (): void => {
|
|
130
|
-
const
|
|
131
|
-
this.
|
|
136
|
+
const closing = this.phase.kind === "closing";
|
|
137
|
+
const pending = this.takeRegistration();
|
|
138
|
+
this.phase = { kind: "closed" };
|
|
132
139
|
this.socket.off("data", this.reader);
|
|
133
140
|
this.socket.off("error", this.onError);
|
|
134
141
|
this.socket.off("close", this.onClose);
|
|
135
142
|
pending?.reject(new Error("Connection closed before registration"));
|
|
136
|
-
this.handlers.closed(this.failure ?? new Error("Client disconnected"),
|
|
143
|
+
this.handlers.closed(this.failure ?? new Error("Client disconnected"), closing);
|
|
137
144
|
};
|
|
138
145
|
|
|
146
|
+
private get registered(): boolean {
|
|
147
|
+
return this.phase.kind === "active" || (this.phase.kind === "closing" && this.phase.registered);
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
isActive(): boolean {
|
|
140
151
|
return (
|
|
141
|
-
this.
|
|
142
|
-
!this.closing &&
|
|
152
|
+
this.phase.kind === "active" &&
|
|
143
153
|
!this.socket.destroyed &&
|
|
144
154
|
!this.socket.writableEnded &&
|
|
145
155
|
this.socket.writable
|
|
@@ -147,15 +157,18 @@ export class BrokerConnection {
|
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
disconnect(): Promise<void> {
|
|
150
|
-
|
|
160
|
+
const phase = this.phase;
|
|
161
|
+
if (phase.kind === "closing") {
|
|
162
|
+
return phase.task;
|
|
163
|
+
}
|
|
164
|
+
if (phase.kind === "closed") {
|
|
165
|
+
return Promise.resolve();
|
|
166
|
+
}
|
|
167
|
+
const pending = this.takeRegistration();
|
|
168
|
+
pending?.reject(new Error("Client disconnected before registration"));
|
|
151
169
|
this.failure = null;
|
|
152
|
-
|
|
153
|
-
let settled = false;
|
|
170
|
+
const task = new Promise<void>((resolve) => {
|
|
154
171
|
const finish = () => {
|
|
155
|
-
if (settled) {
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
settled = true;
|
|
159
172
|
clearTimeout(timeout);
|
|
160
173
|
this.socket.off("close", finish);
|
|
161
174
|
this.socket.off("error", onError);
|
|
@@ -169,12 +182,18 @@ export class BrokerConnection {
|
|
|
169
182
|
}, 2000);
|
|
170
183
|
this.socket.once("close", finish);
|
|
171
184
|
this.socket.once("error", onError);
|
|
172
|
-
|
|
185
|
+
});
|
|
186
|
+
this.phase = { kind: "closing", task, registered: phase.kind === "active" };
|
|
187
|
+
try {
|
|
188
|
+
if (phase.kind === "active" || phase.kind === "registering") {
|
|
173
189
|
writeMessage(this.socket, { type: "unregister" });
|
|
174
190
|
this.socket.end();
|
|
175
|
-
}
|
|
191
|
+
} else {
|
|
176
192
|
this.socket.destroy();
|
|
177
193
|
}
|
|
178
|
-
}
|
|
194
|
+
} catch {
|
|
195
|
+
this.socket.destroy();
|
|
196
|
+
}
|
|
197
|
+
return task;
|
|
179
198
|
}
|
|
180
199
|
}
|
|
@@ -90,10 +90,15 @@ class MessageBuffer {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
type FrameState =
|
|
94
|
+
| { readonly kind: "header" }
|
|
95
|
+
| { readonly kind: "payload"; readonly length: number }
|
|
96
|
+
| { readonly kind: "failed" };
|
|
97
|
+
|
|
93
98
|
/**
|
|
94
99
|
* Create a message reader that handles partial reads.
|
|
95
100
|
* Calls onMessage for each complete message received.
|
|
96
|
-
* Protocol or handler errors are reported
|
|
101
|
+
* Protocol or handler errors end reading and are reported so the caller can close the socket.
|
|
97
102
|
*/
|
|
98
103
|
export function createMessageReader(
|
|
99
104
|
onMessage: (msg: unknown) => void,
|
|
@@ -101,32 +106,39 @@ export function createMessageReader(
|
|
|
101
106
|
limit = MAX_FRAME_SIZE_BYTES,
|
|
102
107
|
): (data: Buffer) => void {
|
|
103
108
|
const buffer = new MessageBuffer();
|
|
104
|
-
let
|
|
109
|
+
let state: FrameState = { kind: "header" };
|
|
110
|
+
const fail = (error: Readonly<Error>) => {
|
|
111
|
+
state = { kind: "failed" };
|
|
112
|
+
buffer.clear();
|
|
113
|
+
onError(error);
|
|
114
|
+
};
|
|
105
115
|
|
|
106
116
|
return (data: Buffer) => {
|
|
117
|
+
if (state.kind === "failed") {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
107
120
|
buffer.append(data);
|
|
108
121
|
|
|
109
|
-
while (buffer.length >= (length
|
|
110
|
-
if (
|
|
111
|
-
length = buffer.read(4).readUInt32BE(0);
|
|
122
|
+
while (buffer.length >= (state.kind === "payload" ? state.length : 4)) {
|
|
123
|
+
if (state.kind === "header") {
|
|
124
|
+
const length = buffer.read(4).readUInt32BE(0);
|
|
112
125
|
if (length > limit) {
|
|
113
|
-
|
|
114
|
-
buffer.clear();
|
|
115
|
-
length = undefined;
|
|
126
|
+
fail(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
|
|
116
127
|
return;
|
|
117
128
|
}
|
|
129
|
+
state = { kind: "payload", length };
|
|
118
130
|
continue;
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
const payload = buffer.read(length);
|
|
122
|
-
|
|
133
|
+
const payload = buffer.read(state.length);
|
|
134
|
+
state = { kind: "header" };
|
|
123
135
|
|
|
124
136
|
let msg: unknown;
|
|
125
137
|
try {
|
|
126
138
|
msg = JSON.parse(payload.toString("utf-8"));
|
|
127
139
|
} catch (error) {
|
|
128
140
|
const message = errorMessage(error);
|
|
129
|
-
|
|
141
|
+
fail(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
|
|
130
142
|
return;
|
|
131
143
|
}
|
|
132
144
|
|
|
@@ -134,7 +146,7 @@ export function createMessageReader(
|
|
|
134
146
|
onMessage(msg);
|
|
135
147
|
} catch (error) {
|
|
136
148
|
const message = errorMessage(error);
|
|
137
|
-
|
|
149
|
+
fail(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
|
|
138
150
|
return;
|
|
139
151
|
}
|
|
140
152
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Message, SessionInfo } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
4
|
+
|
|
5
|
+
interface StagedDelivery {
|
|
6
|
+
readonly fromId: string;
|
|
7
|
+
readonly toId: string;
|
|
8
|
+
readonly from: SessionInfo;
|
|
9
|
+
readonly message: Message;
|
|
10
|
+
readonly timer: NodeJS.Timeout;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Only broker-staged messages remain replaceable; forwarding ends this owner's authority. */
|
|
14
|
+
export class ReplaceDeliveries {
|
|
15
|
+
private readonly pending = new Map<string, StagedDelivery>();
|
|
16
|
+
private readonly deliver: (toId: string, from: SessionInfo, message: Message) => string | null;
|
|
17
|
+
|
|
18
|
+
constructor(deliver: (toId: string, from: SessionInfo, message: Message) => string | null) {
|
|
19
|
+
this.deliver = deliver;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
stage(fromId: string, toId: string, from: SessionInfo, message: Message): void {
|
|
23
|
+
const key = `${fromId}\0${toId}\0${message.threadId ?? ""}`;
|
|
24
|
+
this.take(key);
|
|
25
|
+
const timer = setTimeout(() => this.flush(key), REPLACE_DELIVERY_DELAY_MS);
|
|
26
|
+
timer.unref();
|
|
27
|
+
this.pending.set(key, { fromId, toId, from, message, timer });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private take(key: string): StagedDelivery | undefined {
|
|
31
|
+
const pending = this.pending.get(key);
|
|
32
|
+
if (pending !== undefined) {
|
|
33
|
+
this.pending.delete(key);
|
|
34
|
+
clearTimeout(pending.timer);
|
|
35
|
+
}
|
|
36
|
+
return pending;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private flush(key: string): void {
|
|
40
|
+
const pending = this.take(key);
|
|
41
|
+
if (pending !== undefined) {
|
|
42
|
+
this.deliver(pending.toId, pending.from, pending.message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
disconnected(sessionId: string): void {
|
|
47
|
+
for (const [key, pending] of this.pending) {
|
|
48
|
+
if (
|
|
49
|
+
pending.toId === sessionId ||
|
|
50
|
+
(pending.fromId === sessionId && pending.message.expectsReply === true)
|
|
51
|
+
) {
|
|
52
|
+
this.take(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -43,8 +43,10 @@ export class IntercomConnection {
|
|
|
43
43
|
private parked: IntercomClient | null = null;
|
|
44
44
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
45
45
|
private startupTimer: NodeJS.Timeout | null = null;
|
|
46
|
-
private connecting:
|
|
47
|
-
|
|
46
|
+
private connecting: {
|
|
47
|
+
readonly task: Promise<IntercomTransport>;
|
|
48
|
+
readonly generation: number;
|
|
49
|
+
} | null = null;
|
|
48
50
|
private attempt = 0;
|
|
49
51
|
private readonly lifecycle: Lifecycle;
|
|
50
52
|
private readonly config: IntercomConfig;
|
|
@@ -352,8 +354,8 @@ export class IntercomConnection {
|
|
|
352
354
|
if (!ctx || generation !== this.lifecycle.generation) {
|
|
353
355
|
throw new Error("Intercom shutting down");
|
|
354
356
|
}
|
|
355
|
-
if (this.connecting
|
|
356
|
-
return this.connecting;
|
|
357
|
+
if (this.connecting?.generation === generation) {
|
|
358
|
+
return this.connecting.task;
|
|
357
359
|
}
|
|
358
360
|
if (this.client?.isConnected() === true) {
|
|
359
361
|
return this.client;
|
|
@@ -365,8 +367,7 @@ export class IntercomConnection {
|
|
|
365
367
|
this.clearReconnect();
|
|
366
368
|
// Publish ownership before registration, which can synchronously invoke subscribers.
|
|
367
369
|
const attempt = Promise.resolve().then(() => this.connect(ctx, generation, sessionId));
|
|
368
|
-
this.connecting = attempt;
|
|
369
|
-
this.connectingGeneration = generation;
|
|
370
|
+
this.connecting = { task: attempt, generation };
|
|
370
371
|
return await this.finishAttempt(attempt, ctx, generation);
|
|
371
372
|
}
|
|
372
373
|
private async finishAttempt(
|
|
@@ -381,9 +382,8 @@ export class IntercomConnection {
|
|
|
381
382
|
retry = this.lifecycle.live(ctx, generation) !== null;
|
|
382
383
|
throw error;
|
|
383
384
|
} finally {
|
|
384
|
-
if (this.connecting === attempt) {
|
|
385
|
+
if (this.connecting?.task === attempt) {
|
|
385
386
|
this.connecting = null;
|
|
386
|
-
this.connectingGeneration = null;
|
|
387
387
|
}
|
|
388
388
|
if (retry) {
|
|
389
389
|
this.scheduleReconnect();
|
|
@@ -394,7 +394,7 @@ export class IntercomConnection {
|
|
|
394
394
|
this.clearTimers();
|
|
395
395
|
this.cancelWakes();
|
|
396
396
|
// The bounded launcher attempt is joined before socket shutdown.
|
|
397
|
-
await Promise.allSettled([this.connecting]);
|
|
397
|
+
await Promise.allSettled([this.connecting?.task]);
|
|
398
398
|
if (this.client) {
|
|
399
399
|
await this.client.disconnect();
|
|
400
400
|
this.client = null;
|
|
@@ -191,13 +191,16 @@ export class InboundJournal {
|
|
|
191
191
|
pending.from.id === entry.from.id &&
|
|
192
192
|
pending.message.threadId === entry.message.threadId
|
|
193
193
|
) {
|
|
194
|
-
this.
|
|
195
|
-
this.replies.markReplied(pending.message.id);
|
|
194
|
+
this.supersede(pending.message.id);
|
|
196
195
|
}
|
|
197
196
|
}
|
|
198
197
|
}
|
|
199
198
|
this.pending.set(entry.message.id, entry);
|
|
200
199
|
}
|
|
200
|
+
private supersede(id: string): void {
|
|
201
|
+
this.consume(id);
|
|
202
|
+
this.replies.markReplied(id);
|
|
203
|
+
}
|
|
201
204
|
setStage(entry: PendingInboundMessage, stage: PendingInboundMessage["stage"]): void {
|
|
202
205
|
if (entry.stage === stage) {
|
|
203
206
|
return;
|
|
@@ -16,13 +16,15 @@ export interface JournalOptions {
|
|
|
16
16
|
readonly stringChunk?: StringChunk | undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
type RecordState =
|
|
20
|
+
| { readonly kind: "blank" | "reading" }
|
|
21
|
+
| { readonly kind: "invalid"; readonly error: unknown };
|
|
22
|
+
|
|
19
23
|
/** Byte-framed JSONL. Publication framing is independent of malformed-record tolerance. */
|
|
20
24
|
export class JournalFrames {
|
|
21
25
|
private projection: JsonProjection;
|
|
22
26
|
private decoder = new TextDecoder("utf8", { fatal: true });
|
|
23
|
-
private
|
|
24
|
-
private failed = false;
|
|
25
|
-
private nonblank = false;
|
|
27
|
+
private state: RecordState = { kind: "blank" };
|
|
26
28
|
private start: number;
|
|
27
29
|
offset: number;
|
|
28
30
|
private readonly policy: JournalPolicy;
|
|
@@ -48,20 +50,20 @@ export class JournalFrames {
|
|
|
48
50
|
return new JsonProjection(this.select, this.options.stringChunk);
|
|
49
51
|
}
|
|
50
52
|
private part(bytes: Buffer): void {
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
+
if (this.state.kind === "invalid") {
|
|
54
|
+
return;
|
|
53
55
|
}
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
56
|
+
if (this.state.kind === "blank" && bytes.some((byte) => ![9, 10, 13, 32].includes(byte))) {
|
|
57
|
+
this.state = { kind: "reading" };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
this.projection.write(this.decoder.decode(bytes, { stream: true }));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
this.state = { kind: "invalid", error };
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
private finishRecord(end: number): JournalRecord | undefined {
|
|
64
|
-
if (
|
|
66
|
+
if (this.state.kind !== "reading") {
|
|
65
67
|
return undefined;
|
|
66
68
|
}
|
|
67
69
|
try {
|
|
@@ -72,17 +74,16 @@ export class JournalFrames {
|
|
|
72
74
|
}
|
|
73
75
|
return { value, start: this.start, end };
|
|
74
76
|
} catch (error) {
|
|
75
|
-
this.
|
|
76
|
-
this.error = error;
|
|
77
|
+
this.state = { kind: "invalid", error };
|
|
77
78
|
return undefined;
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
private commit(end: number): void {
|
|
81
82
|
const start = this.start;
|
|
82
83
|
const record = this.finishRecord(end);
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
const state = this.state;
|
|
85
|
+
if (state.kind === "invalid" && this.policy !== "inspect") {
|
|
86
|
+
const { error } = state;
|
|
86
87
|
throw new SyntaxError(`Invalid JSONL record at byte ${start}: ${errorDescription(error)}`, {
|
|
87
88
|
cause: error,
|
|
88
89
|
});
|
|
@@ -90,10 +91,8 @@ export class JournalFrames {
|
|
|
90
91
|
this.start = end;
|
|
91
92
|
this.projection = this.newProjection();
|
|
92
93
|
this.decoder = new TextDecoder("utf8", { fatal: true });
|
|
93
|
-
this.
|
|
94
|
-
|
|
95
|
-
this.nonblank = false;
|
|
96
|
-
if (!failed && record) {
|
|
94
|
+
this.state = { kind: "blank" };
|
|
95
|
+
if (record) {
|
|
97
96
|
this.record(record);
|
|
98
97
|
}
|
|
99
98
|
}
|
|
@@ -110,7 +109,7 @@ export class JournalFrames {
|
|
|
110
109
|
}
|
|
111
110
|
finish(): number {
|
|
112
111
|
if (this.requireNewline) {
|
|
113
|
-
if (this.policy === "strict" && this.
|
|
112
|
+
if (this.policy === "strict" && this.state.kind !== "blank") {
|
|
114
113
|
throw new SyntaxError(`Unpublished JSONL record at byte ${this.start}: newline required`);
|
|
115
114
|
}
|
|
116
115
|
} else if (this.offset > this.start) {
|