@ixo/oracle-runtime-workers 0.17.0 → 0.18.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.
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
1
2
  import type { DoSqliteDatabase } from '../sqlite/database';
2
3
  import type { RunRecord } from '../do/run-store';
3
4
  import type { TurnIdentity, TurnRequest } from '../do/contracts';
@@ -6,9 +7,12 @@ import type { ReplyPlan } from '../delivery/types';
6
7
  import {
7
8
  ChannelError,
8
9
  channelRequestHash,
10
+ channelRunId,
11
+ StaleSessionError,
9
12
  type ChannelTurnInput,
10
13
  type ChannelTurnResponse,
11
14
  } from './contract';
15
+ import { isRoomForbidden } from './failures';
12
16
 
13
17
  interface ReceiptRow extends Record<string, string | number | null> {
14
18
  binding_id: string;
@@ -16,12 +20,59 @@ interface ReceiptRow extends Record<string, string | number | null> {
16
20
  request_hash: string;
17
21
  run_id: string;
18
22
  session_id: string | null;
19
- /** 1 once the assistant reply was mirrored into the Companion room. */
23
+ /** 1 once the user message was mirrored (or there is none to mirror). */
24
+ user_mirrored: number;
25
+ /**
26
+ * 0 while the reply is owed, 1 once it was mirrored into the Companion
27
+ * room, 2 when the run ended without a reply to mirror.
28
+ */
20
29
  reply_mirrored: number;
30
+ /** 1 while a mirror of this request is owed to the Companion room. */
31
+ mirror_pending: number;
32
+ /** Failed mirror attempts since the last success. */
33
+ mirror_failures: number;
34
+ /** Epoch ms before which an owed mirror is not tried again (null = now). */
35
+ mirror_retry_at: number | null;
36
+ /** Epoch ms the receipt was written (rows from before the column: its migration). */
37
+ created_at: number | null;
21
38
  }
22
39
 
40
+ /**
41
+ * An owed mirror is abandoned after this many failed attempts, or at once
42
+ * when the homeserver refuses the oracle in the room. The retries back off
43
+ * from one minute, doubling, to at most an hour (about three hours in all).
44
+ */
45
+ export const CHANNEL_MIRROR_MAX_FAILURES = 8;
46
+ export const CHANNEL_MIRROR_RETRY_BASE_MS = 60_000;
47
+ const CHANNEL_MIRROR_RETRY_MAX_MS = 3_600_000;
48
+
49
+ /**
50
+ * How long a receipt whose request never started a run, and the tombstone
51
+ * of a pruned run, are kept before the housekeeping alarm removes them.
52
+ */
53
+ export const CHANNEL_RECORD_RETENTION_MS = 7 * 24 * 3600 * 1000;
54
+ /** Rows removed per housekeeping tick and table; a full batch re-arms soon. */
55
+ export const CHANNEL_PRUNE_BATCH = 200;
56
+ /** Receipts with an owed mirror read per mirror pass. */
57
+ const MIRROR_SCAN_BATCH = 200;
58
+
59
+ const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
60
+ 'finished',
61
+ 'aborted',
62
+ 'interrupted',
63
+ 'failed',
64
+ ]);
65
+
66
+ const errorText = (error: unknown): string =>
67
+ error instanceof Error ? error.message : String(error);
68
+
23
69
  export interface ChannelTurnsHost {
24
70
  createSession(identity: TurnIdentity, markerTxnId: string): Promise<string>;
71
+ /**
72
+ * Throws `StaleSessionError` when the object holds no such session (it
73
+ * was deleted), any other `ChannelError` when it is not the user's
74
+ * session in the encrypted Companion room.
75
+ */
25
76
  assertSession(identity: TurnIdentity, sessionId: string): Promise<void>;
26
77
  /**
27
78
  * Throws a `ChannelError` (428 `delegation_required`) when the user has no
@@ -33,40 +84,119 @@ export interface ChannelTurnsHost {
33
84
  getPlan(runId: string): Promise<string | null>;
34
85
  wasPruned(runId: string): Promise<boolean>;
35
86
  begin(runId: string, request: TurnRequest): Promise<RunRecord>;
87
+ /** The turn a run was admitted with, read back from its stored request. */
88
+ requestOf(record: RunRecord): TurnRequest;
89
+ /** Post one message into the Companion room; rejects when it could not. */
36
90
  mirror(
37
91
  request: TurnRequest,
38
92
  text: string,
39
93
  author: 'user' | 'oracle',
40
94
  ): Promise<void>;
41
95
  changed(): Promise<void>;
96
+ /** Wake the object at `at` (epoch ms) to try an owed mirror again. */
97
+ scheduleRetry(at: number): void;
98
+ /** Keep background work alive past the request that started it. */
99
+ keepAlive(work: Promise<unknown>): void;
100
+ /** Remove tombstones of runs pruned before `before` (epoch ms), at most `limit`. */
101
+ pruneTombstones(before: number, limit: number): Promise<number>;
102
+ /** The clock (epoch ms); `Date.now` when absent. */
103
+ now?(): number;
42
104
  }
43
105
 
106
+ /** What admission found under the lock, before any Matrix I/O. */
107
+ type Received =
108
+ | { kind: 'run'; response: ChannelTurnResponse }
109
+ | { kind: 'new'; sessionId: string | null };
110
+
111
+ /**
112
+ * Durable channel requests. A request is admitted in three steps, so the
113
+ * per-object admission lock never waits on Matrix:
114
+ *
115
+ * 1. under the lock, SQLite only: the receipt, and the run if the request
116
+ * started one already (a poll ends here);
117
+ * 2. outside it: the delegation check (which may read room state), the
118
+ * session marker of a binding's first message and the session check
119
+ * (gateway RPCs and a homeserver read);
120
+ * 3. under the lock again, SQLite only: bind the session and begin the run.
121
+ *
122
+ * The Companion room mirrors (the user message, then the reply) are owed by
123
+ * the receipt and sent in the background, never awaited by a turn or a poll.
124
+ */
44
125
  export class ChannelTurns {
45
126
  private admission: Promise<unknown> = Promise.resolve();
46
127
  private setupPromise: Promise<void> | undefined;
128
+ /**
129
+ * The session being opened for a binding's first message: concurrent
130
+ * first messages of one binding share it instead of each sending a
131
+ * marker. Removed once a session is bound, or when opening fails.
132
+ */
133
+ private readonly opening = new Map<string, Promise<string>>();
134
+ /** The mirror pass in progress, if any (single-flight). */
135
+ private draining: Promise<void> | null = null;
136
+ /** A pass was asked for while one ran: run another when it ends. */
137
+ private drainAgain = false;
47
138
 
48
139
  constructor(
49
140
  private readonly db: DoSqliteDatabase,
50
141
  private readonly host: ChannelTurnsHost,
51
142
  ) {}
52
143
 
144
+ private now(): number {
145
+ return this.host.now?.() ?? Date.now();
146
+ }
147
+
53
148
  private setup(): Promise<void> {
54
149
  this.setupPromise ??= (async () => {
55
150
  await this.db.run(`CREATE TABLE IF NOT EXISTS channel_requests (
56
151
  binding_id TEXT NOT NULL, request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
57
152
  run_id TEXT NOT NULL UNIQUE, session_id TEXT,
58
153
  reply_mirrored INTEGER NOT NULL DEFAULT 0,
154
+ user_mirrored INTEGER NOT NULL DEFAULT 1,
155
+ mirror_pending INTEGER NOT NULL DEFAULT 0,
156
+ mirror_failures INTEGER NOT NULL DEFAULT 0,
157
+ mirror_retry_at INTEGER,
158
+ created_at INTEGER,
59
159
  PRIMARY KEY (binding_id, request_id)
60
160
  )`);
61
- // Tables created before the column existed (CREATE TABLE IF NOT
161
+ // Tables created before a column existed (CREATE TABLE IF NOT
62
162
  // EXISTS leaves them as they were).
63
- const columns = await this.db.exec<{ name: string }>(
64
- 'PRAGMA table_info(channel_requests)',
163
+ const columns = new Set(
164
+ (
165
+ await this.db.exec<{ name: string }>(
166
+ 'PRAGMA table_info(channel_requests)',
167
+ )
168
+ ).map((c) => c.name),
65
169
  );
66
- if (!columns.some((c) => c.name === 'reply_mirrored'))
170
+ const add = async (name: string, definition: string) => {
171
+ if (columns.has(name)) return false;
67
172
  await this.db.run(
68
- 'ALTER TABLE channel_requests ADD COLUMN reply_mirrored INTEGER NOT NULL DEFAULT 0',
173
+ `ALTER TABLE channel_requests ADD COLUMN ${name} ${definition}`,
69
174
  );
175
+ return true;
176
+ };
177
+ await add('reply_mirrored', 'INTEGER NOT NULL DEFAULT 0');
178
+ // Before background mirroring, the user message was mirrored as the
179
+ // request was admitted: older rows owe none (hence the default 1; a
180
+ // run's admission sets 0 explicitly).
181
+ await add('user_mirrored', 'INTEGER NOT NULL DEFAULT 1');
182
+ if (await add('mirror_pending', 'INTEGER NOT NULL DEFAULT 0'))
183
+ // A reply the old poll path still owed is owed to the mirror pass.
184
+ await this.db.run(
185
+ 'UPDATE channel_requests SET mirror_pending = 1 WHERE reply_mirrored = 0 AND session_id IS NOT NULL',
186
+ );
187
+ await add('mirror_failures', 'INTEGER NOT NULL DEFAULT 0');
188
+ await add('mirror_retry_at', 'INTEGER');
189
+ if (await add('created_at', 'INTEGER'))
190
+ await this.db.run(
191
+ 'UPDATE channel_requests SET created_at = ? WHERE created_at IS NULL',
192
+ [this.now()],
193
+ );
194
+ await this.db.run(
195
+ 'CREATE INDEX IF NOT EXISTS idx_channel_requests_mirror ON channel_requests(mirror_pending)',
196
+ );
197
+ await this.db.run(
198
+ 'CREATE INDEX IF NOT EXISTS idx_channel_requests_created ON channel_requests(created_at)',
199
+ );
70
200
  await this.db.run(`CREATE TABLE IF NOT EXISTS channel_sessions (
71
201
  binding_id TEXT PRIMARY KEY, session_id TEXT NOT NULL
72
202
  )`);
@@ -77,36 +207,104 @@ export class ChannelTurns {
77
207
  return this.setupPromise;
78
208
  }
79
209
 
80
- /** Admissions, reply deliveries and session releases run one at a time. */
210
+ /**
211
+ * The SQLite steps of admissions and session releases run one at a time.
212
+ * Nothing that waits on Matrix runs under it.
213
+ */
81
214
  private serialize<T>(work: () => Promise<T>): Promise<T> {
82
215
  const next = this.admission.then(work);
83
216
  this.admission = next.catch(() => undefined);
84
217
  return next;
85
218
  }
86
219
 
87
- submit(
220
+ async submit(
88
221
  identity: TurnIdentity,
89
222
  input: ChannelTurnInput,
90
223
  requestHash: string,
91
224
  ): Promise<ChannelTurnResponse> {
92
- return this.serialize(() => this.admit(identity, input, requestHash));
225
+ if (
226
+ !identity.channel ||
227
+ identity.channel.bindingId !== input.bindingId ||
228
+ identity.channel.bindingRevision !== input.bindingRevision ||
229
+ identity.channel.provider !== input.provider
230
+ )
231
+ throw new ChannelError(
232
+ 403,
233
+ 'Channel identity does not match this request',
234
+ 'identity_mismatch',
235
+ );
236
+ const runId = await channelRunId(input.bindingId, input.requestId);
237
+ // At most three passes: a binding whose session was deleted opens a new
238
+ // one when the request named none, and a request refused while a
239
+ // concurrent copy of it started its run answers that run.
240
+ for (let pass = 0; ; pass += 1) {
241
+ const received = await this.serialize(() =>
242
+ this.receive(input, requestHash, runId),
243
+ );
244
+ if (received.kind === 'run') return received.response;
245
+ let sessionId: string;
246
+ try {
247
+ sessionId = await this.prepare(identity, input, received.sessionId);
248
+ } catch (error) {
249
+ const stale =
250
+ error instanceof StaleSessionError && received.sessionId !== null;
251
+ const raced = await this.serialize(() =>
252
+ stale
253
+ ? this.release(input, runId, received.sessionId ?? '')
254
+ : this.refuse(runId),
255
+ );
256
+ if (pass < 2 && (raced || (stale && !input.sessionId))) continue;
257
+ if (stale)
258
+ throw new ChannelError(
259
+ 409,
260
+ 'The Companion session of this channel no longer exists; send the request again without sessionId',
261
+ 'session_conflict',
262
+ );
263
+ throw error;
264
+ }
265
+ return this.serialize(() =>
266
+ this.start(identity, input, requestHash, runId, sessionId),
267
+ );
268
+ }
93
269
  }
94
270
 
95
271
  /**
96
- * Mirror a finished run's reply into the Companion room once: the receipt
97
- * records the delivery, so later polls of the same request do not send it
98
- * again. Called when the run ends; a poll retries a delivery that failed.
99
- * `text` is the model's reply; a run with a Reply Plan mirrors the plan
100
- * instead, as the channel user received it.
272
+ * Answer a poll of a request whose run exists, from SQLite alone and
273
+ * without the admission lock: null when the request has no run yet (or
274
+ * its body differs), so the caller admits it the full way.
101
275
  */
102
- deliverReply(
103
- request: TurnRequest,
104
- runId: string,
105
- text: string,
106
- ): Promise<void> {
107
- return this.serialize(async () => {
108
- await this.deliver(request, runId, await this.reply(runId, text));
109
- });
276
+ async poll(
277
+ identity: TurnIdentity,
278
+ input: ChannelTurnInput,
279
+ requestHash: string,
280
+ ): Promise<ChannelTurnResponse | null> {
281
+ if (
282
+ identity.channel?.bindingId !== input.bindingId ||
283
+ identity.channel.bindingRevision !== input.bindingRevision ||
284
+ identity.channel.provider !== input.provider
285
+ )
286
+ return null;
287
+ await this.setup();
288
+ const receipt = await this.receipt(input);
289
+ if (!receipt?.session_id || receipt.request_hash !== requestHash)
290
+ return null;
291
+ const record = await this.host.getRun(receipt.run_id);
292
+ if (!record) return null;
293
+ return this.respond(input, receipt, receipt.session_id, record);
294
+ }
295
+
296
+ /**
297
+ * A run ended: mirror what it still owes the Companion room (its reply)
298
+ * in the background. The receipt records each mirror, so nothing is sent
299
+ * twice; a failed one is retried by the housekeeping alarm.
300
+ */
301
+ deliverReply(): void {
302
+ this.kick();
303
+ }
304
+
305
+ /** Resolves once no mirror pass is running (tests, and a draining object). */
306
+ async whenIdle(): Promise<void> {
307
+ while (this.draining) await this.draining;
110
308
  }
111
309
 
112
310
  /** A finished run's Reply Plan, and the reply as the channel user sees it. */
@@ -126,6 +324,9 @@ export class ChannelTurns {
126
324
  forgetSession(sessionId: string): Promise<boolean> {
127
325
  return this.serialize(async () => {
128
326
  await this.setup();
327
+ // A first message still opening a session must not hand the deleted
328
+ // one to the binding's next message.
329
+ this.opening.clear();
129
330
  const released = await this.db.run(
130
331
  'DELETE FROM channel_sessions WHERE session_id = ?',
131
332
  [sessionId],
@@ -136,43 +337,83 @@ export class ChannelTurns {
136
337
  });
137
338
  }
138
339
 
139
- private async deliver(
140
- request: TurnRequest,
141
- runId: string,
142
- reply: { rendered: string },
143
- ): Promise<void> {
340
+ /**
341
+ * Housekeeping (the object's alarm): remove, in bounded batches, receipts
342
+ * whose request never started a run and tombstones of pruned runs, both
343
+ * once older than `CHANNEL_RECORD_RETENTION_MS`; and start a mirror pass
344
+ * when an owed mirror is due. Returns when to come back (epoch ms), or
345
+ * null when nothing is left to do.
346
+ */
347
+ async housekeeping(): Promise<number | null> {
348
+ // A user who never used a channel has no channel tables: none are
349
+ // created for them here.
350
+ if (
351
+ !this.setupPromise &&
352
+ !(await this.db.get(
353
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'channel_requests'`,
354
+ ))
355
+ )
356
+ return null;
144
357
  await this.setup();
145
- const receipt = await this.db.get<{ reply_mirrored: number }>(
146
- 'SELECT reply_mirrored FROM channel_requests WHERE run_id = ?',
147
- [runId],
358
+ const now = this.now();
359
+ const before = now - CHANNEL_RECORD_RETENTION_MS;
360
+ // `turn_runs` belongs to the run store, in the same database.
361
+ const receipts = await this.db.run(
362
+ `DELETE FROM channel_requests WHERE rowid IN (
363
+ SELECT r.rowid FROM channel_requests r
364
+ WHERE r.created_at < ?
365
+ AND NOT EXISTS (SELECT 1 FROM turn_runs t WHERE t.run_id = r.run_id)
366
+ LIMIT ?
367
+ )`,
368
+ [before, CHANNEL_PRUNE_BATCH],
148
369
  );
149
- if (Number(receipt?.reply_mirrored ?? 0) === 1) return;
150
- await this.host.mirror(request, reply.rendered, 'oracle');
151
- await this.db.run(
152
- 'UPDATE channel_requests SET reply_mirrored = 1 WHERE run_id = ?',
153
- [runId],
370
+ const tombstones = await this.host.pruneTombstones(
371
+ before,
372
+ CHANNEL_PRUNE_BATCH,
154
373
  );
155
- await this.host.changed();
374
+ if (receipts.changes > 0 || tombstones > 0) {
375
+ console.log(
376
+ `[channels] pruned ${receipts.changes} receipt(s) that never started a run and ${tombstones} run tombstone(s)`,
377
+ );
378
+ await this.host.changed();
379
+ }
380
+ const owed = await this.db.get<{ at: number | null }>(
381
+ 'SELECT MIN(COALESCE(mirror_retry_at, 0)) AS at FROM channel_requests WHERE mirror_pending = 1',
382
+ );
383
+ const owedAt = typeof owed?.at === 'number' ? owed.at : null;
384
+ if (owedAt !== null && owedAt <= now) this.kick();
385
+ if (
386
+ receipts.changes >= CHANNEL_PRUNE_BATCH ||
387
+ tombstones >= CHANNEL_PRUNE_BATCH
388
+ )
389
+ return now + 60_000;
390
+ return owedAt !== null && owedAt > now ? owedAt : null;
156
391
  }
157
392
 
158
- private async admit(
159
- identity: TurnIdentity,
393
+ private receipt(
394
+ input: Pick<ChannelTurnInput, 'bindingId' | 'requestId'>,
395
+ ): Promise<ReceiptRow | undefined> {
396
+ return this.db.get<ReceiptRow>(
397
+ 'SELECT * FROM channel_requests WHERE binding_id = ? AND request_id = ?',
398
+ [input.bindingId, input.requestId],
399
+ );
400
+ }
401
+
402
+ private async boundSession(bindingId: string): Promise<string | null> {
403
+ const binding = await this.db.get<{ session_id: string }>(
404
+ 'SELECT session_id FROM channel_sessions WHERE binding_id = ?',
405
+ [bindingId],
406
+ );
407
+ return binding?.session_id ?? null;
408
+ }
409
+
410
+ /** Step 1 (under the lock): the receipt, and the run if there is one. */
411
+ private async receive(
160
412
  input: ChannelTurnInput,
161
413
  requestHash: string,
162
- ): Promise<ChannelTurnResponse> {
414
+ runId: string,
415
+ ): Promise<Received> {
163
416
  await this.setup();
164
- if (
165
- !identity.channel ||
166
- identity.channel.bindingId !== input.bindingId ||
167
- identity.channel.bindingRevision !== input.bindingRevision ||
168
- identity.channel.provider !== input.provider
169
- )
170
- throw new ChannelError(
171
- 403,
172
- 'Channel identity does not match this request',
173
- 'identity_mismatch',
174
- );
175
- const runId = `channel_${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`;
176
417
  // Checked before a receipt is written: the receipt of a pruned run is
177
418
  // gone (run-store.ts), and a new one must not take its place.
178
419
  if (await this.host.wasPruned(runId))
@@ -183,64 +424,215 @@ export class ChannelTurns {
183
424
  );
184
425
  const admitted = await this.db.run(
185
426
  `INSERT OR IGNORE INTO channel_requests
186
- (binding_id, request_id, request_hash, run_id) VALUES (?, ?, ?, ?)`,
187
- [input.bindingId, input.requestId, requestHash, runId],
427
+ (binding_id, request_id, request_hash, run_id, created_at) VALUES (?, ?, ?, ?, ?)`,
428
+ [input.bindingId, input.requestId, requestHash, runId, this.now()],
188
429
  );
189
430
  // A poll of an admitted request changes nothing.
190
431
  if (admitted.changes === 1) await this.host.changed();
191
- const receipt = await this.db.get<ReceiptRow>(
192
- 'SELECT * FROM channel_requests WHERE binding_id = ? AND request_id = ?',
193
- [input.bindingId, input.requestId],
432
+ const receipt = await this.receipt(input);
433
+ if (!receipt || receipt.request_hash !== requestHash)
434
+ throw new ChannelError(
435
+ 409,
436
+ 'This request ID already belongs to another message',
437
+ 'request_conflict',
438
+ );
439
+ if (receipt.session_id) {
440
+ // A poll of a run that exists already only reads its receipt and row.
441
+ const record = await this.host.getRun(runId);
442
+ if (record)
443
+ return {
444
+ kind: 'run',
445
+ response: await this.respond(
446
+ input,
447
+ receipt,
448
+ receipt.session_id,
449
+ record,
450
+ ),
451
+ };
452
+ return { kind: 'new', sessionId: receipt.session_id };
453
+ }
454
+ const bound = await this.boundSession(input.bindingId);
455
+ if (bound && input.sessionId && bound !== input.sessionId) {
456
+ await this.dropReceipt(runId);
457
+ throw new ChannelError(
458
+ 409,
459
+ 'This channel already has another session',
460
+ 'session_conflict',
461
+ );
462
+ }
463
+ return { kind: 'new', sessionId: bound ?? input.sessionId ?? null };
464
+ }
465
+
466
+ /**
467
+ * Step 2 (outside the lock): the checks a new run needs, which may wait
468
+ * on Matrix. The delegation first: a request that cannot run never sends
469
+ * a session marker. Returns the session the request runs in.
470
+ */
471
+ private async prepare(
472
+ identity: TurnIdentity,
473
+ input: ChannelTurnInput,
474
+ sessionId: string | null,
475
+ ): Promise<string> {
476
+ await this.host.requireDelegation(identity);
477
+ const session =
478
+ sessionId ??
479
+ (await this.boundSession(input.bindingId)) ??
480
+ (await this.openSession(identity, input));
481
+ await this.host.assertSession(identity, session);
482
+ return session;
483
+ }
484
+
485
+ /**
486
+ * The session for a binding's first message. The marker transaction id
487
+ * is fixed per binding AND the request that opens the session: a retry
488
+ * of that request reuses the marker event, while a binding whose session
489
+ * was deleted gets a new one. Concurrent first messages share one open.
490
+ */
491
+ private openSession(
492
+ identity: TurnIdentity,
493
+ input: ChannelTurnInput,
494
+ ): Promise<string> {
495
+ const pending = this.opening.get(input.bindingId);
496
+ if (pending) return pending;
497
+ const opening = (async () =>
498
+ this.host.createSession(
499
+ identity,
500
+ `channel-session-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
501
+ ))();
502
+ this.opening.set(input.bindingId, opening);
503
+ opening.catch(() => {
504
+ if (this.opening.get(input.bindingId) === opening)
505
+ this.opening.delete(input.bindingId);
506
+ });
507
+ return opening;
508
+ }
509
+
510
+ /**
511
+ * A request refused before it started a run leaves no receipt behind:
512
+ * the same request id can be sent again, also with another body (without
513
+ * `sessionId`, say). Returns true when a concurrent copy of the request
514
+ * started its run meanwhile (the receipt then stays).
515
+ */
516
+ private async refuse(runId: string): Promise<boolean> {
517
+ await this.setup();
518
+ if (await this.host.getRun(runId)) return true;
519
+ await this.dropReceipt(runId);
520
+ return false;
521
+ }
522
+
523
+ private async dropReceipt(runId: string): Promise<void> {
524
+ const dropped = await this.db.run(
525
+ 'DELETE FROM channel_requests WHERE run_id = ?',
526
+ [runId],
527
+ );
528
+ if (dropped.changes > 0) await this.host.changed();
529
+ }
530
+
531
+ /**
532
+ * The session a new request was to run in is gone: the binding forgets
533
+ * it. A request that named the session loses its receipt (it is sent
534
+ * again without `sessionId`, a different body); one that did not keeps
535
+ * it, unbound, and opens a new session on the next pass.
536
+ */
537
+ private async release(
538
+ input: ChannelTurnInput,
539
+ runId: string,
540
+ sessionId: string,
541
+ ): Promise<boolean> {
542
+ await this.setup();
543
+ if (await this.host.getRun(runId)) return true;
544
+ this.opening.delete(input.bindingId);
545
+ await this.db.run(
546
+ 'DELETE FROM channel_sessions WHERE binding_id = ? AND session_id = ?',
547
+ [input.bindingId, sessionId],
548
+ );
549
+ if (input.sessionId)
550
+ await this.db.run('DELETE FROM channel_requests WHERE run_id = ?', [
551
+ runId,
552
+ ]);
553
+ else
554
+ await this.db.run(
555
+ 'UPDATE channel_requests SET session_id = NULL WHERE run_id = ?',
556
+ [runId],
557
+ );
558
+ await this.host.changed();
559
+ console.warn(
560
+ `[channels] binding ${input.bindingId} released its session ${sessionId}: it no longer exists`,
194
561
  );
562
+ return false;
563
+ }
564
+
565
+ /** Step 3 (under the lock): bind the session and begin the run. */
566
+ private async start(
567
+ identity: TurnIdentity,
568
+ input: ChannelTurnInput,
569
+ requestHash: string,
570
+ runId: string,
571
+ sessionId: string,
572
+ ): Promise<ChannelTurnResponse> {
573
+ await this.setup();
574
+ let receipt = await this.receipt(input);
575
+ if (!receipt) {
576
+ // A concurrent copy of this request was refused and dropped the
577
+ // receipt while this one passed its checks: write it again.
578
+ await this.db.run(
579
+ `INSERT OR IGNORE INTO channel_requests
580
+ (binding_id, request_id, request_hash, run_id, created_at) VALUES (?, ?, ?, ?, ?)`,
581
+ [input.bindingId, input.requestId, requestHash, runId, this.now()],
582
+ );
583
+ receipt = await this.receipt(input);
584
+ }
195
585
  if (!receipt || receipt.request_hash !== requestHash)
196
586
  throw new ChannelError(
197
587
  409,
198
588
  'This request ID already belongs to another message',
199
589
  'request_conflict',
200
590
  );
201
- let sessionId = receipt.session_id;
202
- // The session is checked when this request is bound to it, and again
203
- // before the request starts a run; a poll of a run that exists already
204
- // only reads its receipt and row.
205
- let sessionChecked = false;
206
- if (!sessionId) {
207
- const binding = await this.db.get<{ session_id: string }>(
208
- 'SELECT session_id FROM channel_sessions WHERE binding_id = ?',
209
- [input.bindingId],
591
+ let session = receipt.session_id ?? sessionId;
592
+ if (!receipt.session_id) {
593
+ await this.db.run(
594
+ 'INSERT OR IGNORE INTO channel_sessions (binding_id, session_id) VALUES (?, ?)',
595
+ [input.bindingId, sessionId],
210
596
  );
211
- if (binding && input.sessionId && binding.session_id !== input.sessionId)
212
- throw new ChannelError(
213
- 409,
214
- 'This channel already has another session',
215
- 'session_conflict',
216
- );
217
- sessionId =
218
- binding?.session_id ??
219
- input.sessionId ??
220
- // The marker transaction id is fixed per binding AND the request that
221
- // opens the session: a retry of that request reuses the marker event,
222
- // while a binding whose session was deleted gets a new one.
223
- (await this.host.createSession(
224
- identity,
225
- `channel-session-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
226
- ));
227
- await this.host.assertSession(identity, sessionId);
228
- sessionChecked = true;
229
- await this.db.transaction(async () => {
230
- await this.db.run(
231
- 'INSERT OR IGNORE INTO channel_sessions (binding_id, session_id) VALUES (?, ?)',
232
- [input.bindingId, sessionId],
233
- );
597
+ const bound = await this.boundSession(input.bindingId);
598
+ if (bound && bound !== sessionId) {
599
+ // Another request bound the binding while this one was checked.
600
+ if (input.sessionId) {
601
+ await this.dropReceipt(runId);
602
+ throw new ChannelError(
603
+ 409,
604
+ 'This channel already has another session',
605
+ 'session_conflict',
606
+ );
607
+ }
608
+ session = bound;
609
+ }
610
+ }
611
+ this.opening.delete(input.bindingId);
612
+ const record = await this.host.getRun(runId);
613
+ if (record) {
614
+ // A concurrent copy of this request started the run.
615
+ if (!receipt.session_id) {
234
616
  await this.db.run(
235
617
  'UPDATE channel_requests SET session_id = ? WHERE run_id = ?',
236
- [sessionId, runId],
618
+ [session, runId],
237
619
  );
238
- });
239
- await this.host.changed();
620
+ await this.host.changed();
621
+ }
622
+ return this.respond(input, receipt, session, record);
240
623
  }
241
- const request: TurnRequest = {
624
+ // The run owes its user message to the Companion room from here on;
625
+ // the mirror pass sends it, the turn never waits for it.
626
+ await this.db.run(
627
+ `UPDATE channel_requests SET session_id = ?, user_mirrored = 0, reply_mirrored = 0,
628
+ mirror_pending = 1, mirror_failures = 0, mirror_retry_at = NULL
629
+ WHERE run_id = ?`,
630
+ [session, runId],
631
+ );
632
+ await this.host.changed();
633
+ const started = await this.host.begin(runId, {
242
634
  identity,
243
- sessionId,
635
+ sessionId: session,
244
636
  message: input.message,
245
637
  client: 'channel',
246
638
  requestId: input.requestId,
@@ -250,23 +642,40 @@ export class ChannelTurns {
250
642
  bindingId: input.bindingId,
251
643
  remoteMessageRef: input.remoteMessageRef,
252
644
  },
253
- };
254
- let record = await this.host.getRun(runId);
255
- if (!record) {
256
- if (!sessionChecked) await this.host.assertSession(identity, sessionId);
257
- await this.host.requireDelegation(identity);
258
- await this.host.mirror(request, request.message, 'user');
259
- record = await this.host.begin(runId, request);
260
- }
645
+ });
646
+ this.kick();
647
+ return this.respond(input, null, session, started);
648
+ }
649
+
650
+ /**
651
+ * The answer for a request whose run exists. A finished run is answered
652
+ * with its reply at once; whatever the room is still owed (by this
653
+ * receipt) is mirrored in the background.
654
+ */
655
+ private async respond(
656
+ input: ChannelTurnInput,
657
+ receipt: ReceiptRow | null,
658
+ sessionId: string,
659
+ record: RunRecord,
660
+ ): Promise<ChannelTurnResponse> {
261
661
  const reply =
262
662
  record.status === 'finished'
263
- ? await this.reply(runId, record.partialText ?? '')
663
+ ? await this.reply(record.runId, record.partialText ?? '')
264
664
  : null;
265
- // The Companion room records the reply the channel user received, once.
266
- if (reply) await this.deliver(request, runId, reply);
665
+ // A mirror this request still owes and that is due: its user message,
666
+ // or the reply of a run that ended (a running run owes it later).
667
+ if (
668
+ receipt &&
669
+ Number(receipt.mirror_pending) === 1 &&
670
+ (receipt.mirror_retry_at === null ||
671
+ Number(receipt.mirror_retry_at) <= this.now()) &&
672
+ (Number(receipt.user_mirrored) === 0 ||
673
+ TERMINAL_STATUSES.has(record.status))
674
+ )
675
+ this.kick();
267
676
  return {
268
677
  requestId: input.requestId,
269
- runId,
678
+ runId: record.runId,
270
679
  sessionId,
271
680
  status: record.status,
272
681
  ...(record.messageId ? { messageId: record.messageId } : {}),
@@ -277,4 +686,150 @@ export class ChannelTurns {
277
686
  ...(reply?.plan ? { plan: reply.plan } : {}),
278
687
  };
279
688
  }
689
+
690
+ /** Start a mirror pass, or ask the running one to go again. */
691
+ private kick(): void {
692
+ if (this.draining) {
693
+ this.drainAgain = true;
694
+ return;
695
+ }
696
+ const work = (async () => {
697
+ do {
698
+ this.drainAgain = false;
699
+ await this.drainOnce();
700
+ } while (this.drainAgain);
701
+ })()
702
+ .catch((error: unknown) => {
703
+ console.warn(`[channels] room mirror pass failed: ${errorText(error)}`);
704
+ })
705
+ .finally(() => {
706
+ this.draining = null;
707
+ });
708
+ this.draining = work;
709
+ this.host.keepAlive(work);
710
+ }
711
+
712
+ /**
713
+ * One mirror pass over the receipts that owe the Companion room a
714
+ * message. Sessions are mirrored in parallel; within a session strictly
715
+ * in receipt order, the user message before its reply, and a mirror that
716
+ * fails holds the session's later ones back until it succeeds or is
717
+ * abandoned, so the room keeps the order of the conversation.
718
+ */
719
+ private async drainOnce(): Promise<void> {
720
+ await this.setup();
721
+ const rows = await this.db.exec<ReceiptRow>(
722
+ 'SELECT * FROM channel_requests WHERE mirror_pending = 1 ORDER BY rowid LIMIT ?',
723
+ [MIRROR_SCAN_BATCH],
724
+ );
725
+ const sessions = new Map<string, ReceiptRow[]>();
726
+ for (const row of rows) {
727
+ const key = row.session_id ?? '';
728
+ sessions.set(key, [...(sessions.get(key) ?? []), row]);
729
+ }
730
+ await Promise.all(
731
+ [...sessions.values()].map((owed) => this.drainSession(owed)),
732
+ );
733
+ }
734
+
735
+ private async drainSession(rows: ReceiptRow[]): Promise<void> {
736
+ for (const row of rows) {
737
+ const retryAt =
738
+ row.mirror_retry_at === null ? null : Number(row.mirror_retry_at);
739
+ if (retryAt !== null && retryAt > this.now()) {
740
+ this.host.scheduleRetry(retryAt);
741
+ return;
742
+ }
743
+ const record = await this.host.getRun(row.run_id);
744
+ // No run yet: admission marks the mirror owed just before the run
745
+ // begins (so a reset in between cannot lose it), and this pass may
746
+ // run in that gap. Left as it is; a receipt whose run never began is
747
+ // pruned with the others (`housekeeping`).
748
+ if (!record) continue;
749
+ const request = this.host.requestOf(record);
750
+ let author: 'user' | 'oracle' = 'user';
751
+ try {
752
+ if (Number(row.user_mirrored) === 0) {
753
+ await this.host.mirror(request, request.message, 'user');
754
+ await this.settleMirror(row.run_id, 'user_mirrored = 1');
755
+ }
756
+ if (Number(row.reply_mirrored) === 0) {
757
+ if (record.status === 'finished') {
758
+ author = 'oracle';
759
+ // The reply as the channel user received it: a run with a
760
+ // Reply Plan mirrors the plan, else the model's text.
761
+ const reply = await this.reply(
762
+ record.runId,
763
+ record.partialText ?? '',
764
+ );
765
+ await this.host.mirror(request, reply.rendered, 'oracle');
766
+ await this.settleMirror(
767
+ row.run_id,
768
+ 'reply_mirrored = 1, mirror_pending = 0',
769
+ );
770
+ } else if (TERMINAL_STATUSES.has(record.status)) {
771
+ await this.settleMirror(
772
+ row.run_id,
773
+ 'reply_mirrored = 2, mirror_pending = 0',
774
+ );
775
+ }
776
+ // Still running: the reply is mirrored when the run ends.
777
+ } else {
778
+ await this.settleMirror(row.run_id, 'mirror_pending = 0');
779
+ }
780
+ } catch (error) {
781
+ await this.mirrorFailed(row, author, error);
782
+ return;
783
+ }
784
+ }
785
+ }
786
+
787
+ private async settleMirror(runId: string, set: string): Promise<void> {
788
+ await this.db.run(
789
+ `UPDATE channel_requests SET ${set}, mirror_failures = 0, mirror_retry_at = NULL WHERE run_id = ?`,
790
+ [runId],
791
+ );
792
+ await this.host.changed();
793
+ }
794
+
795
+ /**
796
+ * Record a failed mirror: retry it later with backoff, or abandon it
797
+ * (log it and stop owing it) once the homeserver refused the oracle in
798
+ * the room or the attempts are spent. Never surfaces to a turn or poll.
799
+ */
800
+ private async mirrorFailed(
801
+ row: ReceiptRow,
802
+ author: 'user' | 'oracle',
803
+ error: unknown,
804
+ ): Promise<void> {
805
+ const failures = Number(row.mirror_failures) + 1;
806
+ const forbidden = isRoomForbidden(error);
807
+ const what = `author=${author} run=${row.run_id} session=${row.session_id ?? '-'} attempt=${failures}`;
808
+ if (forbidden || failures >= CHANNEL_MIRROR_MAX_FAILURES) {
809
+ await this.db.run(
810
+ 'UPDATE channel_requests SET mirror_pending = 0, mirror_failures = ?, mirror_retry_at = NULL WHERE run_id = ?',
811
+ [failures, row.run_id],
812
+ );
813
+ await this.host.changed();
814
+ console.error(
815
+ `[channels] channel_mirror_abandoned=1 ${what} reason=${forbidden ? 'room_forbidden' : 'attempts_spent'}: ${errorText(error)}`,
816
+ );
817
+ return;
818
+ }
819
+ const retryAt =
820
+ this.now() +
821
+ Math.min(
822
+ CHANNEL_MIRROR_RETRY_BASE_MS * 2 ** (failures - 1),
823
+ CHANNEL_MIRROR_RETRY_MAX_MS,
824
+ );
825
+ await this.db.run(
826
+ 'UPDATE channel_requests SET mirror_failures = ?, mirror_retry_at = ? WHERE run_id = ?',
827
+ [failures, retryAt, row.run_id],
828
+ );
829
+ await this.host.changed();
830
+ this.host.scheduleRetry(retryAt);
831
+ console.warn(
832
+ `[channels] channel_mirror_failed=1 ${what} retry_at=${new Date(retryAt).toISOString()}: ${errorText(error)}`,
833
+ );
834
+ }
280
835
  }