@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
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;
|
|
@@ -24,6 +24,13 @@ function integer(value: unknown): number {
|
|
|
24
24
|
return value;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
type WakeReservation =
|
|
28
|
+
| { readonly kind: "deferred"; readonly until: number }
|
|
29
|
+
| { readonly kind: "reserved"; readonly post: (action: () => Promise<void>) => Promise<void> };
|
|
30
|
+
type AttemptPhase =
|
|
31
|
+
| { readonly kind: "idle" | "posting" | "settlementPending" | "closed" }
|
|
32
|
+
| Extract<WakeReservation, { readonly kind: "reserved" }>;
|
|
33
|
+
|
|
27
34
|
/** One helper ledger, independent of identity, certificate, URL and credential rotation. */
|
|
28
35
|
export class WakeState {
|
|
29
36
|
private readonly db: DatabaseSync;
|
|
@@ -32,7 +39,7 @@ export class WakeState {
|
|
|
32
39
|
private readonly inode: number;
|
|
33
40
|
private readonly device: number;
|
|
34
41
|
private readonly owner: WakeOwner;
|
|
35
|
-
private
|
|
42
|
+
private phase: AttemptPhase = { kind: "idle" };
|
|
36
43
|
|
|
37
44
|
constructor(stateDir: string) {
|
|
38
45
|
this.stateDir = stateDir;
|
|
@@ -212,14 +219,37 @@ export class WakeState {
|
|
|
212
219
|
this.db.prepare("DELETE FROM wake_batches WHERE bucket=?").run(bucket);
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
/** Persist quota and uncertainty
|
|
216
|
-
reserve(bucket: string):
|
|
222
|
+
/** Persist quota and uncertainty before exposing a single-use POST reservation. */
|
|
223
|
+
reserve(bucket: string): WakeReservation {
|
|
217
224
|
this.retrySettlement();
|
|
218
|
-
|
|
225
|
+
if (this.phase.kind !== "idle") {
|
|
226
|
+
throw new Error("A wake reservation is already active or its owner is closed.");
|
|
227
|
+
}
|
|
228
|
+
const until = this.transaction(() => this.reservation(bucket));
|
|
229
|
+
if (until !== undefined) {
|
|
230
|
+
return { kind: "deferred", until };
|
|
231
|
+
}
|
|
232
|
+
const reservation: Extract<WakeReservation, { readonly kind: "reserved" }> = {
|
|
233
|
+
kind: "reserved",
|
|
234
|
+
post: async (action) => {
|
|
235
|
+
if (this.phase !== reservation) {
|
|
236
|
+
throw new Error("This wake reservation is no longer available.");
|
|
237
|
+
}
|
|
238
|
+
this.phase = { kind: "posting" };
|
|
239
|
+
try {
|
|
240
|
+
await action();
|
|
241
|
+
} finally {
|
|
242
|
+
// Completion, rejection and abort all start the durable expiry window here.
|
|
243
|
+
this.settle();
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
this.phase = reservation;
|
|
248
|
+
return reservation;
|
|
219
249
|
}
|
|
220
250
|
|
|
221
251
|
retrySettlement(): void {
|
|
222
|
-
if (this.
|
|
252
|
+
if (this.phase.kind === "settlementPending") {
|
|
223
253
|
this.settle();
|
|
224
254
|
}
|
|
225
255
|
}
|
|
@@ -238,10 +268,10 @@ export class WakeState {
|
|
|
238
268
|
}
|
|
239
269
|
|
|
240
270
|
/** A failed settlement stays open and must be retried before this owner can dispatch again. */
|
|
241
|
-
settle(): void {
|
|
242
|
-
this.
|
|
271
|
+
private settle(): void {
|
|
272
|
+
this.phase = { kind: "settlementPending" };
|
|
243
273
|
this.transaction(() => this.finishOwner(this.owner.name));
|
|
244
|
-
this.
|
|
274
|
+
this.phase = { kind: "idle" };
|
|
245
275
|
}
|
|
246
276
|
|
|
247
277
|
private finishOwner(owner: string): void {
|
|
@@ -314,6 +344,7 @@ export class WakeState {
|
|
|
314
344
|
try {
|
|
315
345
|
this.settle();
|
|
316
346
|
} finally {
|
|
347
|
+
this.phase = { kind: "closed" };
|
|
317
348
|
this.db.close();
|
|
318
349
|
this.owner.close();
|
|
319
350
|
}
|
|
@@ -211,19 +211,14 @@ export class WakeDeliveries {
|
|
|
211
211
|
next = Math.min(next, due);
|
|
212
212
|
continue;
|
|
213
213
|
}
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
216
|
-
next = Math.min(next,
|
|
214
|
+
const reservation = state.reserve(key);
|
|
215
|
+
if (reservation.kind === "deferred") {
|
|
216
|
+
next = Math.min(next, reservation.until);
|
|
217
217
|
continue;
|
|
218
218
|
}
|
|
219
219
|
// ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
|
|
220
220
|
// requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
|
|
221
|
-
|
|
222
|
-
await this.post(candidate);
|
|
223
|
-
} finally {
|
|
224
|
-
// Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
|
|
225
|
-
state.settle();
|
|
226
|
-
}
|
|
221
|
+
await reservation.post(() => this.post(candidate));
|
|
227
222
|
// Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
|
|
228
223
|
return Date.now();
|
|
229
224
|
}
|
|
@@ -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
|
}
|