@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/src/agent/runner.ts
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
type Result,
|
|
11
11
|
type AccountingState,
|
|
12
12
|
} from "./contracts.ts";
|
|
13
|
-
import {
|
|
14
|
-
import { ownIdentity,
|
|
13
|
+
import { commandIdentity, launchAt, read, runDirectory, write } from "./storage.ts";
|
|
14
|
+
import { ownIdentity, shells, signalProcess } from "./process.ts";
|
|
15
|
+
import { ParentCheck } from "./parent-check.ts";
|
|
15
16
|
import { errorText, loadSDK } from "./host.ts";
|
|
16
17
|
import { Parking, type ParkOutcome, type Runtime } from "./parking.ts";
|
|
17
18
|
import { accountingFields, UsageIndex } from "./usage.ts";
|
|
@@ -20,17 +21,6 @@ import { childRuntime } from "./child-runtime.ts";
|
|
|
20
21
|
import { ActorObservation, type ObservationStats } from "./observation.ts";
|
|
21
22
|
import { DirectoryWatch, Wake } from "./wake.ts";
|
|
22
23
|
|
|
23
|
-
function commandIdentity(file: string): string | undefined {
|
|
24
|
-
try {
|
|
25
|
-
const stat = fs.statSync(file, { bigint: true });
|
|
26
|
-
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
27
|
-
} catch (error) {
|
|
28
|
-
if (missing(error)) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
throw error;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
24
|
function completionStatus(
|
|
35
25
|
stopping: boolean,
|
|
36
26
|
message: AssistantMessage | undefined,
|
|
@@ -53,7 +43,11 @@ const failureReason = (
|
|
|
53
43
|
message?.errorMessage ??
|
|
54
44
|
(message ? undefined : "Native run produced no terminal assistant message.");
|
|
55
45
|
class Actor {
|
|
56
|
-
private
|
|
46
|
+
private work:
|
|
47
|
+
| { readonly kind: "idle" }
|
|
48
|
+
| { readonly kind: "running"; readonly launch: Launch; readonly task: Promise<void> } = {
|
|
49
|
+
kind: "idle",
|
|
50
|
+
};
|
|
57
51
|
private stopping = false;
|
|
58
52
|
private stopDeadline?: NodeJS.Timeout;
|
|
59
53
|
private nextParentCheck = 0;
|
|
@@ -63,7 +57,6 @@ class Actor {
|
|
|
63
57
|
private readonly wake = new Wake();
|
|
64
58
|
// A deferred run waits for an idle session, which has no command-file event.
|
|
65
59
|
private retry = false;
|
|
66
|
-
private current?: Launch | undefined;
|
|
67
60
|
private readonly errors: string[] = [];
|
|
68
61
|
private readonly started = new Set<string>();
|
|
69
62
|
// Actor-local validation metadata only; never retain command prompts or receipt authority.
|
|
@@ -151,7 +144,10 @@ class Actor {
|
|
|
151
144
|
this.stopping = true;
|
|
152
145
|
this.wake.raise();
|
|
153
146
|
write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
|
|
154
|
-
write(path.join(this.dir, "stop-acknowledged.json"), {
|
|
147
|
+
write(path.join(this.dir, "stop-acknowledged.json"), {
|
|
148
|
+
at: Date.now(),
|
|
149
|
+
id: this.work.kind === "running" ? this.work.launch.id : undefined,
|
|
150
|
+
});
|
|
155
151
|
this.session?.abort().catch((error: unknown) => {
|
|
156
152
|
this.errors.push(errorText(error));
|
|
157
153
|
});
|
|
@@ -181,29 +177,33 @@ class Actor {
|
|
|
181
177
|
if (!session) {
|
|
182
178
|
throw new Error("Native child session is not ready.");
|
|
183
179
|
}
|
|
184
|
-
this.current = launch;
|
|
185
180
|
const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
|
|
181
|
+
const task = Promise.resolve().then(() => this.executeRun(launch, session, baseline));
|
|
182
|
+
this.work = { kind: "running", launch, task };
|
|
186
183
|
this.observation?.working(true);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
this.saveResult(launch, baseline, errorText(error));
|
|
201
|
-
} finally {
|
|
202
|
-
this.active = undefined;
|
|
203
|
-
this.current = undefined;
|
|
204
|
-
this.observation?.working(false);
|
|
184
|
+
}
|
|
185
|
+
private async executeRun(
|
|
186
|
+
launch: Launch,
|
|
187
|
+
session: AgentSession,
|
|
188
|
+
baseline: ReadonlySet<string>,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
try {
|
|
191
|
+
if (
|
|
192
|
+
launch.provider !== this.initial.provider ||
|
|
193
|
+
launch.model !== this.initial.model ||
|
|
194
|
+
launch.thinking !== this.initial.thinking
|
|
195
|
+
) {
|
|
196
|
+
throw new Error("Continuation model snapshot changed unexpectedly.");
|
|
205
197
|
}
|
|
206
|
-
|
|
198
|
+
await session.prompt(launch.prompt, { expandPromptTemplates: false });
|
|
199
|
+
await session.waitForIdle();
|
|
200
|
+
this.saveResult(launch, baseline);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.saveResult(launch, baseline, errorText(error));
|
|
203
|
+
} finally {
|
|
204
|
+
this.work = { kind: "idle" };
|
|
205
|
+
this.observation?.working(false);
|
|
206
|
+
}
|
|
207
207
|
}
|
|
208
208
|
private commandLaunch(command: Command): Launch | undefined {
|
|
209
209
|
const receipt = path.join(this.dir, "admissions", `${command.id}.json`);
|
|
@@ -235,7 +235,7 @@ class Actor {
|
|
|
235
235
|
}
|
|
236
236
|
if (
|
|
237
237
|
command.action === "run" &&
|
|
238
|
-
(this.stopping || this.
|
|
238
|
+
(this.stopping || this.work.kind === "running" || !session.isIdle)
|
|
239
239
|
) {
|
|
240
240
|
this.retry = true;
|
|
241
241
|
return;
|
|
@@ -260,33 +260,31 @@ class Actor {
|
|
|
260
260
|
this.applyCommand(command, launch, session);
|
|
261
261
|
}
|
|
262
262
|
private staleCommand(command: Command, launch: Launch, session: AgentSession): boolean {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
switch (command.action) {
|
|
264
|
+
case "stop":
|
|
265
|
+
return command.actorToken !== this.token;
|
|
266
|
+
case "steer":
|
|
267
|
+
return this.work.kind !== "running" || session.isIdle || this.work.launch.id !== launch.id;
|
|
268
|
+
case "run":
|
|
269
|
+
return false;
|
|
266
270
|
}
|
|
267
|
-
return (
|
|
268
|
-
command.action === "steer" &&
|
|
269
|
-
(this.active === undefined || session.isIdle || this.current?.id !== launch.id)
|
|
270
|
-
);
|
|
271
271
|
}
|
|
272
272
|
private applyCommand(command: Command, launch: Launch, session: AgentSession): void {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
switch (command.action) {
|
|
274
|
+
case "stop":
|
|
275
|
+
this.stop();
|
|
276
|
+
return;
|
|
277
|
+
case "run":
|
|
278
|
+
this.run(launch);
|
|
279
|
+
return;
|
|
280
|
+
case "steer":
|
|
281
|
+
// Synchronous native queue admission cannot turn into an idle prompt after an await.
|
|
282
|
+
session.agent.steer({
|
|
283
|
+
role: "user",
|
|
284
|
+
content: [{ type: "text", text: command.prompt }],
|
|
285
|
+
timestamp: Date.now(),
|
|
286
|
+
});
|
|
276
287
|
}
|
|
277
|
-
if (command.action === "run") {
|
|
278
|
-
this.run(launch);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
if (command.prompt === undefined) {
|
|
282
|
-
throw new Error("Missing guidance.");
|
|
283
|
-
}
|
|
284
|
-
// Synchronous native queue admission cannot turn into an idle prompt after an await.
|
|
285
|
-
session.agent.steer({
|
|
286
|
-
role: "user",
|
|
287
|
-
content: [{ type: "text", text: command.prompt }],
|
|
288
|
-
timestamp: Date.now(),
|
|
289
|
-
});
|
|
290
288
|
}
|
|
291
289
|
private pendingCommand(file: string): Command | undefined {
|
|
292
290
|
const identity = commandIdentity(file);
|
|
@@ -328,7 +326,7 @@ class Actor {
|
|
|
328
326
|
this.errors.push(errorText(error));
|
|
329
327
|
});
|
|
330
328
|
}
|
|
331
|
-
return this.stopping && this.
|
|
329
|
+
return this.stopping && this.work.kind === "idle" && (this.session?.isIdle ?? true);
|
|
332
330
|
}
|
|
333
331
|
/** Durable command readiness, not elapsed time, defines admission. */
|
|
334
332
|
private async admitCommands(runtime: Runtime, parked: Parking): Promise<void> {
|
|
@@ -368,14 +366,14 @@ class Actor {
|
|
|
368
366
|
this.parking = await parked.park(runtime, {
|
|
369
367
|
signal: owner.signal,
|
|
370
368
|
pi: this.context?.pi,
|
|
371
|
-
busy: this.stopping || this.
|
|
369
|
+
busy: this.stopping || this.work.kind === "running" || this.observation?.watched === true,
|
|
372
370
|
// Raced commands and viewers keep this actor resident; the loop admits after release.
|
|
373
371
|
quiet: () => {
|
|
374
372
|
const queued = this.pending().some((command) => this.commandLaunch(command));
|
|
375
373
|
if (queued) {
|
|
376
374
|
this.wake.raise();
|
|
377
375
|
}
|
|
378
|
-
const idle = this.
|
|
376
|
+
const idle = this.work.kind === "idle" && !this.stopping;
|
|
379
377
|
return !queued && idle && this.observation?.watched !== true;
|
|
380
378
|
},
|
|
381
379
|
errors: this.errors,
|
|
@@ -386,7 +384,7 @@ class Actor {
|
|
|
386
384
|
return this.parking !== "resident";
|
|
387
385
|
}
|
|
388
386
|
private failure(error: unknown, unopened?: string): void {
|
|
389
|
-
const launch = this.
|
|
387
|
+
const launch = this.work.kind === "running" ? this.work.launch : this.initial;
|
|
390
388
|
const result: Result = {
|
|
391
389
|
id: launch.id,
|
|
392
390
|
owner: launch.owner,
|
|
@@ -482,7 +480,9 @@ class Actor {
|
|
|
482
480
|
process.on("SIGTERM", this.stop);
|
|
483
481
|
process.on("SIGINT", this.stop);
|
|
484
482
|
await this.admitCommands(runtime, parked);
|
|
485
|
-
|
|
483
|
+
if (this.work.kind === "running") {
|
|
484
|
+
await this.work.task;
|
|
485
|
+
}
|
|
486
486
|
} catch (error) {
|
|
487
487
|
this.failure(error, parked?.opened === true ? undefined : parkedToken);
|
|
488
488
|
} finally {
|
package/src/agent/storage.ts
CHANGED
|
@@ -119,18 +119,31 @@ export function latest(base: string, initial: Launch): Launch {
|
|
|
119
119
|
export function conversation(base: string, launch: Launch): string {
|
|
120
120
|
return runDirectory(base, launch.rootId);
|
|
121
121
|
}
|
|
122
|
+
/** Metadata only: replacing command bytes invalidates the actor's parsed-command cache. */
|
|
123
|
+
export function commandIdentity(file: string): string | undefined {
|
|
124
|
+
try {
|
|
125
|
+
const stat = fs.statSync(file, { bigint: true });
|
|
126
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (missing(error)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
122
134
|
export function enqueue(
|
|
123
135
|
base: string,
|
|
124
136
|
launch: Launch,
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
input:
|
|
138
|
+
| { readonly action: "run" }
|
|
139
|
+
| { readonly action: "steer"; readonly prompt: string }
|
|
140
|
+
| { readonly action: "stop"; readonly actorToken: string },
|
|
127
141
|
): string {
|
|
128
|
-
const id = action === "run" ? launch.id : randomUUID();
|
|
142
|
+
const id = input.action === "run" ? launch.id : randomUUID();
|
|
129
143
|
const command: Command = {
|
|
130
144
|
id,
|
|
131
145
|
runId: launch.id,
|
|
132
|
-
|
|
133
|
-
...options,
|
|
146
|
+
...input,
|
|
134
147
|
createdAt: Date.now(),
|
|
135
148
|
};
|
|
136
149
|
write(path.join(conversation(base, launch), "commands", `${id}.json`), command, true);
|
package/src/agent/view.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { readOutputPage } from "../shared/output-page.ts";
|
|
|
5
5
|
import { actor, alive, observeAlive } from "./process.ts";
|
|
6
6
|
import {
|
|
7
7
|
admissionSchema,
|
|
8
|
-
|
|
8
|
+
commandMetadataSchema,
|
|
9
9
|
exitSchema,
|
|
10
10
|
successorSchema,
|
|
11
11
|
wakeFailureSchema,
|
|
@@ -16,9 +16,6 @@ import type { RunsInput } from "./tool-schemas.ts";
|
|
|
16
16
|
import { conversation, read, runDirectory } from "./storage.ts";
|
|
17
17
|
import { published } from "./notifications.ts";
|
|
18
18
|
import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.ts";
|
|
19
|
-
import { Type } from "./typebox.ts";
|
|
20
|
-
|
|
21
|
-
const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
|
|
22
19
|
interface Guidance {
|
|
23
20
|
id: string;
|
|
24
21
|
runId: string;
|
|
@@ -34,8 +34,9 @@ import {
|
|
|
34
34
|
shouldReplaceDelivery,
|
|
35
35
|
} from "./snapshots.ts";
|
|
36
36
|
|
|
37
|
+
import { ReplaceDeliveries } from "./replace-deliveries.ts";
|
|
38
|
+
|
|
37
39
|
const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
|
|
38
|
-
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
39
40
|
|
|
40
41
|
interface ConnectedSession extends ParkablePeer {
|
|
41
42
|
info: SessionInfo;
|
|
@@ -45,18 +46,11 @@ interface ConnectedSession extends ParkablePeer {
|
|
|
45
46
|
readonly receiptReads: boolean;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
interface PendingReplaceDelivery {
|
|
49
|
-
readonly fromId: string;
|
|
50
|
-
readonly toId: string;
|
|
51
|
-
readonly message: Message;
|
|
52
|
-
readonly timer: NodeJS.Timeout;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
49
|
class IntercomBroker {
|
|
56
50
|
private readonly sessions = new Map<string, ConnectedSession>();
|
|
57
51
|
private readonly lastMessages = new LastMessageRelay(this.sessions);
|
|
58
52
|
private readonly parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
|
|
59
|
-
private readonly
|
|
53
|
+
private readonly replacements = new ReplaceDeliveries(this.deliverMessage.bind(this));
|
|
60
54
|
private readonly server: net.Server;
|
|
61
55
|
private shutdownTimer: NodeJS.Timeout | null = null;
|
|
62
56
|
private readonly socketPath: string;
|
|
@@ -201,7 +195,7 @@ class IntercomBroker {
|
|
|
201
195
|
this.lastMessages.disconnected(sessionId);
|
|
202
196
|
if (!this.parking.closed(session)) {
|
|
203
197
|
this.sessions.delete(sessionId);
|
|
204
|
-
this.
|
|
198
|
+
this.replacements.disconnected(sessionId);
|
|
205
199
|
this.broadcast({ type: "session_left", sessionId }, session.info);
|
|
206
200
|
}
|
|
207
201
|
this.scheduleShutdownCheck();
|
|
@@ -417,10 +411,6 @@ class IntercomBroker {
|
|
|
417
411
|
};
|
|
418
412
|
}
|
|
419
413
|
|
|
420
|
-
private replaceKey(fromId: string, toId: string, threadId: string): string {
|
|
421
|
-
return `${fromId}\0${toId}\0${threadId}`;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
414
|
private queueReplaceDelivery(
|
|
425
415
|
fromId: string,
|
|
426
416
|
toId: string,
|
|
@@ -431,17 +421,7 @@ class IntercomBroker {
|
|
|
431
421
|
if (validationError !== null) {
|
|
432
422
|
return { id: message.id, accepted: false, delivered: false, reason: validationError };
|
|
433
423
|
}
|
|
434
|
-
|
|
435
|
-
const existing = this.pendingReplaceDeliveries.get(key);
|
|
436
|
-
if (existing) {
|
|
437
|
-
clearTimeout(existing.timer);
|
|
438
|
-
}
|
|
439
|
-
const timer = setTimeout(() => {
|
|
440
|
-
this.pendingReplaceDeliveries.delete(key);
|
|
441
|
-
this.deliverMessage(toId, from, message);
|
|
442
|
-
}, REPLACE_DELIVERY_DELAY_MS);
|
|
443
|
-
timer.unref();
|
|
444
|
-
this.pendingReplaceDeliveries.set(key, { fromId, toId, message, timer });
|
|
424
|
+
this.replacements.stage(fromId, toId, from, message);
|
|
445
425
|
return {
|
|
446
426
|
id: message.id,
|
|
447
427
|
accepted: true,
|
|
@@ -451,18 +431,6 @@ class IntercomBroker {
|
|
|
451
431
|
};
|
|
452
432
|
}
|
|
453
433
|
|
|
454
|
-
private clearPendingReplaceDeliveries(sessionId: string): void {
|
|
455
|
-
for (const [key, pending] of this.pendingReplaceDeliveries) {
|
|
456
|
-
if (
|
|
457
|
-
pending.toId === sessionId ||
|
|
458
|
-
(pending.fromId === sessionId && pending.message.expectsReply === true)
|
|
459
|
-
) {
|
|
460
|
-
clearTimeout(pending.timer);
|
|
461
|
-
this.pendingReplaceDeliveries.delete(key);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
434
|
/** One admission rule; retained input is checked as the uncompacted envelope any later
|
|
467
435
|
* transport generation accepts. */
|
|
468
436
|
private validateDeliveryPayload(
|
|
@@ -4,20 +4,41 @@ import { writeMessage } from "./framing.ts";
|
|
|
4
4
|
/** Parking negotiation and its single outstanding broker acknowledgement. */
|
|
5
5
|
export class ParkRequests {
|
|
6
6
|
supported = false;
|
|
7
|
-
private
|
|
7
|
+
private state:
|
|
8
|
+
| { readonly kind: "idle" }
|
|
9
|
+
| {
|
|
10
|
+
readonly kind: "pending";
|
|
11
|
+
readonly promise: Promise<boolean>;
|
|
12
|
+
readonly resolve: (parked: boolean) => void;
|
|
13
|
+
} = { kind: "idle" };
|
|
8
14
|
settle(parked: boolean): void {
|
|
9
|
-
this.
|
|
15
|
+
const state = this.state;
|
|
16
|
+
this.state = { kind: "idle" };
|
|
17
|
+
if (state.kind === "pending") {
|
|
18
|
+
state.resolve(parked);
|
|
19
|
+
}
|
|
10
20
|
}
|
|
11
21
|
/** True once the broker retains later input; false when it cannot or the socket closed. */
|
|
12
22
|
request(socket: () => net.Socket, on: boolean): Promise<boolean> {
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
if (on && this.state.kind === "pending") {
|
|
24
|
+
return this.state.promise;
|
|
25
|
+
}
|
|
26
|
+
const { promise, resolve, reject } = Promise.withResolvers<boolean>();
|
|
27
|
+
this.settle(false);
|
|
28
|
+
if (on && this.supported) {
|
|
29
|
+
this.state = { kind: "pending", promise, resolve };
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
15
32
|
if (this.supported) {
|
|
16
33
|
writeMessage(socket(), { type: on ? "park" : "unpark" });
|
|
17
34
|
}
|
|
18
|
-
if (this.
|
|
35
|
+
if (this.state.kind === "idle") {
|
|
19
36
|
resolve(false);
|
|
20
37
|
}
|
|
21
|
-
})
|
|
38
|
+
} catch (error) {
|
|
39
|
+
this.state = { kind: "idle" };
|
|
40
|
+
reject(error);
|
|
41
|
+
}
|
|
42
|
+
return promise;
|
|
22
43
|
}
|
|
23
44
|
}
|
|
@@ -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
|
|