alvin-bot 4.18.0 → 4.18.2

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.
Files changed (74) hide show
  1. package/AEC-PLUGINS-SOURCES.md +53 -0
  2. package/CHANGELOG.md +37 -2
  3. package/DESIGN-SKILLS-SOURCES.md +81 -0
  4. package/bin/cli.js +1 -1
  5. package/dist/providers/claude-sdk-provider.js +24 -0
  6. package/package.json +3 -1
  7. package/test/allowed-users-gate.test.ts +0 -98
  8. package/test/alvin-dispatch.test.ts +0 -220
  9. package/test/async-agent-chunk-flow.test.ts +0 -244
  10. package/test/async-agent-parser-staleness.test.ts +0 -412
  11. package/test/async-agent-parser-streamjson.test.ts +0 -273
  12. package/test/async-agent-parser.test.ts +0 -322
  13. package/test/async-agent-watcher.test.ts +0 -229
  14. package/test/background-bypass-integration.test.ts +0 -443
  15. package/test/background-bypass-stress.test.ts +0 -417
  16. package/test/background-bypass.test.ts +0 -127
  17. package/test/browser-webfetch.test.ts +0 -121
  18. package/test/claude-sdk-provider.test.ts +0 -115
  19. package/test/claude-sdk-tool-use-id.test.ts +0 -180
  20. package/test/console-timestamps.test.ts +0 -98
  21. package/test/cron-progress-ticker.test.ts +0 -76
  22. package/test/cron-restart-resilience.test.ts +0 -191
  23. package/test/cron-run-resolver.test.ts +0 -133
  24. package/test/cron-runjobnow-throw.test.ts +0 -100
  25. package/test/debounce.test.ts +0 -60
  26. package/test/delivery-registry.test.ts +0 -71
  27. package/test/exec-guard-metachars.test.ts +0 -110
  28. package/test/file-permissions.test.ts +0 -130
  29. package/test/i18n.test.ts +0 -108
  30. package/test/list-subagents-merged.test.ts +0 -172
  31. package/test/memory-extractor.test.ts +0 -151
  32. package/test/memory-layers.test.ts +0 -169
  33. package/test/memory-sdk-injection.test.ts +0 -146
  34. package/test/memory-stress-restart.test.ts +0 -337
  35. package/test/multi-session-stress.test.ts +0 -255
  36. package/test/platform-session-key.test.ts +0 -69
  37. package/test/process-manager.test.ts +0 -186
  38. package/test/registry.test.ts +0 -201
  39. package/test/session-pending-background.test.ts +0 -59
  40. package/test/session-persistence.test.ts +0 -195
  41. package/test/slack-progress-ticker.test.ts +0 -123
  42. package/test/slack-slash-command.test.ts +0 -61
  43. package/test/slack-test-connection.test.ts +0 -176
  44. package/test/stress-scenarios.test.ts +0 -356
  45. package/test/stuck-timer.test.ts +0 -116
  46. package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
  47. package/test/subagent-delivery-platform-routing.test.ts +0 -232
  48. package/test/subagent-delivery.test.ts +0 -273
  49. package/test/subagent-final-text.test.ts +0 -132
  50. package/test/subagent-stats.test.ts +0 -119
  51. package/test/subagent-toolset-allowlist.test.ts +0 -146
  52. package/test/subagents-commands.test.ts +0 -64
  53. package/test/subagents-config.test.ts +0 -114
  54. package/test/subagents-depth.test.ts +0 -58
  55. package/test/subagents-inheritance.test.ts +0 -67
  56. package/test/subagents-name-resolver.test.ts +0 -122
  57. package/test/subagents-priority-reject.test.ts +0 -88
  58. package/test/subagents-queue.test.ts +0 -127
  59. package/test/subagents-shutdown.test.ts +0 -126
  60. package/test/subagents-toolset.test.ts +0 -71
  61. package/test/sync-task-timeout.test.ts +0 -153
  62. package/test/system-prompt-background-hint.test.ts +0 -65
  63. package/test/telegram-error-filter.test.ts +0 -85
  64. package/test/telegram-workspace-command.test.ts +0 -78
  65. package/test/timing-safe-bearer.test.ts +0 -65
  66. package/test/watchdog-brake.test.ts +0 -157
  67. package/test/watcher-pending-count.test.ts +0 -228
  68. package/test/watcher-zombie-fix.test.ts +0 -252
  69. package/test/web-server-integration.test.ts +0 -189
  70. package/test/web-server-resilience.test.ts +0 -118
  71. package/test/web-server-shutdown.test.ts +0 -117
  72. package/test/whatsapp-auth-resilience.test.ts +0 -96
  73. package/test/workspaces.test.ts +0 -196
  74. package/vitest.config.ts +0 -17
@@ -1,115 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- // Mock child_process BEFORE importing the provider, so the provider's
4
- // top-level `promisify(execFile)` binds to our mock.
5
- const execFileMock = vi.fn();
6
- vi.mock("child_process", () => ({
7
- execFile: (
8
- path: string,
9
- args: string[],
10
- opts: unknown,
11
- cb: (err: Error | null, stdout: { stdout: string; stderr: string }) => void,
12
- ) => {
13
- execFileMock(path, args, opts, cb);
14
- },
15
- }));
16
-
17
- // Stub findClaudeBinary to return a fake path — we don't want real FS
18
- vi.mock("../src/find-claude-binary.js", () => ({
19
- findClaudeBinary: () => "/fake/claude",
20
- }));
21
-
22
- describe("ClaudeSDKProvider.isAvailable", () => {
23
- beforeEach(() => {
24
- execFileMock.mockReset();
25
- vi.resetModules();
26
- });
27
-
28
- it("returns true when `claude auth status` reports loggedIn: true", async () => {
29
- // Sequence: --version then auth status (JSON)
30
- execFileMock
31
- .mockImplementationOnce((_p, _a, _o, cb) =>
32
- cb(null, { stdout: "1.0.0\n", stderr: "" }),
33
- )
34
- .mockImplementationOnce((_p, _a, _o, cb) =>
35
- cb(null, {
36
- stdout: JSON.stringify({
37
- loggedIn: true,
38
- authMethod: "claude.ai",
39
- subscriptionType: "max",
40
- }),
41
- stderr: "",
42
- }),
43
- );
44
-
45
- const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
46
- const p = new ClaudeSDKProvider();
47
- const result = await p.isAvailable();
48
- expect(result).toBe(true);
49
- });
50
-
51
- it("returns false when `claude auth status` reports loggedIn: false", async () => {
52
- execFileMock
53
- .mockImplementationOnce((_p, _a, _o, cb) =>
54
- cb(null, { stdout: "1.0.0\n", stderr: "" }),
55
- )
56
- .mockImplementationOnce((_p, _a, _o, cb) =>
57
- cb(null, {
58
- stdout: JSON.stringify({ loggedIn: false }),
59
- stderr: "",
60
- }),
61
- );
62
-
63
- const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
64
- const p = new ClaudeSDKProvider();
65
- const result = await p.isAvailable();
66
- expect(result).toBe(false);
67
- });
68
-
69
- it("falls back to `claude -p` probe when `auth status` fails (older CLI)", async () => {
70
- // Sequence: --version → auth status rejects → -p ping succeeds
71
- execFileMock
72
- .mockImplementationOnce((_p, _a, _o, cb) =>
73
- cb(null, { stdout: "1.0.0\n", stderr: "" }),
74
- )
75
- .mockImplementationOnce((_p, _a, _o, cb) =>
76
- cb(new Error("unknown command: auth status"), { stdout: "", stderr: "" }),
77
- )
78
- .mockImplementationOnce((_p, _a, _o, cb) =>
79
- cb(null, { stdout: "pong", stderr: "" }),
80
- );
81
-
82
- const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
83
- const p = new ClaudeSDKProvider();
84
- const result = await p.isAvailable();
85
- expect(result).toBe(true);
86
- });
87
-
88
- it("falls back to `claude -p` probe and detects 'Not logged in' text", async () => {
89
- execFileMock
90
- .mockImplementationOnce((_p, _a, _o, cb) =>
91
- cb(null, { stdout: "1.0.0\n", stderr: "" }),
92
- )
93
- .mockImplementationOnce((_p, _a, _o, cb) =>
94
- cb(new Error("auth status not supported"), { stdout: "", stderr: "" }),
95
- )
96
- .mockImplementationOnce((_p, _a, _o, cb) =>
97
- cb(null, { stdout: "Not logged in · Please run /login", stderr: "" }),
98
- );
99
-
100
- const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
101
- const p = new ClaudeSDKProvider();
102
- const result = await p.isAvailable();
103
- expect(result).toBe(false);
104
- });
105
- });
106
-
107
- describe("ClaudeSDKProvider — isAuthErrorOutput helper", () => {
108
- it("detects 'Not logged in' text as auth error", async () => {
109
- const { isAuthErrorOutput } = await import("../src/providers/claude-sdk-provider.js");
110
- expect(isAuthErrorOutput("Not logged in · Please run /login")).toBe(true);
111
- expect(isAuthErrorOutput(" not logged in · Please run /login ")).toBe(true);
112
- expect(isAuthErrorOutput("Hello! Here is the result")).toBe(false);
113
- expect(isAuthErrorOutput("")).toBe(false);
114
- });
115
- });
@@ -1,180 +0,0 @@
1
- /**
2
- * v4.12.1 — Contract test for claude-sdk-provider's tool_use chunk shape.
3
- *
4
- * The task-aware stuck timer depends on tool_use chunks carrying:
5
- * - toolUseId (matches the tool_result that arrives later)
6
- * - runInBackground (boolean extracted from block.input.run_in_background)
7
- *
8
- * Both are must-have, not nice-to-have. Pin the contract so an SDK
9
- * upgrade or an accidental regression can't silently break it.
10
- *
11
- * See src/handlers/stuck-timer.ts for the consumer side and
12
- * src/handlers/message.ts for the wiring.
13
- */
14
- import { describe, it, expect, vi, beforeEach } from "vitest";
15
- import type { StreamChunk } from "../src/providers/types.js";
16
-
17
- beforeEach(() => vi.resetModules());
18
-
19
- // Helper: mock the Claude Agent SDK with a scripted async generator so we
20
- // control the tool_use block the provider sees.
21
- function mockSDKWithToolUse(toolUseBlock: Record<string, unknown>): void {
22
- const asyncIterable = {
23
- async *[Symbol.asyncIterator]() {
24
- yield {
25
- type: "system",
26
- subtype: "init",
27
- session_id: "s1",
28
- };
29
- yield {
30
- type: "assistant",
31
- session_id: "s1",
32
- message: {
33
- content: [toolUseBlock],
34
- },
35
- };
36
- yield {
37
- type: "result",
38
- session_id: "s1",
39
- total_cost_usd: 0,
40
- usage: null,
41
- };
42
- },
43
- };
44
-
45
- vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
46
- query: () => asyncIterable,
47
- }));
48
- }
49
-
50
- // Helper: find the claude binary. The provider calls findClaudeBinary() and
51
- // passes the path to the SDK — since the SDK is mocked, the path doesn't
52
- // matter, but findClaudeBinary itself must not throw.
53
- function mockFindClaudeBinary(): void {
54
- vi.doMock("../src/find-claude-binary.js", () => ({
55
- findClaudeBinary: () => "/usr/bin/false",
56
- }));
57
- }
58
-
59
- describe("claude-sdk-provider tool_use chunk contract (v4.12.1)", () => {
60
- it("emits toolUseId AND runInBackground=true when the flag is set", async () => {
61
- mockFindClaudeBinary();
62
- mockSDKWithToolUse({
63
- type: "tool_use",
64
- id: "toolu_ABC123",
65
- name: "Task",
66
- input: {
67
- description: "full site audit",
68
- run_in_background: true,
69
- prompt: "audit example.com",
70
- },
71
- });
72
-
73
- const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
74
- const provider = new ClaudeSDKProvider();
75
-
76
- const chunks: StreamChunk[] = [];
77
- for await (const c of provider.query({
78
- prompt: "do the audit",
79
- systemPrompt: "test",
80
- })) {
81
- chunks.push(c);
82
- }
83
-
84
- const toolUse = chunks.find(c => c.type === "tool_use");
85
- expect(toolUse).toBeDefined();
86
- expect(toolUse!.toolUseId).toBe("toolu_ABC123");
87
- expect(toolUse!.runInBackground).toBe(true);
88
- expect(toolUse!.toolName).toBe("Task");
89
- });
90
-
91
- it("extracts runInBackground=undefined when the flag is omitted", async () => {
92
- mockFindClaudeBinary();
93
- mockSDKWithToolUse({
94
- type: "tool_use",
95
- id: "toolu_XYZ",
96
- name: "Task",
97
- input: {
98
- description: "sync task",
99
- prompt: "do it",
100
- },
101
- });
102
-
103
- const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
104
- const provider = new ClaudeSDKProvider();
105
-
106
- const chunks: StreamChunk[] = [];
107
- for await (const c of provider.query({
108
- prompt: "test",
109
- systemPrompt: "test",
110
- })) {
111
- chunks.push(c);
112
- }
113
-
114
- const toolUse = chunks.find(c => c.type === "tool_use");
115
- expect(toolUse).toBeDefined();
116
- expect(toolUse!.toolUseId).toBe("toolu_XYZ");
117
- expect(toolUse!.runInBackground).toBeUndefined();
118
- });
119
-
120
- it("extracts runInBackground=false when the flag is explicitly false", async () => {
121
- mockFindClaudeBinary();
122
- mockSDKWithToolUse({
123
- type: "tool_use",
124
- id: "toolu_EXPLICIT",
125
- name: "Agent",
126
- input: {
127
- description: "explicit sync",
128
- run_in_background: false,
129
- prompt: "do it synchronously",
130
- },
131
- });
132
-
133
- const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
134
- const provider = new ClaudeSDKProvider();
135
-
136
- const chunks: StreamChunk[] = [];
137
- for await (const c of provider.query({
138
- prompt: "test",
139
- systemPrompt: "test",
140
- })) {
141
- chunks.push(c);
142
- }
143
-
144
- const toolUse = chunks.find(c => c.type === "tool_use");
145
- expect(toolUse!.runInBackground).toBe(false);
146
- });
147
-
148
- it("toolInput is still serialized (for display in status line), but truncated at 500 chars", async () => {
149
- mockFindClaudeBinary();
150
- const longPrompt = "x".repeat(1000);
151
- mockSDKWithToolUse({
152
- type: "tool_use",
153
- id: "toolu_LONG",
154
- name: "Task",
155
- input: {
156
- description: "long prompt task",
157
- run_in_background: true,
158
- prompt: longPrompt,
159
- },
160
- });
161
-
162
- const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
163
- const provider = new ClaudeSDKProvider();
164
-
165
- const chunks: StreamChunk[] = [];
166
- for await (const c of provider.query({
167
- prompt: "test",
168
- systemPrompt: "test",
169
- })) {
170
- chunks.push(c);
171
- }
172
-
173
- const toolUse = chunks.find(c => c.type === "tool_use");
174
- // runInBackground is extracted cleanly EVEN THOUGH toolInput is truncated
175
- expect(toolUse!.runInBackground).toBe(true);
176
- // toolInput is the display-truncated serialization (max ~501 chars)
177
- expect(toolUse!.toolInput).toBeDefined();
178
- expect(toolUse!.toolInput!.length).toBeLessThanOrEqual(501);
179
- });
180
- });
@@ -1,98 +0,0 @@
1
- /**
2
- * Fix #10 — console output must carry ISO timestamps so out.log / err.log
3
- * are actually debuggable. Also: silence libsignal's "Closing session"
4
- * SessionEntry dumps which were pushing tens of KB per day into the logs
5
- * and making forensic work painful.
6
- *
7
- * Contract: `installConsoleFormatter(console)` wraps console.log /
8
- * console.warn / console.error so every line is prefixed with the
9
- * current ISO timestamp (zero-padded, UTC), and certain noise patterns
10
- * (libsignal session dumps) are dropped entirely.
11
- *
12
- * The wrapper is idempotent — calling it twice is a no-op.
13
- */
14
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
15
- import {
16
- installConsoleFormatter,
17
- uninstallConsoleFormatter,
18
- isNoisyLine,
19
- } from "../src/util/console-formatter.js";
20
-
21
- describe("installConsoleFormatter (Fix #10)", () => {
22
- let stdoutWrites: string[];
23
- let stderrWrites: string[];
24
- let origStdout: typeof process.stdout.write;
25
- let origStderr: typeof process.stderr.write;
26
-
27
- beforeEach(() => {
28
- stdoutWrites = [];
29
- stderrWrites = [];
30
- origStdout = process.stdout.write.bind(process.stdout);
31
- origStderr = process.stderr.write.bind(process.stderr);
32
- process.stdout.write = ((chunk: unknown) => {
33
- stdoutWrites.push(String(chunk));
34
- return true;
35
- }) as typeof process.stdout.write;
36
- process.stderr.write = ((chunk: unknown) => {
37
- stderrWrites.push(String(chunk));
38
- return true;
39
- }) as typeof process.stderr.write;
40
- });
41
-
42
- afterEach(() => {
43
- uninstallConsoleFormatter();
44
- process.stdout.write = origStdout;
45
- process.stderr.write = origStderr;
46
- });
47
-
48
- it("prefixes console.log output with an ISO timestamp", () => {
49
- installConsoleFormatter();
50
- console.log("hello world");
51
- const line = stdoutWrites.join("");
52
- // ISO format like 2026-04-11T14:00:00.000Z
53
- expect(line).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s+hello world/);
54
- });
55
-
56
- it("prefixes console.error output with an ISO timestamp", () => {
57
- installConsoleFormatter();
58
- console.error("boom");
59
- const line = stderrWrites.join("");
60
- expect(line).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s+boom/);
61
- });
62
-
63
- it("is idempotent — second install call does not double-prefix", () => {
64
- installConsoleFormatter();
65
- installConsoleFormatter();
66
- console.log("once");
67
- const line = stdoutWrites.join("");
68
- // Exactly one ISO timestamp, not two
69
- const matches = line.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g);
70
- expect(matches).not.toBeNull();
71
- expect(matches!.length).toBe(1);
72
- });
73
- });
74
-
75
- describe("isNoisyLine (Fix #10)", () => {
76
- it("treats libsignal session dumps as noise", () => {
77
- const dump = `Closing session: SessionEntry {
78
- _chains: {
79
- 'BUQxzJlwgVTCxCL5C4rTbZP/7a0ciMPnyo47Pwr4flJt': { chainKey: [Object], chainType: 1, messageKeys: {} }
80
- },
81
- registrationId: 1446528770`;
82
- expect(isNoisyLine(dump)).toBe(true);
83
- });
84
-
85
- it("treats the one-line 'Closing open session' as noise", () => {
86
- expect(isNoisyLine("Closing open session in favor of incoming prekey bundle")).toBe(true);
87
- });
88
-
89
- it("treats the repetitive claude native binary banner as noise", () => {
90
- expect(isNoisyLine("[claude] Native binary: /Users/foo/.local/share/claude/versions/2.1.101")).toBe(true);
91
- });
92
-
93
- it("does NOT silence normal log output", () => {
94
- expect(isNoisyLine("⏰ Cron scheduler started (30s interval)")).toBe(false);
95
- expect(isNoisyLine("[watchdog] started — beacon every 30s")).toBe(false);
96
- expect(isNoisyLine("Cron: Running job \"Daily Job Alert\"")).toBe(false);
97
- });
98
- });
@@ -1,76 +0,0 @@
1
- /**
2
- * Fix #15 (B) — /cron run must give visible feedback during long runs.
3
- *
4
- * Regression from production: a 13-minute Daily Job Alert run showed
5
- * the user ZERO feedback between trigger time and completion. The
6
- * sub-agent was actually working (and eventually succeeded), but the
7
- * Telegram chat was silent for the whole duration.
8
- *
9
- * This test doesn't exercise grammy directly — it tests the pure
10
- * helper that drives the live progress message so we can verify the
11
- * formatting, cadence math, and safety edges in isolation.
12
- */
13
- import { describe, it, expect } from "vitest";
14
- import { formatElapsed, buildTickerText, buildDoneText } from "../src/handlers/cron-progress.js";
15
-
16
- describe("formatElapsed (Fix #15B)", () => {
17
- it("formats seconds under a minute", () => {
18
- expect(formatElapsed(0)).toBe("0s");
19
- expect(formatElapsed(45)).toBe("45s");
20
- expect(formatElapsed(59)).toBe("59s");
21
- });
22
-
23
- it("formats minutes+seconds above a minute", () => {
24
- expect(formatElapsed(60)).toBe("1m 0s");
25
- expect(formatElapsed(61)).toBe("1m 1s");
26
- expect(formatElapsed(125)).toBe("2m 5s");
27
- expect(formatElapsed(797)).toBe("13m 17s"); // real prod duration
28
- });
29
-
30
- it("formats hours+minutes above 60m", () => {
31
- expect(formatElapsed(3600)).toBe("1h 0m");
32
- expect(formatElapsed(3660)).toBe("1h 1m");
33
- });
34
- });
35
-
36
- describe("buildTickerText (Fix #15B)", () => {
37
- it("shows job name and elapsed time in the running state", () => {
38
- const text = buildTickerText("Daily Job Alert", 125);
39
- expect(text).toContain("Daily Job Alert");
40
- expect(text).toContain("2m 5s");
41
- expect(text).toMatch(/🔄|running/i);
42
- });
43
-
44
- it("escapes markdown-breaking characters in the job name", () => {
45
- // Underscores and asterisks in job names would otherwise break
46
- // the Markdown edit and trigger "can't parse entities".
47
- const text = buildTickerText("weird_job*name", 10);
48
- expect(text).not.toContain("_job*"); // no raw unescaped asterisk
49
- // We expect some form of escaping — back-slashes are fine
50
- expect(text).toMatch(/weird/);
51
- });
52
- });
53
-
54
- describe("buildDoneText (Fix #15B)", () => {
55
- it("shows green check for a clean completion", () => {
56
- const text = buildDoneText("Daily Job Alert", 797, { ok: true });
57
- expect(text).toContain("✅");
58
- expect(text).toContain("Daily Job Alert");
59
- expect(text).toContain("13m 17s");
60
- });
61
-
62
- it("shows red cross and error excerpt for a failure", () => {
63
- const text = buildDoneText("Daily Job Alert", 10, {
64
- ok: false,
65
- error: "Sub-agent cancelled: timeout",
66
- });
67
- expect(text).toContain("❌");
68
- expect(text).toContain("timeout");
69
- });
70
-
71
- it("shows warning for an already-running skip", () => {
72
- const text = buildDoneText("Daily Job Alert", 0, { ok: true, skipped: true });
73
- expect(text).toContain("⏳");
74
- expect(text).toMatch(/already running|in progress/i);
75
- });
76
- });
@@ -1,191 +0,0 @@
1
- /**
2
- * Fix #3 — Cron scheduler must survive a bot restart during job execution.
3
- *
4
- * Background: the old scheduler set `nextRunAt = null` immediately before
5
- * `await executeJob(job)` and only re-calculated it after completion. A
6
- * crash mid-execution (EADDRINUSE, unhandled rejection, launchd restart)
7
- * left `nextRunAt = null`, so the next boot called `calculateNextRun()`
8
- * from the current time — which for a cron expression always yields a
9
- * FUTURE trigger (e.g. tomorrow 08:00). Today's run was lost forever.
10
- *
11
- * New contract (pure-function pair):
12
- *
13
- * prepareForExecution(job, now)
14
- * - updates lastAttemptAt = now
15
- * - updates nextRunAt = <next regular trigger from `now`>
16
- * - returns the mutated job
17
- *
18
- * handleStartupCatchup(jobs, now, graceMs)
19
- * - for every enabled job where `lastAttemptAt > lastRunAt`
20
- * (i.e. the last attempt never completed) AND the attempt is
21
- * within `graceMs`, rewinds `nextRunAt` to `now` so the next
22
- * scheduler tick picks it up immediately
23
- * - for every enabled job where `lastAttemptAt > lastRunAt` but
24
- * the attempt is older than `graceMs`, gives up and recalculates
25
- * `nextRunAt` normally
26
- * - never touches disabled jobs
27
- * - returns a NEW array of jobs (pure, no mutation of input)
28
- */
29
- import { describe, it, expect } from "vitest";
30
- import {
31
- prepareForExecution,
32
- handleStartupCatchup,
33
- } from "../src/services/cron-scheduling.js";
34
- import type { CronJob } from "../src/services/cron.js";
35
-
36
- function makeJob(overrides: Partial<CronJob> = {}): CronJob {
37
- return {
38
- id: "job-1",
39
- name: "Daily Job Alert",
40
- type: "ai-query",
41
- schedule: "00 08 * * *",
42
- oneShot: false,
43
- payload: { prompt: "x" },
44
- target: { platform: "telegram", chatId: "1" },
45
- enabled: true,
46
- createdAt: 1_700_000_000_000,
47
- lastRunAt: null,
48
- lastResult: null,
49
- lastError: null,
50
- nextRunAt: null,
51
- runCount: 0,
52
- createdBy: "test",
53
- ...overrides,
54
- };
55
- }
56
-
57
- describe("prepareForExecution (Fix #3)", () => {
58
- it("sets lastAttemptAt to now", () => {
59
- const job = makeJob();
60
- const now = 1_775_887_200_000; // 2026-04-11 08:00 Berlin
61
- const updated = prepareForExecution(job, now);
62
- expect(updated.lastAttemptAt).toBe(now);
63
- });
64
-
65
- it("advances nextRunAt to the NEXT regular trigger, not null", () => {
66
- const job = makeJob({ schedule: "00 08 * * *" });
67
- const now = 1_775_887_200_000; // today 08:00
68
- const updated = prepareForExecution(job, now);
69
- // nextRunAt must be a future timestamp, not null, not zero
70
- expect(updated.nextRunAt).not.toBeNull();
71
- expect(updated.nextRunAt!).toBeGreaterThan(now);
72
- });
73
-
74
- it("works with interval schedules — base = now, not lastRunAt", () => {
75
- const job = makeJob({ schedule: "5m", lastRunAt: 1_000_000_000_000 });
76
- const now = 1_775_887_200_000;
77
- const updated = prepareForExecution(job, now);
78
- expect(updated.nextRunAt).toBe(now + 5 * 60_000);
79
- });
80
-
81
- it("does not touch lastRunAt", () => {
82
- const job = makeJob({ lastRunAt: 123 });
83
- const updated = prepareForExecution(job, 9999);
84
- expect(updated.lastRunAt).toBe(123);
85
- });
86
-
87
- it("is pure — returns a new object, leaves the input alone", () => {
88
- const job = makeJob();
89
- const before = JSON.stringify(job);
90
- prepareForExecution(job, 42);
91
- expect(JSON.stringify(job)).toBe(before);
92
- });
93
- });
94
-
95
- describe("handleStartupCatchup (Fix #3)", () => {
96
- const GRACE = 6 * 60 * 60 * 1000; // 6 h
97
-
98
- it("rewinds nextRunAt to now when a recent attempt never completed", () => {
99
- // Scenario: 08:00 triggered, 08:05 bot crashed, 10:30 bot restarts.
100
- const job = makeJob({
101
- lastRunAt: null, // never completed
102
- lastAttemptAt: 1_775_887_200_000, // 08:00
103
- nextRunAt: 1_775_973_600_000, // tomorrow 08:00 (set pre-execution)
104
- });
105
- const now = 1_775_896_200_000; // 10:30
106
- const [out] = handleStartupCatchup([job], now, GRACE);
107
- expect(out.nextRunAt).toBe(now); // rewind → picked up on next tick
108
- });
109
-
110
- it("does not rewind when attempt completed (lastRunAt >= lastAttemptAt)", () => {
111
- const tomorrow8am = 1_775_973_600_000;
112
- const job = makeJob({
113
- lastRunAt: 1_775_896_200_000, // completed at 10:30
114
- lastAttemptAt: 1_775_887_200_000, // started at 08:00
115
- nextRunAt: tomorrow8am,
116
- });
117
- const now = 1_775_900_000_000;
118
- const [out] = handleStartupCatchup([job], now, GRACE);
119
- expect(out.nextRunAt).toBe(tomorrow8am); // unchanged
120
- });
121
-
122
- it("gives up when the attempt is older than the grace window", () => {
123
- // Scenario: attempt was 7h ago, never completed, bot only now back up.
124
- const sevenHoursAgo = 1_775_887_200_000 - 60_000;
125
- const now = sevenHoursAgo + 7 * 60 * 60 * 1000 + 60_000;
126
- const job = makeJob({
127
- lastRunAt: null,
128
- lastAttemptAt: sevenHoursAgo,
129
- nextRunAt: now + 86_400_000, // whatever — scheduler will replace
130
- });
131
- const [out] = handleStartupCatchup([job], now, GRACE);
132
- // Must NOT rewind to `now`. Must either keep the future value or
133
- // recompute — either way it has to stay strictly greater than now.
134
- expect(out.nextRunAt).not.toBe(now);
135
- expect(out.nextRunAt!).toBeGreaterThan(now);
136
- });
137
-
138
- it("ignores disabled jobs", () => {
139
- const job = makeJob({
140
- enabled: false,
141
- lastRunAt: null,
142
- lastAttemptAt: 1_775_887_200_000,
143
- nextRunAt: 1_775_973_600_000,
144
- });
145
- const now = 1_775_896_200_000;
146
- const [out] = handleStartupCatchup([job], now, GRACE);
147
- expect(out).toEqual(job); // untouched
148
- });
149
-
150
- it("handles jobs without any attempt history (no-op)", () => {
151
- const job = makeJob({
152
- lastRunAt: null,
153
- lastAttemptAt: null,
154
- nextRunAt: 1_775_973_600_000,
155
- });
156
- const now = 1_775_896_200_000;
157
- const [out] = handleStartupCatchup([job], now, GRACE);
158
- expect(out).toEqual(job);
159
- });
160
-
161
- it("is pure — does not mutate the input array", () => {
162
- const job = makeJob({
163
- lastRunAt: null,
164
- lastAttemptAt: 1_775_887_200_000,
165
- nextRunAt: 1_775_973_600_000,
166
- });
167
- const input = [job];
168
- const snapshot = JSON.stringify(input);
169
- handleStartupCatchup(input, 1_775_896_200_000, GRACE);
170
- expect(JSON.stringify(input)).toBe(snapshot);
171
- });
172
-
173
- it("processes multiple jobs independently", () => {
174
- const now = 1_775_896_200_000;
175
- const recent = makeJob({
176
- id: "a",
177
- lastRunAt: null,
178
- lastAttemptAt: 1_775_887_200_000, // within grace
179
- nextRunAt: 1_775_973_600_000,
180
- });
181
- const completed = makeJob({
182
- id: "b",
183
- lastRunAt: 1_775_887_500_000,
184
- lastAttemptAt: 1_775_887_200_000,
185
- nextRunAt: 1_775_973_600_000,
186
- });
187
- const out = handleStartupCatchup([recent, completed], now, GRACE);
188
- expect(out[0].nextRunAt).toBe(now); // caught up
189
- expect(out[1].nextRunAt).toBe(1_775_973_600_000); // untouched
190
- });
191
- });