@ixo/oracle-runtime-workers 0.9.0 → 0.11.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/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 +38 -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 +249 -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 +1019 -169
- 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/owner-store/ixo-vfs-store.ts +12 -5
- package/src/owner-store/matrix-media-store.ts +12 -7
- package/src/owner-store/measure.ts +39 -0
- package/src/owner-store/migrating-store.ts +9 -3
- package/src/owner-store/types.ts +14 -4
- 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 +78 -0
- package/src/sqlite/cache-config.ts +27 -6
- package/src/sqlite/chunk-layout.ts +14 -0
- package/src/sqlite/database.ts +188 -19
- package/src/sqlite/do-vfs.ts +963 -99
- package/src/sqlite/index.ts +13 -0
- package/src/sqlite/page-tier.ts +668 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- package/src/tasks/store.ts +22 -0
|
@@ -94,6 +94,19 @@ type MessageRow = {
|
|
|
94
94
|
message: Uint8Array | string;
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
+
/** A transcript row's position in the listing order. */
|
|
98
|
+
export interface ThreadMessageAnchor {
|
|
99
|
+
createdAt: string;
|
|
100
|
+
rowid: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A transcript row with what a pager needs to continue from it. */
|
|
104
|
+
export interface ThreadMessageRow {
|
|
105
|
+
message: BaseMessage;
|
|
106
|
+
messageId: string;
|
|
107
|
+
anchor: ThreadMessageAnchor;
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
interface PendingWriteColumn {
|
|
98
111
|
task_id: string;
|
|
99
112
|
channel: string;
|
|
@@ -360,6 +373,74 @@ export class SqliteSaver extends BaseCheckpointSaver {
|
|
|
360
373
|
return Promise.all(rows.map((row) => this.loadMessage(row.message)));
|
|
361
374
|
}
|
|
362
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Where a transcript row sits in the listing order (`created_at`, then
|
|
378
|
+
* `rowid`), or null when the thread has no row with that message id. A
|
|
379
|
+
* paging cursor names a message id and is resolved through this at query
|
|
380
|
+
* time: rowids move when a checkpoint rewrites the thread's rows, message
|
|
381
|
+
* ids never do.
|
|
382
|
+
*/
|
|
383
|
+
async findThreadMessageAnchor(
|
|
384
|
+
threadId: string,
|
|
385
|
+
messageId: string,
|
|
386
|
+
): Promise<ThreadMessageAnchor | null> {
|
|
387
|
+
await this.setup();
|
|
388
|
+
const row = await this.db.get<{ created_at: string; rowid: number }>(
|
|
389
|
+
'SELECT created_at, rowid FROM messages WHERE thread_id = ? AND message_id = ?',
|
|
390
|
+
[threadId, messageId],
|
|
391
|
+
);
|
|
392
|
+
return row ? { createdAt: row.created_at, rowid: row.rowid } : null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A slice of the transcript next to an anchor: `older` walks back from it
|
|
397
|
+
* (newest first, so a caller can stop once it holds enough turns), `newer`
|
|
398
|
+
* walks forward (oldest first). Without an anchor `older` starts at the
|
|
399
|
+
* newest row and `newer` at the oldest. The anchor row itself is left out
|
|
400
|
+
* unless `inclusive`.
|
|
401
|
+
*/
|
|
402
|
+
async listThreadMessageRows(
|
|
403
|
+
threadId: string,
|
|
404
|
+
opts: {
|
|
405
|
+
direction: 'older' | 'newer';
|
|
406
|
+
anchor?: ThreadMessageAnchor | null;
|
|
407
|
+
/** Include the anchor row itself. */
|
|
408
|
+
inclusive?: boolean;
|
|
409
|
+
limit: number;
|
|
410
|
+
},
|
|
411
|
+
): Promise<ThreadMessageRow[]> {
|
|
412
|
+
await this.setup();
|
|
413
|
+
const older = opts.direction === 'older';
|
|
414
|
+
const cmp = older ? '<' : '>';
|
|
415
|
+
const rowCmp = opts.inclusive ? `${cmp}=` : cmp;
|
|
416
|
+
const order = older ? 'DESC' : 'ASC';
|
|
417
|
+
const anchored = opts.anchor
|
|
418
|
+
? ` AND (created_at ${cmp} ? OR (created_at = ? AND rowid ${rowCmp} ?))`
|
|
419
|
+
: '';
|
|
420
|
+
const rows = await this.db.exec<{
|
|
421
|
+
message: Uint8Array | string;
|
|
422
|
+
message_id: string;
|
|
423
|
+
created_at: string;
|
|
424
|
+
rowid: number;
|
|
425
|
+
}>(
|
|
426
|
+
`SELECT message, message_id, created_at, rowid FROM messages WHERE thread_id = ?${anchored} ORDER BY created_at ${order}, rowid ${order} LIMIT ?`,
|
|
427
|
+
[
|
|
428
|
+
threadId,
|
|
429
|
+
...(opts.anchor
|
|
430
|
+
? [opts.anchor.createdAt, opts.anchor.createdAt, opts.anchor.rowid]
|
|
431
|
+
: []),
|
|
432
|
+
opts.limit,
|
|
433
|
+
],
|
|
434
|
+
);
|
|
435
|
+
return Promise.all(
|
|
436
|
+
rows.map(async (row) => ({
|
|
437
|
+
message: await this.loadMessage(row.message),
|
|
438
|
+
messageId: row.message_id,
|
|
439
|
+
anchor: { createdAt: row.created_at, rowid: row.rowid },
|
|
440
|
+
})),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
363
444
|
/**
|
|
364
445
|
* The thread's messages whose stored plain content (`message_content`)
|
|
365
446
|
* contains `contentContains`, oldest first — a SQL `LIKE` pre-filter, so
|
package/src/tasks/scheduler.ts
CHANGED
|
@@ -103,6 +103,14 @@ export interface TaskSchedulerHost {
|
|
|
103
103
|
runTurn: (req: TurnRequest) => Promise<TurnResult>;
|
|
104
104
|
/** Ask the object to re-arm its alarm no later than `at` (ms epoch). */
|
|
105
105
|
requestAlarm: (at: number) => void;
|
|
106
|
+
/**
|
|
107
|
+
* Whether the durable turn run of a task run (`TurnRequest.taskRunId`) is
|
|
108
|
+
* still live in the object — recovering or executing — so an open
|
|
109
|
+
* `running` row is left alone at reconciliation instead of being closed
|
|
110
|
+
* as interrupted; the object hands the outcome over through
|
|
111
|
+
* `completeRecoveredRun` / `failRecoveredRun` when the run ends.
|
|
112
|
+
*/
|
|
113
|
+
turnRunLive?: (taskRunId: string) => boolean;
|
|
106
114
|
log: Logger;
|
|
107
115
|
/** Live-task cap (`TASKS_MAX_PER_USER`). Default 50. */
|
|
108
116
|
maxTasksPerUser?: number;
|
|
@@ -123,6 +131,16 @@ export interface TaskScheduler {
|
|
|
123
131
|
onAlarm(now: number): Promise<void>;
|
|
124
132
|
/** Runs no incarnation has finished (operator/debug). */
|
|
125
133
|
openRuns(): Promise<OpenTaskRun[]>;
|
|
134
|
+
/** A run this instance is executing or delivering right now. */
|
|
135
|
+
isRunActive(runId: string): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* The turn of a `running` task run finished in a recovered durable run
|
|
138
|
+
* (no `executeRun` continuation is waiting): advance the schedule, store
|
|
139
|
+
* the result and deliver it, exactly as `executeRun` would have.
|
|
140
|
+
*/
|
|
141
|
+
completeRecoveredRun(runId: string, text: string): Promise<void>;
|
|
142
|
+
/** The recovered turn of a `running` task run ended without a result. */
|
|
143
|
+
failRecoveredRun(runId: string, detail: string): Promise<void>;
|
|
126
144
|
}
|
|
127
145
|
|
|
128
146
|
/** How much of a run's output is kept as `lastResult.summary`. */
|
|
@@ -228,6 +246,9 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
228
246
|
*/
|
|
229
247
|
private readonly activeRuns = new Map<string, string>();
|
|
230
248
|
|
|
249
|
+
/** Tasks whose open run is being recovered by the object (rebuilt per alarm). */
|
|
250
|
+
private readonly recoveringTasks = new Set<string>();
|
|
251
|
+
|
|
231
252
|
private hasActiveRun(taskId: string): boolean {
|
|
232
253
|
for (const owner of this.activeRuns.values())
|
|
233
254
|
if (owner === taskId) return true;
|
|
@@ -274,6 +295,102 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
274
295
|
return this.store.openRuns();
|
|
275
296
|
}
|
|
276
297
|
|
|
298
|
+
isRunActive(runId: string): boolean {
|
|
299
|
+
return this.activeRuns.has(runId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async completeRecoveredRun(runId: string, text: string): Promise<void> {
|
|
303
|
+
const run = await this.store.getOpenRun(runId);
|
|
304
|
+
if (!run || run.state !== 'running') return;
|
|
305
|
+
const nowMs = Date.now();
|
|
306
|
+
const loaded = await this.store.get(run.taskId);
|
|
307
|
+
if (!loaded || loaded.status !== 'active') {
|
|
308
|
+
await this.store.updateRun(runId, {
|
|
309
|
+
state: 'failed',
|
|
310
|
+
ok: false,
|
|
311
|
+
finishedAt: new Date().toISOString(),
|
|
312
|
+
detail: `result dropped: task ${loaded?.status ?? 'deleted'} during the run`,
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const task: TaskRecord = loaded;
|
|
317
|
+
const trimmed = text.trim();
|
|
318
|
+
if (trimmed.length === 0) {
|
|
319
|
+
await this.recordFailure(
|
|
320
|
+
task,
|
|
321
|
+
nowMs,
|
|
322
|
+
run.startedAt,
|
|
323
|
+
new Error('Agent returned no output'),
|
|
324
|
+
{ runId, state: 'failed' },
|
|
325
|
+
);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
this.host.log.log(
|
|
329
|
+
`[tasks] run ${runId} of ${task.id}: turn recovered after a reset; delivering its result`,
|
|
330
|
+
);
|
|
331
|
+
// Same bookkeeping as `executeRun` after the turn: advance the schedule
|
|
332
|
+
// now so no later alarm fires this occurrence again, then deliver.
|
|
333
|
+
if (task.schedule.kind === 'once') {
|
|
334
|
+
delete task.nextRunAt;
|
|
335
|
+
} else if (task.approval !== 'before-action') {
|
|
336
|
+
const nextMs = computeNextRunAtMs(task.schedule, nowMs);
|
|
337
|
+
if (nextMs !== null) task.nextRunAt = new Date(nextMs).toISOString();
|
|
338
|
+
else delete task.nextRunAt;
|
|
339
|
+
}
|
|
340
|
+
task.updatedAt = new Date().toISOString();
|
|
341
|
+
const roomId = run.roomId ?? (await this.resolveDeliveryRoom(task));
|
|
342
|
+
await this.host.db.transaction(async () => {
|
|
343
|
+
await this.store.save(task);
|
|
344
|
+
await this.store.updateRun(runId, {
|
|
345
|
+
state: 'delivering',
|
|
346
|
+
...(roomId ? { roomId } : {}),
|
|
347
|
+
resultText: trimmed,
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
const refreshed = (await this.store.get(task.id)) ?? task;
|
|
351
|
+
const open: OpenTaskRun = {
|
|
352
|
+
runId,
|
|
353
|
+
taskId: refreshed.id,
|
|
354
|
+
startedAt: run.startedAt,
|
|
355
|
+
state: 'delivering',
|
|
356
|
+
txnId: run.txnId,
|
|
357
|
+
...(roomId ? { roomId } : {}),
|
|
358
|
+
resultText: trimmed,
|
|
359
|
+
attempts: 0,
|
|
360
|
+
};
|
|
361
|
+
this.activeRuns.set(runId, refreshed.id);
|
|
362
|
+
try {
|
|
363
|
+
await this.deliverOrDefer(refreshed, open, nowMs);
|
|
364
|
+
} finally {
|
|
365
|
+
this.activeRuns.delete(runId);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async failRecoveredRun(runId: string, detail: string): Promise<void> {
|
|
370
|
+
const run = await this.store.getOpenRun(runId);
|
|
371
|
+
if (!run || run.state !== 'running') return;
|
|
372
|
+
const task = await this.store.get(run.taskId);
|
|
373
|
+
if (!task) {
|
|
374
|
+
await this.store.updateRun(runId, {
|
|
375
|
+
state: 'interrupted',
|
|
376
|
+
ok: false,
|
|
377
|
+
finishedAt: new Date().toISOString(),
|
|
378
|
+
detail,
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await this.recordFailure(
|
|
383
|
+
task,
|
|
384
|
+
Date.now(),
|
|
385
|
+
run.startedAt,
|
|
386
|
+
new Error(detail),
|
|
387
|
+
{
|
|
388
|
+
runId,
|
|
389
|
+
state: 'interrupted',
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
277
394
|
async onAlarm(now: number): Promise<void> {
|
|
278
395
|
// Unfinished runs first: what a previous incarnation left behind decides
|
|
279
396
|
// the fate of its task before the due scan can fire the task again.
|
|
@@ -288,9 +405,11 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
288
405
|
}
|
|
289
406
|
if (Date.parse(task.nextRunAt) > now) continue;
|
|
290
407
|
// A run of this task is executing in this instance right now (a long
|
|
291
|
-
// turn while the object's alarm fires for something else)
|
|
292
|
-
//
|
|
293
|
-
|
|
408
|
+
// turn while the object's alarm fires for something else), or its
|
|
409
|
+
// turn is being recovered by the object after a reset: its schedule
|
|
410
|
+
// advances when the turn ends, never start a second one.
|
|
411
|
+
if (this.hasActiveRun(task.id) || this.recoveringTasks.has(task.id))
|
|
412
|
+
continue;
|
|
294
413
|
try {
|
|
295
414
|
if (task.approval === 'before-action') {
|
|
296
415
|
await this.requestApproval(task, now);
|
|
@@ -319,6 +438,7 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
319
438
|
*/
|
|
320
439
|
private async reconcileOpenRuns(now: number): Promise<void> {
|
|
321
440
|
const open = await this.store.openRuns();
|
|
441
|
+
this.recoveringTasks.clear();
|
|
322
442
|
for (const run of open) {
|
|
323
443
|
if (this.activeRuns.has(run.runId)) continue; // alive in this instance
|
|
324
444
|
const task = await this.store.get(run.taskId);
|
|
@@ -338,6 +458,14 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
338
458
|
);
|
|
339
459
|
await this.deliverOrDefer(task, run, now);
|
|
340
460
|
} else {
|
|
461
|
+
// A durable turn run that is recovering (or executing) owns this
|
|
462
|
+
// row: it reports back through `completeRecoveredRun` /
|
|
463
|
+
// `failRecoveredRun` when it ends. Until then the task must not
|
|
464
|
+
// fire again (its schedule only advances after the turn).
|
|
465
|
+
if (this.host.turnRunLive?.(run.runId)) {
|
|
466
|
+
this.recoveringTasks.add(run.taskId);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
341
469
|
this.host.log.warn(
|
|
342
470
|
`[tasks] run ${run.runId} of ${run.taskId} started ${run.startedAt} never finished (the object was reset while it ran); closing it as interrupted, not re-running`,
|
|
343
471
|
);
|
|
@@ -748,6 +876,9 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
748
876
|
client: 'matrix',
|
|
749
877
|
roomId,
|
|
750
878
|
requestId: crypto.randomUUID(),
|
|
879
|
+
// Links the durable turn run to this task run: a reset mid-turn is
|
|
880
|
+
// recovered and delivered by the object instead of closed.
|
|
881
|
+
taskRunId: runId,
|
|
751
882
|
});
|
|
752
883
|
const text = result.text.trim();
|
|
753
884
|
if (text.length === 0) throw new Error('Agent returned no output');
|
package/src/tasks/store.ts
CHANGED
|
@@ -509,6 +509,28 @@ export class TasksStore {
|
|
|
509
509
|
}));
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
+
/** One open run by id (`running` or `delivering`), or undefined. */
|
|
513
|
+
async getOpenRun(runId: string): Promise<OpenTaskRun | undefined> {
|
|
514
|
+
await this.setup();
|
|
515
|
+
const row = await this.db.get<OpenRunRow>(
|
|
516
|
+
`SELECT run_id, task_id, started_at, state, txn_id, room_id, result_text, attempts, retry_at
|
|
517
|
+
FROM task_runs WHERE run_id = ? AND state IN ('running', 'delivering')`,
|
|
518
|
+
[runId],
|
|
519
|
+
);
|
|
520
|
+
if (!row) return undefined;
|
|
521
|
+
return {
|
|
522
|
+
runId: row.run_id,
|
|
523
|
+
taskId: row.task_id,
|
|
524
|
+
startedAt: row.started_at,
|
|
525
|
+
state: row.state === 'delivering' ? 'delivering' : 'running',
|
|
526
|
+
txnId: row.txn_id ?? `task-${row.run_id}`,
|
|
527
|
+
...(row.room_id ? { roomId: row.room_id } : {}),
|
|
528
|
+
...(row.result_text !== null ? { resultText: row.result_text } : {}),
|
|
529
|
+
attempts: Number(row.attempts ?? 0),
|
|
530
|
+
...(row.retry_at !== null ? { retryAt: Number(row.retry_at) } : {}),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
512
534
|
/** Earliest pending delivery retry (ms epoch) over open runs, or null. */
|
|
513
535
|
async minRetryAt(): Promise<number | null> {
|
|
514
536
|
await this.setup();
|