@frockbot/plugin-routines 0.3.7 → 0.3.9

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.7",
3
+ "version": "0.3.9",
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.7",
36
- "@frockbot/client-ui": "0.3.7",
37
- "@frockbot/configuration-core": "0.3.7",
38
- "@frockbot/kernel-agent-loop": "0.3.7",
39
- "@frockbot/kernel-contracts": "0.3.7",
40
- "@frockbot/plugin-shell": "0.3.7",
35
+ "@frockbot/client-core": "0.3.9",
36
+ "@frockbot/client-ui": "0.3.9",
37
+ "@frockbot/configuration-core": "0.3.9",
38
+ "@frockbot/kernel-agent-loop": "0.3.9",
39
+ "@frockbot/kernel-contracts": "0.3.9",
40
+ "@frockbot/plugin-shell": "0.3.9",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "croner": "10.0.1",
43
43
  "vue": "3.5.41"
package/src/agent.test.ts CHANGED
@@ -180,6 +180,97 @@ describe("routine_manage", () => {
180
180
  });
181
181
  });
182
182
 
183
+ // A Bot paused a User's Routine in a Turn about sheep farming: no approval, no
184
+ // confirmation, nothing in the transcript. A Routine the User made is theirs.
185
+ describe("a Routine the User created", () => {
186
+ async function seeded() {
187
+ const seam = host();
188
+ await seam.store.execute(
189
+ {
190
+ schemaVersion: 1,
191
+ type: "routine/create",
192
+ commandId: "cmd-user",
193
+ botId: "scout",
194
+ routineId: "theirs",
195
+ name: "Minute ping",
196
+ prompt: "Say ping.",
197
+ schedule: "@every 1m",
198
+ timezone: "UTC",
199
+ },
200
+ { kind: "user" },
201
+ );
202
+ return seam;
203
+ }
204
+
205
+ for (const action of ["pause", "delete", "update"] as const) {
206
+ test(`refuses ${action} when the User did not ask`, async () => {
207
+ const seam = await seeded();
208
+ const tool = createRoutineManageTool({ ...seam, writer: WRITER });
209
+
210
+ const result = await tool.execute(
211
+ {
212
+ action,
213
+ routineId: "theirs",
214
+ ...(action === "update" ? { prompt: "Say pong." } : {}),
215
+ },
216
+ CONTEXT,
217
+ );
218
+
219
+ expect(result.isError).toBe(true);
220
+ expect(result.content).toContain("created by the User");
221
+ expect(result.content).toContain("userAsked: true");
222
+ const listed = await seam.list();
223
+ expect(listed.routines[0]).toMatchObject({
224
+ enabled: true,
225
+ prompt: "Say ping.",
226
+ createdBy: { kind: "user" },
227
+ });
228
+ });
229
+ }
230
+
231
+ test("pauses it once the User has asked", async () => {
232
+ const seam = await seeded();
233
+ const tool = createRoutineManageTool({ ...seam, writer: WRITER });
234
+
235
+ const result = await tool.execute(
236
+ { action: "pause", routineId: "theirs", userAsked: true },
237
+ CONTEXT,
238
+ );
239
+
240
+ expect(result.isError).toBe(false);
241
+ expect((await seam.list()).routines[0]).toMatchObject({ enabled: false });
242
+ });
243
+
244
+ test("leaves the Bot free to manage its own Routines", async () => {
245
+ const seam = host();
246
+ const tool = createRoutineManageTool({ ...seam, writer: WRITER });
247
+ await tool.execute(
248
+ {
249
+ action: "create",
250
+ routineId: "mine",
251
+ name: "Housekeeping",
252
+ prompt: "Tidy up.",
253
+ schedule: "@daily",
254
+ },
255
+ CONTEXT,
256
+ );
257
+
258
+ const result = await tool.execute(
259
+ { action: "pause", routineId: "mine" },
260
+ { ...CONTEXT, effectId: "tool:1:2:0" },
261
+ );
262
+
263
+ expect(result.isError).toBe(false);
264
+ expect((await seam.list()).routines[0]).toMatchObject({ enabled: false });
265
+ });
266
+
267
+ test("says destructive actions need the User's word", () => {
268
+ const tool = createRoutineManageTool({ ...host(), writer: WRITER });
269
+ expect(tool.description).toContain("only when the User asked you");
270
+ expect(tool.description).toContain("do not switch it off yourself");
271
+ });
272
+ });
273
+
183
274
  describe("routineManageCommandV1", () => {
184
275
  test("maps a webhook trigger to the record's trigger shape", () => {
185
276
  expect(
package/src/agent.ts CHANGED
@@ -102,11 +102,23 @@ const ROUTINE_MANAGE_INPUT_SCHEMA = {
102
102
  description:
103
103
  "IANA time zone the schedule is read in, such as Australia/Sydney.",
104
104
  },
105
+ userAsked: {
106
+ type: "boolean",
107
+ description:
108
+ "Set true only when the User asked you, in this conversation, to pause, edit, or delete this Routine. Required for those three actions on a Routine the User created. Never set it because a Routine looks wrong to you, is failing, or is no longer useful: say so and let the User decide.",
109
+ },
105
110
  },
106
111
  required: ["action"],
107
112
  additionalProperties: false,
108
113
  } as const;
109
114
 
115
+ /** The actions that switch off or overwrite something already running. */
116
+ const DESTRUCTIVE_ROUTINE_ACTIONS = new Set<RoutineManageActionV1>([
117
+ "pause",
118
+ "update",
119
+ "delete",
120
+ ]);
121
+
110
122
  interface RoutineManageInputV1 {
111
123
  action: RoutineManageActionV1;
112
124
  routineId?: string;
@@ -115,6 +127,7 @@ interface RoutineManageInputV1 {
115
127
  schedule?: string;
116
128
  trigger?: "webhook";
117
129
  timezone?: string;
130
+ userAsked?: boolean;
118
131
  }
119
132
 
120
133
  function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
@@ -130,6 +143,7 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
130
143
  "schedule",
131
144
  "trigger",
132
145
  "timezone",
146
+ "userAsked",
133
147
  ]);
134
148
  for (const key of Object.keys(value)) {
135
149
  if (!allowed.has(key)) {
@@ -153,6 +167,9 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
153
167
  if (value.trigger !== undefined && value.trigger !== "webhook") {
154
168
  throw new RoutineDecodeError('routine_manage trigger must be "webhook"');
155
169
  }
170
+ if (value.userAsked !== undefined && typeof value.userAsked !== "boolean") {
171
+ throw new RoutineDecodeError("routine_manage userAsked must be a boolean");
172
+ }
156
173
  return {
157
174
  action,
158
175
  ...(optional("routineId") === undefined
@@ -169,6 +186,9 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
169
186
  ...(optional("timezone") === undefined
170
187
  ? {}
171
188
  : { timezone: optional("timezone")! }),
189
+ ...(value.userAsked === undefined
190
+ ? {}
191
+ : { userAsked: value.userAsked as boolean }),
172
192
  };
173
193
  }
174
194
 
@@ -239,6 +259,28 @@ export function routineManageCommandV1(
239
259
  });
240
260
  }
241
261
 
262
+ /**
263
+ * Whether the User, rather than this Bot, created the Routine.
264
+ *
265
+ * A listing that cannot be read answers `true`: not knowing who owns a Routine
266
+ * is a reason to ask, not a reason to switch it off. A Routine that is not in
267
+ * the listing at all is gone, and the command below will say so properly.
268
+ */
269
+ async function userAuthoredRoutineV1(
270
+ host: RoutinesRuntimeHostV1,
271
+ routineId: string,
272
+ ): Promise<boolean> {
273
+ try {
274
+ const listing = await host.list();
275
+ const routine = listing.routines.find(
276
+ (candidate) => candidate.routineId === routineId,
277
+ );
278
+ return routine === undefined ? false : routine.createdBy.kind === "user";
279
+ } catch {
280
+ return true;
281
+ }
282
+ }
283
+
242
284
  function refusal(reason: string): { content: string; isError: boolean } {
243
285
  return { content: `routine_manage was refused: ${reason}`, isError: true };
244
286
  }
@@ -257,6 +299,10 @@ export function createRoutineManageTool(
257
299
  "A Routine is a standing instruction that fires on a schedule or on a delivered webhook,",
258
300
  `as its own Turn rather than inside this conversation. Names are at most ${ROUTINE_NAME_MAX_LENGTH}`,
259
301
  `characters and prompts at most ${ROUTINE_PROMPT_MAX_LENGTH}.`,
302
+ "Pausing, editing, or deleting a Routine the User created switches off something they set up,",
303
+ "so do it only when the User asked you to in this conversation, and pass userAsked: true when they did.",
304
+ "If a Routine of theirs is failing or looks wrong, tell them and let them decide — do not switch it off yourself.",
305
+ "Say in your reply whatever you changed.",
260
306
  ].join(" "),
261
307
  inputSchema: ROUTINE_MANAGE_INPUT_SCHEMA as unknown as Record<
262
308
  string,
@@ -272,15 +318,32 @@ export function createRoutineManageTool(
272
318
  }
273
319
  },
274
320
  execute: async (input: unknown, context: ToolExecutionContext) => {
321
+ let decoded: RoutineManageInputV1;
275
322
  let command: RoutineCommandV1;
276
323
  try {
277
- command = routineManageCommandV1(decodeRoutineManageInputV1(input), {
324
+ decoded = decodeRoutineManageInputV1(input);
325
+ command = routineManageCommandV1(decoded, {
278
326
  botId: host.botId,
279
327
  commandId: routineToolCommandIdV1(context.effectId),
280
328
  });
281
329
  } catch (error) {
282
330
  return refusal(error instanceof Error ? error.message : String(error));
283
331
  }
332
+ // A Bot paused a User's Routine in a Turn about sheep farming, with no
333
+ // approval, no confirmation, and nothing in the transcript saying so.
334
+ // The User's own Routines are theirs: switching one off, or rewriting
335
+ // it, needs the User to have asked for it in this conversation. The
336
+ // Bot's own Routines it may manage freely — those are its housekeeping.
337
+ if (
338
+ DESTRUCTIVE_ROUTINE_ACTIONS.has(decoded.action) &&
339
+ decoded.userAsked !== true &&
340
+ decoded.routineId !== undefined &&
341
+ (await userAuthoredRoutineV1(host, decoded.routineId))
342
+ ) {
343
+ return refusal(
344
+ `Routine ${decoded.routineId} was created by the User. Ask them before you ${decoded.action === "update" ? "change" : decoded.action} it, and call this again with userAsked: true once they say so.`,
345
+ );
346
+ }
284
347
  const writer: RoutineWriterV1 = {
285
348
  kind: "bot",
286
349
  botId: host.botId,
@@ -82,6 +82,10 @@ function acknowledge(entryIds: string[]): void {
82
82
  <p class="routine-inbox__text">{{ entry.text }}</p>
83
83
  <footer class="routine-inbox__meta">
84
84
  <span>{{ entry.createdAt }}</span>
85
+ <!-- One thing going wrong repeatedly is one entry and a count. -->
86
+ <span v-if="(entry.repeatCount ?? 1) > 1"
87
+ >Happened {{ entry.repeatCount }} times</span
88
+ >
85
89
  <UiButton
86
90
  v-if="!entry.acknowledged"
87
91
  variant="ghost"
@@ -82,6 +82,8 @@ export interface RoutineTerminalInputV1 {
82
82
  /** The Turn's own response text, used when it handed off nothing. */
83
83
  responseText?: string;
84
84
  now: string;
85
+ /** The firing did not work; the entry is a complaint, not a completion. */
86
+ failure?: true;
85
87
  read<T>(key: string): Promise<T | undefined>;
86
88
  }
87
89
 
@@ -129,6 +131,7 @@ export async function routineTerminalRecordsV1(
129
131
  createdAt: input.now,
130
132
  acknowledged: false,
131
133
  ...(input.handoff === undefined ? {} : { wakeId }),
134
+ ...(input.failure === undefined ? {} : { failure: input.failure }),
132
135
  };
133
136
  const records: Record<string, unknown> = {
134
137
  [routineInboxKeyV1(inbox.nextSeq)]: entry,
package/src/inbox.ts CHANGED
@@ -58,6 +58,42 @@ export function subagentAttributionV1(description: string): string {
58
58
 
59
59
  /** Longest hand-off an inbox entry or a pending wake carries. */
60
60
  export const ROUTINE_INBOX_TEXT_MAX = 4_000;
61
+
62
+ /**
63
+ * What a person is told a firing failed for.
64
+ *
65
+ * A failure summary is whatever the kernel had to hand — the run's `failure`
66
+ * string, a thrown message, a lease timeout. Some of those are sentences a
67
+ * person can act on ("the model is unavailable"); others are invariants
68
+ * addressed to this codebase, like `tool occurrence "tool:1:1:1" was not
69
+ * settled before step end`, which told a User nothing except that something
70
+ * they cannot see is broken. The raw string stays on the run-log row, which is
71
+ * where an operator looks; what reaches the inbox and the notification is a
72
+ * sentence.
73
+ *
74
+ * The test is deliberately coarse: anything carrying the shape of an internal
75
+ * identifier — a quoted occurrence, a `foo:1:2:3` coordinate, a stack frame —
76
+ * is not for a person. Everything else is passed through, because a provider
77
+ * saying "rate limited" is exactly what the User wants to read.
78
+ */
79
+ const INTERNAL_FAILURE_MARKERS = [
80
+ /\btool occurrence\b/i,
81
+ /\b[a-z-]+:\d+:\d+:\d+\b/i,
82
+ /\bat [\w$.]+ \(/,
83
+ /\bschemaVersion\b/,
84
+ /\boutcome model-error\b/i,
85
+ /\binvariant\b/i,
86
+ ];
87
+
88
+ /** The sentence a person reads instead of a kernel string. Never empty. */
89
+ export function routineFailureSentenceV1(summary: string | undefined): string {
90
+ const text = (summary ?? "").trim();
91
+ if (text.length === 0) return "It stopped without saying why.";
92
+ if (INTERNAL_FAILURE_MARKERS.some((marker) => marker.test(text))) {
93
+ return "Something inside FrockBot went wrong; the run log has the details.";
94
+ }
95
+ return text;
96
+ }
61
97
  /** Longest title a pending wake carries. */
62
98
  export const ROUTINE_WAKE_TITLE_MAX = 200;
63
99
 
@@ -78,6 +114,23 @@ export interface RoutineInboxEntryV1 {
78
114
  /** Present when the Turn also handed off, naming the wake it queued. */
79
115
  wakeId?: string;
80
116
  acknowledgedAt?: string;
117
+ /**
118
+ * How many firings this entry stands for. Absent means one.
119
+ *
120
+ * A Routine that fails every minute wrote a fresh entry every minute, and a
121
+ * user who had not looked in an hour found sixty rows of the same sentence.
122
+ * Consecutive failures of the same Routine with the same failure fold into
123
+ * the entry already there and raise this count, so the inbox says what is
124
+ * wrong once and how often it has happened.
125
+ */
126
+ repeatCount?: number;
127
+ /**
128
+ * Set when the entry is a firing that did not work. A completion is routine
129
+ * and deliberately badges nothing; a failure is the Bot telling its User
130
+ * that an automation they set up has stopped, which is exactly what the
131
+ * sidebar badge is for.
132
+ */
133
+ failure?: true;
81
134
  /**
82
135
  * What produced this entry. Absent means `routine`; `routineId` then carries
83
136
  * the task id, because the field names the automation the entry came from
@@ -210,7 +263,7 @@ export function decodeRoutineInboxEntryV1(
210
263
  "createdAt",
211
264
  "acknowledged",
212
265
  ],
213
- ["wakeId", "acknowledgedAt", "source"],
266
+ ["wakeId", "acknowledgedAt", "source", "repeatCount", "failure"],
214
267
  label,
215
268
  );
216
269
  if (candidate.schemaVersion !== 1) {
@@ -249,9 +302,25 @@ export function decodeRoutineInboxEntryV1(
249
302
  ...(candidate.source === undefined
250
303
  ? {}
251
304
  : { source: completionSourceV1(candidate.source, `${label} source`) }),
305
+ ...(candidate.repeatCount === undefined
306
+ ? {}
307
+ : {
308
+ repeatCount: routineRepeatCountV1(
309
+ candidate.repeatCount,
310
+ `${label} repeatCount`,
311
+ ),
312
+ }),
313
+ ...(candidate.failure === undefined ? {} : { failure: true as const }),
252
314
  };
253
315
  }
254
316
 
317
+ function routineRepeatCountV1(value: unknown, label: string): number {
318
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
319
+ throw new RoutineDecodeError(`${label} is invalid`);
320
+ }
321
+ return value as number;
322
+ }
323
+
255
324
  export function decodePendingBotInputV1(
256
325
  value: unknown,
257
326
  label = "pending Bot input",
@@ -759,6 +759,119 @@ describe("a failed firing", () => {
759
759
  });
760
760
  });
761
761
 
762
+ describe("what a failing Routine tells the person", () => {
763
+ // The inbox entry, the run-log summary and the notification all carried
764
+ // `tool occurrence "tool:1:1:1" was not settled before step end`.
765
+ test("writes a sentence, and keeps the kernel string on the run log", async () => {
766
+ const { storage, time, scheduler, store, create } = harness({
767
+ start: "2026-01-01T00:00:00.000Z",
768
+ schedule: "@every 1m",
769
+ });
770
+ await store.execute(create, USER);
771
+ time.set("2026-01-01T00:01:00.000Z");
772
+ const raw =
773
+ 'Bot turn ended with outcome model-error: tool occurrence "tool:1:1:1" was not settled before step end';
774
+ await drain(scheduler, { status: "failed", summary: raw });
775
+
776
+ const entries = await new RoutineInboxStore(storage, {
777
+ now: time.now,
778
+ }).list();
779
+ expect(entries).toHaveLength(1);
780
+ expect(entries[0]?.text).toBe(
781
+ '"Morning brief" did not run: Something inside FrockBot went wrong; the run log has the details.',
782
+ );
783
+ expect(entries[0]?.text).not.toContain("tool occurrence");
784
+ // The operator's copy is untouched.
785
+ const runs = await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX });
786
+ expect(
787
+ [...runs.values()].map((row) => decodeRoutineRunEntryV1(row).summary),
788
+ ).toEqual([raw]);
789
+ });
790
+
791
+ test("a provider's own words reach the person unchanged", async () => {
792
+ const { storage, time, scheduler, store, create } = harness({
793
+ start: "2026-01-01T00:00:00.000Z",
794
+ schedule: "@every 1m",
795
+ });
796
+ await store.execute(create, USER);
797
+ time.set("2026-01-01T00:01:00.000Z");
798
+ await drain(scheduler, {
799
+ status: "failed",
800
+ summary: "the model provider is rate limiting this account",
801
+ });
802
+
803
+ const entries = await new RoutineInboxStore(storage, {
804
+ now: time.now,
805
+ }).list();
806
+ expect(entries[0]?.text).toBe(
807
+ '"Morning brief" did not run: the model provider is rate limiting this account',
808
+ );
809
+ });
810
+
811
+ // Sixty rows of the same sentence is not sixty things being wrong.
812
+ test("folds repeats of one failure into a single entry with a count", async () => {
813
+ const { storage, time, scheduler, store, create } = harness({
814
+ start: "2026-01-01T00:00:00.000Z",
815
+ schedule: "@every 1m",
816
+ });
817
+ await store.execute(create, USER);
818
+ const failure = { status: "failed" as const, summary: "flaked" };
819
+ for (const minute of ["00:01", "02:00", "04:00"]) {
820
+ time.set(`2026-01-01T${minute}:00.000Z`);
821
+ await drain(scheduler, failure);
822
+ }
823
+
824
+ const entries = await new RoutineInboxStore(storage, {
825
+ now: time.now,
826
+ }).list();
827
+ expect(entries).toHaveLength(1);
828
+ expect(entries[0]?.repeatCount).toBe(3);
829
+ expect(entries[0]?.createdAt).toBe("2026-01-01T04:00:00.000Z");
830
+ // Every firing still has its own run-log row; only the inbox collapses.
831
+ expect(
832
+ (await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX })).size,
833
+ ).toBeGreaterThanOrEqual(3);
834
+ });
835
+
836
+ test("a different failure gets its own entry", async () => {
837
+ const { storage, time, scheduler, store, create } = harness({
838
+ start: "2026-01-01T00:00:00.000Z",
839
+ schedule: "@every 1m",
840
+ });
841
+ await store.execute(create, USER);
842
+ time.set("2026-01-01T00:01:00.000Z");
843
+ await drain(scheduler, { status: "failed", summary: "flaked" });
844
+ time.set("2026-01-01T02:00:00.000Z");
845
+ await drain(scheduler, { status: "failed", summary: "the disk is full" });
846
+
847
+ const entries = await new RoutineInboxStore(storage, {
848
+ now: time.now,
849
+ }).list();
850
+ expect(entries).toHaveLength(2);
851
+ expect(entries.every((entry) => entry.repeatCount === undefined)).toBe(
852
+ true,
853
+ );
854
+ });
855
+
856
+ // The panel showed a "Next run" five minutes in the past that never moved.
857
+ test("reports a backed-off Routine's next run in the future", async () => {
858
+ const { storage, time, scheduler, store, create } = harness({
859
+ start: "2026-01-01T00:00:00.000Z",
860
+ schedule: "@every 1m",
861
+ });
862
+ await store.execute(create, USER);
863
+ time.set("2026-01-01T00:01:00.000Z");
864
+ await drain(scheduler, { status: "failed", summary: "flaked" });
865
+
866
+ const next = (await scheduler.nextRuns()).get("brief");
867
+ expect(next).toBeDefined();
868
+ expect(Date.parse(next!)).toBeGreaterThan(time.now().getTime());
869
+ expect(next).toBe(
870
+ new Date(routineDeadlineV1(await state(storage))).toISOString(),
871
+ );
872
+ });
873
+ });
874
+
762
875
  describe("an undecodable Routine record", () => {
763
876
  test("degrades that one Routine, never the whole object", async () => {
764
877
  const { storage, time, scheduler, store, create } = harness({
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,6 +58,7 @@ 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,
@@ -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
  }
@@ -798,14 +811,32 @@ export class RoutineScheduler {
798
811
  ): Promise<void> {
799
812
  const name = await this.#routineName(transaction, fire.routineId);
800
813
  const verb = outcome.status === "cancelled" ? "was stopped" : "did not run";
814
+ // The sentence, not the kernel string: the raw summary is on the run-log
815
+ // row this same transaction writes, which is where an operator looks.
816
+ const text = `"${name}" ${verb}: ${routineFailureSentenceV1(outcome.summary)}`;
817
+ // The same Routine failing the same way every minute is one thing that is
818
+ // wrong, not sixty. It folds into the entry already at the head of the
819
+ // inbox, which keeps its place in the order and gains a count.
820
+ const repeated = await this.#collapsibleFailure(
821
+ transaction,
822
+ fire.routineId,
823
+ text,
824
+ );
825
+ if (repeated) {
826
+ await transaction.put(repeated.key, {
827
+ ...repeated.entry,
828
+ runId: fire.fireId,
829
+ createdAt: now,
830
+ repeatCount: (repeated.entry.repeatCount ?? 1) + 1,
831
+ } satisfies RoutineInboxEntryV1);
832
+ return;
833
+ }
801
834
  const records = await routineTerminalRecordsV1({
802
835
  runId: fire.fireId,
803
836
  routineId: fire.routineId,
804
837
  routineName: name,
805
- responseText:
806
- outcome.summary === undefined || outcome.summary.trim().length === 0
807
- ? `"${name}" ${verb}.`
808
- : `"${name}" ${verb}: ${outcome.summary}`,
838
+ responseText: text,
839
+ failure: true,
809
840
  now,
810
841
  read: (key) => transaction.get(key),
811
842
  });
@@ -815,6 +846,41 @@ export class RoutineScheduler {
815
846
  }
816
847
  }
817
848
 
849
+ /**
850
+ * The newest inbox entry, when it is an unread repeat of this same failure.
851
+ *
852
+ * Only the newest: an entry the User has already read, or one with anything
853
+ * in front of it, is part of a history rather than the live complaint, and
854
+ * rewriting it would move a row the User has already looked past.
855
+ */
856
+ async #collapsibleFailure(
857
+ transaction: RoutineStorageWritesV1,
858
+ routineId: string,
859
+ text: string,
860
+ ): Promise<{ key: string; entry: RoutineInboxEntryV1 } | undefined> {
861
+ const newest = await transaction.list<unknown>({
862
+ prefix: ROUTINE_INBOX_PREFIX,
863
+ limit: 1,
864
+ });
865
+ for (const [key, stored] of newest) {
866
+ try {
867
+ const entry = decodeRoutineInboxEntryV1(stored);
868
+ if (
869
+ !entry.acknowledged &&
870
+ entry.routineId === routineId &&
871
+ entry.wakeId === undefined &&
872
+ entry.text === text
873
+ ) {
874
+ return { key, entry };
875
+ }
876
+ } catch {
877
+ // An entry that cannot be read is not one to fold into.
878
+ }
879
+ return undefined;
880
+ }
881
+ return undefined;
882
+ }
883
+
818
884
  async #settleFiring(
819
885
  fire: RoutineFireV1,
820
886
  outcome: RoutineFireOutcomeV1,
package/src/shared.ts CHANGED
@@ -709,6 +709,10 @@ export interface RoutineInboxEntryViewV1 {
709
709
  createdAt: string;
710
710
  acknowledged: boolean;
711
711
  acknowledgedAt?: string;
712
+ /** How many firings this entry stands for; absent means one. */
713
+ repeatCount?: number;
714
+ /** The firing did not work. */
715
+ failure?: true;
712
716
  }
713
717
 
714
718
  export interface RoutineInboxViewV1 {