@ixo/oracle-runtime-workers 0.13.0 → 0.15.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -9,6 +9,7 @@
9
9
  * run one attempt, the latest checkpoint of a session, and what to do when
10
10
  * a run ends (deliver a task result, close a Matrix card).
11
11
  */
12
+ import type { ReplyPlan } from '../delivery/types';
12
13
  import type { Logger } from '../plugin-api/types';
13
14
  import {
14
15
  framesOfSegments,
@@ -19,7 +20,10 @@ import {
19
20
  import {
20
21
  attemptSeqBase,
21
22
  decideRecovery,
23
+ patchRunRecord,
24
+ runHasCheckpointed,
22
25
  type RunDurabilityConfig,
26
+ type RunPatch,
23
27
  type RunRecord,
24
28
  type RunStore,
25
29
  } from './run-store';
@@ -29,11 +33,28 @@ export interface RunOutcome {
29
33
  status: 'finished' | 'aborted' | 'interrupted' | 'failed';
30
34
  /** The reply (the last assistant message, or the streamed text so far). */
31
35
  text: string;
36
+ /** A finished chat-surface run: the reply as its surface delivers it. */
37
+ plan?: ReplyPlan;
32
38
  messageId?: string;
33
39
  toolCalls?: Array<{ name: string; status: 'done' | 'error' }>;
34
40
  error?: unknown;
41
+ /** The run's usage snapshot (JSON), stored with the terminal status. */
42
+ usage?: string;
35
43
  }
36
44
 
45
+ /**
46
+ * Thrown by `runAttempt` before anything executed, when the attempt cannot
47
+ * start for a transient reason outside the run (an authority it must consult
48
+ * is unreachable). The coordinator schedules the same attempt again with the
49
+ * recovery backoff instead of failing the run; after the recovery cap the
50
+ * run fails with this error.
51
+ */
52
+ export class RunAttemptDeferred extends Error {
53
+ override readonly name = 'RunAttemptDeferred';
54
+ }
55
+
56
+ export type AttemptSource = 'begin' | 'dequeue' | 'recovery';
57
+
37
58
  export interface LiveRun {
38
59
  readonly runId: string;
39
60
  readonly sessionId: string;
@@ -48,6 +69,13 @@ export interface LiveRun {
48
69
  resolve: (outcome: RunOutcome) => void;
49
70
  /** An attempt is executing right now. */
50
71
  attemptInFlight: boolean;
72
+ /**
73
+ * What started the current attempt: `begin` (the message that was just
74
+ * admitted, started at once), `dequeue` (it waited behind another run of
75
+ * its session) or `recovery` (a retry after a reset or a deferral).
76
+ * Unset on a run built outside the coordinator.
77
+ */
78
+ attemptSource?: AttemptSource;
51
79
  }
52
80
 
53
81
  /** The slice of `RunStore` the coordinator drives (tests supply an in-memory one). */
@@ -59,7 +87,7 @@ export type RunCoordinatorStore = Pick<
59
87
  | 'listActive'
60
88
  | 'readSegments'
61
89
  | 'appendSegment'
62
- | 'deleteSegments'
90
+ | 'close'
63
91
  >;
64
92
 
65
93
  export interface RunCoordinatorHost {
@@ -80,13 +108,15 @@ export interface RunCoordinatorHost {
80
108
  checkpointIdOf: (sessionId: string) => Promise<string | null>;
81
109
  /** After a run reached a terminal state and its row was closed. */
82
110
  onRunEnded?: (record: RunRecord, outcome: RunOutcome) => Promise<void>;
111
+ /** How long a superseded or aborted attempt gets to wind down (default 5 s). */
112
+ supersedeGraceMs?: number;
83
113
  }
84
114
 
85
115
  export interface BeginRunInput {
86
116
  runId: string;
87
117
  sessionId: string;
88
118
  requestId: string;
89
- client: 'portal' | 'matrix';
119
+ client: 'portal' | 'matrix' | 'channel';
90
120
  /** JSON the host needs to rebuild the attempt (see `StoredRunRequest`). */
91
121
  request: string;
92
122
  multitask: 'interrupt' | 'enqueue';
@@ -96,6 +126,43 @@ export interface BeginRunInput {
96
126
  /** How long a superseded run gets to wind down before the next one starts. */
97
127
  const SUPERSEDE_GRACE_MS = 5_000;
98
128
 
129
+ /** Shown when a run ends failed without its attempt having said why. */
130
+ const RUN_FAILED_MESSAGE =
131
+ 'Something went wrong while answering. Please try again.';
132
+
133
+ /**
134
+ * A `begin` that has not opened its run yet. A later `interrupt` message of
135
+ * the same session (or `abortAllForSession`) supersedes it: it then records
136
+ * its run as aborted and never starts an attempt.
137
+ */
138
+ interface PendingBegin {
139
+ superseded: boolean;
140
+ /** Resolves when superseded, so a wait on the previous run is cut short. */
141
+ readonly woken: Promise<void>;
142
+ wake: () => void;
143
+ }
144
+
145
+ /**
146
+ * Wait for `promise`, at most `ms`, and never leave the timer behind (a
147
+ * pending timer keeps a Durable Object resident).
148
+ */
149
+ async function withinGrace(
150
+ ms: number,
151
+ ...promises: Array<Promise<unknown>>
152
+ ): Promise<void> {
153
+ let timer: ReturnType<typeof setTimeout> | undefined;
154
+ try {
155
+ await Promise.race([
156
+ ...promises,
157
+ new Promise<void>((resolve) => {
158
+ timer = setTimeout(resolve, ms);
159
+ }),
160
+ ]);
161
+ } finally {
162
+ if (timer !== undefined) clearTimeout(timer);
163
+ }
164
+ }
165
+
99
166
  export interface JoinResult {
100
167
  record: RunRecord;
101
168
  /** Frames after the cursor, from the segments and the live tail. */
@@ -107,12 +174,27 @@ export interface JoinResult {
107
174
  export class RunCoordinator {
108
175
  private readonly live = new Map<string, LiveRun>();
109
176
 
177
+ /** Per session: the tail of the chain `begin` calls run on, one at a time. */
178
+ private readonly sessionTurns = new Map<string, Promise<void>>();
179
+
180
+ /** Per session: the `begin` calls that have not opened their run yet. */
181
+ private readonly pendingBegins = new Map<string, Set<PendingBegin>>();
182
+
183
+ /**
184
+ * Runs queued only because the run their message superseded outlived the
185
+ * grace: the message is the session's current one, so Stop ends it too.
186
+ */
187
+ private readonly queuedBehindSuperseded = new WeakSet<LiveRun>();
188
+
110
189
  private keepAliveArmedUntil = 0;
111
190
 
112
191
  private readonly now: () => number;
113
192
 
193
+ private readonly graceMs: number;
194
+
114
195
  constructor(private readonly host: RunCoordinatorHost) {
115
196
  this.now = host.now ?? (() => Date.now());
197
+ this.graceMs = host.supersedeGraceMs ?? SUPERSEDE_GRACE_MS;
116
198
  }
117
199
 
118
200
  // ── lookups ─────────────────────────────────────────────────────────────
@@ -185,56 +267,154 @@ export class RunCoordinator {
185
267
  * Record a run and start it, or queue it behind the session's active run
186
268
  * under the `enqueue` rule. Under `interrupt` (the default) the active run
187
269
  * of the session is aborted first — and any run queued behind it.
270
+ *
271
+ * The `begin` calls of one session take turns, so each decides on the
272
+ * session as the previous one left it, and at most one attempt executes
273
+ * per session. A message superseded by a later `interrupt` message while
274
+ * it waited for its turn (or for the run it supersedes to wind down) is
275
+ * recorded and closed as aborted without an attempt; its `live` comes
276
+ * back with the buffer already closed on its `done` frame. A run that
277
+ * outlives the supersede grace keeps executing; the new message is then
278
+ * queued behind it instead of running beside it.
188
279
  */
189
280
  async begin(
190
281
  input: BeginRunInput,
191
282
  ): Promise<{ live: LiveRun; queued: boolean }> {
192
- const active = this.activeForSession(input.sessionId);
283
+ const { sessionId } = input;
284
+ if (input.multitask === 'interrupt') this.supersedePending(sessionId);
285
+ let wake!: () => void;
286
+ const woken = new Promise<void>((resolve) => {
287
+ wake = resolve;
288
+ });
289
+ const ticket: PendingBegin = { superseded: false, woken, wake };
290
+ let pending = this.pendingBegins.get(sessionId);
291
+ if (!pending) {
292
+ pending = new Set();
293
+ this.pendingBegins.set(sessionId, pending);
294
+ }
295
+ pending.add(ticket);
296
+ try {
297
+ return await this.inSessionTurn(sessionId, () =>
298
+ this.beginInTurn(input, ticket),
299
+ );
300
+ } finally {
301
+ pending.delete(ticket);
302
+ if (pending.size === 0 && this.pendingBegins.get(sessionId) === pending)
303
+ this.pendingBegins.delete(sessionId);
304
+ }
305
+ }
306
+
307
+ /** Run `fn` after every earlier `inSessionTurn` call of the session settled. */
308
+ private inSessionTurn<T>(
309
+ sessionId: string,
310
+ fn: () => Promise<T>,
311
+ ): Promise<T> {
312
+ const previous = this.sessionTurns.get(sessionId) ?? Promise.resolve();
313
+ const result = previous.then(fn);
314
+ const tail = result.then(
315
+ () => undefined,
316
+ () => undefined,
317
+ );
318
+ this.sessionTurns.set(sessionId, tail);
319
+ void tail.then(() => {
320
+ if (this.sessionTurns.get(sessionId) === tail)
321
+ this.sessionTurns.delete(sessionId);
322
+ });
323
+ return result;
324
+ }
325
+
326
+ /** Supersede the session's waiting `begin` calls; whether there were any. */
327
+ private supersedePending(sessionId: string): boolean {
328
+ let any = false;
329
+ for (const ticket of this.pendingBegins.get(sessionId) ?? []) {
330
+ ticket.superseded = true;
331
+ ticket.wake();
332
+ any = true;
333
+ }
334
+ return any;
335
+ }
336
+
337
+ private async beginInTurn(
338
+ input: BeginRunInput,
339
+ ticket: PendingBegin,
340
+ ): Promise<{ live: LiveRun; queued: boolean }> {
341
+ const { sessionId } = input;
193
342
  let queued = false;
343
+ let behindSuperseded = false;
344
+ const active = ticket.superseded
345
+ ? undefined
346
+ : this.activeForSession(sessionId);
194
347
  if (active) {
195
348
  if (input.multitask === 'enqueue') {
196
349
  queued = true;
197
350
  } else {
198
351
  this.host.log.log(
199
- `[runs] ${input.sessionId}: new message supersedes ${active.runId} (${active.record.status})`,
352
+ `[runs] ${sessionId}: new message supersedes ${active.runId} (${active.record.status})`,
200
353
  );
201
354
  for (const run of [...this.live.values()])
202
- if (
203
- run.sessionId === input.sessionId &&
204
- run.record.status === 'queued'
205
- )
355
+ if (run.sessionId === sessionId && run.record.status === 'queued')
206
356
  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
- ]);
357
+ const running = this.activeForSession(sessionId);
358
+ if (running) {
359
+ this.abortRun(running, 'superseded');
360
+ await withinGrace(this.graceMs, running.done, ticket.woken);
361
+ }
362
+ // Still winding down past the grace: wait behind it.
363
+ if (!ticket.superseded && this.activeForSession(sessionId)) {
364
+ queued = true;
365
+ behindSuperseded = true;
213
366
  }
214
367
  }
215
368
  }
216
- const checkpointId = await this.host.checkpointIdOf(input.sessionId);
217
- await this.host.store.create({
369
+ const checkpointId = await this.host.checkpointIdOf(sessionId);
370
+ const record = await this.host.store.create({
218
371
  runId: input.runId,
219
- sessionId: input.sessionId,
372
+ sessionId,
220
373
  requestId: input.requestId,
221
374
  client: input.client,
222
- status: queued ? 'queued' : 'running',
375
+ status: queued || ticket.superseded ? 'queued' : 'running',
223
376
  request: input.request,
224
377
  checkpointId,
225
378
  taskRunId: input.taskRunId ?? null,
226
379
  instanceId: this.host.instanceId,
227
380
  });
228
- const record = (await this.host.store.get(input.runId))!;
381
+ // No await from this check to the attempt's start: a supersede that
382
+ // lands later finds the run open and aborts it like any other.
383
+ if (ticket.superseded) {
384
+ const live = this.open({ ...record, status: 'queued' });
385
+ await this.cancelQueued(live, 'superseded');
386
+ return { live, queued: false };
387
+ }
229
388
  const live = this.open(record);
230
- if (!queued) void this.startAttempt(live, false);
231
- return { live, queued };
389
+ if (!queued) {
390
+ void this.startAttempt(live, false, 'begin');
391
+ return { live, queued };
392
+ }
393
+ if (behindSuperseded) this.queuedBehindSuperseded.add(live);
394
+ // The run ahead may have ended during the awaits above, before this
395
+ // one was open to be dequeued.
396
+ await this.startNextQueued(sessionId);
397
+ return { live, queued: live.record.status === 'queued' };
232
398
  }
233
399
 
234
- /** Abort the session's active run (`POST /messages/abort`). */
400
+ /**
401
+ * Stop the session's current message (`POST /messages/abort`): its active
402
+ * run, and a message still waiting in `begin` or queued behind the run it
403
+ * superseded. Runs enqueued behind the active one still start after it.
404
+ */
235
405
  abortSession(sessionId: string): boolean {
406
+ let stopped = this.supersedePending(sessionId);
407
+ for (const run of [...this.live.values()])
408
+ if (
409
+ run.sessionId === sessionId &&
410
+ run.record.status === 'queued' &&
411
+ this.queuedBehindSuperseded.has(run)
412
+ ) {
413
+ void this.cancelQueued(run, 'aborted');
414
+ stopped = true;
415
+ }
236
416
  const active = this.activeForSession(sessionId);
237
- if (!active) return false;
417
+ if (!active) return stopped;
238
418
  if (active.record.status === 'queued') {
239
419
  void this.cancelQueued(active, 'aborted');
240
420
  return true;
@@ -243,6 +423,31 @@ export class RunCoordinator {
243
423
  return true;
244
424
  }
245
425
 
426
+ /**
427
+ * End every run of the session: the executing attempt is aborted, queued
428
+ * runs and runs waiting for a recovery attempt are closed, and messages
429
+ * still waiting in `begin` are closed without an attempt — each ends
430
+ * `aborted`, with its `done` frame. Resolves once the executing attempt
431
+ * ended, or after the supersede grace. Safe when nothing runs.
432
+ */
433
+ async abortAllForSession(sessionId: string): Promise<void> {
434
+ this.supersedePending(sessionId);
435
+ const runs = [...this.live.values()].filter(
436
+ (run) => run.sessionId === sessionId,
437
+ );
438
+ if (runs.length === 0) return;
439
+ this.host.log.log(
440
+ `[runs] ${sessionId}: aborting all ${runs.length} run(s)`,
441
+ );
442
+ // Queued runs first, so an attempt that ends below dequeues none.
443
+ for (const run of runs)
444
+ if (run.record.status === 'queued')
445
+ void this.cancelQueued(run, 'aborted');
446
+ for (const run of runs)
447
+ if (run.record.status !== 'queued') this.abortRun(run, 'aborted');
448
+ await withinGrace(this.graceMs, Promise.all(runs.map((run) => run.done)));
449
+ }
450
+
246
451
  private abortRun(live: LiveRun, reason: 'aborted' | 'superseded'): void {
247
452
  live.abort.abort(new Error(`run aborted (${reason})`));
248
453
  if (!live.attemptInFlight) {
@@ -260,7 +465,6 @@ export class RunCoordinator {
260
465
  reason: 'aborted' | 'superseded',
261
466
  ): Promise<void> {
262
467
  this.host.log.log(`[runs] ${live.runId} ${reason} while queued`);
263
- live.buffer.push('done', { runId: live.runId, aborted: true });
264
468
  await this.finalize(live, { status: 'aborted', text: '' });
265
469
  }
266
470
 
@@ -276,17 +480,31 @@ export class RunCoordinator {
276
480
  const record =
277
481
  this.live.get(runId)?.record ?? (await this.host.store.get(runId));
278
482
  if (!record) return undefined;
279
- const live = this.live.get(runId);
483
+ const before = this.live.get(runId);
280
484
  const segments = await this.host.store.readSegments(runId, after);
281
485
  const replay = framesOfSegments(segments, after);
282
486
  const lastReplayed =
283
487
  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.
488
+ const live = this.live.get(runId);
489
+ if (!live) {
490
+ if (!before) return { record, replay };
491
+ // The run ended during the read: answer as for an ended run, from its
492
+ // closed row (the caller's trailer carries the final status and text),
493
+ // with the frames after the cursor that the read or the buffer still
494
+ // held, less the `done` the trailer replaces.
495
+ await before.done;
496
+ replay.push(...before.buffer.tailAfter(lastReplayed));
497
+ return {
498
+ record: before.record,
499
+ replay: replay.filter((frame) => frame.event !== 'done'),
500
+ };
501
+ }
502
+ // The tail includes the frames whose segment write is still pending
503
+ // (the read above may have been queued before it). No await between
504
+ // reading the tail and subscribing (the caller subscribes synchronously
505
+ // on the returned buffer), so no frame slips between the two.
288
506
  replay.push(...live.buffer.tailAfter(lastReplayed));
289
- return { record, replay, buffer: live.buffer };
507
+ return { record: live.record, replay, buffer: live.buffer };
290
508
  }
291
509
 
292
510
  // ── attempts ────────────────────────────────────────────────────────────
@@ -307,6 +525,7 @@ export class RunCoordinator {
307
525
  done,
308
526
  resolve,
309
527
  attemptInFlight: false,
528
+ attemptSource: 'begin',
310
529
  };
311
530
  this.live.set(record.runId, live);
312
531
  return live;
@@ -326,40 +545,129 @@ export class RunCoordinator {
326
545
  });
327
546
  }
328
547
 
329
- private async startAttempt(live: LiveRun, resumed: boolean): Promise<void> {
548
+ private async startAttempt(
549
+ live: LiveRun,
550
+ resumed: boolean,
551
+ source: AttemptSource,
552
+ ): Promise<void> {
330
553
  live.attemptInFlight = true;
554
+ live.attemptSource = source;
331
555
  this.touchKeepAlive();
332
556
  let outcome: RunOutcome;
333
557
  try {
334
558
  outcome = await this.host.runAttempt(live, resumed);
335
559
  } 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
- );
560
+ if (error instanceof RunAttemptDeferred) {
561
+ const deferred = await this.defer(live, error).catch(
562
+ (storeError: unknown) => {
563
+ this.host.log.error(
564
+ `[runs] ${live.runId}: could not reschedule the attempt: ${storeError instanceof Error ? storeError.message : String(storeError)}`,
565
+ );
566
+ return false;
567
+ },
568
+ );
569
+ if (deferred) return;
570
+ } else {
571
+ this.host.log.error(
572
+ `[runs] ${live.runId}: attempt crashed: ${error instanceof Error ? error.message : String(error)}`,
573
+ );
574
+ }
575
+ outcome = live.abort.signal.aborted
576
+ ? { status: 'aborted', text: live.continuation ?? '' }
577
+ : { status: 'failed', text: live.continuation ?? '', error };
344
578
  }
345
579
  live.attemptInFlight = false;
346
580
  await this.finalize(live, outcome);
347
581
  }
348
582
 
583
+ /**
584
+ * Put a deferred attempt back on the recovery schedule. The attempt did
585
+ * not touch the checkpoint, so every deferral counts against the recovery
586
+ * cap; `false` when the cap is spent (or the run was aborted meanwhile)
587
+ * and the caller closes the run instead. The attempt stays in flight
588
+ * until the run is rescheduled, so an abort meanwhile only signals.
589
+ */
590
+ private async defer(
591
+ live: LiveRun,
592
+ error: RunAttemptDeferred,
593
+ ): Promise<boolean> {
594
+ if (live.abort.signal.aborted) return false;
595
+ const decision = decideRecovery(
596
+ live.record,
597
+ live.record.checkpointId,
598
+ this.now(),
599
+ this.host.config,
600
+ );
601
+ if (decision.action === 'interrupt') {
602
+ this.host.log.warn(
603
+ `[runs] ${live.runId}: deferred ${decision.attempts} time(s) (${error.message}); failing the run`,
604
+ );
605
+ return false;
606
+ }
607
+ await this.write(live, {
608
+ status: 'recovering',
609
+ attempts: decision.attempts,
610
+ nextAttemptAt: decision.at,
611
+ });
612
+ if (live.abort.signal.aborted) return false;
613
+ live.attemptInFlight = false;
614
+ this.host.log.warn(
615
+ `[runs] ${live.runId}: attempt deferred (${error.message}); retry ${decision.attempts} in ${Math.round((decision.at - this.now()) / 1000)} s`,
616
+ );
617
+ this.host.requestAlarm(decision.at);
618
+ return true;
619
+ }
620
+
621
+ /**
622
+ * The frames a run's subscribers need to finish, when its attempt did not
623
+ * push them (it crashed, was never started, or was closed while waiting):
624
+ * an `error` frame for a failure, then `done`.
625
+ */
626
+ private pushClosingFrames(live: LiveRun, outcome: RunOutcome): void {
627
+ if (live.buffer.isClosed || live.buffer.hasDone) return;
628
+ if (outcome.status === 'failed')
629
+ live.buffer.push('error', {
630
+ error: RUN_FAILED_MESSAGE,
631
+ kind: 'unknown',
632
+ source: 'platform',
633
+ retryable: true,
634
+ sessionId: live.sessionId,
635
+ requestId: live.requestId,
636
+ runId: live.runId,
637
+ timestamp: new Date(this.now()).toISOString(),
638
+ });
639
+ live.buffer.push('done', {
640
+ runId: live.runId,
641
+ ...(outcome.messageId ? { messageId: outcome.messageId } : {}),
642
+ ...(outcome.status === 'finished' ? {} : { [outcome.status]: true }),
643
+ });
644
+ }
645
+
646
+ /** Write `patch` to the run's row and keep `live.record` in step with it. */
647
+ private async write(live: LiveRun, patch: RunPatch): Promise<void> {
648
+ const updatedAt = await this.host.store.update(live.runId, patch);
649
+ live.record = patchRunRecord(live.record, patch, updatedAt);
650
+ }
651
+
349
652
  private async finalize(live: LiveRun, outcome: RunOutcome): Promise<void> {
350
- if (!this.live.has(live.runId)) return;
653
+ if (this.live.get(live.runId) !== live) return;
351
654
  this.live.delete(live.runId);
352
655
  const { runId } = live;
353
656
  try {
657
+ this.pushClosingFrames(live, outcome);
354
658
  await live.buffer.close();
355
659
  const partialText =
356
660
  outcome.status === 'finished'
357
- ? null
661
+ ? live.record.client === 'channel'
662
+ ? outcome.text
663
+ : null
358
664
  : outcome.text ||
359
665
  partialTextOf([
360
666
  ...framesOfSegments(await this.host.store.readSegments(runId)),
361
667
  ]);
362
- await this.host.store.update(runId, {
668
+ // One transaction: the terminal row, and the cutover (the reply, or
669
+ // the partial text, is in the row and the transcript now).
670
+ const patch: RunPatch = {
363
671
  status: outcome.status,
364
672
  lastSeq: live.buffer.lastSeq,
365
673
  messageId: outcome.messageId ?? null,
@@ -371,10 +679,10 @@ export class RunCoordinator {
371
679
  ? outcome.error.message
372
680
  : String(outcome.error),
373
681
  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;
682
+ ...(outcome.usage !== undefined ? { usage: outcome.usage } : {}),
683
+ };
684
+ const updatedAt = await this.host.store.close(runId, patch);
685
+ live.record = patchRunRecord(live.record, patch, updatedAt);
378
686
  this.host.log.log(
379
687
  `[runs] ${runId} ${outcome.status} (${live.record.client}, session ${live.sessionId}, ${live.buffer.lastSeq} frames)`,
380
688
  );
@@ -399,19 +707,46 @@ export class RunCoordinator {
399
707
  // queue waiting: the session's turns stay strictly ordered.
400
708
  const active = this.activeForSession(sessionId);
401
709
  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, {
710
+ let run: LiveRun | undefined;
711
+ for (const candidate of this.live.values())
712
+ if (
713
+ candidate.sessionId === sessionId &&
714
+ candidate.record.status === 'queued'
715
+ ) {
716
+ run = candidate;
717
+ break;
718
+ }
719
+ if (!run) return;
720
+ // Taken before the awaits below: the session is busy from here on, and
721
+ // an abort meanwhile only signals (the run is closed here).
722
+ run.attemptInFlight = true;
723
+ run.record = { ...run.record, status: 'running' };
724
+ try {
725
+ // The run's input is written from this checkpoint on: its start.
726
+ const checkpointId = await this.host.checkpointIdOf(sessionId);
727
+ await this.write(run, {
406
728
  status: 'running',
407
729
  instanceId: this.host.instanceId,
408
- checkpointId: await this.host.checkpointIdOf(sessionId),
730
+ checkpointId,
731
+ startCheckpointId: checkpointId,
409
732
  });
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);
733
+ } catch (error) {
734
+ run.attemptInFlight = false;
735
+ await this.finalize(
736
+ run,
737
+ run.abort.signal.aborted
738
+ ? { status: 'aborted', text: '' }
739
+ : { status: 'failed', text: '', error },
740
+ );
741
+ return;
742
+ }
743
+ if (run.abort.signal.aborted) {
744
+ run.attemptInFlight = false;
745
+ await this.finalize(run, { status: 'aborted', text: '' });
413
746
  return;
414
747
  }
748
+ this.host.log.log(`[runs] ${run.runId}: dequeued for ${sessionId}`);
749
+ void this.startAttempt(run, false, 'dequeue');
415
750
  }
416
751
 
417
752
  // ── recovery ────────────────────────────────────────────────────────────
@@ -454,8 +789,13 @@ export class RunCoordinator {
454
789
  this.host.log.warn(
455
790
  `[runs] ${record.runId}: ${decision.attempts} recovery attempt(s) without progress; closing as interrupted`,
456
791
  );
457
- await this.host.store.update(record.runId, { generation });
458
- const live = this.open(record, startSeq);
792
+ const updatedAt = await this.host.store.update(record.runId, {
793
+ generation,
794
+ });
795
+ const live = this.open(
796
+ patchRunRecord(record, { generation }, updatedAt),
797
+ startSeq,
798
+ );
459
799
  live.continuation = partialTextOf(frames) || null;
460
800
  live.buffer.push('error', {
461
801
  error:
@@ -474,17 +814,24 @@ export class RunCoordinator {
474
814
  });
475
815
  continue;
476
816
  }
477
- await this.host.store.update(record.runId, {
817
+ const patch: RunPatch = {
478
818
  status: 'recovering',
479
819
  attempts: decision.attempts,
480
820
  generation,
481
821
  nextAttemptAt: decision.at,
482
822
  checkpointId: current,
483
823
  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;
824
+ };
825
+ const updatedAt = await this.host.store.update(record.runId, patch);
826
+ const live = this.open(
827
+ patchRunRecord(record, patch, updatedAt),
828
+ startSeq,
829
+ );
830
+ // A run the graph never checkpointed is retried fresh (resumeDue):
831
+ // there is no reply to continue from.
832
+ live.continuation = runHasCheckpointed(record, current)
833
+ ? partialTextOf(frames) || null
834
+ : null;
488
835
  this.host.log.log(
489
836
  `[runs] ${record.runId}: attempt ${decision.attempts} in ${Math.round((decision.at - now) / 1000)} s (${frames.length} frames restored)`,
490
837
  );
@@ -504,27 +851,55 @@ export class RunCoordinator {
504
851
  if (run.record.status !== 'recovering' || run.attemptInFlight) continue;
505
852
  if (run.record.nextAttemptAt !== null && run.record.nextAttemptAt > now)
506
853
  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;
854
+ // Taken before the awaits below: an abort meanwhile only signals, and
855
+ // the run is closed here instead of being attempted.
856
+ run.attemptInFlight = true;
857
+ let resumed: boolean;
858
+ try {
859
+ await this.write(run, { status: 'running', nextAttemptAt: null });
860
+ // Resume only when the graph persisted something for this run;
861
+ // otherwise it never saw the input and the attempt runs fresh with
862
+ // it. Derived from the stored start checkpoint, so it holds across a
863
+ // restart (recoverOrphans) as well as for a deferral in this instance.
864
+ resumed = runHasCheckpointed(
865
+ run.record,
866
+ await this.host.checkpointIdOf(run.sessionId),
867
+ );
868
+ } catch (error) {
869
+ run.attemptInFlight = false;
870
+ if (!run.abort.signal.aborted) throw error;
871
+ await this.finalize(run, {
872
+ status: 'aborted',
873
+ text: run.continuation ?? '',
874
+ });
875
+ continue;
876
+ }
877
+ if (run.abort.signal.aborted) {
878
+ run.attemptInFlight = false;
879
+ await this.finalize(run, {
880
+ status: 'aborted',
881
+ text: run.continuation ?? '',
882
+ });
883
+ continue;
884
+ }
885
+ if (!resumed) run.continuation = null;
512
886
  // `partialLength` is the reply text this attempt continues from (all
513
887
  // packed `message` frames). A client that received more than that
514
888
  // before the reset truncates to it, so the continuation never
515
889
  // 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
- });
890
+ if (resumed)
891
+ run.buffer.push('run', {
892
+ runId: run.runId,
893
+ sessionId: run.sessionId,
894
+ requestId: run.requestId,
895
+ resumed: true,
896
+ attempt: run.record.attempts,
897
+ partialLength: run.continuation?.length ?? 0,
898
+ });
524
899
  this.host.log.log(
525
- `[runs] ${run.runId}: resuming (attempt ${run.record.attempts})`,
900
+ `[runs] ${run.runId}: ${resumed ? 'resuming' : 'retrying'} (attempt ${run.record.attempts})`,
526
901
  );
527
- void this.startAttempt(run, true);
902
+ void this.startAttempt(run, resumed, 'recovery');
528
903
  }
529
904
  }
530
905