@ixo/oracle-runtime-workers 0.4.1 → 0.10.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 (43) hide show
  1. package/README.md +5 -1
  2. package/package.json +2 -2
  3. package/src/attachments/download.ts +43 -22
  4. package/src/attachments/index.ts +1 -0
  5. package/src/core/env.ts +28 -0
  6. package/src/core/main-agent.ts +56 -4
  7. package/src/core/middlewares/dangling-tool-calls.ts +99 -0
  8. package/src/core/middlewares/index.ts +5 -0
  9. package/src/core/runtime-context.ts +14 -5
  10. package/src/core/subagent-as-tool.ts +12 -1
  11. package/src/do/ambient.ts +13 -4
  12. package/src/do/boot-dirty.ts +38 -0
  13. package/src/do/contracts.ts +113 -18
  14. package/src/do/matrix-turn-ledger.ts +126 -0
  15. package/src/do/reauth-prompt.ts +76 -0
  16. package/src/do/room-mirror.ts +111 -0
  17. package/src/do/sse-stream.ts +104 -2
  18. package/src/do/transcript.ts +15 -2
  19. package/src/do/turn-config.ts +24 -0
  20. package/src/do/user-oracle-do.ts +484 -91
  21. package/src/matrix/gateway-do.ts +233 -42
  22. package/src/matrix/inbox-store.ts +188 -0
  23. package/src/matrix/txn-id.ts +35 -0
  24. package/src/matrix/work-status.ts +15 -3
  25. package/src/owner-store/ixo-vfs-store.ts +12 -5
  26. package/src/owner-store/matrix-media-store.ts +65 -40
  27. package/src/owner-store/measure.ts +39 -0
  28. package/src/owner-store/migrating-store.ts +29 -34
  29. package/src/owner-store/types.ts +0 -0
  30. package/src/plugin-api/types.ts +25 -1
  31. package/src/plugins/portal/action-log.ts +36 -9
  32. package/src/plugins/tasks/tasks-tools.ts +7 -7
  33. package/src/shell/app.ts +92 -7
  34. package/src/shell/turn-body-cap.ts +19 -0
  35. package/src/sqlite/cache-config.ts +27 -6
  36. package/src/sqlite/chunk-layout.ts +14 -0
  37. package/src/sqlite/database.ts +188 -19
  38. package/src/sqlite/do-vfs.ts +963 -99
  39. package/src/sqlite/index.ts +13 -0
  40. package/src/sqlite/page-tier.ts +668 -0
  41. package/src/tasks/schedule-input.ts +102 -0
  42. package/src/tasks/scheduler.ts +351 -54
  43. package/src/tasks/store.ts +198 -3
@@ -37,6 +37,8 @@ import {
37
37
  type AttachmentViewSurface,
38
38
  type MatrixMediaSource,
39
39
  type SandboxUploadConfig,
40
+ MAX_FILE_SIZE,
41
+ readBytesCapped,
40
42
  } from '../attachments';
41
43
  import type { RuntimeCore } from '../core';
42
44
  import {
@@ -81,12 +83,17 @@ import {
81
83
  finishCompaction,
82
84
  type CompactCursors,
83
85
  } from '../sqlite/blob-compactor';
84
- import { createHash } from 'node:crypto';
85
86
  import {
86
87
  cachePagesForBytes,
88
+ parseByteSize,
87
89
  parseChunkCacheBytes,
88
90
  } from '../sqlite/cache-config';
89
- import { DoSqliteDatabase } from '../sqlite/database';
91
+ import { DoSqliteDatabase, type DoSqliteOpenOptions } from '../sqlite/database';
92
+ import type { TierFlushResult } from '../sqlite/do-vfs';
93
+ import {
94
+ DEFAULT_TIER_HOT_BUDGET_BYTES,
95
+ type PageTierOptions,
96
+ } from '../sqlite/page-tier';
90
97
  import {
91
98
  shouldVacuum,
92
99
  vacuumWanted,
@@ -103,7 +110,9 @@ import {
103
110
  UNTITLED_SESSION,
104
111
  type SessionRecord,
105
112
  } from '../sqlite/sessions-store';
113
+ import { createAttachmentDecryptor } from '@ixo/matrix-bot-workers-sdk';
106
114
  import { MatrixMediaOwnerStore } from '../owner-store/matrix-media-store';
115
+ import { measureForSave } from '../owner-store/measure';
107
116
  import { MigratingOwnerStore } from '../owner-store/migrating-store';
108
117
  import type { OwnerCopy } from '../owner-store/types';
109
118
  import {
@@ -135,7 +144,13 @@ import {
135
144
  type TurnRequest,
136
145
  type TurnResult,
137
146
  type MemorySchemaDebug,
147
+ TURN_INTERRUPTED_MARKER,
138
148
  } from './contracts';
149
+ import { turnRecursionLimit } from './turn-config';
150
+ import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
151
+ import { decideBootDirty } from './boot-dirty';
152
+ import { mirrorTxnId, RoomMirror } from './room-mirror';
153
+ import { ReauthPrompter } from './reauth-prompt';
139
154
  import {
140
155
  createTaskScheduler,
141
156
  TASK_SESSION_PREFIX,
@@ -215,6 +230,14 @@ interface MatrixUserIdCache {
215
230
  /** Consecutive flush failures (cleared on success). */
216
231
  const META_FLUSH_FAILURES = 'meta:flushFailures';
217
232
  const META_LAST_VACUUM = 'meta:lastVacuumAt';
233
+ /** When the last R2 page-tier eviction pass ran (see `tierTick`). */
234
+ const META_LAST_TIER_PASS = 'meta:lastTierPassAt';
235
+ /**
236
+ * Eviction passes at most this often per object. The horizon is two daily
237
+ * periods, so four checks a day catch chunks the moment they age out
238
+ * without re-reading the hot set more than that.
239
+ */
240
+ const TIER_PASS_INTERVAL_MS = 6 * 60 * 60_000;
218
241
  /**
219
242
  * Persisted twin of the in-memory `dirty` flag. The flag alone dies with an
220
243
  * eviction, and evictions are routine — without the marker an alarm waking a
@@ -239,6 +262,16 @@ const DELEGATION_MISS_TTL_MS = 60_000;
239
262
  const META_REAUTH_PROMPT_AT = 'meta:reauthPromptAt';
240
263
  /** Node's `AgentBuilder.DEFAULT_REAUTH_THROTTLE_SECONDS`. */
241
264
  const DEFAULT_REAUTH_THROTTLE_SECONDS = 6 * 60 * 60;
265
+
266
+ /** `UCAN_REAUTH_PROMPT_THROTTLE_SECONDS` as a positive number, else the default. */
267
+ function reauthThrottleSeconds(env: OracleWorkerEnv): number {
268
+ const raw = env.UCAN_REAUTH_PROMPT_THROTTLE_SECONDS;
269
+ return typeof raw === 'string' &&
270
+ Number.isFinite(Number(raw)) &&
271
+ Number(raw) > 0
272
+ ? Number(raw)
273
+ : DEFAULT_REAUTH_THROTTLE_SECONDS;
274
+ }
242
275
  /** Highest whole-GB size watermark already alerted to Slack (0 = none). */
243
276
  const META_SIZE_ALERT_GB = 'meta:sizeAlertGb';
244
277
  /** Blob re-compression bookkeeping (see sqlite/blob-compactor.ts). */
@@ -295,22 +328,6 @@ interface FlushResult {
295
328
  }
296
329
 
297
330
  /** Hex SHA-256 of a byte stream, hashed as it flows. */
298
- async function sha256OfStream(
299
- stream: ReadableStream<Uint8Array>,
300
- ): Promise<string> {
301
- const hash = createHash('sha256');
302
- const reader = stream.getReader();
303
- try {
304
- for (;;) {
305
- const { done, value } = await reader.read();
306
- if (done) break;
307
- hash.update(value);
308
- }
309
- } finally {
310
- reader.releaseLock();
311
- }
312
- return hash.digest('hex');
313
- }
314
331
 
315
332
  /** Does this adapter expose provider config + role resolution (platform adapters do)? */
316
333
  function isProviderAdapter(llm: LlmAdapter): llm is OpenRouterLlmAdapter {
@@ -433,6 +450,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
433
450
 
434
451
  /** In-flight Matrix turn per session (sessionId → requestId), for supersede. */
435
452
  private readonly matrixTurns = new Map<string, string>();
453
+ /** What this object remembers about each room message it answered (see matrix-turn-ledger.ts). */
454
+ private matrixLedger: MatrixTurnLedger | null = null;
455
+ /** Room mirrors of HTTP turns, serialised per session and retried across gateway restarts (see room-mirror.ts). */
456
+ private mirror: RoomMirror | null = null;
457
+ /** The oracle room of each session this instance resolved: spares the mirrors a row read per send. */
458
+ private readonly sessionRooms = new Map<string, string>();
459
+ /** The throttled `delegation_required` prompt (see reauth-prompt.ts). */
460
+ private reauthPrompter: ReauthPrompter | null = null;
461
+ /** Room turns running right now, by Matrix event id: a gateway that asks again attaches instead of re-running. */
462
+ private readonly matrixTurnRuns = new Map<string, Promise<TurnResult>>();
436
463
 
437
464
  /** Session-history → memory-engine indexing (built lazily, see `scheduleHistoryIndexing`). */
438
465
  private historyIndexer: SessionHistoryIndexer | null = null;
@@ -447,6 +474,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
447
474
  >();
448
475
  private initPromise: Promise<void> | null = null;
449
476
  private reloadedFromOwnerStore = false;
477
+ /** Set by `adoptOwnerCopy` for a legacy copy: `ready()` flushes it to the system of record right after. */
478
+ private pendingLegacyMigration: { bytes: number } | null = null;
450
479
 
451
480
  // ── lifecycle ───────────────────────────────────────────────────────────
452
481
 
@@ -792,7 +821,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
792
821
  const imported = await this.adoptOwnerCopy(db, loaded);
793
822
  this.reloadedFromOwnerStore = true;
794
823
  console.log(
795
- `[user-do] imported ${imported} bytes from ${this.ownerStore.kind} for ${userDid}`,
824
+ `[user-do] imported ${imported} bytes from ${loaded.fromLegacy ? 'legacy Matrix media' : this.ownerStore.kind} for ${userDid}`,
796
825
  );
797
826
  }
798
827
  } else {
@@ -926,6 +955,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
926
955
 
927
956
  this.db ??= db;
928
957
  const liveDb = this.db;
958
+ if (this.pendingLegacyMigration) {
959
+ const { bytes } = this.pendingLegacyMigration;
960
+ this.pendingLegacyMigration = null;
961
+ // Second half of the one-time migration: the legacy copy just
962
+ // imported goes to the system of record from the working copy —
963
+ // streamed from a snapshot like every flush, so a Node-era history of
964
+ // any size costs a few chunks of memory. A failure (no
965
+ // `ixo:filesystem` delegation yet, a VFS outage) keeps the copy dirty
966
+ // and retried every 10 min; the object serves the legacy history
967
+ // meanwhile, and the Matrix copy is only redacted once VFS holds it.
968
+ try {
969
+ const flushed = await this.flushToOwnerStore();
970
+ console.log(
971
+ `[user-do] migrated ${bytes} bytes from legacy Matrix media to ${this.ownerStore.kind} for ${userDid} (${flushed.etag ?? '-'})`,
972
+ );
973
+ } catch (err) {
974
+ console.warn(
975
+ `[user-do] VFS migration write failed for ${userDid} — serving the legacy Matrix copy for now: ${err instanceof Error ? err.message : String(err)}`,
976
+ );
977
+ // Sooner than the 24 h debounce `markDirty` armed: the system of
978
+ // record is still empty for this user.
979
+ this.requestAlarm(Date.now() + FLUSH_RETRY_DELAY_MS);
980
+ }
981
+ }
982
+ await this.reconcileDirtyOnBoot(liveDb, userDid);
929
983
  {
930
984
  // One line per boot so an operator can tell what a user's object
931
985
  // actually holds without a debug route: which store, how big, how
@@ -959,6 +1013,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
959
1013
  await this.saver.setup();
960
1014
  this.sessions = new SessionsStore(liveDb);
961
1015
  await this.sessions.setup();
1016
+ this.matrixLedger = new MatrixTurnLedger(liveDb);
1017
+ await this.matrixLedger.setup();
962
1018
 
963
1019
  const core = this.core;
964
1020
  // Boot-time plugin hooks + collision checks, once per object (memoised).
@@ -1003,8 +1059,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1003
1059
  logger: console,
1004
1060
  });
1005
1061
  this.workStatus = new WorkStatusProducer({
1006
- postEvent: (roomId, type, content) =>
1007
- this.gateway.sendEvent(roomId, type, JSON.stringify(content)),
1062
+ postEvent: (roomId, type, content, opts) =>
1063
+ this.gateway.sendEvent(
1064
+ roomId,
1065
+ type,
1066
+ JSON.stringify(content),
1067
+ opts ?? {},
1068
+ ),
1008
1069
  logger: console,
1009
1070
  });
1010
1071
  this.ambient = createAmbientServices({
@@ -1022,6 +1083,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1022
1083
  delegationFor: (did) => this.delegations.get(did),
1023
1084
  events: this.events,
1024
1085
  secrets: createSecretsAdapter(secretsService),
1086
+ background: (work) => this.ctx.waitUntil(work),
1025
1087
  });
1026
1088
  }
1027
1089
 
@@ -1067,7 +1129,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1067
1129
  if (db) {
1068
1130
  await db.close().catch(() => undefined);
1069
1131
  }
1070
- await DoSqliteDatabase.wipe(this.ctx, DB_FILE);
1132
+ const tier = this.tierOptions();
1133
+ await DoSqliteDatabase.wipe(this.ctx, DB_FILE, tier ? { tier } : {});
1071
1134
  await this.ctx.storage.delete([
1072
1135
  META_OWNER_ETAG,
1073
1136
  META_LAST_CHECKSUM,
@@ -1078,12 +1141,40 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1078
1141
  ]);
1079
1142
  }
1080
1143
 
1081
- /** `CHUNK_CACHE_BYTES` → the VFS clean-chunk budget for this object. */
1082
- private sqliteOpenOptions(): { cachePages: number } {
1144
+ /**
1145
+ * `CHUNK_CACHE_BYTES` → the VFS clean-chunk budget for this object, and
1146
+ * the R2 page tier when the script binds `TIER_BUCKET` (the object's id
1147
+ * is its key prefix; the knobs are documented in `contracts.ts`).
1148
+ */
1149
+ private sqliteOpenOptions(): DoSqliteOpenOptions {
1083
1150
  const bytes = parseChunkCacheBytes(this.env.CHUNK_CACHE_BYTES, (m) =>
1084
1151
  console.warn(`[user-do] ${m}`),
1085
1152
  );
1086
- return { cachePages: cachePagesForBytes(bytes) };
1153
+ const tier = this.tierOptions();
1154
+ return { cachePages: cachePagesForBytes(bytes), ...(tier && { tier }) };
1155
+ }
1156
+
1157
+ private tierOptions(): PageTierOptions | undefined {
1158
+ const bucket = this.env.TIER_BUCKET;
1159
+ if (!bucket) return undefined;
1160
+ const warn = (m: string): void => console.warn(`[user-do] ${m}`);
1161
+ const hotBudgetBytes = parseByteSize(this.env.TIER_HOT_BUDGET_BYTES, {
1162
+ name: 'TIER_HOT_BUDGET_BYTES',
1163
+ fallback: DEFAULT_TIER_HOT_BUDGET_BYTES,
1164
+ min: 1024 * 1024,
1165
+ max: 1024 * 1024 * 1024,
1166
+ warn,
1167
+ });
1168
+ const periods = Number(this.env.TIER_EVICT_AFTER_PERIODS);
1169
+ const periodMs = Number(this.env.TIER_PERIOD_MS);
1170
+ return {
1171
+ bucket,
1172
+ prefix: this.ctx.id.toString(),
1173
+ hotBudgetBytes,
1174
+ ...(Number.isInteger(periods) &&
1175
+ periods >= 1 && { evictAfterPeriods: periods }),
1176
+ ...(Number.isFinite(periodMs) && periodMs >= 1000 && { periodMs }),
1177
+ };
1087
1178
  }
1088
1179
 
1089
1180
  /**
@@ -1094,10 +1185,25 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1094
1185
  */
1095
1186
  private async adoptOwnerCopy(
1096
1187
  db: DoSqliteDatabase,
1097
- copy: { stream: ReadableStream<Uint8Array>; etag: string },
1188
+ copy: OwnerCopy,
1098
1189
  ): Promise<number> {
1099
1190
  const bytes = await db.importFromStream(copy.stream);
1100
1191
  await this.ctx.storage.put(META_OWNER_ETAG, copy.etag);
1192
+ if (copy.fromLegacy) {
1193
+ // Streamed in from the legacy Matrix media, not in the system of
1194
+ // record yet: forget any earlier upload markers so the flush that
1195
+ // `ready()` runs next (`pendingLegacyMigration`) actually sends it,
1196
+ // and forget "no legacy copy, stop looking" — a copy evidently
1197
+ // exists now, and the flush must be able to redact it.
1198
+ await this.ctx.storage.delete([
1199
+ META_LAST_CHECKSUM,
1200
+ META_UPLOADED_GEN,
1201
+ META_LEGACY_CLEARED,
1202
+ ]);
1203
+ this.markDirty();
1204
+ this.pendingLegacyMigration = { bytes };
1205
+ return bytes;
1206
+ }
1101
1207
  await this.ctx.storage.put(META_LAST_CHECKSUM, await db.checksum());
1102
1208
  await this.ctx.storage.put(META_UPLOADED_GEN, db.writeGeneration);
1103
1209
  return bytes;
@@ -1226,8 +1332,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1226
1332
  }
1227
1333
 
1228
1334
  // Legacy-blob compaction: one bounded batch per tick until the whole
1229
- // file is codec-compressed (see sqlite/blob-compactor.ts).
1230
- if (this.db) {
1335
+ // file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
1336
+ // flight holds a snapshot of the file, which the compactor's swap-in
1337
+ // cannot replace — a large migration flush (streamed, seconds long)
1338
+ // routinely overlaps the first tick, so wait for it instead of erroring.
1339
+ if (this.db && this.flushInFlight) {
1340
+ deadlines.push(now + 5000);
1341
+ } else if (this.db) {
1231
1342
  try {
1232
1343
  if (await this.compactionTick(this.db)) deadlines.push(now + 2000);
1233
1344
  } catch (err) {
@@ -1249,6 +1360,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1249
1360
  if (next !== null) deadlines.push(next);
1250
1361
  }
1251
1362
 
1363
+ // R2 page tier: move chunks no turn touched for a couple of days out
1364
+ // of DO storage (see sqlite/page-tier.ts). After the flush above (the
1365
+ // upload reads hot rows, not R2) and never over an in-flight one (it
1366
+ // holds a snapshot). A pass is capped; more work re-arms soon.
1367
+ if (this.db?.tierEnabled && !this.flushInFlight) {
1368
+ const next = await this.tierTick(this.db, now);
1369
+ if (next !== null) deadlines.push(next);
1370
+ }
1371
+
1252
1372
  // Idle housekeeping: a user silent for a day loses the cached pages —
1253
1373
  // their file is safe upstream, and we stop paying to store a copy. Never
1254
1374
  // evict while tasks are scheduled: their runs ARE activity. The flush
@@ -1277,6 +1397,71 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1277
1397
  );
1278
1398
  }
1279
1399
 
1400
+ /**
1401
+ * One page-tier eviction pass, at most once per `TIER_PASS_INTERVAL_MS`
1402
+ * (a pass that hit its segment cap re-arms in a minute). Returns the next
1403
+ * time a pass is due, or null when nothing is pending.
1404
+ */
1405
+ private async tierTick(
1406
+ db: DoSqliteDatabase,
1407
+ now: number,
1408
+ ): Promise<number | null> {
1409
+ const last =
1410
+ (await this.ctx.storage.get<number>(META_LAST_TIER_PASS)) ?? 0;
1411
+ if (now - last < TIER_PASS_INTERVAL_MS)
1412
+ return last + TIER_PASS_INTERVAL_MS;
1413
+ try {
1414
+ const pass = await db.tierFlush();
1415
+ await this.ctx.storage.put(META_LAST_TIER_PASS, now);
1416
+ if (pass.skipped) {
1417
+ if (pass.skipped !== 'no-tier')
1418
+ console.log(
1419
+ `[user-do] tier pass skipped for ${this.userDid}: ${pass.skipped}`,
1420
+ );
1421
+ return now + 60_000;
1422
+ }
1423
+ if (pass.evictedChunks > 0 || pass.remaining > 0) {
1424
+ console.log(
1425
+ `[user-do] tier pass for ${this.userDid}: ${pass.evictedChunks} chunks → R2 in ${pass.segmentsRewritten} segment(s), ${pass.hotRows} hot rows (${Math.round(pass.hotBytes / 1024 / 1024)} MB) kept, ${pass.remaining} segment(s) pending`,
1426
+ );
1427
+ }
1428
+ const budget = this.tierOptions()?.hotBudgetBytes;
1429
+ if (
1430
+ budget !== undefined &&
1431
+ pass.remaining === 0 &&
1432
+ pass.hotBytes > budget
1433
+ ) {
1434
+ // Everything left is recent: the user's real working set exceeds
1435
+ // the target — a sizing signal, not a failure.
1436
+ console.warn(
1437
+ `[user-do] tier hot set of ${this.userDid} is ${Math.round(pass.hotBytes / 1024 / 1024)} MB, over the ${Math.round(budget / 1024 / 1024)} MB target (all of it touched within the eviction horizon)`,
1438
+ );
1439
+ }
1440
+ return pass.remaining > 0 ? now + 60_000 : now + TIER_PASS_INTERVAL_MS;
1441
+ } catch (err) {
1442
+ console.error(
1443
+ `[user-do] tier pass failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1444
+ );
1445
+ return now + FLUSH_RETRY_DELAY_MS;
1446
+ }
1447
+ }
1448
+
1449
+ /** Operator/test entry: run an eviction pass now (debug routes). */
1450
+ async tierFlush(
1451
+ opts: { force?: boolean; maxSegments?: number } = {},
1452
+ ): Promise<TierFlushResult> {
1453
+ if (!this.db) {
1454
+ const userDid =
1455
+ this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
1456
+ if (userDid) await this.ready({ userDid });
1457
+ }
1458
+ if (!this.db) throw new Error('no working copy');
1459
+ if (this.flushInFlight) await this.flushInFlight.catch(() => undefined);
1460
+ const pass = await this.db.tierFlush(opts);
1461
+ await this.ctx.storage.put(META_LAST_TIER_PASS, Date.now());
1462
+ return pass;
1463
+ }
1464
+
1280
1465
  /**
1281
1466
  * One VACUUM policy check. Returns the time of a follow-up check when a
1282
1467
  * rebuild is wanted but the object is not quiet yet, else null.
@@ -1579,42 +1764,70 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1579
1764
  }
1580
1765
 
1581
1766
  /**
1582
- * Emit a single throttled `ixo.oracle.delegation_required` event into the
1583
- * user's room when a Matrix turn finds no valid delegation; the web app
1584
- * listens for it and opens the authorize modal in place. Throttled per
1585
- * object (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`, default 6 h). Never throws.
1767
+ * Ask the web app to open its "authorize for Matrix" modal in place when a
1768
+ * Matrix turn finds no valid delegation — the Node AgentBuilder's
1769
+ * `maybePromptReauth`, throttled per object
1770
+ * (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`, default 6 h). Never throws; the
1771
+ * prompt outlives the turn under `waitUntil` while it is retried across a
1772
+ * gateway restart (see reauth-prompt.ts for the ordering that matters).
1586
1773
  */
1587
- private async maybePromptReauth(
1774
+ private promptReauth(userDid: string, roomId: string): void {
1775
+ this.reauthPrompter ??= new ReauthPrompter({
1776
+ throttleMs: reauthThrottleSeconds(this.env) * 1000,
1777
+ getStamp: () => this.ctx.storage.get<number>(META_REAUTH_PROMPT_AT),
1778
+ setStamp: (at) => this.ctx.storage.put(META_REAUTH_PROMPT_AT, at),
1779
+ send: (room, txnId) =>
1780
+ this.gateway.sendEvent(
1781
+ room,
1782
+ 'ixo.oracle.delegation_required',
1783
+ JSON.stringify({
1784
+ oracleEntityDid: this.core.identity.entityDid,
1785
+ oracleDid: this.env.ORACLE_DID,
1786
+ }),
1787
+ { txnId },
1788
+ ),
1789
+ keepAlive: (work) => this.ctx.waitUntil(work),
1790
+ log: (message) => console.log(message),
1791
+ warn: (message) => console.warn(message),
1792
+ });
1793
+ void this.reauthPrompter.prompt(userDid, roomId);
1794
+ }
1795
+
1796
+ /** Debug route: forget when the last re-authorise prompt was posted, so a drill can trigger the next one. */
1797
+ async debugResetReauthThrottle(): Promise<void> {
1798
+ await this.ctx.storage.delete(META_REAUTH_PROMPT_AT);
1799
+ }
1800
+
1801
+ /**
1802
+ * A turn that died mid-way leaves committed checkpoint steps with no dirty
1803
+ * mark (the mark is set at the end of a turn). Compare the file's write
1804
+ * generation with the last upload's on boot and mark the copy dirty when
1805
+ * it moved on; one batched storage read per boot (see boot-dirty.ts).
1806
+ */
1807
+ private async reconcileDirtyOnBoot(
1808
+ db: DoSqliteDatabase,
1588
1809
  userDid: string,
1589
- roomId: string,
1590
1810
  ): Promise<void> {
1591
- try {
1592
- const raw = this.env.UCAN_REAUTH_PROMPT_THROTTLE_SECONDS;
1593
- const throttleSeconds =
1594
- typeof raw === 'string' &&
1595
- Number.isFinite(Number(raw)) &&
1596
- Number(raw) > 0
1597
- ? Number(raw)
1598
- : DEFAULT_REAUTH_THROTTLE_SECONDS;
1599
- const last = await this.ctx.storage.get<number>(META_REAUTH_PROMPT_AT);
1600
- if (last !== undefined && Date.now() - last < throttleSeconds * 1000)
1601
- return;
1602
- await this.ctx.storage.put(META_REAUTH_PROMPT_AT, Date.now());
1603
- await this.gateway.sendEvent(
1604
- roomId,
1605
- 'ixo.oracle.delegation_required',
1606
- JSON.stringify({
1607
- oracleEntityDid: this.core.identity.entityDid,
1608
- oracleDid: this.env.ORACLE_DID,
1609
- }),
1610
- );
1811
+ if (this.dirty) return;
1812
+ const got = await this.ctx.storage.get<unknown>([
1813
+ META_DIRTY,
1814
+ META_UPLOADED_GEN,
1815
+ ]);
1816
+ const decision = decideBootDirty({
1817
+ dirtyInMemory: this.dirty,
1818
+ dirtyFlag: got.get(META_DIRTY),
1819
+ uploadedGen: got.get(META_UPLOADED_GEN),
1820
+ writeGeneration: db.writeGeneration,
1821
+ });
1822
+ if (decision === 'flagged') {
1823
+ // The mark is on disk already (its flush alarm too); just adopt it.
1824
+ this.dirty = true;
1825
+ this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
1826
+ } else if (decision === 'behind-upload') {
1611
1827
  console.log(
1612
- `[user-do] sent ixo.oracle.delegation_required to ${roomId} for ${userDid}`,
1613
- );
1614
- } catch (err) {
1615
- console.warn(
1616
- `[user-do] delegation-required event failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
1828
+ `[user-do] working copy of ${userDid} is at generation ${db.writeGeneration}, last upload at ${String(got.get(META_UPLOADED_GEN))} — a turn ended without marking it; flushing`,
1617
1829
  );
1830
+ this.markDirty();
1618
1831
  }
1619
1832
  }
1620
1833
 
@@ -1656,8 +1869,68 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1656
1869
  return true;
1657
1870
  }
1658
1871
 
1872
+ /**
1873
+ * A room turn runs at most once per Matrix event, however many times the
1874
+ * gateway asks (it asks again after a reset, see the inbox in
1875
+ * `src/matrix/gateway-do.ts`). The ledger and the in-flight map decide:
1876
+ * answered → stored reply; running → attach; started but lost in a reset
1877
+ * of this object → refused (tools may have run), the user is told to
1878
+ * retry; unknown → run once. HTTP and task turns carry no event id and
1879
+ * always run.
1880
+ */
1659
1881
  async runTurn(req: TurnRequest): Promise<TurnResult> {
1660
1882
  await this.ready(req.identity);
1883
+ const eventId = req.client === 'matrix' ? req.eventId : undefined;
1884
+ const ledger = this.matrixLedger;
1885
+ if (!eventId || !ledger) return this.runTurnOnce(req);
1886
+
1887
+ const running = this.matrixTurnRuns.get(eventId);
1888
+ if (running) {
1889
+ console.log(
1890
+ `[user-do] turn for ${eventId} is still running; the gateway asked again and attaches to it`,
1891
+ );
1892
+ return running;
1893
+ }
1894
+ const existing = await ledger.get(eventId);
1895
+ switch (decideMatrixTurn(existing)) {
1896
+ case 'answered':
1897
+ console.log(
1898
+ `[user-do] turn for ${eventId} already answered; returning the stored reply`,
1899
+ );
1900
+ return {
1901
+ sessionId: req.sessionId,
1902
+ requestId: req.requestId,
1903
+ text: existing?.replyText ?? '',
1904
+ toolCalls: [],
1905
+ replayed: true,
1906
+ };
1907
+ case 'interrupted':
1908
+ throw new Error(`${TURN_INTERRUPTED_MARKER} (${eventId})`);
1909
+ case 'run':
1910
+ break;
1911
+ }
1912
+ await ledger.start(eventId, req.sessionId, req.requestId);
1913
+ const run = this.runTurnOnce(req)
1914
+ .then(async (result) => {
1915
+ await ledger.answer(eventId, result.text);
1916
+ return result;
1917
+ })
1918
+ .finally(() => {
1919
+ this.matrixTurnRuns.delete(eventId);
1920
+ });
1921
+ this.matrixTurnRuns.set(eventId, run);
1922
+ // The turn's I/O belongs to this RPC's request context. If the gateway
1923
+ // that made the call is reset, the context would be cancelled with it:
1924
+ // fetches started afterwards (the next model call, the next tool) never
1925
+ // settle, and the turn hangs forever — with a replay attached to it.
1926
+ // `waitUntil` keeps the context alive until the turn has ended, so the
1927
+ // turn finishes, the ledger records the reply, and the replayed request
1928
+ // returns it.
1929
+ this.ctx.waitUntil(run.catch(() => undefined));
1930
+ return run;
1931
+ }
1932
+
1933
+ private async runTurnOnce(req: TurnRequest): Promise<TurnResult> {
1661
1934
  // Matrix turns drive a `work_status` card in the user's thread; a newer
1662
1935
  // message on the same session supersedes the previous card (the
1663
1936
  // previous turn itself is aborted by prepareTurn).
@@ -1809,6 +2082,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1809
2082
  * while one runs joins it.
1810
2083
  */
1811
2084
  async flushToOwnerStore(): Promise<FlushResult> {
2085
+ if (!this.db) {
2086
+ // A cold (evicted) object: boot from the persisted identity first, so
2087
+ // an operator's `POST /debug/storage/flush` is never a silent no-op.
2088
+ const userDid =
2089
+ this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
2090
+ if (userDid) await this.ready({ userDid });
2091
+ }
1812
2092
  if (this.flushInFlight) return this.flushInFlight;
1813
2093
  const run = this.flushOnce().finally(() => {
1814
2094
  this.flushInFlight = null;
@@ -1836,7 +2116,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1836
2116
 
1837
2117
  const snapshot = await db.snapshot();
1838
2118
  try {
1839
- const checksum = await sha256OfStream(snapshot.open());
2119
+ // One pass yields the change gate (hash) AND the length the store
2120
+ // needs to upload; the upload is the only other read of the file.
2121
+ const { sha256Hex: checksum, gzippedLength } = await measureForSave(
2122
+ snapshot.open(),
2123
+ );
1840
2124
  const last = await this.ctx.storage.get<string>(META_LAST_CHECKSUM);
1841
2125
  if (last === checksum) {
1842
2126
  await this.ctx.storage.put(META_UPLOADED_GEN, snapshot.generation);
@@ -1851,7 +2135,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1851
2135
 
1852
2136
  let saved: { etag: string; bytes: number };
1853
2137
  try {
1854
- saved = await store.save(snapshot);
2138
+ saved = await store.save(snapshot, { gzippedLength });
1855
2139
  } catch (err) {
1856
2140
  // Never diverted anywhere else: the working copy in Durable Object
1857
2141
  // storage is durable, stays dirty, and is retried. Loud from the
@@ -1976,6 +2260,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1976
2260
  this.db = null;
1977
2261
  this.saver = null;
1978
2262
  this.sessions = null;
2263
+ this.matrixLedger = null;
2264
+ this.sessionRooms.clear();
1979
2265
  this.ambient = null;
1980
2266
  this.secretsService = null;
1981
2267
  this.byo = null;
@@ -1994,9 +2280,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1994
2280
  async debugSession(
1995
2281
  userDid: string,
1996
2282
  sessionId: string,
1997
- ): Promise<SessionRecord | null> {
2283
+ ): Promise<Record<string, unknown> | null> {
1998
2284
  await this.ready({ userDid });
1999
- return (await this.sessions!.getSession(sessionId)) ?? null;
2285
+ const session = await this.sessions!.getSession(sessionId);
2286
+ if (!session) return null;
2287
+ // Whether the thread's agent context was condensed: the summarization
2288
+ // middleware's bookkeeping message is stored with the history but
2289
+ // never listed, so `GET /messages/:id` alone cannot tell.
2290
+ const all = await this.saver!.listThreadMessages(sessionId);
2291
+ return {
2292
+ ...session,
2293
+ threadMessages: all.length,
2294
+ summaryMessages: all.filter(isSummarizationMessage).length,
2295
+ // Folded into their assistant messages by the listing, not shown alone.
2296
+ toolMessages: all.filter((m) => m.type === 'tool').length,
2297
+ };
2000
2298
  }
2001
2299
 
2002
2300
  /**
@@ -2004,6 +2302,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2004
2302
  * this user's credentials and return `search_memory_engine`'s raw schema
2005
2303
  * plus whether it converts to Zod. Same headers the memory plugin sends.
2006
2304
  */
2305
+ /**
2306
+ * Operator / testing aid behind `ORACLE_DEBUG_ROUTES`: reset this object
2307
+ * the way an unplanned platform reset does — in-memory state and every
2308
+ * in-flight turn are gone, committed storage survives, the next request
2309
+ * boots a fresh instance. The RPC rejects by design.
2310
+ */
2311
+ async debugAbortObject(): Promise<void> {
2312
+ this.ctx.abort('debug reset requested');
2313
+ }
2314
+
2007
2315
  async debugMemorySchema(userDid: string): Promise<MemorySchemaDebug> {
2008
2316
  await this.ready({ userDid });
2009
2317
  const ambient = this.ambient;
@@ -2092,10 +2400,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2092
2400
  rowsWritten: vfs.rowsWritten,
2093
2401
  }
2094
2402
  : undefined,
2403
+ tier: db
2404
+ ? {
2405
+ ...db.tierStatus(),
2406
+ lastPassAt:
2407
+ await this.ctx.storage.get<number>(META_LAST_TIER_PASS),
2408
+ }
2409
+ : undefined,
2095
2410
  };
2096
2411
  }
2097
2412
 
2098
- async tasksStatus(): Promise<{
2413
+ async tasksStatus(userDid?: string): Promise<{
2099
2414
  now: number;
2100
2415
  alarm: number | null;
2101
2416
  schedulerActive: boolean;
@@ -2107,14 +2422,41 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2107
2422
  lastResult: { ok: boolean; summary: string; at: string } | null;
2108
2423
  consecutiveFailures: number;
2109
2424
  }>;
2425
+ openRuns: Array<{
2426
+ runId: string;
2427
+ taskId: string;
2428
+ startedAt: string;
2429
+ state: 'running' | 'delivering';
2430
+ attempts: number;
2431
+ retryAt: number | null;
2432
+ }>;
2110
2433
  }> {
2434
+ // A cold (evicted or reset) object has no scheduler until it boots;
2435
+ // reporting "no tasks" for it would be wrong, so boot like the alarm
2436
+ // does when the caller — or the stored identity — says whose object.
2437
+ if (!this.taskScheduler) {
2438
+ const did =
2439
+ userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
2440
+ if (did) await this.ready({ userDid: did });
2441
+ }
2111
2442
  const records = this.taskScheduler
2112
2443
  ? await this.taskScheduler.surface.list()
2113
2444
  : [];
2445
+ const openRuns = this.taskScheduler
2446
+ ? await this.taskScheduler.openRuns()
2447
+ : [];
2114
2448
  return {
2115
2449
  now: Date.now(),
2116
2450
  alarm: await this.ctx.storage.getAlarm(),
2117
2451
  schedulerActive: this.taskScheduler !== null,
2452
+ openRuns: openRuns.map((r) => ({
2453
+ runId: r.runId,
2454
+ taskId: r.taskId,
2455
+ startedAt: r.startedAt,
2456
+ state: r.state,
2457
+ attempts: r.attempts,
2458
+ retryAt: r.retryAt ?? null,
2459
+ })),
2118
2460
  tasks: records.map((t) => ({
2119
2461
  id: t.id,
2120
2462
  title: t.title,
@@ -2392,12 +2734,36 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2392
2734
 
2393
2735
  // ── turn plumbing ──────────────────────────────────────────────────────
2394
2736
 
2395
- /** Matrix media access for the attachments pipeline, through the gateway. */
2737
+ /**
2738
+ * Matrix media access for the attachments pipeline, through the gateway.
2739
+ * Both transfers stream across the object boundary and are cut off at
2740
+ * the attachment cap; an encrypted attachment is decrypted HERE, not in
2741
+ * the gateway (its whole-buffer download grew the crypto WASM heap for
2742
+ * good, and a hash mismatch on the far side of the RPC would only reach
2743
+ * this object as a disconnect).
2744
+ */
2396
2745
  private matrixMediaSource(): MatrixMediaSource {
2397
2746
  return {
2398
- downloadMxc: (mxc) => this.gateway.downloadMxcMedia(mxc),
2399
- downloadEvent: (roomId, eventId) =>
2400
- this.gateway.downloadEventMedia(roomId, eventId),
2747
+ downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE) =>
2748
+ readBytesCapped(
2749
+ await this.gateway.downloadMxcMediaStream(mxc),
2750
+ maxBytes,
2751
+ ),
2752
+ downloadEvent: async (roomId, eventId, maxBytes = MAX_FILE_SIZE) => {
2753
+ const media = await this.gateway.downloadEventMediaStream(
2754
+ roomId,
2755
+ eventId,
2756
+ );
2757
+ if (!media) return null;
2758
+ const plain = media.file
2759
+ ? media.stream.pipeThrough(createAttachmentDecryptor(media.file))
2760
+ : media.stream;
2761
+ return {
2762
+ bytes: await readBytesCapped(plain, maxBytes),
2763
+ ...(media.mimetype ? { mimetype: media.mimetype } : {}),
2764
+ ...(media.filename ? { filename: media.filename } : {}),
2765
+ };
2766
+ },
2401
2767
  };
2402
2768
  }
2403
2769
 
@@ -2672,6 +3038,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2672
3038
  `[user-do] no oracle room resolved for ${req.identity.userDid}; room-scoped plugins (memory) will be unavailable this turn`,
2673
3039
  );
2674
3040
  }
3041
+ if (sessionRoomId) this.sessionRooms.set(req.sessionId, sessionRoomId);
2675
3042
 
2676
3043
  // A Matrix turn with no usable delegation: ask the web app to open its
2677
3044
  // "authorize for Matrix" modal (throttled per user, best-effort) — the
@@ -2681,7 +3048,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2681
3048
  sessionRoomId &&
2682
3049
  !this.delegations.get(req.identity.userDid)?.raw
2683
3050
  ) {
2684
- void this.maybePromptReauth(req.identity.userDid, sessionRoomId);
3051
+ this.promptReauth(req.identity.userDid, sessionRoomId);
2685
3052
  }
2686
3053
 
2687
3054
  // Per-room user preferences (tone, language, what to call whom) —
@@ -2873,7 +3240,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2873
3240
  configurable: { thread_id: req.sessionId },
2874
3241
  context,
2875
3242
  signal: abortController.signal,
2876
- recursionLimit: 60,
3243
+ recursionLimit: turnRecursionLimit(this.env),
2877
3244
  metadata: tracing.metadata,
2878
3245
  ...(tracing.callbacks ? { callbacks: tracing.callbacks } : {}),
2879
3246
  };
@@ -2909,40 +3276,57 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2909
3276
  if (!req.sessionId.startsWith('$')) return;
2910
3277
  if (!text.trim()) return;
2911
3278
  const label = who === 'user' ? 'user message' : 'AI response';
2912
- // `waitUntil`: the non-streaming handler returns its Response right
2913
- // after the turn, and un-awaited I/O started inside a request is torn
2914
- // down with it ("Network connection lost") unless the object is told
2915
- // to keep the work alive.
2916
- this.ctx.waitUntil(
2917
- (async () => {
3279
+ // Serialised per session, retried across a gateway restart with a fixed
3280
+ // transaction id, kept alive past the request (room-mirror.ts).
3281
+ void this.roomMirror().enqueue(
3282
+ req.sessionId,
3283
+ async () => {
2918
3284
  const roomId =
3285
+ this.sessionRooms.get(req.sessionId) ??
2919
3286
  req.roomId ??
2920
3287
  (await this.sessions?.getSession(req.sessionId))?.roomId ??
2921
- (await this.gateway.resolveUserRoom(req.identity.userDid))?.roomId;
3288
+ (
3289
+ await retryGateway(() =>
3290
+ this.gateway.resolveUserRoom(req.identity.userDid),
3291
+ )
3292
+ )?.roomId;
2922
3293
  if (!roomId) throw new Error('no oracle room for this user');
3294
+ this.sessionRooms.set(req.sessionId, roomId);
2923
3295
  const { body, formattedBody } = formatReplay({
2924
3296
  message: text,
2925
3297
  isOracle: who === 'oracle',
2926
3298
  oracleName: this.core.identity.name,
2927
3299
  });
2928
- const eventId = await this.gateway.sendText(roomId, body, {
3300
+ return {
3301
+ roomId,
3302
+ body,
3303
+ ...(formattedBody ? { formattedBody } : {}),
2929
3304
  threadId: req.sessionId,
2930
- formattedBody,
2931
- priority: 'background',
2932
- });
2933
- console.log(
2934
- `[user-do] Matrix replay (${label}) → ${eventId} in thread ${req.sessionId}`,
2935
- );
2936
- })().catch((err: unknown) => {
2937
- console.error(
2938
- `[user-do] Matrix replay (${label}) failed — session=${req.sessionId}: ${
2939
- err instanceof Error ? err.message : String(err)
2940
- }`,
2941
- );
2942
- }),
3305
+ txnId: mirrorTxnId(req.sessionId, req.requestId, who),
3306
+ };
3307
+ },
3308
+ label,
2943
3309
  );
2944
3310
  }
2945
3311
 
3312
+ private roomMirror(): RoomMirror {
3313
+ this.mirror ??= new RoomMirror({
3314
+ sendText: (send) =>
3315
+ this.gateway.sendText(send.roomId, send.body, {
3316
+ threadId: send.threadId,
3317
+ ...(send.formattedBody
3318
+ ? { formattedBody: send.formattedBody }
3319
+ : {}),
3320
+ priority: 'background',
3321
+ txnId: send.txnId,
3322
+ }),
3323
+ keepAlive: (work) => this.ctx.waitUntil(work),
3324
+ log: (message) => console.log(message),
3325
+ warn: (message) => console.warn(message),
3326
+ });
3327
+ return this.mirror;
3328
+ }
3329
+
2946
3330
  private async afterTurn(
2947
3331
  sessionId: string,
2948
3332
  messages: BaseMessage[],
@@ -2963,6 +3347,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2963
3347
  await sessions.setTitle(sessionId, title, { onlyIfUntitled: true });
2964
3348
  }
2965
3349
  this.markDirty();
3350
+ // Which chunks this turn touched, for the page tier's eviction policy
3351
+ // (one row write; a no-op without a tier).
3352
+ try {
3353
+ this.db?.recordAccess();
3354
+ } catch (err) {
3355
+ console.warn(
3356
+ `[user-do] tier access record failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
3357
+ );
3358
+ }
2966
3359
  }
2967
3360
 
2968
3361
  private async generateTitle(