@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -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 +2003 -274
  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 +562 -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
@@ -1,3 +1,14 @@
1
+ import { ChannelTurns } from '../channels/turns';
2
+ import { assertChannelAttemptAllowed } from '../channels/auth';
3
+ import {
4
+ ChannelError,
5
+ channelRequestHash,
6
+ channelOrigin,
7
+ delegationRequiredError,
8
+ requireChannelDelegation,
9
+ type ChannelTurnInput,
10
+ type ChannelTurnOutcome,
11
+ } from '../channels/contract';
1
12
  /* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
2
13
  /**
3
14
  * `UserOracleDO` — one Durable Object per (user DID, oracle).
@@ -16,6 +27,12 @@
16
27
  * A Durable Object is single-threaded, which replaces the Node runtime's
17
28
  * per-user ref-counting, busy timeouts and cron locks outright.
18
29
  */
30
+ import {
31
+ TopicOperationId,
32
+ TopicDeliverableRequestSchema,
33
+ type TopicDeliverableCommand,
34
+ type TopicDeliverableResult,
35
+ } from '../tasks/topic-deliverables';
19
36
  import { DurableObject } from 'cloudflare:workers';
20
37
  import {
21
38
  AIMessage,
@@ -38,8 +55,10 @@ import {
38
55
  type AttachmentViewSurface,
39
56
  type MatrixMediaSource,
40
57
  type SandboxUploadConfig,
58
+ DOWNLOAD_TIMEOUT_MS,
41
59
  MAX_FILE_SIZE,
42
60
  readBytesCapped,
61
+ AttachmentTextCacheStore,
43
62
  } from '../attachments';
44
63
  import type { RuntimeCore } from '../core';
45
64
  import {
@@ -50,15 +69,35 @@ import {
50
69
  langsmithEnvFromWorkerEnv,
51
70
  llmEnvFromWorkerEnv,
52
71
  resolveLangsmithTracing,
72
+ type LangsmithTracingDecision,
53
73
  type OpenRouterLlmAdapter,
54
74
  DEFAULT_MODEL_ID,
75
+ OPENROUTER_MODEL_MAP,
55
76
  } from '../core/llm';
77
+ import { renderTurnTimeNote } from '../core/prompt-composer';
78
+ import { TURN_TIME_NOTE_KWARG, withTurnTimeNote } from '../core/turn-time-note';
79
+ import {
80
+ capabilityRouterMode,
81
+ createCapabilityRouter,
82
+ shadowAgreement,
83
+ type CapabilityRouter,
84
+ } from '../core/capability-router';
56
85
  import { createMainAgent } from '../core/main-agent';
86
+ import {
87
+ REQUEST_ADMISSION_TIMEOUT_MS_DEFAULT,
88
+ admitRequest,
89
+ admissionMetadata,
90
+ } from '../core/request-admission';
91
+ import type {
92
+ RequestAdmissionResult,
93
+ RequestDisposition,
94
+ } from '../plugin-api/request-admission';
95
+ import { bootHiddenPlugins } from '../core/tool-access';
57
96
  import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
58
97
  import type { AmbientServices, LlmAdapter } from '../core/runtime-context';
59
98
  import { chatGptBackendFromEnv } from '../llm/byo-client';
60
99
  import { createByoLlmAdapter } from '../llm/byo-adapter';
61
- import { isByoModelId } from '../llm/byo-catalog';
100
+ import { byoModelForRole, isByoModelId } from '../llm/byo-catalog';
62
101
  import { handleByoRequest } from '../llm/byo-routes';
63
102
  import {
64
103
  WorkersByoService,
@@ -79,7 +118,7 @@ import {
79
118
  RealtimeEndpoint,
80
119
  type RealtimeStatus,
81
120
  } from '../realtime/realtime-endpoint';
82
- import { authenticate } from '../shell/auth';
121
+ import { authConfigFromEnv, authenticate } from '../shell/auth';
83
122
  import {
84
123
  compactStep,
85
124
  finishCompaction,
@@ -135,7 +174,11 @@ import {
135
174
  VFS_DEFAULT_BASE_URLS,
136
175
  } from '../owner-store/ixo-vfs-store';
137
176
  import type { OwnerStore } from '../owner-store/types';
138
- import { createAmbientServices, SessionEventRouter } from './ambient';
177
+ import {
178
+ createAmbientServices,
179
+ SessionEventRouter,
180
+ sweepExpiredBlobs,
181
+ } from './ambient';
139
182
  import {
140
183
  checkpointStorageKey,
141
184
  type MatrixGatewayObject,
@@ -157,7 +200,7 @@ import { turnRecursionLimit } from './turn-config';
157
200
  import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
158
201
  import { decideBootDirty } from './boot-dirty';
159
202
  import { decideFlush } from './flush-schedule';
160
- import { mirrorTxnId, RoomMirror } from './room-mirror';
203
+ import { mirrorTxnId, mirrorEventContent, RoomMirror } from './room-mirror';
161
204
  import { prefixSpeaker } from './speaker-prefix';
162
205
  import {
163
206
  type ObservedMessage,
@@ -176,6 +219,7 @@ import { WorkStatusProducer } from '../matrix/work-status';
176
219
  import { fetchUserMatrixServerName } from '../matrix/user-homeserver';
177
220
  import { createWorkStatusMiddleware } from '../core/middlewares/work-status';
178
221
  import { UserPreferencesStore } from '../plugins/user-preferences/user-preferences-store';
222
+ import { SqliteUserKv } from '../sqlite/user-kv-store';
179
223
  import {
180
224
  SessionHistoryIndexer,
181
225
  type HistoryMessage,
@@ -210,6 +254,15 @@ import {
210
254
  type ToolEffect,
211
255
  } from '../core/middlewares/tool-marks';
212
256
  import { createResultCapMiddleware } from '../core/middlewares/result-cap';
257
+ import { createToolExecutionMiddleware } from '../core/middlewares/tool-execution';
258
+ import { budgetedLlm } from '../core/budgeted-llm';
259
+ import { ToolScheduler, type ToolLane } from '../core/tool-scheduler';
260
+ import {
261
+ HarnessLimitError,
262
+ TurnBudget,
263
+ turnLimitsFromEnv,
264
+ type TurnUsage,
265
+ } from '../core/turn-budget';
213
266
  import { READ_RESULT_TOOL_NAME } from '../core/read-result-tool';
214
267
  import {
215
268
  ContextWindowResolver,
@@ -222,6 +275,21 @@ import {
222
275
  } from '../core/middlewares/context-guard';
223
276
  import { fetchOpenRouterContextLengths } from '../core/openrouter-pricing';
224
277
  import { ResultStore, resultStoreKnobs } from './result-store';
278
+ import { artifactStorageConfig } from '../artifacts/config';
279
+ import {
280
+ ArtifactStore,
281
+ artifactIdFor,
282
+ type OwnedArtifact,
283
+ } from '../artifacts/store';
284
+ import { buildCreateArtifactTool } from '../artifacts/tool';
285
+ import { resolveDeliveryProfile } from '../delivery/profile';
286
+ import {
287
+ draftReplyPlan,
288
+ materializeReplyPlan,
289
+ turnSteps,
290
+ } from '../delivery/plan';
291
+ import { parseReplyPlan } from '../delivery/schema';
292
+ import type { DeliveryProfile, ReplyPlan } from '../delivery/types';
225
293
  import { formatReplay } from '../matrix/replay-format';
226
294
  import { retryGateway } from './gateway-retry';
227
295
  import {
@@ -235,8 +303,59 @@ import {
235
303
  TranscriptCursorError,
236
304
  transformTranscript,
237
305
  type TranscriptPageOptions,
306
+ visibleText,
238
307
  } from './transcript';
239
- import { WorkersUcanService } from './ucan-service';
308
+ import { evictIdleWorkingCopy } from './idle-eviction';
309
+ import { FeedbackMarkers, reserveFeedback } from '../feedback/reservation';
310
+ import type {
311
+ FeedbackReservation,
312
+ FeedbackSettlement,
313
+ FeedbackTarget,
314
+ } from '../feedback/contract';
315
+ import {
316
+ readDelegation,
317
+ resolveTurnDelegation,
318
+ WorkersUcanService,
319
+ } from './ucan-service';
320
+ import type { UcanDelegation } from '../plugin-api/types';
321
+
322
+ /** What one attempt's agent build reads from the request body. */
323
+ type TurnBuildBody = Pick<
324
+ TurnBody,
325
+ 'message' | 'timezone' | 'model' | 'tools' | 'agActions' | 'attachments'
326
+ >;
327
+
328
+ /** The run an attempt's agent build belongs to. */
329
+ interface TurnBuildRun {
330
+ runId: string;
331
+ abortController: AbortController;
332
+ /** A recovery attempt: no new input, the graph continues from the checkpoint. */
333
+ resumed: boolean;
334
+ /** The reply text the user already received (resumed attempts). */
335
+ continuation: string | null;
336
+ }
337
+
338
+ /**
339
+ * Whether a turn is offered to the plugins' admission handlers. Not for a
340
+ * turn with attachments, a scheduled task run, or a Matrix group room: a
341
+ * direct read is posted into the room, so one user's authorized read would
342
+ * be shown to every member.
343
+ */
344
+ function admissionApplies(req: TurnRequest): boolean {
345
+ return (
346
+ !req.attachments?.length &&
347
+ !req.taskRunId &&
348
+ !req.sessionId.startsWith(TASK_SESSION_PREFIX) &&
349
+ req.roomKind !== 'group'
350
+ );
351
+ }
352
+
353
+ function admissionTimeoutMs(env: Record<string, unknown>): number {
354
+ const ms = env.REQUEST_ADMISSION_TIMEOUT_MS;
355
+ return typeof ms === 'number' && Number.isFinite(ms) && ms > 0
356
+ ? ms
357
+ : REQUEST_ADMISSION_TIMEOUT_MS_DEFAULT;
358
+ }
240
359
 
241
360
  const DB_FILE = 'oracle.db';
242
361
  /**
@@ -261,8 +380,31 @@ const FLUSH_FAILURES_ERROR_THRESHOLD = 3;
261
380
  * more for anyone who comes back within that window.
262
381
  */
263
382
  const IDLE_EVICT_MS = 5 * 24 * 60 * 60 * 1000;
383
+ /**
384
+ * A stream of requests to a loaded object persists its last access at most
385
+ * this often. The idle checks read the in-memory value as well, so what an
386
+ * instance saw is never lost to the throttle while it lives; an instance
387
+ * that unloads takes at most this much of its last access with it, against
388
+ * the five-day idle horizon.
389
+ */
390
+ const ACCESS_PERSIST_INTERVAL_MS = 60 * 60_000;
391
+ /**
392
+ * The session title is a separate model call after the reply; it runs in
393
+ * the background and is abandoned after this long.
394
+ */
395
+ const TITLE_TIMEOUT_MS = 15_000;
396
+ /** A new sweep of expired ambient blobs starts at most this often per instance. */
397
+ const BLOB_SWEEP_INTERVAL_MS = 60 * 60_000;
398
+ /** The Blocksync lookup of the user's homeserver is abandoned after this long (the fallback applies). */
399
+ const HOMESERVER_LOOKUP_TIMEOUT_MS = 3_000;
264
400
  /** Set once the legacy Matrix copy has been removed (or found absent): no more lookups. */
265
401
  const META_LEGACY_CLEARED = 'meta:legacyCleared';
402
+ /**
403
+ * Set once a zero-turn working copy looked for a legacy Matrix copy to adopt
404
+ * and found none, or one without turns: later boots skip the gateway round
405
+ * trips. Cleared when a legacy copy is imported after all.
406
+ */
407
+ const META_LEGACY_UNUSABLE = 'meta:legacyUnusable';
266
408
  const META_USER_DID = 'meta:userDid';
267
409
 
268
410
  /** KV key of a session's context-guard counters (`GET /debug/context?session=`). */
@@ -403,6 +545,17 @@ interface StoredDelegation {
403
545
  at: number;
404
546
  }
405
547
 
548
+ /** The user's delegation as the object holds it in memory. */
549
+ interface CachedDelegation {
550
+ raw: string;
551
+ /**
552
+ * Unix seconds: the delegation's effective expiry. Taken from where the
553
+ * delegation arrived (the shell's validation, the deposit record) or read
554
+ * off the token itself; absent only when the token cannot be parsed.
555
+ */
556
+ expiration?: number;
557
+ }
558
+
406
559
  interface FlushResult {
407
560
  uploaded: boolean;
408
561
  /** Raw file size (skips) or bytes sent upstream (uploads). */
@@ -466,6 +619,62 @@ function lastAiText(messages: BaseMessage[]): string {
466
619
  }
467
620
 
468
621
  /** Id of the final assistant message (what `POST /messages` reports as `message.id`). */
622
+ /** `turn_runs.usage` for `/debug/runs`; a row written before the column existed, or an unreadable one, is null. */
623
+ function parseUsage(raw: string | null): TurnUsage | null {
624
+ if (!raw) return null;
625
+ try {
626
+ const parsed: unknown = JSON.parse(raw);
627
+ if (!parsed || typeof parsed !== 'object') return null;
628
+ const num = (key: string): number | undefined => {
629
+ const value: unknown = Reflect.get(parsed, key);
630
+ return typeof value === 'number' ? value : undefined;
631
+ };
632
+ const limits: unknown = Reflect.get(parsed, 'limits');
633
+ const limit = (key: string): number | undefined => {
634
+ if (!limits || typeof limits !== 'object') return undefined;
635
+ const value: unknown = Reflect.get(limits, key);
636
+ return typeof value === 'number' ? value : undefined;
637
+ };
638
+ const usage = {
639
+ tokens: num('tokens'),
640
+ reportedTokens: num('reportedTokens'),
641
+ modelCalls: num('modelCalls'),
642
+ toolAttempts: num('toolAttempts'),
643
+ elapsedMs: num('elapsedMs'),
644
+ limits: {
645
+ tokens: limit('tokens'),
646
+ tools: limit('tools'),
647
+ durationMs: limit('durationMs'),
648
+ },
649
+ };
650
+ if (
651
+ usage.tokens === undefined ||
652
+ usage.reportedTokens === undefined ||
653
+ usage.modelCalls === undefined ||
654
+ usage.toolAttempts === undefined ||
655
+ usage.elapsedMs === undefined ||
656
+ usage.limits.tokens === undefined ||
657
+ usage.limits.tools === undefined ||
658
+ usage.limits.durationMs === undefined
659
+ )
660
+ return null;
661
+ return {
662
+ tokens: usage.tokens,
663
+ reportedTokens: usage.reportedTokens,
664
+ modelCalls: usage.modelCalls,
665
+ toolAttempts: usage.toolAttempts,
666
+ elapsedMs: usage.elapsedMs,
667
+ limits: {
668
+ tokens: usage.limits.tokens,
669
+ tools: usage.limits.tools,
670
+ durationMs: usage.limits.durationMs,
671
+ },
672
+ };
673
+ } catch {
674
+ return null;
675
+ }
676
+ }
677
+
469
678
  function lastAiMessageId(messages: BaseMessage[]): string | undefined {
470
679
  for (let i = messages.length - 1; i >= 0; i--) {
471
680
  const m = messages[i];
@@ -510,14 +719,38 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
510
719
  if (this.ctx.getWebSockets().length > 0) this.realtime.nextPingAt();
511
720
  }
512
721
  private readonly aborts = new Map<string, AbortController>();
722
+ /** Capability router (core/capability-router.ts); built with the ambient services. */
723
+ private capabilityRouter: CapabilityRouter | null = null;
724
+ /**
725
+ * Shadow-mode predictions awaiting the turn's end, by session: what the
726
+ * router would have preloaded, against the plugins loaded before the turn.
727
+ */
728
+ private readonly shadowRoutes = new Map<
729
+ string,
730
+ {
731
+ requestId: string;
732
+ priorLoaded: ReadonlySet<string>;
733
+ wouldPreload: readonly string[];
734
+ }
735
+ >();
513
736
  /** Durable runs (docs/plans/durable-runs.md): the rows, and the live side. */
514
737
  private runStore: RunStore | null = null;
738
+ /**
739
+ * Tool concurrency of this user object (tool-scheduler.ts): writes one
740
+ * at a time across every session and turn, reads and sub-agents in
741
+ * bounded lanes. Lives as long as the object.
742
+ */
743
+ private readonly toolScheduler = new ToolScheduler();
515
744
  private runs: RunCoordinator | null = null;
516
745
  private readonly runConfig: RunDurabilityConfig = runDurabilityConfig(
517
746
  this.env,
518
747
  );
519
748
  /** Whole tool results the result cap saved (docs/plans/context-budgets.md). */
520
749
  private resultStore: ResultStore | null = null;
750
+ /** Chat delivery's artefacts; null when no bucket is bound (see `canShare` for new ones). */
751
+ private artifacts: ArtifactStore | null = null;
752
+ /** Text `view_attachment` extracted, per session (`attachments/view-cache.ts`). */
753
+ private attachmentTextCache: AttachmentTextCacheStore | null = null;
521
754
  /**
522
755
  * Per-model context windows; learned limits persist in this object's KV
523
756
  * storage (`ctxwin:<model>`), so a provider's rejection is remembered
@@ -529,6 +762,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
529
762
  fetchOpenRouterContextLengths({
530
763
  apiKey: this.env.OPEN_ROUTER_API_KEY,
531
764
  logger: console,
765
+ // A background refresh of the catalogue outlives the request.
766
+ waitUntil: (work) => this.ctx.waitUntil(work),
532
767
  }),
533
768
  learned: {
534
769
  get: (model) => this.ctx.storage.get<number>(`ctxwin:${model}`),
@@ -542,10 +777,32 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
542
777
  * events of one request (a soft then a hard prune) never race.
543
778
  */
544
779
  private contextStatsWrites: Promise<void> = Promise.resolve();
545
- private readonly delegations = new Map<string, { raw: string }>();
780
+ private readonly delegations = new Map<string, CachedDelegation>();
546
781
  /** Epoch-ms until which a room-state delegation lookup is not retried. */
547
782
  private delegationMissUntil = 0;
548
783
  private dirty = false;
784
+ /**
785
+ * `META_FLUSH_AT` as this instance last wrote or read it (null =
786
+ * unknown). With `dirty`, it tells `markDirty` that the upload is
787
+ * already scheduled, so a turn's mark costs no storage operation.
788
+ */
789
+ private flushAt: number | null = null;
790
+ /** The newest real request this instance served (null = none yet). */
791
+ private lastAccessAt: number | null = null;
792
+ /** The `META_LAST_ACCESS` this instance last wrote (null = none yet). */
793
+ private persistedAccessAt: number | null = null;
794
+ /** The user's Matrix id as resolved (or read from storage) by this instance. */
795
+ private matrixUserIdCache: MatrixUserIdCache | null = null;
796
+ /**
797
+ * The earliest housekeeping deadline armed since the current full alarm
798
+ * tick began: the tick's own `META_HOUSEKEEPING_AT` write never rises
799
+ * above a deadline armed while it ran.
800
+ */
801
+ private housekeepingFloor = Infinity;
802
+ /** Where the expired-blob sweep continues on the next tick (null = from the start). */
803
+ private blobSweepCursor: string | null = null;
804
+ /** When this instance last swept from the start (the sweep reads whole values). */
805
+ private lastBlobSweepAt: number | null = null;
549
806
  /** The flush in progress, if any (single-flight; a VFS snapshot is open while it runs). */
550
807
  private flushInFlight: Promise<FlushResult> | null = null;
551
808
 
@@ -572,6 +829,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
572
829
  private matrixLedger: MatrixTurnLedger | null = null;
573
830
  /** Room mirrors of HTTP turns, serialised per session and retried across gateway restarts (see room-mirror.ts). */
574
831
  private mirror: RoomMirror | null = null;
832
+ private channelTurns: ChannelTurns | null = null;
833
+ /** Anonymous-feedback idempotency markers and the user's submission limit. */
834
+ private feedbackMarkers: FeedbackMarkers | null = null;
575
835
  /** The oracle room of each session this instance resolved: spares the mirrors a row read per send. */
576
836
  private readonly sessionRooms = new Map<string, string>();
577
837
  /** The user's main oracle room, once resolved (the session list is scoped to it). */
@@ -593,6 +853,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
593
853
  { session: IndexableSession; messages: HistoryMessage[] }
594
854
  >();
595
855
  private initPromise: Promise<void> | null = null;
856
+ /** An idle eviction deleting the working copy; a boot waits for it. */
857
+ private evicting: Promise<void> | null = null;
596
858
  private reloadedFromOwnerStore = false;
597
859
  /** Set by `adoptOwnerCopy` for a legacy copy: `ready()` flushes it to the system of record right after. */
598
860
  private pendingLegacyMigration: { bytes: number } | null = null;
@@ -617,22 +879,44 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
617
879
  userDid: string,
618
880
  ): Promise<string | undefined> {
619
881
  if (!userDid.startsWith('did:ixo:')) return undefined;
882
+ const fresh = (
883
+ entry: MatrixUserIdCache | null | undefined,
884
+ ): entry is MatrixUserIdCache =>
885
+ !!entry &&
886
+ entry.userDid === userDid &&
887
+ (entry.resolved ||
888
+ Date.now() - entry.at < MATRIX_USER_ID_FALLBACK_TTL_MS);
889
+ if (fresh(this.matrixUserIdCache))
890
+ return this.matrixUserIdCache.matrixUserId;
620
891
  const cached =
621
892
  await this.ctx.storage.get<MatrixUserIdCache>(META_MATRIX_USER_ID);
622
- if (
623
- cached &&
624
- cached.userDid === userDid &&
625
- (cached.resolved ||
626
- Date.now() - cached.at < MATRIX_USER_ID_FALLBACK_TTL_MS)
627
- ) {
893
+ if (fresh(cached)) {
894
+ this.matrixUserIdCache = cached;
628
895
  return cached.matrixUserId;
629
896
  }
630
897
  let server: string | undefined;
631
898
  let resolved = false;
632
899
  const blocksync = this.env.BLOCKSYNC_GRAPHQL_URL;
633
900
  if (blocksync) {
901
+ // Bounded: this lookup sits on the boot path and on every turn
902
+ // start. The timer is cleared however the lookup ends.
903
+ const lookup = new AbortController();
904
+ const timer = setTimeout(
905
+ () =>
906
+ lookup.abort(
907
+ new Error(
908
+ `Blocksync lookup timed out after ${HOMESERVER_LOOKUP_TIMEOUT_MS} ms`,
909
+ ),
910
+ ),
911
+ HOMESERVER_LOOKUP_TIMEOUT_MS,
912
+ );
634
913
  try {
635
- const fromDid = await fetchUserMatrixServerName(blocksync, userDid);
914
+ const fromDid = await fetchUserMatrixServerName(
915
+ blocksync,
916
+ userDid,
917
+ (input, init) =>
918
+ globalThis.fetch(input, { ...init, signal: lookup.signal }),
919
+ );
636
920
  if (fromDid) {
637
921
  server = fromDid;
638
922
  resolved = true;
@@ -643,6 +927,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
643
927
  err instanceof Error ? err.message : String(err)
644
928
  }`,
645
929
  );
930
+ } finally {
931
+ clearTimeout(timer);
646
932
  }
647
933
  }
648
934
  server ??=
@@ -651,12 +937,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
651
937
  : undefined;
652
938
  if (!server) return undefined;
653
939
  const matrixUserId = `@${userDid.replace(/:/g, '-')}:${server}`;
654
- await this.ctx.storage.put<MatrixUserIdCache>(META_MATRIX_USER_ID, {
940
+ const entry: MatrixUserIdCache = {
655
941
  userDid,
656
942
  matrixUserId,
657
943
  resolved,
658
944
  at: Date.now(),
659
- });
945
+ };
946
+ this.matrixUserIdCache = entry;
947
+ await this.ctx.storage.put<MatrixUserIdCache>(META_MATRIX_USER_ID, entry);
660
948
  return matrixUserId;
661
949
  }
662
950
 
@@ -673,8 +961,28 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
673
961
  );
674
962
  }
675
963
 
676
- /** Idempotent boot: bind user, open SQLite (importing the owner copy on a cold object). */
677
- private async ready(identity: TurnIdentity): Promise<void> {
964
+ /** The delegation a turn runs under (`resolveTurnDelegation`). */
965
+ private async turnDelegation(
966
+ identity: TurnIdentity,
967
+ ): Promise<UcanDelegation> {
968
+ return resolveTurnDelegation(
969
+ identity.ucanDelegation,
970
+ this.delegations.get(identity.userDid)?.raw,
971
+ this.ucan,
972
+ );
973
+ }
974
+
975
+ /**
976
+ * Idempotent boot: bind user, open SQLite (importing the owner copy on a
977
+ * cold object). A request from the user records the access the idle
978
+ * eviction reads; the object's own wakes (the alarm, a flush, debug and
979
+ * operator calls) pass `recordAccess: false`, or an idle object would
980
+ * keep itself alive.
981
+ */
982
+ private async ready(
983
+ identity: TurnIdentity,
984
+ options: { recordAccess?: boolean } = {},
985
+ ): Promise<void> {
678
986
  this.installDebugTimerTracker();
679
987
  this.adoptHibernatedSockets();
680
988
  let delegationReplaced = false;
@@ -685,20 +993,33 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
685
993
  this.delegations.get(identity.userDid)?.raw ??
686
994
  (await this.ctx.storage.get<StoredDelegation>(META_DELEGATION))?.raw;
687
995
  delegationReplaced = known !== identity.ucanDelegation;
688
- this.delegations.set(identity.userDid, {
689
- raw: identity.ucanDelegation,
690
- });
691
- // Remember it for turns that arrive without the header.
692
- void this.ctx.storage.put(META_DELEGATION, {
693
- raw: identity.ucanDelegation,
694
- at: Date.now(),
695
- ...(typeof identity.ucanDelegationExpiration === 'number'
696
- ? { expiration: identity.ucanDelegationExpiration }
697
- : {}),
698
- } satisfies StoredDelegation);
996
+ const cached = this.delegations.get(identity.userDid);
997
+ if (
998
+ cached?.raw !== identity.ucanDelegation ||
999
+ typeof cached.expiration !== 'number'
1000
+ )
1001
+ this.delegations.set(
1002
+ identity.userDid,
1003
+ await this.delegationRecord(
1004
+ identity.ucanDelegation,
1005
+ identity.ucanDelegationExpiration,
1006
+ ),
1007
+ );
1008
+ // Remember a new one for turns that arrive without the header.
1009
+ if (delegationReplaced)
1010
+ await this.ctx.storage.put(META_DELEGATION, {
1011
+ raw: identity.ucanDelegation,
1012
+ at: Date.now(),
1013
+ ...(typeof identity.ucanDelegationExpiration === 'number'
1014
+ ? { expiration: identity.ucanDelegationExpiration }
1015
+ : {}),
1016
+ } satisfies StoredDelegation);
699
1017
  } else if (!this.delegations.has(identity.userDid)) {
700
1018
  await this.hydrateDelegation(identity.userDid);
701
1019
  }
1020
+ // A request that arrived while an idle eviction deletes the working
1021
+ // copy boots afresh from the owner copy once it is gone.
1022
+ while (this.evicting) await this.evicting;
702
1023
  if (!this.initPromise) {
703
1024
  this.initPromise = this.boot(identity.userDid).catch((err) => {
704
1025
  this.initPromise = null;
@@ -711,10 +1032,59 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
711
1032
  `UserOracleDO bound to ${this.userDid} received a request for ${identity.userDid}`,
712
1033
  );
713
1034
  }
714
- await this.ctx.storage.put(META_LAST_ACCESS, Date.now());
1035
+ if (options.recordAccess !== false) await this.recordAccess();
715
1036
  if (delegationReplaced) await this.onDelegationReplaced();
716
1037
  }
717
1038
 
1039
+ /**
1040
+ * A request from the user: remember it in memory, and in storage on this
1041
+ * instance's first request and then at most every
1042
+ * `ACCESS_PERSIST_INTERVAL_MS`.
1043
+ */
1044
+ private async recordAccess(): Promise<void> {
1045
+ const now = Date.now();
1046
+ this.lastAccessAt = now;
1047
+ if (
1048
+ typeof this.persistedAccessAt === 'number' &&
1049
+ now - this.persistedAccessAt < ACCESS_PERSIST_INTERVAL_MS
1050
+ )
1051
+ return;
1052
+ this.persistedAccessAt = now;
1053
+ await this.ctx.storage.put(META_LAST_ACCESS, now);
1054
+ }
1055
+
1056
+ /** The in-memory last access, when it is newer than the stored one. */
1057
+ private async persistAccess(): Promise<void> {
1058
+ const inMemory = this.lastAccessAt;
1059
+ if (
1060
+ typeof inMemory !== 'number' ||
1061
+ (typeof this.persistedAccessAt === 'number' &&
1062
+ this.persistedAccessAt >= inMemory)
1063
+ )
1064
+ return;
1065
+ this.persistedAccessAt = inMemory;
1066
+ await this.ctx.storage.put(META_LAST_ACCESS, inMemory);
1067
+ }
1068
+
1069
+ /**
1070
+ * The user's last request: the newer of what storage holds and what
1071
+ * this instance saw (undefined when neither knows of one).
1072
+ */
1073
+ private async knownLastAccess(): Promise<number | undefined> {
1074
+ const stored = await this.ctx.storage.get<number>(META_LAST_ACCESS);
1075
+ const inMemory = this.lastAccessAt;
1076
+ const known = Math.max(
1077
+ typeof stored === 'number' ? stored : -Infinity,
1078
+ typeof inMemory === 'number' ? inMemory : -Infinity,
1079
+ );
1080
+ return Number.isFinite(known) ? known : undefined;
1081
+ }
1082
+
1083
+ /** `knownLastAccess`, or `now` when there is none (never idle). */
1084
+ private async lastAccess(now: number): Promise<number> {
1085
+ return (await this.knownLastAccess()) ?? now;
1086
+ }
1087
+
718
1088
  /**
719
1089
  * The mnemonic the oracle signs downstream UCAN invocations with:
720
1090
  * `ORACLE_SIGNING_MNEMONIC` when set, otherwise the one the Node runtime
@@ -745,10 +1115,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
745
1115
  * The object holds a delegation it had not seen before (a header on this
746
1116
  * request, or a deposit through the shell). A fresh delegation is the one
747
1117
  * thing that turns a "no file-storage grant" flush failure into a
748
- * success: forget the failure streak and, with unsaved turns, flush now
749
- * instead of at the next 10-minute retry.
1118
+ * success: after a recorded failure, forget the streak and, with unsaved
1119
+ * turns, flush now instead of at the next 10-minute retry. Without one
1120
+ * the debounce stands: a user on two clients alternates two delegations,
1121
+ * and an upload per request would replace the daily one.
750
1122
  */
751
1123
  private async onDelegationReplaced(): Promise<void> {
1124
+ const failures =
1125
+ (await this.ctx.storage.get<number>(META_FLUSH_FAILURES)) ?? 0;
1126
+ if (failures === 0) return;
752
1127
  await this.ctx.storage.delete(META_FLUSH_FAILURES);
753
1128
  if (!this.db) return;
754
1129
  if (
@@ -775,7 +1150,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
775
1150
  const cached =
776
1151
  await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
777
1152
  if (live(cached)) {
778
- this.delegations.set(userDid, { raw: cached.raw });
1153
+ this.delegations.set(
1154
+ userDid,
1155
+ await this.delegationRecord(cached.raw, cached.expiration),
1156
+ );
779
1157
  return;
780
1158
  }
781
1159
  if (Date.now() < this.delegationMissUntil) return;
@@ -806,7 +1184,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
806
1184
  }
807
1185
  : null;
808
1186
  if (live(deposited)) {
809
- this.delegations.set(userDid, { raw: deposited.raw });
1187
+ this.delegations.set(
1188
+ userDid,
1189
+ await this.delegationRecord(deposited.raw, deposited.expiration),
1190
+ );
810
1191
  await this.ctx.storage.put(META_DELEGATION, deposited);
811
1192
  console.log(
812
1193
  `[user-do] delegation for ${userDid} loaded from the deposited room state (header-less turn)`,
@@ -821,6 +1202,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
821
1202
  this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
822
1203
  }
823
1204
 
1205
+ /**
1206
+ * The in-memory record of a delegation: the expiry where it arrived
1207
+ * stated one (the shell validated the token and read it off the chain),
1208
+ * else the token's own effective expiry along its proof chain. A token
1209
+ * that cannot be parsed keeps no expiry; channel turns refuse it.
1210
+ */
1211
+ private async delegationRecord(
1212
+ raw: string,
1213
+ expiration: number | undefined,
1214
+ ): Promise<CachedDelegation> {
1215
+ if (typeof expiration === 'number' && Number.isFinite(expiration))
1216
+ return { raw, expiration };
1217
+ try {
1218
+ const grant = await readDelegation(raw);
1219
+ return typeof grant.expiration === 'number'
1220
+ ? { raw, expiration: grant.expiration }
1221
+ : { raw };
1222
+ } catch (err) {
1223
+ console.warn(
1224
+ `[user-do] could not read the expiry of a delegation: ${err instanceof Error ? err.message : String(err)}`,
1225
+ );
1226
+ return { raw };
1227
+ }
1228
+ }
1229
+
824
1230
  private async boot(userDid: string): Promise<void> {
825
1231
  const stored = await this.ctx.storage.get<string>(META_USER_DID);
826
1232
  if (stored && stored !== userDid)
@@ -900,6 +1306,45 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
900
1306
  DB_FILE,
901
1307
  this.sqliteOpenOptions(),
902
1308
  );
1309
+ try {
1310
+ await this.bootWorkingCopy(
1311
+ db,
1312
+ userDid,
1313
+ this.ownerStore,
1314
+ this.ucan,
1315
+ secretsService,
1316
+ );
1317
+ } catch (err) {
1318
+ // Nothing of a failed boot stays behind: the connection (and the
1319
+ // one a dropped zero-turn copy was reopened into) is closed and the
1320
+ // handles forgotten, so the next request or alarm boots afresh on
1321
+ // one connection instead of finding a half-built object.
1322
+ const reopened = this.db;
1323
+ this.dropInMemoryState();
1324
+ await db.close().catch(() => undefined);
1325
+ if (reopened && reopened !== db)
1326
+ await reopened.close().catch(() => undefined);
1327
+ throw err;
1328
+ }
1329
+ }
1330
+
1331
+ /**
1332
+ * The fallible part of `boot()`, from the open database on: reconcile it
1333
+ * with the owner copy, then build the stores and services over it.
1334
+ */
1335
+ private async bootWorkingCopy(
1336
+ db: DoSqliteDatabase,
1337
+ userDid: string,
1338
+ ownerStore: OwnerStore,
1339
+ ucan: WorkersUcanService,
1340
+ secretsService: WorkersSecretsService,
1341
+ ): Promise<void> {
1342
+ const env = this.env;
1343
+ // Whether the working copy holds a turn: one existence query, asked
1344
+ // again only after an import or a wipe changed the file.
1345
+ let turns: boolean | undefined;
1346
+ const hasTurns = async (target: DoSqliteDatabase): Promise<boolean> =>
1347
+ (turns ??= await this.hasLocalTurns(target));
903
1348
  // Cold object (no pages yet) → pull the user's file if they have one.
904
1349
  // `null` means the owner store PROVABLY holds no file (a new user): an
905
1350
  // empty working copy is right. A load that FAILS is different: there is
@@ -908,7 +1353,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
908
1353
  // real copy. So the request fails instead; the boot is not memoised on
909
1354
  // failure, and the next request simply retries the load.
910
1355
  if (db.fileSize === 0) {
911
- const store = this.ownerStore;
1356
+ const store = ownerStore;
912
1357
  let loaded: OwnerCopy | null;
913
1358
  let attempts = 0;
914
1359
  try {
@@ -938,24 +1383,35 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
938
1383
  throw toRpcError(failure);
939
1384
  }
940
1385
  if (loaded) {
941
- const imported = await this.adoptOwnerCopy(db, loaded);
1386
+ let imported: number;
1387
+ try {
1388
+ imported = await this.adoptOwnerCopy(db, loaded);
1389
+ } catch (err) {
1390
+ // The object was empty, so nothing is lost by emptying it
1391
+ // again: a copy that is not a database (a short or truncated
1392
+ // file passes the import's header check) must not stay behind,
1393
+ // or every later boot would open it and fail.
1394
+ await this.wipeWorkingCopy(db).catch(() => undefined);
1395
+ throw err;
1396
+ }
942
1397
  this.reloadedFromOwnerStore = true;
943
1398
  console.log(
944
- `[user-do] imported ${imported} bytes from ${loaded.fromLegacy ? 'legacy Matrix media' : this.ownerStore.kind} for ${userDid}`,
1399
+ `[user-do] imported ${imported} bytes from ${loaded.fromLegacy ? 'legacy Matrix media' : ownerStore.kind} for ${userDid}`,
945
1400
  );
946
1401
  }
947
1402
  } else {
948
1403
  // Warm object: notice if the user replaced or deleted their file
949
1404
  // upstream. A `head()` that FAILS (a VFS error/timeout, or our own
950
- // delete→move replace window where the real path momentarily does
951
- // not exist) must NEVER be read as "the file is gone" — swallowing
1405
+ // replace window between moving the old file aside and moving the
1406
+ // new one in, where the real path momentarily does not exist) must
1407
+ // NEVER be read as "the file is gone" — swallowing
952
1408
  // the error to null and wiping the working copy on it would destroy
953
1409
  // the durable local history over a transient blip. So distinguish a
954
1410
  // proven absence (null) from an unknown (the call threw).
955
1411
  const knownEtag = await this.ctx.storage.get<string>(META_OWNER_ETAG);
956
1412
  let head: { etag: string } | null | undefined;
957
1413
  try {
958
- head = await this.ownerStore.head();
1414
+ head = await ownerStore.head();
959
1415
  } catch (err) {
960
1416
  head = undefined; // unknown — leave the working copy untouched
961
1417
  console.warn(
@@ -971,11 +1427,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
971
1427
  // re-flushed, re-establishing the owner copy; a genuine "forget me"
972
1428
  // goes through the explicit `remove()` path, never through an
973
1429
  // auto-wipe that could fire on a mid-replace crash or a stale list.
974
- if ((await this.localTurnCount(db)) === 0) {
1430
+ if (!(await hasTurns(db))) {
975
1431
  console.warn(
976
1432
  `[user-do] owner copy for ${userDid} is gone upstream and the working copy is empty — dropping it`,
977
1433
  );
978
1434
  await this.wipeWorkingCopy(db);
1435
+ turns = undefined;
979
1436
  this.db = await DoSqliteDatabase.open(
980
1437
  this.ctx,
981
1438
  DB_FILE,
@@ -988,13 +1445,27 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
988
1445
  this.markDirty();
989
1446
  }
990
1447
  } else if (head && knownEtag && head.etag !== knownEtag) {
991
- const loaded = await this.ownerStore.load().catch(() => null);
992
- if (loaded) {
993
- await this.adoptOwnerCopy(db, loaded);
994
- this.reloadedFromOwnerStore = true;
995
- console.log(
996
- `[user-do] owner copy changed upstream (${knownEtag} → ${head.etag}); re-imported`,
1448
+ // The file changed upstream. Re-import it only when every local
1449
+ // write already went up: up to a day of turns waits for the
1450
+ // debounced flush, and an import would drop them. With unflushed
1451
+ // turns neither side can win automatically, so keep local (the
1452
+ // next flush replaces the upstream file) and say so loudly.
1453
+ const unflushed = await this.unflushedLocalWrites(db);
1454
+ if (unflushed) {
1455
+ console.warn(
1456
+ `[user-do] owner copy for ${userDid} changed upstream (${knownEtag} → ${head.etag}) but the working copy holds writes not uploaded yet — keeping the local copy`,
997
1457
  );
1458
+ this.markDirty();
1459
+ } else {
1460
+ const loaded = await ownerStore.load().catch(() => null);
1461
+ if (loaded) {
1462
+ await this.adoptOwnerCopy(db, loaded);
1463
+ turns = undefined;
1464
+ this.reloadedFromOwnerStore = true;
1465
+ console.log(
1466
+ `[user-do] owner copy changed upstream (${knownEtag} → ${head.etag}); re-imported`,
1467
+ );
1468
+ }
998
1469
  }
999
1470
  } else if (head && !knownEtag) {
1000
1471
  // This object first booted before the user's owner file became
@@ -1005,9 +1476,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1005
1476
  // empty session, but no turn ever ran → no checkpoints); once local
1006
1477
  // turns exist neither side can win automatically, so keep local
1007
1478
  // and say so loudly (operators resolve via /debug/storage/reset).
1008
- const localTurns = await this.localTurnCount(db);
1009
- if (localTurns === 0) {
1010
- const loaded = await this.ownerStore.load().catch((err) => {
1479
+ if (!(await hasTurns(db))) {
1480
+ const loaded = await ownerStore.load().catch((err) => {
1011
1481
  console.warn(
1012
1482
  `[user-do] late owner copy load failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
1013
1483
  );
@@ -1015,14 +1485,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1015
1485
  });
1016
1486
  if (loaded) {
1017
1487
  const imported = await this.adoptOwnerCopy(db, loaded);
1488
+ turns = undefined;
1018
1489
  this.reloadedFromOwnerStore = true;
1019
1490
  console.log(
1020
- `[user-do] owner copy appeared upstream after first boot; imported ${imported} bytes from ${this.ownerStore.kind} for ${userDid}`,
1491
+ `[user-do] owner copy appeared upstream after first boot; imported ${imported} bytes from ${ownerStore.kind} for ${userDid}`,
1021
1492
  );
1022
1493
  }
1023
1494
  } else {
1024
1495
  console.warn(
1025
- `[user-do] owner copy exists upstream (${head.etag}) but was never imported and ${localTurns} local checkpoint(s) already exist for ${userDid} — keeping the local copy`,
1496
+ `[user-do] owner copy exists upstream (${head.etag}) but was never imported and local checkpoints already exist for ${userDid} — keeping the local copy`,
1026
1497
  );
1027
1498
  }
1028
1499
  }
@@ -1036,21 +1507,39 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1036
1507
  // the working copy has zero turns and a legacy (Matrix) copy WITH turns
1037
1508
  // exists, adopt it and flush it over the empty file. Guard on the legacy
1038
1509
  // copy actually having turns so a genuinely new user (empty everywhere)
1039
- // is left empty, not churned.
1510
+ // is left empty, not churned. A probe that proved there is nothing
1511
+ // usable is remembered (`META_LEGACY_UNUSABLE`), as is a legacy copy
1512
+ // already removed: later boots of a still zero-turn copy skip the
1513
+ // gateway. A probe that failed proves nothing and runs again on the
1514
+ // next boot.
1040
1515
  const activeDb = this.db ?? db;
1041
- if (
1042
- this.ownerStore.loadLegacy &&
1043
- (await this.localTurnCount(activeDb)) === 0
1044
- ) {
1045
- const legacy = await this.ownerStore.loadLegacy();
1516
+ if (ownerStore.loadLegacy && !(await hasTurns(activeDb))) {
1517
+ const markers = await this.ctx.storage.get<boolean>([
1518
+ META_LEGACY_CLEARED,
1519
+ META_LEGACY_UNUSABLE,
1520
+ ]);
1521
+ const settled =
1522
+ markers.get(META_LEGACY_CLEARED) === true ||
1523
+ markers.get(META_LEGACY_UNUSABLE) === true;
1524
+ let legacy: OwnerCopy | null = null;
1525
+ if (!settled) {
1526
+ try {
1527
+ legacy = await ownerStore.loadLegacy();
1528
+ if (!legacy) await this.ctx.storage.put(META_LEGACY_UNUSABLE, true);
1529
+ } catch (err) {
1530
+ console.warn(
1531
+ `[user-do] legacy Matrix copy of ${userDid} could not be read; checking again on the next boot: ${err instanceof Error ? err.message : String(err)}`,
1532
+ );
1533
+ }
1534
+ }
1046
1535
  if (legacy) {
1047
- // Trial import: only the checkpoint count tells us whether the
1048
- // legacy copy holds history, so keep the (tiny, zero-turn) working
1049
- // copy to put back if it does not.
1536
+ // Trial import: only the checkpoints tell us whether the legacy
1537
+ // copy holds history, so keep the (tiny, zero-turn) working copy
1538
+ // to put back if it does not.
1050
1539
  const before = await activeDb.export();
1051
1540
  const adoptedBytes = await activeDb.importFromStream(legacy.stream);
1052
- const adoptedTurns = await this.localTurnCount(activeDb);
1053
- if (adoptedTurns > 0) {
1541
+ turns = undefined;
1542
+ if (await hasTurns(activeDb)) {
1054
1543
  // The adopted bytes are NOT what the system of record holds:
1055
1544
  // forget the last upload so the flush below actually sends them.
1056
1545
  await this.ctx.storage.delete([
@@ -1062,10 +1551,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1062
1551
  // record so the next cold boot imports it directly.
1063
1552
  this.markDirty();
1064
1553
  console.log(
1065
- `[user-do] working copy had no turns; adopted the legacy Matrix copy (${adoptedBytes} bytes, ${adoptedTurns} checkpoint(s)) for ${userDid} — flushing it to the system of record`,
1554
+ `[user-do] working copy had no turns; adopted the legacy Matrix copy (${adoptedBytes} bytes) for ${userDid} — flushing it to the system of record`,
1066
1555
  );
1067
1556
  } else {
1068
1557
  await activeDb.import(before);
1558
+ turns = undefined;
1559
+ await this.ctx.storage.put(META_LEGACY_UNUSABLE, true);
1069
1560
  console.log(
1070
1561
  `[user-do] legacy Matrix copy for ${userDid} holds no turns either; working copy left as is`,
1071
1562
  );
@@ -1088,7 +1579,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1088
1579
  try {
1089
1580
  const flushed = await this.flushToOwnerStore();
1090
1581
  console.log(
1091
- `[user-do] migrated ${bytes} bytes from legacy Matrix media to ${this.ownerStore.kind} for ${userDid} (${flushed.etag ?? '-'})`,
1582
+ `[user-do] migrated ${bytes} bytes from legacy Matrix media to ${ownerStore.kind} for ${userDid} (${flushed.etag ?? '-'})`,
1092
1583
  );
1093
1584
  } catch (err) {
1094
1585
  console.warn(
@@ -1115,16 +1606,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1115
1606
  // Redacting on etag alone would drop the last backstop while the VFS
1116
1607
  // copy could still be a smaller or older export. A dirty or
1117
1608
  // not-yet-verified copy waits: the flush that lands it does the
1118
- // removal itself.
1609
+ // removal itself. The verification hashes the whole file, so it
1610
+ // only runs while a legacy copy may still exist.
1611
+ const ownerEtag = await this.ctx.storage.get<string>(META_OWNER_ETAG);
1119
1612
  if (
1120
- (await this.localTurnCount(liveDb)) > 0 &&
1613
+ ownerEtag &&
1614
+ ownerStore.removeLegacyCopy &&
1615
+ (await this.ctx.storage.get<boolean>(META_LEGACY_CLEARED)) !== true &&
1616
+ (await hasTurns(liveDb)) &&
1121
1617
  (await this.ownerCopyIsCurrent(liveDb))
1122
- ) {
1123
- const ownerEtag = await this.ctx.storage.get<string>(META_OWNER_ETAG);
1124
- if (ownerEtag) await this.removeLegacyCopyIfAny(ownerEtag);
1125
- }
1618
+ )
1619
+ await this.removeLegacyCopyIfAny(ownerEtag);
1126
1620
  console.log(
1127
- `[user-do] ready ${userDid}: store=${this.ownerStore.kind} legacy=${this.ownerStore.loadLegacy ? 'yes' : 'no'} file=${liveDb.fileSize}B turns=${await this.localTurnCount(liveDb)} sessions=${sessionRow?.n ?? 0} ownerEtag=${(await this.ctx.storage.get<string>(META_OWNER_ETAG)) ?? '-'} reloaded=${this.reloadedFromOwnerStore}`,
1621
+ `[user-do] ready ${userDid}: store=${ownerStore.kind} legacy=${ownerStore.loadLegacy ? 'yes' : 'no'} file=${liveDb.fileSize}B turns=${(await hasTurns(liveDb)) ? 'yes' : 'none'} sessions=${sessionRow?.n ?? 0} ownerEtag=${ownerEtag ?? '-'} reloaded=${this.reloadedFromOwnerStore}`,
1128
1622
  );
1129
1623
  }
1130
1624
  this.saver = new SqliteSaver(liveDb, undefined, {
@@ -1137,6 +1631,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1137
1631
  await this.matrixLedger.setup();
1138
1632
  this.runStore = new RunStore(liveDb);
1139
1633
  await this.runStore.setup();
1634
+ this.feedbackMarkers = new FeedbackMarkers(liveDb);
1635
+ await this.feedbackMarkers.setup();
1140
1636
  this.resultStore = new ResultStore(liveDb, {
1141
1637
  ...(this.env.TIER_BUCKET ? { bucket: this.env.TIER_BUCKET } : {}),
1142
1638
  prefix: this.ctx.id.toString(),
@@ -1144,6 +1640,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1144
1640
  logger: console,
1145
1641
  });
1146
1642
  await this.resultStore.setup();
1643
+ this.attachmentTextCache = new AttachmentTextCacheStore(liveDb, console);
1644
+ // The store exists whenever the bucket is bound, so artefacts made
1645
+ // earlier stay readable, revocable and deleted with their session after
1646
+ // the public origin is removed; only new ones need it.
1647
+ const artifactConfig = artifactStorageConfig(this.env, (message) =>
1648
+ console.warn(message),
1649
+ );
1650
+ if (artifactConfig) {
1651
+ this.artifacts = new ArtifactStore(liveDb, artifactConfig);
1652
+ await this.artifacts.setup();
1653
+ }
1147
1654
  // Threads opened before the runtime adopted Node's session rule
1148
1655
  // (session id = thread root event id) are renamed to it, once.
1149
1656
  const renamed = await migrateThreadSessionIds(liveDb, console);
@@ -1153,12 +1660,96 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1153
1660
  config: this.runConfig,
1154
1661
  instanceId: this.instanceId,
1155
1662
  log: console,
1156
- requestAlarm: (at) => this.requestAlarm(at),
1663
+ // Keep-alive and recovery deadlines alike: after a reset, the
1664
+ // keep-alive deadline is the wake that boots the object and
1665
+ // recovers its runs, so the heartbeat fast path must not skip it.
1666
+ requestAlarm: (at) => this.ctx.waitUntil(this.requestHousekeeping(at)),
1157
1667
  runAttempt: (live, resumed) => this.runAttempt(live, resumed),
1158
1668
  checkpointIdOf: (sessionId) => this.checkpointIdOf(sessionId),
1159
1669
  onRunEnded: (record, outcome) => this.onRunEnded(record, outcome),
1160
1670
  });
1161
1671
 
1672
+ this.channelTurns = new ChannelTurns(liveDb, {
1673
+ createSession: async (identity, markerTxnId) => {
1674
+ const room = await this.gateway.resolveUserRoom(identity.userDid);
1675
+ if (!room)
1676
+ throw new ChannelError(
1677
+ 428,
1678
+ 'Companion room is not ready',
1679
+ 'room_not_ready',
1680
+ );
1681
+ return (
1682
+ await this.createSession(identity, {
1683
+ roomId: room.roomId,
1684
+ markerTxnId,
1685
+ })
1686
+ ).sessionId;
1687
+ },
1688
+ assertSession: async (identity, sessionId) => {
1689
+ const session = await this.sessions!.getSession(sessionId);
1690
+ const room = await this.gateway.resolveUserRoom(identity.userDid);
1691
+ if (
1692
+ !session ||
1693
+ !sessionId.startsWith('$') ||
1694
+ !room ||
1695
+ session.roomId !== room.roomId
1696
+ )
1697
+ throw new ChannelError(
1698
+ 404,
1699
+ 'Companion session not found',
1700
+ 'session_not_found',
1701
+ );
1702
+ const encryption = await this.gateway.getRoomStateEvent(
1703
+ room.roomId,
1704
+ 'm.room.encryption',
1705
+ );
1706
+ const parsed: unknown = encryption ? JSON.parse(encryption) : null;
1707
+ if (
1708
+ !parsed ||
1709
+ typeof parsed !== 'object' ||
1710
+ !('algorithm' in parsed) ||
1711
+ parsed.algorithm !== 'm.megolm.v1.aes-sha2'
1712
+ )
1713
+ throw new ChannelError(
1714
+ 409,
1715
+ 'Companion room must be encrypted',
1716
+ 'room_not_encrypted',
1717
+ );
1718
+ },
1719
+ // Checked again as the run starts: admission may have waited.
1720
+ requireDelegation: async (identity) =>
1721
+ requireChannelDelegation(
1722
+ this.delegations.get(identity.userDid),
1723
+ Math.floor(Date.now() / 1000),
1724
+ ),
1725
+ getRun: (runId) => this.runStore!.get(runId),
1726
+ getPlan: (runId) => this.runStore!.getPlan(runId),
1727
+ wasPruned: (runId) => this.runStore!.wasChannelRunPruned(runId),
1728
+ begin: async (runId, request) => {
1729
+ const { live } = await this.runs!.begin({
1730
+ runId,
1731
+ sessionId: request.sessionId,
1732
+ requestId: request.requestId,
1733
+ client: 'channel',
1734
+ request: this.runRequestJson(request, storedRunRequest(request)),
1735
+ multitask: 'enqueue',
1736
+ });
1737
+ this.markDirty();
1738
+ await this.requestHousekeeping(
1739
+ Date.now() + this.runConfig.keepAliveMs,
1740
+ );
1741
+ await this.ctx.storage.sync();
1742
+ this.ctx.waitUntil(live.done.catch(() => undefined));
1743
+ return live.record;
1744
+ },
1745
+ mirror: (request, text, author) =>
1746
+ this.replayToRoom(request, text, author),
1747
+ changed: async () => {
1748
+ this.markDirty();
1749
+ await this.ctx.storage.sync();
1750
+ },
1751
+ });
1752
+
1162
1753
  const core = this.core;
1163
1754
  // Boot-time plugin hooks + collision checks, once per object (memoised).
1164
1755
  await core.warm();
@@ -1173,7 +1764,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1173
1764
  matrixUserId: await this.resolveMatrixUserId(userDid),
1174
1765
  gateway: this.gateway,
1175
1766
  runTurn: (req) => this.runTurn(req),
1176
- requestAlarm: (at) => this.requestAlarm(at),
1767
+ abortTurn: (sessionId) => this.abortTurn(sessionId),
1768
+ requestAlarm: (at) => this.requestHousekeeping(at),
1177
1769
  turnRunLive: (taskRunId) => Boolean(this.runs?.byTaskRunId(taskRunId)),
1178
1770
  log: console,
1179
1771
  ...(Number.isFinite(maxTasks) ? { maxTasksPerUser: maxTasks } : {}),
@@ -1181,7 +1773,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1181
1773
  });
1182
1774
  if (this.taskScheduler) {
1183
1775
  const next = await this.taskScheduler.nextWakeAt().catch(() => null);
1184
- if (next !== null) this.requestAlarm(Math.max(next, Date.now() + 1000));
1776
+ if (next !== null)
1777
+ await this.requestHousekeeping(Math.max(next, Date.now() + 1000));
1185
1778
  }
1186
1779
 
1187
1780
  // Runs a previous incarnation left in flight: schedule their recovery
@@ -1198,7 +1791,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1198
1791
  // imported from the Node runtime arrive with uncompressed blobs.
1199
1792
  this.compactDone ??=
1200
1793
  (await this.ctx.storage.get<boolean>(META_COMPACT_DONE)) ?? false;
1201
- if (!this.compactDone) this.requestAlarm(Date.now() + 3000);
1794
+ if (!this.compactDone) await this.requestHousekeeping(Date.now() + 3000);
1202
1795
 
1203
1796
  // Provider selection: `core.llm` is the OpenRouter adapter built from
1204
1797
  // the validated base env; `LLM_PROVIDER=nebius` (read from the raw
@@ -1225,20 +1818,27 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1225
1818
  this.ambient = createAmbientServices({
1226
1819
  ...(this.taskScheduler ? { tasks: this.taskScheduler.surface } : {}),
1227
1820
  preferences: this.preferences,
1821
+ kv: new SqliteUserKv(liveDb),
1228
1822
  frontend: this.realtime.frontend,
1229
1823
  config: core.validatedEnv,
1230
1824
  identity: core.identity,
1231
1825
  availablePlugins: core.availablePlugins,
1232
1826
  llm,
1827
+ decisions: core.decisions,
1233
1828
  logger: console,
1234
1829
  storage: this.ctx.storage,
1235
1830
  gateway: this.gateway,
1236
- ucan: this.ucan,
1831
+ ucan,
1237
1832
  delegationFor: (did) => this.delegations.get(did),
1238
1833
  events: this.events,
1239
1834
  secrets: createSecretsAdapter(secretsService),
1240
1835
  background: (work) => this.ctx.waitUntil(work),
1241
1836
  });
1837
+ this.capabilityRouter = createCapabilityRouter({
1838
+ evaluator: core.decisions,
1839
+ logger: console,
1840
+ background: (work) => this.ctx.waitUntil(work),
1841
+ });
1242
1842
  }
1243
1843
 
1244
1844
  /**
@@ -1278,6 +1878,50 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1278
1878
  });
1279
1879
  }
1280
1880
 
1881
+ /**
1882
+ * Forget every handle on the working copy, so the next request boots
1883
+ * afresh (and imports the owner copy into an empty file).
1884
+ */
1885
+ private dropInMemoryState(): void {
1886
+ this.db = null;
1887
+ this.saver = null;
1888
+ this.sessions = null;
1889
+ this.matrixLedger = null;
1890
+ this.runStore = null;
1891
+ this.channelTurns = null;
1892
+ this.feedbackMarkers = null;
1893
+ this.runs = null;
1894
+ this.resultStore = null;
1895
+ this.artifacts = null;
1896
+ this.attachmentTextCache = null;
1897
+ // Its store is bound to the database just dropped: a caller that sees
1898
+ // no scheduler boots again instead of reading a closed connection.
1899
+ this.taskScheduler = null;
1900
+ this.sessionRooms.clear();
1901
+ this.mainRoomId = null;
1902
+ this.ambient = null;
1903
+ this.secretsService = null;
1904
+ this.byo = null;
1905
+ this.initPromise = null;
1906
+ this.reloadedFromOwnerStore = false;
1907
+ }
1908
+
1909
+ /**
1910
+ * Still idle after the idle tick's awaits: the same working copy, nothing
1911
+ * written or uploading, no run, and no request since (every request
1912
+ * records its access in `ready()`, in memory at once).
1913
+ */
1914
+ private async stillIdle(db: DoSqliteDatabase): Promise<boolean> {
1915
+ const lastAccess = await this.lastAccess(Date.now());
1916
+ return (
1917
+ this.db === db &&
1918
+ !this.dirty &&
1919
+ !this.flushInFlight &&
1920
+ (this.runs?.size ?? 0) === 0 &&
1921
+ Date.now() - lastAccess > IDLE_EVICT_MS
1922
+ );
1923
+ }
1924
+
1281
1925
  /** Drop the working copy entirely; the caller reopens/reboots afterwards. */
1282
1926
  private async wipeWorkingCopy(db: DoSqliteDatabase | null): Promise<void> {
1283
1927
  if (db) {
@@ -1294,6 +1938,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1294
1938
  META_FLUSH_FAILURES,
1295
1939
  META_LAST_VACUUM,
1296
1940
  ]);
1941
+ this.flushAt = null;
1297
1942
  }
1298
1943
 
1299
1944
  /**
@@ -1354,6 +1999,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1354
1999
  META_LAST_CHECKSUM,
1355
2000
  META_UPLOADED_GEN,
1356
2001
  META_LEGACY_CLEARED,
2002
+ META_LEGACY_UNUSABLE,
1357
2003
  ]);
1358
2004
  this.markDirty();
1359
2005
  this.pendingLegacyMigration = { bytes };
@@ -1375,8 +2021,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1375
2021
  const uploadedGen = await this.ctx.storage.get<number>(META_UPLOADED_GEN);
1376
2022
  const lastChecksum =
1377
2023
  await this.ctx.storage.get<string>(META_LAST_CHECKSUM);
1378
- if (lastChecksum === undefined)
1379
- return (await this.localTurnCount(db)) === 0;
2024
+ if (lastChecksum === undefined) return !(await this.hasLocalTurns(db));
1380
2025
  // Objects that last flushed before generations were recorded verify
1381
2026
  // by hash alone; everyone else gets the cheap generation gate first.
1382
2027
  if (uploadedGen !== undefined && uploadedGen !== db.writeGeneration)
@@ -1384,17 +2029,47 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1384
2029
  return (await db.checksum()) === lastChecksum;
1385
2030
  }
1386
2031
 
1387
- /** Checkpoint rows in the working copy — zero means no turn ever ran here. */
1388
- private async localTurnCount(db: DoSqliteDatabase): Promise<number> {
2032
+ /** Whether the working copy holds a checkpoint — none means no turn ever ran here. */
2033
+ private async hasLocalTurns(db: DoSqliteDatabase): Promise<boolean> {
1389
2034
  const row = await db
1390
- .get<{ n: number }>('SELECT count(*) AS n FROM checkpoints')
2035
+ .get<{ one: number }>('SELECT 1 AS one FROM checkpoints LIMIT 1')
1391
2036
  .catch(() => undefined);
1392
- return row?.n ?? 0;
2037
+ return row !== undefined;
1393
2038
  }
1394
2039
 
2040
+ /**
2041
+ * Whether the working copy holds writes the owner store has not seen:
2042
+ * marked dirty (in memory or on disk), or written past the generation
2043
+ * of the last upload (`decideBootDirty`).
2044
+ */
2045
+ private async unflushedLocalWrites(db: DoSqliteDatabase): Promise<boolean> {
2046
+ const got = await this.ctx.storage.get<unknown>([
2047
+ META_DIRTY,
2048
+ META_UPLOADED_GEN,
2049
+ ]);
2050
+ const decision = decideBootDirty({
2051
+ dirtyInMemory: this.dirty,
2052
+ dirtyFlag: got.get(META_DIRTY),
2053
+ uploadedGen: got.get(META_UPLOADED_GEN),
2054
+ writeGeneration: db.writeGeneration,
2055
+ });
2056
+ return (
2057
+ decision === 'already-dirty' ||
2058
+ decision === 'flagged' ||
2059
+ decision === 'behind-upload'
2060
+ );
2061
+ }
2062
+
2063
+ /**
2064
+ * The working copy holds writes the owner store has not seen: persist the
2065
+ * mark and make sure the upload is scheduled. Free once the copy is dirty
2066
+ * with a known deadline — the alarm armed for it only ever moves earlier,
2067
+ * and the tick that serves it re-arms whatever it leaves pending.
2068
+ */
1395
2069
  private markDirty(): void {
2070
+ if (this.dirty && typeof this.flushAt === 'number') return;
2071
+ if (!this.dirty) void this.ctx.storage.put(META_DIRTY, true);
1396
2072
  this.dirty = true;
1397
- void this.ctx.storage.put(META_DIRTY, true);
1398
2073
  void this.armFlush(Date.now() + FLUSH_DEBOUNCE_MS);
1399
2074
  }
1400
2075
 
@@ -1404,12 +2079,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1404
2079
  * `FLUSH_DEBOUNCE_MS` after the first of them, never later.
1405
2080
  */
1406
2081
  private async armFlush(at: number): Promise<void> {
1407
- const existing = await this.ctx.storage.get<number>(META_FLUSH_AT);
2082
+ const existing =
2083
+ typeof this.flushAt === 'number'
2084
+ ? this.flushAt
2085
+ : await this.ctx.storage.get<number>(META_FLUSH_AT);
1408
2086
  const deadline =
1409
2087
  typeof existing === 'number' && existing <= at ? existing : at;
2088
+ this.flushAt = deadline;
1410
2089
  if (deadline !== existing)
1411
2090
  await this.ctx.storage.put(META_FLUSH_AT, deadline);
1412
- this.requestAlarm(deadline);
2091
+ await this.requestHousekeeping(deadline);
1413
2092
  }
1414
2093
 
1415
2094
  /**
@@ -1417,17 +2096,47 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1417
2096
  * that just came due (which would otherwise make every wake retry).
1418
2097
  */
1419
2098
  private async scheduleFlushRetry(at: number): Promise<void> {
2099
+ this.flushAt = at;
1420
2100
  await this.ctx.storage.put(META_FLUSH_AT, at);
1421
- this.requestAlarm(at);
2101
+ await this.requestHousekeeping(at);
2102
+ }
2103
+
2104
+ private alarmArm: Promise<void> = Promise.resolve();
2105
+
2106
+ /**
2107
+ * Arm the alarm for a deadline the full housekeeping tick serves (a
2108
+ * flush, its retry, a task, compaction, a run's keep-alive or recovery),
2109
+ * and lower the stored housekeeping deadline to it: the heartbeat fast
2110
+ * path trusts that value, and a deadline it does not know of would wait
2111
+ * for as long as a socket stays attached. Serialised with every other
2112
+ * arm.
2113
+ */
2114
+ private requestHousekeeping(at: number): Promise<void> {
2115
+ this.housekeepingFloor = Math.min(this.housekeepingFloor, at);
2116
+ this.alarmArm = this.alarmArm
2117
+ .catch(() => {})
2118
+ .then(async () => {
2119
+ const stored =
2120
+ await this.ctx.storage.get<number>(META_HOUSEKEEPING_AT);
2121
+ if (typeof stored === 'number' && stored > at)
2122
+ await this.ctx.storage.put(META_HOUSEKEEPING_AT, at);
2123
+ const existing = await this.ctx.storage.getAlarm();
2124
+ if (existing === null || existing > at)
2125
+ await this.ctx.storage.setAlarm(at);
2126
+ });
2127
+ return this.alarmArm;
1422
2128
  }
1423
2129
 
1424
2130
  /** Arm the object's single alarm no later than `at` (multiplexed). */
1425
- private requestAlarm(at: number): void {
1426
- void this.ctx.storage.getAlarm().then((existing) => {
1427
- if (existing === null || existing > at)
1428
- return this.ctx.storage.setAlarm(at);
1429
- return undefined;
1430
- });
2131
+ private requestAlarm(at: number): Promise<void> {
2132
+ this.alarmArm = this.alarmArm
2133
+ .catch(() => {})
2134
+ .then(async () => {
2135
+ const existing = await this.ctx.storage.getAlarm();
2136
+ if (existing === null || existing > at)
2137
+ await this.ctx.storage.setAlarm(at);
2138
+ });
2139
+ return this.alarmArm;
1431
2140
  }
1432
2141
 
1433
2142
  /**
@@ -1452,8 +2161,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1452
2161
  // exists once the object booted).
1453
2162
  const keepAliveAt = this.runs?.keepAliveDeadline(wakeAt) ?? null;
1454
2163
  const recoveryAt = this.runs?.nextRecoveryAt() ?? null;
1455
- if (this.runs && recoveryAt !== null && recoveryAt <= wakeAt + 1000)
1456
- await this.runs.resumeDue(wakeAt);
2164
+ if (this.runs && recoveryAt !== null && recoveryAt <= wakeAt + 1000) {
2165
+ // A store error here must not reject the alarm: the tick below (or
2166
+ // the re-arm) still has to run.
2167
+ try {
2168
+ await this.runs.resumeDue(wakeAt);
2169
+ } catch (err) {
2170
+ console.error(
2171
+ `[user-do] run recovery tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
2172
+ );
2173
+ }
2174
+ }
1457
2175
  if (
1458
2176
  nextPingAt !== null &&
1459
2177
  housekeepingAt !== undefined &&
@@ -1461,28 +2179,33 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1461
2179
  keepAliveAt === null &&
1462
2180
  (recoveryAt === null || recoveryAt > wakeAt + 1000)
1463
2181
  ) {
1464
- await this.ctx.storage.setAlarm(
2182
+ await this.requestAlarm(
1465
2183
  Math.min(nextPingAt, housekeepingAt, recoveryAt ?? Infinity),
1466
2184
  );
1467
2185
  return;
1468
2186
  }
1469
2187
 
2188
+ // A full tick from here on. Deadlines armed while it runs (the boot
2189
+ // below included) are kept by the housekeeping write at its end.
2190
+ this.housekeepingFloor = Infinity;
2191
+
1470
2192
  // Alarms routinely wake an EVICTED object: no db, no task scheduler,
1471
2193
  // dirty flag gone. Boot from the persisted identity first — a tick on
1472
2194
  // the un-booted object would skip every client and re-arm at the idle
1473
- // horizon, silently dropping pending task runs and flushes.
2195
+ // horizon, silently dropping pending task runs and flushes. The boot
2196
+ // is not a request: it records no access.
1474
2197
  if (!this.db) {
1475
2198
  const storedDid = await this.ctx.storage.get<string>(META_USER_DID);
1476
2199
  if (storedDid) {
1477
2200
  try {
1478
- await this.ready({ userDid: storedDid });
2201
+ await this.ready({ userDid: storedDid }, { recordAccess: false });
1479
2202
  } catch (err) {
1480
2203
  console.error(
1481
2204
  `[user-do] alarm boot failed for ${storedDid}: ${err instanceof Error ? err.message : String(err)}`,
1482
2205
  );
1483
2206
  // State exists but could not be opened — retry soon instead of
1484
2207
  // falling through to a schedulerless re-arm.
1485
- await this.ctx.storage.setAlarm(Date.now() + 60_000);
2208
+ await this.requestHousekeeping(Date.now() + 60_000);
1486
2209
  return;
1487
2210
  }
1488
2211
  }
@@ -1516,8 +2239,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1516
2239
  deadlines.push(Math.max(nextRecovery, now + 1000));
1517
2240
  }
1518
2241
 
1519
- const lastAccess =
1520
- (await this.ctx.storage.get<number>(META_LAST_ACCESS)) ?? now;
2242
+ // The last real request, stored for the next instance before anything
2243
+ // is decided on it.
2244
+ await this.persistAccess();
2245
+ const lastAccess = await this.lastAccess(now);
1521
2246
  const idle = now - lastAccess > IDLE_EVICT_MS;
1522
2247
 
1523
2248
  // Owner-store flush: only when its own deadline is due, or the copy
@@ -1548,14 +2273,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1548
2273
  }
1549
2274
 
1550
2275
  if (this.taskScheduler) {
2276
+ let next: number | null;
1551
2277
  try {
1552
- await this.taskScheduler.onAlarm(now);
2278
+ next = await this.taskScheduler.onAlarm(now);
1553
2279
  } catch (err) {
1554
2280
  console.error(
1555
2281
  `[user-do] task scheduler tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1556
2282
  );
2283
+ next = await this.taskScheduler.nextWakeAt().catch(() => null);
1557
2284
  }
1558
- const next = await this.taskScheduler.nextWakeAt().catch(() => null);
1559
2285
  if (next !== null) deadlines.push(Math.max(next, now + 1000));
1560
2286
  }
1561
2287
 
@@ -1585,24 +2311,39 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1585
2311
  if (next !== null) deadlines.push(next);
1586
2312
  }
1587
2313
 
1588
- // R2 page tier: move chunks no turn touched for a couple of days out
1589
- // of DO storage (see sqlite/page-tier.ts). After the flush above (the
1590
- // upload reads hot rows, not R2) and never over an in-flight one (it
1591
- // holds a snapshot). A pass is capped; more work re-arms soon.
2314
+ // Expired ambient blobs (`blob:` keys), one page per tick while a
2315
+ // sweep is under way, a new sweep at most hourly (a page read loads
2316
+ // the whole values, and blobs live a day at most). Its follow-up is
2317
+ // no reason to keep an idle copy: it is armed after the eviction
2318
+ // decision below.
2319
+ let blobSweepPending = false;
1592
2320
  if (
1593
- this.db?.tierEnabled &&
1594
- !this.flushInFlight &&
1595
- (this.runs?.activeCount ?? 0) === 0
2321
+ this.blobSweepCursor !== null ||
2322
+ this.lastBlobSweepAt === null ||
2323
+ now - this.lastBlobSweepAt >= BLOB_SWEEP_INTERVAL_MS
1596
2324
  ) {
1597
- const next = await this.tierTick(this.db, now);
1598
- if (next !== null) deadlines.push(next);
2325
+ if (this.blobSweepCursor === null) this.lastBlobSweepAt = now;
2326
+ try {
2327
+ const swept = await sweepExpiredBlobs(
2328
+ this.ctx.storage,
2329
+ this.blobSweepCursor ? { startAfter: this.blobSweepCursor } : {},
2330
+ );
2331
+ this.blobSweepCursor = swept.next;
2332
+ blobSweepPending = swept.next !== null;
2333
+ } catch (err) {
2334
+ console.warn(
2335
+ `[user-do] blob sweep failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
2336
+ );
2337
+ }
1599
2338
  }
1600
2339
 
1601
- // Idle housekeeping: a user silent for a day loses the cached pages —
1602
- // their file is safe upstream, and we stop paying to store a copy. Never
1603
- // evict while tasks are scheduled: their runs ARE activity. The flush
1604
- // above ran first; the wipe additionally verifies the upstream copy
1605
- // matches the working copy, else the copy stays until it does.
2340
+ // Idle housekeeping: a user silent for five days loses the cached
2341
+ // pages — their file is safe upstream, and we stop paying to store a
2342
+ // copy. Never evict while tasks are scheduled: their runs ARE
2343
+ // activity. The flush above ran first; the wipe additionally verifies
2344
+ // the upstream copy matches the working copy, else the copy stays
2345
+ // until it does. The R2 tier's next pass is no reason to stay: the
2346
+ // wipe deletes the object's R2 prefix with the working copy.
1606
2347
  if (
1607
2348
  idle &&
1608
2349
  !this.dirty &&
@@ -1610,27 +2351,82 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1610
2351
  deadlines.length === 0 &&
1611
2352
  (this.runs?.size ?? 0) === 0
1612
2353
  ) {
1613
- if (await this.ownerCopyIsCurrent(this.db)) {
1614
- await this.wipeWorkingCopy(this.db);
2354
+ const db = this.db;
2355
+ const outcome = await evictIdleWorkingCopy({
2356
+ ownerCopyIsCurrent: () => this.ownerCopyIsCurrent(db),
2357
+ stillIdle: () => this.stillIdle(db),
2358
+ dropHandles: () => this.dropInMemoryState(),
2359
+ wipe: () => {
2360
+ const wipe = this.wipeWorkingCopy(db);
2361
+ this.evicting = wipe
2362
+ .catch(() => undefined)
2363
+ .finally(() => {
2364
+ this.evicting = null;
2365
+ });
2366
+ return wipe;
2367
+ },
2368
+ });
2369
+ if (outcome === 'evicted') {
1615
2370
  await this.ctx.storage.delete(META_HOUSEKEEPING_AT);
1616
2371
  // An attached (idle) socket still needs its heartbeat rounds.
1617
- if (nextPingAt !== null) await this.ctx.storage.setAlarm(nextPingAt);
1618
- else await this.ctx.storage.deleteAlarm();
2372
+ if (nextPingAt !== null) await this.requestAlarm(nextPingAt);
2373
+ // A request that waited on the wipe has booted the object afresh
2374
+ // and armed its own deadlines (a task Start's run): keep them.
2375
+ else if (this.initPromise === null)
2376
+ await this.ctx.storage.deleteAlarm();
1619
2377
  return;
1620
2378
  }
1621
- console.warn(
1622
- `[user-do] ${this.userDid} is idle but the owner copy is not verified current — keeping the working copy`,
1623
- );
1624
- deadlines.push(now + FLUSH_RETRY_DELAY_MS);
2379
+ if (outcome === 'not-current') {
2380
+ console.warn(
2381
+ `[user-do] ${this.userDid} is idle but the owner copy is not verified current — keeping the working copy`,
2382
+ );
2383
+ deadlines.push(now + FLUSH_RETRY_DELAY_MS);
2384
+ } else {
2385
+ console.log(
2386
+ `[user-do] ${this.userDid} became active during the idle check — keeping the working copy`,
2387
+ );
2388
+ }
1625
2389
  }
2390
+
2391
+ // R2 page tier: move chunks no turn touched for a couple of days out
2392
+ // of DO storage (see sqlite/page-tier.ts). After the flush above (the
2393
+ // upload reads hot rows, not R2) and never over an in-flight one (it
2394
+ // holds a snapshot). A pass is capped; more work re-arms soon.
2395
+ if (
2396
+ this.db?.tierEnabled &&
2397
+ !this.flushInFlight &&
2398
+ (this.runs?.activeCount ?? 0) === 0
2399
+ ) {
2400
+ const next = await this.tierTick(this.db, now);
2401
+ if (next !== null) deadlines.push(next);
2402
+ }
2403
+ if (blobSweepPending) deadlines.push(now + 60_000);
2404
+
1626
2405
  deadlines.push(now + IDLE_EVICT_MS);
1627
2406
  const housekeeping = Math.min(...deadlines);
1628
- await this.ctx.storage.put(META_HOUSEKEEPING_AT, housekeeping);
1629
- await this.ctx.storage.setAlarm(
2407
+ await this.storeHousekeeping(housekeeping);
2408
+ await this.requestAlarm(
1630
2409
  nextPingAt === null ? housekeeping : Math.min(housekeeping, nextPingAt),
1631
2410
  );
1632
2411
  }
1633
2412
 
2413
+ /**
2414
+ * Record the next housekeeping deadline at the end of a full tick: the
2415
+ * earliest the tick found, lowered by any deadline armed while it ran
2416
+ * (an arm that lands after this write lowers it itself). Serialised
2417
+ * with the arms, so neither overwrites the other.
2418
+ */
2419
+ private storeHousekeeping(at: number): Promise<void> {
2420
+ this.alarmArm = this.alarmArm
2421
+ .catch(() => {})
2422
+ .then(async () => {
2423
+ const deadline = Math.min(at, this.housekeepingFloor);
2424
+ this.housekeepingFloor = Infinity;
2425
+ await this.ctx.storage.put(META_HOUSEKEEPING_AT, deadline);
2426
+ });
2427
+ return this.alarmArm;
2428
+ }
2429
+
1634
2430
  /**
1635
2431
  * One page-tier eviction pass, at most once per `TIER_PASS_INTERVAL_MS`
1636
2432
  * (a pass that hit its segment cap re-arms in a minute). Returns the next
@@ -1687,7 +2483,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1687
2483
  if (!this.db) {
1688
2484
  const userDid =
1689
2485
  this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
1690
- if (userDid) await this.ready({ userDid });
2486
+ if (userDid) await this.ready({ userDid }, { recordAccess: false });
1691
2487
  }
1692
2488
  if (!this.db) throw new Error('no working copy');
1693
2489
  if (this.flushInFlight) await this.flushInFlight.catch(() => undefined);
@@ -1747,14 +2543,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1747
2543
 
1748
2544
  async createSession(
1749
2545
  identity: TurnIdentity,
1750
- o: { roomId?: string; sessionId?: string } = {},
2546
+ o: { roomId?: string; sessionId?: string; markerTxnId?: string } = {},
1751
2547
  ): Promise<SessionSummary> {
1752
2548
  await this.ready(identity);
1753
2549
  const sessions = this.sessions!;
1754
2550
  // Like the Node runtime: creating a session sends the MOST RECENT
1755
2551
  // previous session's transcript to the memory engine, in the
1756
- // background — the new session never waits on it.
1757
- const { sessions: recent } = await sessions.listSessions(undefined, 1, 0);
2552
+ // background — the new session never waits on it. Task runs are not
2553
+ // conversations and are never indexed (`scheduleHistoryIndexing`).
2554
+ const { sessions: recent } = await sessions.listSessions(
2555
+ undefined,
2556
+ 1,
2557
+ 0,
2558
+ TASK_SESSION_PREFIX,
2559
+ );
1758
2560
  const previous = recent[0];
1759
2561
  if (previous) this.scheduleHistoryIndexing(previous.sessionId);
1760
2562
  let sessionId = o.sessionId;
@@ -1782,7 +2584,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1782
2584
  // the same transaction id across the restart window, which the
1783
2585
  // homeserver deduplicates. Node fails the request on send failure;
1784
2586
  // so do we — no silent local id when the user has an oracle room.
1785
- const txnId = `marker-${crypto.randomUUID()}`;
2587
+ const txnId = o.markerTxnId ?? `marker-${crypto.randomUUID()}`;
1786
2588
  sessionId = await retryGateway(
1787
2589
  () =>
1788
2590
  this.gateway.sendText(room.roomId, NEW_CONVERSATION_TEXT, {
@@ -1802,6 +2604,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1802
2604
  }
1803
2605
  }
1804
2606
  sessionId ??= `s_${crypto.randomUUID()}`;
2607
+ if (o.markerTxnId) {
2608
+ const existing = await sessions.getSession(sessionId);
2609
+ if (existing) return toSummary(existing);
2610
+ }
1805
2611
  const row = await sessions.createSession({
1806
2612
  sessionId,
1807
2613
  roomId,
@@ -1813,6 +2619,66 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1813
2619
  return toSummary(row);
1814
2620
  }
1815
2621
 
2622
+ async channelTurn(
2623
+ identity: TurnIdentity,
2624
+ input: ChannelTurnInput,
2625
+ requestHash: string,
2626
+ ): Promise<ChannelTurnOutcome> {
2627
+ try {
2628
+ if (identity.ucanDelegation)
2629
+ throw new ChannelError(
2630
+ 403,
2631
+ 'Channel grants cannot become tool authority',
2632
+ 'channel_grant_not_tool_authority',
2633
+ );
2634
+ // The shell (the only caller) checked the binding just before.
2635
+ // The delegation is checked BEFORE the object boots: a cold boot
2636
+ // loads the owner copy under it, and a user without a usable one
2637
+ // must get `delegation_required`, not a boot failure. Hydration
2638
+ // reads the cached copy, then the deposited room state (throttled
2639
+ // after a miss); an in-memory one that no longer passes is
2640
+ // re-read too, in case a newer deposit did not reach this object.
2641
+ const nowSeconds = Math.floor(Date.now() / 1000);
2642
+ try {
2643
+ requireChannelDelegation(
2644
+ this.delegations.get(identity.userDid),
2645
+ nowSeconds,
2646
+ );
2647
+ } catch {
2648
+ await this.hydrateDelegation(identity.userDid);
2649
+ requireChannelDelegation(
2650
+ this.delegations.get(identity.userDid),
2651
+ nowSeconds,
2652
+ );
2653
+ }
2654
+ try {
2655
+ await this.ready(identity);
2656
+ } catch (error) {
2657
+ // A delegation that lacks the file-storage grant over
2658
+ // `/.oracles` cannot load the owner copy: the user must
2659
+ // authorize again, exactly as without a delegation. (Boot throws
2660
+ // the failure as its RPC envelope, so it is parsed, not matched
2661
+ // by class.)
2662
+ if (parseOwnerCopyFailure(error)?.code === 'NO_VFS_DELEGATION')
2663
+ throw delegationRequiredError();
2664
+ throw error;
2665
+ }
2666
+ return {
2667
+ ok: true,
2668
+ result: await this.channelTurns!.submit(identity, input, requestHash),
2669
+ };
2670
+ } catch (error) {
2671
+ if (error instanceof ChannelError)
2672
+ return {
2673
+ ok: false,
2674
+ status: error.status,
2675
+ code: error.code,
2676
+ message: error.message,
2677
+ };
2678
+ throw error;
2679
+ }
2680
+ }
2681
+
1816
2682
  async listSessions(
1817
2683
  identity: TurnIdentity,
1818
2684
  o: { limit?: number; offset?: number } = {},
@@ -1853,12 +2719,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1853
2719
  sessionId: string,
1854
2720
  ): Promise<boolean> {
1855
2721
  await this.ready(identity);
2722
+ // End every run of the session first: the executing one is aborted
2723
+ // and waited for (up to the supersede grace), queued and recovering
2724
+ // ones are closed — none may start a turn on the deleted thread, or
2725
+ // recreate its session row, after the rows below are gone.
2726
+ await this.runs?.abortAllForSession(sessionId);
1856
2727
  this.aborts.get(sessionId)?.abort();
1857
2728
  // Node indexes a session into the memory engine as it is deleted.
1858
2729
  // The rows are gone once this RPC returns, so capture the transcript
1859
2730
  // now and let the background indexer read the snapshot.
1860
2731
  const doomed = await this.sessions!.getSession(sessionId);
1861
- if (doomed) {
2732
+ if (doomed && !sessionId.startsWith(TASK_SESSION_PREFIX)) {
1862
2733
  this.historySnapshots.set(sessionId, {
1863
2734
  session: doomed,
1864
2735
  messages: await this.historyMessages(sessionId),
@@ -1868,6 +2739,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1868
2739
  const deleted = await this.sessions!.deleteSession(sessionId);
1869
2740
  if (deleted) {
1870
2741
  await this.saver!.deleteThread(sessionId);
2742
+ await this.attachmentTextCache
2743
+ ?.forgetSession(sessionId)
2744
+ .catch((err: unknown) => {
2745
+ console.warn(
2746
+ `[user-do] could not delete the cached attachment text of ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
2747
+ );
2748
+ });
1871
2749
  await this.resultStore
1872
2750
  ?.deleteForSession(sessionId)
1873
2751
  .catch((err: unknown) => {
@@ -1875,12 +2753,60 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1875
2753
  `[user-do] could not delete the saved tool results of ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
1876
2754
  );
1877
2755
  });
2756
+ await this.artifacts
2757
+ ?.deleteForSession(sessionId)
2758
+ .catch((err: unknown) => {
2759
+ console.warn(
2760
+ `[user-do] could not delete the artefacts of ${sessionId}; their links expire on schedule: ${err instanceof Error ? err.message : String(err)}`,
2761
+ );
2762
+ });
1878
2763
  await this.ctx.storage.delete(contextStatsKey(sessionId));
2764
+ await this.feedbackMarkers!.forgetSession(sessionId);
1879
2765
  this.markDirty();
1880
2766
  }
2767
+ // A channel binding that pointed at the session opens a new one on
2768
+ // its next message instead of failing the session check for ever.
2769
+ if (await this.channelTurns!.forgetSession(sessionId)) this.markDirty();
1881
2770
  return deleted;
1882
2771
  }
1883
2772
 
2773
+ /**
2774
+ * `GET /artifacts/:id` — the canonical copy, for its owner. `url` is
2775
+ * present only while the share link still works.
2776
+ */
2777
+ async artifact(
2778
+ identity: TurnIdentity,
2779
+ artifactId: string,
2780
+ ): Promise<OwnedArtifact | null> {
2781
+ await this.ready(identity);
2782
+ const stored = await this.artifacts?.get(artifactId);
2783
+ if (!stored) return null;
2784
+ const { url, ...ref } = stored.ref;
2785
+ const live =
2786
+ url !== undefined &&
2787
+ !stored.revoked &&
2788
+ Date.parse(ref.expiresAt) > Date.now();
2789
+ return {
2790
+ ...ref,
2791
+ ...(live ? { url } : {}),
2792
+ sessionId: stored.sessionId,
2793
+ createdAt: stored.createdAt,
2794
+ revoked: stored.revoked,
2795
+ content: stored.content,
2796
+ };
2797
+ }
2798
+
2799
+ /** `DELETE /artifacts/:id` — revoke the share link; the canonical copy stays. */
2800
+ async revokeArtifact(
2801
+ identity: TurnIdentity,
2802
+ artifactId: string,
2803
+ ): Promise<boolean> {
2804
+ await this.ready(identity);
2805
+ const revoked = (await this.artifacts?.revoke(artifactId)) ?? false;
2806
+ if (revoked) this.markDirty();
2807
+ return revoked;
2808
+ }
2809
+
1884
2810
  /** The session transcript as the indexer wants it (summarisation bookkeeping removed). */
1885
2811
  private async historyMessages(
1886
2812
  sessionId: string,
@@ -1890,7 +2816,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1890
2816
  .filter(
1891
2817
  (m) => !isSummarizationMessage(m) && !isAttachmentViewMessage(m),
1892
2818
  )
1893
- .map((m) => ({ type: m.type, content: contentToText(m.content) }));
2819
+ .map((m) => ({ type: m.type, content: visibleText(m) }));
1894
2820
  }
1895
2821
 
1896
2822
  /**
@@ -1899,8 +2825,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1899
2825
  * seeded from the row's `lastProcessedCount` for files migrated from Node;
1900
2826
  * `touchSession` is not used because it bumps `last_updated_at` and
1901
2827
  * would reorder the session list.
2828
+ *
2829
+ * Task-run sessions (`task:<id>`) are never indexed: a run's transcript
2830
+ * is the task's instructions and its result, not something the user
2831
+ * said, and a supplied-context task's source text must not become
2832
+ * long-term memory that ordinary, tool-enabled turns can retrieve.
1902
2833
  */
1903
2834
  private scheduleHistoryIndexing(sessionId: string): void {
2835
+ if (sessionId.startsWith(TASK_SESSION_PREFIX)) return;
1904
2836
  const ambient = this.ambient;
1905
2837
  const userDid = this.userDid;
1906
2838
  if (!ambient || !this.sessions || !userDid) return;
@@ -1984,6 +2916,57 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1984
2916
  return JSON.stringify(dtos);
1985
2917
  }
1986
2918
 
2919
+ /**
2920
+ * Anonymous feedback, step one (src/feedback/submit.ts): the message must
2921
+ * be a completed Agent reply in this user's own session; then the
2922
+ * idempotency marker and the user's submission limit decide. The shell
2923
+ * holds the feedback text; it never reaches this object.
2924
+ */
2925
+ async reserveMessageFeedback(
2926
+ identity: TurnIdentity,
2927
+ target: FeedbackTarget,
2928
+ ): Promise<FeedbackReservation> {
2929
+ await this.ready(identity);
2930
+ const reservation = await reserveFeedback(
2931
+ {
2932
+ sessionExists: async (sessionId) =>
2933
+ (await this.sessions!.getSession(sessionId)) !== undefined,
2934
+ transcript: async (sessionId) =>
2935
+ (
2936
+ await transformTranscript(
2937
+ (await this.saver!.listThreadMessages(sessionId)).filter(
2938
+ (m) => !isSummarizationMessage(m),
2939
+ ),
2940
+ )
2941
+ ).messages,
2942
+ // A queued run has not written its user message yet, so it does
2943
+ // not make the latest reply incomplete; a running one has.
2944
+ runActive: async (sessionId) => {
2945
+ const status = (
2946
+ this.runs?.activeForSession(sessionId)?.record ??
2947
+ (await this.runStore?.activeForSession(sessionId))
2948
+ )?.status;
2949
+ return status === 'running' || status === 'recovering';
2950
+ },
2951
+ markers: this.feedbackMarkers!,
2952
+ },
2953
+ target,
2954
+ );
2955
+ if (reservation.kind === 'reserved') this.markDirty();
2956
+ return reservation;
2957
+ }
2958
+
2959
+ /** Anonymous feedback, step two: settle the reservation (`FeedbackMarkers.settle`). */
2960
+ async settleMessageFeedback(
2961
+ identity: TurnIdentity,
2962
+ target: FeedbackTarget,
2963
+ outcome: FeedbackSettlement,
2964
+ ): Promise<void> {
2965
+ await this.ready(identity);
2966
+ await this.feedbackMarkers!.settle(target, outcome);
2967
+ this.markDirty();
2968
+ }
2969
+
1987
2970
  async listMessagesPage(
1988
2971
  identity: TurnIdentity,
1989
2972
  sessionId: string,
@@ -2015,7 +2998,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2015
2998
  raw: string,
2016
2999
  expiration?: number,
2017
3000
  ): Promise<void> {
2018
- this.delegations.set(userDid, { raw });
3001
+ this.delegations.set(
3002
+ userDid,
3003
+ await this.delegationRecord(raw, expiration),
3004
+ );
2019
3005
  this.delegationMissUntil = 0;
2020
3006
  await this.ctx.storage.put(META_DELEGATION, {
2021
3007
  raw,
@@ -2125,12 +3111,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2125
3111
  await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
2126
3112
  const inMemory = this.delegations.get(userDid);
2127
3113
  if (inMemory) {
3114
+ const expiration = inMemory.expiration ?? stored?.expiration;
2128
3115
  return {
2129
3116
  present: true,
2130
3117
  source: 'memory',
2131
- ...(typeof stored?.expiration === 'number'
2132
- ? { expiration: stored.expiration }
2133
- : {}),
3118
+ ...(typeof expiration === 'number' ? { expiration } : {}),
2134
3119
  };
2135
3120
  }
2136
3121
  if (stored?.raw) {
@@ -2192,6 +3177,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2192
3177
  sessionId: req.sessionId,
2193
3178
  requestId: req.requestId,
2194
3179
  text: existing?.replyText ?? '',
3180
+ ...(existing?.replyPlan ? { plan: existing.replyPlan } : {}),
2195
3181
  toolCalls: [],
2196
3182
  replayed: true,
2197
3183
  };
@@ -2211,7 +3197,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2211
3197
  await ledger.start(eventId, req.sessionId, req.requestId);
2212
3198
  const run = this.runTurnOnce(req)
2213
3199
  .then(async (result) => {
2214
- await ledger.answer(eventId, result.text);
3200
+ await ledger.answer(eventId, result.text, result.plan);
2215
3201
  return result;
2216
3202
  })
2217
3203
  .finally(() => {
@@ -2259,7 +3245,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2259
3245
  sessionId: req.sessionId,
2260
3246
  requestId: req.requestId,
2261
3247
  client: req.client,
2262
- request: JSON.stringify(
3248
+ request: this.runRequestJson(
3249
+ req,
2263
3250
  storedRunRequest(req, { timezone: req.identity.timezone }),
2264
3251
  ),
2265
3252
  multitask: req.multitask ?? this.runConfig.multitaskDefault,
@@ -2280,6 +3267,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2280
3267
  sessionId: req.sessionId,
2281
3268
  requestId: req.requestId,
2282
3269
  text: outcome.text,
3270
+ ...(outcome.plan ? { plan: JSON.stringify(outcome.plan) } : {}),
2283
3271
  ...(outcome.messageId !== undefined
2284
3272
  ? { messageId: outcome.messageId }
2285
3273
  : {}),
@@ -2306,6 +3294,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2306
3294
  sessionId: req.sessionId,
2307
3295
  requestId: req.requestId,
2308
3296
  text: outcome.text,
3297
+ ...(outcome.plan ? { plan: JSON.stringify(outcome.plan) } : {}),
2309
3298
  ...(outcome.messageId ? { messageId: outcome.messageId } : {}),
2310
3299
  toolCalls: outcome.toolCalls ?? [],
2311
3300
  replayed: true,
@@ -2313,10 +3302,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2313
3302
  }
2314
3303
  const record = await this.runStore?.getByRequestId(requestId);
2315
3304
  if (record?.status === 'finished') {
3305
+ const plan = parseReplyPlan(await this.runStore!.getPlan(record.runId));
2316
3306
  return {
2317
3307
  sessionId: req.sessionId,
2318
3308
  requestId: req.requestId,
2319
3309
  text: record.partialText ?? '',
3310
+ ...(plan ? { plan: JSON.stringify(plan) } : {}),
2320
3311
  ...(record.messageId ? { messageId: record.messageId } : {}),
2321
3312
  toolCalls: [],
2322
3313
  replayed: true,
@@ -2359,6 +3350,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2359
3350
  // One export at the end, not per batch — flushing a multi-GB file
2360
3351
  // after every 300 rows would dwarf the savings.
2361
3352
  this.markDirty();
3353
+ } else if (await this.unflushedLocalWrites(db)) {
3354
+ // Nothing saved, but the closing vacuum rewrote the file: an
3355
+ // unmarked write would keep the owner copy unverifiable, and the
3356
+ // idle wipe waiting on it, until some later boot noticed.
3357
+ this.markDirty();
2362
3358
  }
2363
3359
  return false;
2364
3360
  }
@@ -2421,7 +3417,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2421
3417
  // an operator's `POST /debug/storage/flush` is never a silent no-op.
2422
3418
  const userDid =
2423
3419
  this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
2424
- if (userDid) await this.ready({ userDid });
3420
+ if (userDid) await this.ready({ userDid }, { recordAccess: false });
2425
3421
  }
2426
3422
  if (this.flushInFlight) return this.flushInFlight;
2427
3423
  const run = this.flushOnce().finally(() => {
@@ -2521,7 +3517,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2521
3517
  // Defence in depth: never drop the backstop for an empty working copy —
2522
3518
  // a zero-turn file is not worth confirming against, and redacting here
2523
3519
  // would strip a migrated user's only history if VFS held a stub.
2524
- if (this.db && (await this.localTurnCount(this.db)) === 0) return false;
3520
+ if (this.db && !(await this.hasLocalTurns(this.db))) return false;
2525
3521
  try {
2526
3522
  const removed = await store.removeLegacyCopy(etag);
2527
3523
  await this.ctx.storage.put(META_LEGACY_CLEARED, true);
@@ -2573,21 +3569,36 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2573
3569
  uploadedGen !== this.db.writeGeneration
2574
3570
  ) {
2575
3571
  const at = Date.now() + FLUSH_DEBOUNCE_MS;
3572
+ this.flushAt = at;
2576
3573
  await this.ctx.storage.put(META_FLUSH_AT, at);
2577
- this.requestAlarm(at);
3574
+ await this.requestHousekeeping(at);
2578
3575
  return;
2579
3576
  }
2580
3577
  this.dirty = false;
3578
+ this.flushAt = null;
2581
3579
  await this.ctx.storage.delete([META_DIRTY, META_FLUSH_AT]);
2582
3580
  }
2583
3581
 
2584
- async resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }> {
3582
+ /**
3583
+ * `POST /debug/storage/reset`: upload what is unsaved, drop the working
3584
+ * copy and boot again from the owner copy. `identity` is the caller the
3585
+ * shell authenticated, with the delegation the request carried: an
3586
+ * object that never booted boots for it (the owner store needs that
3587
+ * delegation), and an object bound to another DID refuses before
3588
+ * anything is dropped.
3589
+ */
3590
+ async resetWorkingCopy(
3591
+ identity?: TurnIdentity,
3592
+ ): Promise<{ reloadedFromOwnerStore: boolean }> {
2585
3593
  const userDid =
2586
- this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
3594
+ identity?.userDid ??
3595
+ this.userDid ??
3596
+ (await this.ctx.storage.get<string>(META_USER_DID));
3597
+ const caller = identity ?? (userDid ? { userDid } : undefined);
2587
3598
  // On a cold (evicted) object the in-memory dirty flag is gone — boot and
2588
3599
  // consult the persisted marker, or the wipe below discards changes the
2589
- // owner store never received.
2590
- if (!this.db && userDid) await this.ready({ userDid });
3600
+ // owner store never received. A booted object only checks the caller.
3601
+ if (caller) await this.ready(caller, { recordAccess: false });
2591
3602
  if (
2592
3603
  this.dirty ||
2593
3604
  (await this.ctx.storage.get<boolean>(META_DIRTY)) === true
@@ -2596,21 +3607,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2596
3607
  await this.flushToOwnerStore();
2597
3608
  }
2598
3609
  await this.wipeWorkingCopy(this.db);
2599
- this.db = null;
2600
- this.saver = null;
2601
- this.sessions = null;
2602
- this.matrixLedger = null;
2603
- this.runStore = null;
2604
- this.runs = null;
2605
- this.resultStore = null;
2606
- this.sessionRooms.clear();
2607
- this.mainRoomId = null;
2608
- this.ambient = null;
2609
- this.secretsService = null;
2610
- this.byo = null;
2611
- this.initPromise = null;
2612
- this.reloadedFromOwnerStore = false;
2613
- if (userDid) await this.ready({ userDid });
3610
+ this.dropInMemoryState();
3611
+ if (caller) await this.ready(caller, { recordAccess: false });
2614
3612
  return { reloadedFromOwnerStore: this.reloadedFromOwnerStore };
2615
3613
  }
2616
3614
 
@@ -2624,7 +3622,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2624
3622
  userDid: string,
2625
3623
  sessionId: string,
2626
3624
  ): Promise<Record<string, unknown> | null> {
2627
- await this.ready({ userDid });
3625
+ await this.ready({ userDid }, { recordAccess: false });
2628
3626
  const session = await this.sessions!.getSession(sessionId);
2629
3627
  if (!session) return null;
2630
3628
  // Whether the thread's agent context was condensed: the summarization
@@ -2656,7 +3654,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2656
3654
  }
2657
3655
 
2658
3656
  async debugMemorySchema(userDid: string): Promise<MemorySchemaDebug> {
2659
- await this.ready({ userDid });
3657
+ await this.ready({ userDid }, { recordAccess: false });
2660
3658
  const ambient = this.ambient;
2661
3659
  const url = this.core.validatedEnv['MEMORY_MCP_URL'];
2662
3660
  if (!ambient || typeof url !== 'string' || url.length === 0) {
@@ -2719,7 +3717,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2719
3717
  lastVacuumAt: await this.ctx.storage.get<number>(META_LAST_VACUUM),
2720
3718
  legacyCleared:
2721
3719
  (await this.ctx.storage.get<boolean>(META_LEGACY_CLEARED)) === true,
2722
- lastAccessAt: await this.ctx.storage.get<number>(META_LAST_ACCESS),
3720
+ lastAccessAt: await this.knownLastAccess(),
2723
3721
  indexingInFlight: this.historyIndexer?.inFlightCount ?? 0,
2724
3722
  activeTurns: this.aborts.size,
2725
3723
  pendingTimers: timerTrackerInstalled()
@@ -2754,6 +3752,49 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2754
3752
  };
2755
3753
  }
2756
3754
 
3755
+ async topicDeliverable(
3756
+ identity: TurnIdentity,
3757
+ operationId: string,
3758
+ command: TopicDeliverableCommand,
3759
+ ): Promise<TopicDeliverableResult> {
3760
+ if (this.env.TOPIC_DELIVERABLES_ENABLED !== 'true')
3761
+ return { ok: false, status: 404, message: 'Not found.' };
3762
+ TopicOperationId.parse(operationId);
3763
+ // Like every request: waits out an idle eviction in progress and
3764
+ // records the access, so the idle tick re-reads this as activity.
3765
+ await this.ready(identity);
3766
+ const scheduler = this.taskScheduler;
3767
+ const db = this.db;
3768
+ if (!scheduler || !db) throw new Error('Task scheduler unavailable');
3769
+ // Start and cancel write the task row outside any turn; nothing else
3770
+ // would schedule their upload (and an unmarked write keeps the owner
3771
+ // copy "not current", which blocks eviction for good). A write that
3772
+ // committed before a later step threw (the alarm arm) counts too.
3773
+ const execute = (): Promise<TopicDeliverableResult> => {
3774
+ switch (command.action) {
3775
+ case 'start':
3776
+ return scheduler.startTopicDeliverable(
3777
+ operationId,
3778
+ TopicDeliverableRequestSchema.parse(command.request),
3779
+ );
3780
+ case 'read':
3781
+ return scheduler.readTopicDeliverable(operationId);
3782
+ case 'cancel':
3783
+ return scheduler.cancelTopicDeliverable(
3784
+ operationId,
3785
+ TopicDeliverableRequestSchema.parse(command.request),
3786
+ );
3787
+ }
3788
+ };
3789
+ const generation = db.writeGeneration;
3790
+ try {
3791
+ return await execute();
3792
+ } finally {
3793
+ if (this.db === db && db.writeGeneration !== generation)
3794
+ this.markDirty();
3795
+ }
3796
+ }
3797
+
2757
3798
  async tasksStatus(userDid?: string): Promise<{
2758
3799
  now: number;
2759
3800
  alarm: number | null;
@@ -2781,7 +3822,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2781
3822
  if (!this.taskScheduler) {
2782
3823
  const did =
2783
3824
  userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
2784
- if (did) await this.ready({ userDid: did });
3825
+ if (did) await this.ready({ userDid: did }, { recordAccess: false });
2785
3826
  }
2786
3827
  const records = this.taskScheduler
2787
3828
  ? await this.taskScheduler.surface.list()
@@ -2831,13 +3872,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2831
3872
  }
2832
3873
  if (auth.ucanDelegation)
2833
3874
  headers.set('x-ucan-delegation', auth.ucanDelegation);
2834
- return authenticate(headers, {
2835
- oracleDid: this.env.ORACLE_DID,
2836
- blocksyncUri: this.env.BLOCKSYNC_GRAPHQL_URL,
2837
- maxTtlSeconds: this.env.UCAN_AUTH_MAX_TTL_SECONDS
2838
- ? Number(this.env.UCAN_AUTH_MAX_TTL_SECONDS)
2839
- : undefined,
2840
- });
3875
+ return authenticate(headers, authConfigFromEnv(this.env));
2841
3876
  },
2842
3877
  sessionExists: async (userDid, sessionId) => {
2843
3878
  await this.ready({ userDid });
@@ -2845,7 +3880,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2845
3880
  },
2846
3881
  router: this.events,
2847
3882
  logger: console,
2848
- requestAlarm: (at) => this.requestAlarm(at),
3883
+ requestAlarm: (at) => this.ctx.waitUntil(this.requestAlarm(at)),
2849
3884
  // Node indexes a session into the memory engine when its last socket
2850
3885
  // disconnects (`WsService.removeClientConnection`); same trigger here.
2851
3886
  onSessionDrained: (sessionId) =>
@@ -2975,10 +4010,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2975
4010
  };
2976
4011
  const stream = body.stream !== false;
2977
4012
  if (!stream) {
2978
- this.replayToRoom(req, body.message, 'user');
4013
+ void this.replayToRoom(req, body.message, 'user');
2979
4014
  try {
2980
4015
  const result = await this.runTurn(req);
2981
- this.replayToRoom(req, result.text, 'oracle');
4016
+ void this.replayToRoom(req, result.text, 'oracle');
2982
4017
  // Node's `SendMessageResponse.message` is `{ type, content, id }`.
2983
4018
  const payload: Record<string, unknown> = {
2984
4019
  message: {
@@ -3011,13 +4046,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3011
4046
  // A streamed turn is a durable run: recorded, kept alive and resumable
3012
4047
  // (docs/plans/durable-runs.md). The response is one subscriber of the
3013
4048
  // run's buffer; the run itself outlives it.
3014
- this.replayToRoom(req, body.message, 'user');
4049
+ void this.replayToRoom(req, body.message, 'user');
3015
4050
  const { live, queued } = await this.runs!.begin({
3016
4051
  runId: crypto.randomUUID(),
3017
4052
  sessionId,
3018
4053
  requestId,
3019
4054
  client: 'portal',
3020
- request: JSON.stringify(
4055
+ request: this.runRequestJson(
4056
+ req,
3021
4057
  storedRunRequest(req, {
3022
4058
  timezone: body.timezone,
3023
4059
  tools: body.tools,
@@ -3112,6 +4148,23 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3112
4148
  });
3113
4149
  }
3114
4150
 
4151
+ /**
4152
+ * The stored request of a new run. When no admission handler can see the
4153
+ * turn (no plugin declares one, or the turn is out of admission's scope)
4154
+ * it is recorded as an agent turn from the start, which spares the first
4155
+ * attempt both rewrites of the row (`admitting`, then `agent`). Recovery
4156
+ * stays correct: a run the graph never checkpointed is retried with its
4157
+ * input whatever its disposition.
4158
+ */
4159
+ private runRequestJson(req: TurnRequest, stored: StoredRunRequest): string {
4160
+ const admissible =
4161
+ admissionApplies(req) &&
4162
+ this.core.plugins.some((plugin) => plugin.getRequestAdmission);
4163
+ return JSON.stringify(
4164
+ admissible ? stored : { ...stored, disposition: { kind: 'agent' } },
4165
+ );
4166
+ }
4167
+
3115
4168
  /**
3116
4169
  * One attempt of a run (the coordinator's `runAttempt` host hook): build
3117
4170
  * the agent for the stored request, stream the turn's frames into the
@@ -3125,6 +4178,101 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3125
4178
  ): Promise<RunOutcome> {
3126
4179
  const stored = JSON.parse(live.record.request) as StoredRunRequest;
3127
4180
  const req = stored.turn;
4181
+ if (req.client === 'channel') {
4182
+ await this.ctx.storage.sync();
4183
+ // The binding was checked as the request was admitted; a queued or
4184
+ // recovered attempt starts later and checks it again.
4185
+ if (live.attemptSource !== 'begin')
4186
+ await assertChannelAttemptAllowed(req.identity, this.env);
4187
+ requireChannelDelegation(
4188
+ this.delegations.get(req.identity.userDid),
4189
+ Math.floor(Date.now() / 1000),
4190
+ );
4191
+ }
4192
+ // An attempt that starts from `admitting` never reached the agent (the
4193
+ // previous incarnation stopped during admission), so the user's message
4194
+ // is not in the graph: the agent runs with its input, not from the
4195
+ // checkpoint.
4196
+ const freshInput = !resumed || stored.disposition?.kind === 'admitting';
4197
+ let announced = resumed;
4198
+ const announce = (): void => {
4199
+ if (announced) return;
4200
+ announced = true;
4201
+ live.buffer.push('run', {
4202
+ runId: live.runId,
4203
+ sessionId: req.sessionId,
4204
+ requestId: req.requestId,
4205
+ });
4206
+ };
4207
+ if (!stored.disposition && !resumed) {
4208
+ stored.disposition = { kind: 'admitting' };
4209
+ const request = JSON.stringify(stored);
4210
+ await this.runStore!.update(live.runId, { request });
4211
+ live.record.request = request;
4212
+ }
4213
+ if (!stored.disposition || stored.disposition.kind === 'admitting') {
4214
+ let admission: RequestAdmissionResult = { kind: 'pass' };
4215
+ try {
4216
+ if (stored.disposition?.kind === 'admitting' && admissionApplies(req))
4217
+ admission = await admitRequest(
4218
+ this.core.plugins,
4219
+ {
4220
+ user: {
4221
+ did: req.identity.userDid,
4222
+ matrixUserId: req.identity.matrixUserId ?? '',
4223
+ ucanDelegation: { raw: req.identity.ucanDelegation ?? '' },
4224
+ timezone: req.identity.timezone,
4225
+ currentTime: new Date().toISOString(),
4226
+ },
4227
+ session: {
4228
+ id: req.sessionId,
4229
+ client: req.client,
4230
+ requestId: req.requestId,
4231
+ roomId:
4232
+ req.roomId ??
4233
+ (await this.sessions!.getSession(req.sessionId))?.roomId,
4234
+ ...(req.roomKind ? { roomKind: req.roomKind } : {}),
4235
+ ...(req.eventId ? { eventId: req.eventId } : {}),
4236
+ ...(req.threadId ? { threadId: req.threadId } : {}),
4237
+ },
4238
+ message: req.message,
4239
+ metadata: admissionMetadata(req.metadata),
4240
+ signal: live.abort.signal,
4241
+ },
4242
+ {
4243
+ env: this.core.validatedEnv,
4244
+ timeoutMs: admissionTimeoutMs(this.core.validatedEnv),
4245
+ warn: (m) => console.warn(`[user-do] ${m}`),
4246
+ },
4247
+ );
4248
+ } catch (error) {
4249
+ return this.refuseTurn(live, req, error, announce, 'admission');
4250
+ }
4251
+ stored.disposition =
4252
+ admission.kind === 'handled'
4253
+ ? {
4254
+ kind: 'direct-read',
4255
+ text: admission.text,
4256
+ title: admission.title,
4257
+ messageId: `direct:${req.sessionId}:${req.requestId}:ai`,
4258
+ }
4259
+ : { kind: 'agent' };
4260
+ const request = JSON.stringify(stored);
4261
+ await this.runStore!.update(live.runId, { request });
4262
+ live.record.request = request;
4263
+ }
4264
+ if (stored.disposition.kind === 'direct-read') {
4265
+ try {
4266
+ return await this.runDirectRead(
4267
+ live,
4268
+ req,
4269
+ stored.disposition,
4270
+ announce,
4271
+ );
4272
+ } catch (error) {
4273
+ return this.refuseTurn(live, req, error, announce, 'direct read');
4274
+ }
4275
+ }
3128
4276
  const {
3129
4277
  agent,
3130
4278
  stateInput,
@@ -3133,6 +4281,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3133
4281
  byoNotice,
3134
4282
  byoProvider,
3135
4283
  toolOutputCapChars,
4284
+ delivery,
4285
+ usage,
3136
4286
  } = await this.prepareTurn(
3137
4287
  req,
3138
4288
  {
@@ -3141,13 +4291,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3141
4291
  model: req.model,
3142
4292
  tools: stored.tools,
3143
4293
  agActions: stored.agActions,
3144
- ...(resumed ? {} : { attachments: req.attachments }),
4294
+ ...(freshInput ? { attachments: req.attachments } : {}),
3145
4295
  },
3146
4296
  {
3147
4297
  runId: live.runId,
3148
4298
  abortController: live.abort,
3149
- resumed,
3150
- continuation: resumed ? live.continuation : null,
4299
+ resumed: !freshInput,
4300
+ continuation: freshInput ? null : live.continuation,
3151
4301
  },
3152
4302
  );
3153
4303
  const sessionId = req.sessionId;
@@ -3157,20 +4307,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3157
4307
  live.buffer.push(eventName, payload);
3158
4308
  },
3159
4309
  };
3160
- this.events.register(sessionId, sink);
3161
- if (byoNotice)
3162
- sink.emit('error', {
3163
- ...byoNotice,
3164
- sessionId,
3165
- requestId: req.requestId,
3166
- });
3167
- const events = agent.streamEvents(resumed ? null : stateInput, {
3168
- ...config,
3169
- version: 'v2',
3170
- });
3171
4310
  const capture: BaseMessage[] = [];
3172
4311
  let completed = false;
4312
+ // Everything after `prepareTurn` runs inside the try: its cleanups
4313
+ // (the turn's deadline timer among them) run however the attempt ends.
3173
4314
  try {
4315
+ this.events.register(sessionId, sink);
4316
+ if (byoNotice)
4317
+ sink.emit('error', {
4318
+ ...byoNotice,
4319
+ sessionId,
4320
+ requestId: req.requestId,
4321
+ });
4322
+ const events = agent.streamEvents(freshInput ? stateInput : null, {
4323
+ ...config,
4324
+ version: 'v2',
4325
+ });
3174
4326
  const outcome = await runTurnFrames({
3175
4327
  events: tapMessages(events, capture),
3176
4328
  sink: live.buffer,
@@ -3188,8 +4340,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3188
4340
  onFrame: () => this.runs?.touchKeepAlive(),
3189
4341
  onComplete: async () => {
3190
4342
  completed = true;
3191
- this.replayToRoom(req, lastAiText(capture), 'oracle');
3192
- await this.afterTurn(sessionId, capture);
4343
+ if (req.client !== 'channel')
4344
+ void this.replayToRoom(req, lastAiText(capture), 'oracle');
4345
+ await this.afterTurn(
4346
+ sessionId,
4347
+ capture,
4348
+ { kind: 'agent' },
4349
+ live.abort.signal,
4350
+ );
3193
4351
  },
3194
4352
  onError: (err) => {
3195
4353
  console.error(`[user-do] turn ${req.requestId} failed:`, err);
@@ -3204,8 +4362,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3204
4362
  status: 'done' as const,
3205
4363
  })),
3206
4364
  );
3207
- const text = completed ? lastAiText(capture) : outcome.fullText;
3208
4365
  const messageId = completed ? lastAiMessageId(capture) : undefined;
4366
+ // A chat surface also gets its reply as a plan. `text` stays the
4367
+ // model's own text on every surface: stored results (task runs, the
4368
+ // run record, the Matrix ledger) never hold a chat rendering whose
4369
+ // links expire.
4370
+ const plan =
4371
+ completed && delivery.kind === 'chat'
4372
+ ? await this.replyPlanOf(live, req, capture, delivery)
4373
+ : undefined;
4374
+ const text =
4375
+ (live.continuation ?? '') +
4376
+ (completed ? lastAiText(capture) : outcome.fullText);
3209
4377
  return {
3210
4378
  status:
3211
4379
  outcome.status === 'completed'
@@ -3213,10 +4381,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3213
4381
  : outcome.status === 'aborted'
3214
4382
  ? 'aborted'
3215
4383
  : 'failed',
3216
- text: (live.continuation ?? '') + text,
4384
+ text,
4385
+ ...(plan ? { plan } : {}),
3217
4386
  ...(messageId ? { messageId } : {}),
3218
4387
  toolCalls,
3219
4388
  ...(outcome.status === 'failed' ? { error: outcome.error } : {}),
4389
+ // Written with the terminal status: one row update per run.
4390
+ usage: usage(),
3220
4391
  };
3221
4392
  } finally {
3222
4393
  this.events.unregister(sessionId, sink);
@@ -3226,6 +4397,178 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3226
4397
  }
3227
4398
  }
3228
4399
 
4400
+ /**
4401
+ * The Reply Plan of a finished chat-surface run, built once from the
4402
+ * turn's messages and kept with the run (`/channels/turn` polls read it
4403
+ * back). A long step becomes an artefact when storage is configured; an
4404
+ * artefact that fails falls back to plain messages.
4405
+ */
4406
+ private async replyPlanOf(
4407
+ live: LiveRun,
4408
+ req: TurnRequest,
4409
+ capture: BaseMessage[],
4410
+ delivery: Extract<DeliveryProfile, { kind: 'chat' }>,
4411
+ ): Promise<ReplyPlan> {
4412
+ const artifacts = this.artifacts?.canShare ? this.artifacts : null;
4413
+ const { steps, toolResults } = turnSteps(capture);
4414
+ const draft = draftReplyPlan({
4415
+ steps,
4416
+ toolResults,
4417
+ continuation: live.continuation,
4418
+ limits: delivery.limits,
4419
+ canSpill: artifacts !== null,
4420
+ });
4421
+ const plan = await materializeReplyPlan(draft, {
4422
+ limits: delivery.limits,
4423
+ createSpill: async (key, spill) => {
4424
+ if (!artifacts)
4425
+ throw new Error('artifacts: storage is not configured');
4426
+ return artifacts.create({
4427
+ artifactId: await artifactIdFor(live.runId, `spill:${key}`),
4428
+ sessionId: req.sessionId,
4429
+ runId: live.runId,
4430
+ title: spill.title,
4431
+ content: spill.markdown,
4432
+ });
4433
+ },
4434
+ onSpillError: (error) =>
4435
+ console.warn(
4436
+ `[delivery] run ${live.runId}: could not store a long reply as an artefact; sending it as messages: ${error instanceof Error ? error.message : String(error)}`,
4437
+ ),
4438
+ });
4439
+ await this.runStore!.setPlan(live.runId, JSON.stringify(plan));
4440
+ console.log(
4441
+ `[delivery] run ${live.runId}: ${delivery.surface} reply in ${plan.parts.length} parts (${plan.parts.filter((p) => p.kind === 'artifact').length} artefacts)`,
4442
+ );
4443
+ return plan;
4444
+ }
4445
+
4446
+ private async runDirectRead(
4447
+ live: LiveRun,
4448
+ req: TurnRequest,
4449
+ disposition: Extract<RequestDisposition, { kind: 'direct-read' }>,
4450
+ announce: () => void,
4451
+ ): Promise<RunOutcome> {
4452
+ live.abort.signal.throwIfAborted();
4453
+ announce();
4454
+ const sessions = this.sessions!;
4455
+ if (
4456
+ req.client === 'matrix' &&
4457
+ !(await sessions.getSession(req.sessionId))
4458
+ ) {
4459
+ await sessions.createSession({
4460
+ sessionId: req.sessionId,
4461
+ roomId: req.roomId,
4462
+ oracleName: this.core.identity.name,
4463
+ oracleDid: this.env.ORACLE_DID,
4464
+ oracleEntityDid: this.core.identity.entityDid,
4465
+ });
4466
+ }
4467
+ const kwargs = {
4468
+ timestamp: new Date().toISOString(),
4469
+ oracleName: this.core.identity.name,
4470
+ msgFromMatrixRoom: req.client === 'matrix',
4471
+ ...(req.client === 'matrix'
4472
+ ? {
4473
+ senderDid: req.identity.userDid,
4474
+ senderMatrixUserId: req.identity.matrixUserId,
4475
+ senderDisplayName: req.senderDisplayName ?? req.identity.userDid,
4476
+ threadId: req.sessionId,
4477
+ eventId: req.eventId,
4478
+ }
4479
+ : {}),
4480
+ };
4481
+ const messages = [
4482
+ new HumanMessage({
4483
+ id: `direct:${req.sessionId}:${req.requestId}:human`,
4484
+ content:
4485
+ req.client === 'matrix' && req.roomKind === 'group'
4486
+ ? prefixSpeaker(
4487
+ req.message,
4488
+ req.senderDisplayName ??
4489
+ req.identity.matrixUserId ??
4490
+ req.identity.userDid,
4491
+ )
4492
+ : req.message,
4493
+ additional_kwargs: kwargs,
4494
+ }),
4495
+ new AIMessage({
4496
+ id: disposition.messageId,
4497
+ content: disposition.text,
4498
+ additional_kwargs: {
4499
+ timestamp: kwargs.timestamp,
4500
+ oracleName: kwargs.oracleName,
4501
+ msgFromMatrixRoom: kwargs.msgFromMatrixRoom,
4502
+ },
4503
+ }),
4504
+ ];
4505
+ await this.saver!.appendTurnMessages(req.sessionId, messages);
4506
+ // An abort that lands now ends the run as `aborted`; the reply stays
4507
+ // in the transcript (the Matrix mirror and the title are skipped).
4508
+ live.abort.signal.throwIfAborted();
4509
+ await this.afterTurn(req.sessionId, messages, disposition);
4510
+ // A channel's reply reaches the room through the channel's own
4511
+ // delivery (`onRunEnded`), as on the agent path; its mirror is the
4512
+ // required kind, whose failure would surface here unhandled.
4513
+ if (req.client !== 'channel')
4514
+ void this.replayToRoom(req, disposition.text, 'oracle');
4515
+ const delivered = live.continuation ?? '';
4516
+ const content = disposition.text.startsWith(delivered)
4517
+ ? disposition.text.slice(delivered.length)
4518
+ : disposition.text;
4519
+ if (content)
4520
+ live.buffer.push('message', { content, timestamp: kwargs.timestamp });
4521
+ live.buffer.push('done', {
4522
+ runId: live.runId,
4523
+ messageId: disposition.messageId,
4524
+ });
4525
+ return {
4526
+ status: 'finished',
4527
+ text: disposition.text,
4528
+ messageId: disposition.messageId,
4529
+ toolCalls: [],
4530
+ };
4531
+ }
4532
+
4533
+ /**
4534
+ * End a turn whose admission or direct read did not complete. The client
4535
+ * gets the same terminal frames as a failed agent turn (`error`, then
4536
+ * `done`) or, after a user abort or a superseding message, `done` with
4537
+ * `aborted`. The handler's own message goes to the log only.
4538
+ */
4539
+ private refuseTurn(
4540
+ live: LiveRun,
4541
+ req: TurnRequest,
4542
+ error: unknown,
4543
+ announce: () => void,
4544
+ stage: 'admission' | 'direct read',
4545
+ ): RunOutcome {
4546
+ announce();
4547
+ if (live.abort.signal.aborted) {
4548
+ live.buffer.push('done', { runId: live.runId, aborted: true });
4549
+ return { status: 'aborted', text: live.continuation ?? '' };
4550
+ }
4551
+ console.error(
4552
+ `[user-do] turn ${req.requestId}: ${stage} failed: ${error instanceof Error ? error.message : String(error)}`,
4553
+ );
4554
+ live.buffer.push('error', {
4555
+ error: 'This request could not be answered. Please try again.',
4556
+ kind: 'request_admission',
4557
+ source: 'platform',
4558
+ retryable: true,
4559
+ sessionId: req.sessionId,
4560
+ requestId: req.requestId,
4561
+ runId: live.runId,
4562
+ timestamp: new Date().toISOString(),
4563
+ });
4564
+ live.buffer.push('done', { runId: live.runId, failed: true });
4565
+ return {
4566
+ status: 'failed',
4567
+ text: live.continuation ?? '',
4568
+ error: new Error(`request ${stage} failed`),
4569
+ };
4570
+ }
4571
+
3229
4572
  /** Latest checkpoint id of a session — recovery's progress marker. */
3230
4573
  private async checkpointIdOf(sessionId: string): Promise<string | null> {
3231
4574
  const tuple = await this.saver
@@ -3243,6 +4586,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3243
4586
  record: RunRecord,
3244
4587
  outcome: RunOutcome,
3245
4588
  ): Promise<void> {
4589
+ if (record.client === 'channel' && outcome.status === 'finished') {
4590
+ const stored = JSON.parse(record.request) as StoredRunRequest;
4591
+ // Without the handle (a reset dropped it mid-run) the next poll of
4592
+ // the request delivers the reply instead.
4593
+ await this.channelTurns?.deliverReply(
4594
+ stored.turn,
4595
+ record.runId,
4596
+ outcome.text,
4597
+ );
4598
+ }
3246
4599
  if (!record.taskRunId || !this.taskScheduler) return;
3247
4600
  if (this.taskScheduler.isRunActive(record.taskRunId)) return;
3248
4601
  if (outcome.status === 'finished')
@@ -3288,7 +4641,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3288
4641
  const window = await this.contextWindows.resolve(modelId, {
3289
4642
  ...(byoProvider ? { byoProvider } : {}),
3290
4643
  });
3291
- const budget = contextBudgetFor(window, contextKnobs(this.env, console));
4644
+ const budget = contextBudgetFor(
4645
+ window,
4646
+ contextKnobs(this.env, console),
4647
+ await this.contextWindows.resolve(
4648
+ llm && isProviderAdapter(llm)
4649
+ ? llm.modelForRole('routing')
4650
+ : OPENROUTER_MODEL_MAP.routing,
4651
+ ),
4652
+ );
3292
4653
  const stats = (await this.resultStore?.stats()) ?? {
3293
4654
  rows: 0,
3294
4655
  sqliteBytes: 0,
@@ -3403,7 +4764,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3403
4764
  async runsStatus(): Promise<RunsStatus> {
3404
4765
  const userDid =
3405
4766
  this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
3406
- if (!this.db && userDid) await this.ready({ userDid });
4767
+ if (!this.db && userDid)
4768
+ await this.ready({ userDid }, { recordAccess: false });
3407
4769
  const store = this.runStore;
3408
4770
  const records = store ? await store.listRecent(50) : [];
3409
4771
  const runs: RunsStatus['runs'] = [];
@@ -3425,12 +4787,14 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3425
4787
  nextAttemptAt: record.nextAttemptAt,
3426
4788
  error: record.error,
3427
4789
  taskRunId: record.taskRunId,
4790
+ usage: parseUsage(record.usage),
3428
4791
  });
3429
4792
  }
3430
4793
  return {
3431
4794
  config: { ...this.runConfig },
3432
4795
  live: this.runs?.snapshot() ?? [],
3433
4796
  runs,
4797
+ claims: store ? await store.listClaims() : [],
3434
4798
  };
3435
4799
  }
3436
4800
 
@@ -3443,25 +4807,39 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3443
4807
  * the gateway (its whole-buffer download grew the crypto WASM heap for
3444
4808
  * good, and a hash mismatch on the far side of the RPC would only reach
3445
4809
  * this object as a disconnect).
4810
+ *
4811
+ * The gateway bounds each transfer to the attachment download deadline,
4812
+ * from opening it to the last byte. An `AbortSignal` cannot cross the
4813
+ * Durable Object RPC, so the turn's abort acts on this side instead:
4814
+ * `readBytesCapped` cancels the stream it reads, and the cancel travels
4815
+ * back over the RPC stream to stop the gateway's download.
3446
4816
  */
3447
4817
  private matrixMediaSource(): MatrixMediaSource {
4818
+ const deadline = { timeoutMs: DOWNLOAD_TIMEOUT_MS };
3448
4819
  return {
3449
- downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE) =>
4820
+ downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE, signal) =>
3450
4821
  readBytesCapped(
3451
- await this.gateway.downloadMxcMediaStream(mxc),
4822
+ await this.gateway.downloadMxcMediaStream(mxc, deadline),
3452
4823
  maxBytes,
4824
+ signal,
3453
4825
  ),
3454
- downloadEvent: async (roomId, eventId, maxBytes = MAX_FILE_SIZE) => {
4826
+ downloadEvent: async (
4827
+ roomId,
4828
+ eventId,
4829
+ maxBytes = MAX_FILE_SIZE,
4830
+ signal,
4831
+ ) => {
3455
4832
  const media = await this.gateway.downloadEventMediaStream(
3456
4833
  roomId,
3457
4834
  eventId,
4835
+ deadline,
3458
4836
  );
3459
4837
  if (!media) return null;
3460
4838
  const plain = media.file
3461
4839
  ? media.stream.pipeThrough(createAttachmentDecryptor(media.file))
3462
4840
  : media.stream;
3463
4841
  return {
3464
- bytes: await readBytesCapped(plain, maxBytes),
4842
+ bytes: await readBytesCapped(plain, maxBytes, signal),
3465
4843
  ...(media.mimetype ? { mimetype: media.mimetype } : {}),
3466
4844
  ...(media.filename ? { filename: media.filename } : {}),
3467
4845
  };
@@ -3523,6 +4901,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3523
4901
  roomId: input.roomId,
3524
4902
  signal: input.signal,
3525
4903
  logger: console,
4904
+ ...(this.attachmentTextCache
4905
+ ? { cache: this.attachmentTextCache.forSession(input.sessionId) }
4906
+ : {}),
3526
4907
  });
3527
4908
  return { meta, view };
3528
4909
  },
@@ -3626,23 +5007,73 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3626
5007
  };
3627
5008
  }
3628
5009
 
5010
+ /**
5011
+ * The model that summarises the turn's history (the `routing` role): its
5012
+ * window bounds what the summariser is handed.
5013
+ */
5014
+ private summaryModelId(
5015
+ platform: LlmAdapter,
5016
+ byoTurn: ByoTurnState | null,
5017
+ ): string {
5018
+ if (byoTurn)
5019
+ return (
5020
+ byoModelForRole(
5021
+ byoTurn.credential.provider,
5022
+ 'routing',
5023
+ byoTurn.mainModelId,
5024
+ ) ?? OPENROUTER_MODEL_MAP.routing
5025
+ );
5026
+ return isProviderAdapter(platform)
5027
+ ? platform.modelForRole('routing')
5028
+ : OPENROUTER_MODEL_MAP.routing;
5029
+ }
5030
+
5031
+ /**
5032
+ * Build one attempt's agent and input (`buildTurn`). A build that fails
5033
+ * part-way leaves nothing behind: the cleanups it registered run (the
5034
+ * turn's deadline timer would otherwise keep the object resident for
5035
+ * the whole turn limit and abort a finished run later) and the session's
5036
+ * abort entry is released.
5037
+ */
3629
5038
  private async prepareTurn(
3630
5039
  req: TurnRequest,
3631
- body: Pick<
3632
- TurnBody,
3633
- 'message' | 'timezone' | 'model' | 'tools' | 'agActions' | 'attachments'
3634
- >,
3635
- run: {
3636
- runId: string;
3637
- abortController: AbortController;
3638
- /** A recovery attempt: no new input, the graph continues from the checkpoint. */
3639
- resumed: boolean;
3640
- /** The reply text the user already received (resumed attempts). */
3641
- continuation: string | null;
3642
- },
5040
+ body: TurnBuildBody,
5041
+ run: TurnBuildRun,
5042
+ ) {
5043
+ const turnDisposables = new Set<() => void | Promise<void>>();
5044
+ try {
5045
+ return await this.buildTurn(req, body, run, turnDisposables);
5046
+ } catch (err) {
5047
+ await this.runTurnDisposables(turnDisposables);
5048
+ if (this.aborts.get(req.sessionId) === run.abortController)
5049
+ this.aborts.delete(req.sessionId);
5050
+ throw err;
5051
+ }
5052
+ }
5053
+
5054
+ private async buildTurn(
5055
+ req: TurnRequest,
5056
+ body: TurnBuildBody,
5057
+ run: TurnBuildRun,
5058
+ turnDisposables: Set<() => void | Promise<void>>,
3643
5059
  ) {
5060
+ if (req.executionProfile && !this.taskScheduler)
5061
+ throw new Error('Task scheduler unavailable');
5062
+ await this.taskScheduler?.assertTurnProfile(req);
5063
+ const suppliedContextOnly =
5064
+ req.executionProfile === 'supplied-context-markdown';
5065
+ if (
5066
+ suppliedContextOnly &&
5067
+ (body.attachments?.length ||
5068
+ body.tools?.length ||
5069
+ body.agActions?.length)
5070
+ ) {
5071
+ throw new Error('Supplied-context task accepts plain text only');
5072
+ }
3644
5073
  const core = this.core;
3645
5074
  const baseAmbient = this.ambient!;
5075
+ // One instant per turn: the prompt's date and the message's time note.
5076
+ const turnAt = new Date();
3646
5077
  const saver = this.saver!;
3647
5078
  const sessions = this.sessions!;
3648
5079
 
@@ -3729,6 +5160,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3729
5160
  const contextBudget = contextBudgetFor(
3730
5161
  windowResolution,
3731
5162
  contextKnobs(this.env, console),
5163
+ await this.contextWindows.resolve(
5164
+ this.summaryModelId(baseAmbient.llm, byoTurn),
5165
+ byoTurn ? { byoProvider: byoTurn.provider } : {},
5166
+ ),
3732
5167
  );
3733
5168
  if (
3734
5169
  req.client === 'matrix' &&
@@ -3753,15 +5188,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3753
5188
  // memory engine rejects calls that carry no `x-room-id` (as a generic
3754
5189
  // "invalid token"). Matrix-ingress turns bring the room; HTTP turns
3755
5190
  // take it from the session row or resolve the user↔oracle room alias.
3756
- const sessionRoomId =
3757
- req.roomId ??
3758
- (await sessions.getSession(req.sessionId))?.roomId ??
3759
- (
3760
- await this.gateway
3761
- .resolveUserRoom(req.identity.userDid)
3762
- .catch(() => null)
3763
- )?.roomId;
3764
- if (!sessionRoomId) {
5191
+ const sessionRoomId = suppliedContextOnly
5192
+ ? undefined
5193
+ : (req.roomId ??
5194
+ (await sessions.getSession(req.sessionId))?.roomId ??
5195
+ (
5196
+ await this.gateway
5197
+ .resolveUserRoom(req.identity.userDid)
5198
+ .catch(() => null)
5199
+ )?.roomId);
5200
+ if (!suppliedContextOnly && !sessionRoomId) {
3765
5201
  console.warn(
3766
5202
  `[user-do] no oracle room resolved for ${req.identity.userDid}; room-scoped plugins (memory) will be unavailable this turn`,
3767
5203
  );
@@ -3784,7 +5220,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3784
5220
  // on turn 1, exactly like the Node AgentBuilder. Best-effort: a
3785
5221
  // preferences read failure never fails the turn.
3786
5222
  const userPreferences =
3787
- sessionRoomId && this.preferences
5223
+ !suppliedContextOnly && sessionRoomId && this.preferences
3788
5224
  ? await this.preferences.get(sessionRoomId).catch((err: unknown) => {
3789
5225
  console.warn(
3790
5226
  `[user-do] could not load user preferences for ${sessionRoomId}: ${err instanceof Error ? err.message : String(err)}`,
@@ -3834,18 +5270,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3834
5270
  // `retainAttachmentPayloads`); the tool fetches one again on demand.
3835
5271
  // Cleanups registered by tools for THIS turn (MCP clients …): run once
3836
5272
  // the turn ends, however it ends — see `RuntimeContext.onTurnEnd`.
3837
- const turnDisposables = new Set<() => void | Promise<void>>();
3838
5273
  abortController.signal.addEventListener('abort', () => {
3839
5274
  void this.runTurnDisposables(turnDisposables);
3840
5275
  });
3841
- const attachmentAccess = await this.attachmentViewSurface({
3842
- sessionId: req.sessionId,
3843
- roomId: sessionRoomId,
3844
- model: attachmentModel,
3845
- ambientLlm: baseAmbient.llm,
3846
- platform: core.llm,
3847
- signal: abortController.signal,
3848
- });
5276
+ const attachmentAccess = suppliedContextOnly
5277
+ ? undefined
5278
+ : await this.attachmentViewSurface({
5279
+ sessionId: req.sessionId,
5280
+ roomId: sessionRoomId,
5281
+ model: attachmentModel,
5282
+ ambientLlm: baseAmbient.llm,
5283
+ platform: core.llm,
5284
+ signal: abortController.signal,
5285
+ });
3849
5286
 
3850
5287
  const existing = await saver.getTupleWithoutMessages({
3851
5288
  configurable: { thread_id: req.sessionId },
@@ -3853,29 +5290,153 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3853
5290
  const priorState = existing?.checkpoint.channel_values ?? {};
3854
5291
  // Request metadata (editor room, space, session run, entity) → state,
3855
5292
  // by the Node agent-builder's rules (see turn-metadata.ts).
3856
- const meta = parseTurnMetadata(req.metadata);
3857
- const priorMeta = priorMetadataState(priorState);
5293
+ const meta = parseTurnMetadata(
5294
+ suppliedContextOnly ? undefined : req.metadata,
5295
+ );
5296
+ const priorMeta = priorMetadataState(
5297
+ suppliedContextOnly ? {} : priorState,
5298
+ );
5299
+
5300
+ // Capability router: predict the on-demand plugin this message needs
5301
+ // and preload it for THIS turn only. `on` is awaited here, ahead of the
5302
+ // first model call; `shadow` returns at once and logs its verdict when
5303
+ // it lands. The preload never enters `stateInput` — the checkpointed
5304
+ // `loadedPlugins` channel is written by `load_capability` alone.
5305
+ const priorLoaded: ReadonlySet<string> = new Set(
5306
+ priorMeta.loadedPlugins ?? [],
5307
+ );
5308
+ this.shadowRoutes.delete(req.sessionId);
5309
+ // LangSmith: resolved before the router so its Decision, which runs
5310
+ // ahead of the graph, lands on the turn's tracer under the same
5311
+ // allowlist gate. Metadata is attached unconditionally (inert without a
5312
+ // tracer); the explicit tracer only when this turn is traced (global
5313
+ // switch or per-DID allowlist — see `resolveLangsmithTracing`).
5314
+ // A supplied-context turn is never traced: its source and output stay
5315
+ // off third-party services whatever the tracing switches say.
5316
+ const tracing: LangsmithTracingDecision = suppliedContextOnly
5317
+ ? { metadata: {} }
5318
+ : resolveLangsmithTracing({
5319
+ userDid: req.identity.userDid,
5320
+ client: req.client,
5321
+ env: langsmithEnvFromWorkerEnv(this.env),
5322
+ });
5323
+ // The delegation the whole turn reads: the router's candidates here,
5324
+ // every capability check of the build and its tools below.
5325
+ const ucanDelegation = await this.turnDelegation(req.identity);
5326
+ const preloadedPlugins =
5327
+ !suppliedContextOnly && this.capabilityRouter
5328
+ ? await this.capabilityRouter({
5329
+ mode: capabilityRouterMode(core.validatedEnv.CAPABILITY_ROUTER),
5330
+ manifests: core.registries.manifests.collect(),
5331
+ loaded: priorLoaded,
5332
+ hidden: () =>
5333
+ bootHiddenPlugins({
5334
+ registries: core.registries,
5335
+ buildCtx: core.buildCtx(),
5336
+ has: (resource, action) =>
5337
+ ambient.ucan.hasCapability(
5338
+ ucanDelegation,
5339
+ resource,
5340
+ action,
5341
+ ),
5342
+ }),
5343
+ hasCapability: (resource, action) =>
5344
+ ambient.ucan.hasCapability(ucanDelegation, resource, action),
5345
+ text: body.message,
5346
+ requestId: req.requestId,
5347
+ signal: abortController.signal,
5348
+ trace: {
5349
+ ...(tracing.callbacks && { callbacks: tracing.callbacks }),
5350
+ // `thread_id` matches the graph run's, so LangSmith's thread
5351
+ // view groups the router span with the turn.
5352
+ metadata: {
5353
+ ...tracing.metadata,
5354
+ thread_id: req.sessionId,
5355
+ request_id: req.requestId,
5356
+ },
5357
+ },
5358
+ onShadowVerdict: (wouldPreload) =>
5359
+ this.shadowRoutes.set(req.sessionId, {
5360
+ requestId: req.requestId,
5361
+ priorLoaded,
5362
+ wouldPreload,
5363
+ }),
5364
+ })
5365
+ : undefined;
3858
5366
 
3859
5367
  // Host page-context / safety-guardrail hooks, resolved against this
3860
5368
  // object's ambient services (see `OracleWorkerHooks`).
3861
- const hostRoomTitle = opts.hooks?.getRoomTitle;
3862
- const hostSafetyModel = opts.hooks?.safetyModel;
5369
+ const hostRoomTitle = suppliedContextOnly
5370
+ ? undefined
5371
+ : opts.hooks?.getRoomTitle;
5372
+ const hostSafetyModel = suppliedContextOnly
5373
+ ? undefined
5374
+ : opts.hooks?.safetyModel;
3863
5375
 
3864
5376
  // The client-declared tool surface: this body's, else the thread's
3865
5377
  // checkpointed one (run-request.ts).
3866
- const surface = clientSurfaceFor(body, priorState);
5378
+ const surface = suppliedContextOnly
5379
+ ? clientSurfaceFor({}, {})
5380
+ : clientSurfaceFor(body, priorState);
3867
5381
 
3868
5382
  // Write-ahead tool marks + the resume policy (tool-marks.ts). The
3869
5383
  // effect map is filled from the build below; the closure reads it at
3870
5384
  // call time, so the middleware can be created before the agent.
3871
5385
  const toolEffects = new Map<string, ToolEffect>();
5386
+ const subAgentToolNames = new Set<string>();
5387
+ const effectOf = (name: string): ToolEffect =>
5388
+ toolEffects.get(name) ?? toolEffectOf({ name });
3872
5389
  const marksMiddleware = createToolMarksMiddleware({
3873
5390
  runId: run.runId,
3874
5391
  store: this.runStore!,
3875
- effectOf: (name) => toolEffects.get(name) ?? toolEffectOf({ name }),
5392
+ effectOf,
3876
5393
  continuation: run.continuation,
3877
5394
  logger: console,
3878
5395
  });
5396
+ // The turn's budget (turn-budget.ts): model calls are charged by the
5397
+ // metered adapter every model of this turn comes from, tool calls by
5398
+ // the execution middleware below, and the deadline aborts the run
5399
+ // with the limit as the reason (the stream reports it as a terminal
5400
+ // error, not as a user abort).
5401
+ const turnLimits = turnLimitsFromEnv(core.validatedEnv);
5402
+ const budget = new TurnBudget(turnLimits);
5403
+ const meteredLlm = budgetedLlm(ambient.llm, {
5404
+ budget,
5405
+ outputReserveTokens: contextBudget.outputReserveTokens,
5406
+ signal: abortController.signal,
5407
+ });
5408
+ const deadline = setTimeout(() => {
5409
+ if (abortController.signal.aborted) return;
5410
+ console.warn(
5411
+ `[harness] turn ${req.requestId} reached its deadline (${turnLimits.durationMs} ms); aborting`,
5412
+ );
5413
+ abortController.abort(
5414
+ new HarnessLimitError(
5415
+ 'budget_exhausted',
5416
+ 'time',
5417
+ 'The turn reached its time limit. Completed work is preserved; no further work was started.',
5418
+ ),
5419
+ );
5420
+ }, turnLimits.durationMs);
5421
+ turnDisposables.add(async () => {
5422
+ clearTimeout(deadline);
5423
+ const usage = budget.snapshot();
5424
+ console.log(
5425
+ `[harness] turn ${req.requestId} usage: ~${usage.tokens} tokens (${usage.reportedTokens} reported over ${usage.modelCalls} model calls), ${usage.toolAttempts} tool attempts, ${usage.elapsedMs} ms`,
5426
+ );
5427
+ });
5428
+ const laneOf = (name: string): ToolLane =>
5429
+ subAgentToolNames.has(name) ? 'subagent' : effectOf(name);
5430
+ const executionMiddleware = createToolExecutionMiddleware({
5431
+ budget,
5432
+ scheduler: this.toolScheduler,
5433
+ laneOf,
5434
+ runId: run.runId,
5435
+ sessionId: req.sessionId,
5436
+ claims: this.runStore!,
5437
+ signal: abortController.signal,
5438
+ logger: console,
5439
+ });
3879
5440
  // Every tool result above the budget's cap is saved whole and handed
3880
5441
  // to the model as head + tail + handle (result-cap.ts); applied to
3881
5442
  // sub-agents too through `toolMiddlewares`.
@@ -3892,15 +5453,45 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3892
5453
  // inside the wrapper (already under the cap by the time it runs).
3893
5454
  const capMiddleware = createResultCapMiddleware(resultCap);
3894
5455
 
5456
+ // Chat delivery: a channel or Matrix turn replies as short messages and
5457
+ // may hand long content to `create_artifact` (bound only when artefact
5458
+ // storage is configured). The Portal streams as it always has.
5459
+ const delivery = resolveDeliveryProfile(req, core.delivery);
5460
+ const artifacts =
5461
+ delivery.kind === 'chat' && this.artifacts?.canShare
5462
+ ? this.artifacts
5463
+ : null;
5464
+ const turnTools = artifacts
5465
+ ? [
5466
+ {
5467
+ tool: buildCreateArtifactTool(async (input) =>
5468
+ artifacts.create({
5469
+ artifactId: await artifactIdFor(run.runId, input.source),
5470
+ sessionId: req.sessionId,
5471
+ runId: run.runId,
5472
+ title: input.title,
5473
+ content: input.content,
5474
+ }),
5475
+ ),
5476
+ returnDirect: true,
5477
+ },
5478
+ ]
5479
+ : [];
5480
+
3895
5481
  const built = await createMainAgent({
5482
+ executionProfile: req.executionProfile,
3896
5483
  registries: core.registries,
3897
5484
  identity: core.identity,
3898
5485
  config: core.validatedEnv,
3899
5486
  availablePlugins: core.availablePlugins,
5487
+ preloadedPlugins,
3900
5488
  byoProvider: byoTurn?.provider,
3901
5489
  contextBudget,
5490
+ turnBudget: budget,
5491
+ delivery,
3902
5492
  ambient: {
3903
5493
  ...ambient,
5494
+ llm: meteredLlm,
3904
5495
  attachments: attachmentAccess,
3905
5496
  onTurnEnd: (dispose) => turnDisposables.add(dispose),
3906
5497
  },
@@ -3913,7 +5504,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3913
5504
  // Applied to the main agent AND every sub-agent: a sub-agent's
3914
5505
  // own tool calls are marked and capped too.
3915
5506
  toolMiddlewares: [marksMiddleware, capMiddleware],
5507
+ // Budget, scheduling and write claims, innermost around each tool
5508
+ // call (main agent and sub-agents alike).
5509
+ toolExecution: executionMiddleware,
3916
5510
  resultCap,
5511
+ turnTools,
3917
5512
  onContextOverflow: (error) =>
3918
5513
  this.contextWindows.learnFromError(mainModelId, error, {
3919
5514
  ...(byoTurn ? { byoProvider: byoTurn.provider } : {}),
@@ -3941,9 +5536,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3941
5536
  req.identity.matrixUserId ??
3942
5537
  (await this.resolveMatrixUserId(req.identity.userDid)) ??
3943
5538
  '',
3944
- ucanDelegation: { raw: req.identity.ucanDelegation ?? '' },
5539
+ ucanDelegation,
3945
5540
  timezone: req.identity.timezone,
3946
- currentTime: new Date().toISOString(),
5541
+ currentTime: turnAt.toISOString(),
3947
5542
  },
3948
5543
  session: {
3949
5544
  id: req.sessionId,
@@ -3954,7 +5549,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3954
5549
  model: effectiveModel,
3955
5550
  },
3956
5551
  state: {
3957
- ...priorState,
5552
+ ...(suppliedContextOnly ? {} : priorState),
3958
5553
  userPreferences,
3959
5554
  ...metadataBuildState(meta, priorMeta),
3960
5555
  // The client-declared surface: the portal and AG-UI plugins turn
@@ -3969,6 +5564,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3969
5564
  const { agent, context } = built;
3970
5565
  for (const [name, effect] of built.toolEffects)
3971
5566
  toolEffects.set(name, effect);
5567
+ for (const name of built.subAgentToolNames) subAgentToolNames.add(name);
3972
5568
 
3973
5569
  const attachmentKwargs =
3974
5570
  prepared && prepared.metas.length > 0
@@ -3994,19 +5590,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3994
5590
  }
3995
5591
  : {};
3996
5592
  const rawContent = prepared ? prepared.content : body.message;
3997
- const content =
5593
+ const spoken =
3998
5594
  req.client === 'matrix' && req.roomKind === 'group'
3999
5595
  ? prefixSpeaker(rawContent, speakerName)
4000
5596
  : rawContent;
5597
+ // The exact time rides on the message (outermost, after the speaker
5598
+ // prefix and the attachment blocks), so the system prompt carries the
5599
+ // date only and stays identical across the session's turns. A
5600
+ // supplied-context task's input stays exactly what was supplied.
5601
+ const timeNote = suppliedContextOnly
5602
+ ? undefined
5603
+ : renderTurnTimeNote(turnAt, req.identity.timezone);
5604
+ const content = timeNote ? withTurnTimeNote(spoken, timeNote) : spoken;
4001
5605
  const stateInput = {
4002
5606
  messages: [
4003
5607
  new HumanMessage({
4004
5608
  content,
4005
5609
  additional_kwargs: {
4006
- timestamp: new Date().toISOString(),
5610
+ timestamp: turnAt.toISOString(),
5611
+ ...(timeNote ? { [TURN_TIME_NOTE_KWARG]: timeNote } : {}),
4007
5612
  oracleName: core.identity.name,
4008
5613
  msgFromMatrixRoom: req.client === 'matrix',
4009
5614
  ...speakerKwargs,
5615
+ ...(req.channel
5616
+ ? { 'org.ixo.qi.origin': channelOrigin(req.channel) }
5617
+ : {}),
4010
5618
  ...attachmentKwargs,
4011
5619
  },
4012
5620
  }),
@@ -4029,22 +5637,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4029
5637
  ...metadataGraphInput(meta, priorMeta),
4030
5638
  ...surface.input,
4031
5639
  };
4032
- // LangSmith: metadata is attached unconditionally (inert without a
4033
- // tracer); the explicit tracer only when this turn is traced (global
4034
- // switch or per-DID allowlist — see `resolveLangsmithTracing`).
4035
- const tracing = resolveLangsmithTracing({
4036
- userDid: req.identity.userDid,
4037
- client: req.client,
4038
- env: langsmithEnvFromWorkerEnv(this.env),
4039
- });
4040
-
4041
5640
  const config = {
4042
5641
  configurable: { thread_id: req.sessionId },
4043
5642
  context,
4044
5643
  signal: abortController.signal,
4045
5644
  recursionLimit: turnRecursionLimit(this.env),
4046
5645
  metadata: tracing.metadata,
4047
- ...(tracing.callbacks ? { callbacks: tracing.callbacks } : {}),
5646
+ callbacks: [meteredLlm.callback, ...(tracing.callbacks ?? [])],
4048
5647
  };
4049
5648
  return {
4050
5649
  agent,
@@ -4055,6 +5654,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4055
5654
  byoProvider: byoTurn?.provider,
4056
5655
  turnDisposables,
4057
5656
  toolOutputCapChars: contextBudget.resultCapChars,
5657
+ /** The budget's usage so far (JSON), stored with the run's end. */
5658
+ usage: (): string => JSON.stringify(budget.snapshot()),
5659
+ delivery,
4058
5660
  };
4059
5661
  }
4060
5662
 
@@ -4070,18 +5672,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4070
5672
  req: TurnRequest,
4071
5673
  text: string,
4072
5674
  who: 'user' | 'oracle',
4073
- ): void {
4074
- if (req.client === 'matrix') return;
4075
- if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX)) return;
5675
+ ): Promise<void> {
5676
+ if (req.client === 'matrix') return Promise.resolve();
5677
+ if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX))
5678
+ return Promise.resolve();
4076
5679
  // A local session id has no marker event to thread on: the user had
4077
5680
  // no oracle room when the session was created, so there is no room
4078
5681
  // transcript to mirror either.
4079
- if (!req.sessionId.startsWith('$')) return;
4080
- if (!text.trim()) return;
5682
+ if (!req.sessionId.startsWith('$')) return Promise.resolve();
5683
+ if (!text.trim()) return Promise.resolve();
4081
5684
  const label = who === 'user' ? 'user message' : 'AI response';
4082
5685
  // Serialised per session, retried across a gateway restart with a fixed
4083
5686
  // transaction id, kept alive past the request (room-mirror.ts).
4084
- void this.roomMirror().enqueue(
5687
+ return this.roomMirror().enqueue(
4085
5688
  req.sessionId,
4086
5689
  async () => {
4087
5690
  const roomId =
@@ -4105,24 +5708,35 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4105
5708
  body,
4106
5709
  ...(formattedBody ? { formattedBody } : {}),
4107
5710
  threadId: req.sessionId,
4108
- txnId: mirrorTxnId(req.sessionId, req.requestId, who),
5711
+ txnId: req.channel
5712
+ ? `channel-${await channelRequestHash(JSON.stringify([req.identity.userDid, req.channel.bindingId, req.requestId, who]))}`
5713
+ : mirrorTxnId(req.sessionId, req.requestId, who),
5714
+ ...(req.channel ? { origin: req.channel } : {}),
4109
5715
  };
4110
5716
  },
4111
5717
  label,
5718
+ req.client === 'channel',
4112
5719
  );
4113
5720
  }
4114
5721
 
4115
5722
  private roomMirror(): RoomMirror {
4116
5723
  this.mirror ??= new RoomMirror({
4117
5724
  sendText: (send) =>
4118
- this.gateway.sendText(send.roomId, send.body, {
4119
- threadId: send.threadId,
4120
- ...(send.formattedBody
4121
- ? { formattedBody: send.formattedBody }
4122
- : {}),
4123
- priority: 'background',
4124
- txnId: send.txnId,
4125
- }),
5725
+ send.origin
5726
+ ? this.gateway.sendEvent(
5727
+ send.roomId,
5728
+ 'm.room.message',
5729
+ JSON.stringify(mirrorEventContent(send)),
5730
+ { txnId: send.txnId },
5731
+ )
5732
+ : this.gateway.sendText(send.roomId, send.body, {
5733
+ threadId: send.threadId,
5734
+ ...(send.formattedBody
5735
+ ? { formattedBody: send.formattedBody }
5736
+ : {}),
5737
+ priority: 'background',
5738
+ txnId: send.txnId,
5739
+ }),
4126
5740
  keepAlive: (work) => this.ctx.waitUntil(work),
4127
5741
  log: (message) => console.log(message),
4128
5742
  warn: (message) => console.warn(message),
@@ -4133,6 +5747,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4133
5747
  private async afterTurn(
4134
5748
  sessionId: string,
4135
5749
  messages: BaseMessage[],
5750
+ disposition: RequestDisposition = { kind: 'agent' },
5751
+ signal?: AbortSignal,
4136
5752
  ): Promise<void> {
4137
5753
  const sessions = this.sessions!;
4138
5754
  await sessions.touchSession(sessionId);
@@ -4143,11 +5759,25 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4143
5759
  );
4144
5760
  },
4145
5761
  );
5762
+ if (disposition.kind === 'agent')
5763
+ await this.compareShadowRoute(sessionId);
4146
5764
  const row = await sessions.getSession(sessionId);
4147
- if (row && (!row.title || row.title === UNTITLED_SESSION)) {
4148
- const title = await this.generateTitle(messages).catch(() => null);
4149
- if (title)
4150
- await sessions.setTitle(sessionId, title, { onlyIfUntitled: true });
5765
+ // The title model is the platform adapter, outside the turn's metered
5766
+ // model and budget: a supplied-context task's source and result never
5767
+ // reach it. A direct read brings its own deterministic title.
5768
+ // A generated title is a model call of its own: it runs in the
5769
+ // background, so the reply's `done` (and a Matrix answer) never waits
5770
+ // for it.
5771
+ if (
5772
+ row &&
5773
+ (!row.title || row.title === UNTITLED_SESSION) &&
5774
+ !(await this.isRestrictedTaskSession(sessionId))
5775
+ ) {
5776
+ if (disposition.kind === 'direct-read')
5777
+ await sessions.setTitle(sessionId, disposition.title, {
5778
+ onlyIfUntitled: true,
5779
+ });
5780
+ else this.ctx.waitUntil(this.titleSession(sessionId, messages, signal));
4151
5781
  }
4152
5782
  this.markDirty();
4153
5783
  // Which chunks this turn touched, for the page tier's eviction policy
@@ -4161,6 +5791,51 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4161
5791
  }
4162
5792
  }
4163
5793
 
5794
+ /**
5795
+ * Whether a session belongs to a supplied-context task, read from the
5796
+ * task row (never from the request). A task session with no scheduler to
5797
+ * read the row from is treated as restricted.
5798
+ */
5799
+ private async isRestrictedTaskSession(sessionId: string): Promise<boolean> {
5800
+ if (!sessionId.startsWith(TASK_SESSION_PREFIX)) return false;
5801
+ return this.taskScheduler
5802
+ ? this.taskScheduler.isRestrictedSession(sessionId)
5803
+ : true;
5804
+ }
5805
+
5806
+ /**
5807
+ * Shadow-mode follow-up: once the turn has ended, compare what the router
5808
+ * would have preloaded with what `load_capability` actually added to the
5809
+ * thread. Best effort — one checkpoint read, and it never fails the turn.
5810
+ * Nothing to compare when the shadow verdict has not landed yet.
5811
+ */
5812
+ private async compareShadowRoute(sessionId: string): Promise<void> {
5813
+ const shadow = this.shadowRoutes.get(sessionId);
5814
+ if (!shadow) return;
5815
+ this.shadowRoutes.delete(sessionId);
5816
+ try {
5817
+ const tuple = await this.saver?.getTupleWithoutMessages({
5818
+ configurable: { thread_id: sessionId },
5819
+ });
5820
+ const loadedAfter = new Set(
5821
+ priorMetadataState(tuple?.checkpoint.channel_values ?? {})
5822
+ .loadedPlugins ?? [],
5823
+ );
5824
+ const { loadedDuringTurn, agree } = shadowAgreement({
5825
+ priorLoaded: shadow.priorLoaded,
5826
+ loadedAfter,
5827
+ wouldPreload: shadow.wouldPreload,
5828
+ });
5829
+ console.log(
5830
+ `[capability-router-shadow] request=${shadow.requestId} loadedDuringTurn=[${loadedDuringTurn.join(', ')}] agree=${agree}`,
5831
+ );
5832
+ } catch (err: unknown) {
5833
+ console.warn(
5834
+ `[capability-router-shadow] request=${shadow.requestId} comparison failed: ${err instanceof Error ? err.message : String(err)}`,
5835
+ );
5836
+ }
5837
+ }
5838
+
4164
5839
  /**
4165
5840
  * Channel-memory compaction for the gateway's group rooms
4166
5841
  * (`src/matrix/group-chat.ts`): the platform's small model distils a
@@ -4181,8 +5856,47 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4181
5856
  );
4182
5857
  }
4183
5858
 
5859
+ /**
5860
+ * Title an untitled session from its first exchange, in the background:
5861
+ * bounded by `TITLE_TIMEOUT_MS` and by the turn's abort, never throws.
5862
+ * The timer is cleared however the call ends.
5863
+ */
5864
+ private async titleSession(
5865
+ sessionId: string,
5866
+ messages: BaseMessage[],
5867
+ turnSignal: AbortSignal | undefined,
5868
+ ): Promise<void> {
5869
+ const bound = new AbortController();
5870
+ const onAbort = (): void => bound.abort(turnSignal?.reason);
5871
+ if (turnSignal?.aborted) return;
5872
+ turnSignal?.addEventListener('abort', onAbort, { once: true });
5873
+ const timer = setTimeout(
5874
+ () =>
5875
+ bound.abort(
5876
+ new Error(`session title timed out after ${TITLE_TIMEOUT_MS} ms`),
5877
+ ),
5878
+ TITLE_TIMEOUT_MS,
5879
+ );
5880
+ try {
5881
+ const title = await this.generateTitle(messages, bound.signal);
5882
+ // A session deleted (or reset away) meanwhile is not titled.
5883
+ const sessions = this.sessions;
5884
+ if (!title || !sessions || bound.signal.aborted) return;
5885
+ if (await sessions.setTitle(sessionId, title, { onlyIfUntitled: true }))
5886
+ this.markDirty();
5887
+ } catch (err) {
5888
+ console.warn(
5889
+ `[user-do] no title for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
5890
+ );
5891
+ } finally {
5892
+ clearTimeout(timer);
5893
+ turnSignal?.removeEventListener('abort', onAbort);
5894
+ }
5895
+ }
5896
+
4184
5897
  private async generateTitle(
4185
5898
  messages: BaseMessage[],
5899
+ signal: AbortSignal,
4186
5900
  ): Promise<string | null> {
4187
5901
  const human = messages.find((m) => m.type === 'human');
4188
5902
  const ai = [...messages].reverse().find((m) => m.type === 'ai');
@@ -4192,9 +5906,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4192
5906
  const model = (this.ambient?.llm ?? this.core.llm).get('session-title');
4193
5907
  const prompt =
4194
5908
  'Write a 3–6 word title for this conversation. Reply with the title only, no quotes.\n\n' +
4195
- `User: ${contentToText(human.content).slice(0, 500)}\n` +
5909
+ `User: ${visibleText(human).slice(0, 500)}\n` +
4196
5910
  (ai ? `Assistant: ${contentToText(ai.content).slice(0, 500)}` : '');
4197
- const res = await model.invoke(prompt);
5911
+ // Raced as well as signalled: a provider that ignores the signal
5912
+ // still cannot hold the call past it.
5913
+ const aborted = new Promise<never>((_resolve, reject) => {
5914
+ const fail = (): void =>
5915
+ reject(
5916
+ signal.reason instanceof Error
5917
+ ? signal.reason
5918
+ : new Error('session title aborted'),
5919
+ );
5920
+ if (signal.aborted) fail();
5921
+ else signal.addEventListener('abort', fail, { once: true });
5922
+ });
5923
+ const res = await Promise.race([
5924
+ model.invoke(prompt, { signal }),
5925
+ aborted,
5926
+ ]);
4198
5927
  const title = contentToText(res.content)
4199
5928
  .trim()
4200
5929
  .replace(/^["']|["']$/g, '')