@ixo/oracle-runtime-workers 0.10.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.
Files changed (48) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +29 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +227 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +875 -146
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/plugin-api/tool-helper.ts +9 -1
  39. package/src/plugin-api/types.ts +86 -0
  40. package/src/plugins/index.ts +3 -0
  41. package/src/plugins/matrix-group-chats/README.md +70 -0
  42. package/src/plugins/matrix-group-chats/index.ts +132 -0
  43. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  44. package/src/plugins/tasks/tasks-tools.ts +4 -0
  45. package/src/shell/app.ts +66 -0
  46. package/src/sqlite/sqlite-saver.ts +81 -0
  47. package/src/tasks/scheduler.ts +134 -3
  48. package/src/tasks/store.ts +22 -0
@@ -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): its
292
- // schedule advances when the turn ends, never start a second one.
293
- if (this.hasActiveRun(task.id)) continue;
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');
@@ -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();