@frockbot/plugin-routines 0.3.3 → 0.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-routines",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -32,12 +32,12 @@
32
32
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
33
33
  },
34
34
  "dependencies": {
35
- "@frockbot/client-core": "0.3.3",
36
- "@frockbot/client-ui": "0.3.3",
37
- "@frockbot/configuration-core": "0.3.3",
38
- "@frockbot/kernel-agent-loop": "0.3.3",
39
- "@frockbot/kernel-contracts": "0.3.3",
40
- "@frockbot/plugin-shell": "0.3.3",
35
+ "@frockbot/client-core": "0.3.6",
36
+ "@frockbot/client-ui": "0.3.6",
37
+ "@frockbot/configuration-core": "0.3.6",
38
+ "@frockbot/kernel-agent-loop": "0.3.6",
39
+ "@frockbot/kernel-contracts": "0.3.6",
40
+ "@frockbot/plugin-shell": "0.3.6",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "croner": "10.0.1",
43
43
  "vue": "3.5.41"
@@ -69,8 +69,7 @@ function acknowledge(entryIds: string[]): void {
69
69
  >
70
70
  </header>
71
71
  <p v-if="routines.inbox.length === 0" class="routine-inbox__empty">
72
- Nothing has been left here. A Routine that finishes lands its message in
73
- this drawer rather than in the conversation.
72
+ Nothing here yet. Finished Routines leave their results here.
74
73
  </p>
75
74
  <ul v-else class="routine-inbox__list">
76
75
  <li
@@ -158,8 +158,8 @@ async function toggleLog(routineId: string): Promise<void> {
158
158
  <span class="routines__intro">
159
159
  <strong>Routines</strong>
160
160
  <small>
161
- Standing instructions this Bot runs on a schedule or on a delivered
162
- webhook, as their own Turns.
161
+ Standing instructions this Bot runs on a schedule, or when a webhook
162
+ fires.
163
163
  </small>
164
164
  </span>
165
165
  <UiButton type="button" :disabled="!botId" @click="startCreate">
@@ -175,8 +175,7 @@ async function toggleLog(routineId: string): Promise<void> {
175
175
  v-if="routines.loaded && routines.routines.length === 0"
176
176
  class="routines__empty"
177
177
  >
178
- No Routines yet. A Routine fires on its own and reports back on this Bot's
179
- next conversation.
178
+ No Routines yet. Set one up to have this Bot do something on a schedule.
180
179
  </p>
181
180
 
182
181
  <article
@@ -204,8 +203,8 @@ async function toggleLog(routineId: string): Promise<void> {
204
203
  >Webhook key, version {{ routines.mintedHook.keyVersion }}</strong
205
204
  >
206
205
  <small>
207
- This is the only time it is shown. Copy it now; rotating is the only
208
- way to see a key again.
206
+ This is the only time you'll see this key. Copy it now you'll need a
207
+ new one otherwise.
209
208
  </small>
210
209
  <code>{{ hookUrl() }}</code>
211
210
  <code class="routine-hook__token">{{ routines.mintedHook.token }}</code>
package/src/firing.ts CHANGED
@@ -36,6 +36,14 @@ export interface RoutineScheduleStateV1 {
36
36
  dueAt: number;
37
37
  /** Epoch milliseconds before which the alarm must not settle this Routine. */
38
38
  deferredUntil?: number;
39
+ /**
40
+ * Firings that failed in a row, reset by the first that did not.
41
+ *
42
+ * It is on the clock rather than on the record because it is timing state:
43
+ * what it buys is the backoff `deferredUntil` carries, and the auto-pause it
44
+ * ends in.
45
+ */
46
+ consecutiveFailures?: number;
39
47
  }
40
48
 
41
49
  /** One firing, durable before the Turn it admits. The same-Routine lock. */
@@ -92,7 +100,7 @@ export function decodeRoutineScheduleStateV1(
92
100
  routineExactKeys(
93
101
  candidate,
94
102
  ["schemaVersion", "routineId", "anchor", "dueAt"],
95
- ["deferredUntil"],
103
+ ["deferredUntil", "consecutiveFailures"],
96
104
  "Routine schedule state",
97
105
  );
98
106
  if (candidate.schemaVersion !== 1) {
@@ -116,6 +124,14 @@ export function decodeRoutineScheduleStateV1(
116
124
  "Routine schedule state deferredUntil",
117
125
  ),
118
126
  }),
127
+ ...(candidate.consecutiveFailures === undefined
128
+ ? {}
129
+ : {
130
+ consecutiveFailures: count(
131
+ candidate.consecutiveFailures,
132
+ "Routine schedule state consecutiveFailures",
133
+ ),
134
+ }),
119
135
  };
120
136
  }
121
137
 
@@ -6,12 +6,17 @@ import {
6
6
  } from "./scheduler.js";
7
7
  import { decodeRoutineScheduleStateV1, type RoutineFireV1 } from "./firing.js";
8
8
  import { RoutineStore } from "./store.js";
9
+ import { RoutineInboxStore } from "./inbox-store.js";
9
10
  import { createMemoryRoutineStorageV1 } from "./testing.js";
10
11
  import {
11
12
  routineFireKeyV1,
12
13
  routineScheduleKeyV1,
14
+ ROUTINE_FAILURE_PAUSE_AFTER,
15
+ ROUTINE_FIRE_LEASE_MS,
13
16
  ROUTINE_QUEUE_LIMIT,
17
+ ROUTINE_RUN_PREFIX,
14
18
  } from "./storage-keys.js";
19
+ import { decodeRoutineRunEntryV1 } from "./records.js";
15
20
  import type { RoutineCommandV1 } from "./shared.js";
16
21
 
17
22
  const USER = { kind: "user" } as const;
@@ -30,10 +35,23 @@ function clock(start: string) {
30
35
  };
31
36
  }
32
37
 
33
- function harness(options: { start: string; schedule?: string }) {
38
+ function harness(options: {
39
+ start: string;
40
+ schedule?: string;
41
+ fireTimeoutMs?: number;
42
+ fireLeaseMs?: number;
43
+ }) {
34
44
  const storage = createMemoryRoutineStorageV1();
35
45
  const time = clock(options.start);
36
- const scheduler = new RoutineScheduler(storage, { now: time.now });
46
+ const scheduler = new RoutineScheduler(storage, {
47
+ now: time.now,
48
+ ...(options.fireTimeoutMs === undefined
49
+ ? {}
50
+ : { fireTimeoutMs: options.fireTimeoutMs }),
51
+ ...(options.fireLeaseMs === undefined
52
+ ? {}
53
+ : { fireLeaseMs: options.fireLeaseMs }),
54
+ });
37
55
  const store = new RoutineStore(storage, {
38
56
  now: time.now,
39
57
  firings: scheduler,
@@ -480,3 +498,293 @@ describe("nextRuns", () => {
480
498
  ).toBeUndefined();
481
499
  });
482
500
  });
501
+
502
+ describe("an abandoned firing", () => {
503
+ /** Every run-log entry stored, newest first. */
504
+ async function runLog(
505
+ storage: ReturnType<typeof createMemoryRoutineStorageV1>,
506
+ ) {
507
+ const stored = await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX });
508
+ return [...stored.values()].map((value) => decodeRoutineRunEntryV1(value));
509
+ }
510
+
511
+ test("still contributes a deadline, so the object's alarm is not deleted under it", async () => {
512
+ const { storage, time, scheduler, store, create } = harness({
513
+ start: "2026-01-01T08:59:00.000Z",
514
+ schedule: "0 9 * * *",
515
+ });
516
+ await store.execute(create, USER);
517
+
518
+ // The isolate is killed between `#writeClaim` and `#settleFiring`: the
519
+ // lock is durable and nothing will ever delete it.
520
+ time.set("2026-01-01T09:00:00.000Z");
521
+ await scheduler.settle(async () => {
522
+ throw new DOMException("CPU time limit", "Error");
523
+ });
524
+ // (that one settles) — now stage a genuinely orphaned lock.
525
+ await storage.put(routineFireKeyV1("brief"), {
526
+ schemaVersion: 1,
527
+ routineId: "brief",
528
+ fireId: "rf-brief-orphan",
529
+ trigger: "cron",
530
+ cue: "Routine fired.",
531
+ mintedAt: "2026-01-01T09:00:00.000Z",
532
+ entryId: "rf-brief-orphan-entry",
533
+ });
534
+
535
+ // Before the lease: the Routine is being dealt with, and the deadline the
536
+ // object arms on is when the lease runs out — never nothing, which is what
537
+ // let `deleteAlarm()` silence the Bot.
538
+ time.set("2026-01-01T09:01:00.000Z");
539
+ expect(await scheduler.deadlines(storage)).toEqual([
540
+ Date.parse("2026-01-01T09:00:00.000Z") + ROUTINE_FIRE_LEASE_MS,
541
+ ]);
542
+ });
543
+
544
+ test("is reaped once its lease expires, and the Routine fires again", async () => {
545
+ const { storage, time, scheduler, store, create } = harness({
546
+ start: "2026-01-01T08:59:00.000Z",
547
+ schedule: "0 9 * * *",
548
+ });
549
+ await store.execute(create, USER);
550
+ await storage.put(routineFireKeyV1("brief"), {
551
+ schemaVersion: 1,
552
+ routineId: "brief",
553
+ fireId: "rf-brief-orphan",
554
+ trigger: "cron",
555
+ cue: "Routine fired.",
556
+ mintedAt: "2026-01-01T09:00:00.000Z",
557
+ entryId: "rf-brief-orphan-entry",
558
+ });
559
+
560
+ time.set(
561
+ new Date(
562
+ Date.parse("2026-01-01T09:00:00.000Z") + ROUTINE_FIRE_LEASE_MS + 1,
563
+ ).toISOString(),
564
+ );
565
+ const fired = await drain(scheduler);
566
+
567
+ // The lock is gone, the abandoned firing is `failed` in the log with a
568
+ // reason, and the next occurrence really ran.
569
+ expect(await storage.get(routineFireKeyV1("brief"))).toBeUndefined();
570
+ const log = await runLog(storage);
571
+ expect(
572
+ log.find((entry) => entry.fireId === "rf-brief-orphan"),
573
+ ).toMatchObject({ status: "failed" });
574
+ expect(fired).toHaveLength(1);
575
+ });
576
+
577
+ test("releases a lock nothing can decode", async () => {
578
+ const { storage, scheduler, store, create } = harness({
579
+ start: "2026-01-01T08:59:00.000Z",
580
+ schedule: "0 9 * * *",
581
+ });
582
+ await store.execute(create, USER);
583
+ await storage.put(routineFireKeyV1("brief"), { schemaVersion: 99 });
584
+
585
+ await scheduler.reapExpiredFirings();
586
+
587
+ expect(await storage.get(routineFireKeyV1("brief"))).toBeUndefined();
588
+ });
589
+ });
590
+
591
+ describe("a firing that never comes back", () => {
592
+ test("is stopped at its timeout and settled as failed", async () => {
593
+ const { storage, time, scheduler, store, create } = harness({
594
+ start: "2026-01-01T08:59:00.000Z",
595
+ schedule: "0 9 * * *",
596
+ fireTimeoutMs: 5,
597
+ });
598
+ await store.execute(create, USER);
599
+
600
+ time.set("2026-01-01T09:00:00.000Z");
601
+ let aborted = false;
602
+ await scheduler.settle(
603
+ (_fire, signal) =>
604
+ new Promise((resolve) => {
605
+ signal.addEventListener("abort", () => {
606
+ aborted = true;
607
+ // Never resolves on its own: the timeout is the only thing that
608
+ // ends this firing.
609
+ });
610
+ void resolve;
611
+ }),
612
+ );
613
+
614
+ expect(aborted).toBe(true);
615
+ expect(await storage.get(routineFireKeyV1("brief"))).toBeUndefined();
616
+ const stored = await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX });
617
+ const entries = [...stored.values()].map((value) =>
618
+ decodeRoutineRunEntryV1(value),
619
+ );
620
+ expect(entries[0]).toMatchObject({ status: "failed" });
621
+ expect(entries[0]?.summary).toContain("longer than");
622
+ });
623
+ });
624
+
625
+ describe("a firing that keeps failing", () => {
626
+ test("backs off instead of re-arming identically, and pauses itself", async () => {
627
+ const { storage, time, scheduler, store, create } = harness({
628
+ start: "2026-01-01T00:00:00.000Z",
629
+ schedule: "@every 1m",
630
+ });
631
+ await store.execute(create, USER);
632
+
633
+ // The live wedge was `ok, ok, ok, failed, failed, …` at exactly 60s
634
+ // intervals, for ever: every failure re-armed the next occurrence
635
+ // identically and nothing paused, backed off, or said so.
636
+ const failing = async () =>
637
+ scheduler.settle(async () => {
638
+ firedAt.push(time.now().getTime());
639
+ return { status: "failed", summary: "the provider refused" };
640
+ });
641
+ const firedAt: number[] = [];
642
+
643
+ time.set("2026-01-01T00:01:00.000Z");
644
+ await failing();
645
+ time.set("2026-01-01T00:02:00.000Z");
646
+ await failing();
647
+ expect(firedAt).toHaveLength(2);
648
+ // The third occurrence is due — and the backoff holds it, where before
649
+ // every failure re-armed the next occurrence identically.
650
+ time.set("2026-01-01T00:03:00.000Z");
651
+ await failing();
652
+ expect(firedAt).toHaveLength(2);
653
+
654
+ // Given all the time in the world it still fires only until it gives up.
655
+ for (let step = 1; step <= 10; step += 1) {
656
+ time.set(
657
+ new Date(
658
+ Date.parse("2026-01-01T00:02:00.000Z") + step * 60 * 60_000,
659
+ ).toISOString(),
660
+ );
661
+ await failing();
662
+ }
663
+ expect(firedAt).toHaveLength(ROUTINE_FAILURE_PAUSE_AFTER);
664
+ const listed = await store.list("scout", await scheduler.nextRuns());
665
+ expect(listed.routines[0]).toMatchObject({ enabled: false });
666
+ // The reason is durable and in the run log the panel already renders.
667
+ const runs = await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX });
668
+ const paused = [...runs.values()]
669
+ .map((value) => decodeRoutineRunEntryV1(value))
670
+ .find((entry) => entry.summary?.includes("times in a row"));
671
+ expect(paused).toMatchObject({ status: "skipped" });
672
+ });
673
+
674
+ test("a firing that succeeds clears the backoff", async () => {
675
+ const { storage, time, scheduler, store, create } = harness({
676
+ start: "2026-01-01T00:00:00.000Z",
677
+ schedule: "@every 1m",
678
+ });
679
+ await store.execute(create, USER);
680
+
681
+ time.set("2026-01-01T00:01:00.000Z");
682
+ await drain(scheduler, { status: "failed", summary: "flaked" });
683
+ expect((await state(storage)).consecutiveFailures).toBe(1);
684
+
685
+ time.set("2026-01-01T00:05:00.000Z");
686
+ await drain(scheduler);
687
+ expect((await state(storage)).consecutiveFailures).toBeUndefined();
688
+ });
689
+ });
690
+
691
+ describe("a burst of missed occurrences", () => {
692
+ test("coalesces into one firing even inside the grace window", async () => {
693
+ const { time, scheduler, store, create } = harness({
694
+ start: "2026-01-01T00:00:00.000Z",
695
+ schedule: "@every 1m",
696
+ });
697
+ await store.execute(create, USER);
698
+
699
+ // Four minutes of stall — well inside the five-minute grace, which is
700
+ // exactly where each occurrence used to be claimed on its own and one
701
+ // drain ran four Turns back to back.
702
+ time.set("2026-01-01T00:04:30.000Z");
703
+ const fired = await drain(scheduler);
704
+
705
+ expect(fired).toHaveLength(1);
706
+ expect(fired[0]?.missedCount).toBe(4);
707
+ expect(fired[0]?.cue).toContain("4 scheduled occurrences elapsed");
708
+ });
709
+ });
710
+
711
+ describe("a failed firing", () => {
712
+ test("writes a completion-inbox entry the person can see", async () => {
713
+ const { storage, time, scheduler, store, create } = harness({
714
+ start: "2026-01-01T00:00:00.000Z",
715
+ schedule: "@every 1m",
716
+ });
717
+ await store.execute(create, USER);
718
+
719
+ time.set("2026-01-01T00:01:00.000Z");
720
+ await drain(scheduler, {
721
+ status: "failed",
722
+ summary: "turn 10 started while turn 9 is open",
723
+ });
724
+
725
+ // Before this, a failed firing produced only a `failed` run-log row: the
726
+ // header badge stayed at its old count through six consecutive failures
727
+ // and the only trace was behind Run log → expand a row.
728
+ const inbox = new RoutineInboxStore(storage, { now: time.now });
729
+ const entries = await inbox.list();
730
+ expect(entries).toHaveLength(1);
731
+ expect(entries[0]).toMatchObject({
732
+ routineId: "brief",
733
+ acknowledged: false,
734
+ });
735
+ expect(entries[0]?.text).toContain("Morning brief");
736
+ expect(entries[0]?.text).toContain("turn 10 started while turn 9 is open");
737
+ // A failure is addressed to the person, not handed to the Bot's next
738
+ // conversational Turn: no pending wake.
739
+ expect(await inbox.pending()).toEqual([]);
740
+ });
741
+
742
+ test("records one entry however many times the settle is retried", async () => {
743
+ const { storage, time, scheduler, store, create } = harness({
744
+ start: "2026-01-01T00:00:00.000Z",
745
+ schedule: "@every 1m",
746
+ });
747
+ await store.execute(create, USER);
748
+ time.set("2026-01-01T00:01:00.000Z");
749
+ await drain(scheduler, { status: "failed", summary: "flaked" });
750
+ time.set("2026-01-01T02:00:00.000Z");
751
+ await drain(scheduler, { status: "failed", summary: "flaked" });
752
+
753
+ const entries = await new RoutineInboxStore(storage, {
754
+ now: time.now,
755
+ }).list();
756
+ expect(new Set(entries.map((entry) => entry.entryId)).size).toBe(
757
+ entries.length,
758
+ );
759
+ });
760
+ });
761
+
762
+ describe("an undecodable Routine record", () => {
763
+ test("degrades that one Routine, never the whole object", async () => {
764
+ const { storage, time, scheduler, store, create } = harness({
765
+ start: "2026-01-01T08:59:00.000Z",
766
+ schedule: "0 9 * * *",
767
+ });
768
+ await store.execute(create, USER);
769
+ // A record written by a newer deploy and read back after a rollback: the
770
+ // decoder is exact-keys, so it refuses it outright.
771
+ await storage.put("routine:from-the-future", {
772
+ schemaVersion: 1,
773
+ routineId: "from-the-future",
774
+ unknownFieldFromANewerDeploy: true,
775
+ });
776
+
777
+ // `#clocks` is reached from `deadlines()` → `refreshRecoveryAlarm`, which
778
+ // runs inside `completeRun`, `failRun` and `acceptRun`: a throw here used
779
+ // to poison every alarm refresh and every Turn settlement of the object.
780
+ time.set("2026-01-01T09:00:00.000Z");
781
+ expect(await scheduler.deadlines(storage)).toEqual([
782
+ Date.parse("2026-01-01T09:00:00.000Z"),
783
+ ]);
784
+ expect(await scheduler.nextRuns()).toEqual(
785
+ new Map([["brief", "2026-01-01T09:00:00.000Z"]]),
786
+ );
787
+ // And the healthy Routine still fires.
788
+ expect(await drain(scheduler)).toHaveLength(1);
789
+ });
790
+ });
package/src/scheduler.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  type RoutineFireV1,
36
36
  type RoutineScheduleStateV1,
37
37
  } from "./firing.js";
38
+ import { routineTerminalRecordsV1 } from "./inbox-store.js";
38
39
  import {
39
40
  decodeRoutineRecordV1,
40
41
  type RoutineRecordV1,
@@ -50,6 +51,11 @@ import {
50
51
  import {
51
52
  nextQueueSequenceV1,
52
53
  ROUTINE_DEFERRAL_MS,
54
+ ROUTINE_FIRE_LEASE_MS,
55
+ ROUTINE_FIRE_PREFIX,
56
+ ROUTINE_FAILURE_BACKOFF_MS,
57
+ ROUTINE_FAILURE_PAUSE_AFTER,
58
+ ROUTINE_FIRE_TIMEOUT_MS,
53
59
  ROUTINE_LIMIT_PER_BOT,
54
60
  ROUTINE_MISSED_GRACE_MS,
55
61
  ROUTINE_PREFIX,
@@ -78,12 +84,34 @@ export interface RoutineFireOutcomeV1 {
78
84
  */
79
85
  export type RoutineFireExecutorV1 = (
80
86
  fire: RoutineFireV1,
87
+ signal: AbortSignal,
81
88
  ) => Promise<RoutineFireOutcomeV1>;
82
89
 
83
90
  export interface RoutineSchedulerOptionsV1 {
84
91
  now?(): Date;
85
92
  /** Injected so a test can watch the drain without a Durable Object. */
86
- onSettled?(fire: RoutineFireV1, outcome: RoutineFireOutcomeV1): void;
93
+ onSettled?(
94
+ fire: RoutineFireV1,
95
+ outcome: RoutineFireOutcomeV1,
96
+ ): void | Promise<void>;
97
+ /** How long one firing's Turn may run. Overridden only by tests. */
98
+ fireTimeoutMs?: number;
99
+ /** How long an unsettled firing holds its lock. Overridden only by tests. */
100
+ fireLeaseMs?: number;
101
+ }
102
+
103
+ /**
104
+ * When an unsettled firing stops being "in flight" and starts being abandoned.
105
+ *
106
+ * Derived from `mintedAt`, which is already durable on the firing, so the lease
107
+ * needs no second record and survives the eviction it exists to survive.
108
+ */
109
+ export function routineFireLeaseExpiryV1(
110
+ fire: RoutineFireV1,
111
+ leaseMs: number = ROUTINE_FIRE_LEASE_MS,
112
+ ): number {
113
+ const minted = Date.parse(fire.mintedAt);
114
+ return (Number.isFinite(minted) ? minted : 0) + leaseMs;
87
115
  }
88
116
 
89
117
  /** A firing waiting behind an unsettled one. */
@@ -156,7 +184,13 @@ export class RoutineScheduler {
156
184
  readonly #storage: RoutineStorageV1;
157
185
  readonly #now: () => Date;
158
186
  readonly #onSettled:
159
- ((fire: RoutineFireV1, outcome: RoutineFireOutcomeV1) => void) | undefined;
187
+ | ((
188
+ fire: RoutineFireV1,
189
+ outcome: RoutineFireOutcomeV1,
190
+ ) => void | Promise<void>)
191
+ | undefined;
192
+ readonly #fireTimeoutMs: number;
193
+ readonly #fireLeaseMs: number;
160
194
 
161
195
  constructor(
162
196
  storage: RoutineStorageV1,
@@ -165,6 +199,8 @@ export class RoutineScheduler {
165
199
  this.#storage = storage;
166
200
  this.#now = options.now ?? (() => new Date());
167
201
  this.#onSettled = options.onSettled;
202
+ this.#fireTimeoutMs = options.fireTimeoutMs ?? ROUTINE_FIRE_TIMEOUT_MS;
203
+ this.#fireLeaseMs = options.fireLeaseMs ?? ROUTINE_FIRE_LEASE_MS;
168
204
  }
169
205
 
170
206
  /**
@@ -179,7 +215,23 @@ export class RoutineScheduler {
179
215
  const locked = await reads.get<unknown>(
180
216
  routineFireKeyV1(record.routineId),
181
217
  );
182
- if (locked) continue;
218
+ if (locked) {
219
+ // A Routine with an unsettled firing is being dealt with — but only
220
+ // until its lease runs out. Skipping it outright is what let a firing
221
+ // killed mid-flight take the object's whole alarm down with it: the
222
+ // locked Routine contributed no deadline, so the kernel's
223
+ // `deleteAlarm()` ran and the Bot went silent for ever. The lease
224
+ // expiry is the deadline, and `settle` is what reaps it.
225
+ // The hold still applies, for the same reason it applies to a debt: an
226
+ // expired lease on a Routine whose Turn is executing right now would
227
+ // otherwise arm the alarm on a moment already past, over and over,
228
+ // which is a spin rather than a deadline. `defer()` moves the hold and
229
+ // never the lease, so the reaping still happens — just not on a loop.
230
+ deadlines.push(
231
+ Math.max(this.#leaseExpiry(locked), state.deferredUntil ?? 0),
232
+ );
233
+ continue;
234
+ }
183
235
  deadlines.push(routineDeadlineV1(state));
184
236
  }
185
237
  for (const routineId of await this.#queuedRoutineIds(reads)) {
@@ -209,20 +261,98 @@ export class RoutineScheduler {
209
261
  * durable firing, run it, settle it, then look again.
210
262
  */
211
263
  async settle(execute: RoutineFireExecutorV1): Promise<void> {
264
+ await this.reapExpiredFirings();
212
265
  for (let drained = 0; drained < ROUTINE_SETTLE_BATCH; drained += 1) {
213
266
  const claimed = await this.#claim();
214
267
  if (!claimed) return;
215
- let outcome: RoutineFireOutcomeV1;
216
- try {
217
- outcome = await execute(claimed.fire);
218
- } catch (error) {
219
- outcome = {
268
+ const outcome = await this.#execute(execute, claimed.fire);
269
+ await this.#settleFiring(claimed.fire, outcome);
270
+ await this.#onSettled?.(claimed.fire, outcome);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Run one firing under a hard time bound.
276
+ *
277
+ * Nothing else bounds it: `maxSteps` bounds the loop and not the wall clock,
278
+ * and an automation run is not offered to the user's Stop. A firing that
279
+ * never comes back must still settle, or its lock outlives the isolate.
280
+ */
281
+ async #execute(
282
+ execute: RoutineFireExecutorV1,
283
+ fire: RoutineFireV1,
284
+ ): Promise<RoutineFireOutcomeV1> {
285
+ const controller = new AbortController();
286
+ let timer: ReturnType<typeof setTimeout> | undefined;
287
+ const expiry = new Promise<RoutineFireOutcomeV1>((resolve) => {
288
+ timer = setTimeout(() => {
289
+ controller.abort();
290
+ resolve({
220
291
  status: "failed",
221
- summary: error instanceof Error ? error.message : String(error),
222
- };
292
+ summary: `The Routine ran for longer than ${Math.round(
293
+ this.#fireTimeoutMs / 1000,
294
+ )} seconds and was stopped.`,
295
+ });
296
+ }, this.#fireTimeoutMs);
297
+ });
298
+ try {
299
+ return await Promise.race([
300
+ // `Promise.resolve().then` and not a bare call: an executor that throws
301
+ // synchronously must reach the same failed outcome as one that rejects,
302
+ // or the throw escapes `settle` with the fire lock still held.
303
+ Promise.resolve()
304
+ .then(() => execute(fire, controller.signal))
305
+ .catch((error: unknown): RoutineFireOutcomeV1 => ({
306
+ status: "failed",
307
+ summary: error instanceof Error ? error.message : String(error),
308
+ })),
309
+ expiry,
310
+ ]);
311
+ } finally {
312
+ if (timer !== undefined) clearTimeout(timer);
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Settle for dead every firing whose lease has run out.
318
+ *
319
+ * The other half of the lease: `deadlines()` makes an abandoned firing wake
320
+ * the object, and this is what the object then does about it. An undecodable
321
+ * lock is released too — it names a firing nothing can run, and leaving it
322
+ * wedges the Routine exactly as an abandoned one does.
323
+ */
324
+ async reapExpiredFirings(): Promise<void> {
325
+ const now = this.#now().getTime();
326
+ const stored = await this.#storage.list<unknown>({
327
+ prefix: ROUTINE_FIRE_PREFIX,
328
+ });
329
+ for (const [key, value] of stored.entries()) {
330
+ let fire: RoutineFireV1;
331
+ try {
332
+ fire = decodeRoutineFireV1(value);
333
+ } catch {
334
+ await this.#storage.delete(key);
335
+ continue;
223
336
  }
224
- await this.#settleFiring(claimed.fire, outcome);
225
- this.#onSettled?.(claimed.fire, outcome);
337
+ if (this.#leaseExpiry(value) > now) continue;
338
+ await this.#settleFiring(fire, {
339
+ status: "failed",
340
+ summary:
341
+ "The Routine stopped without reporting an outcome and its firing was settled as failed.",
342
+ });
343
+ }
344
+ }
345
+
346
+ #leaseExpiry(stored: unknown): number {
347
+ try {
348
+ return routineFireLeaseExpiryV1(
349
+ decodeRoutineFireV1(stored),
350
+ this.#fireLeaseMs,
351
+ );
352
+ } catch {
353
+ // An undecodable lock is already overdue: reaping it is the only way its
354
+ // Routine ever fires again.
355
+ return 0;
226
356
  }
227
357
  }
228
358
 
@@ -327,7 +457,18 @@ export class RoutineScheduler {
327
457
  state: RoutineScheduleStateV1;
328
458
  }> = [];
329
459
  for (const value of stored.values()) {
330
- const record = decodeRoutineRecordV1(value);
460
+ let record: RoutineRecordV1;
461
+ try {
462
+ record = decodeRoutineRecordV1(value);
463
+ } catch {
464
+ // One record written by a newer deploy and read back after a rollback
465
+ // used to poison every alarm refresh and every Turn settlement of the
466
+ // whole object: `#clocks` is reached from `deadlines()` →
467
+ // `refreshRecoveryAlarm`, which runs inside `completeRun`, `failRun`
468
+ // and `acceptRun`. A record nothing can read is one Routine that does
469
+ // not fire, never a Bot that cannot settle a Turn.
470
+ continue;
471
+ }
331
472
  if (!record.enabled || record.schedule === undefined) continue;
332
473
  const normalized = normalizeRoutineScheduleV1(
333
474
  record.schedule,
@@ -416,7 +557,14 @@ export class RoutineScheduler {
416
557
  // record it would have run is gone, and a firing needs one.
417
558
  return undefined;
418
559
  }
419
- const record = decodeRoutineRecordV1(stored);
560
+ let record: RoutineRecordV1;
561
+ try {
562
+ record = decodeRoutineRecordV1(stored);
563
+ } catch {
564
+ // The waiting firing named a record nothing can read. Dropping the
565
+ // request is the same answer as a deleted record: a firing needs one.
566
+ return undefined;
567
+ }
420
568
  const fire: RoutineFireV1 = {
421
569
  schemaVersion: 1,
422
570
  routineId,
@@ -448,10 +596,17 @@ export class RoutineScheduler {
448
596
  record.timezone,
449
597
  );
450
598
  const anchor = new Date(record.updatedAt);
451
- const late = now.getTime() - state.dueAt > ROUTINE_MISSED_GRACE_MS;
452
- const missedCount = late
453
- ? missedRoutineRunsV1(normalized, new Date(state.dueAt), now, anchor)
454
- : 1;
599
+ // Every occurrence that has already elapsed coalesces into this one firing,
600
+ // not only the ones past the grace window. A `@every 1m` Routine after a
601
+ // four-minute stall used to run four Turns back to back, because inside
602
+ // the grace each occurrence was claimed on its own and one drain takes
603
+ // eight: lateness is lateness, and the rule is that it coalesces.
604
+ const missedCount = missedRoutineRunsV1(
605
+ normalized,
606
+ new Date(state.dueAt),
607
+ now,
608
+ anchor,
609
+ );
455
610
  const fire: RoutineFireV1 = {
456
611
  schemaVersion: 1,
457
612
  routineId: record.routineId,
@@ -468,10 +623,11 @@ export class RoutineScheduler {
468
623
  ...(missedCount > 1 ? { missedCount } : {}),
469
624
  entryId: `${routineFireIdV1(record.routineId, String(state.dueAt))}-entry`,
470
625
  };
471
- // Recompute forward from now when the firing was late, and from the
472
- // occurrence itself when it was on time; either way the clock advances
473
- // before the Turn runs, so a crash mid-Turn cannot re-owe this occurrence.
474
- const from = late ? now : new Date(state.dueAt);
626
+ // Recompute forward from now when the firing covered more than its own
627
+ // occurrence, and from the occurrence itself when it was on time; either
628
+ // way the clock advances before the Turn runs, so a crash mid-Turn cannot
629
+ // re-owe this occurrence.
630
+ const from = missedCount > 1 ? now : new Date(state.dueAt);
475
631
  const next = nextRoutineRunV1(normalized, from, anchor);
476
632
  await transaction.put(routineScheduleKeyV1(record.routineId), {
477
633
  schemaVersion: 1,
@@ -480,6 +636,9 @@ export class RoutineScheduler {
480
636
  dueAt: (
481
637
  next ?? new Date(now.getTime() + ROUTINE_MISSED_GRACE_MS)
482
638
  ).getTime(),
639
+ ...(state.consecutiveFailures === undefined
640
+ ? {}
641
+ : { consecutiveFailures: state.consecutiveFailures }),
483
642
  } satisfies RoutineScheduleStateV1);
484
643
  if (missedCount > 1) {
485
644
  // One entry says what was slept through. It is `skipped`, not `ok`: the
@@ -524,13 +683,150 @@ export class RoutineScheduler {
524
683
  } satisfies RoutineRecordV1);
525
684
  }
526
685
 
686
+ /**
687
+ * What a settled firing does to its Routine's clock.
688
+ *
689
+ * A firing that failed will most likely fail again on the next occurrence,
690
+ * and each attempt is a whole model Turn. Consecutive failures back the
691
+ * Routine off, and past the threshold it pauses itself and says why — where
692
+ * before it re-armed identically and burned a Turn a minute for ever with
693
+ * nothing on screen to say so.
694
+ */
695
+ async #recordOutcomeOnClock(
696
+ transaction: RoutineStorageWritesV1,
697
+ fire: RoutineFireV1,
698
+ outcome: RoutineFireOutcomeV1,
699
+ now: Date,
700
+ ): Promise<void> {
701
+ const stored = await transaction.get<unknown>(
702
+ routineScheduleKeyV1(fire.routineId),
703
+ );
704
+ if (stored === undefined) return;
705
+ let state: RoutineScheduleStateV1;
706
+ try {
707
+ state = decodeRoutineScheduleStateV1(stored);
708
+ } catch {
709
+ return;
710
+ }
711
+ if (outcome.status === "ok") {
712
+ if (state.consecutiveFailures === undefined) return;
713
+ const { consecutiveFailures: _cleared, ...cleared } = state;
714
+ await transaction.put(routineScheduleKeyV1(fire.routineId), cleared);
715
+ return;
716
+ }
717
+ const failures = (state.consecutiveFailures ?? 0) + 1;
718
+ if (failures >= ROUTINE_FAILURE_PAUSE_AFTER) {
719
+ const record = await transaction.get<unknown>(
720
+ routineKeyV1(fire.routineId),
721
+ );
722
+ if (record !== undefined) {
723
+ let decoded: RoutineRecordV1 | undefined;
724
+ try {
725
+ decoded = decodeRoutineRecordV1(record);
726
+ } catch {
727
+ decoded = undefined;
728
+ }
729
+ if (decoded && decoded.enabled) {
730
+ await transaction.put(routineKeyV1(fire.routineId), {
731
+ ...decoded,
732
+ enabled: false,
733
+ updatedAt: now.toISOString(),
734
+ } satisfies RoutineRecordV1);
735
+ await appendRoutineRunEntryV1(transaction, {
736
+ schemaVersion: 1,
737
+ entryId: `${fire.entryId}-paused`,
738
+ routineId: fire.routineId,
739
+ runId: fire.fireId,
740
+ fireId: fire.fireId,
741
+ trigger: fire.trigger,
742
+ status: "skipped",
743
+ startedAt: now.toISOString(),
744
+ finishedAt: now.toISOString(),
745
+ summary: `This Routine failed ${failures} times in a row and has been turned off. Turn it back on once the cause is fixed.`,
746
+ } satisfies RoutineRunEntryV1);
747
+ }
748
+ }
749
+ }
750
+ const backoff =
751
+ ROUTINE_FAILURE_BACKOFF_MS[
752
+ Math.min(failures, ROUTINE_FAILURE_BACKOFF_MS.length) - 1
753
+ ] ?? 0;
754
+ await transaction.put(routineScheduleKeyV1(fire.routineId), {
755
+ ...state,
756
+ consecutiveFailures: failures,
757
+ deferredUntil: Math.max(
758
+ state.deferredUntil ?? 0,
759
+ now.getTime() + backoff,
760
+ ),
761
+ } satisfies RoutineScheduleStateV1);
762
+ }
763
+
764
+ /**
765
+ * The name a person knows this Routine by, for anything addressed to them.
766
+ * Falls back to the id when the record is gone or unreadable — a message
767
+ * about a Routine is worth more than nothing, even under a broken record.
768
+ */
769
+ async #routineName(
770
+ transaction: RoutineStorageWritesV1,
771
+ routineId: string,
772
+ ): Promise<string> {
773
+ const stored = await transaction.get<unknown>(routineKeyV1(routineId));
774
+ if (stored === undefined) return routineId;
775
+ try {
776
+ return decodeRoutineRecordV1(stored).name;
777
+ } catch {
778
+ return routineId;
779
+ }
780
+ }
781
+
782
+ /**
783
+ * What a person is told about a firing that did not work.
784
+ *
785
+ * A completed firing has always written a completion-inbox entry; a failed
786
+ * one wrote nothing at all — no entry, no badge, no notification — so six
787
+ * consecutive failures left the header count untouched and the only trace
788
+ * was behind Bot settings → Routines → Run log → expand a row. "A Routine
789
+ * that stops working tells you." The entry is written in the transaction
790
+ * that settles the firing, so there is no window where a firing has failed
791
+ * and its outcome is nowhere, and it is idempotent on the run id.
792
+ */
793
+ async #recordFailureInbox(
794
+ transaction: RoutineStorageWritesV1,
795
+ fire: RoutineFireV1,
796
+ outcome: RoutineFireOutcomeV1,
797
+ now: string,
798
+ ): Promise<void> {
799
+ const name = await this.#routineName(transaction, fire.routineId);
800
+ const verb = outcome.status === "cancelled" ? "was stopped" : "did not run";
801
+ const records = await routineTerminalRecordsV1({
802
+ runId: fire.fireId,
803
+ routineId: fire.routineId,
804
+ routineName: name,
805
+ responseText:
806
+ outcome.summary === undefined || outcome.summary.trim().length === 0
807
+ ? `"${name}" ${verb}.`
808
+ : `"${name}" ${verb}: ${outcome.summary}`,
809
+ now,
810
+ read: (key) => transaction.get(key),
811
+ });
812
+ if (!records) return;
813
+ for (const [key, value] of Object.entries(records.records)) {
814
+ await transaction.put(key, value);
815
+ }
816
+ }
817
+
527
818
  async #settleFiring(
528
819
  fire: RoutineFireV1,
529
820
  outcome: RoutineFireOutcomeV1,
530
821
  ): Promise<void> {
531
- const finishedAt = this.#now().toISOString();
822
+ const at = this.#now();
823
+ const finishedAt = at.toISOString();
532
824
  await this.#storage.transaction(async (transaction) => {
533
825
  await transaction.delete(routineFireKeyV1(fire.routineId));
826
+ await this.#recordOutcomeOnClock(transaction, fire, outcome, at);
827
+ if (outcome.status !== "ok") {
828
+ await this.#recordFailureInbox(transaction, fire, outcome, finishedAt);
829
+ }
534
830
  const startedAt = fire.mintedAt;
535
831
  await appendRoutineRunEntryV1(transaction, {
536
832
  schemaVersion: 1,
@@ -46,6 +46,50 @@ export const ROUTINE_MISSED_GRACE_MS = 5 * 60_000;
46
46
  /** How long a deferral holds the alarm off a Routine while the object is busy. */
47
47
  export const ROUTINE_DEFERRAL_MS = 15_000;
48
48
 
49
+ /**
50
+ * How long a Routine is held off after each consecutive failed firing.
51
+ *
52
+ * A firing that failed will most likely fail again on the next occurrence, and
53
+ * an occurrence is a whole model Turn. The last entry repeats for every failure
54
+ * past it, until the auto-pause.
55
+ */
56
+ export const ROUTINE_FAILURE_BACKOFF_MS = [
57
+ 30_000,
58
+ 2 * 60_000,
59
+ 10 * 60_000,
60
+ 30 * 60_000,
61
+ ] as const;
62
+
63
+ /**
64
+ * How many consecutive failures before a Routine pauses itself.
65
+ *
66
+ * A Routine that has failed five times running is broken, not unlucky, and
67
+ * burning a Turn an occurrence for ever is unbounded model spend nobody asked
68
+ * for. Pausing is durable, visible in the panel, and reversible by the person
69
+ * who owns it.
70
+ */
71
+ export const ROUTINE_FAILURE_PAUSE_AFTER = 5;
72
+
73
+ /**
74
+ * How long one firing may hold its Routine's lock before the alarm settles it
75
+ * for dead.
76
+ *
77
+ * The lock is `routine-fire:<id>`, written before the Turn and deleted when it
78
+ * settles. An isolate killed mid-firing leaves it behind, and without a lease
79
+ * that Routine is skipped for ever — and, because a locked Routine used to
80
+ * contribute no deadline, the object's alarm was deleted with it. The lease is
81
+ * what makes an abandoned firing a deadline rather than a silence.
82
+ */
83
+ export const ROUTINE_FIRE_LEASE_MS = 10 * 60_000;
84
+
85
+ /**
86
+ * How long one firing's Turn may run before the scheduler stops waiting on it.
87
+ *
88
+ * Strictly inside the lease, so the firing that timed out settles itself rather
89
+ * than waiting to be reaped.
90
+ */
91
+ export const ROUTINE_FIRE_TIMEOUT_MS = 5 * 60_000;
92
+
49
93
  /**
50
94
  * Run entries are keyed by a descending sequence so a prefix listing returns
51
95
  * the newest first without reading the whole log.