@frockbot/plugin-routines 0.3.8 → 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 +7 -7
- package/src/agent.test.ts +91 -0
- package/src/agent.ts +64 -1
- package/src/client/RoutineInboxBadge.vue +4 -0
- package/src/inbox-store.ts +3 -0
- package/src/inbox.ts +70 -1
- package/src/scheduler.test.ts +113 -0
- package/src/scheduler.ts +71 -5
- package/src/shared.ts +4 -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.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.
|
|
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.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
|
-
|
|
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"
|
package/src/inbox-store.ts
CHANGED
|
@@ -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",
|
package/src/scheduler.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|