@ixo/oracle-runtime-workers 0.4.0 → 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.
@@ -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
+ );
@@ -47,7 +47,13 @@ import {
47
47
  validateSchedule,
48
48
  } from './schedule';
49
49
  import { newTaskId } from './spec';
50
- import { TasksStore, type TaskRecord } from './store';
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
- /** Send a text message to a room; resolves to the new event id. */
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?: { threadId?: string; formattedBody?: string },
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
- return this.store.minNextRunAt();
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.store.minNextRunAt();
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: run the agent turn on the task's synthetic session, deliver
587
- * the output to the user's room, then advance the schedule. Any throw —
588
- * turn, empty output, delivery — lands in `recordFailure`.
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
- task: TaskRecord,
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
- const finishedAt = new Date().toISOString();
627
- task.lastRunAt = startedAt;
628
- task.lastResult = {
629
- ok: true,
630
- summary: text.slice(0, RESULT_SUMMARY_MAX),
631
- at: finishedAt,
632
- };
633
- task.consecutiveFailures = 0;
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 = finishedAt;
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.recordRun({
651
- runId: crypto.randomUUID(),
652
- taskId: task.id,
653
- startedAt,
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
- this.host.log.log(
660
- `[tasks] run delivered for ${task.id} (${text.length} chars)`,
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
- await this.recordFailure(task, nowMs, startedAt, err);
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
- task: TaskRecord,
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 = { ok: false, summary: message, at: finishedAt };
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
- await this.store.recordRun({
717
- runId: crypto.randomUUID(),
718
- taskId: task.id,
719
- startedAt,
720
- finishedAt,
721
- ok: false,
722
- detail: message,
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
- ? `🛑 Your scheduled task \`${task.id}\` failed and is stopped: ${message.slice(0, 200)}\n\nAsk me to **suggest a fix** when you're ready.`
734
- : `🛑 Task \`${task.id}\` failed ${task.consecutiveFailures} times in a row and is stopped. Ask me to **suggest a fix** when you're ready.`;
735
- await this.host.gateway
736
- .sendText(roomId, text)
737
- .catch((postErr: unknown) => {
738
- this.host.log.warn(
739
- `[tasks] failure notice for ${task.id} could not be posted: ${errorMessage(postErr)}`,
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
  }