@llblab/pi-kit 0.1.13 → 0.2.0

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 (70) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +5 -3
  4. package/node_modules/@llblab/pi-actors/BACKLOG.md +1 -1
  5. package/node_modules/@llblab/pi-actors/CHANGELOG.md +15 -0
  6. package/node_modules/@llblab/pi-actors/README.md +5 -3
  7. package/node_modules/@llblab/pi-actors/dist/index.js +4 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +2 -2
  9. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +42 -19
  10. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.d.ts +2 -1
  11. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +7 -2
  12. package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +9 -0
  13. package/node_modules/@llblab/pi-actors/dist/lib/limits.js +9 -0
  14. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +10 -11
  15. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +81 -56
  16. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +31 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +180 -0
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +115 -0
  19. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +623 -0
  20. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +3 -0
  21. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +341 -13
  22. package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.d.ts +1 -1
  23. package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.js +5 -3
  24. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +16 -0
  25. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +143 -0
  26. package/node_modules/@llblab/pi-actors/dist/lib/temp.js +1 -1
  27. package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +3 -1
  28. package/node_modules/@llblab/pi-actors/dist/scripts/async-runner.mjs +5 -19
  29. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +2 -2
  30. package/node_modules/@llblab/pi-actors/dist/skills/actors/references/runs.md +1 -1
  31. package/node_modules/@llblab/pi-actors/dist/skills/swarm/SKILL.md +1 -1
  32. package/node_modules/@llblab/pi-actors/docs/README.md +1 -0
  33. package/node_modules/@llblab/pi-actors/docs/async-runs.md +8 -4
  34. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +207 -0
  35. package/node_modules/@llblab/pi-actors/index.ts +4 -1
  36. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +42 -21
  37. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +8 -3
  38. package/node_modules/@llblab/pi-actors/lib/limits.ts +9 -0
  39. package/node_modules/@llblab/pi-actors/lib/observability.ts +97 -78
  40. package/node_modules/@llblab/pi-actors/lib/pi.ts +210 -0
  41. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +800 -0
  42. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +370 -18
  43. package/node_modules/@llblab/pi-actors/lib/runs-trace.ts +6 -4
  44. package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +153 -0
  45. package/node_modules/@llblab/pi-actors/lib/temp.ts +1 -1
  46. package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +4 -1
  47. package/node_modules/@llblab/pi-actors/package.json +3 -3
  48. package/node_modules/@llblab/pi-actors/scripts/async-runner.mjs +5 -19
  49. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +2 -2
  50. package/node_modules/@llblab/pi-actors/skills/actors/references/runs.md +1 -1
  51. package/node_modules/@llblab/pi-actors/skills/swarm/SKILL.md +1 -1
  52. package/node_modules/@llblab/pi-telegram/AGENTS.md +4 -4
  53. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  54. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  55. package/node_modules/@llblab/pi-telegram/docs/activity.md +4 -4
  56. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  57. package/node_modules/@llblab/pi-telegram/docs/outbound.md +2 -2
  58. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -4
  59. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +4 -5
  60. package/node_modules/@llblab/pi-telegram/docs/voice.md +12 -2
  61. package/node_modules/@llblab/pi-telegram/index.ts +0 -1
  62. package/node_modules/@llblab/pi-telegram/lib/activity.ts +3 -5
  63. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -17
  64. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +1 -1
  65. package/node_modules/@llblab/pi-telegram/lib/config.ts +25 -25
  66. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +10 -73
  67. package/node_modules/@llblab/pi-telegram/lib/status.ts +11 -1
  68. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  69. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +5 -3
  70. package/package.json +3 -3
@@ -0,0 +1,800 @@
1
+ /**
2
+ * Coordinator delivery persistence.
3
+ * Zones: owner-scoped completion batches, delivery phase fencing, bounded receipts
4
+ * Owns durable delivery state; excludes Run discovery, Pi scheduling, and model formatting.
5
+ */
6
+
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+
11
+ import { withFileMutationLock, writeTextAtomic } from "./file-state.ts";
12
+ import * as Limits from "./limits.ts";
13
+
14
+ export type RunCompletionDeliveryStatus = "done" | "failed" | "killed" | "exited";
15
+ export type RunDeliveryPhase = "pending" | "queued" | "presented";
16
+
17
+ export interface RunCompletionBatchMember {
18
+ artifacts?: Record<string, string>;
19
+ run: string;
20
+ run_instance_id: string;
21
+ state_dir: string;
22
+ status: RunCompletionDeliveryStatus;
23
+ summary: string;
24
+ terminal_at: string;
25
+ }
26
+
27
+ export interface RunCompletionBatch {
28
+ attempts?: number;
29
+ batch_id: string;
30
+ created_at: string;
31
+ last_error?: string;
32
+ last_failed_at?: string;
33
+ members: RunCompletionBatchMember[];
34
+ phase: RunDeliveryPhase;
35
+ presented_at?: string;
36
+ queued_at?: string;
37
+ }
38
+
39
+ export interface RunSteerEnvelope {
40
+ attempts?: number;
41
+ content: string;
42
+ created_at: string;
43
+ event_id: string;
44
+ kind: string;
45
+ last_error?: string;
46
+ last_failed_at?: string;
47
+ level: "info" | "warning" | "error";
48
+ occurred_at: string;
49
+ phase: RunDeliveryPhase;
50
+ presented_at?: string;
51
+ queued_at?: string;
52
+ run: string;
53
+ run_instance_id: string;
54
+ state_dir: string;
55
+ steer_id: string;
56
+ }
57
+
58
+ export interface RunDeliveryReceipt {
59
+ delivery_id: string;
60
+ kind: "completion_batch" | "urgent_steer";
61
+ presented_at: string;
62
+ }
63
+
64
+ export interface RunDeliveryJournal {
65
+ completion_batch?: RunCompletionBatch;
66
+ owner_id: string;
67
+ receipts: RunDeliveryReceipt[];
68
+ schema: "run-delivery-v1";
69
+ steers: RunSteerEnvelope[];
70
+ }
71
+
72
+ export interface AdmitRunCompletionBatchInput {
73
+ batchId?: string;
74
+ members: RunCompletionBatchMember[];
75
+ now?: Date;
76
+ ownerId: string;
77
+ tempDir: string;
78
+ }
79
+
80
+ export interface RunDeliveryTransitionInput {
81
+ batchId: string;
82
+ now?: Date;
83
+ ownerId: string;
84
+ tempDir: string;
85
+ }
86
+
87
+ export interface AdmitRunSteerEnvelopeInput {
88
+ content: string;
89
+ eventId: string;
90
+ kind: string;
91
+ level: "info" | "warning" | "error";
92
+ now?: Date;
93
+ occurredAt: string;
94
+ ownerId: string;
95
+ run: string;
96
+ runInstanceId: string;
97
+ stateDir: string;
98
+ steerId?: string;
99
+ tempDir: string;
100
+ }
101
+
102
+ export interface RunSteerTransitionInput {
103
+ now?: Date;
104
+ ownerId: string;
105
+ steerId: string;
106
+ tempDir: string;
107
+ }
108
+
109
+ const RUN_DELIVERY_SCHEMA = "run-delivery-v1";
110
+ const MEMBER_FIELDS = new Set([
111
+ "artifacts",
112
+ "run",
113
+ "run_instance_id",
114
+ "state_dir",
115
+ "status",
116
+ "summary",
117
+ "terminal_at",
118
+ ]);
119
+ const BATCH_FIELDS = new Set([
120
+ "attempts",
121
+ "batch_id",
122
+ "created_at",
123
+ "last_error",
124
+ "last_failed_at",
125
+ "members",
126
+ "phase",
127
+ "presented_at",
128
+ "queued_at",
129
+ ]);
130
+ const JOURNAL_FIELDS = new Set([
131
+ "completion_batch",
132
+ "owner_id",
133
+ "receipts",
134
+ "schema",
135
+ "steers",
136
+ ]);
137
+ const RECEIPT_FIELDS = new Set([
138
+ "delivery_id",
139
+ "kind",
140
+ "presented_at",
141
+ ]);
142
+ const STEER_FIELDS = new Set([
143
+ "attempts",
144
+ "content",
145
+ "created_at",
146
+ "event_id",
147
+ "kind",
148
+ "last_error",
149
+ "last_failed_at",
150
+ "level",
151
+ "occurred_at",
152
+ "phase",
153
+ "presented_at",
154
+ "queued_at",
155
+ "run",
156
+ "run_instance_id",
157
+ "state_dir",
158
+ "steer_id",
159
+ ]);
160
+
161
+ function assertExactFields(
162
+ value: Record<string, unknown>,
163
+ fields: Set<string>,
164
+ label: string,
165
+ ): void {
166
+ const unknown = Object.keys(value).find((key) => !fields.has(key));
167
+ if (unknown) throw new Error(`${label} has unknown field: ${unknown}`);
168
+ }
169
+
170
+ function asRecord(value: unknown, label: string): Record<string, unknown> {
171
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
172
+ throw new Error(`${label} must be an object`);
173
+ }
174
+ return value as Record<string, unknown>;
175
+ }
176
+
177
+ function boundedString(
178
+ value: unknown,
179
+ label: string,
180
+ maxLength: number,
181
+ ): string {
182
+ if (typeof value !== "string" || !value.trim()) {
183
+ throw new Error(`${label} must be a non-empty string`);
184
+ }
185
+ if (value.length > maxLength) {
186
+ throw new Error(`${label} exceeds ${maxLength} characters`);
187
+ }
188
+ return value;
189
+ }
190
+
191
+ function timestamp(value: unknown, label: string): string {
192
+ const text = boundedString(value, label, 64);
193
+ if (Number.isNaN(Date.parse(text))) throw new Error(`${label} must be a timestamp`);
194
+ return text;
195
+ }
196
+
197
+ function completionStatus(value: unknown): RunCompletionDeliveryStatus {
198
+ if (value === "done" || value === "failed" || value === "killed" || value === "exited") {
199
+ return value;
200
+ }
201
+ throw new Error("Completion batch member status is invalid");
202
+ }
203
+
204
+ function artifacts(value: unknown): Record<string, string> | undefined {
205
+ if (value === undefined) return undefined;
206
+ const record = asRecord(value, "Completion batch member artifacts");
207
+ const entries = Object.entries(record);
208
+ if (entries.length > 4) throw new Error("Completion batch member has too many artifacts");
209
+ return Object.fromEntries(entries.map(([name, path]) => [
210
+ boundedString(name, "Completion batch artifact name", 120),
211
+ boundedString(path, "Completion batch artifact path", 4_096),
212
+ ]));
213
+ }
214
+
215
+ function parseMember(value: unknown): RunCompletionBatchMember {
216
+ const record = asRecord(value, "Completion batch member");
217
+ assertExactFields(record, MEMBER_FIELDS, "Completion batch member");
218
+ const parsedArtifacts = artifacts(record.artifacts);
219
+ return {
220
+ ...(parsedArtifacts ? { artifacts: parsedArtifacts } : {}),
221
+ run: boundedString(record.run, "Completion batch member run", 120),
222
+ run_instance_id: boundedString(
223
+ record.run_instance_id,
224
+ "Completion batch member run_instance_id",
225
+ 256,
226
+ ),
227
+ state_dir: boundedString(record.state_dir, "Completion batch member state_dir", 4_096),
228
+ status: completionStatus(record.status),
229
+ summary: boundedString(record.summary, "Completion batch member summary", 1_000),
230
+ terminal_at: timestamp(record.terminal_at, "Completion batch member terminal_at"),
231
+ };
232
+ }
233
+
234
+ function parseBatch(value: unknown): RunCompletionBatch {
235
+ const record = asRecord(value, "Completion batch");
236
+ assertExactFields(record, BATCH_FIELDS, "Completion batch");
237
+ if (!Array.isArray(record.members) || record.members.length === 0) {
238
+ throw new Error("Completion batch members must be a non-empty array");
239
+ }
240
+ if (record.members.length > Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS) {
241
+ throw new Error("Completion batch exceeds the member limit");
242
+ }
243
+ const members = record.members.map(parseMember);
244
+ const identities = new Set<string>();
245
+ for (const member of members) {
246
+ const identity = `${member.state_dir}\0${member.run_instance_id}`;
247
+ if (identities.has(identity)) throw new Error("Completion batch has duplicate generation members");
248
+ identities.add(identity);
249
+ }
250
+ const phase = record.phase;
251
+ if (phase !== "pending" && phase !== "queued" && phase !== "presented") {
252
+ throw new Error("Completion batch phase is invalid");
253
+ }
254
+ const queuedAt = record.queued_at === undefined
255
+ ? undefined
256
+ : timestamp(record.queued_at, "Completion batch queued_at");
257
+ const presentedAt = record.presented_at === undefined
258
+ ? undefined
259
+ : timestamp(record.presented_at, "Completion batch presented_at");
260
+ const attempts = record.attempts;
261
+ if (
262
+ attempts !== undefined &&
263
+ (typeof attempts !== "number" ||
264
+ !Number.isSafeInteger(attempts) ||
265
+ attempts < 0 ||
266
+ attempts > 1_000_000)
267
+ ) throw new Error("Completion batch attempts are invalid");
268
+ const lastError = record.last_error === undefined
269
+ ? undefined
270
+ : boundedString(record.last_error, "Completion batch last_error", 4_096);
271
+ const lastFailedAt = record.last_failed_at === undefined
272
+ ? undefined
273
+ : timestamp(record.last_failed_at, "Completion batch last_failed_at");
274
+ if (Boolean(lastError) !== Boolean(lastFailedAt)) {
275
+ throw new Error("Completion batch failure evidence is incomplete");
276
+ }
277
+ if (phase === "pending" && (queuedAt || presentedAt)) {
278
+ throw new Error("Pending completion batch cannot have delivery timestamps");
279
+ }
280
+ if (phase === "queued" && (!queuedAt || presentedAt)) {
281
+ throw new Error("Queued completion batch must have only queued_at");
282
+ }
283
+ if (phase === "presented" && (!queuedAt || !presentedAt)) {
284
+ throw new Error("Presented completion batch requires queued_at and presented_at");
285
+ }
286
+ return {
287
+ ...(attempts !== undefined ? { attempts } : {}),
288
+ batch_id: boundedString(record.batch_id, "Completion batch id", 128),
289
+ created_at: timestamp(record.created_at, "Completion batch created_at"),
290
+ ...(lastError ? { last_error: lastError } : {}),
291
+ ...(lastFailedAt ? { last_failed_at: lastFailedAt } : {}),
292
+ members,
293
+ phase,
294
+ ...(queuedAt ? { queued_at: queuedAt } : {}),
295
+ ...(presentedAt ? { presented_at: presentedAt } : {}),
296
+ };
297
+ }
298
+
299
+ function parseSteer(value: unknown): RunSteerEnvelope {
300
+ const record = asRecord(value, "Urgent steer envelope");
301
+ assertExactFields(record, STEER_FIELDS, "Urgent steer envelope");
302
+ const phase = record.phase;
303
+ if (phase !== "pending" && phase !== "queued" && phase !== "presented") {
304
+ throw new Error("Urgent steer phase is invalid");
305
+ }
306
+ const queuedAt = record.queued_at === undefined
307
+ ? undefined
308
+ : timestamp(record.queued_at, "Urgent steer queued_at");
309
+ const presentedAt = record.presented_at === undefined
310
+ ? undefined
311
+ : timestamp(record.presented_at, "Urgent steer presented_at");
312
+ if (phase === "pending" && (queuedAt || presentedAt)) {
313
+ throw new Error("Pending urgent steer cannot have delivery timestamps");
314
+ }
315
+ if (phase === "queued" && (!queuedAt || presentedAt)) {
316
+ throw new Error("Queued urgent steer must have only queued_at");
317
+ }
318
+ if (phase === "presented" && (!queuedAt || !presentedAt)) {
319
+ throw new Error("Presented urgent steer requires queued_at and presented_at");
320
+ }
321
+ const attempts = record.attempts;
322
+ if (
323
+ attempts !== undefined &&
324
+ (typeof attempts !== "number" ||
325
+ !Number.isSafeInteger(attempts) ||
326
+ attempts < 0 ||
327
+ attempts > 1_000_000)
328
+ ) throw new Error("Urgent steer attempts are invalid");
329
+ const lastError = record.last_error === undefined
330
+ ? undefined
331
+ : boundedString(record.last_error, "Urgent steer last_error", 4_096);
332
+ const lastFailedAt = record.last_failed_at === undefined
333
+ ? undefined
334
+ : timestamp(record.last_failed_at, "Urgent steer last_failed_at");
335
+ if (Boolean(lastError) !== Boolean(lastFailedAt)) {
336
+ throw new Error("Urgent steer failure evidence is incomplete");
337
+ }
338
+ const content = boundedString(
339
+ record.content,
340
+ "Urgent steer content",
341
+ Limits.RUN_DELIVERY_STEER_MAX_BYTES,
342
+ );
343
+ if (Buffer.byteLength(content, "utf8") > Limits.RUN_DELIVERY_STEER_MAX_BYTES) {
344
+ throw new Error("Urgent steer content exceeds the byte limit");
345
+ }
346
+ const level = record.level;
347
+ if (level !== "info" && level !== "warning" && level !== "error") {
348
+ throw new Error("Urgent steer level is invalid");
349
+ }
350
+ return {
351
+ ...(attempts !== undefined ? { attempts } : {}),
352
+ content,
353
+ created_at: timestamp(record.created_at, "Urgent steer created_at"),
354
+ event_id: boundedString(record.event_id, "Urgent steer event_id", 256),
355
+ kind: boundedString(record.kind, "Urgent steer kind", 256),
356
+ ...(lastError ? { last_error: lastError } : {}),
357
+ ...(lastFailedAt ? { last_failed_at: lastFailedAt } : {}),
358
+ level,
359
+ occurred_at: timestamp(record.occurred_at, "Urgent steer occurred_at"),
360
+ phase,
361
+ ...(presentedAt ? { presented_at: presentedAt } : {}),
362
+ ...(queuedAt ? { queued_at: queuedAt } : {}),
363
+ run: boundedString(record.run, "Urgent steer run", 120),
364
+ run_instance_id: boundedString(
365
+ record.run_instance_id,
366
+ "Urgent steer run_instance_id",
367
+ 256,
368
+ ),
369
+ state_dir: boundedString(record.state_dir, "Urgent steer state_dir", 4_096),
370
+ steer_id: boundedString(record.steer_id, "Urgent steer id", 128),
371
+ };
372
+ }
373
+
374
+ function parseReceipt(value: unknown): RunDeliveryReceipt {
375
+ const record = asRecord(value, "Run delivery receipt");
376
+ assertExactFields(record, RECEIPT_FIELDS, "Run delivery receipt");
377
+ if (record.kind !== "completion_batch" && record.kind !== "urgent_steer") {
378
+ throw new Error("Run delivery receipt kind is invalid");
379
+ }
380
+ return {
381
+ delivery_id: boundedString(record.delivery_id, "Run delivery receipt id", 128),
382
+ kind: record.kind,
383
+ presented_at: timestamp(record.presented_at, "Run delivery receipt presented_at"),
384
+ };
385
+ }
386
+
387
+ function emptyJournal(ownerId: string): RunDeliveryJournal {
388
+ return {
389
+ owner_id: ownerId,
390
+ receipts: [],
391
+ schema: RUN_DELIVERY_SCHEMA,
392
+ steers: [],
393
+ };
394
+ }
395
+
396
+ function parseJournal(value: unknown, ownerId: string): RunDeliveryJournal {
397
+ const record = asRecord(value, "Run delivery journal");
398
+ assertExactFields(record, JOURNAL_FIELDS, "Run delivery journal");
399
+ if (record.schema !== RUN_DELIVERY_SCHEMA) {
400
+ throw new Error("Run delivery journal schema is invalid");
401
+ }
402
+ if (record.owner_id !== ownerId) {
403
+ throw new Error("Run delivery journal owner does not match the active owner");
404
+ }
405
+ if (!Array.isArray(record.receipts) || record.receipts.length > Limits.RUN_DELIVERY_RECEIPT_LIMIT) {
406
+ throw new Error("Run delivery journal receipts are invalid");
407
+ }
408
+ const receipts = record.receipts.map(parseReceipt);
409
+ if (new Set(receipts.map((receipt) =>
410
+ `${receipt.kind}\0${receipt.delivery_id}`)).size !== receipts.length) {
411
+ throw new Error("Run delivery journal has duplicate receipts");
412
+ }
413
+ const rawSteers = record.steers === undefined ? [] : record.steers;
414
+ if (
415
+ !Array.isArray(rawSteers) ||
416
+ rawSteers.length > Limits.RUN_DELIVERY_STEER_MAX_ENVELOPES
417
+ ) throw new Error("Run delivery journal urgent steers are invalid");
418
+ const steers = rawSteers.map(parseSteer);
419
+ if (new Set(steers.map((steer) => steer.steer_id)).size !== steers.length) {
420
+ throw new Error("Run delivery journal has duplicate urgent steer ids");
421
+ }
422
+ return {
423
+ ...(record.completion_batch !== undefined
424
+ ? { completion_batch: parseBatch(record.completion_batch) }
425
+ : {}),
426
+ owner_id: ownerId,
427
+ receipts,
428
+ schema: RUN_DELIVERY_SCHEMA,
429
+ steers,
430
+ };
431
+ }
432
+
433
+ function ownerKey(ownerId: string): string {
434
+ const exact = boundedString(ownerId, "Run delivery owner", 4_096);
435
+ return createHash("sha256").update(exact).digest("hex");
436
+ }
437
+
438
+ export function getRunDeliveryJournalPath(tempDir: string, ownerId: string): string {
439
+ return join(tempDir, "delivery", ownerKey(ownerId), "projection.json");
440
+ }
441
+
442
+ function assertNoDeliverySymlinks(tempDir: string, path: string): void {
443
+ for (const candidate of [join(tempDir, "delivery"), dirname(path), path]) {
444
+ if (existsSync(candidate) && lstatSync(candidate).isSymbolicLink()) {
445
+ throw new Error(`Run delivery state rejects symbolic link: ${candidate}`);
446
+ }
447
+ }
448
+ }
449
+
450
+ function readJournalAtPath(path: string, ownerId: string): RunDeliveryJournal {
451
+ if (!existsSync(path)) return emptyJournal(ownerId);
452
+ const stat = statSync(path);
453
+ if (!stat.isFile() || stat.size > Limits.RUN_DELIVERY_JOURNAL_MAX_BYTES) {
454
+ throw new Error("Run delivery journal is not a bounded regular file");
455
+ }
456
+ let parsed: unknown;
457
+ try {
458
+ parsed = JSON.parse(readFileSync(path, "utf8"));
459
+ } catch (error) {
460
+ throw new Error("Run delivery journal is malformed", { cause: error });
461
+ }
462
+ return parseJournal(parsed, ownerId);
463
+ }
464
+
465
+ export function readRunDeliveryJournal(
466
+ tempDir: string,
467
+ ownerId: string,
468
+ ): RunDeliveryJournal {
469
+ const path = getRunDeliveryJournalPath(tempDir, ownerId);
470
+ assertNoDeliverySymlinks(tempDir, path);
471
+ return readJournalAtPath(path, ownerId);
472
+ }
473
+
474
+ function persistJournal(path: string, journal: RunDeliveryJournal): void {
475
+ const content = `${JSON.stringify(journal, null, 2)}\n`;
476
+ if (Buffer.byteLength(content, "utf8") > Limits.RUN_DELIVERY_JOURNAL_MAX_BYTES) {
477
+ throw new Error("Run delivery journal exceeds the byte limit");
478
+ }
479
+ writeTextAtomic(path, content);
480
+ }
481
+
482
+ function mutateJournal<T>(
483
+ tempDir: string,
484
+ ownerId: string,
485
+ mutate: (journal: RunDeliveryJournal) => T,
486
+ ): T {
487
+ const path = getRunDeliveryJournalPath(tempDir, ownerId);
488
+ return withFileMutationLock(path, () => {
489
+ assertNoDeliverySymlinks(tempDir, path);
490
+ const journal = readJournalAtPath(path, ownerId);
491
+ const result = mutate(journal);
492
+ persistJournal(path, parseJournal(journal, ownerId));
493
+ return result;
494
+ });
495
+ }
496
+
497
+ export function admitRunCompletionBatch(
498
+ input: AdmitRunCompletionBatchInput,
499
+ ): RunCompletionBatch {
500
+ const now = (input.now ?? new Date()).toISOString();
501
+ const batch = parseBatch({
502
+ batch_id: input.batchId ?? randomUUID(),
503
+ created_at: now,
504
+ members: input.members,
505
+ phase: "pending",
506
+ });
507
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
508
+ if (journal.completion_batch) {
509
+ throw new Error("Run delivery journal already has an active completion batch");
510
+ }
511
+ if (journal.receipts.some((receipt) => receipt.delivery_id === batch.batch_id)) {
512
+ throw new Error("Run delivery batch id already has a presented receipt");
513
+ }
514
+ journal.completion_batch = batch;
515
+ return batch;
516
+ });
517
+ }
518
+
519
+ export function markRunCompletionBatchQueued(
520
+ input: RunDeliveryTransitionInput,
521
+ ): boolean {
522
+ const now = (input.now ?? new Date()).toISOString();
523
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
524
+ const batch = journal.completion_batch;
525
+ if (!batch || batch.batch_id !== input.batchId) return false;
526
+ if (batch.phase === "queued" || batch.phase === "presented") return true;
527
+ batch.phase = "queued";
528
+ batch.queued_at = now;
529
+ return true;
530
+ });
531
+ }
532
+
533
+ export function resetRunCompletionBatchPending(
534
+ input: Omit<RunDeliveryTransitionInput, "now">,
535
+ ): boolean {
536
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
537
+ const batch = journal.completion_batch;
538
+ if (!batch || batch.batch_id !== input.batchId || batch.phase === "presented") {
539
+ return false;
540
+ }
541
+ if (batch.phase === "pending") return true;
542
+ batch.phase = "pending";
543
+ delete batch.queued_at;
544
+ return true;
545
+ });
546
+ }
547
+
548
+ export function recordRunCompletionBatchDeliveryFailure(
549
+ input: RunDeliveryTransitionInput & { error: unknown },
550
+ ): boolean {
551
+ const now = (input.now ?? new Date()).toISOString();
552
+ const raw = input.error instanceof Error ? input.error.message : String(input.error);
553
+ const error = raw.replaceAll(/\s+/g, " ").trim().slice(0, 4_096) ||
554
+ "Unknown completion delivery failure";
555
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
556
+ const batch = journal.completion_batch;
557
+ if (!batch || batch.batch_id !== input.batchId || batch.phase !== "pending") {
558
+ return false;
559
+ }
560
+ batch.attempts = Math.min((batch.attempts ?? 0) + 1, 1_000_000);
561
+ batch.last_error = error;
562
+ batch.last_failed_at = now;
563
+ return true;
564
+ });
565
+ }
566
+
567
+ export function markRunCompletionBatchPresented(
568
+ input: RunDeliveryTransitionInput,
569
+ ): boolean {
570
+ const now = (input.now ?? new Date()).toISOString();
571
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
572
+ const batch = journal.completion_batch;
573
+ if (!batch || batch.batch_id !== input.batchId || batch.phase === "pending") return false;
574
+ if (batch.phase === "presented") return true;
575
+ batch.phase = "presented";
576
+ batch.presented_at = now;
577
+ return true;
578
+ });
579
+ }
580
+
581
+ export function finalizeRunCompletionBatch(
582
+ input: Omit<RunDeliveryTransitionInput, "now">,
583
+ ): boolean {
584
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
585
+ const batch = journal.completion_batch;
586
+ if (!batch || batch.batch_id !== input.batchId) {
587
+ return journal.receipts.some((receipt) => receipt.delivery_id === input.batchId);
588
+ }
589
+ if (batch.phase !== "presented" || !batch.presented_at) return false;
590
+ journal.receipts = [
591
+ ...journal.receipts.filter((receipt) => receipt.delivery_id !== batch.batch_id),
592
+ {
593
+ delivery_id: batch.batch_id,
594
+ kind: "completion_batch",
595
+ presented_at: batch.presented_at,
596
+ } satisfies RunDeliveryReceipt,
597
+ ].slice(-Limits.RUN_DELIVERY_RECEIPT_LIMIT);
598
+ delete journal.completion_batch;
599
+ return true;
600
+ });
601
+ }
602
+
603
+ export function getRunSteerDeliveryId(input: {
604
+ eventId: string;
605
+ runInstanceId: string;
606
+ stateDir: string;
607
+ }): string {
608
+ return createHash("sha256").update(JSON.stringify([
609
+ input.stateDir,
610
+ input.runInstanceId,
611
+ input.eventId,
612
+ ])).digest("hex");
613
+ }
614
+
615
+ export function admitRunSteerEnvelope(
616
+ input: AdmitRunSteerEnvelopeInput,
617
+ ): RunSteerEnvelope | undefined {
618
+ const steer = parseSteer({
619
+ content: input.content,
620
+ created_at: (input.now ?? new Date()).toISOString(),
621
+ event_id: input.eventId,
622
+ kind: input.kind,
623
+ level: input.level,
624
+ occurred_at: input.occurredAt,
625
+ phase: "pending",
626
+ run: input.run,
627
+ run_instance_id: input.runInstanceId,
628
+ state_dir: input.stateDir,
629
+ steer_id: input.steerId ?? getRunSteerDeliveryId(input),
630
+ });
631
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
632
+ const existing = journal.steers.find((item) => item.steer_id === steer.steer_id);
633
+ if (existing) {
634
+ if (
635
+ existing.content !== steer.content ||
636
+ existing.event_id !== steer.event_id ||
637
+ existing.run_instance_id !== steer.run_instance_id ||
638
+ existing.state_dir !== steer.state_dir
639
+ ) throw new Error("Urgent steer id conflicts with a different envelope");
640
+ return existing;
641
+ }
642
+ if (journal.receipts.some((receipt) =>
643
+ receipt.kind === "urgent_steer" && receipt.delivery_id === steer.steer_id)) {
644
+ return undefined;
645
+ }
646
+ if (journal.steers.length >= Limits.RUN_DELIVERY_STEER_MAX_ENVELOPES) {
647
+ throw Object.assign(new Error("Run delivery urgent steer capacity is full"), {
648
+ code: "RUN_STEER_CAPACITY",
649
+ });
650
+ }
651
+ journal.steers.push(steer);
652
+ return steer;
653
+ });
654
+ }
655
+
656
+ function mutateSteer(
657
+ input: RunSteerTransitionInput,
658
+ mutate: (steer: RunSteerEnvelope) => boolean,
659
+ ): boolean {
660
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
661
+ const steer = journal.steers.find((item) => item.steer_id === input.steerId);
662
+ return steer ? mutate(steer) : false;
663
+ });
664
+ }
665
+
666
+ export function markRunSteerQueued(input: RunSteerTransitionInput): boolean {
667
+ const now = (input.now ?? new Date()).toISOString();
668
+ return mutateSteer(input, (steer) => {
669
+ if (steer.phase === "queued" || steer.phase === "presented") return true;
670
+ steer.phase = "queued";
671
+ steer.queued_at = now;
672
+ return true;
673
+ });
674
+ }
675
+
676
+ export function resetRunSteerPending(
677
+ input: Omit<RunSteerTransitionInput, "now">,
678
+ ): boolean {
679
+ return mutateSteer(input, (steer) => {
680
+ if (steer.phase === "presented") return false;
681
+ if (steer.phase === "pending") return true;
682
+ steer.phase = "pending";
683
+ delete steer.queued_at;
684
+ return true;
685
+ });
686
+ }
687
+
688
+ export function recordRunSteerDeliveryFailure(
689
+ input: RunSteerTransitionInput & { error: unknown },
690
+ ): boolean {
691
+ const now = (input.now ?? new Date()).toISOString();
692
+ const raw = input.error instanceof Error ? input.error.message : String(input.error);
693
+ const error = raw.replaceAll(/\s+/g, " ").trim().slice(0, 4_096) ||
694
+ "Unknown urgent steer delivery failure";
695
+ return mutateSteer(input, (steer) => {
696
+ if (steer.phase !== "pending") return false;
697
+ steer.attempts = Math.min((steer.attempts ?? 0) + 1, 1_000_000);
698
+ steer.last_error = error;
699
+ steer.last_failed_at = now;
700
+ return true;
701
+ });
702
+ }
703
+
704
+ export function markRunSteerPresented(input: RunSteerTransitionInput): boolean {
705
+ const now = (input.now ?? new Date()).toISOString();
706
+ return mutateSteer(input, (steer) => {
707
+ if (steer.phase === "pending") return false;
708
+ if (steer.phase === "presented") return true;
709
+ steer.phase = "presented";
710
+ steer.presented_at = now;
711
+ return true;
712
+ });
713
+ }
714
+
715
+ export function finalizeRunSteer(
716
+ input: Omit<RunSteerTransitionInput, "now">,
717
+ ): boolean {
718
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
719
+ const index = journal.steers.findIndex((item) => item.steer_id === input.steerId);
720
+ if (index < 0) return journal.receipts.some((receipt) =>
721
+ receipt.kind === "urgent_steer" && receipt.delivery_id === input.steerId);
722
+ const steer = journal.steers[index]!;
723
+ if (steer.phase !== "presented" || !steer.presented_at) return false;
724
+ journal.steers.splice(index, 1);
725
+ journal.receipts = [
726
+ ...journal.receipts.filter((receipt) =>
727
+ receipt.kind !== "urgent_steer" || receipt.delivery_id !== steer.steer_id),
728
+ {
729
+ delivery_id: steer.steer_id,
730
+ kind: "urgent_steer",
731
+ presented_at: steer.presented_at,
732
+ } satisfies RunDeliveryReceipt,
733
+ ].slice(-Limits.RUN_DELIVERY_RECEIPT_LIMIT);
734
+ return true;
735
+ });
736
+ }
737
+
738
+ function compactModelText(value: string, limit: number): string {
739
+ const compact = value.replaceAll(/\s+/g, " ").replaceAll("`", "'").trim();
740
+ return compact.length > limit ? `${compact.slice(0, limit - 1)}…` : compact;
741
+ }
742
+
743
+ function formatCompletionMember(member: RunCompletionBatchMember): string {
744
+ const summary = compactModelText(member.summary, 200);
745
+ const artifactEntries = Object.entries(member.artifacts ?? {}).slice(0, 4);
746
+ const artifactText = artifactEntries.length === 0
747
+ ? ""
748
+ : `; artifacts: ${artifactEntries.map(([name, path]) =>
749
+ `${compactModelText(name, 120)}=\`${compactModelText(path, 320)}\``
750
+ ).join(", ")}`;
751
+ return `- \`${compactModelText(member.run, 120)}\` — \`${member.status}\`: ${summary}${artifactText}`;
752
+ }
753
+
754
+ function formatStatusCounts(members: RunCompletionBatchMember[]): string {
755
+ const counts = new Map<RunCompletionDeliveryStatus, number>();
756
+ for (const member of members) {
757
+ counts.set(member.status, (counts.get(member.status) ?? 0) + 1);
758
+ }
759
+ return (["done", "failed", "killed", "exited"] as const)
760
+ .filter((status) => counts.has(status))
761
+ .map((status) => `${status}=${counts.get(status)}`)
762
+ .join(" ");
763
+ }
764
+
765
+ /** Format one immutable batch for a model-bound custom message. */
766
+ export function formatRunCompletionBatchMessage(batch: RunCompletionBatch): string {
767
+ const safe = parseBatch(batch);
768
+ const terminalTimes = safe.members.map((member) => member.terminal_at).sort();
769
+ const header = [
770
+ `Actor completions: ${safe.members.length}`,
771
+ `Batch: \`${compactModelText(safe.batch_id, 128)}\``,
772
+ `Window: \`${terminalTimes[0]}\` → \`${terminalTimes.at(-1)}\``,
773
+ `Statuses: \`${formatStatusCounts(safe.members)}\``,
774
+ ];
775
+ const candidateRows = safe.members
776
+ .slice(0, Limits.RUN_DELIVERY_MODEL_MAX_MEMBERS)
777
+ .map(formatCompletionMember);
778
+ const rows: string[] = [];
779
+ for (const row of candidateRows) {
780
+ const omitted = safe.members.length - rows.length - 1;
781
+ const candidate = [
782
+ ...header,
783
+ ...rows,
784
+ row,
785
+ ...(omitted > 0 ? [`… ${omitted} more completion(s) retained in batch.`] : []),
786
+ ].join("\n");
787
+ if (Buffer.byteLength(candidate, "utf8") > Limits.RUN_DELIVERY_MODEL_MAX_BYTES) break;
788
+ rows.push(row);
789
+ }
790
+ const omitted = safe.members.length - rows.length;
791
+ const message = [
792
+ ...header,
793
+ ...rows,
794
+ ...(omitted > 0 ? [`… ${omitted} more completion(s) retained in batch.`] : []),
795
+ ].join("\n");
796
+ if (Buffer.byteLength(message, "utf8") > Limits.RUN_DELIVERY_MODEL_MAX_BYTES) {
797
+ throw new Error("Run completion batch header exceeds the model byte limit");
798
+ }
799
+ return message;
800
+ }