@fitchmultz/pi-subagents 0.52.19 → 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.
Files changed (56) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/agent/contracts.js +12 -3
  3. package/dist/agent/index.js +9 -9
  4. package/dist/agent/legacy.js +5 -5
  5. package/dist/agent/notifications.js +21 -14
  6. package/dist/agent/parent-check.js +38 -0
  7. package/dist/agent/process.js +45 -52
  8. package/dist/agent/runner.js +59 -64
  9. package/dist/agent/storage.js +16 -4
  10. package/dist/agent/view.js +1 -3
  11. package/dist/build-info.js +1 -1
  12. package/dist/pi-intercom/bridge-presence.js +30 -18
  13. package/dist/pi-intercom/bridge-wake.js +60 -37
  14. package/dist/pi-intercom/broker/broker.js +4 -26
  15. package/dist/pi-intercom/broker/client-last.js +6 -18
  16. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  17. package/dist/pi-intercom/broker/client-park.js +22 -6
  18. package/dist/pi-intercom/broker/client.d.ts +3 -2
  19. package/dist/pi-intercom/broker/client.js +104 -112
  20. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  21. package/dist/pi-intercom/broker/connection.js +48 -33
  22. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  23. package/dist/pi-intercom/broker/framing.js +19 -12
  24. package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
  25. package/dist/pi-intercom/broker/pending-requests.js +26 -0
  26. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  27. package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
  28. package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
  29. package/dist/pi-intercom/connection.js +5 -8
  30. package/dist/pi-intercom/inbound-journal.js +5 -2
  31. package/dist/shared/journal-frames.js +19 -24
  32. package/docs/development.md +6 -0
  33. package/package.json +1 -1
  34. package/src/agent/contracts.ts +12 -3
  35. package/src/agent/index.ts +10 -10
  36. package/src/agent/legacy.ts +5 -5
  37. package/src/agent/notifications.ts +35 -15
  38. package/src/agent/parent-check.ts +40 -0
  39. package/src/agent/process.ts +53 -55
  40. package/src/agent/runner.ts +65 -65
  41. package/src/agent/storage.ts +18 -5
  42. package/src/agent/view.ts +1 -4
  43. package/src/pi-intercom/bridge-presence.ts +37 -18
  44. package/src/pi-intercom/bridge-wake.ts +70 -38
  45. package/src/pi-intercom/broker/broker.ts +5 -37
  46. package/src/pi-intercom/broker/client-last.ts +14 -25
  47. package/src/pi-intercom/broker/client-park.ts +27 -6
  48. package/src/pi-intercom/broker/client.ts +127 -126
  49. package/src/pi-intercom/broker/connection.ts +58 -39
  50. package/src/pi-intercom/broker/framing.ts +24 -12
  51. package/src/pi-intercom/broker/pending-requests.ts +37 -0
  52. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  53. package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
  54. package/src/pi-intercom/connection.ts +9 -9
  55. package/src/pi-intercom/inbound-journal.ts +5 -2
  56. package/src/shared/journal-frames.ts +22 -23
@@ -10,8 +10,9 @@ import {
10
10
  type Result,
11
11
  type AccountingState,
12
12
  } from "./contracts.ts";
13
- import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
14
- import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
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 active?: Promise<void> | undefined;
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"), { at: Date.now(), id: this.current?.id });
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
- this.active = (async () => {
188
- try {
189
- if (
190
- launch.provider !== this.initial.provider ||
191
- launch.model !== this.initial.model ||
192
- launch.thinking !== this.initial.thinking
193
- ) {
194
- throw new Error("Continuation model snapshot changed unexpectedly.");
195
- }
196
- await session.prompt(launch.prompt, { expandPromptTemplates: false });
197
- await session.waitForIdle();
198
- this.saveResult(launch, baseline);
199
- } catch (error) {
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.active !== undefined || !session.isIdle)
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
- // Unbound legacy Stops cannot safely target this generation either.
264
- if (command.action === "stop") {
265
- return command.actorToken !== this.token;
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
- if (command.action === "stop") {
274
- this.stop();
275
- return;
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.active === undefined && (this.session?.isIdle ?? true);
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.active !== undefined || this.observation?.watched === true,
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.active === undefined && !this.stopping;
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.current ?? this.initial;
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
- await this.active;
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 {
@@ -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
- action: Command["action"],
126
- options: Pick<Command, "prompt" | "actorToken"> = {},
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
- action,
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
- commandSchema,
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;
@@ -18,11 +18,15 @@ interface PresenceOwner {
18
18
  readonly restored: (identity: Identity, expires: number) => void;
19
19
  }
20
20
 
21
+ type PresencePhase =
22
+ | { readonly kind: "idle" }
23
+ | { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
24
+ | { readonly kind: "running"; readonly task: Promise<void> }
25
+ | { readonly kind: "stopped"; readonly task: Promise<void> };
26
+
21
27
  export class PersistentPresence {
22
28
  private readonly registrations = new Map<string, Registration>();
23
- private stopped = false;
24
- private timer?: NodeJS.Timeout;
25
- private running?: Promise<void> | undefined;
29
+ private phase: PresencePhase = { kind: "idle" };
26
30
  private readonly owner: PresenceOwner;
27
31
 
28
32
  constructor(owner: PresenceOwner) {
@@ -88,22 +92,30 @@ export class PersistentPresence {
88
92
  }
89
93
 
90
94
  start(): void {
91
- if (!this.stopped && !this.running) {
92
- clearTimeout(this.timer);
93
- this.timer = setTimeout(() => {
94
- this.running = this.maintain().finally(() => {
95
- this.running = undefined;
96
- if (!this.stopped) {
97
- this.timer = setTimeout(() => this.start(), 15000);
98
- }
99
- });
100
- }, 0);
95
+ if (this.phase.kind === "running" || this.phase.kind === "stopped") {
96
+ return;
97
+ }
98
+ this.schedule(0);
99
+ }
100
+
101
+ private schedule(delay: number): void {
102
+ if (this.phase.kind === "scheduled") {
103
+ clearTimeout(this.phase.timer);
101
104
  }
105
+ const timer = setTimeout(() => {
106
+ const task = this.maintain().finally(() => {
107
+ if (this.phase.kind !== "stopped") {
108
+ this.schedule(15000);
109
+ }
110
+ });
111
+ this.phase = { kind: "running", task };
112
+ }, delay);
113
+ this.phase = { kind: "scheduled", timer };
102
114
  }
103
115
 
104
116
  private async maintain(): Promise<void> {
105
117
  for (const registration of this.registrations.values()) {
106
- if (this.stopped) {
118
+ if (this.phase.kind === "stopped") {
107
119
  return;
108
120
  }
109
121
  const identity = this.owner.clients().get(registration.fingerprint);
@@ -125,9 +137,16 @@ export class PersistentPresence {
125
137
  }
126
138
  }
127
139
 
128
- async stop(): Promise<void> {
129
- this.stopped = true;
130
- clearTimeout(this.timer);
131
- await this.running;
140
+ stop(): Promise<void> {
141
+ const phase = this.phase;
142
+ if (phase.kind === "stopped") {
143
+ return phase.task;
144
+ }
145
+ if (phase.kind === "scheduled") {
146
+ clearTimeout(phase.timer);
147
+ }
148
+ const task = phase.kind === "running" ? phase.task : Promise.resolve();
149
+ this.phase = { kind: "stopped", task };
150
+ return task;
132
151
  }
133
152
  }
@@ -15,6 +15,12 @@ interface Candidate {
15
15
  readonly entry: PendingNotification;
16
16
  }
17
17
 
18
+ type DeliveryPhase =
19
+ | { readonly kind: "idle" }
20
+ | { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
21
+ | { readonly kind: "running" | "running-dirty"; readonly task: Promise<void> }
22
+ | { readonly kind: "stopped"; readonly task: Promise<void> };
23
+
18
24
  function actionable(entry: PendingNotification): boolean {
19
25
  const message = entry.message;
20
26
  if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
@@ -39,9 +45,7 @@ export class WakeDeliveries {
39
45
  private readonly allowed: (identity: Identity) => boolean;
40
46
  private readonly deliveries = new Map<string, Delivery>();
41
47
  private readonly controller = new AbortController();
42
- private timer?: NodeJS.Timeout;
43
- private running?: Promise<void> | undefined;
44
- private dirty = false;
48
+ private phase: DeliveryPhase = { kind: "idle" };
45
49
  private storageBackoff = 15000;
46
50
 
47
51
  constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
@@ -83,10 +87,17 @@ export class WakeDeliveries {
83
87
  } catch {
84
88
  // The scheduled storage recovery fails closed; never let an event bypass durable admission.
85
89
  }
86
- if (this.running) {
87
- this.dirty = true;
88
- } else {
89
- this.schedule(Date.now());
90
+ const phase = this.phase;
91
+ switch (phase.kind) {
92
+ case "running":
93
+ this.phase = { kind: "running-dirty", task: phase.task };
94
+ return;
95
+ case "running-dirty":
96
+ case "stopped":
97
+ return;
98
+ case "idle":
99
+ case "scheduled":
100
+ this.schedule(Date.now());
90
101
  }
91
102
  }
92
103
 
@@ -101,31 +112,40 @@ export class WakeDeliveries {
101
112
  }
102
113
 
103
114
  private schedule(deadline: number): void {
104
- clearTimeout(this.timer);
105
- this.timer = setTimeout(
106
- () => {
107
- this.dirty = false;
108
- let next = Infinity;
109
- this.running = this.deliver()
110
- .then((due) => {
111
- next = due;
112
- this.storageBackoff = 15000;
113
- return;
114
- })
115
- .catch(() => {
116
- log("wake_delivery", { result: "storage_unavailable" });
117
- next = Date.now() + this.storageBackoff;
118
- this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
119
- })
120
- .finally(() => {
121
- this.running = undefined;
122
- if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
123
- this.schedule(this.dirty ? Date.now() : next);
124
- }
125
- });
126
- },
115
+ if (this.phase.kind === "scheduled") {
116
+ clearTimeout(this.phase.timer);
117
+ }
118
+ const timer = setTimeout(
119
+ () => this.startDelivery(),
127
120
  Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)),
128
121
  );
122
+ this.phase = { kind: "scheduled", timer };
123
+ }
124
+
125
+ private startDelivery(): void {
126
+ let next = Infinity;
127
+ const task = this.deliver()
128
+ .then((due) => {
129
+ next = due;
130
+ this.storageBackoff = 15000;
131
+ return;
132
+ })
133
+ .catch(() => {
134
+ log("wake_delivery", { result: "storage_unavailable" });
135
+ next = Date.now() + this.storageBackoff;
136
+ this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
137
+ })
138
+ .finally(() => {
139
+ if (this.phase.kind === "stopped") {
140
+ return;
141
+ }
142
+ const dirty = this.phase.kind === "running-dirty";
143
+ this.phase = { kind: "idle" };
144
+ if (dirty || Number.isFinite(next)) {
145
+ this.schedule(dirty ? Date.now() : next);
146
+ }
147
+ });
148
+ this.phase = { kind: "running", task };
129
149
  }
130
150
 
131
151
  private authorized(delivery: Delivery): boolean {
@@ -267,14 +287,26 @@ export class WakeDeliveries {
267
287
  this.changed(false);
268
288
  }
269
289
 
270
- async stop(): Promise<void> {
271
- this.controller.abort();
272
- clearTimeout(this.timer);
273
- await this.running;
274
- for (const delivery of this.deliveries.values()) {
275
- delivery.mailbox.close();
290
+ stop(): Promise<void> {
291
+ const phase = this.phase;
292
+ if (phase.kind === "stopped") {
293
+ return phase.task;
276
294
  }
277
- this.deliveries.clear();
278
- this.state?.close();
295
+ if (phase.kind === "scheduled") {
296
+ clearTimeout(phase.timer);
297
+ }
298
+ const running =
299
+ phase.kind === "running" || phase.kind === "running-dirty" ? phase.task : undefined;
300
+ const task = Promise.resolve(running).then(() => {
301
+ for (const delivery of this.deliveries.values()) {
302
+ delivery.mailbox.close();
303
+ }
304
+ this.deliveries.clear();
305
+ this.state?.close();
306
+ return;
307
+ });
308
+ this.phase = { kind: "stopped", task };
309
+ this.controller.abort();
310
+ return task;
279
311
  }
280
312
  }
@@ -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 pendingReplaceDeliveries = new Map<string, PendingReplaceDelivery>();
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.clearPendingReplaceDeliveries(sessionId);
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
- const key = this.replaceKey(fromId, toId, message.threadId ?? "");
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(
@@ -18,6 +18,7 @@ import {
18
18
  type SessionInfo,
19
19
  } from "../types.ts";
20
20
  import { writeMessage } from "./framing.ts";
21
+ import { PendingRequests } from "./pending-requests.ts";
21
22
 
22
23
  /** A peer's read of this session or, for a main, of its exited `parked` child. */
23
24
  export function lastRequest(msg: UnknownRecord): {
@@ -46,35 +47,25 @@ export class LastMessageRequests {
46
47
  constructor(timeoutMs: number) {
47
48
  this.timeoutMs = timeoutMs;
48
49
  }
49
- private readonly pending = new Map<
50
- string,
51
- {
52
- readonly resolve: (result: NativeReadResult) => void;
53
- readonly reject: (error: Readonly<Error>) => void;
54
- readonly timer: NodeJS.Timeout;
55
- }
56
- >();
50
+ private readonly pending = new PendingRequests<{
51
+ readonly resolve: (result: NativeReadResult) => void;
52
+ readonly reject: (error: Readonly<Error>) => void;
53
+ }>();
57
54
 
58
55
  fail(error: Readonly<Error>): void {
59
56
  this.lastMessages = false;
60
57
  this.receiptReads = false;
61
- for (const pending of this.pending.values()) {
62
- clearTimeout(pending.timer);
63
- pending.reject(error);
64
- }
65
- this.pending.clear();
58
+ this.pending.fail(error);
66
59
  }
67
60
 
68
61
  receive(message: UnknownRecord): void {
69
62
  if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
70
63
  throw new Error("Invalid last-message response");
71
64
  }
72
- const pending = this.pending.get(message.requestId);
65
+ const pending = this.pending.take(message.requestId);
73
66
  if (!pending) {
74
67
  return;
75
68
  }
76
- clearTimeout(pending.timer);
77
- this.pending.delete(message.requestId);
78
69
  pending.resolve(message.result);
79
70
  }
80
71
 
@@ -100,18 +91,16 @@ export class LastMessageRequests {
100
91
  }
101
92
  return new Promise((resolve, reject) => {
102
93
  const requestId = randomUUID();
103
- const timer = setTimeout(() => {
104
- this.pending.delete(requestId);
105
- reject(new Error("Last-message read timeout"));
106
- }, this.timeoutMs);
107
- timer.unref();
108
- this.pending.set(requestId, { resolve, reject, timer });
94
+ this.pending.set(
95
+ requestId,
96
+ { resolve, reject },
97
+ this.timeoutMs,
98
+ () => new Error("Last-message read timeout"),
99
+ );
109
100
  try {
110
101
  writeMessage(socket, { type: "last_request", requestId, to, query });
111
102
  } catch (error) {
112
- clearTimeout(timer);
113
- this.pending.delete(requestId);
114
- reject(new Error(errorMessage(error)));
103
+ this.pending.take(requestId)?.reject(new Error(errorMessage(error)));
115
104
  }
116
105
  });
117
106
  }