@fitchmultz/pi-subagents 0.52.18 → 0.52.20

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 (57) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +4 -0
  3. package/dist/agent/host.js +12 -0
  4. package/dist/agent/index.js +70 -56
  5. package/dist/agent/launch.js +12 -3
  6. package/dist/agent/notifications.js +5 -5
  7. package/dist/agent/parking.js +26 -15
  8. package/dist/agent/runner.js +18 -16
  9. package/dist/agent/tool-schemas.js +2 -0
  10. package/dist/agent/usage.js +42 -3
  11. package/dist/agent/view.js +13 -13
  12. package/dist/agent/wake.js +17 -15
  13. package/dist/build-info.js +1 -1
  14. package/dist/pi-intercom/bridge-presence.js +30 -18
  15. package/dist/pi-intercom/bridge-wake.js +60 -37
  16. package/dist/pi-intercom/broker/broker.js +2 -2
  17. package/dist/pi-intercom/broker/client-last.js +6 -18
  18. package/dist/pi-intercom/broker/client.js +16 -50
  19. package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
  20. package/dist/pi-intercom/broker/pending-requests.js +26 -0
  21. package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
  22. package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
  23. package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
  24. package/dist/pi-intercom/broker/snapshots.js +56 -25
  25. package/dist/pi-intercom/broker/spawn-claim.js +89 -0
  26. package/dist/pi-intercom/broker/spawn.js +31 -78
  27. package/dist/pi-intercom/lifecycle.js +26 -26
  28. package/dist/shared/post-exit-stdio-guard.js +44 -37
  29. package/docs/development.md +25 -0
  30. package/docs/intercom.md +13 -0
  31. package/docs/reference.md +16 -5
  32. package/package.json +1 -1
  33. package/scripts/build.mjs +41 -14
  34. package/skills/pi-subagents/SKILL.md +8 -4
  35. package/src/agent/contracts.ts +17 -0
  36. package/src/agent/host.ts +19 -0
  37. package/src/agent/index.ts +91 -62
  38. package/src/agent/launch.ts +16 -3
  39. package/src/agent/notifications.ts +5 -5
  40. package/src/agent/parking.ts +39 -17
  41. package/src/agent/runner.ts +20 -21
  42. package/src/agent/tool-schemas.ts +2 -0
  43. package/src/agent/usage.ts +48 -3
  44. package/src/agent/view.ts +15 -13
  45. package/src/agent/wake.ts +19 -15
  46. package/src/pi-intercom/bridge-presence.ts +37 -18
  47. package/src/pi-intercom/bridge-wake.ts +70 -38
  48. package/src/pi-intercom/broker/broker.ts +2 -1
  49. package/src/pi-intercom/broker/client-last.ts +14 -25
  50. package/src/pi-intercom/broker/client.ts +36 -65
  51. package/src/pi-intercom/broker/pending-requests.ts +37 -0
  52. package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
  53. package/src/pi-intercom/broker/snapshots.ts +82 -37
  54. package/src/pi-intercom/broker/spawn-claim.ts +98 -0
  55. package/src/pi-intercom/broker/spawn.ts +42 -82
  56. package/src/pi-intercom/lifecycle.ts +38 -27
  57. package/src/shared/post-exit-stdio-guard.ts +48 -40
@@ -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
  }
@@ -29,6 +29,7 @@ import {
29
29
  snapshotFrames,
30
30
  TopicSnapshot,
31
31
  publicationMessage,
32
+ preparedPublication,
32
33
  deliveryAcknowledgement,
33
34
  shouldReplaceDelivery,
34
35
  } from "./snapshots.ts";
@@ -245,7 +246,7 @@ class IntercomBroker {
245
246
  ? session.info
246
247
  : session.topics.snapshot(session.info, prepared);
247
248
  const from = messageSender(nextInfo);
248
- const deliveries = this.planPublications(currentId, from, prepared.publication);
249
+ const deliveries = this.planPublications(currentId, from, preparedPublication(prepared));
249
250
  // Validate every frame before committing state or delivering publications.
250
251
  if (prepared.restore) {
251
252
  session.topics.validateRestore(session.info, prepared, requestId);
@@ -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
  }
@@ -4,6 +4,7 @@ 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 { PendingRequests } from "./pending-requests.ts";
7
8
  import { LastMessageRequests, lastRequest } from "./client-last.ts";
8
9
  import { ParkRequests } from "./client-park.ts";
9
10
  import { WakeMessages } from "./client-wake.ts";
@@ -70,18 +71,15 @@ export class IntercomClient extends EventEmitter {
70
71
  readonly wakes = new WakeMessages(this, () => this.requireActiveSocket());
71
72
  private guardedDeliverySupported = false;
72
73
  private readonly lastRequests: LastMessageRequests;
73
- private readonly pendingSends = new Map<
74
- string,
75
- { readonly resolve: (r: SendResult) => void; readonly reject: (e: Readonly<Error>) => void }
76
- >();
77
- private readonly pendingLists = new Map<
78
- string,
79
- {
80
- readonly resolve: (snapshot: SessionSnapshot) => void;
81
- readonly reject: (e: Readonly<Error>) => void;
82
- readonly reader: SnapshotReader;
83
- }
84
- >();
74
+ private readonly pendingSends = new PendingRequests<{
75
+ readonly resolve: (r: SendResult) => void;
76
+ readonly reject: (e: Readonly<Error>) => void;
77
+ }>();
78
+ private readonly pendingLists = new PendingRequests<{
79
+ readonly resolve: (snapshot: SessionSnapshot) => void;
80
+ readonly reject: (e: Readonly<Error>) => void;
81
+ readonly reader: SnapshotReader;
82
+ }>();
85
83
  private connecting = false;
86
84
  private disconnecting = false;
87
85
  private readonly sendTimeoutMs: number;
@@ -96,14 +94,8 @@ export class IntercomClient extends EventEmitter {
96
94
 
97
95
  private failPending(error: Readonly<Error>): void {
98
96
  this.lastRequests.fail(error);
99
- for (const pending of this.pendingSends.values()) {
100
- pending.reject(error);
101
- }
102
- this.pendingSends.clear();
103
- for (const pending of this.pendingLists.values()) {
104
- pending.reject(error);
105
- }
106
- this.pendingLists.clear();
97
+ this.pendingSends.fail(error);
98
+ this.pendingLists.fail(error);
107
99
  }
108
100
 
109
101
  get sessionId(): string | null {
@@ -301,12 +293,11 @@ export class IntercomClient extends EventEmitter {
301
293
  if (type !== "delivered" && typeof reason !== "string") {
302
294
  throw new Error(`Invalid ${errorMessage(type)} message`);
303
295
  }
304
- const pending = this.pendingSends.get(messageId);
296
+ const pending = this.pendingSends.take(messageId);
305
297
  // Late responses are harmless after the caller has timed out.
306
298
  if (!pending) {
307
299
  return;
308
300
  }
309
- this.pendingSends.delete(messageId);
310
301
  pending.resolve({
311
302
  id: messageId,
312
303
  accepted: type !== "delivery_failed",
@@ -330,14 +321,12 @@ export class IntercomClient extends EventEmitter {
330
321
  return;
331
322
  }
332
323
  if (typeof error === "string") {
333
- this.pendingLists.delete(requestId);
334
- pending.reject(new Error(error));
324
+ this.pendingLists.take(requestId)?.reject(new Error(error));
335
325
  return;
336
326
  }
337
327
  pending.reader.append(sessions, receipts);
338
328
  if (more !== true) {
339
- this.pendingLists.delete(requestId);
340
- pending.resolve(pending.reader.finish());
329
+ this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
341
330
  }
342
331
  }
343
332
 
@@ -396,8 +385,8 @@ export class IntercomClient extends EventEmitter {
396
385
 
397
386
  return new Promise((resolve, reject) => {
398
387
  const requestId = randomUUID();
388
+ const reader = new SnapshotReader();
399
389
  const wrappedResolve = (snapshot: SessionSnapshot) => {
400
- clearTimeout(timeout);
401
390
  try {
402
391
  // Commit confirmed subscriptions before handling a following message in the same read.
403
392
  onAccepted?.(snapshot);
@@ -407,23 +396,19 @@ export class IntercomClient extends EventEmitter {
407
396
  }
408
397
  };
409
398
  const wrappedReject = (error: Readonly<Error>) => {
410
- clearTimeout(timeout);
399
+ reader.fail();
411
400
  reject(error);
412
401
  };
413
- const timeout = setTimeout(() => {
414
- if (this.pendingLists.has(requestId)) {
415
- this.pendingLists.delete(requestId);
416
- wrappedReject(
417
- Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
418
- );
419
- }
420
- }, this.listTimeoutMs);
421
- timeout.unref();
422
- this.pendingLists.set(requestId, {
423
- resolve: wrappedResolve,
424
- reject: wrappedReject,
425
- reader: new SnapshotReader(),
426
- });
402
+ this.pendingLists.set(
403
+ requestId,
404
+ {
405
+ resolve: wrappedResolve,
406
+ reject: wrappedReject,
407
+ reader,
408
+ },
409
+ this.listTimeoutMs,
410
+ () => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
411
+ );
427
412
  try {
428
413
  writeMessage(socket, {
429
414
  type: "list",
@@ -432,9 +417,7 @@ export class IntercomClient extends EventEmitter {
432
417
  ...(change ? { change } : {}),
433
418
  });
434
419
  } catch (error) {
435
- clearTimeout(timeout);
436
- this.pendingLists.delete(requestId);
437
- reject(toError(error));
420
+ this.pendingLists.take(requestId)?.reject(toError(error));
438
421
  }
439
422
  });
440
423
  }
@@ -456,7 +439,7 @@ export class IntercomClient extends EventEmitter {
456
439
  }
457
440
 
458
441
  const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
459
- if (this.pendingSends.has(messageId)) {
442
+ if (this.pendingSends.get(messageId)) {
460
443
  return Promise.reject(new Error("Message ID already has a pending send"));
461
444
  }
462
445
  const message: Message = {
@@ -482,22 +465,12 @@ export class IntercomClient extends EventEmitter {
482
465
  return Promise.reject(new Error("Invalid message format"));
483
466
  }
484
467
  return new Promise((resolve, reject) => {
485
- const wrappedResolve = (result: SendResult) => {
486
- clearTimeout(timeout);
487
- resolve(result);
488
- };
489
- const wrappedReject = (error: Readonly<Error>) => {
490
- clearTimeout(timeout);
491
- reject(error);
492
- };
493
- const timeout = setTimeout(() => {
494
- if (this.pendingSends.has(messageId)) {
495
- this.pendingSends.delete(messageId);
496
- wrappedReject(new Error("Send timeout"));
497
- }
498
- }, this.sendTimeoutMs);
499
- timeout.unref();
500
- this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
468
+ this.pendingSends.set(
469
+ messageId,
470
+ { resolve, reject },
471
+ this.sendTimeoutMs,
472
+ () => new Error("Send timeout"),
473
+ );
501
474
 
502
475
  try {
503
476
  writeMessage(socket, {
@@ -506,9 +479,7 @@ export class IntercomClient extends EventEmitter {
506
479
  message: this._topicsSupported ? compactTopicMessage(message) : message,
507
480
  });
508
481
  } catch (error) {
509
- clearTimeout(timeout);
510
- this.pendingSends.delete(messageId);
511
- reject(toError(error));
482
+ this.pendingSends.take(messageId)?.reject(toError(error));
512
483
  }
513
484
  });
514
485
  }
@@ -0,0 +1,37 @@
1
+ interface Request {
2
+ readonly reject: (error: Readonly<Error>) => void;
3
+ }
4
+
5
+ /** Map membership owns settlement; taking a request also releases its deadline. */
6
+ export class PendingRequests<T extends Request> {
7
+ private readonly pending = new Map<
8
+ string,
9
+ { readonly value: T; readonly timer: NodeJS.Timeout }
10
+ >();
11
+
12
+ get(id: string): T | undefined {
13
+ return this.pending.get(id)?.value;
14
+ }
15
+
16
+ set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void {
17
+ const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
18
+ timer.unref();
19
+ this.pending.set(id, { value, timer });
20
+ }
21
+
22
+ take(id: string): T | undefined {
23
+ const pending = this.pending.get(id);
24
+ if (pending === undefined) {
25
+ return;
26
+ }
27
+ this.pending.delete(id);
28
+ clearTimeout(pending.timer);
29
+ return pending.value;
30
+ }
31
+
32
+ fail(error: Readonly<Error>): void {
33
+ for (const id of this.pending.keys()) {
34
+ this.take(id)?.reject(error);
35
+ }
36
+ }
37
+ }
@@ -58,6 +58,14 @@ interface SessionParts {
58
58
  readonly subscriptions: TopicSubscription[] | undefined;
59
59
  }
60
60
 
61
+ type SnapshotPhase =
62
+ | {
63
+ readonly kind: "open";
64
+ readonly sessions: Map<string, SessionParts>;
65
+ readonly receipts: Receipt[];
66
+ }
67
+ | { readonly kind: "finished" | "failed" };
68
+
61
69
  function appendRows<T>(
62
70
  previous: T[] | undefined,
63
71
  rows: readonly T[] | undefined,
@@ -75,16 +83,28 @@ function appendRows<T>(
75
83
 
76
84
  /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
77
85
  export class SnapshotReader {
78
- private readonly sessions = new Map<string, SessionParts>();
79
- private readonly receipts: Receipt[] = [];
86
+ private phase: SnapshotPhase = { kind: "open", sessions: new Map(), receipts: [] };
87
+
88
+ private open(): Extract<SnapshotPhase, { readonly kind: "open" }> {
89
+ if (this.phase.kind !== "open") {
90
+ throw new Error(`Session snapshot is ${this.phase.kind}`);
91
+ }
92
+ return this.phase;
93
+ }
80
94
 
81
- private addSession(row: unknown): void {
95
+ fail(): void {
96
+ if (this.phase.kind === "open") {
97
+ this.phase = { kind: "failed" };
98
+ }
99
+ }
100
+
101
+ private addSession(state: Extract<SnapshotPhase, { readonly kind: "open" }>, row: unknown): void {
82
102
  if (!isRecord(row) || typeof row.id !== "string") {
83
103
  throw new Error("Invalid session snapshot row");
84
104
  }
85
105
  const topics = optionalArray(row.topics);
86
106
  const subscriptions = optionalArray(row.subscriptions);
87
- const previous = this.sessions.get(row.id);
107
+ const previous = state.sessions.get(row.id);
88
108
  const info = normalizeSessionInfo({
89
109
  ...previous?.info,
90
110
  ...row,
@@ -95,7 +115,7 @@ export class SnapshotReader {
95
115
  throw new Error("Invalid session snapshot");
96
116
  }
97
117
  const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
98
- this.sessions.set(info.id, {
118
+ state.sessions.set(info.id, {
99
119
  info: identity,
100
120
  topics: appendRows(previous?.topics, newTopics, "topics" in row),
101
121
  subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
@@ -103,27 +123,35 @@ export class SnapshotReader {
103
123
  }
104
124
 
105
125
  append(sessions: readonly unknown[], receipts: unknown): void {
106
- for (const row of sessions) {
107
- this.addSession(row);
108
- }
109
- if (receipts === undefined) {
110
- return;
111
- }
112
- if (!isUnknownArray(receipts)) {
113
- throw new Error("Invalid topic delivery receipts");
126
+ const state = this.open();
127
+ try {
128
+ for (const row of sessions) {
129
+ this.addSession(state, row);
130
+ }
131
+ if (receipts === undefined) {
132
+ return;
133
+ }
134
+ if (!isUnknownArray(receipts)) {
135
+ throw new Error("Invalid topic delivery receipts");
136
+ }
137
+ state.receipts.push(...receipts.map(receipt));
138
+ } catch (error) {
139
+ this.fail();
140
+ throw error;
114
141
  }
115
- this.receipts.push(...receipts.map(receipt));
116
142
  }
117
143
 
118
144
  finish(): SessionSnapshot {
145
+ const state = this.open();
119
146
  const sessions: SessionInfo[] = [];
120
- for (const { info, topics, subscriptions } of this.sessions.values()) {
147
+ for (const { info, topics, subscriptions } of state.sessions.values()) {
121
148
  sessions.push({
122
149
  ...info,
123
150
  ...(topics !== undefined ? { topics } : {}),
124
151
  ...(subscriptions !== undefined ? { subscriptions } : {}),
125
152
  });
126
153
  }
127
- return { sessions, receipts: this.receipts };
154
+ this.phase = { kind: "finished" };
155
+ return { sessions, receipts: state.receipts };
128
156
  }
129
157
  }