letagents 0.12.12 → 0.12.13
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/dist/mcp/git-remote.js +7 -7
- package/dist/mcp/local-state/agent-sessions.js +63 -5
- package/dist/mcp/local-state/local-chat.js +189 -48
- package/dist/mcp/local-state/storage.js +16 -9
- package/dist/mcp/server/daemon-tool-executor.js +92 -0
- package/dist/mcp/server/register-tools.js +4 -2
- package/dist/mcp/server/runtime/agent-sessions.js +14 -5
- package/dist/mcp/server/runtime/api.js +11 -3
- package/dist/mcp/server/runtime/daemon-tool-context.js +11 -0
- package/dist/mcp/server/runtime/messages.js +55 -0
- package/dist/mcp/server/runtime/room-state.js +13 -3
- package/dist/mcp/server/runtime/rooms.js +51 -30
- package/dist/mcp/server/runtime/supervisor-bridge.js +49 -8
- package/dist/mcp/server/runtime/worker-bearer.js +5 -1
- package/dist/mcp/server/runtime.js +3 -3
- package/dist/mcp/server/supervised-tool-facade.js +34 -5
- package/dist/mcp/server/tools/messages/read-tool.js +54 -97
- package/dist/mcp/server/tools/messages/reasoning-tool.js +2 -0
- package/dist/mcp/server/tools/messages/send-tool.js +2 -0
- package/dist/mcp/server/tools/messages/status-tool.js +2 -0
- package/dist/mcp/server/tools/messages/wait-tool.js +290 -68
- package/dist/mcp/server/tools/onboarding/status-tool.js +4 -4
- package/dist/mcp/server/tools/rooms/inspection-tools.js +15 -10
- package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +2 -1
- package/dist/mcp/server/tools/tasks/board-tools.js +34 -2
- package/dist/mcp/sse-client.js +163 -20
- package/dist/shared/activation-routing.js +146 -23
- package/dist/shared/agent-presence.js +6 -0
- package/dist/shared/desktop-release-manifest.js +63 -0
- package/dist/shared/desktop-release.js +60 -0
- package/dist/shared/scoped-ids.js +6 -0
- package/package.json +6 -2
- package/shared/message-contracts.d.mts +32 -0
- package/shared/message-contracts.mjs +109 -0
- package/shared/routing-aliases.d.mts +18 -0
- package/shared/routing-aliases.mjs +66 -0
- package/shared/sqlite-thread-routing.d.mts +72 -0
- package/shared/sqlite-thread-routing.mjs +1038 -0
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
import {
|
|
2
|
+
routingAliasHash,
|
|
3
|
+
routingIdentityAliases,
|
|
4
|
+
routingSenderAliasRows,
|
|
5
|
+
} from "./routing-aliases.mjs";
|
|
6
|
+
|
|
7
|
+
export const LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE = 100;
|
|
8
|
+
export const LOCAL_THREAD_ROUTING_LOOKUP_BATCH_SIZE = 64;
|
|
9
|
+
export const LOCAL_THREAD_ROUTING_BACKFILL_TIME_BUDGET_MS = 12;
|
|
10
|
+
const LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS = 500;
|
|
11
|
+
const LOCAL_THREAD_ROUTING_REQUESTED_REPAIR_SLICE = 64;
|
|
12
|
+
const LOCAL_THREAD_ROUTING_INVALIDATION_DELETE_BATCH_SIZE = 100;
|
|
13
|
+
const LOCAL_THREAD_ROUTING_MAX_IDENTITY_ALIASES = 25_000;
|
|
14
|
+
const LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS = 25_000;
|
|
15
|
+
const LOCAL_THREAD_ROUTING_LOCK_RETRY_INITIAL_MS = 10;
|
|
16
|
+
const LOCAL_THREAD_ROUTING_LOCK_RETRY_MAX_MS = 250;
|
|
17
|
+
const LOCAL_THREAD_ROUTING_LOCK_RETRY_DEADLINE_MS = 2_000;
|
|
18
|
+
const LOCAL_THREAD_ROUTING_FOREGROUND_REPAIR_BUDGET_MS = 75;
|
|
19
|
+
|
|
20
|
+
const scheduledDatabases = new WeakMap();
|
|
21
|
+
const publisherKeyColumnByDatabase = new WeakMap();
|
|
22
|
+
const sourceColumnByDatabase = new WeakMap();
|
|
23
|
+
|
|
24
|
+
function publisherAgentKeySelect(database, qualifier = "") {
|
|
25
|
+
let hasColumn = publisherKeyColumnByDatabase.get(database);
|
|
26
|
+
if (hasColumn === undefined) {
|
|
27
|
+
hasColumn = database.prepare("PRAGMA table_info(local_chat_messages)")
|
|
28
|
+
.all()
|
|
29
|
+
.some((column) => String(column.name) === "publisher_agent_key");
|
|
30
|
+
publisherKeyColumnByDatabase.set(database, hasColumn);
|
|
31
|
+
}
|
|
32
|
+
return hasColumn
|
|
33
|
+
? `${qualifier}publisher_agent_key AS publisher_agent_key`
|
|
34
|
+
: "NULL AS publisher_agent_key";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function messageSourceSelect(database, qualifier = "") {
|
|
38
|
+
let hasColumn = sourceColumnByDatabase.get(database);
|
|
39
|
+
if (hasColumn === undefined) {
|
|
40
|
+
hasColumn = database.prepare("PRAGMA table_info(local_chat_messages)")
|
|
41
|
+
.all()
|
|
42
|
+
.some((column) => String(column.name) === "source");
|
|
43
|
+
sourceColumnByDatabase.set(database, hasColumn);
|
|
44
|
+
}
|
|
45
|
+
return hasColumn ? `${qualifier}source AS source` : "NULL AS source";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runWithBusyTimeout(database, timeoutMs, work) {
|
|
49
|
+
const previousBusyTimeout = Number(database.prepare("PRAGMA busy_timeout").get()?.timeout ?? 0);
|
|
50
|
+
database.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(timeoutMs))}`);
|
|
51
|
+
try {
|
|
52
|
+
return work();
|
|
53
|
+
} finally {
|
|
54
|
+
database.exec(`PRAGMA busy_timeout = ${Math.max(0, previousBusyTimeout)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Install only bounded metadata/DDL. Historical rows are projected later. */
|
|
59
|
+
export function ensureLocalThreadRoutingProjectionSchema(database) {
|
|
60
|
+
return runNonblockingImmediateTransaction(database, () => {
|
|
61
|
+
// Feature-preview builds used an incompatible aliases table. A versioned,
|
|
62
|
+
// empty projection avoids ALTER/CREATE INDEX scans of historical rows and
|
|
63
|
+
// makes the complete schema cutover crash-atomic.
|
|
64
|
+
database.exec(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS local_chat_thread_routing_aliases_v2 (
|
|
66
|
+
alias_id INTEGER PRIMARY KEY,
|
|
67
|
+
room_id TEXT NOT NULL,
|
|
68
|
+
thread_root_number INTEGER NOT NULL,
|
|
69
|
+
participant_hash TEXT NOT NULL DEFAULT '',
|
|
70
|
+
participant_text TEXT NOT NULL DEFAULT '',
|
|
71
|
+
alias_hash TEXT NOT NULL CHECK (LENGTH(alias_hash) = 32),
|
|
72
|
+
alias_text TEXT NOT NULL,
|
|
73
|
+
is_full INTEGER NOT NULL DEFAULT 0 CHECK (is_full IN (0, 1))
|
|
74
|
+
);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS local_chat_thread_routing_alias_lookup_v2_idx
|
|
76
|
+
ON local_chat_thread_routing_aliases_v2 (room_id, alias_hash, thread_root_number);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS local_chat_thread_routing_alias_root_lookup_v2_idx
|
|
78
|
+
ON local_chat_thread_routing_aliases_v2 (room_id, thread_root_number, alias_hash);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS local_chat_thread_routing_participant_lookup_v2_idx
|
|
80
|
+
ON local_chat_thread_routing_aliases_v2 (
|
|
81
|
+
room_id, thread_root_number, participant_hash
|
|
82
|
+
);
|
|
83
|
+
CREATE TABLE IF NOT EXISTS local_chat_thread_routing_agents_v2 (
|
|
84
|
+
agent_id INTEGER PRIMARY KEY,
|
|
85
|
+
room_id TEXT NOT NULL,
|
|
86
|
+
thread_root_number INTEGER NOT NULL,
|
|
87
|
+
participant_hash TEXT NOT NULL,
|
|
88
|
+
participant_text TEXT NOT NULL,
|
|
89
|
+
agent_key_hash TEXT NOT NULL CHECK (LENGTH(agent_key_hash) = 32),
|
|
90
|
+
agent_key TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS local_chat_thread_routing_agent_lookup_v2_idx
|
|
93
|
+
ON local_chat_thread_routing_agents_v2 (
|
|
94
|
+
room_id, thread_root_number, agent_key_hash
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS local_chat_thread_routing_agent_participant_v2_idx
|
|
97
|
+
ON local_chat_thread_routing_agents_v2 (
|
|
98
|
+
room_id, thread_root_number, participant_hash
|
|
99
|
+
);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS local_chat_thread_routing_projection_state_v2 (
|
|
101
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
102
|
+
room_cursor TEXT NOT NULL DEFAULT '',
|
|
103
|
+
message_cursor INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1))
|
|
105
|
+
);
|
|
106
|
+
INSERT OR IGNORE INTO local_chat_thread_routing_projection_state_v2 (singleton)
|
|
107
|
+
VALUES (1);
|
|
108
|
+
CREATE TABLE IF NOT EXISTS local_chat_thread_routing_root_state_v2 (
|
|
109
|
+
room_id TEXT NOT NULL,
|
|
110
|
+
thread_root_number INTEGER NOT NULL,
|
|
111
|
+
through_message_number INTEGER NOT NULL,
|
|
112
|
+
PRIMARY KEY (room_id, thread_root_number)
|
|
113
|
+
);
|
|
114
|
+
CREATE TABLE IF NOT EXISTS local_chat_thread_routing_invalidated_roots_v2 (
|
|
115
|
+
room_id TEXT NOT NULL,
|
|
116
|
+
thread_root_number INTEGER NOT NULL,
|
|
117
|
+
cleanup_completed INTEGER NOT NULL DEFAULT 0 CHECK (cleanup_completed IN (0, 1)),
|
|
118
|
+
PRIMARY KEY (room_id, thread_root_number)
|
|
119
|
+
);
|
|
120
|
+
`);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Retry projection DDL without ever sleeping synchronously on SQLite's lock. */
|
|
125
|
+
export async function ensureLocalThreadRoutingProjectionSchemaAsync(database, options) {
|
|
126
|
+
await retrySqliteBusy(
|
|
127
|
+
() => ensureLocalThreadRoutingProjectionSchema(database),
|
|
128
|
+
options,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function participantIdentity(sender) {
|
|
133
|
+
const participantText = String(sender ?? "");
|
|
134
|
+
return {
|
|
135
|
+
participantText,
|
|
136
|
+
participantHash: routingAliasHash(participantText),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function participantProjectionStatements(database) {
|
|
141
|
+
return {
|
|
142
|
+
existing: database.prepare(`
|
|
143
|
+
SELECT 1 FROM local_chat_thread_routing_aliases_v2
|
|
144
|
+
WHERE room_id = ? AND thread_root_number = ?
|
|
145
|
+
AND participant_hash = ? AND participant_text = ?
|
|
146
|
+
AND alias_hash = ? AND alias_text = ? AND is_full = ?
|
|
147
|
+
LIMIT 1
|
|
148
|
+
`),
|
|
149
|
+
insert: database.prepare(`
|
|
150
|
+
INSERT INTO local_chat_thread_routing_aliases_v2 (
|
|
151
|
+
room_id, thread_root_number, participant_hash, participant_text,
|
|
152
|
+
alias_hash, alias_text, is_full
|
|
153
|
+
)
|
|
154
|
+
SELECT ?, ?, ?, ?, ?, ?, ?
|
|
155
|
+
WHERE NOT EXISTS (
|
|
156
|
+
SELECT 1 FROM local_chat_thread_routing_aliases_v2
|
|
157
|
+
WHERE room_id = ? AND thread_root_number = ?
|
|
158
|
+
AND participant_hash = ? AND participant_text = ?
|
|
159
|
+
AND alias_hash = ? AND alias_text = ? AND is_full = ?
|
|
160
|
+
)
|
|
161
|
+
`),
|
|
162
|
+
existingAgent: database.prepare(`
|
|
163
|
+
SELECT 1 FROM local_chat_thread_routing_agents_v2
|
|
164
|
+
WHERE room_id = ? AND thread_root_number = ?
|
|
165
|
+
AND participant_hash = ? AND participant_text = ?
|
|
166
|
+
AND agent_key_hash = ? AND agent_key = ?
|
|
167
|
+
LIMIT 1
|
|
168
|
+
`),
|
|
169
|
+
insertAgent: database.prepare(`
|
|
170
|
+
INSERT INTO local_chat_thread_routing_agents_v2 (
|
|
171
|
+
room_id, thread_root_number, participant_hash, participant_text,
|
|
172
|
+
agent_key_hash, agent_key
|
|
173
|
+
)
|
|
174
|
+
SELECT ?, ?, ?, ?, ?, ?
|
|
175
|
+
WHERE NOT EXISTS (
|
|
176
|
+
SELECT 1 FROM local_chat_thread_routing_agents_v2
|
|
177
|
+
WHERE room_id = ? AND thread_root_number = ?
|
|
178
|
+
AND participant_hash = ? AND participant_text = ?
|
|
179
|
+
AND agent_key_hash = ? AND agent_key = ?
|
|
180
|
+
)
|
|
181
|
+
`),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function projectLocalThreadRoutingSenderWithStatements(
|
|
186
|
+
statements,
|
|
187
|
+
roomId,
|
|
188
|
+
threadRootNumber,
|
|
189
|
+
sender,
|
|
190
|
+
source,
|
|
191
|
+
) {
|
|
192
|
+
// Alias authority is positive, not inferred from "not browser". Imported
|
|
193
|
+
// legacy/anonymous rows with a NULL source remain display-only; authenticated
|
|
194
|
+
// durable publisher keys are projected by the separate agent-key path.
|
|
195
|
+
if (String(source ?? "").trim() !== "agent") return;
|
|
196
|
+
const aliases = routingSenderAliasRows(sender);
|
|
197
|
+
if (aliases.length === 0) return;
|
|
198
|
+
const { participantHash, participantText } = participantIdentity(sender);
|
|
199
|
+
const sentinel = aliases.find(({ isFull }) => isFull) ?? aliases[0];
|
|
200
|
+
const existing = statements.existing.get(
|
|
201
|
+
roomId,
|
|
202
|
+
threadRootNumber,
|
|
203
|
+
participantHash,
|
|
204
|
+
participantText,
|
|
205
|
+
routingAliasHash(sentinel.alias),
|
|
206
|
+
sentinel.alias,
|
|
207
|
+
sentinel.isFull ? 1 : 0,
|
|
208
|
+
);
|
|
209
|
+
if (existing) return;
|
|
210
|
+
|
|
211
|
+
for (const { alias, isFull } of aliases) {
|
|
212
|
+
const hash = routingAliasHash(alias);
|
|
213
|
+
const full = isFull ? 1 : 0;
|
|
214
|
+
statements.insert.run(
|
|
215
|
+
roomId, threadRootNumber, participantHash, participantText, hash, alias, full,
|
|
216
|
+
roomId, threadRootNumber, participantHash, participantText, hash, alias, full,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function projectLocalThreadRoutingAgentWithStatements(
|
|
222
|
+
statements,
|
|
223
|
+
roomId,
|
|
224
|
+
threadRootNumber,
|
|
225
|
+
sender,
|
|
226
|
+
agentKeyInput,
|
|
227
|
+
) {
|
|
228
|
+
const agentKey = String(agentKeyInput ?? "").trim();
|
|
229
|
+
if (!agentKey) return;
|
|
230
|
+
const { participantHash, participantText } = participantIdentity(sender);
|
|
231
|
+
const agentKeyHash = routingAliasHash(agentKey);
|
|
232
|
+
if (statements.existingAgent.get(
|
|
233
|
+
roomId, threadRootNumber, participantHash, participantText, agentKeyHash, agentKey,
|
|
234
|
+
)) return;
|
|
235
|
+
statements.insertAgent.run(
|
|
236
|
+
roomId, threadRootNumber, participantHash, participantText, agentKeyHash, agentKey,
|
|
237
|
+
roomId, threadRootNumber, participantHash, participantText, agentKeyHash, agentKey,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function projectLocalThreadRoutingMessage(database, row) {
|
|
242
|
+
const threadRootNumber = Number(row?.thread_root_number ?? 0);
|
|
243
|
+
if (!threadRootNumber) return;
|
|
244
|
+
const invalidated = database.prepare(`
|
|
245
|
+
SELECT 1 FROM local_chat_thread_routing_invalidated_roots_v2
|
|
246
|
+
WHERE room_id = ? AND thread_root_number = ?
|
|
247
|
+
`).get(row.room_id, threadRootNumber);
|
|
248
|
+
// A corrected root is rebuilt from its authoritative rows by the bounded
|
|
249
|
+
// repair lane. Live writes during that window remain in local_chat_messages
|
|
250
|
+
// and are picked up by the same replay; they must not advance across the
|
|
251
|
+
// invalidated generation or expose a partial replacement.
|
|
252
|
+
if (invalidated) return;
|
|
253
|
+
const statements = participantProjectionStatements(database);
|
|
254
|
+
const root = database.prepare(`
|
|
255
|
+
SELECT sender, ${messageSourceSelect(database)}, ${publisherAgentKeySelect(database)}
|
|
256
|
+
FROM local_chat_messages WHERE room_id = ? AND number = ?
|
|
257
|
+
`).get(row.room_id, threadRootNumber);
|
|
258
|
+
if (root?.sender !== undefined) {
|
|
259
|
+
projectLocalThreadRoutingSenderWithStatements(
|
|
260
|
+
statements, row.room_id, threadRootNumber, root.sender, root.source,
|
|
261
|
+
);
|
|
262
|
+
projectLocalThreadRoutingAgentWithStatements(
|
|
263
|
+
statements, row.room_id, threadRootNumber, root.sender, root.publisher_agent_key,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
projectLocalThreadRoutingSenderWithStatements(
|
|
267
|
+
statements, row.room_id, threadRootNumber, row.sender, row.source,
|
|
268
|
+
);
|
|
269
|
+
projectLocalThreadRoutingAgentWithStatements(
|
|
270
|
+
statements, row.room_id, threadRootNumber, row.sender, row.publisher_agent_key,
|
|
271
|
+
);
|
|
272
|
+
const currentState = database.prepare(`
|
|
273
|
+
SELECT through_message_number
|
|
274
|
+
FROM local_chat_thread_routing_root_state_v2
|
|
275
|
+
WHERE room_id = ? AND thread_root_number = ?
|
|
276
|
+
`).get(row.room_id, threadRootNumber);
|
|
277
|
+
const priorReply = database.prepare(`
|
|
278
|
+
SELECT MAX(number) AS number
|
|
279
|
+
FROM local_chat_messages
|
|
280
|
+
WHERE room_id = ? AND thread_root_number = ? AND number < ?
|
|
281
|
+
`).get(row.room_id, threadRootNumber, Number(row.number));
|
|
282
|
+
const coveredThrough = Math.max(
|
|
283
|
+
Number(currentState?.through_message_number ?? 0),
|
|
284
|
+
root?.sender !== undefined ? threadRootNumber : 0,
|
|
285
|
+
);
|
|
286
|
+
const precedingMessage = Math.max(
|
|
287
|
+
root?.sender !== undefined && threadRootNumber < Number(row.number) ? threadRootNumber : 0,
|
|
288
|
+
Number(priorReply?.number ?? 0),
|
|
289
|
+
);
|
|
290
|
+
// A legacy process may have inserted the predecessor without maintaining
|
|
291
|
+
// this projection. Never advance across that gap: the bounded lazy/background
|
|
292
|
+
// repair will replay every row after the last proven contiguous cursor.
|
|
293
|
+
const throughMessageNumber = precedingMessage <= coveredThrough
|
|
294
|
+
? Math.max(coveredThrough, Number(row.number))
|
|
295
|
+
: coveredThrough;
|
|
296
|
+
if (throughMessageNumber <= 0) return;
|
|
297
|
+
database.prepare(`
|
|
298
|
+
INSERT INTO local_chat_thread_routing_root_state_v2 (
|
|
299
|
+
room_id, thread_root_number, through_message_number
|
|
300
|
+
) VALUES (?, ?, ?)
|
|
301
|
+
ON CONFLICT(room_id, thread_root_number) DO UPDATE SET
|
|
302
|
+
through_message_number = MAX(through_message_number, excluded.through_message_number)
|
|
303
|
+
`).run(row.room_id, threadRootNumber, throughMessageNumber);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function rollbackQuietly(database) {
|
|
307
|
+
try { database.exec("ROLLBACK"); } catch { /* transaction did not begin */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isSqliteBusy(error) {
|
|
311
|
+
const code = String(error?.code ?? "");
|
|
312
|
+
return code === "SQLITE_BUSY"
|
|
313
|
+
|| code === "SQLITE_LOCKED"
|
|
314
|
+
|| /\bdatabase (?:is )?(?:busy|locked)\b/i.test(String(error));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isTransientSqliteIo(error) {
|
|
318
|
+
const code = String(error?.code ?? "");
|
|
319
|
+
return code === "SQLITE_IOERR"
|
|
320
|
+
|| code.startsWith("SQLITE_IOERR_")
|
|
321
|
+
|| /\bdisk I\/O error\b/i.test(String(error));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Maintenance work must never inherit the foreground connection's multi-second
|
|
326
|
+
* busy timeout. Try the writer lock once, restore the caller's timeout, and let
|
|
327
|
+
* the async scheduler decide when to retry.
|
|
328
|
+
*/
|
|
329
|
+
function runNonblockingImmediateTransaction(database, work) {
|
|
330
|
+
return runWithBusyTimeout(database, 0, () => {
|
|
331
|
+
database.exec("BEGIN IMMEDIATE");
|
|
332
|
+
try {
|
|
333
|
+
const result = work();
|
|
334
|
+
database.exec("COMMIT");
|
|
335
|
+
return result;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
rollbackQuietly(database);
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** One committed batch; callers schedule another turn instead of looping. */
|
|
344
|
+
export function runLocalThreadRoutingBackfillBatch(
|
|
345
|
+
database,
|
|
346
|
+
batchSize = LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE,
|
|
347
|
+
) {
|
|
348
|
+
const boundedBatchSize = Math.max(
|
|
349
|
+
1,
|
|
350
|
+
Math.min(LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE, Math.floor(Number(batchSize) || LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE)),
|
|
351
|
+
);
|
|
352
|
+
const initialState = database.prepare(`
|
|
353
|
+
SELECT completed FROM local_chat_thread_routing_projection_state_v2 WHERE singleton = 1
|
|
354
|
+
`).get();
|
|
355
|
+
if (Number(initialState?.completed ?? 0) === 1) return { processed: 0, completed: true };
|
|
356
|
+
return runNonblockingImmediateTransaction(database, () => {
|
|
357
|
+
const state = database.prepare(`
|
|
358
|
+
SELECT room_cursor, message_cursor, completed
|
|
359
|
+
FROM local_chat_thread_routing_projection_state_v2 WHERE singleton = 1
|
|
360
|
+
`).get();
|
|
361
|
+
if (Number(state?.completed ?? 0) === 1) {
|
|
362
|
+
return { processed: 0, completed: true };
|
|
363
|
+
}
|
|
364
|
+
const roomCursor = String(state?.room_cursor ?? "");
|
|
365
|
+
const messageCursor = Number(state?.message_cursor ?? 0);
|
|
366
|
+
const rows = database.prepare(`
|
|
367
|
+
SELECT room_id, number, thread_root_number, sender,
|
|
368
|
+
${messageSourceSelect(database)},
|
|
369
|
+
${publisherAgentKeySelect(database)}
|
|
370
|
+
FROM local_chat_messages
|
|
371
|
+
WHERE thread_root_number IS NOT NULL
|
|
372
|
+
AND (room_id > ? OR (room_id = ? AND number > ?))
|
|
373
|
+
ORDER BY room_id, number
|
|
374
|
+
LIMIT ?
|
|
375
|
+
`).all(roomCursor, roomCursor, messageCursor, boundedBatchSize);
|
|
376
|
+
const startedAt = performance.now();
|
|
377
|
+
let processed = 0;
|
|
378
|
+
for (const row of rows) {
|
|
379
|
+
if (processed > 0 && performance.now() - startedAt >= LOCAL_THREAD_ROUTING_BACKFILL_TIME_BUDGET_MS) break;
|
|
380
|
+
projectLocalThreadRoutingMessage(database, row);
|
|
381
|
+
processed += 1;
|
|
382
|
+
}
|
|
383
|
+
const last = rows[processed - 1];
|
|
384
|
+
const completed = processed === rows.length && rows.length < boundedBatchSize;
|
|
385
|
+
database.prepare(`
|
|
386
|
+
UPDATE local_chat_thread_routing_projection_state_v2
|
|
387
|
+
SET room_cursor = ?, message_cursor = ?, completed = ? WHERE singleton = 1
|
|
388
|
+
`).run(
|
|
389
|
+
last ? String(last.room_id) : roomCursor,
|
|
390
|
+
last ? Number(last.number) : messageCursor,
|
|
391
|
+
completed ? 1 : 0,
|
|
392
|
+
);
|
|
393
|
+
return { processed, completed };
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Yield between batches so opening a 50k-message local database stays fast. */
|
|
398
|
+
export function scheduleLocalThreadRoutingBackfill(database, options = {}) {
|
|
399
|
+
if (scheduledDatabases.has(database)) return;
|
|
400
|
+
const state = { failures: 0 };
|
|
401
|
+
scheduledDatabases.set(database, state);
|
|
402
|
+
const scheduleImmediate = options.setImmediate ?? setImmediate;
|
|
403
|
+
const scheduleTimeout = options.setTimeout ?? setTimeout;
|
|
404
|
+
const onError = options.onError ?? ((error, delayMs) => {
|
|
405
|
+
const disposition = delayMs === null
|
|
406
|
+
? "parked until process restart"
|
|
407
|
+
: `retrying in ${delayMs}ms`;
|
|
408
|
+
console.error(`Local thread routing backfill failed; ${disposition}`, error);
|
|
409
|
+
});
|
|
410
|
+
const unref = (handle) => handle?.unref?.();
|
|
411
|
+
const run = () => {
|
|
412
|
+
let result;
|
|
413
|
+
try {
|
|
414
|
+
result = runLocalThreadRoutingBackfillBatch(database);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
// Busy/locked is ordinary cross-process contention. I/O errors get a
|
|
417
|
+
// bounded retry window; programmer/schema/corruption failures are parked
|
|
418
|
+
// after one durable diagnostic instead of waking and logging forever.
|
|
419
|
+
state.failures += 1;
|
|
420
|
+
const delayMs = Math.min(5_000, 25 * (2 ** Math.min(state.failures - 1, 8)));
|
|
421
|
+
const busy = isSqliteBusy(error);
|
|
422
|
+
const retryableIo = isTransientSqliteIo(error) && state.failures <= 8;
|
|
423
|
+
if (busy || retryableIo) {
|
|
424
|
+
if (retryableIo && state.failures === 1) onError(error, delayMs);
|
|
425
|
+
unref(scheduleTimeout(run, delayMs));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
onError(error, null);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
state.failures = 0;
|
|
432
|
+
if (result.completed) {
|
|
433
|
+
scheduledDatabases.delete(database);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
unref(scheduleImmediate(run));
|
|
437
|
+
};
|
|
438
|
+
unref(scheduleImmediate(run));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function yieldToEventLoop(unref = false) {
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
const handle = setImmediate(resolve);
|
|
444
|
+
if (unref) handle.unref?.();
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export class LocalThreadRoutingProjectionUnavailableError extends Error {
|
|
449
|
+
constructor() {
|
|
450
|
+
super("Local thread routing projection is still repairing; retry shortly.");
|
|
451
|
+
this.name = "LocalThreadRoutingProjectionUnavailableError";
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function lockRetryDelay(attempt, random) {
|
|
456
|
+
const ceiling = Math.min(
|
|
457
|
+
LOCAL_THREAD_ROUTING_LOCK_RETRY_MAX_MS,
|
|
458
|
+
LOCAL_THREAD_ROUTING_LOCK_RETRY_INITIAL_MS * (2 ** Math.min(attempt, 8)),
|
|
459
|
+
);
|
|
460
|
+
return Math.max(1, Math.floor(ceiling * (0.5 + (0.5 * random()))));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function retrySqliteBusy(work, options = {}) {
|
|
464
|
+
const maxWaitMs = Math.max(
|
|
465
|
+
0,
|
|
466
|
+
Math.floor(Number(options.maxWaitMs ?? LOCAL_THREAD_ROUTING_LOCK_RETRY_DEADLINE_MS)),
|
|
467
|
+
);
|
|
468
|
+
const random = options.random ?? Math.random;
|
|
469
|
+
const deadline = Date.now() + maxWaitMs;
|
|
470
|
+
let attempt = 0;
|
|
471
|
+
for (;;) {
|
|
472
|
+
try {
|
|
473
|
+
return work();
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
|
|
476
|
+
const remaining = deadline - Date.now();
|
|
477
|
+
const delayMs = Math.min(remaining, lockRetryDelay(attempt, random));
|
|
478
|
+
if (delayMs <= 0) throw error;
|
|
479
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
480
|
+
attempt += 1;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Run foreground schema/write maintenance without inheriting a multi-second
|
|
487
|
+
* SQLite busy timeout. Each lock attempt is synchronous and immediate; waits
|
|
488
|
+
* happen only through bounded asynchronous backoff between attempts.
|
|
489
|
+
*/
|
|
490
|
+
export async function runLocalSqliteWriteTransactionAsync(database, work, options) {
|
|
491
|
+
return await retrySqliteBusy(
|
|
492
|
+
() => runNonblockingImmediateTransaction(database, work),
|
|
493
|
+
options,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function requestedRootProjectionStatements(database) {
|
|
498
|
+
return {
|
|
499
|
+
ranges: database.prepare(`
|
|
500
|
+
WITH requested_root AS (
|
|
501
|
+
SELECT DISTINCT CAST(value AS INTEGER) AS thread_root_number
|
|
502
|
+
FROM json_each(?)
|
|
503
|
+
), latest AS (
|
|
504
|
+
SELECT requested_root.thread_root_number,
|
|
505
|
+
MAX(message.number) AS latest_number
|
|
506
|
+
FROM requested_root
|
|
507
|
+
LEFT JOIN local_chat_messages AS message
|
|
508
|
+
ON message.room_id = ?
|
|
509
|
+
AND ((message.number = requested_root.thread_root_number
|
|
510
|
+
AND message.thread_root_number IS NULL)
|
|
511
|
+
OR message.thread_root_number = requested_root.thread_root_number)
|
|
512
|
+
GROUP BY requested_root.thread_root_number
|
|
513
|
+
)
|
|
514
|
+
SELECT latest.thread_root_number,
|
|
515
|
+
COALESCE(latest.latest_number, 0) AS latest_number,
|
|
516
|
+
COALESCE(state.through_message_number, 0) AS through_message_number
|
|
517
|
+
FROM latest
|
|
518
|
+
LEFT JOIN local_chat_thread_routing_root_state_v2 AS state
|
|
519
|
+
ON state.room_id = ?
|
|
520
|
+
AND state.thread_root_number = latest.thread_root_number
|
|
521
|
+
`),
|
|
522
|
+
rows: database.prepare(`
|
|
523
|
+
WITH requested_root AS (
|
|
524
|
+
SELECT DISTINCT CAST(value AS INTEGER) AS thread_root_number
|
|
525
|
+
FROM json_each(?)
|
|
526
|
+
)
|
|
527
|
+
SELECT requested_root.thread_root_number, message.number, message.sender,
|
|
528
|
+
${messageSourceSelect(database, "message.")},
|
|
529
|
+
${publisherAgentKeySelect(database, "message.")}
|
|
530
|
+
FROM requested_root
|
|
531
|
+
JOIN local_chat_messages AS message
|
|
532
|
+
ON message.room_id = ?
|
|
533
|
+
AND ((message.number = requested_root.thread_root_number
|
|
534
|
+
AND message.thread_root_number IS NULL)
|
|
535
|
+
OR message.thread_root_number = requested_root.thread_root_number)
|
|
536
|
+
LEFT JOIN local_chat_thread_routing_root_state_v2 AS state
|
|
537
|
+
ON state.room_id = ?
|
|
538
|
+
AND state.thread_root_number = requested_root.thread_root_number
|
|
539
|
+
WHERE message.number > COALESCE(state.through_message_number, 0)
|
|
540
|
+
ORDER BY requested_root.thread_root_number, message.number
|
|
541
|
+
LIMIT ?
|
|
542
|
+
`),
|
|
543
|
+
state: database.prepare(`
|
|
544
|
+
INSERT INTO local_chat_thread_routing_root_state_v2 (
|
|
545
|
+
room_id, thread_root_number, through_message_number
|
|
546
|
+
) VALUES (?, ?, ?)
|
|
547
|
+
ON CONFLICT(room_id, thread_root_number) DO UPDATE SET
|
|
548
|
+
through_message_number = MAX(through_message_number, excluded.through_message_number)
|
|
549
|
+
`),
|
|
550
|
+
invalidated: database.prepare(`
|
|
551
|
+
SELECT thread_root_number
|
|
552
|
+
FROM local_chat_thread_routing_invalidated_roots_v2
|
|
553
|
+
WHERE room_id = ?
|
|
554
|
+
AND cleanup_completed = 0
|
|
555
|
+
AND thread_root_number IN (SELECT CAST(value AS INTEGER) FROM json_each(?))
|
|
556
|
+
`),
|
|
557
|
+
deleteInvalidatedAliases: database.prepare(`
|
|
558
|
+
DELETE FROM local_chat_thread_routing_aliases_v2
|
|
559
|
+
WHERE alias_id IN (
|
|
560
|
+
SELECT alias.alias_id
|
|
561
|
+
FROM local_chat_thread_routing_aliases_v2 AS alias
|
|
562
|
+
JOIN json_each(?) AS requested
|
|
563
|
+
ON alias.thread_root_number = CAST(requested.value AS INTEGER)
|
|
564
|
+
WHERE alias.room_id = ?
|
|
565
|
+
LIMIT ?
|
|
566
|
+
)
|
|
567
|
+
`),
|
|
568
|
+
deleteInvalidatedAgents: database.prepare(`
|
|
569
|
+
DELETE FROM local_chat_thread_routing_agents_v2
|
|
570
|
+
WHERE agent_id IN (
|
|
571
|
+
SELECT agent.agent_id
|
|
572
|
+
FROM local_chat_thread_routing_agents_v2 AS agent
|
|
573
|
+
JOIN json_each(?) AS requested
|
|
574
|
+
ON agent.thread_root_number = CAST(requested.value AS INTEGER)
|
|
575
|
+
WHERE agent.room_id = ?
|
|
576
|
+
LIMIT ?
|
|
577
|
+
)
|
|
578
|
+
`),
|
|
579
|
+
completeCleanup: database.prepare(`
|
|
580
|
+
UPDATE local_chat_thread_routing_invalidated_roots_v2 AS invalidated
|
|
581
|
+
SET cleanup_completed = 1
|
|
582
|
+
WHERE invalidated.room_id = ?
|
|
583
|
+
AND invalidated.cleanup_completed = 0
|
|
584
|
+
AND invalidated.thread_root_number IN (
|
|
585
|
+
SELECT CAST(value AS INTEGER) FROM json_each(?)
|
|
586
|
+
)
|
|
587
|
+
AND NOT EXISTS (
|
|
588
|
+
SELECT 1 FROM local_chat_thread_routing_aliases_v2 AS alias
|
|
589
|
+
WHERE alias.room_id = invalidated.room_id
|
|
590
|
+
AND alias.thread_root_number = invalidated.thread_root_number
|
|
591
|
+
)
|
|
592
|
+
AND NOT EXISTS (
|
|
593
|
+
SELECT 1 FROM local_chat_thread_routing_agents_v2 AS agent
|
|
594
|
+
WHERE agent.room_id = invalidated.room_id
|
|
595
|
+
AND agent.thread_root_number = invalidated.thread_root_number
|
|
596
|
+
)
|
|
597
|
+
RETURNING thread_root_number
|
|
598
|
+
`),
|
|
599
|
+
resetCleanedState: database.prepare(`
|
|
600
|
+
DELETE FROM local_chat_thread_routing_root_state_v2
|
|
601
|
+
WHERE room_id = ? AND thread_root_number = ?
|
|
602
|
+
`),
|
|
603
|
+
finalizeInvalidations: database.prepare(`
|
|
604
|
+
DELETE FROM local_chat_thread_routing_invalidated_roots_v2 AS invalidated
|
|
605
|
+
WHERE invalidated.room_id = ?
|
|
606
|
+
AND invalidated.cleanup_completed = 1
|
|
607
|
+
AND invalidated.thread_root_number IN (
|
|
608
|
+
SELECT CAST(value AS INTEGER) FROM json_each(?)
|
|
609
|
+
)
|
|
610
|
+
AND COALESCE((
|
|
611
|
+
SELECT state.through_message_number
|
|
612
|
+
FROM local_chat_thread_routing_root_state_v2 AS state
|
|
613
|
+
WHERE state.room_id = invalidated.room_id
|
|
614
|
+
AND state.thread_root_number = invalidated.thread_root_number
|
|
615
|
+
), 0) >= COALESCE((
|
|
616
|
+
SELECT MAX(message.number)
|
|
617
|
+
FROM local_chat_messages AS message
|
|
618
|
+
WHERE message.room_id = invalidated.room_id
|
|
619
|
+
AND ((message.number = invalidated.thread_root_number
|
|
620
|
+
AND message.thread_root_number IS NULL)
|
|
621
|
+
OR message.thread_root_number = invalidated.thread_root_number)
|
|
622
|
+
), 0)
|
|
623
|
+
`),
|
|
624
|
+
participant: participantProjectionStatements(database),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function pendingRequestedRoots(statements, roomId, rootNumbersJson) {
|
|
629
|
+
return statements.ranges.all(rootNumbersJson, roomId, roomId)
|
|
630
|
+
.filter((row) => Number(row.latest_number) > Number(row.through_message_number))
|
|
631
|
+
.map((row) => Number(row.thread_root_number));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function projectRequestedRootBatch(database, statements, roomId, rootNumbersJson) {
|
|
635
|
+
return runNonblockingImmediateTransaction(database, () => {
|
|
636
|
+
const invalidatedRoots = statements.invalidated
|
|
637
|
+
.all(roomId, rootNumbersJson)
|
|
638
|
+
.map((row) => Number(row.thread_root_number));
|
|
639
|
+
if (invalidatedRoots.length > 0) {
|
|
640
|
+
const invalidatedJson = JSON.stringify(invalidatedRoots);
|
|
641
|
+
const aliasesDeleted = Number(statements.deleteInvalidatedAliases.run(
|
|
642
|
+
invalidatedJson,
|
|
643
|
+
roomId,
|
|
644
|
+
LOCAL_THREAD_ROUTING_INVALIDATION_DELETE_BATCH_SIZE,
|
|
645
|
+
).changes ?? 0);
|
|
646
|
+
const agentsDeleted = Number(statements.deleteInvalidatedAgents.run(
|
|
647
|
+
invalidatedJson,
|
|
648
|
+
roomId,
|
|
649
|
+
LOCAL_THREAD_ROUTING_INVALIDATION_DELETE_BATCH_SIZE,
|
|
650
|
+
).changes ?? 0);
|
|
651
|
+
if (aliasesDeleted + agentsDeleted > 0) {
|
|
652
|
+
return { processed: aliasesDeleted + agentsDeleted };
|
|
653
|
+
}
|
|
654
|
+
const cleaned = statements.completeCleanup.all(roomId, invalidatedJson);
|
|
655
|
+
for (const row of cleaned) {
|
|
656
|
+
statements.resetCleanedState.run(roomId, Number(row.thread_root_number));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const rows = statements.rows.all(
|
|
660
|
+
rootNumbersJson,
|
|
661
|
+
roomId,
|
|
662
|
+
roomId,
|
|
663
|
+
LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE,
|
|
664
|
+
);
|
|
665
|
+
const startedAt = performance.now();
|
|
666
|
+
let processed = 0;
|
|
667
|
+
const throughByRoot = new Map();
|
|
668
|
+
for (const row of rows) {
|
|
669
|
+
if (processed > 0 && performance.now() - startedAt >= LOCAL_THREAD_ROUTING_BACKFILL_TIME_BUDGET_MS) break;
|
|
670
|
+
const rootNumber = Number(row.thread_root_number);
|
|
671
|
+
projectLocalThreadRoutingSenderWithStatements(
|
|
672
|
+
statements.participant,
|
|
673
|
+
roomId,
|
|
674
|
+
rootNumber,
|
|
675
|
+
String(row.sender ?? ""),
|
|
676
|
+
row.source,
|
|
677
|
+
);
|
|
678
|
+
projectLocalThreadRoutingAgentWithStatements(
|
|
679
|
+
statements.participant,
|
|
680
|
+
roomId,
|
|
681
|
+
rootNumber,
|
|
682
|
+
String(row.sender ?? ""),
|
|
683
|
+
row.publisher_agent_key,
|
|
684
|
+
);
|
|
685
|
+
throughByRoot.set(rootNumber, Number(row.number));
|
|
686
|
+
processed += 1;
|
|
687
|
+
}
|
|
688
|
+
for (const [rootNumber, throughMessageNumber] of throughByRoot) {
|
|
689
|
+
statements.state.run(roomId, rootNumber, throughMessageNumber);
|
|
690
|
+
}
|
|
691
|
+
statements.finalizeInvalidations.run(roomId, rootNumbersJson);
|
|
692
|
+
return { processed };
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const scheduledRequestedRepairs = new WeakMap();
|
|
697
|
+
|
|
698
|
+
function scheduleRequestedRootsRepair(database, roomId, rootNumbers) {
|
|
699
|
+
let roomRepairs = scheduledRequestedRepairs.get(database);
|
|
700
|
+
if (!roomRepairs) {
|
|
701
|
+
roomRepairs = new Map();
|
|
702
|
+
scheduledRequestedRepairs.set(database, roomRepairs);
|
|
703
|
+
}
|
|
704
|
+
const existing = roomRepairs.get(roomId);
|
|
705
|
+
if (existing) {
|
|
706
|
+
for (const rootNumber of rootNumbers) {
|
|
707
|
+
if (existing.roots.size >= LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) break;
|
|
708
|
+
existing.roots.add(rootNumber);
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const state = {
|
|
713
|
+
roots: new Set(rootNumbers.slice(0, LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS)),
|
|
714
|
+
failures: 0,
|
|
715
|
+
};
|
|
716
|
+
roomRepairs.set(roomId, state);
|
|
717
|
+
const unref = (handle) => handle?.unref?.();
|
|
718
|
+
const run = async () => {
|
|
719
|
+
const batchRoots = [...state.roots].slice(0, LOCAL_THREAD_ROUTING_REQUESTED_REPAIR_SLICE);
|
|
720
|
+
for (const rootNumber of batchRoots) state.roots.delete(rootNumber);
|
|
721
|
+
try {
|
|
722
|
+
await ensureRequestedRootsProjected(database, roomId, batchRoots, {
|
|
723
|
+
foregroundTimeBudgetMs: Number.POSITIVE_INFINITY,
|
|
724
|
+
scheduleOnTimeout: false,
|
|
725
|
+
unrefYields: true,
|
|
726
|
+
});
|
|
727
|
+
state.failures = 0;
|
|
728
|
+
} catch (error) {
|
|
729
|
+
state.failures += 1;
|
|
730
|
+
if ((isSqliteBusy(error) || isTransientSqliteIo(error)) && state.failures <= 8) {
|
|
731
|
+
for (const rootNumber of batchRoots) {
|
|
732
|
+
if (state.roots.size >= LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) break;
|
|
733
|
+
state.roots.add(rootNumber);
|
|
734
|
+
}
|
|
735
|
+
const delayMs = Math.min(5_000, 25 * (2 ** Math.min(state.failures - 1, 8)));
|
|
736
|
+
unref(setTimeout(() => void run(), delayMs));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
roomRepairs.delete(roomId);
|
|
740
|
+
console.error("Local requested-root routing repair failed; parked until retry", error);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (state.roots.size > 0) {
|
|
744
|
+
unref(setImmediate(() => void run()));
|
|
745
|
+
} else {
|
|
746
|
+
roomRepairs.delete(roomId);
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
unref(setImmediate(() => void run()));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Queue bounded asynchronous repair for roots invalidated by an import/update. */
|
|
753
|
+
export function scheduleLocalThreadRoutingRootsRepair(database, roomId, rootNumbersInput) {
|
|
754
|
+
const rootNumbers = [...new Set(rootNumbersInput
|
|
755
|
+
.map(Number)
|
|
756
|
+
.filter((value) => Number.isInteger(value) && value > 0))];
|
|
757
|
+
if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
|
|
758
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
759
|
+
}
|
|
760
|
+
if (rootNumbers.length > 0) scheduleRequestedRootsRepair(database, roomId, rootNumbers);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Invalidate projected roots in constant rows. Old-generation projection rows
|
|
765
|
+
* stay hidden behind the marker and are deleted in bounded async batches.
|
|
766
|
+
* Call this from the transaction that commits the authoritative correction.
|
|
767
|
+
*/
|
|
768
|
+
export function invalidateLocalThreadRoutingRoots(database, roomId, rootNumbersInput) {
|
|
769
|
+
const rootNumbers = [...new Set(rootNumbersInput
|
|
770
|
+
.map(Number)
|
|
771
|
+
.filter((value) => Number.isInteger(value) && value > 0))];
|
|
772
|
+
if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
|
|
773
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
774
|
+
}
|
|
775
|
+
const invalidate = database.prepare(`
|
|
776
|
+
INSERT INTO local_chat_thread_routing_invalidated_roots_v2 (
|
|
777
|
+
room_id, thread_root_number, cleanup_completed
|
|
778
|
+
) VALUES (?, ?, 0)
|
|
779
|
+
ON CONFLICT(room_id, thread_root_number) DO UPDATE SET cleanup_completed = 0
|
|
780
|
+
`);
|
|
781
|
+
const resetState = database.prepare(`
|
|
782
|
+
DELETE FROM local_chat_thread_routing_root_state_v2
|
|
783
|
+
WHERE room_id = ? AND thread_root_number = ?
|
|
784
|
+
`);
|
|
785
|
+
for (const rootNumber of rootNumbers) {
|
|
786
|
+
invalidate.run(roomId, rootNumber);
|
|
787
|
+
resetState.run(roomId, rootNumber);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
|
|
792
|
+
if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
|
|
793
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
794
|
+
}
|
|
795
|
+
const statements = requestedRootProjectionStatements(database);
|
|
796
|
+
const rootNumbersJson = JSON.stringify(rootNumbers);
|
|
797
|
+
const foregroundTimeBudgetMs = Number(
|
|
798
|
+
options.foregroundTimeBudgetMs ?? LOCAL_THREAD_ROUTING_FOREGROUND_REPAIR_BUDGET_MS,
|
|
799
|
+
);
|
|
800
|
+
const scheduleOnTimeout = options.scheduleOnTimeout !== false;
|
|
801
|
+
const foregroundStartedAt = performance.now();
|
|
802
|
+
let processedSinceYield = 0;
|
|
803
|
+
let workStartedAt = performance.now();
|
|
804
|
+
for (;;) {
|
|
805
|
+
if (options.signal?.aborted) {
|
|
806
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
807
|
+
}
|
|
808
|
+
const pendingRoots = pendingRequestedRoots(statements, roomId, rootNumbersJson);
|
|
809
|
+
if (pendingRoots.length === 0) break;
|
|
810
|
+
const remainingForegroundMs = foregroundTimeBudgetMs === Number.POSITIVE_INFINITY
|
|
811
|
+
? Number.POSITIVE_INFINITY
|
|
812
|
+
: Math.max(0, foregroundTimeBudgetMs - (performance.now() - foregroundStartedAt));
|
|
813
|
+
if (remainingForegroundMs <= 0) {
|
|
814
|
+
if (scheduleOnTimeout) scheduleRequestedRootsRepair(database, roomId, rootNumbers);
|
|
815
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
816
|
+
}
|
|
817
|
+
let batch;
|
|
818
|
+
try {
|
|
819
|
+
batch = await retrySqliteBusy(
|
|
820
|
+
() => projectRequestedRootBatch(
|
|
821
|
+
database,
|
|
822
|
+
statements,
|
|
823
|
+
roomId,
|
|
824
|
+
JSON.stringify(pendingRoots),
|
|
825
|
+
),
|
|
826
|
+
{
|
|
827
|
+
maxWaitMs: remainingForegroundMs === Number.POSITIVE_INFINITY
|
|
828
|
+
? LOCAL_THREAD_ROUTING_LOCK_RETRY_DEADLINE_MS
|
|
829
|
+
: remainingForegroundMs,
|
|
830
|
+
},
|
|
831
|
+
);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
if (isSqliteBusy(error) && foregroundTimeBudgetMs !== Number.POSITIVE_INFINITY) {
|
|
834
|
+
if (scheduleOnTimeout) scheduleRequestedRootsRepair(database, roomId, rootNumbers);
|
|
835
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
836
|
+
}
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
processedSinceYield += batch.processed;
|
|
840
|
+
const exhaustedSharedBudget = processedSinceYield >= LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE
|
|
841
|
+
|| performance.now() - workStartedAt >= LOCAL_THREAD_ROUTING_BACKFILL_TIME_BUDGET_MS;
|
|
842
|
+
if (batch.processed > 0 && exhaustedSharedBudget) {
|
|
843
|
+
await yieldToEventLoop(options.unrefYields === true);
|
|
844
|
+
processedSinceYield = 0;
|
|
845
|
+
workStartedAt = performance.now();
|
|
846
|
+
}
|
|
847
|
+
if (batch.processed === 0) break;
|
|
848
|
+
if (performance.now() - foregroundStartedAt >= foregroundTimeBudgetMs) {
|
|
849
|
+
if (scheduleOnTimeout) scheduleRequestedRootsRepair(database, roomId, rootNumbers);
|
|
850
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Resolve all requested durable identities together. A participant full-label
|
|
857
|
+
* match wins over pipe segments globally; ambiguous aliases activate nobody.
|
|
858
|
+
*/
|
|
859
|
+
export async function getLocalThreadRoutingAgentKeysForRoots(
|
|
860
|
+
database,
|
|
861
|
+
roomId,
|
|
862
|
+
rootNumbersInput,
|
|
863
|
+
identities,
|
|
864
|
+
options = {},
|
|
865
|
+
) {
|
|
866
|
+
const rootNumbers = [...new Set(rootNumbersInput
|
|
867
|
+
.map(Number)
|
|
868
|
+
.filter((value) => Number.isInteger(value) && value > 0))];
|
|
869
|
+
if (rootNumbers.length === 0 || identities.length === 0) return new Map();
|
|
870
|
+
await ensureRequestedRootsProjected(database, roomId, rootNumbers, options);
|
|
871
|
+
|
|
872
|
+
const keysByHash = new Map();
|
|
873
|
+
const durableKeysByHash = new Map();
|
|
874
|
+
for (const identity of identities) {
|
|
875
|
+
const agentKey = String(identity?.agentKey ?? identity?.agent_key ?? "").trim();
|
|
876
|
+
if (!agentKey) continue;
|
|
877
|
+
const durableHash = routingAliasHash(agentKey);
|
|
878
|
+
const durableKeys = durableKeysByHash.get(durableHash) ?? new Set();
|
|
879
|
+
durableKeys.add(agentKey);
|
|
880
|
+
durableKeysByHash.set(durableHash, durableKeys);
|
|
881
|
+
for (const alias of routingIdentityAliases(identity)) {
|
|
882
|
+
const hash = routingAliasHash(alias);
|
|
883
|
+
const aliases = keysByHash.get(hash) ?? new Map();
|
|
884
|
+
const keys = aliases.get(alias) ?? new Set();
|
|
885
|
+
keys.add(agentKey);
|
|
886
|
+
aliases.set(alias, keys);
|
|
887
|
+
keysByHash.set(hash, aliases);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (keysByHash.size === 0) return new Map();
|
|
891
|
+
|
|
892
|
+
const result = new Map();
|
|
893
|
+
const rootNumbersJson = JSON.stringify(rootNumbers);
|
|
894
|
+
const durableLookup = database.prepare(`
|
|
895
|
+
WITH candidate AS MATERIALIZED (
|
|
896
|
+
SELECT thread_root_number, agent_key_hash, agent_key
|
|
897
|
+
FROM local_chat_thread_routing_agents_v2
|
|
898
|
+
WHERE room_id = ?
|
|
899
|
+
AND agent_key_hash IN (SELECT value FROM json_each(?))
|
|
900
|
+
AND thread_root_number IN (SELECT CAST(value AS INTEGER) FROM json_each(?))
|
|
901
|
+
LIMIT ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS + 1}
|
|
902
|
+
)
|
|
903
|
+
SELECT DISTINCT thread_root_number, agent_key_hash, agent_key, 0 AS overflow
|
|
904
|
+
FROM candidate
|
|
905
|
+
UNION ALL
|
|
906
|
+
SELECT NULL, NULL, NULL, 1 AS overflow
|
|
907
|
+
WHERE (SELECT COUNT(*) FROM candidate) > ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS}
|
|
908
|
+
LIMIT ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS + 2}
|
|
909
|
+
`);
|
|
910
|
+
const durableHashes = [...durableKeysByHash.keys()];
|
|
911
|
+
let resultRowsConsumed = 0;
|
|
912
|
+
for (
|
|
913
|
+
let hashOffset = 0;
|
|
914
|
+
hashOffset < durableHashes.length;
|
|
915
|
+
hashOffset += LOCAL_THREAD_ROUTING_LOOKUP_BATCH_SIZE
|
|
916
|
+
) {
|
|
917
|
+
const durableRows = durableLookup.all(
|
|
918
|
+
roomId,
|
|
919
|
+
JSON.stringify(durableHashes.slice(
|
|
920
|
+
hashOffset,
|
|
921
|
+
hashOffset + LOCAL_THREAD_ROUTING_LOOKUP_BATCH_SIZE,
|
|
922
|
+
)),
|
|
923
|
+
rootNumbersJson,
|
|
924
|
+
);
|
|
925
|
+
if (durableRows.some((row) => Number(row.overflow) === 1)) {
|
|
926
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
927
|
+
}
|
|
928
|
+
resultRowsConsumed += durableRows.length;
|
|
929
|
+
if (resultRowsConsumed > LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS) {
|
|
930
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
931
|
+
}
|
|
932
|
+
for (const row of durableRows) {
|
|
933
|
+
const exactKeys = durableKeysByHash.get(String(row.agent_key_hash));
|
|
934
|
+
const agentKey = String(row.agent_key ?? "");
|
|
935
|
+
if (!exactKeys?.has(agentKey)) continue;
|
|
936
|
+
const root = Number(row.thread_root_number);
|
|
937
|
+
const keys = result.get(root) ?? new Set();
|
|
938
|
+
keys.add(agentKey);
|
|
939
|
+
result.set(root, keys);
|
|
940
|
+
}
|
|
941
|
+
await yieldToEventLoop();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const aliasInputs = [];
|
|
945
|
+
for (const [aliasHash, aliases] of keysByHash) {
|
|
946
|
+
for (const [aliasText, agentKeys] of aliases) {
|
|
947
|
+
for (const agentKey of agentKeys) {
|
|
948
|
+
aliasInputs.push({ alias_hash: aliasHash, alias_text: aliasText, agent_key: agentKey });
|
|
949
|
+
if (aliasInputs.length > LOCAL_THREAD_ROUTING_MAX_IDENTITY_ALIASES) {
|
|
950
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const lookup = database.prepare(`
|
|
956
|
+
WITH input_alias AS MATERIALIZED (
|
|
957
|
+
SELECT json_extract(value, '$.alias_hash') AS alias_hash,
|
|
958
|
+
json_extract(value, '$.alias_text') AS alias_text,
|
|
959
|
+
json_extract(value, '$.agent_key') AS agent_key
|
|
960
|
+
FROM json_each(?)
|
|
961
|
+
), input_root AS MATERIALIZED (
|
|
962
|
+
SELECT CAST(value AS INTEGER) AS thread_root_number
|
|
963
|
+
FROM json_each(?)
|
|
964
|
+
), matched AS MATERIALIZED (
|
|
965
|
+
SELECT alias.thread_root_number, alias.participant_hash, alias.participant_text,
|
|
966
|
+
alias.is_full, input_alias.agent_key
|
|
967
|
+
FROM input_root
|
|
968
|
+
CROSS JOIN local_chat_thread_routing_aliases_v2 AS alias
|
|
969
|
+
INDEXED BY local_chat_thread_routing_alias_root_lookup_v2_idx
|
|
970
|
+
JOIN input_alias
|
|
971
|
+
ON input_alias.alias_hash = alias.alias_hash
|
|
972
|
+
AND input_alias.alias_text = alias.alias_text
|
|
973
|
+
WHERE alias.room_id = ?
|
|
974
|
+
AND alias.thread_root_number = input_root.thread_root_number
|
|
975
|
+
AND alias.participant_text <> ''
|
|
976
|
+
AND NOT EXISTS (
|
|
977
|
+
SELECT 1 FROM local_chat_thread_routing_agents_v2 AS durable
|
|
978
|
+
WHERE durable.room_id = alias.room_id
|
|
979
|
+
AND durable.thread_root_number = alias.thread_root_number
|
|
980
|
+
AND durable.participant_hash = alias.participant_hash
|
|
981
|
+
AND durable.participant_text = alias.participant_text
|
|
982
|
+
)
|
|
983
|
+
LIMIT ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS + 1}
|
|
984
|
+
), ranked AS (
|
|
985
|
+
SELECT matched.*,
|
|
986
|
+
MAX(is_full) OVER (
|
|
987
|
+
PARTITION BY thread_root_number, participant_hash, participant_text
|
|
988
|
+
) AS preferred_is_full
|
|
989
|
+
FROM matched
|
|
990
|
+
), preferred AS (
|
|
991
|
+
SELECT * FROM ranked WHERE is_full = preferred_is_full
|
|
992
|
+
), unique_participant AS (
|
|
993
|
+
SELECT thread_root_number, participant_hash, participant_text,
|
|
994
|
+
MIN(agent_key) AS agent_key
|
|
995
|
+
FROM preferred
|
|
996
|
+
GROUP BY thread_root_number, participant_hash, participant_text
|
|
997
|
+
HAVING COUNT(DISTINCT agent_key) = 1
|
|
998
|
+
)
|
|
999
|
+
SELECT DISTINCT thread_root_number, agent_key, 0 AS overflow
|
|
1000
|
+
FROM unique_participant
|
|
1001
|
+
UNION ALL
|
|
1002
|
+
SELECT NULL, NULL, 1 AS overflow
|
|
1003
|
+
WHERE (SELECT COUNT(*) FROM matched) > ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS}
|
|
1004
|
+
LIMIT ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS + 2}
|
|
1005
|
+
`);
|
|
1006
|
+
const aliasInputsJson = JSON.stringify(aliasInputs);
|
|
1007
|
+
for (
|
|
1008
|
+
let rootOffset = 0;
|
|
1009
|
+
rootOffset < rootNumbers.length;
|
|
1010
|
+
rootOffset += LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS
|
|
1011
|
+
) {
|
|
1012
|
+
const rows = lookup.all(
|
|
1013
|
+
aliasInputsJson,
|
|
1014
|
+
JSON.stringify(rootNumbers.slice(
|
|
1015
|
+
rootOffset,
|
|
1016
|
+
rootOffset + LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS,
|
|
1017
|
+
)),
|
|
1018
|
+
roomId,
|
|
1019
|
+
);
|
|
1020
|
+
if (rows.some((row) => Number(row.overflow) === 1)) {
|
|
1021
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
1022
|
+
}
|
|
1023
|
+
resultRowsConsumed += rows.length;
|
|
1024
|
+
if (resultRowsConsumed > LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS) {
|
|
1025
|
+
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
1026
|
+
}
|
|
1027
|
+
for (const row of rows) {
|
|
1028
|
+
const root = Number(row.thread_root_number);
|
|
1029
|
+
const agentKey = String(row.agent_key ?? "");
|
|
1030
|
+
if (!agentKey) continue;
|
|
1031
|
+
const keys = result.get(root) ?? new Set();
|
|
1032
|
+
keys.add(agentKey);
|
|
1033
|
+
result.set(root, keys);
|
|
1034
|
+
}
|
|
1035
|
+
await yieldToEventLoop();
|
|
1036
|
+
}
|
|
1037
|
+
return result;
|
|
1038
|
+
}
|