@ixo/oracle-runtime-workers 0.4.1 → 0.9.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 -1
- package/package.json +2 -2
- package/src/attachments/download.ts +43 -22
- package/src/attachments/index.ts +1 -0
- package/src/core/env.ts +19 -0
- package/src/core/main-agent.ts +56 -4
- package/src/core/middlewares/dangling-tool-calls.ts +99 -0
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/runtime-context.ts +14 -5
- package/src/core/subagent-as-tool.ts +12 -1
- package/src/do/ambient.ts +13 -4
- package/src/do/boot-dirty.ts +38 -0
- package/src/do/contracts.ts +91 -18
- package/src/do/matrix-turn-ledger.ts +126 -0
- package/src/do/reauth-prompt.ts +76 -0
- package/src/do/room-mirror.ts +111 -0
- package/src/do/sse-stream.ts +104 -2
- package/src/do/transcript.ts +15 -2
- package/src/do/turn-config.ts +24 -0
- package/src/do/user-oracle-do.ts +339 -67
- package/src/matrix/gateway-do.ts +233 -42
- package/src/matrix/inbox-store.ts +188 -0
- package/src/matrix/txn-id.ts +35 -0
- package/src/matrix/work-status.ts +15 -3
- package/src/owner-store/matrix-media-store.ts +59 -39
- package/src/owner-store/migrating-store.ts +22 -33
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/types.ts +25 -1
- package/src/plugins/portal/action-log.ts +36 -9
- package/src/plugins/tasks/tasks-tools.ts +7 -7
- package/src/shell/app.ts +80 -7
- package/src/shell/turn-body-cap.ts +19 -0
- package/src/tasks/schedule-input.ts +102 -0
- package/src/tasks/scheduler.ts +351 -54
- package/src/tasks/store.ts +198 -3
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schedule as the MODEL writes it: one flat object with a `kind` enum
|
|
3
|
+
* and the per-kind fields optional, refined to `OracleTaskSchedule`.
|
|
4
|
+
*
|
|
5
|
+
* `TaskScheduleSchema` (spec.ts) is a discriminated union — the right shape
|
|
6
|
+
* for the store and the spec frontmatter, but LangChain renders it as JSON
|
|
7
|
+
* schema `oneOf` + `const`, and Gemini 3.5 Flash answers that shape with a
|
|
8
|
+
* bare ISO string for `schedule` (5/5 probes through OpenRouter with the
|
|
9
|
+
* runtime's exact kwargs). A flat object with an `enum` discriminator is
|
|
10
|
+
* honoured by every provider probed (Gemini 2/2, GPT-5.6 2/2), so the tool
|
|
11
|
+
* boundary uses this and converts; nothing downstream changes.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import type { OracleTaskSchedule } from '../plugin-api/types';
|
|
15
|
+
|
|
16
|
+
const SCHEDULE_DESCRIPTION =
|
|
17
|
+
'When the task fires. kind "once" needs `at` (ISO timestamp, computed from the current time — ALWAYS use it for one-time requests such as "in 10 minutes" or "tomorrow at 5pm"); kind "cron" needs `cron` (+ optional IANA `timezone`) and is ONLY for genuinely recurring intents — `*/10 * * * *` means every 10 minutes forever, not once in 10 minutes; kind "interval" needs `everySeconds`. Leave the other kinds\' fields out.';
|
|
18
|
+
|
|
19
|
+
export const ScheduleInputSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
kind: z.enum(['once', 'cron', 'interval']),
|
|
22
|
+
at: z
|
|
23
|
+
.string()
|
|
24
|
+
.min(1)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('ISO timestamp — kind "once" only.'),
|
|
27
|
+
cron: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('Cron expression (5 fields) — kind "cron" only.'),
|
|
32
|
+
timezone: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('IANA timezone for the cron expression — kind "cron" only.'),
|
|
37
|
+
everySeconds: z
|
|
38
|
+
.number()
|
|
39
|
+
.int()
|
|
40
|
+
.positive()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('Seconds between runs — kind "interval" only.'),
|
|
43
|
+
})
|
|
44
|
+
.describe(SCHEDULE_DESCRIPTION);
|
|
45
|
+
|
|
46
|
+
export type ScheduleInput = z.infer<typeof ScheduleInputSchema>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The flat input as the scheduler's schedule, or a message naming what is
|
|
50
|
+
* missing. Fields of the other kinds are ignored, not rejected: a model that
|
|
51
|
+
* fills `cron` for a "once" schedule made one mistake, not two.
|
|
52
|
+
*/
|
|
53
|
+
export function toTaskSchedule(
|
|
54
|
+
input: ScheduleInput,
|
|
55
|
+
): { ok: true; schedule: OracleTaskSchedule } | { ok: false; error: string } {
|
|
56
|
+
switch (input.kind) {
|
|
57
|
+
case 'once':
|
|
58
|
+
if (!input.at)
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: 'schedule.kind "once" needs `at` (ISO timestamp).',
|
|
62
|
+
};
|
|
63
|
+
return { ok: true, schedule: { kind: 'once', at: input.at } };
|
|
64
|
+
case 'cron':
|
|
65
|
+
if (!input.cron)
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: 'schedule.kind "cron" needs `cron` (a cron expression).',
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
schedule: {
|
|
73
|
+
kind: 'cron',
|
|
74
|
+
cron: input.cron,
|
|
75
|
+
...(input.timezone ? { timezone: input.timezone } : {}),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
case 'interval':
|
|
79
|
+
if (input.everySeconds === undefined)
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error:
|
|
83
|
+
'schedule.kind "interval" needs `everySeconds` (a positive integer).',
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
schedule: { kind: 'interval', everySeconds: input.everySeconds },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A zod schema that accepts the flat input and yields `OracleTaskSchedule`. */
|
|
93
|
+
export const ScheduleInputToScheduleSchema = ScheduleInputSchema.transform(
|
|
94
|
+
(input, ctx) => {
|
|
95
|
+
const converted = toTaskSchedule(input);
|
|
96
|
+
if (!converted.ok) {
|
|
97
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: converted.error });
|
|
98
|
+
return z.NEVER;
|
|
99
|
+
}
|
|
100
|
+
return converted.schedule;
|
|
101
|
+
},
|
|
102
|
+
);
|
package/src/tasks/scheduler.ts
CHANGED
|
@@ -47,7 +47,13 @@ import {
|
|
|
47
47
|
validateSchedule,
|
|
48
48
|
} from './schedule';
|
|
49
49
|
import { newTaskId } from './spec';
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
TasksStore,
|
|
52
|
+
type OpenTaskRun,
|
|
53
|
+
type TaskRecord,
|
|
54
|
+
type TaskRunState,
|
|
55
|
+
} from './store';
|
|
56
|
+
import { retryGateway } from '../do/gateway-retry';
|
|
51
57
|
|
|
52
58
|
/** Session-id prefix for the synthetic sessions task runs execute on. */
|
|
53
59
|
export const TASK_SESSION_PREFIX = 'task:';
|
|
@@ -65,11 +71,19 @@ export interface TaskGateway {
|
|
|
65
71
|
invite: string[];
|
|
66
72
|
userDid: string;
|
|
67
73
|
}): Promise<{ roomId: string }>;
|
|
68
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Send a text message to a room; resolves to the new event id. `txnId`
|
|
76
|
+
* makes a repeated send of the same message idempotent on the homeserver.
|
|
77
|
+
*/
|
|
69
78
|
sendText(
|
|
70
79
|
roomId: string,
|
|
71
80
|
body: string,
|
|
72
|
-
opts?: {
|
|
81
|
+
opts?: {
|
|
82
|
+
threadId?: string;
|
|
83
|
+
formattedBody?: string;
|
|
84
|
+
txnId?: string;
|
|
85
|
+
priority?: 'interactive' | 'background';
|
|
86
|
+
},
|
|
73
87
|
): Promise<string>;
|
|
74
88
|
/** Resolve the canonical user↔oracle room for a user DID. */
|
|
75
89
|
resolveUserRoom(
|
|
@@ -94,6 +108,10 @@ export interface TaskSchedulerHost {
|
|
|
94
108
|
maxTasksPerUser?: number;
|
|
95
109
|
/** Minimum seconds between recurring runs (`TASKS_MIN_CRON_INTERVAL_SEC`). Default 300. */
|
|
96
110
|
minCronIntervalSec?: number;
|
|
111
|
+
/** Delays between the send attempts of one delivery round (tests shorten them). */
|
|
112
|
+
deliveryRetryDelaysMs?: readonly number[];
|
|
113
|
+
/** Delays between delivery rounds, indexed by rounds already spent (tests shorten them). */
|
|
114
|
+
deliveryRoundBackoffMs?: readonly number[];
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
export interface TaskScheduler {
|
|
@@ -103,12 +121,26 @@ export interface TaskScheduler {
|
|
|
103
121
|
nextWakeAt(): Promise<number | null>;
|
|
104
122
|
/** Run everything that is due. Must be safe to call spuriously. */
|
|
105
123
|
onAlarm(now: number): Promise<void>;
|
|
124
|
+
/** Runs no incarnation has finished (operator/debug). */
|
|
125
|
+
openRuns(): Promise<OpenTaskRun[]>;
|
|
106
126
|
}
|
|
107
127
|
|
|
108
128
|
/** How much of a run's output is kept as `lastResult.summary`. */
|
|
109
129
|
const RESULT_SUMMARY_MAX = 500;
|
|
110
130
|
/** How much of an error message is kept in bookkeeping / notices. */
|
|
111
131
|
const ERROR_MAX = 1024;
|
|
132
|
+
/**
|
|
133
|
+
* A finished run's result is delivered in rounds: one retried send sequence
|
|
134
|
+
* per round (`deliveryRetryDelaysMs`), the next round on the object's alarm
|
|
135
|
+
* after a growing pause. Past the last round the run is failed.
|
|
136
|
+
*/
|
|
137
|
+
const MAX_DELIVERY_ROUNDS = 5;
|
|
138
|
+
const DEFAULT_DELIVERY_ROUND_BACKOFF_MS: readonly number[] = [
|
|
139
|
+
60_000, 120_000, 240_000, 480_000,
|
|
140
|
+
];
|
|
141
|
+
/** Shown to the user instead of the technical reason (which goes to the log and the run row). */
|
|
142
|
+
const FAILED_RESULT_SUMMARY = 'The run could not be completed.';
|
|
143
|
+
const UNDELIVERED_RESULT_SUMMARY = 'The result could not be delivered.';
|
|
112
144
|
|
|
113
145
|
/** The instruction message a scheduled run enters the agent with. */
|
|
114
146
|
function buildRunMessage(task: TaskRecord, approvalNote?: string): string {
|
|
@@ -185,6 +217,22 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
185
217
|
readonly surface: OracleTasksSurface;
|
|
186
218
|
private readonly maxTasksPerUser: number;
|
|
187
219
|
private readonly minCronIntervalSec: number;
|
|
220
|
+
private readonly deliveryRetryDelaysMs: readonly number[] | undefined;
|
|
221
|
+
private readonly deliveryRoundBackoffMs: readonly number[];
|
|
222
|
+
/**
|
|
223
|
+
* Runs executing in THIS instance (run id → task id). A ledger row in
|
|
224
|
+
* `running` or `delivering` whose id is not here belongs to an incarnation
|
|
225
|
+
* that is gone: memory is per instance and empty after any reset, and the
|
|
226
|
+
* object is single-threaded, so nothing else can own it. That, not a
|
|
227
|
+
* timeout, is how a long live run is told apart from a dead one.
|
|
228
|
+
*/
|
|
229
|
+
private readonly activeRuns = new Map<string, string>();
|
|
230
|
+
|
|
231
|
+
private hasActiveRun(taskId: string): boolean {
|
|
232
|
+
for (const owner of this.activeRuns.values())
|
|
233
|
+
if (owner === taskId) return true;
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
188
236
|
|
|
189
237
|
constructor(
|
|
190
238
|
private readonly host: TaskSchedulerHost,
|
|
@@ -193,6 +241,9 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
193
241
|
this.maxTasksPerUser = host.maxTasksPerUser ?? DEFAULT_MAX_TASKS_PER_USER;
|
|
194
242
|
this.minCronIntervalSec =
|
|
195
243
|
host.minCronIntervalSec ?? DEFAULT_MIN_CRON_INTERVAL_SEC;
|
|
244
|
+
this.deliveryRetryDelaysMs = host.deliveryRetryDelaysMs;
|
|
245
|
+
this.deliveryRoundBackoffMs =
|
|
246
|
+
host.deliveryRoundBackoffMs ?? DEFAULT_DELIVERY_ROUND_BACKOFF_MS;
|
|
196
247
|
this.surface = {
|
|
197
248
|
preview: (input) => this.preview(input),
|
|
198
249
|
create: (input) => this.create(input),
|
|
@@ -210,10 +261,23 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
210
261
|
// ── alarm client ─────────────────────────────────────────────────────────
|
|
211
262
|
|
|
212
263
|
async nextWakeAt(): Promise<number | null> {
|
|
213
|
-
|
|
264
|
+
const [nextRun, nextRetry] = await Promise.all([
|
|
265
|
+
this.store.minNextRunAt(),
|
|
266
|
+
this.store.minRetryAt(),
|
|
267
|
+
]);
|
|
268
|
+
if (nextRun === null) return nextRetry;
|
|
269
|
+
if (nextRetry === null) return nextRun;
|
|
270
|
+
return Math.min(nextRun, nextRetry);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async openRuns(): Promise<OpenTaskRun[]> {
|
|
274
|
+
return this.store.openRuns();
|
|
214
275
|
}
|
|
215
276
|
|
|
216
277
|
async onAlarm(now: number): Promise<void> {
|
|
278
|
+
// Unfinished runs first: what a previous incarnation left behind decides
|
|
279
|
+
// the fate of its task before the due scan can fire the task again.
|
|
280
|
+
await this.reconcileOpenRuns(now);
|
|
217
281
|
const due = await this.store.due(now);
|
|
218
282
|
for (const stale of due) {
|
|
219
283
|
// Re-load: a tool call awaited between iterations may have paused,
|
|
@@ -223,6 +287,10 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
223
287
|
continue;
|
|
224
288
|
}
|
|
225
289
|
if (Date.parse(task.nextRunAt) > now) continue;
|
|
290
|
+
// 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;
|
|
226
294
|
try {
|
|
227
295
|
if (task.approval === 'before-action') {
|
|
228
296
|
await this.requestApproval(task, now);
|
|
@@ -238,10 +306,63 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
238
306
|
);
|
|
239
307
|
}
|
|
240
308
|
}
|
|
241
|
-
const next = await this.
|
|
309
|
+
const next = await this.nextWakeAt();
|
|
242
310
|
if (next !== null) this.host.requestAlarm(Math.max(next, now + 1000));
|
|
243
311
|
}
|
|
244
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Close or continue the runs no live owner holds. `delivering` rows have a
|
|
315
|
+
* stored result: re-send it under the run's fixed transaction id (a copy
|
|
316
|
+
* the dead incarnation already got out is deduplicated by the homeserver).
|
|
317
|
+
* `running` rows have no result and may have executed tools: never re-run;
|
|
318
|
+
* close as interrupted and tell the user the task did not complete.
|
|
319
|
+
*/
|
|
320
|
+
private async reconcileOpenRuns(now: number): Promise<void> {
|
|
321
|
+
const open = await this.store.openRuns();
|
|
322
|
+
for (const run of open) {
|
|
323
|
+
if (this.activeRuns.has(run.runId)) continue; // alive in this instance
|
|
324
|
+
const task = await this.store.get(run.taskId);
|
|
325
|
+
if (run.state === 'delivering') {
|
|
326
|
+
if (run.retryAt !== undefined && run.retryAt > now) continue;
|
|
327
|
+
if (!task || task.status === 'cancelled') {
|
|
328
|
+
await this.store.updateRun(run.runId, {
|
|
329
|
+
state: 'failed',
|
|
330
|
+
ok: false,
|
|
331
|
+
finishedAt: new Date().toISOString(),
|
|
332
|
+
detail: 'result dropped: the task no longer exists',
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
this.host.log.log(
|
|
337
|
+
`[tasks] run ${run.runId} of ${task.id}: re-delivering the stored result (round ${run.attempts + 1})`,
|
|
338
|
+
);
|
|
339
|
+
await this.deliverOrDefer(task, run, now);
|
|
340
|
+
} else {
|
|
341
|
+
this.host.log.warn(
|
|
342
|
+
`[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
|
+
);
|
|
344
|
+
if (!task) {
|
|
345
|
+
await this.store.updateRun(run.runId, {
|
|
346
|
+
state: 'interrupted',
|
|
347
|
+
ok: false,
|
|
348
|
+
finishedAt: new Date().toISOString(),
|
|
349
|
+
detail: 'interrupted: object reset mid-run; task no longer exists',
|
|
350
|
+
});
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
await this.recordFailure(
|
|
354
|
+
task,
|
|
355
|
+
now,
|
|
356
|
+
run.startedAt,
|
|
357
|
+
new Error(
|
|
358
|
+
'interrupted: the object was reset while the run was in progress',
|
|
359
|
+
),
|
|
360
|
+
{ runId: run.runId, state: 'interrupted' },
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
245
366
|
// ── surface: preview / create ────────────────────────────────────────────
|
|
246
367
|
|
|
247
368
|
private async problemsFor(input: OracleTaskInput): Promise<string[]> {
|
|
@@ -583,22 +704,36 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
583
704
|
}
|
|
584
705
|
|
|
585
706
|
/**
|
|
586
|
-
* The hot path
|
|
587
|
-
*
|
|
588
|
-
*
|
|
707
|
+
* The hot path, written to the run ledger as it goes so a reset at any
|
|
708
|
+
* instant is recoverable (see `reconcileOpenRuns`), in three row writes:
|
|
709
|
+
*
|
|
710
|
+
* 1. `running` before anything else — the turn is now owned;
|
|
711
|
+
* 2. the result stored and the schedule advanced, `delivering`, in one
|
|
712
|
+
* transaction — from here the turn is never re-run, only re-sent;
|
|
713
|
+
* 3. `delivered` with the task's last-run bookkeeping, one transaction.
|
|
714
|
+
*
|
|
715
|
+
* Two extra single-row writes per run against the dozens of checkpoint
|
|
716
|
+
* writes the turn itself makes; task runs are rare next to chat turns.
|
|
589
717
|
*
|
|
590
718
|
* `opts.approvalNote` marks an approval-triggered run: the schedule was
|
|
591
719
|
* already advanced when the request was posted, so only a one-shot's
|
|
592
720
|
* completion is recorded here.
|
|
593
721
|
*/
|
|
594
722
|
private async executeRun(
|
|
595
|
-
|
|
723
|
+
taskAtFire: TaskRecord,
|
|
596
724
|
nowMs: number,
|
|
597
725
|
opts: { approvalNote?: string } = {},
|
|
598
726
|
): Promise<void> {
|
|
727
|
+
let task = taskAtFire;
|
|
599
728
|
const approvalRun = task.approval === 'before-action';
|
|
600
729
|
const startedAt = new Date(nowMs).toISOString();
|
|
730
|
+
const runId = crypto.randomUUID();
|
|
731
|
+
const txnId = `task-${runId}`;
|
|
732
|
+
const ledger = { runId, state: 'failed' as const };
|
|
733
|
+
let open: OpenTaskRun | undefined;
|
|
601
734
|
try {
|
|
735
|
+
await this.store.startRun({ runId, taskId: task.id, startedAt, txnId });
|
|
736
|
+
this.activeRuns.set(runId, task.id);
|
|
602
737
|
const roomId = await this.resolveDeliveryRoom(task);
|
|
603
738
|
if (!roomId) throw new Error('Could not resolve a delivery room');
|
|
604
739
|
const result = await this.host.runTurn({
|
|
@@ -616,23 +751,30 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
616
751
|
});
|
|
617
752
|
const text = result.text.trim();
|
|
618
753
|
if (text.length === 0) throw new Error('Agent returned no output');
|
|
619
|
-
// A delivery failure IS a run failure — a silent log-and-continue would
|
|
620
|
-
// mean "task succeeded" while the user never saw the result.
|
|
621
|
-
await this.host.gateway.sendText(
|
|
622
|
-
roomId,
|
|
623
|
-
`🕒 **${task.title}**\n\n${text}`,
|
|
624
|
-
);
|
|
625
754
|
|
|
626
|
-
|
|
627
|
-
task.
|
|
628
|
-
task
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
755
|
+
// The turn took a while: the user may have paused or cancelled the
|
|
756
|
+
// task meanwhile. Re-read it so nothing below resurrects the stale
|
|
757
|
+
// copy, and drop the result if the task is no longer active.
|
|
758
|
+
const current = await this.store.get(task.id);
|
|
759
|
+
if (!current || current.status !== 'active') {
|
|
760
|
+
await this.store.updateRun(runId, {
|
|
761
|
+
state: 'failed',
|
|
762
|
+
ok: false,
|
|
763
|
+
finishedAt: new Date().toISOString(),
|
|
764
|
+
detail: `result dropped: task ${current?.status ?? 'deleted'} during the run`,
|
|
765
|
+
});
|
|
766
|
+
this.host.log.log(
|
|
767
|
+
`[tasks] run ${runId} of ${task.id}: task ${current?.status ?? 'deleted'} during the run; result dropped`,
|
|
768
|
+
);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
task = current;
|
|
772
|
+
|
|
773
|
+
// The turn is over and its result exists: advance the schedule NOW so
|
|
774
|
+
// no later alarm can fire this occurrence again, whatever happens to
|
|
775
|
+
// the delivery. A one-shot keeps `active` until delivered; without a
|
|
776
|
+
// next run it can no longer come due.
|
|
634
777
|
if (task.schedule.kind === 'once') {
|
|
635
|
-
task.status = 'completed';
|
|
636
778
|
delete task.nextRunAt;
|
|
637
779
|
} else if (!approvalRun) {
|
|
638
780
|
// Advance from real time (a long run must not produce a next fire in
|
|
@@ -644,24 +786,131 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
644
786
|
if (nextMs !== null) task.nextRunAt = new Date(nextMs).toISOString();
|
|
645
787
|
else delete task.nextRunAt;
|
|
646
788
|
}
|
|
647
|
-
task.updatedAt =
|
|
789
|
+
task.updatedAt = new Date().toISOString();
|
|
648
790
|
await this.host.db.transaction(async () => {
|
|
649
791
|
await this.store.save(task);
|
|
650
|
-
await this.store.
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
finishedAt,
|
|
655
|
-
ok: true,
|
|
656
|
-
detail: task.lastResult?.summary,
|
|
792
|
+
await this.store.updateRun(runId, {
|
|
793
|
+
state: 'delivering',
|
|
794
|
+
roomId,
|
|
795
|
+
resultText: text,
|
|
657
796
|
});
|
|
658
797
|
});
|
|
659
|
-
|
|
660
|
-
|
|
798
|
+
open = {
|
|
799
|
+
runId,
|
|
800
|
+
taskId: task.id,
|
|
801
|
+
startedAt,
|
|
802
|
+
state: 'delivering',
|
|
803
|
+
txnId,
|
|
804
|
+
roomId,
|
|
805
|
+
resultText: text,
|
|
806
|
+
attempts: 0,
|
|
807
|
+
};
|
|
808
|
+
} catch (err) {
|
|
809
|
+
this.activeRuns.delete(runId);
|
|
810
|
+
await this.recordFailure(task, nowMs, startedAt, err, ledger);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
await this.deliverOrDefer(task, open, nowMs);
|
|
815
|
+
} finally {
|
|
816
|
+
this.activeRuns.delete(runId);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** The room message a run's result is delivered as. */
|
|
821
|
+
private deliveryBody(task: TaskRecord, text: string): string {
|
|
822
|
+
return `🕒 **${task.title}**\n\n${text}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* One delivery round for a run whose result is stored: the send is retried
|
|
827
|
+
* across a gateway restart under the run's fixed transaction id, so a
|
|
828
|
+
* response lost on the way never turns a delivered result into a failure
|
|
829
|
+
* and never produces a second copy. A round that still fails parks the
|
|
830
|
+
* run for the next round on the alarm; the last round fails the task.
|
|
831
|
+
*/
|
|
832
|
+
private async deliverOrDefer(
|
|
833
|
+
task: TaskRecord,
|
|
834
|
+
run: OpenTaskRun,
|
|
835
|
+
nowMs: number,
|
|
836
|
+
): Promise<void> {
|
|
837
|
+
const roomId = run.roomId ?? (await this.resolveDeliveryRoom(task));
|
|
838
|
+
const text = run.resultText ?? '';
|
|
839
|
+
try {
|
|
840
|
+
if (!roomId) throw new Error('Could not resolve a delivery room');
|
|
841
|
+
await retryGateway(
|
|
842
|
+
() =>
|
|
843
|
+
this.host.gateway.sendText(roomId, this.deliveryBody(task, text), {
|
|
844
|
+
txnId: run.txnId,
|
|
845
|
+
priority: 'interactive',
|
|
846
|
+
}),
|
|
847
|
+
{
|
|
848
|
+
...(this.deliveryRetryDelaysMs
|
|
849
|
+
? { delaysMs: this.deliveryRetryDelaysMs }
|
|
850
|
+
: {}),
|
|
851
|
+
onRetry: (err, attempt, delayMs) =>
|
|
852
|
+
this.host.log.warn(
|
|
853
|
+
`[tasks] delivery of run ${run.runId} (${task.id}) failed on attempt ${attempt}, retrying in ${delayMs} ms: ${errorMessage(err)}`,
|
|
854
|
+
),
|
|
855
|
+
},
|
|
661
856
|
);
|
|
662
857
|
} catch (err) {
|
|
663
|
-
|
|
858
|
+
const attempts = run.attempts + 1;
|
|
859
|
+
if (attempts >= MAX_DELIVERY_ROUNDS) {
|
|
860
|
+
this.host.log.error(
|
|
861
|
+
`[tasks] run ${run.runId} of ${task.id}: giving up delivery after ${attempts} rounds: ${errorMessage(err)}`,
|
|
862
|
+
);
|
|
863
|
+
await this.recordFailure(
|
|
864
|
+
task,
|
|
865
|
+
nowMs,
|
|
866
|
+
run.startedAt,
|
|
867
|
+
new Error(
|
|
868
|
+
`undeliverable after ${attempts} rounds: ${errorMessage(err)}`,
|
|
869
|
+
),
|
|
870
|
+
{ runId: run.runId, state: 'failed', undelivered: true },
|
|
871
|
+
);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const pause =
|
|
875
|
+
this.deliveryRoundBackoffMs[
|
|
876
|
+
Math.min(attempts - 1, this.deliveryRoundBackoffMs.length - 1)
|
|
877
|
+
] ?? 60_000;
|
|
878
|
+
const retryAt = Math.max(nowMs, Date.now()) + pause;
|
|
879
|
+
await this.store.updateRun(run.runId, { attempts, retryAt });
|
|
880
|
+
this.host.requestAlarm(retryAt);
|
|
881
|
+
this.host.log.warn(
|
|
882
|
+
`[tasks] run ${run.runId} of ${task.id}: delivery round ${attempts} failed (${errorMessage(err)}); next round at ${new Date(retryAt).toISOString()}`,
|
|
883
|
+
);
|
|
884
|
+
return;
|
|
664
885
|
}
|
|
886
|
+
|
|
887
|
+
const finishedAt = new Date().toISOString();
|
|
888
|
+
// Delivered. Bookkeeping goes onto the task as it is NOW (a pause or
|
|
889
|
+
// cancel during the send must stand); only an active one-shot completes.
|
|
890
|
+
const current = (await this.store.get(task.id)) ?? task;
|
|
891
|
+
current.lastRunAt = run.startedAt;
|
|
892
|
+
current.lastResult = {
|
|
893
|
+
ok: true,
|
|
894
|
+
summary: text.slice(0, RESULT_SUMMARY_MAX),
|
|
895
|
+
at: finishedAt,
|
|
896
|
+
};
|
|
897
|
+
current.consecutiveFailures = 0;
|
|
898
|
+
if (current.schedule.kind === 'once' && current.status === 'active')
|
|
899
|
+
current.status = 'completed';
|
|
900
|
+
current.updatedAt = finishedAt;
|
|
901
|
+
await this.host.db.transaction(async () => {
|
|
902
|
+
await this.store.save(current);
|
|
903
|
+
await this.store.updateRun(run.runId, {
|
|
904
|
+
state: 'delivered',
|
|
905
|
+
ok: true,
|
|
906
|
+
finishedAt,
|
|
907
|
+
detail: task.lastResult?.summary,
|
|
908
|
+
retryAt: null,
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
this.host.log.log(
|
|
912
|
+
`[tasks] run delivered for ${task.id} (${text.length} chars${run.attempts > 0 ? `, round ${run.attempts + 1}` : ''})`,
|
|
913
|
+
);
|
|
665
914
|
}
|
|
666
915
|
|
|
667
916
|
/**
|
|
@@ -669,18 +918,47 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
669
918
|
* any failure stops it as `failed` — loudly. A recurring task retries at
|
|
670
919
|
* the LATER of its own next fire and the exponential backoff, and stops as
|
|
671
920
|
* `failed` at the consecutive-failure threshold.
|
|
921
|
+
*
|
|
922
|
+
* The technical reason goes to the log and the run row; what the user
|
|
923
|
+
* sees — the notice and `lastResult.summary` the task tools relay — is
|
|
924
|
+
* plain language with no runtime internals.
|
|
672
925
|
*/
|
|
673
926
|
private async recordFailure(
|
|
674
|
-
|
|
927
|
+
taskAtFire: TaskRecord,
|
|
675
928
|
nowMs: number,
|
|
676
929
|
startedAt: string,
|
|
677
930
|
err: unknown,
|
|
931
|
+
ledger?: { runId: string; state: TaskRunState; undelivered?: boolean },
|
|
678
932
|
): Promise<void> {
|
|
679
933
|
const message = errorMessage(err);
|
|
680
934
|
const finishedAt = new Date().toISOString();
|
|
935
|
+
// Bookkeeping on the task as it is NOW: a pause or cancel that happened
|
|
936
|
+
// during the run stands, and a stale copy never resurrects a task.
|
|
937
|
+
const task = (await this.store.get(taskAtFire.id)) ?? taskAtFire;
|
|
938
|
+
if (task.status !== 'active') {
|
|
939
|
+
if (ledger) {
|
|
940
|
+
await this.store.updateRun(ledger.runId, {
|
|
941
|
+
state: ledger.state,
|
|
942
|
+
ok: false,
|
|
943
|
+
finishedAt,
|
|
944
|
+
detail: `${message} (task ${task.status} meanwhile)`,
|
|
945
|
+
retryAt: null,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
this.host.log.warn(
|
|
949
|
+
`[tasks] run failed for ${task.id} while the task is ${task.status}: ${message}`,
|
|
950
|
+
);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
681
953
|
task.consecutiveFailures += 1;
|
|
682
954
|
task.lastRunAt = startedAt;
|
|
683
|
-
task.lastResult = {
|
|
955
|
+
task.lastResult = {
|
|
956
|
+
ok: false,
|
|
957
|
+
summary: ledger?.undelivered
|
|
958
|
+
? UNDELIVERED_RESULT_SUMMARY
|
|
959
|
+
: FAILED_RESULT_SUMMARY,
|
|
960
|
+
at: finishedAt,
|
|
961
|
+
};
|
|
684
962
|
task.updatedAt = finishedAt;
|
|
685
963
|
|
|
686
964
|
const oneShot = task.schedule.kind === 'once';
|
|
@@ -713,32 +991,51 @@ class AlarmTaskScheduler implements TaskScheduler {
|
|
|
713
991
|
}
|
|
714
992
|
await this.host.db.transaction(async () => {
|
|
715
993
|
await this.store.save(task);
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
994
|
+
if (ledger) {
|
|
995
|
+
await this.store.updateRun(ledger.runId, {
|
|
996
|
+
state: ledger.state,
|
|
997
|
+
ok: false,
|
|
998
|
+
finishedAt,
|
|
999
|
+
detail: message,
|
|
1000
|
+
retryAt: null,
|
|
1001
|
+
});
|
|
1002
|
+
} else {
|
|
1003
|
+
await this.store.recordRun({
|
|
1004
|
+
runId: crypto.randomUUID(),
|
|
1005
|
+
taskId: task.id,
|
|
1006
|
+
startedAt,
|
|
1007
|
+
finishedAt,
|
|
1008
|
+
ok: false,
|
|
1009
|
+
detail: message,
|
|
1010
|
+
state: 'failed',
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
724
1013
|
});
|
|
725
1014
|
this.host.log.warn(
|
|
726
1015
|
`[tasks] run failed for ${task.id} (${task.consecutiveFailures} consecutive${stopped ? ', stopped' : ''}): ${message}`,
|
|
727
1016
|
);
|
|
728
1017
|
if (stopped) {
|
|
729
1018
|
// Best-effort notice — the failure bookkeeping above is already saved.
|
|
1019
|
+
// Retried across a gateway restart under a fixed id: never two copies.
|
|
730
1020
|
const roomId = await this.resolveDeliveryRoom(task);
|
|
731
1021
|
if (roomId) {
|
|
732
1022
|
const text = oneShot
|
|
733
|
-
? `🛑
|
|
734
|
-
: `🛑
|
|
735
|
-
await
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
)
|
|
741
|
-
|
|
1023
|
+
? `🛑 **${task.title}** could not be completed. Ask me to run it again or to reschedule it.`
|
|
1024
|
+
: `🛑 **${task.title}** has been stopped after ${task.consecutiveFailures} unsuccessful runs in a row. Ask me to look into it or to reschedule it when you're ready.`;
|
|
1025
|
+
await retryGateway(
|
|
1026
|
+
() =>
|
|
1027
|
+
this.host.gateway.sendText(roomId, text, {
|
|
1028
|
+
txnId: `task-${ledger?.runId ?? crypto.randomUUID()}-notice`,
|
|
1029
|
+
priority: 'interactive',
|
|
1030
|
+
}),
|
|
1031
|
+
this.deliveryRetryDelaysMs
|
|
1032
|
+
? { delaysMs: this.deliveryRetryDelaysMs }
|
|
1033
|
+
: {},
|
|
1034
|
+
).catch((postErr: unknown) => {
|
|
1035
|
+
this.host.log.warn(
|
|
1036
|
+
`[tasks] failure notice for ${task.id} could not be posted: ${errorMessage(postErr)}`,
|
|
1037
|
+
);
|
|
1038
|
+
});
|
|
742
1039
|
}
|
|
743
1040
|
}
|
|
744
1041
|
}
|