@frockbot/plugin-routines 0.3.8 → 0.3.10

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.
@@ -10,6 +10,7 @@ import { RoutineInboxStore } from "./inbox-store.js";
10
10
  import { createMemoryRoutineStorageV1 } from "./testing.js";
11
11
  import {
12
12
  routineFireKeyV1,
13
+ routineKeyV1,
13
14
  routineScheduleKeyV1,
14
15
  ROUTINE_FAILURE_PAUSE_AFTER,
15
16
  ROUTINE_FIRE_LEASE_MS,
@@ -575,7 +576,7 @@ describe("an abandoned firing", () => {
575
576
  });
576
577
 
577
578
  test("releases a lock nothing can decode", async () => {
578
- const { storage, scheduler, store, create } = harness({
579
+ const { storage, time, scheduler, store, create } = harness({
579
580
  start: "2026-01-01T08:59:00.000Z",
580
581
  schedule: "0 9 * * *",
581
582
  });
@@ -759,6 +760,119 @@ describe("a failed firing", () => {
759
760
  });
760
761
  });
761
762
 
763
+ describe("what a failing Routine tells the person", () => {
764
+ // The inbox entry, the run-log summary and the notification all carried
765
+ // `tool occurrence "tool:1:1:1" was not settled before step end`.
766
+ test("writes a sentence, and keeps the kernel string on the run log", async () => {
767
+ const { storage, time, scheduler, store, create } = harness({
768
+ start: "2026-01-01T00:00:00.000Z",
769
+ schedule: "@every 1m",
770
+ });
771
+ await store.execute(create, USER);
772
+ time.set("2026-01-01T00:01:00.000Z");
773
+ const raw =
774
+ 'Bot turn ended with outcome model-error: tool occurrence "tool:1:1:1" was not settled before step end';
775
+ await drain(scheduler, { status: "failed", summary: raw });
776
+
777
+ const entries = await new RoutineInboxStore(storage, {
778
+ now: time.now,
779
+ }).list();
780
+ expect(entries).toHaveLength(1);
781
+ expect(entries[0]?.text).toBe(
782
+ '"Morning brief" did not run: Something inside FrockBot went wrong; the run log has the details.',
783
+ );
784
+ expect(entries[0]?.text).not.toContain("tool occurrence");
785
+ // The operator's copy is untouched.
786
+ const runs = await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX });
787
+ expect(
788
+ [...runs.values()].map((row) => decodeRoutineRunEntryV1(row).summary),
789
+ ).toEqual([raw]);
790
+ });
791
+
792
+ test("a provider's own words reach the person unchanged", async () => {
793
+ const { storage, time, scheduler, store, create } = harness({
794
+ start: "2026-01-01T00:00:00.000Z",
795
+ schedule: "@every 1m",
796
+ });
797
+ await store.execute(create, USER);
798
+ time.set("2026-01-01T00:01:00.000Z");
799
+ await drain(scheduler, {
800
+ status: "failed",
801
+ summary: "the model provider is rate limiting this account",
802
+ });
803
+
804
+ const entries = await new RoutineInboxStore(storage, {
805
+ now: time.now,
806
+ }).list();
807
+ expect(entries[0]?.text).toBe(
808
+ '"Morning brief" did not run: the model provider is rate limiting this account',
809
+ );
810
+ });
811
+
812
+ // Sixty rows of the same sentence is not sixty things being wrong.
813
+ test("folds repeats of one failure into a single entry with a count", async () => {
814
+ const { storage, time, scheduler, store, create } = harness({
815
+ start: "2026-01-01T00:00:00.000Z",
816
+ schedule: "@every 1m",
817
+ });
818
+ await store.execute(create, USER);
819
+ const failure = { status: "failed" as const, summary: "flaked" };
820
+ for (const minute of ["00:01", "02:00", "04:00"]) {
821
+ time.set(`2026-01-01T${minute}:00.000Z`);
822
+ await drain(scheduler, failure);
823
+ }
824
+
825
+ const entries = await new RoutineInboxStore(storage, {
826
+ now: time.now,
827
+ }).list();
828
+ expect(entries).toHaveLength(1);
829
+ expect(entries[0]?.repeatCount).toBe(3);
830
+ expect(entries[0]?.createdAt).toBe("2026-01-01T04:00:00.000Z");
831
+ // Every firing still has its own run-log row; only the inbox collapses.
832
+ expect(
833
+ (await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX })).size,
834
+ ).toBeGreaterThanOrEqual(3);
835
+ });
836
+
837
+ test("a different failure gets its own entry", async () => {
838
+ const { storage, time, scheduler, store, create } = harness({
839
+ start: "2026-01-01T00:00:00.000Z",
840
+ schedule: "@every 1m",
841
+ });
842
+ await store.execute(create, USER);
843
+ time.set("2026-01-01T00:01:00.000Z");
844
+ await drain(scheduler, { status: "failed", summary: "flaked" });
845
+ time.set("2026-01-01T02:00:00.000Z");
846
+ await drain(scheduler, { status: "failed", summary: "the disk is full" });
847
+
848
+ const entries = await new RoutineInboxStore(storage, {
849
+ now: time.now,
850
+ }).list();
851
+ expect(entries).toHaveLength(2);
852
+ expect(entries.every((entry) => entry.repeatCount === undefined)).toBe(
853
+ true,
854
+ );
855
+ });
856
+
857
+ // The panel showed a "Next run" five minutes in the past that never moved.
858
+ test("reports a backed-off Routine's next run in the future", async () => {
859
+ const { storage, time, scheduler, store, create } = harness({
860
+ start: "2026-01-01T00:00:00.000Z",
861
+ schedule: "@every 1m",
862
+ });
863
+ await store.execute(create, USER);
864
+ time.set("2026-01-01T00:01:00.000Z");
865
+ await drain(scheduler, { status: "failed", summary: "flaked" });
866
+
867
+ const next = (await scheduler.nextRuns()).get("brief");
868
+ expect(next).toBeDefined();
869
+ expect(Date.parse(next!)).toBeGreaterThan(time.now().getTime());
870
+ expect(next).toBe(
871
+ new Date(routineDeadlineV1(await state(storage))).toISOString(),
872
+ );
873
+ });
874
+ });
875
+
762
876
  describe("an undecodable Routine record", () => {
763
877
  test("degrades that one Routine, never the whole object", async () => {
764
878
  const { storage, time, scheduler, store, create } = harness({
@@ -788,3 +902,100 @@ describe("an undecodable Routine record", () => {
788
902
  expect(await drain(scheduler)).toHaveLength(1);
789
903
  });
790
904
  });
905
+
906
+ describe("a Routine deleted while it was firing", () => {
907
+ /**
908
+ * Delete sweeps the record, the clock, the lock, anything queued and the
909
+ * whole run log. A firing already in flight settled afterwards and wrote a
910
+ * run entry back under the swept key and an inbox entry naming a Routine
911
+ * that no longer exists — an orphan the user cannot open, delete, or
912
+ * explain.
913
+ */
914
+ test("leaves no run entry and no inbox entry behind", async () => {
915
+ const { storage, time, scheduler, store, create } = harness({
916
+ start: "2026-01-01T08:59:00.000Z",
917
+ schedule: "0 9 * * *",
918
+ });
919
+ await store.execute(create, USER);
920
+ time.set("2026-01-01T09:00:00.000Z");
921
+
922
+ const inbox = new RoutineInboxStore(storage);
923
+ await scheduler.settle(async () => {
924
+ // The user deletes it from the panel while the Turn is still running.
925
+ await store.execute(
926
+ {
927
+ schemaVersion: 1,
928
+ type: "routine/delete",
929
+ commandId: "cmd-delete",
930
+ botId: "scout",
931
+ routineId: "brief",
932
+ },
933
+ USER,
934
+ );
935
+ return { status: "failed", summary: "the model refused" };
936
+ });
937
+
938
+ expect(
939
+ storage.keys().filter((key) => key.startsWith(ROUTINE_RUN_PREFIX)),
940
+ ).toEqual([]);
941
+ expect(await inbox.list()).toEqual([]);
942
+ expect(await storage.get(routineFireKeyV1("brief"))).toBeUndefined();
943
+ expect(await storage.get(routineScheduleKeyV1("brief"))).toBeUndefined();
944
+ expect(await scheduler.deadlines(storage)).toEqual([]);
945
+ });
946
+
947
+ test("a firing whose Routine still exists settles as it always did", async () => {
948
+ const { storage, time, scheduler, store, create } = harness({
949
+ start: "2026-01-01T08:59:00.000Z",
950
+ schedule: "0 9 * * *",
951
+ });
952
+ await store.execute(create, USER);
953
+ time.set("2026-01-01T09:00:00.000Z");
954
+ const inbox = new RoutineInboxStore(storage);
955
+ await drain(scheduler, { status: "failed", summary: "the model refused" });
956
+ expect(
957
+ storage.keys().filter((key) => key.startsWith(ROUTINE_RUN_PREFIX)),
958
+ ).not.toEqual([]);
959
+ expect(await inbox.list()).toHaveLength(1);
960
+ });
961
+ });
962
+
963
+ describe("a stored schedule with no occurrence left", () => {
964
+ /**
965
+ * The write path refuses these now, but a record written before it did — or
966
+ * by an older deploy — is still in storage. The claim used to fall back to
967
+ * `now + ROUTINE_MISSED_GRACE_MS` when the schedule named no next run, so
968
+ * the Routine fired a real model Turn every five minutes for ever, with
969
+ * nothing on screen and no bound on the spend.
970
+ */
971
+ test("turns itself off and says why, instead of firing every five minutes", async () => {
972
+ const { storage, scheduler, store, create } = harness({
973
+ start: "2026-01-01T00:00:00.000Z",
974
+ schedule: "0 9 * * *",
975
+ });
976
+ await store.execute(create, USER);
977
+ // February the 30th, straight into storage, past the write-time guard.
978
+ const stored = (await storage.get(routineKeyV1("brief"))) as Record<
979
+ string,
980
+ unknown
981
+ >;
982
+ await storage.put(routineKeyV1("brief"), {
983
+ ...stored,
984
+ schedule: "0 0 30 2 *",
985
+ });
986
+ await storage.delete(routineScheduleKeyV1("brief"));
987
+
988
+ expect(await drain(scheduler)).toEqual([]);
989
+
990
+ const listed = await store.list("scout");
991
+ expect(listed.routines[0]!.enabled).toBe(false);
992
+ const runs = await store.listRuns("scout", "brief");
993
+ expect(runs.entries[0]!.status).toBe("skipped");
994
+ expect(runs.entries[0]!.summary).toContain("never comes around again");
995
+ // And the alarm no longer has anything to arm on for it.
996
+ expect(await scheduler.deadlines(storage)).toEqual([]);
997
+
998
+ // Draining again does not fire it either: it is off and its clock is gone.
999
+ expect(await drain(scheduler)).toEqual([]);
1000
+ });
1001
+ });
package/src/scheduler.ts CHANGED
@@ -35,6 +35,11 @@ import {
35
35
  type RoutineFireV1,
36
36
  type RoutineScheduleStateV1,
37
37
  } from "./firing.js";
38
+ import {
39
+ decodeRoutineInboxEntryV1,
40
+ routineFailureSentenceV1,
41
+ type RoutineInboxEntryV1,
42
+ } from "./inbox.js";
38
43
  import { routineTerminalRecordsV1 } from "./inbox-store.js";
39
44
  import {
40
45
  decodeRoutineRecordV1,
@@ -53,11 +58,11 @@ import {
53
58
  ROUTINE_DEFERRAL_MS,
54
59
  ROUTINE_FIRE_LEASE_MS,
55
60
  ROUTINE_FIRE_PREFIX,
61
+ ROUTINE_INBOX_PREFIX,
56
62
  ROUTINE_FAILURE_BACKOFF_MS,
57
63
  ROUTINE_FAILURE_PAUSE_AFTER,
58
64
  ROUTINE_FIRE_TIMEOUT_MS,
59
65
  ROUTINE_LIMIT_PER_BOT,
60
- ROUTINE_MISSED_GRACE_MS,
61
66
  ROUTINE_PREFIX,
62
67
  ROUTINE_QUEUE_LIMIT,
63
68
  ROUTINE_QUEUE_PREFIX,
@@ -65,6 +70,7 @@ import {
65
70
  routineKeyV1,
66
71
  routineQueueKeyV1,
67
72
  routineQueuePrefixV1,
73
+ routineRunPrefixV1,
68
74
  routineScheduleKeyV1,
69
75
  } from "./storage-keys.js";
70
76
 
@@ -437,7 +443,14 @@ export class RoutineScheduler {
437
443
  async nextRuns(): Promise<Map<string, string>> {
438
444
  const next = new Map<string, string>();
439
445
  for (const { record, state } of await this.#clocks(this.#storage)) {
440
- next.set(record.routineId, new Date(state.dueAt).toISOString());
446
+ // The deadline, not the raw due time: a Routine held back by a deferral
447
+ // or a failure backoff is next owed a firing when the hold ends. Reading
448
+ // `dueAt` alone showed a "Next run" that had already gone past and never
449
+ // moved, for as long as the backoff lasted.
450
+ next.set(
451
+ record.routineId,
452
+ new Date(routineDeadlineV1(state)).toISOString(),
453
+ );
441
454
  }
442
455
  return next;
443
456
  }
@@ -590,7 +603,9 @@ export class RoutineScheduler {
590
603
  record: RoutineRecordV1,
591
604
  state: RoutineScheduleStateV1,
592
605
  now: Date,
593
- ): Promise<ClaimedFiringV1> {
606
+ // `undefined` when the schedule has no occurrence left: the Routine turns
607
+ // itself off and there is nothing to fire.
608
+ ): Promise<ClaimedFiringV1 | undefined> {
594
609
  const normalized = normalizeRoutineScheduleV1(
595
610
  record.schedule!,
596
611
  record.timezone,
@@ -629,13 +644,38 @@ export class RoutineScheduler {
629
644
  // re-owe this occurrence.
630
645
  const from = missedCount > 1 ? now : new Date(state.dueAt);
631
646
  const next = nextRoutineRunV1(normalized, from, anchor);
647
+ if (next === undefined) {
648
+ // The schedule has no occurrence left — `0 0 30 2 *` is February the
649
+ // 30th, and a Routine written before this was refused at write time
650
+ // still holds one. Falling back to "five minutes from now" turned that
651
+ // into a real model Turn every five minutes for ever. It is turned off
652
+ // instead, with a run-log entry that says why, and the firing this claim
653
+ // was about is abandoned rather than run.
654
+ await transaction.put(routineKeyV1(record.routineId), {
655
+ ...record,
656
+ enabled: false,
657
+ updatedAt: now.toISOString(),
658
+ } satisfies RoutineRecordV1);
659
+ await transaction.delete(routineScheduleKeyV1(record.routineId));
660
+ await appendRoutineRunEntryV1(transaction, {
661
+ schemaVersion: 1,
662
+ entryId: `${routineFireIdV1(record.routineId, String(state.dueAt))}-unreachable`,
663
+ routineId: record.routineId,
664
+ runId: routineFireIdV1(record.routineId, String(state.dueAt)),
665
+ fireId: routineFireIdV1(record.routineId, String(state.dueAt)),
666
+ trigger: "cron",
667
+ status: "skipped",
668
+ startedAt: now.toISOString(),
669
+ finishedAt: now.toISOString(),
670
+ summary: `The schedule "${record.schedule}" never comes around again in ${record.timezone}, so this Routine has been turned off. Give it a schedule that does and turn it back on.`,
671
+ } satisfies RoutineRunEntryV1);
672
+ return undefined;
673
+ }
632
674
  await transaction.put(routineScheduleKeyV1(record.routineId), {
633
675
  schemaVersion: 1,
634
676
  routineId: record.routineId,
635
677
  anchor: record.updatedAt,
636
- dueAt: (
637
- next ?? new Date(now.getTime() + ROUTINE_MISSED_GRACE_MS)
638
- ).getTime(),
678
+ dueAt: next.getTime(),
639
679
  ...(state.consecutiveFailures === undefined
640
680
  ? {}
641
681
  : { consecutiveFailures: state.consecutiveFailures }),
@@ -798,14 +838,32 @@ export class RoutineScheduler {
798
838
  ): Promise<void> {
799
839
  const name = await this.#routineName(transaction, fire.routineId);
800
840
  const verb = outcome.status === "cancelled" ? "was stopped" : "did not run";
841
+ // The sentence, not the kernel string: the raw summary is on the run-log
842
+ // row this same transaction writes, which is where an operator looks.
843
+ const text = `"${name}" ${verb}: ${routineFailureSentenceV1(outcome.summary)}`;
844
+ // The same Routine failing the same way every minute is one thing that is
845
+ // wrong, not sixty. It folds into the entry already at the head of the
846
+ // inbox, which keeps its place in the order and gains a count.
847
+ const repeated = await this.#collapsibleFailure(
848
+ transaction,
849
+ fire.routineId,
850
+ text,
851
+ );
852
+ if (repeated) {
853
+ await transaction.put(repeated.key, {
854
+ ...repeated.entry,
855
+ runId: fire.fireId,
856
+ createdAt: now,
857
+ repeatCount: (repeated.entry.repeatCount ?? 1) + 1,
858
+ } satisfies RoutineInboxEntryV1);
859
+ return;
860
+ }
801
861
  const records = await routineTerminalRecordsV1({
802
862
  runId: fire.fireId,
803
863
  routineId: fire.routineId,
804
864
  routineName: name,
805
- responseText:
806
- outcome.summary === undefined || outcome.summary.trim().length === 0
807
- ? `"${name}" ${verb}.`
808
- : `"${name}" ${verb}: ${outcome.summary}`,
865
+ responseText: text,
866
+ failure: true,
809
867
  now,
810
868
  read: (key) => transaction.get(key),
811
869
  });
@@ -815,6 +873,41 @@ export class RoutineScheduler {
815
873
  }
816
874
  }
817
875
 
876
+ /**
877
+ * The newest inbox entry, when it is an unread repeat of this same failure.
878
+ *
879
+ * Only the newest: an entry the User has already read, or one with anything
880
+ * in front of it, is part of a history rather than the live complaint, and
881
+ * rewriting it would move a row the User has already looked past.
882
+ */
883
+ async #collapsibleFailure(
884
+ transaction: RoutineStorageWritesV1,
885
+ routineId: string,
886
+ text: string,
887
+ ): Promise<{ key: string; entry: RoutineInboxEntryV1 } | undefined> {
888
+ const newest = await transaction.list<unknown>({
889
+ prefix: ROUTINE_INBOX_PREFIX,
890
+ limit: 1,
891
+ });
892
+ for (const [key, stored] of newest) {
893
+ try {
894
+ const entry = decodeRoutineInboxEntryV1(stored);
895
+ if (
896
+ !entry.acknowledged &&
897
+ entry.routineId === routineId &&
898
+ entry.wakeId === undefined &&
899
+ entry.text === text
900
+ ) {
901
+ return { key, entry };
902
+ }
903
+ } catch {
904
+ // An entry that cannot be read is not one to fold into.
905
+ }
906
+ return undefined;
907
+ }
908
+ return undefined;
909
+ }
910
+
818
911
  async #settleFiring(
819
912
  fire: RoutineFireV1,
820
913
  outcome: RoutineFireOutcomeV1,
@@ -823,6 +916,23 @@ export class RoutineScheduler {
823
916
  const finishedAt = at.toISOString();
824
917
  await this.#storage.transaction(async (transaction) => {
825
918
  await transaction.delete(routineFireKeyV1(fire.routineId));
919
+ // The Routine may have been deleted while this firing was in flight.
920
+ // Delete sweeps the record, the clock, the lock and the whole run log,
921
+ // and the settlement then used to re-append a run entry under the swept
922
+ // key and write an inbox entry naming a Routine that no longer exists —
923
+ // an orphan the user cannot open, delete, or explain. Nothing outlives
924
+ // the record it belongs to.
925
+ if (
926
+ (await transaction.get<unknown>(routineKeyV1(fire.routineId))) ===
927
+ undefined
928
+ ) {
929
+ const orphans = await transaction.list<unknown>({
930
+ prefix: routineRunPrefixV1(fire.routineId),
931
+ });
932
+ for (const key of orphans.keys()) await transaction.delete(key);
933
+ await transaction.delete(routineScheduleKeyV1(fire.routineId));
934
+ return;
935
+ }
826
936
  await this.#recordOutcomeOnClock(transaction, fire, outcome, at);
827
937
  if (outcome.status !== "ok") {
828
938
  await this.#recordFailureInbox(transaction, fire, outcome, finishedAt);
@@ -97,10 +97,14 @@ describe("RoutineViewV1", () => {
97
97
  });
98
98
  if (receipt.status !== "applied") throw new Error("unreachable");
99
99
  const view = receipt.routine;
100
- // The Bot writer's Session and Turn stay in the durable record.
101
- expect(view.createdBy).toEqual({ kind: "bot", botId: "scout" });
102
- expect(JSON.stringify(view)).not.toContain("tim:scout");
103
- expect(JSON.stringify(view)).not.toContain("turn-1");
100
+ // The Bot writer's Session and Turn travel with it: they are provenance,
101
+ // not key material, and the journey asks the record to name the Turn.
102
+ expect(view.createdBy).toEqual({
103
+ kind: "bot",
104
+ botId: "scout",
105
+ sessionId: "tim:scout",
106
+ turnId: "turn-1",
107
+ });
104
108
  expect(decodeRoutineViewV1(JSON.parse(JSON.stringify(view)))).toEqual(view);
105
109
  expect(
106
110
  decodeRoutineCommandReceiptV1(JSON.parse(JSON.stringify(receipt))),
package/src/shared.ts CHANGED
@@ -55,12 +55,16 @@ export const ROUTINE_LIST_MAX = 100;
55
55
  export const ROUTINE_RUN_LIST_MAX = 50;
56
56
 
57
57
  /**
58
- * Who wrote a Routine, as the client is told it. The Session and Turn a Bot
59
- * writer records stay in the durable record: the UI needs to say "the Bot wrote
60
- * this", not to replay the Turn that did.
58
+ * Who wrote a Routine, as the client is told it.
59
+ *
60
+ * A Bot writer names the Session and the Turn it wrote from. The panel only
61
+ * says "the Bot wrote this", but provenance that cannot answer "which Turn?"
62
+ * is not provenance: the audit trail and the run log both address a Turn, and
63
+ * a Routine that a Bot wrote must be traceable to the one that wrote it.
61
64
  */
62
65
  export type RoutineWriterViewV1 =
63
- { kind: "user" } | { kind: "bot"; botId: string };
66
+ | { kind: "user" }
67
+ | { kind: "bot"; botId: string; sessionId: string; turnId: string };
64
68
 
65
69
  /** One Routine as the hosted client sees it. Never any key material. */
66
70
  export interface RoutineViewV1 {
@@ -420,10 +424,17 @@ function decodeRoutineWriterViewV1(
420
424
  if (candidate.kind !== "bot") {
421
425
  throw new RoutineDecodeError(`${label} kind is invalid`);
422
426
  }
423
- routineExactKeys(candidate, ["kind", "botId"], [], label);
427
+ routineExactKeys(
428
+ candidate,
429
+ ["kind", "botId", "sessionId", "turnId"],
430
+ [],
431
+ label,
432
+ );
424
433
  return {
425
434
  kind: "bot",
426
435
  botId: routineText(candidate.botId, 128, `${label} botId`),
436
+ sessionId: routineText(candidate.sessionId, 128, `${label} sessionId`),
437
+ turnId: routineText(candidate.turnId, 128, `${label} turnId`),
427
438
  };
428
439
  }
429
440
 
@@ -709,6 +720,10 @@ export interface RoutineInboxEntryViewV1 {
709
720
  createdAt: string;
710
721
  acknowledged: boolean;
711
722
  acknowledgedAt?: string;
723
+ /** How many firings this entry stands for; absent means one. */
724
+ repeatCount?: number;
725
+ /** The firing did not work. */
726
+ failure?: true;
712
727
  }
713
728
 
714
729
  export interface RoutineInboxViewV1 {
package/src/store.test.ts CHANGED
@@ -54,11 +54,19 @@ describe("RoutineStore.execute", () => {
54
54
  expect(listed.routines).toHaveLength(1);
55
55
  });
56
56
 
57
- test("records a Bot writer as the Bot, and the view names no Session", async () => {
57
+ test("records a Bot writer as the Bot, naming the Turn that wrote it", async () => {
58
58
  const routines = store();
59
59
  const receipt = await routines.execute(create(), BOT);
60
60
  if (receipt.status !== "applied") throw new Error("unreachable");
61
- expect(receipt.routine.createdBy).toEqual({ kind: "bot", botId: "scout" });
61
+ // Provenance that cannot answer "which Turn?" is not provenance: the view
62
+ // used to carry the Bot id alone, so a Routine a Bot wrote could not be
63
+ // traced back to the Turn that wrote it.
64
+ expect(receipt.routine.createdBy).toEqual({
65
+ kind: "bot",
66
+ botId: "scout",
67
+ sessionId: "tim:scout",
68
+ turnId: "turn-7",
69
+ });
62
70
  const stored = await routines.read("brief");
63
71
  expect(stored?.createdBy).toEqual(BOT);
64
72
  });
@@ -259,3 +267,43 @@ describe("RoutineStore run log", () => {
259
267
  expect(log.entries[0]).toMatchObject({ status: "failed" });
260
268
  });
261
269
  });
270
+
271
+ describe("a schedule that never comes around", () => {
272
+ /**
273
+ * `0 0 30 2 *` is February the 30th: croner parses it happily and then never
274
+ * names a next run. It used to be stored, and the scheduler's clock then fell
275
+ * back to "five minutes from now" on every claim, so the Routine burned a
276
+ * whole model Turn every five minutes for ever. A schedule with no future
277
+ * occurrence is not a schedule.
278
+ */
279
+ test("is refused at write time, and nothing is stored", async () => {
280
+ const routines = store();
281
+ await expect(
282
+ routines.execute(create({ schedule: "0 0 30 2 *" }), USER),
283
+ ).rejects.toThrow(/never comes around again/u);
284
+ await expect(
285
+ routines.execute(
286
+ create({ commandId: "cmd-2", schedule: "0 0 31 4 *" }),
287
+ USER,
288
+ ),
289
+ ).rejects.toThrow(/never comes around again/u);
290
+ expect((await routines.list("scout")).routines).toHaveLength(0);
291
+ });
292
+
293
+ test("names the expression and the zone, so the field can be corrected", async () => {
294
+ const routines = store();
295
+ const refusal = await routines
296
+ .execute(create({ schedule: "0 0 30 2 *" }), USER)
297
+ .catch((error: unknown) => error);
298
+ expect((refusal as Error).message).toContain("0 0 30 2 *");
299
+ expect((refusal as Error).message).toContain("Australia/Sydney");
300
+ });
301
+
302
+ test("an ordinary rare schedule is still accepted", async () => {
303
+ const routines = store();
304
+ // February the 29th happens; it is simply not every year.
305
+ await expect(
306
+ routines.execute(create({ schedule: "0 0 29 2 *" }), USER),
307
+ ).resolves.toMatchObject({ status: "applied" });
308
+ });
309
+ });
package/src/store.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  // trigger kind, and minting is D3's.
22
22
  import {
23
23
  isRoutineTimezoneV1,
24
+ nextRoutineRunV1,
24
25
  normalizeRoutineScheduleV1,
25
26
  RoutineScheduleError,
26
27
  } from "./cron.js";
@@ -161,9 +162,17 @@ export interface RoutineStoreOptionsV1 {
161
162
  }
162
163
 
163
164
  function writerView(writer: RoutineWriterV1): RoutineWriterViewV1 {
165
+ // The Session and Turn travel with the Bot writer. A Routine a Bot wrote is
166
+ // provenance, and provenance that cannot name the Turn it came from is only
167
+ // half a record: "the Bot wrote this" is not answerable to "which Turn?".
164
168
  return writer.kind === "user"
165
169
  ? { kind: "user" }
166
- : { kind: "bot", botId: writer.botId };
170
+ : {
171
+ kind: "bot",
172
+ botId: writer.botId,
173
+ sessionId: writer.sessionId,
174
+ turnId: writer.turnId,
175
+ };
167
176
  }
168
177
 
169
178
  /**
@@ -774,8 +783,12 @@ export class RoutineStore {
774
783
  );
775
784
  }
776
785
  if (decoded.schedule !== undefined) {
786
+ let normalized;
777
787
  try {
778
- normalizeRoutineScheduleV1(decoded.schedule, decoded.timezone);
788
+ normalized = normalizeRoutineScheduleV1(
789
+ decoded.schedule,
790
+ decoded.timezone,
791
+ );
779
792
  } catch (error) {
780
793
  throw new RoutineDecodeError(
781
794
  error instanceof RoutineScheduleError
@@ -783,6 +796,17 @@ export class RoutineStore {
783
796
  : `Routine schedule is invalid: ${String(error)}`,
784
797
  );
785
798
  }
799
+ // A syntactically valid expression can still name a moment that never
800
+ // arrives — `0 0 30 2 *` is February the 30th. It used to be accepted,
801
+ // and then the clock fell back to "five minutes from now" on every claim,
802
+ // so the Routine burned a whole model Turn every five minutes for ever.
803
+ // A schedule that never comes around is not a schedule.
804
+ const now = this.#now();
805
+ if (nextRoutineRunV1(normalized, now, now) === undefined) {
806
+ throw new RoutineDecodeError(
807
+ `schedule "${decoded.schedule}" never comes around again in ${decoded.timezone}`,
808
+ );
809
+ }
786
810
  }
787
811
  return decoded;
788
812
  }