@vellumai/assistant 0.10.3-dev.202606292136.ce8bf33 → 0.10.3-dev.202606292229.153e9b2
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 +1 -1
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +22 -22
- package/src/__tests__/job-handler-registry-guard.test.ts +104 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +18 -0
- package/src/__tests__/schedule-retry.test.ts +51 -51
- package/src/__tests__/schedule-routes-workflow-validation.test.ts +45 -41
- package/src/__tests__/schedule-routes.test.ts +192 -182
- package/src/__tests__/schedule-store.test.ts +172 -170
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
- package/src/__tests__/scheduler-recurrence.test.ts +12 -12
- package/src/__tests__/scheduler-reuse-conversation.test.ts +10 -10
- package/src/__tests__/scheduler-wake.test.ts +6 -6
- package/src/__tests__/task-scheduler.test.ts +22 -22
- package/src/background-wake/wake-intent-hooks.test.ts +3 -3
- package/src/daemon/external-plugins-bootstrap.ts +22 -0
- package/src/daemon/lifecycle.ts +1 -1
- package/src/daemon/shutdown-handlers.ts +148 -147
- package/src/jobs/register-job-handlers.ts +24 -167
- package/src/memory/memory-retrospective-job.ts +5 -5
- package/src/memory/memory-retrospective-state.ts +7 -7
- package/src/notifications/conversation-pairing.ts +23 -14
- package/src/persistence/conversation-bootstrap.ts +24 -15
- package/src/persistence/conversation-crud.ts +62 -59
- package/src/plugins/defaults/index.ts +28 -0
- package/src/plugins/defaults/memory/job-handlers.ts +203 -0
- package/src/plugins/job-handler-registry.ts +94 -0
- package/src/plugins/types.ts +26 -0
- package/src/runtime/background-job-runner.ts +9 -7
- package/src/runtime/routes/conversation-cli-routes.ts +5 -1
- package/src/runtime/routes/conversation-management-routes.ts +6 -6
- package/src/runtime/routes/conversations-import-routes.ts +5 -1
- package/src/runtime/routes/defer-routes.ts +3 -3
- package/src/runtime/routes/home-feed-routes.ts +9 -4
- package/src/runtime/routes/schedule-routes.ts +31 -28
- package/src/runtime/routes/wipe-conversation-routes.ts +1 -1
- package/src/runtime/services/analyze-conversation.ts +18 -9
- package/src/schedule/retry-policy.ts +8 -8
- package/src/schedule/schedule-recovery.ts +8 -5
- package/src/schedule/schedule-store.ts +124 -109
- package/src/schedule/scheduler.ts +64 -52
- package/src/subagent/manager.ts +1 -1
- package/src/tasks/task-runner.ts +1 -1
- package/src/tools/schedule/create.ts +2 -2
- package/src/tools/schedule/delete.ts +1 -1
- package/src/tools/schedule/update.ts +1 -1
- package/src/util/sqlite-retry.ts +8 -41
package/package.json
CHANGED
|
@@ -69,8 +69,8 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
69
69
|
getRawDb().run("DELETE FROM conversations");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
test("deleting a conversation with a scheduleJobId removes the schedule", () => {
|
|
73
|
-
const schedule = createSchedule({
|
|
72
|
+
test("deleting a conversation with a scheduleJobId removes the schedule", async () => {
|
|
73
|
+
const schedule = await createSchedule({
|
|
74
74
|
name: "Daily standup",
|
|
75
75
|
expression: "0 9 * * 1-5",
|
|
76
76
|
message: "Time for standup!",
|
|
@@ -83,7 +83,7 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
83
83
|
|
|
84
84
|
expect(getSchedule(schedule.id)).not.toBeNull();
|
|
85
85
|
|
|
86
|
-
deleteRoute.handler({
|
|
86
|
+
await deleteRoute.handler({
|
|
87
87
|
pathParams: { id: conv.id },
|
|
88
88
|
body: {},
|
|
89
89
|
headers: {},
|
|
@@ -93,8 +93,8 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
93
93
|
expect(getConversation(conv.id)).toBeNull();
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
test("deleting a conversation without a scheduleJobId does not affect schedules", () => {
|
|
97
|
-
const schedule = createSchedule({
|
|
96
|
+
test("deleting a conversation without a scheduleJobId does not affect schedules", async () => {
|
|
97
|
+
const schedule = await createSchedule({
|
|
98
98
|
name: "Unrelated schedule",
|
|
99
99
|
expression: "0 12 * * *",
|
|
100
100
|
message: "Noon check",
|
|
@@ -102,7 +102,7 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
102
102
|
|
|
103
103
|
const conv = createConversation("no-schedule-conv");
|
|
104
104
|
|
|
105
|
-
deleteRoute.handler({
|
|
105
|
+
await deleteRoute.handler({
|
|
106
106
|
pathParams: { id: conv.id },
|
|
107
107
|
body: {},
|
|
108
108
|
headers: {},
|
|
@@ -112,8 +112,8 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
112
112
|
expect(getConversation(conv.id)).toBeNull();
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
test("deleting a conversation with a schedule also removes its cron_runs", () => {
|
|
116
|
-
const schedule = createSchedule({
|
|
115
|
+
test("deleting a conversation with a schedule also removes its cron_runs", async () => {
|
|
116
|
+
const schedule = await createSchedule({
|
|
117
117
|
name: "Recurring job",
|
|
118
118
|
expression: "0 9 * * *",
|
|
119
119
|
message: "Daily task",
|
|
@@ -137,7 +137,7 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
137
137
|
.get();
|
|
138
138
|
expect(runBefore).not.toBeNull();
|
|
139
139
|
|
|
140
|
-
deleteRoute.handler({
|
|
140
|
+
await deleteRoute.handler({
|
|
141
141
|
pathParams: { id: conv.id },
|
|
142
142
|
body: {},
|
|
143
143
|
headers: {},
|
|
@@ -150,8 +150,8 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
150
150
|
expect(runAfter).toBeNull();
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
-
test("deleting one of multiple conversations sharing a schedule preserves the schedule", () => {
|
|
154
|
-
const schedule = createSchedule({
|
|
153
|
+
test("deleting one of multiple conversations sharing a schedule preserves the schedule", async () => {
|
|
154
|
+
const schedule = await createSchedule({
|
|
155
155
|
name: "Recurring daily",
|
|
156
156
|
expression: "0 9 * * *",
|
|
157
157
|
message: "Daily task",
|
|
@@ -166,7 +166,7 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
166
166
|
scheduleJobId: schedule.id,
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
-
deleteRoute.handler({
|
|
169
|
+
await deleteRoute.handler({
|
|
170
170
|
pathParams: { id: conv1.id },
|
|
171
171
|
body: {},
|
|
172
172
|
headers: {},
|
|
@@ -175,13 +175,13 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
175
175
|
expect(getSchedule(schedule.id)).not.toBeNull();
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
test("deleting one scheduled conversation does not affect other schedules", () => {
|
|
179
|
-
const scheduleA = createSchedule({
|
|
178
|
+
test("deleting one scheduled conversation does not affect other schedules", async () => {
|
|
179
|
+
const scheduleA = await createSchedule({
|
|
180
180
|
name: "Schedule A",
|
|
181
181
|
expression: "0 9 * * *",
|
|
182
182
|
message: "Task A",
|
|
183
183
|
});
|
|
184
|
-
const scheduleB = createSchedule({
|
|
184
|
+
const scheduleB = await createSchedule({
|
|
185
185
|
name: "Schedule B",
|
|
186
186
|
expression: "0 17 * * *",
|
|
187
187
|
message: "Task B",
|
|
@@ -196,7 +196,7 @@ describe("DELETE /conversations/:id — schedule cleanup", () => {
|
|
|
196
196
|
scheduleJobId: scheduleB.id,
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
-
deleteRoute.handler({
|
|
199
|
+
await deleteRoute.handler({
|
|
200
200
|
pathParams: { id: convA.id },
|
|
201
201
|
body: {},
|
|
202
202
|
headers: {},
|
|
@@ -222,8 +222,8 @@ describe("POST /conversations/:id/wipe — schedule cleanup", () => {
|
|
|
222
222
|
getRawDb().run("DELETE FROM conversations");
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
test("wiping a conversation with a scheduleJobId removes the schedule", () => {
|
|
226
|
-
const schedule = createSchedule({
|
|
225
|
+
test("wiping a conversation with a scheduleJobId removes the schedule", async () => {
|
|
226
|
+
const schedule = await createSchedule({
|
|
227
227
|
name: "Wipe-test schedule",
|
|
228
228
|
expression: "0 9 * * 1-5",
|
|
229
229
|
message: "Time for standup!",
|
|
@@ -236,7 +236,7 @@ describe("POST /conversations/:id/wipe — schedule cleanup", () => {
|
|
|
236
236
|
|
|
237
237
|
expect(getSchedule(schedule.id)).not.toBeNull();
|
|
238
238
|
|
|
239
|
-
wipeRoute.handler({
|
|
239
|
+
await wipeRoute.handler({
|
|
240
240
|
pathParams: { id: conv.id },
|
|
241
241
|
body: {},
|
|
242
242
|
headers: {},
|
|
@@ -245,8 +245,8 @@ describe("POST /conversations/:id/wipe — schedule cleanup", () => {
|
|
|
245
245
|
expect(getSchedule(schedule.id)).toBeNull();
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
test("wiping a conversation without a scheduleJobId does not affect schedules", () => {
|
|
249
|
-
const schedule = createSchedule({
|
|
248
|
+
test("wiping a conversation without a scheduleJobId does not affect schedules", async () => {
|
|
249
|
+
const schedule = await createSchedule({
|
|
250
250
|
name: "Unrelated schedule",
|
|
251
251
|
expression: "0 12 * * *",
|
|
252
252
|
message: "Noon check",
|
|
@@ -254,7 +254,7 @@ describe("POST /conversations/:id/wipe — schedule cleanup", () => {
|
|
|
254
254
|
|
|
255
255
|
const conv = createConversation("no-schedule-wipe");
|
|
256
256
|
|
|
257
|
-
wipeRoute.handler({
|
|
257
|
+
await wipeRoute.handler({
|
|
258
258
|
pathParams: { id: conv.id },
|
|
259
259
|
body: {},
|
|
260
260
|
headers: {},
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { registerMemoryJobHandlers } from "../jobs/register-job-handlers.js";
|
|
4
|
+
import * as jobsWorker from "../persistence/jobs-worker.js";
|
|
5
|
+
import { registerDefaultPluginJobHandlers } from "../plugins/defaults/index.js";
|
|
6
|
+
import {
|
|
7
|
+
clearJobHandlerRegistry,
|
|
8
|
+
getRegisteredJobHandlers,
|
|
9
|
+
registerPluginJobHandlers,
|
|
10
|
+
} from "../plugins/job-handler-registry.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The exact job types the `default-memory` plugin contributes via its
|
|
14
|
+
* `jobHandlers` field. Locks the contribution against an accidental add/drop —
|
|
15
|
+
* the job-handler analog of the injector order guard.
|
|
16
|
+
*/
|
|
17
|
+
const MEMORY_JOB_TYPES = [
|
|
18
|
+
"embed_segment",
|
|
19
|
+
"embed_summary",
|
|
20
|
+
"backfill",
|
|
21
|
+
"rebuild_index",
|
|
22
|
+
"delete_qdrant_vectors",
|
|
23
|
+
"embed_media",
|
|
24
|
+
"embed_attachment",
|
|
25
|
+
"embed_graph_node",
|
|
26
|
+
"embed_pkb_file",
|
|
27
|
+
"graph_trigger_embed",
|
|
28
|
+
"graph_extract",
|
|
29
|
+
"graph_decay",
|
|
30
|
+
"graph_consolidate",
|
|
31
|
+
"graph_pattern_scan",
|
|
32
|
+
"graph_narrative_refine",
|
|
33
|
+
"graph_bootstrap",
|
|
34
|
+
"embed_concept_page",
|
|
35
|
+
"memory_v2_sweep",
|
|
36
|
+
"memory_v2_consolidate",
|
|
37
|
+
"memory_v2_migrate",
|
|
38
|
+
"memory_v2_reembed",
|
|
39
|
+
"memory_v2_activation_recompute",
|
|
40
|
+
"memory_v3_maintain",
|
|
41
|
+
"memory_retrospective",
|
|
42
|
+
].sort();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Job types wired directly by `registerMemoryJobHandlers` for domains that are
|
|
46
|
+
* not plugins (persistence cleanup, conversations, media, home, runtime).
|
|
47
|
+
*/
|
|
48
|
+
const NON_PLUGIN_JOB_TYPES = [
|
|
49
|
+
"prune_old_conversations",
|
|
50
|
+
"prune_old_llm_request_logs",
|
|
51
|
+
"prune_old_trace_events",
|
|
52
|
+
"build_conversation_summary",
|
|
53
|
+
"media_processing",
|
|
54
|
+
"conversation_analyze",
|
|
55
|
+
"generate_conversation_starters",
|
|
56
|
+
].sort();
|
|
57
|
+
|
|
58
|
+
describe("job-handler registry — memory plugin contribution", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
clearJobHandlerRegistry();
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
clearJobHandlerRegistry();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("default-memory contributes exactly the memory job types", () => {
|
|
67
|
+
registerDefaultPluginJobHandlers();
|
|
68
|
+
const types = getRegisteredJobHandlers()
|
|
69
|
+
.map((e) => e.type)
|
|
70
|
+
.sort();
|
|
71
|
+
expect(types).toEqual(MEMORY_JOB_TYPES);
|
|
72
|
+
// Globally unique types (the registry would have thrown on a dupe).
|
|
73
|
+
expect(new Set(types).size).toBe(types.length);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("registerMemoryJobHandlers wires the full job-type set into the worker", () => {
|
|
77
|
+
const captured: string[] = [];
|
|
78
|
+
const spy = spyOn(jobsWorker, "registerJobHandler").mockImplementation(
|
|
79
|
+
(type: string) => {
|
|
80
|
+
captured.push(type);
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
try {
|
|
84
|
+
registerMemoryJobHandlers();
|
|
85
|
+
} finally {
|
|
86
|
+
spy.mockRestore();
|
|
87
|
+
}
|
|
88
|
+
const expected = [...MEMORY_JOB_TYPES, ...NON_PLUGIN_JOB_TYPES].sort();
|
|
89
|
+
expect(captured.slice().sort()).toEqual(expected);
|
|
90
|
+
// Each type registered exactly once — no plugin/non-plugin overlap, no drop.
|
|
91
|
+
expect(new Set(captured).size).toBe(captured.length);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects a duplicate job-handler type across plugins", () => {
|
|
95
|
+
registerPluginJobHandlers("plugin-a", [
|
|
96
|
+
{ type: "graph_extract", handler: () => undefined },
|
|
97
|
+
]);
|
|
98
|
+
expect(() =>
|
|
99
|
+
registerPluginJobHandlers("plugin-b", [
|
|
100
|
+
{ type: "graph_extract", handler: () => undefined },
|
|
101
|
+
]),
|
|
102
|
+
).toThrow(/already registered by plugin "plugin-a"/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -125,16 +125,34 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
125
125
|
"../../../../util/strip-comment-lines.js",
|
|
126
126
|
"../../../../util/truncate.js",
|
|
127
127
|
"../../../config/loader.js",
|
|
128
|
+
"../../../config/types.js",
|
|
128
129
|
"../../../context/strip-injections.js",
|
|
129
130
|
"../../../daemon/pkb-context-tracker.js",
|
|
130
131
|
"../../../daemon/pkb-reminder-builder.js",
|
|
131
132
|
"../../../daemon/trust-context.js",
|
|
133
|
+
"../../../memory/graph/bootstrap.js",
|
|
134
|
+
"../../../memory/graph/consolidation.js",
|
|
132
135
|
"../../../memory/graph/conversation-graph-memory.js",
|
|
136
|
+
"../../../memory/graph/decay.js",
|
|
137
|
+
"../../../memory/graph/extraction-job.js",
|
|
138
|
+
"../../../memory/graph/graph-search.js",
|
|
139
|
+
"../../../memory/graph/narrative.js",
|
|
140
|
+
"../../../memory/graph/pattern-scan.js",
|
|
141
|
+
"../../../memory/job-handlers/backfill.js",
|
|
142
|
+
"../../../memory/job-handlers/embedding.js",
|
|
143
|
+
"../../../memory/job-handlers/index-maintenance.js",
|
|
144
|
+
"../../../memory/jobs/embed-concept-page.js",
|
|
145
|
+
"../../../memory/jobs/embed-pkb-file.js",
|
|
146
|
+
"../../../memory/memory-retrospective-job.js",
|
|
133
147
|
"../../../memory/pkb/autoinject.js",
|
|
134
148
|
"../../../memory/pkb/context.js",
|
|
135
149
|
"../../../memory/pkb/pkb-search.js",
|
|
136
150
|
"../../../memory/pkb/types.js",
|
|
151
|
+
"../../../memory/v2/backfill-jobs.js",
|
|
152
|
+
"../../../memory/v2/consolidation-job.js",
|
|
137
153
|
"../../../memory/v2/static-context.js",
|
|
154
|
+
"../../../memory/v2/sweep-job.js",
|
|
155
|
+
"../../../persistence/jobs-store.js",
|
|
138
156
|
"../../../types.js",
|
|
139
157
|
"../../../util/logger.js",
|
|
140
158
|
"../../../util/platform.js",
|
|
@@ -107,8 +107,8 @@ describe("schedule retry store", () => {
|
|
|
107
107
|
db.run("DELETE FROM cron_jobs");
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
test("scheduleRetry reverts one-shot from firing to active with future nextRunAt", () => {
|
|
111
|
-
const schedule = createSchedule({
|
|
110
|
+
test("scheduleRetry reverts one-shot from firing to active with future nextRunAt", async () => {
|
|
111
|
+
const schedule = await createSchedule({
|
|
112
112
|
name: "One-shot retry",
|
|
113
113
|
expression: null,
|
|
114
114
|
nextRunAt: Date.now() - 1000,
|
|
@@ -122,15 +122,15 @@ describe("schedule retry store", () => {
|
|
|
122
122
|
]);
|
|
123
123
|
|
|
124
124
|
const futureTime = Date.now() + 120_000;
|
|
125
|
-
scheduleRetry(schedule.id, futureTime);
|
|
125
|
+
await scheduleRetry(schedule.id, futureTime);
|
|
126
126
|
|
|
127
127
|
const updated = getSchedule(schedule.id)!;
|
|
128
128
|
expect(updated.status).toBe("active");
|
|
129
129
|
expect(updated.nextRunAt).toBe(futureTime);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
test("scheduleRetry sets nextRunAt for recurring schedule without changing status", () => {
|
|
133
|
-
const schedule = createSchedule({
|
|
132
|
+
test("scheduleRetry sets nextRunAt for recurring schedule without changing status", async () => {
|
|
133
|
+
const schedule = await createSchedule({
|
|
134
134
|
name: "Recurring retry",
|
|
135
135
|
cronExpression: "0 * * * *",
|
|
136
136
|
message: "test",
|
|
@@ -139,7 +139,7 @@ describe("schedule retry store", () => {
|
|
|
139
139
|
|
|
140
140
|
const originalStatus = getSchedule(schedule.id)!.status;
|
|
141
141
|
const futureTime = Date.now() + 120_000;
|
|
142
|
-
scheduleRetry(schedule.id, futureTime);
|
|
142
|
+
await scheduleRetry(schedule.id, futureTime);
|
|
143
143
|
|
|
144
144
|
const updated = getSchedule(schedule.id)!;
|
|
145
145
|
expect(updated.nextRunAt).toBe(futureTime);
|
|
@@ -147,8 +147,8 @@ describe("schedule retry store", () => {
|
|
|
147
147
|
expect(updated.status).toBe(originalStatus);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
test("resetRetryCount resets retryCount to 0 after error", () => {
|
|
151
|
-
const schedule = createSchedule({
|
|
150
|
+
test("resetRetryCount resets retryCount to 0 after error", async () => {
|
|
151
|
+
const schedule = await createSchedule({
|
|
152
152
|
name: "Reset test",
|
|
153
153
|
cronExpression: "0 * * * *",
|
|
154
154
|
message: "test",
|
|
@@ -156,20 +156,20 @@ describe("schedule retry store", () => {
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
// Simulate an error run which increments retryCount
|
|
159
|
-
const runId = createScheduleRun(schedule.id, "test-conv");
|
|
160
|
-
completeScheduleRun(runId, { status: "error", error: "boom" });
|
|
159
|
+
const runId = await createScheduleRun(schedule.id, "test-conv");
|
|
160
|
+
await completeScheduleRun(runId, { status: "error", error: "boom" });
|
|
161
161
|
|
|
162
162
|
const afterError = getSchedule(schedule.id)!;
|
|
163
163
|
expect(afterError.retryCount).toBe(1);
|
|
164
164
|
|
|
165
|
-
resetRetryCount(schedule.id);
|
|
165
|
+
await resetRetryCount(schedule.id);
|
|
166
166
|
|
|
167
167
|
const afterReset = getSchedule(schedule.id)!;
|
|
168
168
|
expect(afterReset.retryCount).toBe(0);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
test("createSchedule with custom maxRetries and retryBackoffMs", () => {
|
|
172
|
-
const schedule = createSchedule({
|
|
171
|
+
test("createSchedule with custom maxRetries and retryBackoffMs", async () => {
|
|
172
|
+
const schedule = await createSchedule({
|
|
173
173
|
name: "Custom retry config",
|
|
174
174
|
cronExpression: "0 * * * *",
|
|
175
175
|
message: "test",
|
|
@@ -183,8 +183,8 @@ describe("schedule retry store", () => {
|
|
|
183
183
|
expect(retrieved.retryBackoffMs).toBe(30000);
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
-
test("createSchedule with defaults has maxRetries=3 and retryBackoffMs=60000", () => {
|
|
187
|
-
const schedule = createSchedule({
|
|
186
|
+
test("createSchedule with defaults has maxRetries=3 and retryBackoffMs=60000", async () => {
|
|
187
|
+
const schedule = await createSchedule({
|
|
188
188
|
name: "Default retry config",
|
|
189
189
|
cronExpression: "0 * * * *",
|
|
190
190
|
message: "test",
|
|
@@ -213,7 +213,7 @@ describe("scheduler retry integration", () => {
|
|
|
213
213
|
});
|
|
214
214
|
|
|
215
215
|
test("execute mode failure retries with backoff", async () => {
|
|
216
|
-
const schedule = createSchedule({
|
|
216
|
+
const schedule = await createSchedule({
|
|
217
217
|
name: "Failing hourly",
|
|
218
218
|
cronExpression: "0 * * * *",
|
|
219
219
|
message: "do work",
|
|
@@ -249,7 +249,7 @@ describe("scheduler retry integration", () => {
|
|
|
249
249
|
});
|
|
250
250
|
|
|
251
251
|
test("execute mode retry success resets retryCount", async () => {
|
|
252
|
-
const schedule = createSchedule({
|
|
252
|
+
const schedule = await createSchedule({
|
|
253
253
|
name: "Flaky hourly",
|
|
254
254
|
cronExpression: "0 * * * *",
|
|
255
255
|
message: "do work",
|
|
@@ -283,7 +283,7 @@ describe("scheduler retry integration", () => {
|
|
|
283
283
|
});
|
|
284
284
|
|
|
285
285
|
test("execute mode exhaustion resets retryCount and resumes cadence", async () => {
|
|
286
|
-
const schedule = createSchedule({
|
|
286
|
+
const schedule = await createSchedule({
|
|
287
287
|
name: "Exhaust hourly",
|
|
288
288
|
cronExpression: "0 * * * *",
|
|
289
289
|
message: "do work",
|
|
@@ -320,7 +320,7 @@ describe("scheduler retry integration", () => {
|
|
|
320
320
|
});
|
|
321
321
|
|
|
322
322
|
test("one-shot failure retries with backoff", async () => {
|
|
323
|
-
const schedule = createSchedule({
|
|
323
|
+
const schedule = await createSchedule({
|
|
324
324
|
name: "One-shot failing",
|
|
325
325
|
expression: null,
|
|
326
326
|
nextRunAt: Date.now() - 1000,
|
|
@@ -347,7 +347,7 @@ describe("scheduler retry integration", () => {
|
|
|
347
347
|
});
|
|
348
348
|
|
|
349
349
|
test("one-shot exhaustion permanently cancels", async () => {
|
|
350
|
-
const schedule = createSchedule({
|
|
350
|
+
const schedule = await createSchedule({
|
|
351
351
|
name: "One-shot exhaust",
|
|
352
352
|
expression: null,
|
|
353
353
|
nextRunAt: Date.now() - 1000,
|
|
@@ -379,7 +379,7 @@ describe("scheduler retry integration", () => {
|
|
|
379
379
|
});
|
|
380
380
|
|
|
381
381
|
test("notify one-shot failure creates schedule run", async () => {
|
|
382
|
-
const schedule = createSchedule({
|
|
382
|
+
const schedule = await createSchedule({
|
|
383
383
|
name: "Notify one-shot",
|
|
384
384
|
expression: null,
|
|
385
385
|
nextRunAt: Date.now() - 1000,
|
|
@@ -406,7 +406,7 @@ describe("scheduler retry integration", () => {
|
|
|
406
406
|
});
|
|
407
407
|
|
|
408
408
|
test("recurring schedule retry blocks normal cadence", async () => {
|
|
409
|
-
const schedule = createSchedule({
|
|
409
|
+
const schedule = await createSchedule({
|
|
410
410
|
name: "Cadence blocking",
|
|
411
411
|
cronExpression: "0 * * * *",
|
|
412
412
|
message: "do work",
|
|
@@ -444,8 +444,8 @@ describe("crash recovery", () => {
|
|
|
444
444
|
db.run("DELETE FROM cron_jobs");
|
|
445
445
|
});
|
|
446
446
|
|
|
447
|
-
test("recovers stale firing one-shot", () => {
|
|
448
|
-
const schedule = createSchedule({
|
|
447
|
+
test("recovers stale firing one-shot", async () => {
|
|
448
|
+
const schedule = await createSchedule({
|
|
449
449
|
name: "Stale one-shot",
|
|
450
450
|
expression: null,
|
|
451
451
|
nextRunAt: Date.now() - 60_000,
|
|
@@ -460,7 +460,7 @@ describe("crash recovery", () => {
|
|
|
460
460
|
[Date.now() - 60_000, schedule.id],
|
|
461
461
|
);
|
|
462
462
|
|
|
463
|
-
const recovered = recoverStaleSchedules();
|
|
463
|
+
const recovered = await recoverStaleSchedules();
|
|
464
464
|
expect(recovered).toBe(1);
|
|
465
465
|
|
|
466
466
|
// A schedule run should be created with error
|
|
@@ -476,8 +476,8 @@ describe("crash recovery", () => {
|
|
|
476
476
|
expect(updated.nextRunAt).toBeGreaterThan(Date.now() - 5000);
|
|
477
477
|
});
|
|
478
478
|
|
|
479
|
-
test("recovers stale running cron_run", () => {
|
|
480
|
-
const schedule = createSchedule({
|
|
479
|
+
test("recovers stale running cron_run", async () => {
|
|
480
|
+
const schedule = await createSchedule({
|
|
481
481
|
name: "Stale run",
|
|
482
482
|
cronExpression: "0 * * * *",
|
|
483
483
|
message: "test",
|
|
@@ -485,10 +485,10 @@ describe("crash recovery", () => {
|
|
|
485
485
|
});
|
|
486
486
|
|
|
487
487
|
// Create a "running" schedule run to simulate crash
|
|
488
|
-
const runId = createScheduleRun(schedule.id, "stale-conv");
|
|
488
|
+
const runId = await createScheduleRun(schedule.id, "stale-conv");
|
|
489
489
|
// The run is now in "running" status by default
|
|
490
490
|
|
|
491
|
-
const recovered = recoverStaleSchedules();
|
|
491
|
+
const recovered = await recoverStaleSchedules();
|
|
492
492
|
expect(recovered).toBe(1);
|
|
493
493
|
|
|
494
494
|
const runs = getScheduleRuns(schedule.id);
|
|
@@ -498,8 +498,8 @@ describe("crash recovery", () => {
|
|
|
498
498
|
expect(recoveredRun!.error).toContain("recovered on restart");
|
|
499
499
|
});
|
|
500
500
|
|
|
501
|
-
test("respects maxRetries on recovery - cancels exhausted one-shot", () => {
|
|
502
|
-
const schedule = createSchedule({
|
|
501
|
+
test("respects maxRetries on recovery - cancels exhausted one-shot", async () => {
|
|
502
|
+
const schedule = await createSchedule({
|
|
503
503
|
name: "Exhausted one-shot",
|
|
504
504
|
expression: null,
|
|
505
505
|
nextRunAt: Date.now() - 60_000,
|
|
@@ -514,15 +514,15 @@ describe("crash recovery", () => {
|
|
|
514
514
|
[3, Date.now() - 60_000, schedule.id],
|
|
515
515
|
);
|
|
516
516
|
|
|
517
|
-
recoverStaleSchedules();
|
|
517
|
+
await recoverStaleSchedules();
|
|
518
518
|
|
|
519
519
|
const updated = getSchedule(schedule.id)!;
|
|
520
520
|
expect(updated.status).toBe("cancelled");
|
|
521
521
|
expect(updated.enabled).toBe(false);
|
|
522
522
|
});
|
|
523
523
|
|
|
524
|
-
test("idempotent - second call returns 0", () => {
|
|
525
|
-
const schedule = createSchedule({
|
|
524
|
+
test("idempotent - second call returns 0", async () => {
|
|
525
|
+
const schedule = await createSchedule({
|
|
526
526
|
name: "Idempotent test",
|
|
527
527
|
expression: null,
|
|
528
528
|
nextRunAt: Date.now() - 60_000,
|
|
@@ -535,15 +535,15 @@ describe("crash recovery", () => {
|
|
|
535
535
|
[Date.now() - 60_000, schedule.id],
|
|
536
536
|
);
|
|
537
537
|
|
|
538
|
-
const first = recoverStaleSchedules();
|
|
538
|
+
const first = await recoverStaleSchedules();
|
|
539
539
|
expect(first).toBe(1);
|
|
540
540
|
|
|
541
|
-
const second = recoverStaleSchedules();
|
|
541
|
+
const second = await recoverStaleSchedules();
|
|
542
542
|
expect(second).toBe(0);
|
|
543
543
|
});
|
|
544
544
|
|
|
545
|
-
test("age threshold filters recent in-flight jobs", () => {
|
|
546
|
-
const schedule = createSchedule({
|
|
545
|
+
test("age threshold filters recent in-flight jobs", async () => {
|
|
546
|
+
const schedule = await createSchedule({
|
|
547
547
|
name: "Recent firing",
|
|
548
548
|
expression: null,
|
|
549
549
|
nextRunAt: Date.now() - 1000,
|
|
@@ -585,7 +585,7 @@ describe("scheduler-recovery equivalence", () => {
|
|
|
585
585
|
|
|
586
586
|
test("same retry state for identical recurring job inputs", async () => {
|
|
587
587
|
// Create two identical recurring schedules
|
|
588
|
-
const scheduleA = createSchedule({
|
|
588
|
+
const scheduleA = await createSchedule({
|
|
589
589
|
name: "Equiv A",
|
|
590
590
|
cronExpression: "0 * * * *",
|
|
591
591
|
message: "test",
|
|
@@ -594,7 +594,7 @@ describe("scheduler-recovery equivalence", () => {
|
|
|
594
594
|
retryBackoffMs: 60000,
|
|
595
595
|
});
|
|
596
596
|
|
|
597
|
-
const scheduleB = createSchedule({
|
|
597
|
+
const scheduleB = await createSchedule({
|
|
598
598
|
name: "Equiv B",
|
|
599
599
|
cronExpression: "0 * * * *",
|
|
600
600
|
message: "test",
|
|
@@ -620,8 +620,8 @@ describe("scheduler-recovery equivalence", () => {
|
|
|
620
620
|
scheduleB.id,
|
|
621
621
|
]);
|
|
622
622
|
// Create a run and complete it with error (same as what scheduler does)
|
|
623
|
-
const runId = createScheduleRun(scheduleB.id, "recovery-conv");
|
|
624
|
-
completeScheduleRun(runId, {
|
|
623
|
+
const runId = await createScheduleRun(scheduleB.id, "recovery-conv");
|
|
624
|
+
await completeScheduleRun(runId, {
|
|
625
625
|
status: "error",
|
|
626
626
|
error: "Process terminated during execution (recovered on restart)",
|
|
627
627
|
});
|
|
@@ -631,7 +631,7 @@ describe("scheduler-recovery equivalence", () => {
|
|
|
631
631
|
const noopLogger = new Proxy({} as Record<string, unknown>, {
|
|
632
632
|
get: () => () => {},
|
|
633
633
|
});
|
|
634
|
-
applyRetryDecision({
|
|
634
|
+
await applyRetryDecision({
|
|
635
635
|
job: jobB,
|
|
636
636
|
isOneShot: false,
|
|
637
637
|
errorMsg: "fail",
|
|
@@ -659,7 +659,7 @@ describe("scheduler-recovery equivalence", () => {
|
|
|
659
659
|
|
|
660
660
|
test("same exhaust state for identical one-shot inputs", async () => {
|
|
661
661
|
// Create two identical one-shot schedules at max retries
|
|
662
|
-
const scheduleA = createSchedule({
|
|
662
|
+
const scheduleA = await createSchedule({
|
|
663
663
|
name: "Exhaust A",
|
|
664
664
|
expression: null,
|
|
665
665
|
nextRunAt: Date.now() - 1000,
|
|
@@ -669,7 +669,7 @@ describe("scheduler-recovery equivalence", () => {
|
|
|
669
669
|
retryBackoffMs: 1000,
|
|
670
670
|
});
|
|
671
671
|
|
|
672
|
-
const scheduleB = createSchedule({
|
|
672
|
+
const scheduleB = await createSchedule({
|
|
673
673
|
name: "Exhaust B",
|
|
674
674
|
expression: null,
|
|
675
675
|
nextRunAt: Date.now() - 1000,
|
|
@@ -703,15 +703,15 @@ describe("scheduler-recovery equivalence", () => {
|
|
|
703
703
|
"UPDATE cron_jobs SET status = 'firing', last_run_at = ? WHERE id = ?",
|
|
704
704
|
[Date.now() - 60_000, scheduleB.id],
|
|
705
705
|
);
|
|
706
|
-
const runB1 = createScheduleRun(scheduleB.id, "recovery-conv-1");
|
|
707
|
-
completeScheduleRun(runB1, { status: "error", error: "fail" });
|
|
706
|
+
const runB1 = await createScheduleRun(scheduleB.id, "recovery-conv-1");
|
|
707
|
+
await completeScheduleRun(runB1, { status: "error", error: "fail" });
|
|
708
708
|
// decideRetry + apply for first failure
|
|
709
709
|
const jobB1 = getSchedule(scheduleB.id)!;
|
|
710
710
|
const decision1 = decideRetry(jobB1);
|
|
711
711
|
const noopLogger = new Proxy({} as Record<string, unknown>, {
|
|
712
712
|
get: () => () => {},
|
|
713
713
|
});
|
|
714
|
-
applyRetryDecision({
|
|
714
|
+
await applyRetryDecision({
|
|
715
715
|
job: jobB1,
|
|
716
716
|
isOneShot: true,
|
|
717
717
|
errorMsg: "fail",
|
|
@@ -734,11 +734,11 @@ describe("scheduler-recovery equivalence", () => {
|
|
|
734
734
|
"UPDATE cron_jobs SET status = 'firing', last_run_at = ? WHERE id = ?",
|
|
735
735
|
[Date.now() - 60_000, scheduleB.id],
|
|
736
736
|
);
|
|
737
|
-
const runB2 = createScheduleRun(scheduleB.id, "recovery-conv-2");
|
|
738
|
-
completeScheduleRun(runB2, { status: "error", error: "fail" });
|
|
737
|
+
const runB2 = await createScheduleRun(scheduleB.id, "recovery-conv-2");
|
|
738
|
+
await completeScheduleRun(runB2, { status: "error", error: "fail" });
|
|
739
739
|
const jobB2 = getSchedule(scheduleB.id)!;
|
|
740
740
|
const decision2 = decideRetry(jobB2);
|
|
741
|
-
applyRetryDecision({
|
|
741
|
+
await applyRetryDecision({
|
|
742
742
|
job: jobB2,
|
|
743
743
|
isOneShot: true,
|
|
744
744
|
errorMsg: "fail",
|