@ixo/oracle-runtime-workers 0.10.0 → 0.11.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 (48) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +29 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +227 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +875 -146
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/plugin-api/tool-helper.ts +9 -1
  39. package/src/plugin-api/types.ts +86 -0
  40. package/src/plugins/index.ts +3 -0
  41. package/src/plugins/matrix-group-chats/README.md +70 -0
  42. package/src/plugins/matrix-group-chats/index.ts +132 -0
  43. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  44. package/src/plugins/tasks/tasks-tools.ts +4 -0
  45. package/src/shell/app.ts +66 -0
  46. package/src/sqlite/sqlite-saver.ts +81 -0
  47. package/src/tasks/scheduler.ts +134 -3
  48. package/src/tasks/store.ts +22 -0
@@ -0,0 +1,572 @@
1
+ /**
2
+ * The runs of one user object, live and recovering (docs/plans/durable-runs.md).
3
+ *
4
+ * The coordinator owns the in-memory side of durability: which runs are in
5
+ * flight in this instance, their output buffers, their abort controllers,
6
+ * the keep-alive that holds the object loaded while a run is active, the
7
+ * per-session multitask rule, re-joins, and the recovery schedule for runs
8
+ * a previous incarnation left behind. The object supplies the host: how to
9
+ * run one attempt, the latest checkpoint of a session, and what to do when
10
+ * a run ends (deliver a task result, close a Matrix card).
11
+ */
12
+ import type { Logger } from '../plugin-api/types';
13
+ import {
14
+ framesOfSegments,
15
+ partialTextOf,
16
+ RunBuffer,
17
+ type RunFrame,
18
+ } from './run-buffer';
19
+ import {
20
+ attemptSeqBase,
21
+ decideRecovery,
22
+ type RunDurabilityConfig,
23
+ type RunRecord,
24
+ type RunStore,
25
+ } from './run-store';
26
+ import { isImmediateFrame } from './sse-stream';
27
+
28
+ export interface RunOutcome {
29
+ status: 'finished' | 'aborted' | 'interrupted' | 'failed';
30
+ /** The reply (the last assistant message, or the streamed text so far). */
31
+ text: string;
32
+ messageId?: string;
33
+ toolCalls?: Array<{ name: string; status: 'done' | 'error' }>;
34
+ error?: unknown;
35
+ }
36
+
37
+ export interface LiveRun {
38
+ readonly runId: string;
39
+ readonly sessionId: string;
40
+ readonly requestId: string;
41
+ record: RunRecord;
42
+ buffer: RunBuffer;
43
+ abort: AbortController;
44
+ /** Set on a resumed attempt: the reply text the user already received. */
45
+ continuation: string | null;
46
+ /** Resolves when the run reaches a terminal state (any outcome). */
47
+ readonly done: Promise<RunOutcome>;
48
+ resolve: (outcome: RunOutcome) => void;
49
+ /** An attempt is executing right now. */
50
+ attemptInFlight: boolean;
51
+ }
52
+
53
+ /** The slice of `RunStore` the coordinator drives (tests supply an in-memory one). */
54
+ export type RunCoordinatorStore = Pick<
55
+ RunStore,
56
+ | 'create'
57
+ | 'get'
58
+ | 'update'
59
+ | 'listActive'
60
+ | 'readSegments'
61
+ | 'appendSegment'
62
+ | 'deleteSegments'
63
+ >;
64
+
65
+ export interface RunCoordinatorHost {
66
+ store: RunCoordinatorStore;
67
+ config: RunDurabilityConfig;
68
+ instanceId: string;
69
+ log: Logger;
70
+ now?: () => number;
71
+ /** Arm the object's multiplexed alarm no later than `at` (ms epoch). */
72
+ requestAlarm: (at: number) => void;
73
+ /**
74
+ * Execute one attempt: build the agent, stream the turn's frames into
75
+ * `live.buffer`, and resolve with the outcome. Never throws for a turn
76
+ * that failed (that is a `failed` outcome); a throw is an internal fault.
77
+ */
78
+ runAttempt: (live: LiveRun, resumed: boolean) => Promise<RunOutcome>;
79
+ /** Latest checkpoint id of a session (progress detection across attempts). */
80
+ checkpointIdOf: (sessionId: string) => Promise<string | null>;
81
+ /** After a run reached a terminal state and its row was closed. */
82
+ onRunEnded?: (record: RunRecord, outcome: RunOutcome) => Promise<void>;
83
+ }
84
+
85
+ export interface BeginRunInput {
86
+ runId: string;
87
+ sessionId: string;
88
+ requestId: string;
89
+ client: 'portal' | 'matrix';
90
+ /** JSON the host needs to rebuild the attempt (see `StoredRunRequest`). */
91
+ request: string;
92
+ multitask: 'interrupt' | 'enqueue';
93
+ taskRunId?: string;
94
+ }
95
+
96
+ /** How long a superseded run gets to wind down before the next one starts. */
97
+ const SUPERSEDE_GRACE_MS = 5_000;
98
+
99
+ export interface JoinResult {
100
+ record: RunRecord;
101
+ /** Frames after the cursor, from the segments and the live tail. */
102
+ replay: RunFrame[];
103
+ /** The live buffer, when the run is still producing (or waiting to). */
104
+ buffer?: RunBuffer;
105
+ }
106
+
107
+ export class RunCoordinator {
108
+ private readonly live = new Map<string, LiveRun>();
109
+
110
+ private keepAliveArmedUntil = 0;
111
+
112
+ private readonly now: () => number;
113
+
114
+ constructor(private readonly host: RunCoordinatorHost) {
115
+ this.now = host.now ?? (() => Date.now());
116
+ }
117
+
118
+ // ── lookups ─────────────────────────────────────────────────────────────
119
+
120
+ get(runId: string): LiveRun | undefined {
121
+ return this.live.get(runId);
122
+ }
123
+
124
+ /** The run this session is executing or about to (running/recovering first). */
125
+ activeForSession(sessionId: string): LiveRun | undefined {
126
+ let candidate: LiveRun | undefined;
127
+ for (const run of this.live.values()) {
128
+ if (run.sessionId !== sessionId) continue;
129
+ if (run.record.status === 'running' || run.record.status === 'recovering')
130
+ return run;
131
+ if (run.record.status === 'queued' && !candidate) candidate = run;
132
+ }
133
+ return candidate;
134
+ }
135
+
136
+ byRequestId(requestId: string): LiveRun | undefined {
137
+ for (const run of this.live.values())
138
+ if (run.requestId === requestId) return run;
139
+ return undefined;
140
+ }
141
+
142
+ byTaskRunId(taskRunId: string): LiveRun | undefined {
143
+ for (const run of this.live.values())
144
+ if (run.record.taskRunId === taskRunId) return run;
145
+ return undefined;
146
+ }
147
+
148
+ /** Attempts executing right now. */
149
+ get activeCount(): number {
150
+ let n = 0;
151
+ for (const run of this.live.values()) if (run.attemptInFlight) n += 1;
152
+ return n;
153
+ }
154
+
155
+ get size(): number {
156
+ return this.live.size;
157
+ }
158
+
159
+ // ── keep-alive ──────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Hold the object loaded while attempts execute: the alarm is re-armed
163
+ * only when it is within a quarter of the horizon of expiring (one
164
+ * storage write per ~15 s of active turn, coalesced across runs).
165
+ */
166
+ touchKeepAlive(): void {
167
+ if (this.activeCount === 0) return;
168
+ const now = this.now();
169
+ const horizon = this.host.config.keepAliveMs;
170
+ if (this.keepAliveArmedUntil - now > horizon / 4) return;
171
+ this.keepAliveArmedUntil = now + horizon;
172
+ this.host.requestAlarm(this.keepAliveArmedUntil);
173
+ }
174
+
175
+ /** The alarm deadline the keep-alive needs, if anything is executing. */
176
+ keepAliveDeadline(now: number): number | null {
177
+ if (this.activeCount === 0) return null;
178
+ this.keepAliveArmedUntil = now + this.host.config.keepAliveMs;
179
+ return this.keepAliveArmedUntil;
180
+ }
181
+
182
+ // ── begin / queue / abort ───────────────────────────────────────────────
183
+
184
+ /**
185
+ * Record a run and start it, or queue it behind the session's active run
186
+ * under the `enqueue` rule. Under `interrupt` (the default) the active run
187
+ * of the session is aborted first — and any run queued behind it.
188
+ */
189
+ async begin(
190
+ input: BeginRunInput,
191
+ ): Promise<{ live: LiveRun; queued: boolean }> {
192
+ const active = this.activeForSession(input.sessionId);
193
+ let queued = false;
194
+ if (active) {
195
+ if (input.multitask === 'enqueue') {
196
+ queued = true;
197
+ } else {
198
+ this.host.log.log(
199
+ `[runs] ${input.sessionId}: new message supersedes ${active.runId} (${active.record.status})`,
200
+ );
201
+ for (const run of [...this.live.values()])
202
+ if (
203
+ run.sessionId === input.sessionId &&
204
+ run.record.status === 'queued'
205
+ )
206
+ await this.cancelQueued(run, 'superseded');
207
+ if (active.record.status !== 'queued') {
208
+ this.abortRun(active, 'superseded');
209
+ await Promise.race([
210
+ active.done,
211
+ new Promise((r) => setTimeout(r, SUPERSEDE_GRACE_MS)),
212
+ ]);
213
+ }
214
+ }
215
+ }
216
+ const checkpointId = await this.host.checkpointIdOf(input.sessionId);
217
+ await this.host.store.create({
218
+ runId: input.runId,
219
+ sessionId: input.sessionId,
220
+ requestId: input.requestId,
221
+ client: input.client,
222
+ status: queued ? 'queued' : 'running',
223
+ request: input.request,
224
+ checkpointId,
225
+ taskRunId: input.taskRunId ?? null,
226
+ instanceId: this.host.instanceId,
227
+ });
228
+ const record = (await this.host.store.get(input.runId))!;
229
+ const live = this.open(record);
230
+ if (!queued) void this.startAttempt(live, false);
231
+ return { live, queued };
232
+ }
233
+
234
+ /** Abort the session's active run (`POST /messages/abort`). */
235
+ abortSession(sessionId: string): boolean {
236
+ const active = this.activeForSession(sessionId);
237
+ if (!active) return false;
238
+ if (active.record.status === 'queued') {
239
+ void this.cancelQueued(active, 'aborted');
240
+ return true;
241
+ }
242
+ this.abortRun(active, 'aborted');
243
+ return true;
244
+ }
245
+
246
+ private abortRun(live: LiveRun, reason: 'aborted' | 'superseded'): void {
247
+ live.abort.abort(new Error(`run aborted (${reason})`));
248
+ if (!live.attemptInFlight) {
249
+ // Recovering and not yet re-attempted: nothing is running to notice
250
+ // the signal; close it here.
251
+ void this.finalize(live, {
252
+ status: 'aborted',
253
+ text: live.continuation ?? '',
254
+ });
255
+ }
256
+ }
257
+
258
+ private async cancelQueued(
259
+ live: LiveRun,
260
+ reason: 'aborted' | 'superseded',
261
+ ): Promise<void> {
262
+ this.host.log.log(`[runs] ${live.runId} ${reason} while queued`);
263
+ live.buffer.push('done', { runId: live.runId, aborted: true });
264
+ await this.finalize(live, { status: 'aborted', text: '' });
265
+ }
266
+
267
+ // ── join ────────────────────────────────────────────────────────────────
268
+
269
+ /**
270
+ * Everything a client needs to (re-)attach after `after`: the packed
271
+ * segments, the unflushed tail, and the live buffer. A run that already
272
+ * ended replays what is left (usually nothing: segments are dropped at
273
+ * cutover) with no buffer.
274
+ */
275
+ async join(runId: string, after = 0): Promise<JoinResult | undefined> {
276
+ const record =
277
+ this.live.get(runId)?.record ?? (await this.host.store.get(runId));
278
+ if (!record) return undefined;
279
+ const live = this.live.get(runId);
280
+ const segments = await this.host.store.readSegments(runId, after);
281
+ const replay = framesOfSegments(segments, after);
282
+ const lastReplayed =
283
+ replay.length > 0 ? replay[replay.length - 1]!.seq : after;
284
+ if (!live) return { record, replay };
285
+ // No await between reading the tail and subscribing (the caller
286
+ // subscribes synchronously on the returned buffer), so no frame slips
287
+ // between the two.
288
+ replay.push(...live.buffer.tailAfter(lastReplayed));
289
+ return { record, replay, buffer: live.buffer };
290
+ }
291
+
292
+ // ── attempts ────────────────────────────────────────────────────────────
293
+
294
+ private open(record: RunRecord, startSeq = 0): LiveRun {
295
+ let resolve!: (outcome: RunOutcome) => void;
296
+ const done = new Promise<RunOutcome>((r) => {
297
+ resolve = r;
298
+ });
299
+ const live: LiveRun = {
300
+ runId: record.runId,
301
+ sessionId: record.sessionId,
302
+ requestId: record.requestId,
303
+ record,
304
+ buffer: this.makeBuffer(record.runId, startSeq),
305
+ abort: new AbortController(),
306
+ continuation: null,
307
+ done,
308
+ resolve,
309
+ attemptInFlight: false,
310
+ };
311
+ this.live.set(record.runId, live);
312
+ return live;
313
+ }
314
+
315
+ private makeBuffer(runId: string, startSeq: number): RunBuffer {
316
+ return new RunBuffer({
317
+ flushMs: this.host.config.segmentFlushMs,
318
+ flushBytes: this.host.config.segmentBytes,
319
+ immediate: isImmediateFrame,
320
+ startSeq,
321
+ onPack: (segment) => this.host.store.appendSegment(runId, segment),
322
+ onPackError: (error) =>
323
+ this.host.log.warn(
324
+ `[runs] ${runId}: segment not persisted: ${error instanceof Error ? error.message : String(error)}`,
325
+ ),
326
+ });
327
+ }
328
+
329
+ private async startAttempt(live: LiveRun, resumed: boolean): Promise<void> {
330
+ live.attemptInFlight = true;
331
+ this.touchKeepAlive();
332
+ let outcome: RunOutcome;
333
+ try {
334
+ outcome = await this.host.runAttempt(live, resumed);
335
+ } catch (error) {
336
+ outcome = {
337
+ status: 'failed',
338
+ text: live.continuation ?? '',
339
+ error,
340
+ };
341
+ this.host.log.error(
342
+ `[runs] ${live.runId}: attempt crashed: ${error instanceof Error ? error.message : String(error)}`,
343
+ );
344
+ }
345
+ live.attemptInFlight = false;
346
+ await this.finalize(live, outcome);
347
+ }
348
+
349
+ private async finalize(live: LiveRun, outcome: RunOutcome): Promise<void> {
350
+ if (!this.live.has(live.runId)) return;
351
+ this.live.delete(live.runId);
352
+ const { runId } = live;
353
+ try {
354
+ await live.buffer.close();
355
+ const partialText =
356
+ outcome.status === 'finished'
357
+ ? null
358
+ : outcome.text ||
359
+ partialTextOf([
360
+ ...framesOfSegments(await this.host.store.readSegments(runId)),
361
+ ]);
362
+ await this.host.store.update(runId, {
363
+ status: outcome.status,
364
+ lastSeq: live.buffer.lastSeq,
365
+ messageId: outcome.messageId ?? null,
366
+ partialText,
367
+ error:
368
+ outcome.error === undefined
369
+ ? null
370
+ : outcome.error instanceof Error
371
+ ? outcome.error.message
372
+ : String(outcome.error),
373
+ nextAttemptAt: null,
374
+ });
375
+ // Cutover: the reply (or the partial text) is in the row/transcript.
376
+ await this.host.store.deleteSegments(runId);
377
+ live.record = (await this.host.store.get(runId)) ?? live.record;
378
+ this.host.log.log(
379
+ `[runs] ${runId} ${outcome.status} (${live.record.client}, session ${live.sessionId}, ${live.buffer.lastSeq} frames)`,
380
+ );
381
+ } catch (error) {
382
+ this.host.log.error(
383
+ `[runs] ${runId}: could not close the run row: ${error instanceof Error ? error.message : String(error)}`,
384
+ );
385
+ }
386
+ live.resolve(outcome);
387
+ try {
388
+ await this.host.onRunEnded?.(live.record, outcome);
389
+ } catch (error) {
390
+ this.host.log.error(
391
+ `[runs] ${runId}: onRunEnded failed: ${error instanceof Error ? error.message : String(error)}`,
392
+ );
393
+ }
394
+ await this.startNextQueued(live.sessionId);
395
+ }
396
+
397
+ private async startNextQueued(sessionId: string): Promise<void> {
398
+ // A running turn, or one waiting for its recovery attempt, keeps the
399
+ // queue waiting: the session's turns stay strictly ordered.
400
+ const active = this.activeForSession(sessionId);
401
+ if (active && active.record.status !== 'queued') return;
402
+ for (const run of this.live.values()) {
403
+ if (run.sessionId !== sessionId || run.record.status !== 'queued')
404
+ continue;
405
+ await this.host.store.update(run.runId, {
406
+ status: 'running',
407
+ instanceId: this.host.instanceId,
408
+ checkpointId: await this.host.checkpointIdOf(sessionId),
409
+ });
410
+ run.record = (await this.host.store.get(run.runId)) ?? run.record;
411
+ this.host.log.log(`[runs] ${run.runId}: dequeued for ${sessionId}`);
412
+ void this.startAttempt(run, false);
413
+ return;
414
+ }
415
+ }
416
+
417
+ // ── recovery ────────────────────────────────────────────────────────────
418
+
419
+ /**
420
+ * Runs a previous incarnation left `queued`/`running`/`recovering`. Every
421
+ * boot is a new instance, so anything active that this coordinator does
422
+ * not hold is an orphan: queued runs are re-opened (and started when
423
+ * their session is idle); running ones get a recovery attempt scheduled
424
+ * with the backoff, or are closed as interrupted after the cap.
425
+ */
426
+ async recoverOrphans(): Promise<void> {
427
+ const active = await this.host.store.listActive();
428
+ const now = this.now();
429
+ let earliest: number | null = null;
430
+ for (const record of active) {
431
+ if (this.live.has(record.runId)) continue;
432
+ if (record.status === 'queued') {
433
+ this.open(record, record.lastSeq);
434
+ continue;
435
+ }
436
+ const current = await this.host.checkpointIdOf(record.sessionId);
437
+ const decision = decideRecovery(record, current, now, this.host.config);
438
+ // Everything packed so far: the cursor continues after it (the row's
439
+ // `lastSeq` is only written when a run ends) and the continuation note
440
+ // shows the model what the user already read.
441
+ const segments = await this.host.store.readSegments(record.runId);
442
+ const frames = framesOfSegments(segments);
443
+ const lastPacked = Math.max(
444
+ record.lastSeq,
445
+ segments.length > 0 ? segments[segments.length - 1]!.seqTo : 0,
446
+ );
447
+ // The frames of the previous attempt that were still unpacked at the
448
+ // reset are gone, but a client may hold a cursor pointing past them:
449
+ // this attempt numbers from the next attempt base so that cursor
450
+ // stays below everything it emits.
451
+ const generation = record.generation + 1;
452
+ const startSeq = Math.max(lastPacked, attemptSeqBase(generation));
453
+ if (decision.action === 'interrupt') {
454
+ this.host.log.warn(
455
+ `[runs] ${record.runId}: ${decision.attempts} recovery attempt(s) without progress; closing as interrupted`,
456
+ );
457
+ await this.host.store.update(record.runId, { generation });
458
+ const live = this.open(record, startSeq);
459
+ live.continuation = partialTextOf(frames) || null;
460
+ live.buffer.push('error', {
461
+ error:
462
+ 'Something went wrong while answering and the reply could not be completed. Please try again.',
463
+ kind: 'interrupted',
464
+ retryable: true,
465
+ sessionId: record.sessionId,
466
+ requestId: record.requestId,
467
+ runId: record.runId,
468
+ timestamp: new Date(now).toISOString(),
469
+ });
470
+ live.buffer.push('done', { runId: record.runId, interrupted: true });
471
+ await this.finalize(live, {
472
+ status: 'interrupted',
473
+ text: live.continuation ?? '',
474
+ });
475
+ continue;
476
+ }
477
+ await this.host.store.update(record.runId, {
478
+ status: 'recovering',
479
+ attempts: decision.attempts,
480
+ generation,
481
+ nextAttemptAt: decision.at,
482
+ checkpointId: current,
483
+ instanceId: this.host.instanceId,
484
+ });
485
+ const refreshed = (await this.host.store.get(record.runId)) ?? record;
486
+ const live = this.open(refreshed, startSeq);
487
+ live.continuation = partialTextOf(frames) || null;
488
+ this.host.log.log(
489
+ `[runs] ${record.runId}: attempt ${decision.attempts} in ${Math.round((decision.at - now) / 1000)} s (${frames.length} frames restored)`,
490
+ );
491
+ earliest =
492
+ earliest === null ? decision.at : Math.min(earliest, decision.at);
493
+ }
494
+ // Queued runs of idle sessions.
495
+ for (const run of [...this.live.values()])
496
+ if (run.record.status === 'queued')
497
+ await this.startNextQueued(run.sessionId);
498
+ if (earliest !== null) this.host.requestAlarm(earliest);
499
+ }
500
+
501
+ /** Start every recovery attempt that is due (from the alarm). */
502
+ async resumeDue(now: number): Promise<void> {
503
+ for (const run of [...this.live.values()]) {
504
+ if (run.record.status !== 'recovering' || run.attemptInFlight) continue;
505
+ if (run.record.nextAttemptAt !== null && run.record.nextAttemptAt > now)
506
+ continue;
507
+ await this.host.store.update(run.runId, {
508
+ status: 'running',
509
+ nextAttemptAt: null,
510
+ });
511
+ run.record = (await this.host.store.get(run.runId)) ?? run.record;
512
+ // `partialLength` is the reply text this attempt continues from (all
513
+ // packed `message` frames). A client that received more than that
514
+ // before the reset truncates to it, so the continuation never
515
+ // duplicates text the runtime lost with the unpacked tail.
516
+ run.buffer.push('run', {
517
+ runId: run.runId,
518
+ sessionId: run.sessionId,
519
+ requestId: run.requestId,
520
+ resumed: true,
521
+ attempt: run.record.attempts,
522
+ partialLength: run.continuation?.length ?? 0,
523
+ });
524
+ this.host.log.log(
525
+ `[runs] ${run.runId}: resuming (attempt ${run.record.attempts})`,
526
+ );
527
+ void this.startAttempt(run, true);
528
+ }
529
+ }
530
+
531
+ /** Earliest pending recovery attempt, for the alarm multiplexer. */
532
+ nextRecoveryAt(): number | null {
533
+ let at: number | null = null;
534
+ for (const run of this.live.values()) {
535
+ if (
536
+ run.record.status !== 'recovering' ||
537
+ run.record.nextAttemptAt === null
538
+ )
539
+ continue;
540
+ at =
541
+ at === null
542
+ ? run.record.nextAttemptAt
543
+ : Math.min(at, run.record.nextAttemptAt);
544
+ }
545
+ return at;
546
+ }
547
+
548
+ /** Diagnostics. */
549
+ snapshot(): Array<{
550
+ runId: string;
551
+ sessionId: string;
552
+ status: string;
553
+ attemptInFlight: boolean;
554
+ generation: number;
555
+ lastSeq: number;
556
+ packedSeq: number;
557
+ subscribers: number;
558
+ nextAttemptAt: number | null;
559
+ }> {
560
+ return [...this.live.values()].map((run) => ({
561
+ runId: run.runId,
562
+ sessionId: run.sessionId,
563
+ status: run.record.status,
564
+ attemptInFlight: run.attemptInFlight,
565
+ generation: run.record.generation,
566
+ lastSeq: run.buffer.lastSeq,
567
+ packedSeq: run.buffer.packedSeq,
568
+ subscribers: run.buffer.subscriberCount,
569
+ nextAttemptAt: run.record.nextAttemptAt,
570
+ }));
571
+ }
572
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What a run row stores so a later incarnation of the user object can
3
+ * rebuild the attempt: the `TurnRequest` (minus attachments — they are in
4
+ * the checkpointed first message) and the stream options the frame producer
5
+ * needs. The browser tool catalogue and the AG-UI action schemas are NOT
6
+ * stored: they live in the graph state (`browserTools` / `agActions`), which
7
+ * a resumed attempt reads back from the checkpoint.
8
+ */
9
+ import type { RunSummary, TurnRequest } from './contracts';
10
+ import type { RunRecord } from './run-store';
11
+
12
+ export interface StoredRunRequest {
13
+ turn: Omit<TurnRequest, 'attachments'> & {
14
+ attachments?: TurnRequest['attachments'];
15
+ };
16
+ timezone?: string;
17
+ /** Names the client declared as AG-UI actions (rendered as `action_call`). */
18
+ agActionNames?: string[];
19
+ stream?: boolean;
20
+ }
21
+
22
+ export function storedRunRequest(
23
+ req: TurnRequest,
24
+ extra: Pick<StoredRunRequest, 'timezone' | 'agActionNames' | 'stream'> = {},
25
+ ): StoredRunRequest {
26
+ const { attachments, ...turn } = req;
27
+ return {
28
+ turn: { ...turn, ...(attachments?.length ? { attachments } : {}) },
29
+ ...(extra.timezone ? { timezone: extra.timezone } : {}),
30
+ ...(extra.agActionNames?.length
31
+ ? { agActionNames: extra.agActionNames }
32
+ : {}),
33
+ ...(extra.stream !== undefined ? { stream: extra.stream } : {}),
34
+ };
35
+ }
36
+
37
+ export function runSummaryOf(record: RunRecord): RunSummary {
38
+ return {
39
+ runId: record.runId,
40
+ sessionId: record.sessionId,
41
+ requestId: record.requestId,
42
+ client: record.client,
43
+ status: record.status,
44
+ startedAt: record.startedAt,
45
+ updatedAt: record.updatedAt,
46
+ lastSeq: record.lastSeq,
47
+ ...(record.messageId ? { messageId: record.messageId } : {}),
48
+ ...(record.partialText && record.status !== 'finished'
49
+ ? { partialText: record.partialText }
50
+ : {}),
51
+ };
52
+ }