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.
- package/AEC-PLUGINS-SOURCES.md +53 -0
- package/CHANGELOG.md +37 -2
- package/DESIGN-SKILLS-SOURCES.md +81 -0
- package/bin/cli.js +1 -1
- package/dist/providers/claude-sdk-provider.js +24 -0
- package/package.json +3 -1
- package/test/allowed-users-gate.test.ts +0 -98
- package/test/alvin-dispatch.test.ts +0 -220
- package/test/async-agent-chunk-flow.test.ts +0 -244
- package/test/async-agent-parser-staleness.test.ts +0 -412
- package/test/async-agent-parser-streamjson.test.ts +0 -273
- package/test/async-agent-parser.test.ts +0 -322
- package/test/async-agent-watcher.test.ts +0 -229
- package/test/background-bypass-integration.test.ts +0 -443
- package/test/background-bypass-stress.test.ts +0 -417
- package/test/background-bypass.test.ts +0 -127
- package/test/browser-webfetch.test.ts +0 -121
- package/test/claude-sdk-provider.test.ts +0 -115
- package/test/claude-sdk-tool-use-id.test.ts +0 -180
- package/test/console-timestamps.test.ts +0 -98
- package/test/cron-progress-ticker.test.ts +0 -76
- package/test/cron-restart-resilience.test.ts +0 -191
- package/test/cron-run-resolver.test.ts +0 -133
- package/test/cron-runjobnow-throw.test.ts +0 -100
- package/test/debounce.test.ts +0 -60
- package/test/delivery-registry.test.ts +0 -71
- package/test/exec-guard-metachars.test.ts +0 -110
- package/test/file-permissions.test.ts +0 -130
- package/test/i18n.test.ts +0 -108
- package/test/list-subagents-merged.test.ts +0 -172
- package/test/memory-extractor.test.ts +0 -151
- package/test/memory-layers.test.ts +0 -169
- package/test/memory-sdk-injection.test.ts +0 -146
- package/test/memory-stress-restart.test.ts +0 -337
- package/test/multi-session-stress.test.ts +0 -255
- package/test/platform-session-key.test.ts +0 -69
- package/test/process-manager.test.ts +0 -186
- package/test/registry.test.ts +0 -201
- package/test/session-pending-background.test.ts +0 -59
- package/test/session-persistence.test.ts +0 -195
- package/test/slack-progress-ticker.test.ts +0 -123
- package/test/slack-slash-command.test.ts +0 -61
- package/test/slack-test-connection.test.ts +0 -176
- package/test/stress-scenarios.test.ts +0 -356
- package/test/stuck-timer.test.ts +0 -116
- package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
- package/test/subagent-delivery-platform-routing.test.ts +0 -232
- package/test/subagent-delivery.test.ts +0 -273
- package/test/subagent-final-text.test.ts +0 -132
- package/test/subagent-stats.test.ts +0 -119
- package/test/subagent-toolset-allowlist.test.ts +0 -146
- package/test/subagents-commands.test.ts +0 -64
- package/test/subagents-config.test.ts +0 -114
- package/test/subagents-depth.test.ts +0 -58
- package/test/subagents-inheritance.test.ts +0 -67
- package/test/subagents-name-resolver.test.ts +0 -122
- package/test/subagents-priority-reject.test.ts +0 -88
- package/test/subagents-queue.test.ts +0 -127
- package/test/subagents-shutdown.test.ts +0 -126
- package/test/subagents-toolset.test.ts +0 -71
- package/test/sync-task-timeout.test.ts +0 -153
- package/test/system-prompt-background-hint.test.ts +0 -65
- package/test/telegram-error-filter.test.ts +0 -85
- package/test/telegram-workspace-command.test.ts +0 -78
- package/test/timing-safe-bearer.test.ts +0 -65
- package/test/watchdog-brake.test.ts +0 -157
- package/test/watcher-pending-count.test.ts +0 -228
- package/test/watcher-zombie-fix.test.ts +0 -252
- package/test/web-server-integration.test.ts +0 -189
- package/test/web-server-resilience.test.ts +0 -118
- package/test/web-server-shutdown.test.ts +0 -117
- package/test/whatsapp-auth-resilience.test.ts +0 -96
- package/test/workspaces.test.ts +0 -196
- 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
|
-
});
|