@fitchmultz/pi-subagents 0.51.0 → 0.52.1
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 +18 -0
- package/dist/agent/child-runtime.js +80 -0
- package/dist/agent/contracts.js +9 -0
- package/dist/agent/index.js +6 -20
- package/dist/agent/observation.js +4 -0
- package/dist/agent/parking.js +253 -0
- package/dist/agent/process.js +31 -4
- package/dist/agent/runner.js +124 -126
- package/dist/agent/shell.js +2 -2
- package/dist/agent/storage.js +18 -0
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +42 -29
- package/dist/pi-intercom/broker/client-last.d.ts +12 -1
- package/dist/pi-intercom/broker/client-last.js +25 -0
- package/dist/pi-intercom/broker/client-park.d.ts +9 -0
- package/dist/pi-intercom/broker/client-park.js +21 -0
- package/dist/pi-intercom/broker/client.d.ts +2 -3
- package/dist/pi-intercom/broker/client.js +23 -28
- package/dist/pi-intercom/broker/last.d.ts +5 -1
- package/dist/pi-intercom/broker/last.js +34 -12
- package/dist/pi-intercom/broker/parked.js +107 -0
- package/dist/pi-intercom/broker/server-connection.js +1 -1
- package/dist/pi-intercom/connection.js +65 -22
- package/dist/pi-intercom/inbound.js +11 -0
- package/dist/pi-intercom/index.js +38 -0
- package/dist/shared/parking.js +12 -0
- package/dist/shared/post-exit-stdio-guard.js +4 -2
- package/docs/code-quality.md +5 -5
- package/docs/development.md +3 -3
- package/docs/intercom.md +2 -1
- package/docs/performance.md +31 -3
- package/docs/reference.md +12 -1
- package/package.json +1 -1
- package/scripts/measure-size.mjs +1 -1
- package/src/agent/child-runtime.ts +105 -0
- package/src/agent/contracts.ts +10 -0
- package/src/agent/host.ts +22 -2
- package/src/agent/index.ts +10 -17
- package/src/agent/observation.ts +4 -0
- package/src/agent/parking.ts +341 -0
- package/src/agent/process.ts +41 -4
- package/src/agent/runner.ts +124 -144
- package/src/agent/shell.ts +6 -2
- package/src/agent/storage.ts +17 -0
- package/src/pi-intercom/broker/broker.ts +44 -37
- package/src/pi-intercom/broker/client-last.ts +41 -1
- package/src/pi-intercom/broker/client-park.ts +23 -0
- package/src/pi-intercom/broker/client.ts +24 -28
- package/src/pi-intercom/broker/last.ts +49 -14
- package/src/pi-intercom/broker/parked.ts +130 -0
- package/src/pi-intercom/broker/server-connection.ts +2 -2
- package/src/pi-intercom/connection.ts +87 -34
- package/src/pi-intercom/inbound.ts +14 -0
- package/src/pi-intercom/index.ts +41 -0
- package/src/pi-intercom/last-message.ts +1 -1
- package/src/shared/parking.ts +22 -0
- package/src/shared/post-exit-stdio-guard.ts +5 -2
|
@@ -6,12 +6,42 @@ import {
|
|
|
6
6
|
isNativeReadResult,
|
|
7
7
|
isLastMessageResult,
|
|
8
8
|
isReceiptResult,
|
|
9
|
+
type NativeReadQuery,
|
|
9
10
|
type NativeReadResult,
|
|
10
11
|
} from "./last.ts";
|
|
11
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
normalizeSessionInfo,
|
|
14
|
+
type LastMessageQuery,
|
|
15
|
+
type LastMessageResult,
|
|
16
|
+
type ReceiptQuery,
|
|
17
|
+
type ReceiptResult,
|
|
18
|
+
type SessionInfo,
|
|
19
|
+
} from "../types.ts";
|
|
12
20
|
import { writeMessage } from "./framing.ts";
|
|
13
21
|
|
|
22
|
+
/** A peer's read of this session or, for a main, of its exited `parked` child. */
|
|
23
|
+
export function lastRequest(msg: UnknownRecord): {
|
|
24
|
+
readonly requestId: string;
|
|
25
|
+
readonly from: SessionInfo;
|
|
26
|
+
readonly query: NativeReadQuery;
|
|
27
|
+
readonly parked?: SessionInfo;
|
|
28
|
+
} {
|
|
29
|
+
const from = normalizeSessionInfo(msg.from);
|
|
30
|
+
if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
|
|
31
|
+
throw new Error("Invalid last-message request");
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
requestId: msg.requestId,
|
|
35
|
+
from,
|
|
36
|
+
query: msg.query,
|
|
37
|
+
parked: normalizeSessionInfo(msg.parked) ?? undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
14
41
|
export class LastMessageRequests {
|
|
42
|
+
/** Negotiated at registration; a failed connection supports neither. */
|
|
43
|
+
lastMessages = false;
|
|
44
|
+
receiptReads = false;
|
|
15
45
|
private readonly timeoutMs: number;
|
|
16
46
|
constructor(timeoutMs: number) {
|
|
17
47
|
this.timeoutMs = timeoutMs;
|
|
@@ -26,6 +56,8 @@ export class LastMessageRequests {
|
|
|
26
56
|
>();
|
|
27
57
|
|
|
28
58
|
fail(error: Readonly<Error>): void {
|
|
59
|
+
this.lastMessages = false;
|
|
60
|
+
this.receiptReads = false;
|
|
29
61
|
for (const pending of this.pending.values()) {
|
|
30
62
|
clearTimeout(pending.timer);
|
|
31
63
|
pending.reject(error);
|
|
@@ -55,9 +87,17 @@ export class LastMessageRequests {
|
|
|
55
87
|
return isReceiptResult(result) ? result : { error: "Invalid receipt response" };
|
|
56
88
|
}
|
|
57
89
|
private request(socket: net.Socket, to: string, query: unknown): Promise<NativeReadResult> {
|
|
90
|
+
if (!this.lastMessages) {
|
|
91
|
+
return Promise.reject(
|
|
92
|
+
new Error("This broker does not support last-message reads; reload after it exits."),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
58
95
|
if (!isNativeReadQuery(query)) {
|
|
59
96
|
return Promise.reject(new Error("Invalid last-message query"));
|
|
60
97
|
}
|
|
98
|
+
if ("messageId" in query && !this.receiptReads) {
|
|
99
|
+
return Promise.reject(new Error("publication_unavailable"));
|
|
100
|
+
}
|
|
61
101
|
return new Promise((resolve, reject) => {
|
|
62
102
|
const requestId = randomUUID();
|
|
63
103
|
const timer = setTimeout(() => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type net from "node:net";
|
|
2
|
+
import { writeMessage } from "./framing.ts";
|
|
3
|
+
|
|
4
|
+
/** Parking negotiation and its single outstanding broker acknowledgement. */
|
|
5
|
+
export class ParkRequests {
|
|
6
|
+
supported = false;
|
|
7
|
+
private acknowledged?: (parked: boolean) => void;
|
|
8
|
+
settle(parked: boolean): void {
|
|
9
|
+
this.acknowledged?.(parked);
|
|
10
|
+
}
|
|
11
|
+
/** True once the broker retains later input; false when it cannot or the socket closed. */
|
|
12
|
+
request(socket: () => net.Socket, on: boolean): Promise<boolean> {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
this.acknowledged = on && this.supported ? resolve : undefined;
|
|
15
|
+
if (this.supported) {
|
|
16
|
+
writeMessage(socket(), { type: on ? "park" : "unpark" });
|
|
17
|
+
}
|
|
18
|
+
if (this.acknowledged === undefined) {
|
|
19
|
+
resolve(false);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -4,8 +4,9 @@ import { randomUUID } from "crypto";
|
|
|
4
4
|
import { writeMessage } from "./framing.ts";
|
|
5
5
|
import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
|
|
6
6
|
import { SnapshotReader } from "./snapshot-reader.ts";
|
|
7
|
-
import { LastMessageRequests } from "./client-last.ts";
|
|
8
|
-
import {
|
|
7
|
+
import { LastMessageRequests, lastRequest } from "./client-last.ts";
|
|
8
|
+
import { ParkRequests } from "./client-park.ts";
|
|
9
|
+
import { isNativeReadResult } from "./last.ts";
|
|
9
10
|
import {
|
|
10
11
|
errorMessage,
|
|
11
12
|
isRecord,
|
|
@@ -64,8 +65,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
64
65
|
private connection: BrokerConnection | null = null;
|
|
65
66
|
private _sessionId: string | null = null;
|
|
66
67
|
private _topicsSupported = false;
|
|
67
|
-
private
|
|
68
|
-
private receiptReadsSupported = false;
|
|
68
|
+
private readonly parking = new ParkRequests();
|
|
69
69
|
private guardedDeliverySupported = false;
|
|
70
70
|
private readonly lastRequests: LastMessageRequests;
|
|
71
71
|
private readonly pendingSends = new Map<
|
|
@@ -156,8 +156,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
156
156
|
this.connection = null;
|
|
157
157
|
this._sessionId = null;
|
|
158
158
|
this._topicsSupported = false;
|
|
159
|
-
this.
|
|
160
|
-
this.receiptReadsSupported = false;
|
|
159
|
+
this.parking.settle(false);
|
|
161
160
|
this.guardedDeliverySupported = false;
|
|
162
161
|
this.disconnecting = false;
|
|
163
162
|
this.failPending(error);
|
|
@@ -175,6 +174,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
175
174
|
requestedId,
|
|
176
175
|
topicFrames: true,
|
|
177
176
|
lastMessages: true,
|
|
177
|
+
parking: true,
|
|
178
178
|
receiptReads: 1,
|
|
179
179
|
guardedDelivery: 1,
|
|
180
180
|
});
|
|
@@ -241,11 +241,15 @@ export class IntercomClient extends EventEmitter {
|
|
|
241
241
|
case "delivery_failed":
|
|
242
242
|
this.receiveDelivery(brokerMessage);
|
|
243
243
|
break;
|
|
244
|
+
case "parked":
|
|
245
|
+
this.parking.settle(true);
|
|
246
|
+
break;
|
|
247
|
+
case "wake":
|
|
244
248
|
case "session_left":
|
|
245
249
|
if (typeof brokerMessage.sessionId !== "string") {
|
|
246
|
-
throw new Error(
|
|
250
|
+
throw new Error(`Invalid ${type} message`);
|
|
247
251
|
}
|
|
248
|
-
this.emit(
|
|
252
|
+
this.emit(type, brokerMessage.sessionId);
|
|
249
253
|
break;
|
|
250
254
|
default:
|
|
251
255
|
throw new Error(`Unknown broker message type: ${type}`);
|
|
@@ -261,20 +265,18 @@ export class IntercomClient extends EventEmitter {
|
|
|
261
265
|
}
|
|
262
266
|
this._sessionId = msg.sessionId;
|
|
263
267
|
this._topicsSupported = msg.topicsSupported === true && msg.topicFrames === true;
|
|
264
|
-
this.
|
|
265
|
-
this.
|
|
268
|
+
this.lastRequests.lastMessages = msg.lastMessages === true;
|
|
269
|
+
this.parking.supported = msg.parking === true;
|
|
270
|
+
this.lastRequests.receiptReads = msg.receiptReads === 1;
|
|
266
271
|
this.guardedDeliverySupported = msg.guardedDelivery === 1;
|
|
267
272
|
this.connection?.confirmRegistration();
|
|
268
273
|
this.emit("_registered", { type: "registered", sessionId: msg.sessionId });
|
|
269
274
|
}
|
|
270
275
|
|
|
271
276
|
private receiveLastRequest(msg: UnknownRecord): void {
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
if (!this.emit("last_request", { requestId: msg.requestId, from, query: msg.query })) {
|
|
277
|
-
this.respondLast(msg.requestId, { error: "Recipient has no last-message reader." });
|
|
277
|
+
const request = lastRequest(msg);
|
|
278
|
+
if (!this.emit("last_request", request)) {
|
|
279
|
+
this.respondLast(request.requestId, { error: "Recipient has no last-message reader." });
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
282
|
|
|
@@ -335,6 +337,10 @@ export class IntercomClient extends EventEmitter {
|
|
|
335
337
|
}
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
park(on = true): Promise<boolean> {
|
|
341
|
+
return this.parking.request(() => this.requireActiveSocket(), on);
|
|
342
|
+
}
|
|
343
|
+
|
|
338
344
|
async disconnect(): Promise<void> {
|
|
339
345
|
const connection = this.connection;
|
|
340
346
|
if (!connection) {
|
|
@@ -350,20 +356,10 @@ export class IntercomClient extends EventEmitter {
|
|
|
350
356
|
}
|
|
351
357
|
|
|
352
358
|
async last(to: string, query: LastMessageQuery = {}): Promise<LastMessageResult> {
|
|
353
|
-
return await this.lastRequests.last(this.
|
|
359
|
+
return await this.lastRequests.last(this.requireActiveSocket(), to, query);
|
|
354
360
|
}
|
|
355
361
|
async receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult> {
|
|
356
|
-
return await this.lastRequests.receipt(this.
|
|
357
|
-
}
|
|
358
|
-
private readSocket(query: NativeReadQuery): net.Socket {
|
|
359
|
-
const socket = this.requireActiveSocket();
|
|
360
|
-
if (!this.lastMessagesSupported) {
|
|
361
|
-
throw new Error("This broker does not support last-message reads; reload after it exits.");
|
|
362
|
-
}
|
|
363
|
-
if ("messageId" in query && !this.receiptReadsSupported) {
|
|
364
|
-
throw new Error("publication_unavailable");
|
|
365
|
-
}
|
|
366
|
-
return socket;
|
|
362
|
+
return await this.lastRequests.receipt(this.requireActiveSocket(), to, query);
|
|
367
363
|
}
|
|
368
364
|
|
|
369
365
|
respondLast(requestId: string, result: unknown): void {
|
|
@@ -100,19 +100,37 @@ interface LastPeer {
|
|
|
100
100
|
readonly info: SessionInfo;
|
|
101
101
|
readonly lastMessages: boolean;
|
|
102
102
|
readonly receiptReads: boolean;
|
|
103
|
+
readonly parked?: { readonly closed: boolean };
|
|
103
104
|
}
|
|
104
105
|
interface PendingLast {
|
|
105
106
|
readonly fromId: string;
|
|
106
107
|
readonly toId: string;
|
|
108
|
+
/** A parked child's main answers from the child's captured native history. */
|
|
109
|
+
readonly via?: string;
|
|
107
110
|
readonly requestId: string;
|
|
108
111
|
readonly query: NativeReadQuery;
|
|
109
112
|
readonly timer: NodeJS.Timeout;
|
|
110
113
|
}
|
|
114
|
+
/** An exited parked child answers nothing itself; only its main holds the captured history. */
|
|
115
|
+
function exitedRefusal(
|
|
116
|
+
target: LastPeer,
|
|
117
|
+
query: NativeReadQuery,
|
|
118
|
+
via?: LastPeer,
|
|
119
|
+
): string | undefined {
|
|
120
|
+
if (target.parked?.closed !== true) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
// No loaded journal owner answers receipts, with or without its main.
|
|
124
|
+
if ("messageId" in query) {
|
|
125
|
+
return "publication_unavailable";
|
|
126
|
+
}
|
|
127
|
+
return via === undefined ? "Parked session history is unavailable." : undefined;
|
|
128
|
+
}
|
|
111
129
|
function unavailable(
|
|
112
130
|
targets: readonly LastPeer[],
|
|
113
131
|
name: string,
|
|
114
132
|
pending: number,
|
|
115
|
-
query: NativeReadQuery,
|
|
133
|
+
{ query, via }: { readonly query: NativeReadQuery; readonly via?: LastPeer },
|
|
116
134
|
): string | undefined {
|
|
117
135
|
if (targets.length === 0) {
|
|
118
136
|
return "Session not found";
|
|
@@ -120,6 +138,10 @@ function unavailable(
|
|
|
120
138
|
if (targets.length > 1) {
|
|
121
139
|
return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
|
|
122
140
|
}
|
|
141
|
+
const exited = exitedRefusal(targets[0], query, via);
|
|
142
|
+
if (exited !== undefined) {
|
|
143
|
+
return exited;
|
|
144
|
+
}
|
|
123
145
|
if (!targets[0].lastMessages) {
|
|
124
146
|
return "Recipient does not support last-message reads; reload its extension.";
|
|
125
147
|
}
|
|
@@ -164,6 +186,17 @@ function matchesLastPage(
|
|
|
164
186
|
);
|
|
165
187
|
}
|
|
166
188
|
|
|
189
|
+
function lastRequest(message: UnknownRecord): {
|
|
190
|
+
readonly requestId: string;
|
|
191
|
+
readonly to: string;
|
|
192
|
+
readonly query: NativeReadQuery;
|
|
193
|
+
} {
|
|
194
|
+
const { requestId, to, query } = message;
|
|
195
|
+
if (typeof requestId !== "string" || typeof to !== "string" || !isNativeReadQuery(query)) {
|
|
196
|
+
throw new Error("Invalid last message request");
|
|
197
|
+
}
|
|
198
|
+
return { requestId, to, query };
|
|
199
|
+
}
|
|
167
200
|
/** Correlation belongs to the broker, not the requester or the recipient's model queue. */
|
|
168
201
|
export class LastMessageRelay {
|
|
169
202
|
private readonly pending = new Map<string, PendingLast>();
|
|
@@ -172,14 +205,13 @@ export class LastMessageRelay {
|
|
|
172
205
|
this.sessions = sessions;
|
|
173
206
|
}
|
|
174
207
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
208
|
+
/** `reader` names another peer that answers for an exited target from its captured history. */
|
|
209
|
+
request(
|
|
210
|
+
fromId: string,
|
|
211
|
+
input: UnknownRecord,
|
|
212
|
+
reader: (id: string) => LastPeer | undefined,
|
|
213
|
+
): void {
|
|
214
|
+
const message = lastRequest(input);
|
|
183
215
|
const targets = findSessions(this.sessions, message.to, fromId);
|
|
184
216
|
const target = targets[0];
|
|
185
217
|
const from = this.sessions.get(fromId);
|
|
@@ -187,14 +219,16 @@ export class LastMessageRelay {
|
|
|
187
219
|
return;
|
|
188
220
|
}
|
|
189
221
|
const token = randomUUID();
|
|
222
|
+
const via = targets.length === 1 ? reader(target.info.id) : undefined;
|
|
190
223
|
const frame = {
|
|
191
224
|
type: "last_request" as const,
|
|
192
225
|
requestId: token,
|
|
193
226
|
from: messageSender(from.info),
|
|
194
227
|
query: message.query,
|
|
228
|
+
parked: via && messageSender(target.info),
|
|
195
229
|
};
|
|
196
230
|
const error =
|
|
197
|
-
unavailable(targets, message.to, this.pending.size, message.query) ??
|
|
231
|
+
unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
|
|
198
232
|
validateIntercomMessageSize(frame)?.message;
|
|
199
233
|
if (error !== undefined) {
|
|
200
234
|
writeMessage(from.socket, {
|
|
@@ -212,12 +246,13 @@ export class LastMessageRelay {
|
|
|
212
246
|
this.pending.set(token, {
|
|
213
247
|
fromId,
|
|
214
248
|
toId: target.info.id,
|
|
249
|
+
via: via?.info.id,
|
|
215
250
|
requestId: message.requestId,
|
|
216
251
|
query: message.query,
|
|
217
252
|
timer,
|
|
218
253
|
});
|
|
219
254
|
try {
|
|
220
|
-
writeMessage(target.socket, frame);
|
|
255
|
+
writeMessage((via ?? target).socket, frame);
|
|
221
256
|
} catch {
|
|
222
257
|
this.finish(token, { error: "Recipient disconnected before last-message read" });
|
|
223
258
|
}
|
|
@@ -228,13 +263,13 @@ export class LastMessageRelay {
|
|
|
228
263
|
throw new Error("Invalid last message response");
|
|
229
264
|
}
|
|
230
265
|
const pending = this.pending.get(message.requestId);
|
|
231
|
-
if (!pending || pending.toId !== fromId) {
|
|
266
|
+
if (!pending || (pending.via ?? pending.toId) !== fromId) {
|
|
232
267
|
return;
|
|
233
268
|
}
|
|
234
269
|
const result = message.result;
|
|
235
270
|
this.finish(
|
|
236
271
|
message.requestId,
|
|
237
|
-
isNativeReadResult(result) && matchesPage(result, pending,
|
|
272
|
+
isNativeReadResult(result) && matchesPage(result, pending, pending.toId)
|
|
238
273
|
? result
|
|
239
274
|
: { error: "Recipient returned an invalid last-message page" },
|
|
240
275
|
);
|
|
@@ -242,7 +277,7 @@ export class LastMessageRelay {
|
|
|
242
277
|
|
|
243
278
|
disconnected(sessionId: string): void {
|
|
244
279
|
for (const [token, pending] of this.pending) {
|
|
245
|
-
if (pending.fromId
|
|
280
|
+
if ([pending.fromId, pending.toId, pending.via].includes(sessionId)) {
|
|
246
281
|
this.finish(token, { error: "Peer disconnected during last-message read" });
|
|
247
282
|
}
|
|
248
283
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type net from "net";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import type { Message, SessionInfo } from "../types.ts";
|
|
4
|
+
import { nativePeerId } from "../session-targets.ts";
|
|
5
|
+
import { writeMessage } from "./framing.ts";
|
|
6
|
+
|
|
7
|
+
type Retained = Array<{ readonly from: SessionInfo; readonly message: Message }>;
|
|
8
|
+
interface Parked {
|
|
9
|
+
readonly retained: Retained;
|
|
10
|
+
/** The fenced actor's socket closed: input waits for its restored replacement. */
|
|
11
|
+
closed: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ParkablePeer {
|
|
14
|
+
readonly socket: net.Socket;
|
|
15
|
+
readonly info: SessionInfo;
|
|
16
|
+
/** This client can park itself, or wake and answer for its parked children. */
|
|
17
|
+
readonly parking: boolean;
|
|
18
|
+
parked?: Parked;
|
|
19
|
+
}
|
|
20
|
+
export function reachable<T extends ParkablePeer>(target?: T): target is T {
|
|
21
|
+
const socket = target?.socket;
|
|
22
|
+
return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
|
|
23
|
+
}
|
|
24
|
+
type Deliver = (id: string, from: SessionInfo, message: Message) => string | null;
|
|
25
|
+
function requested(id: unknown): string | undefined {
|
|
26
|
+
return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A fenced child keeps its identity and queued input; it never departs while parked. */
|
|
30
|
+
export class ParkedSessions<T extends ParkablePeer> {
|
|
31
|
+
private readonly sessions: ReadonlyMap<string, T>;
|
|
32
|
+
private readonly deliver: Deliver;
|
|
33
|
+
constructor(sessions: ReadonlyMap<string, T>, deliver: Deliver) {
|
|
34
|
+
this.sessions = sessions;
|
|
35
|
+
this.deliver = deliver;
|
|
36
|
+
}
|
|
37
|
+
park(socket: net.Socket, id: string, on: boolean): void {
|
|
38
|
+
const session = this.sessions.get(id);
|
|
39
|
+
if (on && session?.parking === true) {
|
|
40
|
+
session.parked ??= { retained: [], closed: false };
|
|
41
|
+
// Frames already written to this socket precede the acknowledgement.
|
|
42
|
+
writeMessage(socket, { type: "parked" });
|
|
43
|
+
} else if (!on && session?.parked?.closed === false) {
|
|
44
|
+
const { retained } = session.parked;
|
|
45
|
+
session.parked = undefined;
|
|
46
|
+
this.flush(id, retained);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Delivers retained input in order. A transport that fails mid-flush keeps the rest for its
|
|
51
|
+
* next generation; delivery refusals on a live transport follow resident delivery.
|
|
52
|
+
*/
|
|
53
|
+
private flush(id: string, retained: Retained): void {
|
|
54
|
+
for (const [index, { from, message }] of retained.entries()) {
|
|
55
|
+
const session = this.sessions.get(id);
|
|
56
|
+
if (this.deliver(id, from, message) !== null && session?.socket.writable === false) {
|
|
57
|
+
session.parked = { retained: retained.slice(index), closed: false };
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** A free requested identity, or a parked one resumed by the same child lineage. */
|
|
63
|
+
claim(requestedId: unknown, next: Omit<SessionInfo, "id">): { id: string; resumed?: T } {
|
|
64
|
+
const id = requested(requestedId);
|
|
65
|
+
const previous = id === undefined ? undefined : this.sessions.get(id);
|
|
66
|
+
const resumes =
|
|
67
|
+
previous?.parked !== undefined &&
|
|
68
|
+
previous.info.rootSessionId === next.rootSessionId &&
|
|
69
|
+
previous.info.parentSessionId === next.parentSessionId;
|
|
70
|
+
if (id === undefined || (previous && !resumes)) {
|
|
71
|
+
return { id: randomUUID() };
|
|
72
|
+
}
|
|
73
|
+
previous?.socket.destroy();
|
|
74
|
+
return { id, resumed: previous };
|
|
75
|
+
}
|
|
76
|
+
/** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
|
|
77
|
+
registered(id: string, previous?: T): void {
|
|
78
|
+
this.flush(id, previous?.parked?.retained ?? []);
|
|
79
|
+
for (const child of this.sessions.values()) {
|
|
80
|
+
if (nativePeerId(child.info.rootSessionId ?? "") === id) {
|
|
81
|
+
this.wake(child);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** A transport handoff, not a departure: no session_left and queued input stays. */
|
|
86
|
+
closed(session: T): boolean {
|
|
87
|
+
const { parked } = session;
|
|
88
|
+
if (parked) {
|
|
89
|
+
parked.closed = true;
|
|
90
|
+
this.wake(session);
|
|
91
|
+
}
|
|
92
|
+
return parked !== undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Exited parked children keep identities while some live session remains. Input already
|
|
96
|
+
* admitted for one keeps the broker until a returning main wakes it.
|
|
97
|
+
*/
|
|
98
|
+
get exited(): boolean {
|
|
99
|
+
return [...this.sessions.values()].every(
|
|
100
|
+
({ parked }) => parked?.closed === true && parked.retained.length === 0,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
retain(target: T | undefined, from: SessionInfo, message: Message): boolean {
|
|
104
|
+
target?.parked?.retained.push({ from, message });
|
|
105
|
+
if (target) {
|
|
106
|
+
this.wake(target);
|
|
107
|
+
}
|
|
108
|
+
return target?.parked !== undefined;
|
|
109
|
+
}
|
|
110
|
+
/** Reads of an exited parked child go to its main, which answers from captured history. */
|
|
111
|
+
reader(id: string): T | undefined {
|
|
112
|
+
const child = this.sessions.get(id);
|
|
113
|
+
return child?.parked?.closed === true ? this.main(child) : undefined;
|
|
114
|
+
}
|
|
115
|
+
// Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
|
|
116
|
+
private wake(child: T): void {
|
|
117
|
+
const main = this.main(child);
|
|
118
|
+
const wakes = child.parked?.retained.some(
|
|
119
|
+
({ message }) =>
|
|
120
|
+
message.passive !== true && message.delivery !== "passive" && message.topic === undefined,
|
|
121
|
+
);
|
|
122
|
+
if (main && child.parked?.closed === true && wakes === true) {
|
|
123
|
+
writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
private main(child: T): T | undefined {
|
|
127
|
+
const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
|
|
128
|
+
return main?.parking === true && main.parked === undefined ? main : undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -37,7 +37,7 @@ type ClientMessageHandler = (
|
|
|
37
37
|
export function acceptBrokerConnection(
|
|
38
38
|
socket: net.Socket,
|
|
39
39
|
handleMessage: ClientMessageHandler,
|
|
40
|
-
disconnected: (sessionId: string) => void,
|
|
40
|
+
disconnected: (sessionId: string, socket: net.Socket) => void,
|
|
41
41
|
): void {
|
|
42
42
|
let sessionId: string | null = null;
|
|
43
43
|
socket.on(
|
|
@@ -54,7 +54,7 @@ export function acceptBrokerConnection(
|
|
|
54
54
|
);
|
|
55
55
|
socket.on("close", () => {
|
|
56
56
|
if (sessionId !== null) {
|
|
57
|
-
disconnected(sessionId);
|
|
57
|
+
disconnected(sessionId, socket);
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
60
|
socket.on("error", (error: Error) => {
|
|
@@ -6,7 +6,7 @@ import type { IntercomConfig } from "./config.ts";
|
|
|
6
6
|
import type { IntercomLifecycle } from "./lifecycle.ts";
|
|
7
7
|
import type { Message, SessionInfo, ReceiptQuery, ReceiptResult } from "./types.ts";
|
|
8
8
|
import type { NativeReadQuery } from "./broker/last.ts";
|
|
9
|
-
import { readLastMessage } from "./last-message.ts";
|
|
9
|
+
import { readLastMessage, type NativeHistory } from "./last-message.ts";
|
|
10
10
|
import { asError } from "./validation.ts";
|
|
11
11
|
import { RecoveryError } from "./tool-results.ts";
|
|
12
12
|
import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.ts";
|
|
@@ -24,10 +24,20 @@ interface ConnectionHooks {
|
|
|
24
24
|
readonly incoming: (ctx: ExtensionContext, from: SessionInfo, message: Message) => Promise<void>;
|
|
25
25
|
readonly peerLeft: (id: string) => void;
|
|
26
26
|
readonly disconnected: (error: Readonly<Error>) => void;
|
|
27
|
+
/** A main answers for and wakes its exited parked children. */
|
|
28
|
+
readonly parkedHistory: (peer: SessionInfo) => Promise<NativeHistory | undefined>;
|
|
29
|
+
readonly wake: (peer: string) => void;
|
|
30
|
+
}
|
|
31
|
+
interface LastRequest {
|
|
32
|
+
readonly requestId: string;
|
|
33
|
+
readonly query: NativeReadQuery;
|
|
34
|
+
readonly parked?: SessionInfo;
|
|
27
35
|
}
|
|
28
36
|
/** Owns socket registration, coalesced connects and the reconnect/startup timers. */
|
|
29
37
|
export class IntercomConnection {
|
|
30
38
|
private client: IntercomClient | null = null;
|
|
39
|
+
/** The transport whose broker retains this actor's input while it parks. */
|
|
40
|
+
private parked: IntercomClient | null = null;
|
|
31
41
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
32
42
|
private startupTimer: NodeJS.Timeout | null = null;
|
|
33
43
|
private connecting: Promise<IntercomTransport> | null = null;
|
|
@@ -44,6 +54,22 @@ export class IntercomConnection {
|
|
|
44
54
|
get active(): IntercomTransport | null {
|
|
45
55
|
return this.client;
|
|
46
56
|
}
|
|
57
|
+
async park(): Promise<boolean> {
|
|
58
|
+
const client = this.client;
|
|
59
|
+
this.parked = client !== null && (await client.park().catch(() => false)) ? client : null;
|
|
60
|
+
return this.parked !== null && this.parked === this.client;
|
|
61
|
+
}
|
|
62
|
+
get parking(): boolean {
|
|
63
|
+
return this.parked !== null;
|
|
64
|
+
}
|
|
65
|
+
get reconnected(): boolean {
|
|
66
|
+
return this.client !== null && this.client !== this.parked;
|
|
67
|
+
}
|
|
68
|
+
unpark(): Promise<boolean> {
|
|
69
|
+
const client = this.parked === this.client ? this.client : null;
|
|
70
|
+
this.parked = null;
|
|
71
|
+
return client?.park(false).catch(() => false) ?? Promise.resolve(false);
|
|
72
|
+
}
|
|
47
73
|
start(ctx: ExtensionContext, generation: number): void {
|
|
48
74
|
this.attempt = 0;
|
|
49
75
|
this.clearTimers();
|
|
@@ -119,45 +145,67 @@ export class IntercomConnection {
|
|
|
119
145
|
});
|
|
120
146
|
}
|
|
121
147
|
}
|
|
148
|
+
private answerLast(next: IntercomClient, request: LastRequest): void {
|
|
149
|
+
const ctx = this.lifecycle.live();
|
|
150
|
+
const id = next.sessionId;
|
|
151
|
+
if (this.client !== next || !ctx || id === null) {
|
|
152
|
+
next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const { parked, query } = request;
|
|
156
|
+
if ("messageId" in query) {
|
|
157
|
+
// The broker refuses receipt reads of an exited parked child before relaying them.
|
|
158
|
+
next.respondLast(
|
|
159
|
+
request.requestId,
|
|
160
|
+
parked ? { error: "publication_unavailable" } : this.hooks.receipt(query),
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (parked) {
|
|
165
|
+
this.hooks
|
|
166
|
+
.parkedHistory(parked)
|
|
167
|
+
.then((history) =>
|
|
168
|
+
history
|
|
169
|
+
? readLastMessage(history, parked, query)
|
|
170
|
+
: { error: "Parked session history is unavailable." },
|
|
171
|
+
)
|
|
172
|
+
.catch((error: unknown) => ({ error: asError(error).message }))
|
|
173
|
+
.then((result) => {
|
|
174
|
+
next.respondLast(request.requestId, result);
|
|
175
|
+
})
|
|
176
|
+
.catch(() => {
|
|
177
|
+
/* The requester's broker timeout reports a lost transport. */
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
next.respondLast(
|
|
183
|
+
request.requestId,
|
|
184
|
+
readLastMessage(
|
|
185
|
+
ctx.sessionManager,
|
|
186
|
+
{
|
|
187
|
+
id,
|
|
188
|
+
...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
|
|
189
|
+
cwd: ctx.cwd,
|
|
190
|
+
model: ctx.model?.id ?? "unknown",
|
|
191
|
+
status: this.lifecycle.status(),
|
|
192
|
+
},
|
|
193
|
+
query,
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
next.respondLast(request.requestId, { error: asError(error).message });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
122
200
|
private createClient(): IntercomClient {
|
|
123
201
|
const next = new IntercomClient({
|
|
124
202
|
sendTimeoutMs: this.config.sendTimeoutMs,
|
|
125
203
|
listTimeoutMs: this.config.listTimeoutMs,
|
|
126
204
|
});
|
|
127
205
|
this.client = next;
|
|
128
|
-
next.on(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const ctx = this.lifecycle.live();
|
|
132
|
-
const id = next.sessionId;
|
|
133
|
-
if (this.client !== next || !ctx || id === null) {
|
|
134
|
-
next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
try {
|
|
138
|
-
if ("messageId" in request.query) {
|
|
139
|
-
next.respondLast(request.requestId, this.hooks.receipt(request.query));
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
next.respondLast(
|
|
143
|
-
request.requestId,
|
|
144
|
-
readLastMessage(
|
|
145
|
-
ctx.sessionManager,
|
|
146
|
-
{
|
|
147
|
-
id,
|
|
148
|
-
...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
|
|
149
|
-
cwd: ctx.cwd,
|
|
150
|
-
model: ctx.model?.id ?? "unknown",
|
|
151
|
-
status: this.lifecycle.status(),
|
|
152
|
-
},
|
|
153
|
-
request.query,
|
|
154
|
-
),
|
|
155
|
-
);
|
|
156
|
-
} catch (error) {
|
|
157
|
-
next.respondLast(request.requestId, { error: asError(error).message });
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
);
|
|
206
|
+
next.on("last_request", (request: LastRequest) => {
|
|
207
|
+
this.answerLast(next, request);
|
|
208
|
+
});
|
|
161
209
|
next.on("message", (from: SessionInfo, message: Message) => {
|
|
162
210
|
const ctx = this.lifecycle.live();
|
|
163
211
|
if (this.client !== next || !ctx) {
|
|
@@ -167,6 +215,11 @@ export class IntercomConnection {
|
|
|
167
215
|
console.error("Intercom inbound delivery failed:", error);
|
|
168
216
|
});
|
|
169
217
|
});
|
|
218
|
+
next.on("wake", (peer: string) => {
|
|
219
|
+
if (this.client === next) {
|
|
220
|
+
this.hooks.wake(peer);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
170
223
|
next.on("session_left", (id: string) => {
|
|
171
224
|
if (this.client !== next) {
|
|
172
225
|
return;
|