@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 +7 -7
- package/src/client/RoutineInboxBadge.vue +1 -2
- package/src/client/RoutinesSection.vue +5 -6
- package/src/firing.ts +17 -1
- package/src/scheduler.test.ts +310 -2
- package/src/scheduler.ts +319 -23
- package/src/storage-keys.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-routines",
|
|
3
|
-
"version": "0.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.
|
|
36
|
-
"@frockbot/client-ui": "0.3.
|
|
37
|
-
"@frockbot/configuration-core": "0.3.
|
|
38
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
39
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
40
|
-
"@frockbot/plugin-shell": "0.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
|
|
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
|
|
162
|
-
|
|
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.
|
|
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
|
|
208
|
-
|
|
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
|
|
package/src/scheduler.test.ts
CHANGED
|
@@ -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: {
|
|
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, {
|
|
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?(
|
|
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
|
-
((
|
|
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)
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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:
|
|
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
|
-
|
|
225
|
-
this.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
472
|
-
// occurrence itself when it was on time; either
|
|
473
|
-
// before the Turn runs, so a crash mid-Turn cannot
|
|
474
|
-
|
|
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
|
|
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,
|
package/src/storage-keys.ts
CHANGED
|
@@ -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.
|