@ixo/oracle-runtime-workers 0.10.0 → 0.11.1
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/README.md +5 -2
- package/package.json +1 -1
- package/src/core/context-budget.ts +201 -0
- package/src/core/context-window.ts +289 -0
- package/src/core/env.ts +29 -0
- package/src/core/main-agent-types.ts +46 -0
- package/src/core/main-agent.ts +86 -9
- package/src/core/middlewares/context-guard.ts +270 -0
- package/src/core/middlewares/result-cap.ts +180 -0
- package/src/core/middlewares/summarization.ts +87 -6
- package/src/core/middlewares/tool-marks.ts +209 -0
- package/src/core/openrouter-pricing.ts +42 -1
- package/src/core/read-result-tool.ts +77 -0
- package/src/core/runtime-context.ts +7 -0
- package/src/core/sub-agent-fallback.ts +20 -3
- package/src/core/wrap-plugin-tool.ts +24 -3
- package/src/do/ambient.ts +17 -0
- package/src/do/contracts.ts +229 -0
- package/src/do/flush-schedule.ts +40 -0
- package/src/do/result-store-test-do.ts +88 -0
- package/src/do/result-store.ts +339 -0
- package/src/do/run-buffer.ts +204 -0
- package/src/do/run-coordinator.ts +572 -0
- package/src/do/run-request.ts +52 -0
- package/src/do/run-store-test-do.ts +130 -0
- package/src/do/run-store.ts +604 -0
- package/src/do/session-id-migration.ts +85 -0
- package/src/do/speaker-prefix.ts +37 -0
- package/src/do/sse-stream.ts +567 -400
- package/src/do/transcript.ts +238 -0
- package/src/do/turn-body.ts +10 -0
- package/src/do/user-oracle-do.ts +948 -162
- package/src/matrix/gateway-do.ts +230 -29
- package/src/matrix/group-chat-summarizer.ts +107 -0
- package/src/matrix/group-chat.ts +1163 -0
- package/src/matrix/inbox-store.ts +23 -3
- package/src/matrix/ingest.ts +54 -11
- package/src/matrix/reply-chain.ts +34 -25
- package/src/plugin-api/tool-helper.ts +9 -1
- package/src/plugin-api/types.ts +86 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/matrix-group-chats/README.md +70 -0
- package/src/plugins/matrix-group-chats/index.ts +132 -0
- package/src/plugins/matrix-group-chats/tools.ts +172 -0
- package/src/plugins/tasks/tasks-tools.ts +4 -0
- package/src/shell/app.ts +66 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- package/src/tasks/store.ts +22 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable record of every turn the user object runs — HTTP, Matrix room
|
|
3
|
+
* and scheduled task turns alike — in the user's own SQLite, next to the
|
|
4
|
+
* checkpoints. Three tables:
|
|
5
|
+
*
|
|
6
|
+
* - `turn_runs`: one row per run, written before the first model call and
|
|
7
|
+
* closed when the run ends. It carries what a later incarnation of the
|
|
8
|
+
* object needs to resume the run after a reset (the request), how many
|
|
9
|
+
* recovery attempts were spent without progress, and the checkpoint id
|
|
10
|
+
* seen at the last attempt (progress resets the counter).
|
|
11
|
+
* - `turn_run_segments`: the run's output, packed by `RunBuffer` into one
|
|
12
|
+
* row per flush, so a client can re-join after any cursor and a recovery
|
|
13
|
+
* can restore exactly what the user had already seen. Deleted at cutover.
|
|
14
|
+
* - `turn_tool_marks`: one row per tool call, written BEFORE the tool runs
|
|
15
|
+
* (`started`) and updated when it returns (`done`). This is what makes a
|
|
16
|
+
* resume safe: a started-but-unfinished write call is never re-executed.
|
|
17
|
+
*
|
|
18
|
+
* Cost shape (SQLite-backed DO storage bills rows written or deleted): a run
|
|
19
|
+
* costs 1 insert + ~2 updates, a segment 1 insert + 1 delete, a tool call
|
|
20
|
+
* 1 insert + 1 update.
|
|
21
|
+
*/
|
|
22
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
23
|
+
import type { PackedSegment } from './run-buffer';
|
|
24
|
+
|
|
25
|
+
export type RunStatus =
|
|
26
|
+
| 'queued'
|
|
27
|
+
| 'running'
|
|
28
|
+
| 'recovering'
|
|
29
|
+
| 'finished'
|
|
30
|
+
| 'aborted'
|
|
31
|
+
| 'interrupted'
|
|
32
|
+
| 'failed';
|
|
33
|
+
|
|
34
|
+
export const ACTIVE_RUN_STATUSES: ReadonlySet<RunStatus> = new Set([
|
|
35
|
+
'queued',
|
|
36
|
+
'running',
|
|
37
|
+
'recovering',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export type ToolEffect = 'read' | 'write';
|
|
41
|
+
|
|
42
|
+
export interface RunRecord {
|
|
43
|
+
runId: string;
|
|
44
|
+
sessionId: string;
|
|
45
|
+
requestId: string;
|
|
46
|
+
client: 'portal' | 'matrix';
|
|
47
|
+
status: RunStatus;
|
|
48
|
+
startedAt: string;
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
/** JSON: the `TurnRequest` (minus attachments) plus the stream options needed to rebuild the producer. */
|
|
51
|
+
request: string;
|
|
52
|
+
/** Consecutive recovery attempts without progress. */
|
|
53
|
+
attempts: number;
|
|
54
|
+
/**
|
|
55
|
+
* Attempts started for this run, never reset. The frames of attempt `g`
|
|
56
|
+
* are numbered from `attemptSeqBase(g)`, so a client's cursor from an
|
|
57
|
+
* earlier attempt — even one pointing at frames that were never packed
|
|
58
|
+
* before the reset — is below every frame a later attempt produces.
|
|
59
|
+
*/
|
|
60
|
+
generation: number;
|
|
61
|
+
/** When the next recovery attempt is due (ms since epoch), while `recovering`. */
|
|
62
|
+
nextAttemptAt: number | null;
|
|
63
|
+
/** Checkpoint id observed when the run (or its last attempt) started. */
|
|
64
|
+
checkpointId: string | null;
|
|
65
|
+
/** Highest frame sequence packed into a segment. */
|
|
66
|
+
lastSeq: number;
|
|
67
|
+
/** The reply so far, kept when the run ends without a committed reply. */
|
|
68
|
+
partialText: string | null;
|
|
69
|
+
/** Final assistant message id, when finished. */
|
|
70
|
+
messageId: string | null;
|
|
71
|
+
error: string | null;
|
|
72
|
+
/** Scheduler run id when this is a task run (delivered after recovery). */
|
|
73
|
+
taskRunId: string | null;
|
|
74
|
+
/** Instance that owns the run — a different instance at boot means an orphan. */
|
|
75
|
+
instanceId: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ToolMark {
|
|
79
|
+
runId: string;
|
|
80
|
+
toolCallId: string;
|
|
81
|
+
toolName: string;
|
|
82
|
+
effect: ToolEffect;
|
|
83
|
+
startedAt: string;
|
|
84
|
+
doneAt: string | null;
|
|
85
|
+
/** `ok` | `error` | `interrupted` once done. */
|
|
86
|
+
outcome: string | null;
|
|
87
|
+
/** Executions started for this call id (a read re-run bumps it). */
|
|
88
|
+
attempts: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rows of ended runs are kept a week (a re-join that late gets 404). */
|
|
92
|
+
export const RUN_RETENTION_MS = 7 * 24 * 3600 * 1000;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sequence-number space of one attempt. A cursor is `after=<seq>`; the
|
|
96
|
+
* next attempt starts numbering at the next multiple of this, above any
|
|
97
|
+
* frame the previous attempt could have handed out (packed or not).
|
|
98
|
+
*/
|
|
99
|
+
export const SEQ_ATTEMPT_SPAN = 2 ** 32;
|
|
100
|
+
|
|
101
|
+
/** First sequence number (exclusive) of attempt `generation` (0 = the original attempt). */
|
|
102
|
+
export function attemptSeqBase(generation: number): number {
|
|
103
|
+
return Math.max(0, Math.floor(generation)) * SEQ_ATTEMPT_SPAN;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const DEFAULT_RECOVERY_DELAYS_MS: readonly number[] = [
|
|
107
|
+
5_000, 15_000, 30_000, 60_000,
|
|
108
|
+
];
|
|
109
|
+
export const DEFAULT_RECOVERY_ATTEMPTS = 4;
|
|
110
|
+
export const DEFAULT_KEEPALIVE_MS = 20_000;
|
|
111
|
+
export const DEFAULT_SEGMENT_FLUSH_MS = 2_000;
|
|
112
|
+
export const DEFAULT_SEGMENT_BYTES = 16 * 1024;
|
|
113
|
+
|
|
114
|
+
export interface RunDurabilityConfig {
|
|
115
|
+
keepAliveMs: number;
|
|
116
|
+
segmentFlushMs: number;
|
|
117
|
+
segmentBytes: number;
|
|
118
|
+
recoveryAttempts: number;
|
|
119
|
+
recoveryDelaysMs: readonly number[];
|
|
120
|
+
multitaskDefault: 'interrupt' | 'enqueue';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Parse the `RUN_*` / `TURN_MULTITASK_DEFAULT` env knobs (defaults documented in docs/plans/durable-runs.md). */
|
|
124
|
+
export function runDurabilityConfig(
|
|
125
|
+
env: Record<string, unknown>,
|
|
126
|
+
): RunDurabilityConfig {
|
|
127
|
+
const int = (key: string, fallback: number, min = 1): number => {
|
|
128
|
+
const raw = env[key];
|
|
129
|
+
const n = typeof raw === 'string' ? Number(raw) : Number.NaN;
|
|
130
|
+
return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
|
|
131
|
+
};
|
|
132
|
+
const delaysRaw = env.RUN_RECOVERY_DELAYS_MS;
|
|
133
|
+
const delays =
|
|
134
|
+
typeof delaysRaw === 'string'
|
|
135
|
+
? delaysRaw
|
|
136
|
+
.split(',')
|
|
137
|
+
.map((s) => Number(s.trim()))
|
|
138
|
+
.filter((n) => Number.isFinite(n) && n >= 0)
|
|
139
|
+
: [];
|
|
140
|
+
const multitask = env.TURN_MULTITASK_DEFAULT;
|
|
141
|
+
return {
|
|
142
|
+
keepAliveMs: int('RUN_KEEPALIVE_MS', DEFAULT_KEEPALIVE_MS, 1000),
|
|
143
|
+
segmentFlushMs: int('RUN_SEGMENT_FLUSH_MS', DEFAULT_SEGMENT_FLUSH_MS, 100),
|
|
144
|
+
segmentBytes: int('RUN_SEGMENT_BYTES', DEFAULT_SEGMENT_BYTES, 1024),
|
|
145
|
+
recoveryAttempts: int(
|
|
146
|
+
'RUN_RECOVERY_ATTEMPTS',
|
|
147
|
+
DEFAULT_RECOVERY_ATTEMPTS,
|
|
148
|
+
0,
|
|
149
|
+
),
|
|
150
|
+
recoveryDelaysMs: delays.length > 0 ? delays : DEFAULT_RECOVERY_DELAYS_MS,
|
|
151
|
+
multitaskDefault: multitask === 'enqueue' ? 'enqueue' : 'interrupt',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type RecoveryDecision =
|
|
156
|
+
| { action: 'schedule'; at: number; attempts: number }
|
|
157
|
+
| { action: 'interrupt'; attempts: number };
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What to do with an orphaned run found at boot (or an attempt that just
|
|
161
|
+
* failed). Progress — a checkpoint newer than the one seen at the previous
|
|
162
|
+
* attempt — resets the counter; `attempts` in the result is the number of
|
|
163
|
+
* consecutive no-progress attempts INCLUDING the one being scheduled.
|
|
164
|
+
*/
|
|
165
|
+
export function decideRecovery(
|
|
166
|
+
run: Pick<RunRecord, 'attempts' | 'checkpointId'>,
|
|
167
|
+
currentCheckpointId: string | null,
|
|
168
|
+
now: number,
|
|
169
|
+
config: Pick<RunDurabilityConfig, 'recoveryAttempts' | 'recoveryDelaysMs'>,
|
|
170
|
+
): RecoveryDecision {
|
|
171
|
+
const progressed =
|
|
172
|
+
currentCheckpointId !== null && currentCheckpointId !== run.checkpointId;
|
|
173
|
+
const spent = progressed ? 0 : run.attempts;
|
|
174
|
+
if (spent >= config.recoveryAttempts)
|
|
175
|
+
return { action: 'interrupt', attempts: spent };
|
|
176
|
+
const delays = config.recoveryDelaysMs;
|
|
177
|
+
const delay = delays[Math.min(spent, delays.length - 1)] ?? 0;
|
|
178
|
+
return { action: 'schedule', at: now + delay, attempts: spent + 1 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type RunRow = {
|
|
182
|
+
run_id: string;
|
|
183
|
+
session_id: string;
|
|
184
|
+
request_id: string;
|
|
185
|
+
client: string;
|
|
186
|
+
status: string;
|
|
187
|
+
started_at: string;
|
|
188
|
+
updated_at: string;
|
|
189
|
+
request: string;
|
|
190
|
+
attempts: number;
|
|
191
|
+
generation: number;
|
|
192
|
+
next_attempt_at: number | null;
|
|
193
|
+
checkpoint_id: string | null;
|
|
194
|
+
last_seq: number;
|
|
195
|
+
partial_text: string | null;
|
|
196
|
+
message_id: string | null;
|
|
197
|
+
error: string | null;
|
|
198
|
+
task_run_id: string | null;
|
|
199
|
+
instance_id: string;
|
|
200
|
+
} & Record<string, string | number | null>;
|
|
201
|
+
|
|
202
|
+
type MarkRow = {
|
|
203
|
+
run_id: string;
|
|
204
|
+
tool_call_id: string;
|
|
205
|
+
tool_name: string;
|
|
206
|
+
effect: string;
|
|
207
|
+
started_at: string;
|
|
208
|
+
done_at: string | null;
|
|
209
|
+
outcome: string | null;
|
|
210
|
+
attempts: number;
|
|
211
|
+
} & Record<string, string | number | null>;
|
|
212
|
+
|
|
213
|
+
type SegmentRow = {
|
|
214
|
+
seq_from: number;
|
|
215
|
+
seq_to: number;
|
|
216
|
+
payload: string;
|
|
217
|
+
} & Record<string, string | number | null>;
|
|
218
|
+
|
|
219
|
+
const RUN_COLUMNS =
|
|
220
|
+
'run_id, session_id, request_id, client, status, started_at, updated_at, request, attempts, generation, next_attempt_at, checkpoint_id, last_seq, partial_text, message_id, error, task_run_id, instance_id';
|
|
221
|
+
|
|
222
|
+
function toRecord(row: RunRow): RunRecord {
|
|
223
|
+
return {
|
|
224
|
+
runId: row.run_id,
|
|
225
|
+
sessionId: row.session_id,
|
|
226
|
+
requestId: row.request_id,
|
|
227
|
+
client: row.client === 'matrix' ? 'matrix' : 'portal',
|
|
228
|
+
status: row.status as RunStatus,
|
|
229
|
+
startedAt: row.started_at,
|
|
230
|
+
updatedAt: row.updated_at,
|
|
231
|
+
request: row.request,
|
|
232
|
+
attempts: Number(row.attempts),
|
|
233
|
+
generation: Number(row.generation),
|
|
234
|
+
nextAttemptAt:
|
|
235
|
+
row.next_attempt_at === null ? null : Number(row.next_attempt_at),
|
|
236
|
+
checkpointId: row.checkpoint_id,
|
|
237
|
+
lastSeq: Number(row.last_seq),
|
|
238
|
+
partialText: row.partial_text,
|
|
239
|
+
messageId: row.message_id,
|
|
240
|
+
error: row.error,
|
|
241
|
+
taskRunId: row.task_run_id,
|
|
242
|
+
instanceId: row.instance_id,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function toMark(row: MarkRow): ToolMark {
|
|
247
|
+
return {
|
|
248
|
+
runId: row.run_id,
|
|
249
|
+
toolCallId: row.tool_call_id,
|
|
250
|
+
toolName: row.tool_name,
|
|
251
|
+
effect: row.effect === 'read' ? 'read' : 'write',
|
|
252
|
+
startedAt: row.started_at,
|
|
253
|
+
doneAt: row.done_at,
|
|
254
|
+
outcome: row.outcome,
|
|
255
|
+
attempts: Number(row.attempts),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export class RunStore {
|
|
260
|
+
private setupPromise: Promise<void> | undefined;
|
|
261
|
+
|
|
262
|
+
constructor(
|
|
263
|
+
readonly db: DoSqliteDatabase,
|
|
264
|
+
private readonly now: () => number = () => Date.now(),
|
|
265
|
+
) {}
|
|
266
|
+
|
|
267
|
+
setup(): Promise<void> {
|
|
268
|
+
this.setupPromise ??= this.doSetup().catch((error: unknown) => {
|
|
269
|
+
this.setupPromise = undefined;
|
|
270
|
+
throw error;
|
|
271
|
+
});
|
|
272
|
+
return this.setupPromise;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async doSetup(): Promise<void> {
|
|
276
|
+
await this.db.run(`
|
|
277
|
+
CREATE TABLE IF NOT EXISTS turn_runs (
|
|
278
|
+
run_id TEXT PRIMARY KEY,
|
|
279
|
+
session_id TEXT NOT NULL,
|
|
280
|
+
request_id TEXT NOT NULL,
|
|
281
|
+
client TEXT NOT NULL,
|
|
282
|
+
status TEXT NOT NULL,
|
|
283
|
+
started_at TEXT NOT NULL,
|
|
284
|
+
updated_at TEXT NOT NULL,
|
|
285
|
+
request TEXT NOT NULL,
|
|
286
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
287
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
288
|
+
next_attempt_at INTEGER,
|
|
289
|
+
checkpoint_id TEXT,
|
|
290
|
+
last_seq INTEGER NOT NULL DEFAULT 0,
|
|
291
|
+
partial_text TEXT,
|
|
292
|
+
message_id TEXT,
|
|
293
|
+
error TEXT,
|
|
294
|
+
task_run_id TEXT,
|
|
295
|
+
instance_id TEXT NOT NULL
|
|
296
|
+
)`);
|
|
297
|
+
// Columns added after the table first shipped (CREATE TABLE IF NOT
|
|
298
|
+
// EXISTS leaves an existing table as it was).
|
|
299
|
+
const columns = await this.db.exec<{ name: string }>(
|
|
300
|
+
`PRAGMA table_info(turn_runs)`,
|
|
301
|
+
);
|
|
302
|
+
if (!columns.some((c) => c.name === 'generation'))
|
|
303
|
+
await this.db.run(
|
|
304
|
+
`ALTER TABLE turn_runs ADD COLUMN generation INTEGER NOT NULL DEFAULT 0`,
|
|
305
|
+
);
|
|
306
|
+
await this.db.run(
|
|
307
|
+
`CREATE INDEX IF NOT EXISTS idx_turn_runs_session ON turn_runs(session_id, started_at)`,
|
|
308
|
+
);
|
|
309
|
+
await this.db.run(`
|
|
310
|
+
CREATE TABLE IF NOT EXISTS turn_run_segments (
|
|
311
|
+
run_id TEXT NOT NULL,
|
|
312
|
+
seq_from INTEGER NOT NULL,
|
|
313
|
+
seq_to INTEGER NOT NULL,
|
|
314
|
+
payload TEXT NOT NULL,
|
|
315
|
+
PRIMARY KEY (run_id, seq_from)
|
|
316
|
+
) WITHOUT ROWID`);
|
|
317
|
+
await this.db.run(`
|
|
318
|
+
CREATE TABLE IF NOT EXISTS turn_tool_marks (
|
|
319
|
+
run_id TEXT NOT NULL,
|
|
320
|
+
tool_call_id TEXT NOT NULL,
|
|
321
|
+
tool_name TEXT NOT NULL,
|
|
322
|
+
effect TEXT NOT NULL,
|
|
323
|
+
started_at TEXT NOT NULL,
|
|
324
|
+
done_at TEXT,
|
|
325
|
+
outcome TEXT,
|
|
326
|
+
attempts INTEGER NOT NULL DEFAULT 1,
|
|
327
|
+
PRIMARY KEY (run_id, tool_call_id)
|
|
328
|
+
) WITHOUT ROWID`);
|
|
329
|
+
// Ended runs older than the retention window: their segments are gone
|
|
330
|
+
// already (cutover); drop the rows and marks in one pass per boot.
|
|
331
|
+
const cutoff = new Date(this.now() - RUN_RETENTION_MS).toISOString();
|
|
332
|
+
const stale = await this.db.exec<{ run_id: string }>(
|
|
333
|
+
`SELECT run_id FROM turn_runs WHERE status IN ('finished','aborted','interrupted','failed') AND updated_at < ?`,
|
|
334
|
+
[cutoff],
|
|
335
|
+
);
|
|
336
|
+
for (const row of stale) {
|
|
337
|
+
await this.db.run(`DELETE FROM turn_tool_marks WHERE run_id = ?`, [
|
|
338
|
+
row.run_id,
|
|
339
|
+
]);
|
|
340
|
+
await this.db.run(`DELETE FROM turn_run_segments WHERE run_id = ?`, [
|
|
341
|
+
row.run_id,
|
|
342
|
+
]);
|
|
343
|
+
await this.db.run(`DELETE FROM turn_runs WHERE run_id = ?`, [row.run_id]);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private iso(): string {
|
|
348
|
+
return new Date(this.now()).toISOString();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── runs ────────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
async create(input: {
|
|
354
|
+
runId: string;
|
|
355
|
+
sessionId: string;
|
|
356
|
+
requestId: string;
|
|
357
|
+
client: 'portal' | 'matrix';
|
|
358
|
+
status: 'queued' | 'running';
|
|
359
|
+
request: string;
|
|
360
|
+
checkpointId: string | null;
|
|
361
|
+
taskRunId?: string | null;
|
|
362
|
+
instanceId: string;
|
|
363
|
+
}): Promise<void> {
|
|
364
|
+
await this.setup();
|
|
365
|
+
const at = this.iso();
|
|
366
|
+
await this.db.run(
|
|
367
|
+
`INSERT INTO turn_runs (${RUN_COLUMNS})
|
|
368
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?, 0, NULL, NULL, NULL, ?, ?)`,
|
|
369
|
+
[
|
|
370
|
+
input.runId,
|
|
371
|
+
input.sessionId,
|
|
372
|
+
input.requestId,
|
|
373
|
+
input.client,
|
|
374
|
+
input.status,
|
|
375
|
+
at,
|
|
376
|
+
at,
|
|
377
|
+
input.request,
|
|
378
|
+
input.checkpointId,
|
|
379
|
+
input.taskRunId ?? null,
|
|
380
|
+
input.instanceId,
|
|
381
|
+
],
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async get(runId: string): Promise<RunRecord | undefined> {
|
|
386
|
+
await this.setup();
|
|
387
|
+
const row = await this.db.get<RunRow>(
|
|
388
|
+
`SELECT ${RUN_COLUMNS} FROM turn_runs WHERE run_id = ?`,
|
|
389
|
+
[runId],
|
|
390
|
+
);
|
|
391
|
+
return row ? toRecord(row) : undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async getByRequestId(requestId: string): Promise<RunRecord | undefined> {
|
|
395
|
+
await this.setup();
|
|
396
|
+
const row = await this.db.get<RunRow>(
|
|
397
|
+
`SELECT ${RUN_COLUMNS} FROM turn_runs WHERE request_id = ? ORDER BY started_at DESC LIMIT 1`,
|
|
398
|
+
[requestId],
|
|
399
|
+
);
|
|
400
|
+
return row ? toRecord(row) : undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The run a session is busy with (running/recovering), or the oldest queued one. */
|
|
404
|
+
async activeForSession(sessionId: string): Promise<RunRecord | undefined> {
|
|
405
|
+
await this.setup();
|
|
406
|
+
const row = await this.db.get<RunRow>(
|
|
407
|
+
`SELECT ${RUN_COLUMNS} FROM turn_runs
|
|
408
|
+
WHERE session_id = ? AND status IN ('running','recovering','queued')
|
|
409
|
+
ORDER BY CASE status WHEN 'running' THEN 0 WHEN 'recovering' THEN 1 ELSE 2 END, started_at ASC
|
|
410
|
+
LIMIT 1`,
|
|
411
|
+
[sessionId],
|
|
412
|
+
);
|
|
413
|
+
return row ? toRecord(row) : undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Every run that is not over, oldest first. */
|
|
417
|
+
async listActive(): Promise<RunRecord[]> {
|
|
418
|
+
await this.setup();
|
|
419
|
+
const rows = await this.db.exec<RunRow>(
|
|
420
|
+
`SELECT ${RUN_COLUMNS} FROM turn_runs
|
|
421
|
+
WHERE status IN ('running','recovering','queued') ORDER BY started_at ASC`,
|
|
422
|
+
);
|
|
423
|
+
return rows.map(toRecord);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Queued runs of one session, oldest first. */
|
|
427
|
+
async queuedForSession(sessionId: string): Promise<RunRecord[]> {
|
|
428
|
+
await this.setup();
|
|
429
|
+
const rows = await this.db.exec<RunRow>(
|
|
430
|
+
`SELECT ${RUN_COLUMNS} FROM turn_runs
|
|
431
|
+
WHERE session_id = ? AND status = 'queued' ORDER BY started_at ASC`,
|
|
432
|
+
[sessionId],
|
|
433
|
+
);
|
|
434
|
+
return rows.map(toRecord);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Recent runs (any status), newest first — the debug view. */
|
|
438
|
+
async listRecent(limit = 50): Promise<RunRecord[]> {
|
|
439
|
+
await this.setup();
|
|
440
|
+
const rows = await this.db.exec<RunRow>(
|
|
441
|
+
`SELECT ${RUN_COLUMNS} FROM turn_runs ORDER BY started_at DESC LIMIT ?`,
|
|
442
|
+
[limit],
|
|
443
|
+
);
|
|
444
|
+
return rows.map(toRecord);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async update(
|
|
448
|
+
runId: string,
|
|
449
|
+
patch: Partial<
|
|
450
|
+
Pick<
|
|
451
|
+
RunRecord,
|
|
452
|
+
| 'status'
|
|
453
|
+
| 'attempts'
|
|
454
|
+
| 'generation'
|
|
455
|
+
| 'nextAttemptAt'
|
|
456
|
+
| 'checkpointId'
|
|
457
|
+
| 'lastSeq'
|
|
458
|
+
| 'partialText'
|
|
459
|
+
| 'messageId'
|
|
460
|
+
| 'error'
|
|
461
|
+
| 'taskRunId'
|
|
462
|
+
| 'instanceId'
|
|
463
|
+
>
|
|
464
|
+
>,
|
|
465
|
+
): Promise<void> {
|
|
466
|
+
await this.setup();
|
|
467
|
+
const sets: string[] = ['updated_at = ?'];
|
|
468
|
+
const params: Array<string | number | null> = [this.iso()];
|
|
469
|
+
const column: Record<string, string> = {
|
|
470
|
+
status: 'status',
|
|
471
|
+
attempts: 'attempts',
|
|
472
|
+
generation: 'generation',
|
|
473
|
+
nextAttemptAt: 'next_attempt_at',
|
|
474
|
+
checkpointId: 'checkpoint_id',
|
|
475
|
+
lastSeq: 'last_seq',
|
|
476
|
+
partialText: 'partial_text',
|
|
477
|
+
messageId: 'message_id',
|
|
478
|
+
error: 'error',
|
|
479
|
+
taskRunId: 'task_run_id',
|
|
480
|
+
instanceId: 'instance_id',
|
|
481
|
+
};
|
|
482
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
483
|
+
if (value === undefined) continue;
|
|
484
|
+
sets.push(`${column[key]} = ?`);
|
|
485
|
+
params.push(value);
|
|
486
|
+
}
|
|
487
|
+
params.push(runId);
|
|
488
|
+
await this.db.run(
|
|
489
|
+
`UPDATE turn_runs SET ${sets.join(', ')} WHERE run_id = ?`,
|
|
490
|
+
params,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── segments ────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
async appendSegment(runId: string, segment: PackedSegment): Promise<void> {
|
|
497
|
+
await this.setup();
|
|
498
|
+
await this.db.run(
|
|
499
|
+
`INSERT OR REPLACE INTO turn_run_segments (run_id, seq_from, seq_to, payload) VALUES (?, ?, ?, ?)`,
|
|
500
|
+
[runId, segment.seqFrom, segment.seqTo, segment.payload],
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Segments whose last frame is after `after`, in order. */
|
|
505
|
+
async readSegments(runId: string, after = 0): Promise<PackedSegment[]> {
|
|
506
|
+
await this.setup();
|
|
507
|
+
const rows = await this.db.exec<SegmentRow>(
|
|
508
|
+
`SELECT seq_from, seq_to, payload FROM turn_run_segments
|
|
509
|
+
WHERE run_id = ? AND seq_to > ? ORDER BY seq_from ASC`,
|
|
510
|
+
[runId, after],
|
|
511
|
+
);
|
|
512
|
+
return rows.map((row) => ({
|
|
513
|
+
seqFrom: Number(row.seq_from),
|
|
514
|
+
seqTo: Number(row.seq_to),
|
|
515
|
+
payload: row.payload,
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async countSegments(runId: string): Promise<number> {
|
|
520
|
+
await this.setup();
|
|
521
|
+
const row = await this.db.get<{ n: number }>(
|
|
522
|
+
`SELECT COUNT(*) AS n FROM turn_run_segments WHERE run_id = ?`,
|
|
523
|
+
[runId],
|
|
524
|
+
);
|
|
525
|
+
return Number(row?.n ?? 0);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Cutover: the reply is in the transcript, the segments are not needed. */
|
|
529
|
+
async deleteSegments(runId: string): Promise<void> {
|
|
530
|
+
await this.setup();
|
|
531
|
+
await this.db.run(`DELETE FROM turn_run_segments WHERE run_id = ?`, [
|
|
532
|
+
runId,
|
|
533
|
+
]);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── tool marks ──────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Record that a tool call is about to execute. Returns the existing mark
|
|
540
|
+
* when the call id was seen before (a resume), so the caller can apply the
|
|
541
|
+
* re-run policy; `undefined` means this is the first execution.
|
|
542
|
+
*/
|
|
543
|
+
async startMark(input: {
|
|
544
|
+
runId: string;
|
|
545
|
+
toolCallId: string;
|
|
546
|
+
toolName: string;
|
|
547
|
+
effect: ToolEffect;
|
|
548
|
+
}): Promise<ToolMark | undefined> {
|
|
549
|
+
await this.setup();
|
|
550
|
+
const existing = await this.getMark(input.runId, input.toolCallId);
|
|
551
|
+
if (existing) return existing;
|
|
552
|
+
await this.db.run(
|
|
553
|
+
`INSERT OR IGNORE INTO turn_tool_marks (run_id, tool_call_id, tool_name, effect, started_at, attempts)
|
|
554
|
+
VALUES (?, ?, ?, ?, ?, 1)`,
|
|
555
|
+
[input.runId, input.toolCallId, input.toolName, input.effect, this.iso()],
|
|
556
|
+
);
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A read-only call re-executed on resume: count the execution, keep the row open. */
|
|
561
|
+
async bumpMark(runId: string, toolCallId: string): Promise<void> {
|
|
562
|
+
await this.setup();
|
|
563
|
+
await this.db.run(
|
|
564
|
+
`UPDATE turn_tool_marks SET attempts = attempts + 1, started_at = ?, done_at = NULL, outcome = NULL
|
|
565
|
+
WHERE run_id = ? AND tool_call_id = ?`,
|
|
566
|
+
[this.iso(), runId, toolCallId],
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async finishMark(
|
|
571
|
+
runId: string,
|
|
572
|
+
toolCallId: string,
|
|
573
|
+
outcome: 'ok' | 'error' | 'interrupted',
|
|
574
|
+
): Promise<void> {
|
|
575
|
+
await this.setup();
|
|
576
|
+
await this.db.run(
|
|
577
|
+
`UPDATE turn_tool_marks SET done_at = ?, outcome = ? WHERE run_id = ? AND tool_call_id = ?`,
|
|
578
|
+
[this.iso(), outcome, runId, toolCallId],
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async getMark(
|
|
583
|
+
runId: string,
|
|
584
|
+
toolCallId: string,
|
|
585
|
+
): Promise<ToolMark | undefined> {
|
|
586
|
+
await this.setup();
|
|
587
|
+
const row = await this.db.get<MarkRow>(
|
|
588
|
+
`SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts
|
|
589
|
+
FROM turn_tool_marks WHERE run_id = ? AND tool_call_id = ?`,
|
|
590
|
+
[runId, toolCallId],
|
|
591
|
+
);
|
|
592
|
+
return row ? toMark(row) : undefined;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async listMarks(runId: string): Promise<ToolMark[]> {
|
|
596
|
+
await this.setup();
|
|
597
|
+
const rows = await this.db.exec<MarkRow>(
|
|
598
|
+
`SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts
|
|
599
|
+
FROM turn_tool_marks WHERE run_id = ? ORDER BY started_at ASC`,
|
|
600
|
+
[runId],
|
|
601
|
+
);
|
|
602
|
+
return rows.map(toMark);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time rename of the room-thread sessions an earlier build of this
|
|
3
|
+
* runtime keyed as `thread:<rootEventId>`. The rule is Node's now: the
|
|
4
|
+
* session id of a room thread IS its root event id (`src/matrix/ingest.ts`),
|
|
5
|
+
* so a reply in a thread opened before the change must find its transcript
|
|
6
|
+
* under the root id, and a reply inside a Portal session's thread must find
|
|
7
|
+
* the Portal session — not a prefixed twin of it.
|
|
8
|
+
*
|
|
9
|
+
* Runs on every boot of a user object and is cheap when there is nothing to
|
|
10
|
+
* do (one indexed read of the sessions table). A legacy row whose root id
|
|
11
|
+
* already names a session (the Portal session the thread was mirrored from)
|
|
12
|
+
* is left as it is: two transcripts cannot be folded into one checkpoint
|
|
13
|
+
* chain, and the row stays reachable in the Portal under its old id.
|
|
14
|
+
*
|
|
15
|
+
* `matrix:<roomId>` sessions — the main timeline of a room, one session per
|
|
16
|
+
* room in that build — have no Node equivalent and are not renamed: they keep
|
|
17
|
+
* their transcript, new room messages simply no longer continue them.
|
|
18
|
+
*/
|
|
19
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
20
|
+
|
|
21
|
+
export const LEGACY_THREAD_SESSION_PREFIX = 'thread:';
|
|
22
|
+
|
|
23
|
+
/** Every table that keys rows on a session / LangGraph thread id. */
|
|
24
|
+
const SESSION_KEYED_TABLES: ReadonlyArray<{ table: string; column: string }> = [
|
|
25
|
+
{ table: 'sessions', column: 'session_id' },
|
|
26
|
+
{ table: 'checkpoints', column: 'thread_id' },
|
|
27
|
+
{ table: 'writes', column: 'thread_id' },
|
|
28
|
+
{ table: 'messages', column: 'thread_id' },
|
|
29
|
+
{ table: 'turn_runs', column: 'session_id' },
|
|
30
|
+
{ table: 'matrix_turns', column: 'session_id' },
|
|
31
|
+
{ table: 'tool_results', column: 'session_id' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export interface ThreadSessionMigrationResult {
|
|
35
|
+
/** Legacy rows renamed to their root event id. */
|
|
36
|
+
renamed: number;
|
|
37
|
+
/** Legacy rows kept because a session with the root id already exists. */
|
|
38
|
+
skipped: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Rename every `thread:<root>` session to `<root>` across the session-keyed
|
|
43
|
+
* tables (each rename in one transaction). The tables must exist: call it
|
|
44
|
+
* after every store's `setup()`.
|
|
45
|
+
*/
|
|
46
|
+
export async function migrateThreadSessionIds(
|
|
47
|
+
db: DoSqliteDatabase,
|
|
48
|
+
log: Pick<Console, 'log' | 'warn'>,
|
|
49
|
+
): Promise<ThreadSessionMigrationResult> {
|
|
50
|
+
const prefix = LEGACY_THREAD_SESSION_PREFIX;
|
|
51
|
+
// substr() rather than LIKE: DO SQL rejects LIKE patterns.
|
|
52
|
+
const legacy = await db.exec<{ session_id: string }>(
|
|
53
|
+
`SELECT session_id FROM sessions WHERE substr(session_id, 1, ?) = ?`,
|
|
54
|
+
[prefix.length, prefix],
|
|
55
|
+
);
|
|
56
|
+
const result: ThreadSessionMigrationResult = { renamed: 0, skipped: 0 };
|
|
57
|
+
if (legacy.length === 0) return result;
|
|
58
|
+
for (const { session_id: legacyId } of legacy) {
|
|
59
|
+
const rootId = legacyId.slice(prefix.length);
|
|
60
|
+
const clash = await db.get<{ one: number }>(
|
|
61
|
+
`SELECT 1 AS one FROM sessions WHERE session_id = ?`,
|
|
62
|
+
[rootId],
|
|
63
|
+
);
|
|
64
|
+
if (clash) {
|
|
65
|
+
result.skipped += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
await db.transaction(async () => {
|
|
69
|
+
for (const { table, column } of SESSION_KEYED_TABLES) {
|
|
70
|
+
await db.run(`UPDATE ${table} SET ${column} = ? WHERE ${column} = ?`, [
|
|
71
|
+
rootId,
|
|
72
|
+
legacyId,
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
result.renamed += 1;
|
|
77
|
+
}
|
|
78
|
+
log.log(
|
|
79
|
+
`[session-ids] ${result.renamed} thread session(s) renamed to their root event id` +
|
|
80
|
+
(result.skipped > 0
|
|
81
|
+
? `, ${result.skipped} kept (a session with the root id already exists)`
|
|
82
|
+
: ''),
|
|
83
|
+
);
|
|
84
|
+
return result;
|
|
85
|
+
}
|