@fitchmultz/pi-subagents 0.46.0 → 0.46.2

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 (92) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/agents/agent-management.js +8 -2
  3. package/dist/agents/agent-selection.js +5 -17
  4. package/dist/agents/agents.js +10 -10
  5. package/dist/extension/build-info.js +1 -1
  6. package/dist/extension/index.js +11 -5
  7. package/dist/extension/result-snapshot.js +0 -18
  8. package/dist/history/ingest.js +40 -31
  9. package/dist/history/prune.js +1 -1
  10. package/dist/history/run-projection.js +1 -2
  11. package/dist/history/search-queries.js +8 -2
  12. package/dist/history/store.js +20 -4
  13. package/dist/history/text-chunks.js +1 -2
  14. package/dist/history/transcript-queries.js +12 -6
  15. package/dist/intercom/result-intercom.js +0 -74
  16. package/dist/pi-intercom/broker/broker.js +33 -16
  17. package/dist/pi-intercom/broker/framing.js +67 -17
  18. package/dist/pi-intercom/broker/snapshot-reader.js +29 -8
  19. package/dist/pi-intercom/broker/snapshots.js +144 -41
  20. package/dist/pi-intercom/session-targets.js +0 -1
  21. package/dist/runs/background/async-run-discovery.js +4 -6
  22. package/dist/runs/background/completion-dedupe.js +4 -55
  23. package/dist/runs/background/notify.js +1 -9
  24. package/dist/runs/background/result-watcher.js +1 -1
  25. package/dist/runs/background/runner-child-observer.js +12 -7
  26. package/dist/runs/background/runner-monitor.js +2 -0
  27. package/dist/runs/background/runner-parallel.js +2 -3
  28. package/dist/runs/background/runner-step-output.js +1 -3
  29. package/dist/runs/shared/acceptance-finalization.js +2 -2
  30. package/dist/runs/shared/acceptance.js +1 -1
  31. package/dist/runs/shared/child-observation-spool.js +8 -3
  32. package/dist/runs/shared/model-fallback.js +3 -0
  33. package/dist/runs/shared/parallel-utils.js +1 -21
  34. package/dist/runs/shared/run-restoration.js +4 -5
  35. package/dist/runs/shared/single-output.js +1 -4
  36. package/dist/runs/shared/subagent-prompt-runtime.js +2 -2
  37. package/dist/runs/shared/supervisor-questions.js +7 -4
  38. package/dist/runs/shared/workflow-policy.js +2 -1
  39. package/dist/shared/journal-frames.js +1 -1
  40. package/dist/shared/settings.js +1 -4
  41. package/dist/slash/command-discovery.js +13 -12
  42. package/dist/tui/agent-history.js +0 -14
  43. package/dist/tui/agent-view.js +0 -1
  44. package/dist/tui/conversation-history.js +10 -1
  45. package/dist/tui/history-cards.js +8 -22
  46. package/docs/intercom.md +5 -1
  47. package/docs/performance.md +72 -3
  48. package/docs/reference.md +1 -1
  49. package/package.json +1 -1
  50. package/src/agents/agent-management.ts +8 -4
  51. package/src/agents/agent-selection.ts +8 -19
  52. package/src/agents/agents.ts +19 -14
  53. package/src/extension/index.ts +12 -5
  54. package/src/extension/result-snapshot.ts +1 -27
  55. package/src/history/ingest.ts +57 -49
  56. package/src/history/prune.ts +1 -1
  57. package/src/history/run-projection.ts +2 -4
  58. package/src/history/search-queries.ts +9 -2
  59. package/src/history/store.ts +21 -5
  60. package/src/history/text-chunks.ts +7 -2
  61. package/src/history/transcript-queries.ts +16 -10
  62. package/src/intercom/result-intercom.ts +0 -103
  63. package/src/pi-intercom/broker/broker.ts +34 -20
  64. package/src/pi-intercom/broker/framing.ts +71 -20
  65. package/src/pi-intercom/broker/snapshot-reader.ts +47 -9
  66. package/src/pi-intercom/broker/snapshots.ts +184 -51
  67. package/src/pi-intercom/session-targets.ts +0 -2
  68. package/src/runs/background/async-run-discovery.ts +4 -10
  69. package/src/runs/background/completion-dedupe.ts +6 -69
  70. package/src/runs/background/notify.ts +2 -12
  71. package/src/runs/background/result-watcher.ts +1 -1
  72. package/src/runs/background/runner-child-observer.ts +19 -11
  73. package/src/runs/background/runner-monitor.ts +2 -0
  74. package/src/runs/background/runner-parallel.ts +2 -3
  75. package/src/runs/background/runner-step-output.ts +1 -3
  76. package/src/runs/shared/acceptance-finalization.ts +2 -2
  77. package/src/runs/shared/acceptance.ts +0 -2
  78. package/src/runs/shared/child-observation-spool.ts +8 -3
  79. package/src/runs/shared/model-fallback.ts +3 -0
  80. package/src/runs/shared/parallel-utils.ts +1 -25
  81. package/src/runs/shared/run-restoration.ts +4 -4
  82. package/src/runs/shared/single-output.ts +0 -4
  83. package/src/runs/shared/subagent-prompt-runtime.ts +2 -2
  84. package/src/runs/shared/supervisor-questions.ts +10 -10
  85. package/src/runs/shared/workflow-policy.ts +1 -1
  86. package/src/shared/journal-frames.ts +1 -1
  87. package/src/shared/settings.ts +1 -4
  88. package/src/slash/command-discovery.ts +13 -12
  89. package/src/tui/agent-history.ts +1 -18
  90. package/src/tui/agent-view.ts +0 -1
  91. package/src/tui/conversation-history.ts +15 -1
  92. package/src/tui/history-cards.ts +8 -26
@@ -23,7 +23,7 @@ import { errorMessage, isRecord, type UnknownRecord } from "../../shared/unknown
23
23
  import {
24
24
  messageSender,
25
25
  snapshotFrames,
26
- prepareTopicChange,
26
+ TopicSnapshot,
27
27
  publicationMessage,
28
28
  deliveryAcknowledgement,
29
29
  shouldReplaceDelivery,
@@ -36,6 +36,7 @@ const REPLACE_DELIVERY_DELAY_MS = 1500;
36
36
  interface ConnectedSession {
37
37
  readonly socket: net.Socket;
38
38
  info: SessionInfo;
39
+ topics: TopicSnapshot;
39
40
  readonly topicFrames: boolean;
40
41
  }
41
42
 
@@ -178,7 +179,12 @@ class IntercomBroker {
178
179
  const info: SessionInfo = { ...msg.session, id, lastSeen: msg.session.lastSeen ?? Date.now() };
179
180
  snapshotFrames(randomUUID(), [info], true);
180
181
  const topicFrames = msg.topicFrames === true;
181
- this.sessions.set(id, { socket, info, topicFrames });
182
+ this.sessions.set(id, {
183
+ socket,
184
+ info: messageSender(info),
185
+ topics: new TopicSnapshot(info),
186
+ topicFrames,
187
+ });
182
188
  setId(id);
183
189
  if (this.shutdownTimer) {
184
190
  clearTimeout(this.shutdownTimer);
@@ -208,9 +214,7 @@ class IntercomBroker {
208
214
  return deliveries;
209
215
  }
210
216
  for (const target of this.sessions.values()) {
211
- const subscription = target.info.subscriptions?.find(
212
- (entry) => entry.topic === publication.topic,
213
- );
217
+ const subscription = target.topics.subscription(publication.topic);
214
218
  if (target.info.id === fromId || !subscription) {
215
219
  continue;
216
220
  }
@@ -234,24 +238,28 @@ class IntercomBroker {
234
238
  if (!session) {
235
239
  throw new Error("Sender session not found");
236
240
  }
237
- const prepared = prepareTopicChange(session.info, msg.change, session.topicFrames);
238
- const nextInfo = prepared.info;
241
+ const prepared = session.topics.prepare(msg.change, session.topicFrames);
242
+ const nextInfo = prepared.restore
243
+ ? session.info
244
+ : session.topics.snapshot(session.info, prepared);
239
245
  const from = messageSender(nextInfo);
240
246
  const deliveries = this.planPublications(currentId, from, prepared.publication);
241
- const restore = prepared.restore;
242
- const infos = [...this.sessions.values()].map((entry) =>
243
- entry === session ? nextInfo : entry.info,
244
- );
245
247
  // Validate every frame before committing state or delivering publications.
246
- if (restore) {
247
- snapshotFrames(requestId, [nextInfo], true);
248
+ if (prepared.restore) {
249
+ session.topics.validateRestore(session.info, prepared, requestId);
248
250
  }
249
- const frames = snapshotFrames(requestId, restore ? [] : infos, msg.stream === true);
251
+ const infos = prepared.restore
252
+ ? []
253
+ : [...this.sessions.values()].map((entry) => {
254
+ if (entry === session) {
255
+ return nextInfo;
256
+ }
257
+ return msg.stream === true ? entry.topics.snapshot(entry.info) : entry.info;
258
+ });
259
+ const frames = snapshotFrames(requestId, infos, msg.stream === true);
250
260
  const last = frames.pop();
251
- if (!last) {
252
- throw new Error("Missing final snapshot frame");
253
- }
254
- session.info = nextInfo;
261
+ session.topics.commit(prepared);
262
+ session.info = messageSender(nextInfo);
255
263
  const receipts: Array<SessionSnapshot["receipts"][number]> = [];
256
264
  for (const { to, message } of deliveries) {
257
265
  receipts.push({ to, ...this.sendToSession(currentId, to, from, message) });
@@ -326,7 +334,10 @@ class IntercomBroker {
326
334
  if (!session) {
327
335
  return;
328
336
  }
329
- const update: Record<string, unknown> = { ...session.info, lastSeen: Date.now() };
337
+ const update: Record<string, unknown> = {
338
+ ...session.topics.snapshot(session.info),
339
+ lastSeen: Date.now(),
340
+ };
330
341
  for (const key of [
331
342
  "name",
332
343
  "status",
@@ -346,7 +357,10 @@ class IntercomBroker {
346
357
  throw new Error("Invalid presence update");
347
358
  }
348
359
  snapshotFrames(randomUUID(), [nextInfo], true);
349
- session.info = nextInfo;
360
+ if (msg.topics !== undefined || msg.subscriptions !== undefined) {
361
+ session.topics = new TopicSnapshot(nextInfo);
362
+ }
363
+ session.info = messageSender(nextInfo);
350
364
  }
351
365
 
352
366
  private sendToSession(
@@ -19,17 +19,67 @@ export function validateIntercomMessageSize(msg: unknown): Error | null {
19
19
  * Format: 4-byte big-endian length + JSON payload
20
20
  */
21
21
  export function writeMessage(socket: Socket, msg: unknown): void {
22
- const tooLarge = validateIntercomMessageSize(msg);
23
- if (tooLarge) {
24
- throw tooLarge;
25
- }
26
22
  const json = JSON.stringify(msg);
23
+ const size = Buffer.byteLength(json, "utf-8");
24
+ if (size > MAX_FRAME_SIZE_BYTES) {
25
+ throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
26
+ }
27
27
  const payload = Buffer.from(json, "utf-8");
28
28
  const header = Buffer.alloc(4);
29
29
  header.writeUInt32BE(payload.length, 0);
30
30
  socket.write(Buffer.concat([header, payload]));
31
31
  }
32
32
 
33
+ /** Retain fragments until a header or payload is complete; copy each fragmented value once. */
34
+ class MessageBuffer {
35
+ private chunks: Buffer[] = [];
36
+ private index = 0;
37
+ length = 0;
38
+
39
+ append(data: Buffer): void {
40
+ if (data.length > 0) {
41
+ this.chunks.push(data);
42
+ this.length += data.length;
43
+ }
44
+ }
45
+
46
+ clear(): void {
47
+ this.chunks = [];
48
+ this.index = 0;
49
+ this.length = 0;
50
+ }
51
+
52
+ read(size: number): Buffer {
53
+ const first = this.chunks[this.index];
54
+ if (size === 0) {
55
+ return Buffer.alloc(0);
56
+ }
57
+ const result = first.length >= size ? first.subarray(0, size) : Buffer.allocUnsafe(size);
58
+ let written = 0;
59
+ while (written < size) {
60
+ const chunk = this.chunks[this.index];
61
+ const count = Math.min(chunk.length, size - written);
62
+ if (first.length < size) {
63
+ chunk.copy(result, written, 0, count);
64
+ }
65
+ written += count;
66
+ this.length -= count;
67
+ if (count === chunk.length) {
68
+ this.index++;
69
+ } else {
70
+ this.chunks[this.index] = chunk.subarray(count);
71
+ }
72
+ }
73
+ if (this.index === this.chunks.length) {
74
+ this.clear();
75
+ } else if (this.index * 2 >= this.chunks.length) {
76
+ this.chunks = this.chunks.slice(this.index);
77
+ this.index = 0;
78
+ }
79
+ return result;
80
+ }
81
+ }
82
+
33
83
  /**
34
84
  * Create a message reader that handles partial reads.
35
85
  * Calls onMessage for each complete message received.
@@ -39,27 +89,28 @@ export function createMessageReader(
39
89
  onMessage: (msg: unknown) => void,
40
90
  onError: (error: Readonly<Error>) => void,
41
91
  ): (data: Buffer) => void {
42
- let buffer = Buffer.alloc(0);
92
+ const buffer = new MessageBuffer();
93
+ let length: number | undefined;
43
94
 
44
95
  return (data: Buffer) => {
45
- buffer = Buffer.concat([buffer, data]);
46
-
47
- while (buffer.length >= 4) {
48
- const length = buffer.readUInt32BE(0);
49
- if (length > MAX_FRAME_SIZE_BYTES) {
50
- onError(
51
- new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`),
52
- );
53
- buffer = Buffer.alloc(0);
54
- return;
55
- }
96
+ buffer.append(data);
56
97
 
57
- if (buffer.length < 4 + length) {
58
- break;
98
+ while (buffer.length >= (length ?? 4)) {
99
+ if (length === undefined) {
100
+ length = buffer.read(4).readUInt32BE(0);
101
+ if (length > MAX_FRAME_SIZE_BYTES) {
102
+ onError(
103
+ new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`),
104
+ );
105
+ buffer.clear();
106
+ length = undefined;
107
+ return;
108
+ }
109
+ continue;
59
110
  }
60
111
 
61
- const payload = buffer.subarray(4, 4 + length);
62
- buffer = buffer.subarray(4 + length);
112
+ const payload = buffer.read(length);
113
+ length = undefined;
63
114
 
64
115
  let msg: unknown;
65
116
  try {
@@ -1,5 +1,11 @@
1
1
  import { isRecord, isUnknownArray, type UnknownRecord } from "../../shared/unknown.ts";
2
- import { normalizeSessionInfo, type SessionInfo, type SessionSnapshot } from "../types.ts";
2
+ import {
3
+ normalizeSessionInfo,
4
+ type SessionInfo,
5
+ type SessionSnapshot,
6
+ type TopicSubscription,
7
+ type TopicUpdate,
8
+ } from "../types.ts";
3
9
 
4
10
  type Receipt = SessionSnapshot["receipts"][number];
5
11
 
@@ -46,9 +52,30 @@ function receipt(value: unknown): Receipt {
46
52
  };
47
53
  }
48
54
 
55
+ interface SessionParts {
56
+ readonly info: SessionInfo;
57
+ readonly topics: TopicUpdate[] | undefined;
58
+ readonly subscriptions: TopicSubscription[] | undefined;
59
+ }
60
+
61
+ function appendRows<T>(
62
+ previous: T[] | undefined,
63
+ rows: readonly T[] | undefined,
64
+ supplied: boolean,
65
+ ): T[] | undefined {
66
+ if (rows === undefined) {
67
+ return supplied ? undefined : previous;
68
+ }
69
+ const result = previous ?? [];
70
+ for (const row of rows) {
71
+ result.push(row);
72
+ }
73
+ return result;
74
+ }
75
+
49
76
  /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
50
77
  export class SnapshotReader {
51
- private readonly sessions = new Map<string, SessionInfo>();
78
+ private readonly sessions = new Map<string, SessionParts>();
52
79
  private readonly receipts: Receipt[] = [];
53
80
 
54
81
  private addSession(row: unknown): void {
@@ -59,17 +86,20 @@ export class SnapshotReader {
59
86
  const subscriptions = optionalArray(row.subscriptions);
60
87
  const previous = this.sessions.get(row.id);
61
88
  const info = normalizeSessionInfo({
62
- ...previous,
89
+ ...previous?.info,
63
90
  ...row,
64
- ...(topics !== undefined ? { topics: [...(previous?.topics ?? []), ...topics] } : {}),
65
- ...(subscriptions !== undefined
66
- ? { subscriptions: [...(previous?.subscriptions ?? []), ...subscriptions] }
67
- : {}),
91
+ ...(topics !== undefined ? { topics } : {}),
92
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
68
93
  });
69
94
  if (!info) {
70
95
  throw new Error("Invalid session snapshot");
71
96
  }
72
- this.sessions.set(info.id, info);
97
+ const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
98
+ this.sessions.set(info.id, {
99
+ info: identity,
100
+ topics: appendRows(previous?.topics, newTopics, "topics" in row),
101
+ subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
102
+ });
73
103
  }
74
104
 
75
105
  append(sessions: readonly unknown[], receipts: unknown): void {
@@ -86,6 +116,14 @@ export class SnapshotReader {
86
116
  }
87
117
 
88
118
  finish(): SessionSnapshot {
89
- return { sessions: [...this.sessions.values()], receipts: this.receipts };
119
+ const sessions: SessionInfo[] = [];
120
+ for (const { info, topics, subscriptions } of this.sessions.values()) {
121
+ sessions.push({
122
+ ...info,
123
+ ...(topics !== undefined ? { topics } : {}),
124
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
125
+ });
126
+ }
127
+ return { sessions, receipts: this.receipts };
90
128
  }
91
129
  }
@@ -10,7 +10,11 @@ import {
10
10
  type TopicSubscription,
11
11
  type SendResult,
12
12
  } from "../types.ts";
13
- import { validateIntercomMessageSize } from "./framing.ts";
13
+ import {
14
+ intercomMessageSizeBytes,
15
+ MAX_FRAME_SIZE_BYTES,
16
+ validateIntercomMessageSize,
17
+ } from "./framing.ts";
14
18
 
15
19
  /** Direct messages and old clients receive identity, not potentially large topic state. */
16
20
  export function messageSender(info: SessionInfo): SessionInfo {
@@ -124,72 +128,201 @@ function publicationUpdate(
124
128
  return { ...topic, revision };
125
129
  }
126
130
 
127
- class TopicSnapshot {
128
- private readonly info: SessionInfo;
129
- private readonly topics: Map<string, TopicUpdate>;
130
- private readonly subscriptions: Map<string, TopicSubscription>;
131
+ class SnapshotRecords<T extends { readonly topic: string }> {
132
+ private readonly records = new Map<string, { readonly value: T; readonly bytes: number }>();
133
+ private largest = 0;
134
+ private initial: readonly T[] | undefined;
135
+ private present: boolean;
131
136
 
132
- constructor(info: SessionInfo) {
133
- this.info = info;
134
- this.topics = new Map((info.topics ?? []).map((topic) => [topic.topic, topic]));
135
- this.subscriptions = new Map((info.subscriptions ?? []).map((entry) => [entry.topic, entry]));
137
+ constructor(values: readonly T[] | undefined) {
138
+ this.initial = values;
139
+ this.present = values !== undefined;
140
+ for (const value of values ?? []) {
141
+ this.set(value, intercomMessageSizeBytes(value));
142
+ }
136
143
  }
137
144
 
138
- apply(mutation: TopicMutation): TopicUpdate | undefined {
139
- switch (mutation.kind) {
140
- case "publication": {
141
- const previous = this.topics.get(mutation.topic.topic);
142
- const update = publicationUpdate(mutation.topic, previous, mutation.publish);
143
- if (mutation.publish || (previous?.revision ?? 0) < update.revision) {
144
- this.topics.set(update.topic, update);
145
- }
146
- if (mutation.publish) {
147
- return update;
148
- }
145
+ get(topic: string, first = false): T | undefined {
146
+ if (first && this.initial !== undefined) {
147
+ return this.initial.find((record) => record.topic === topic);
148
+ }
149
+ return this.records.get(topic)?.value;
150
+ }
151
+
152
+ maximumAfter(topic?: string, bytes = 0): number {
153
+ const previous = topic === undefined ? undefined : this.records.get(topic);
154
+ if (previous?.bytes !== this.largest || bytes >= this.largest) {
155
+ return Math.max(this.largest, bytes);
156
+ }
157
+ // ponytail: shrinking the largest record scans cached sizes; index sizes if churn warrants it.
158
+ let largest = bytes;
159
+ for (const [key, record] of this.records) {
160
+ if (key !== topic) {
161
+ largest = Math.max(largest, record.bytes);
162
+ }
163
+ }
164
+ return largest;
165
+ }
166
+
167
+ set(value: T, bytes: number): void {
168
+ this.largest = this.maximumAfter(value.topic, bytes);
169
+ this.records.set(value.topic, { value, bytes });
170
+ }
171
+
172
+ delete(topic: string): void {
173
+ this.largest = this.maximumAfter(topic);
174
+ this.records.delete(topic);
175
+ }
176
+
177
+ accept(): void {
178
+ this.initial = undefined;
179
+ this.present = true;
180
+ }
181
+
182
+ valuesAfter(topic?: string, value?: T, changed = false): readonly T[] | undefined {
183
+ if (!changed) {
184
+ if (this.initial !== undefined) {
185
+ return this.initial;
186
+ }
187
+ if (!this.present) {
149
188
  return;
150
189
  }
190
+ }
191
+ const values: T[] = [];
192
+ for (const [key, record] of this.records) {
193
+ const next = key === topic ? value : record.value;
194
+ if (next !== undefined) {
195
+ values.push(next);
196
+ }
197
+ }
198
+ if (value !== undefined && !this.records.has(value.topic)) {
199
+ values.push(value);
200
+ }
201
+ return values;
202
+ }
203
+ }
204
+
205
+ interface PreparedTopicChange {
206
+ readonly topic?: TopicUpdate;
207
+ readonly subscription?: TopicSubscription;
208
+ readonly unsubscribe?: string;
209
+ readonly publication?: TopicUpdate;
210
+ readonly restore: boolean;
211
+ readonly changed: boolean;
212
+ readonly bytes: number;
213
+ }
214
+
215
+ const UNCHANGED: PreparedTopicChange = { restore: false, changed: false, bytes: 0 };
216
+
217
+ /** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
218
+ export class TopicSnapshot {
219
+ private readonly topics: SnapshotRecords<TopicUpdate>;
220
+ private readonly subscriptions: SnapshotRecords<TopicSubscription>;
221
+
222
+ constructor(info: SessionInfo) {
223
+ this.topics = new SnapshotRecords(info.topics);
224
+ this.subscriptions = new SnapshotRecords(info.subscriptions);
225
+ }
226
+
227
+ subscription(topic: string): TopicSubscription | undefined {
228
+ return this.subscriptions.get(topic, true);
229
+ }
230
+
231
+ prepare(change: unknown, supported: boolean): PreparedTopicChange {
232
+ if (change === undefined) {
233
+ return UNCHANGED;
234
+ }
235
+ if (!supported || !isRecord(change)) {
236
+ throw new Error("Topic snapshots unavailable for this client");
237
+ }
238
+ const mutation = topicMutation(change);
239
+ const base = { restore: change.action === "restore", changed: true, bytes: 0 };
240
+ switch (mutation.kind) {
241
+ case "publication":
242
+ return this.preparePublication(mutation, base);
151
243
  case "subscription":
152
- this.subscriptions.set(mutation.subscription.topic, mutation.subscription);
153
- return;
244
+ return {
245
+ ...base,
246
+ subscription: mutation.subscription,
247
+ bytes: intercomMessageSizeBytes(mutation.subscription),
248
+ };
154
249
  case "unsubscribe":
155
- this.subscriptions.delete(mutation.topic);
156
- return;
250
+ return { ...base, unsubscribe: mutation.topic };
251
+ }
252
+ }
253
+
254
+ private preparePublication(
255
+ mutation: Extract<TopicMutation, { readonly kind: "publication" }>,
256
+ base: PreparedTopicChange,
257
+ ): PreparedTopicChange {
258
+ const previous = this.topics.get(mutation.topic.topic);
259
+ const update = publicationUpdate(mutation.topic, previous, mutation.publish);
260
+ if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
261
+ return base;
157
262
  }
263
+ return {
264
+ ...base,
265
+ topic: update,
266
+ bytes: intercomMessageSizeBytes(update),
267
+ ...(mutation.publish ? { publication: update } : {}),
268
+ };
158
269
  }
159
270
 
160
- snapshot(publication?: TopicUpdate): SessionInfo {
271
+ snapshot(info: SessionInfo, prepared = UNCHANGED): SessionInfo {
272
+ const topics = this.topics.valuesAfter(prepared.topic?.topic, prepared.topic, prepared.changed);
273
+ const subscriptions = this.subscriptions.valuesAfter(
274
+ prepared.subscription?.topic ?? prepared.unsubscribe,
275
+ prepared.subscription,
276
+ prepared.changed,
277
+ );
161
278
  return {
162
- ...this.info,
163
- topics: [...this.topics.values()],
164
- subscriptions: [...this.subscriptions.values()],
165
- ...(publication ? { lastIntercomActivity: Date.now() } : {}),
279
+ ...info,
280
+ ...(topics !== undefined ? { topics } : {}),
281
+ ...(subscriptions !== undefined ? { subscriptions } : {}),
282
+ ...(prepared.publication ? { lastIntercomActivity: Date.now() } : {}),
166
283
  };
167
284
  }
168
- }
169
285
 
170
- /** Prepare a change without mutating the usable broker snapshot before frame validation. */
171
- export function prepareTopicChange(
172
- info: SessionInfo,
173
- change: unknown,
174
- supported: boolean,
175
- ): {
176
- readonly info: SessionInfo;
177
- readonly publication?: TopicUpdate;
178
- readonly restore: boolean;
179
- } {
180
- if (change === undefined) {
181
- return { info, restore: false };
286
+ validateRestore(info: SessionInfo, prepared: PreparedTopicChange, requestId: string): void {
287
+ snapshotFrames(requestId, [{ ...info, topics: [], subscriptions: [] }], true);
288
+ const topicBytes = this.topics.maximumAfter(
289
+ prepared.topic?.topic,
290
+ prepared.topic ? prepared.bytes : 0,
291
+ );
292
+ const subscriptionBytes = this.subscriptions.maximumAfter(
293
+ prepared.subscription?.topic,
294
+ prepared.subscription ? prepared.bytes : 0,
295
+ );
296
+ for (const [field, bytes] of [
297
+ ["topics", topicBytes],
298
+ ["subscriptions", subscriptionBytes],
299
+ ] as const) {
300
+ const size =
301
+ intercomMessageSizeBytes({
302
+ type: "sessions",
303
+ requestId,
304
+ sessions: [{ id: info.id, [field]: [] }],
305
+ more: true,
306
+ }) + bytes;
307
+ if (size > MAX_FRAME_SIZE_BYTES) {
308
+ throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
309
+ }
310
+ }
182
311
  }
183
- if (!supported || !isRecord(change)) {
184
- throw new Error("Topic snapshots unavailable for this client");
312
+
313
+ commit(prepared: PreparedTopicChange): void {
314
+ if (prepared.changed) {
315
+ this.topics.accept();
316
+ this.subscriptions.accept();
317
+ }
318
+ if (prepared.topic) {
319
+ this.topics.set(prepared.topic, prepared.bytes);
320
+ } else if (prepared.subscription) {
321
+ this.subscriptions.set(prepared.subscription, prepared.bytes);
322
+ } else if (prepared.unsubscribe !== undefined) {
323
+ this.subscriptions.delete(prepared.unsubscribe);
324
+ }
185
325
  }
186
- const pending = new TopicSnapshot(info);
187
- const publication = pending.apply(topicMutation(change));
188
- return {
189
- info: pending.snapshot(publication),
190
- restore: change.action === "restore",
191
- ...(publication ? { publication } : {}),
192
- };
193
326
  }
194
327
 
195
328
  export function deliveryAcknowledgement(receipt: SendResult): BrokerMessage {
@@ -18,7 +18,6 @@ export interface TargetResolution<T extends TargetIdentity> {
18
18
  readonly status: "none" | "found" | "ambiguous" | "prefix_too_short";
19
19
  readonly target?: T;
20
20
  readonly matches: readonly T[];
21
- readonly minLength?: number;
22
21
  }
23
22
 
24
23
  export function shortSessionId(sessionId: string): string {
@@ -180,7 +179,6 @@ function shortTargetResolution<T extends TargetIdentity>(
180
179
  return {
181
180
  status: "prefix_too_short",
182
181
  matches: prefixes,
183
- minLength: MIN_SESSION_TARGET_PREFIX_LENGTH,
184
182
  };
185
183
  }
186
184
  const byId = new Map<string, T>();
@@ -6,12 +6,12 @@ import { runSynchronously } from "../../shared/cooperative.ts";
6
6
  import { asyncRunRoots, readAsyncRunRecord } from "./async-resume.ts";
7
7
  import type { AsyncRunRecord } from "./async-run-record.ts";
8
8
  import type { AsyncRunSummary } from "./async-run-summary.ts";
9
- import { getRunMetadataDir, readRunJson } from "../shared/supervisor-questions.ts";
9
+ import { readQuestionOwner } from "../shared/supervisor-questions.ts";
10
10
  import { findNestedRouteForRootId } from "../shared/nested-events.ts";
11
11
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
12
12
  import { validateStatusForSummary } from "./async-status-validation.ts";
13
13
  import { asyncStatusToSummary } from "./async-status-projection.ts";
14
- import { errorCode, errorMessage, hasText, isRecord } from "./async-value.ts";
14
+ import { errorCode, errorMessage, hasText } from "./async-value.ts";
15
15
 
16
16
  interface AsyncRunListOptions {
17
17
  readonly states?: readonly AsyncRunSummary["state"][];
@@ -112,14 +112,8 @@ class AsyncRunDiscovery {
112
112
  let owner = this.owners.get(entry);
113
113
  if (!hasText(owner) && hasText(this.ownerSessionId)) {
114
114
  try {
115
- const saved: unknown = readRunJson(
116
- path.join(getRunMetadataDir(entry), "question-owner.json"),
117
- );
118
- if (
119
- isRecord(saved) &&
120
- typeof saved.sessionId === "string" &&
121
- saved.sessionId.trim().length > 0
122
- ) {
115
+ const saved = readQuestionOwner(entry);
116
+ if (saved?.sessionId !== undefined && saved.sessionId.trim().length > 0) {
123
117
  owner = saved.sessionId;
124
118
  this.owners.set(entry, owner);
125
119
  }