@ixo/oracle-runtime-workers 0.17.1 → 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.
- package/package.json +3 -3
- package/src/channels/auth.ts +65 -0
- package/src/channels/contract.ts +32 -2
- package/src/channels/failures.ts +64 -0
- package/src/channels/turns.ts +660 -105
- package/src/core/index.ts +36 -3
- package/src/do/contracts.ts +10 -0
- package/src/do/room-mirror.ts +7 -2
- package/src/do/run-store-test-do.ts +2 -2
- package/src/do/run-store.ts +41 -6
- package/src/do/user-oracle-do.ts +188 -77
- package/src/shell/app.ts +16 -9
package/src/channels/turns.ts
CHANGED
|
@@ -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
|
|
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
|
|
161
|
+
// Tables created before a column existed (CREATE TABLE IF NOT
|
|
62
162
|
// EXISTS leaves them as they were).
|
|
63
|
-
const columns =
|
|
64
|
-
|
|
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
|
-
|
|
170
|
+
const add = async (name: string, definition: string) => {
|
|
171
|
+
if (columns.has(name)) return false;
|
|
67
172
|
await this.db.run(
|
|
68
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
): Promise<
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
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.
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
[
|
|
618
|
+
[session, runId],
|
|
237
619
|
);
|
|
238
|
-
|
|
239
|
-
|
|
620
|
+
await this.host.changed();
|
|
621
|
+
}
|
|
622
|
+
return this.respond(input, receipt, session, record);
|
|
240
623
|
}
|
|
241
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
//
|
|
266
|
-
|
|
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
|
}
|