alvin-bot 4.9.0 β 4.9.1
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/CHANGELOG.md +17 -0
- package/dist/handlers/commands.js +18 -7
- package/dist/services/cron-resolver.js +58 -0
- package/dist/services/cron.js +38 -4
- package/package.json +1 -1
- package/test/cron-run-resolver.test.ts +133 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.9.1] β 2026-04-11
|
|
6
|
+
|
|
7
|
+
### π `/cron run <name>` accepts the job name, not just the opaque ID
|
|
8
|
+
|
|
9
|
+
Reported via screenshot: `/cron run Daily Job Alert` replied with `β Job not found.` because `runJobNow(id)` only matched against `job.id` β the random base-36 string (`mn90rrsndzto`) that nobody types. Worse, when Claude tried to trigger the same job through a natural-language request in an earlier session, it retried with different variants until one happened to succeed β and the absence of a re-entry guard in `runJobNow` meant the retries sometimes spawned a second parallel sub-agent, producing the "upsβ¦ wurde doppelt ausgefΓΌhrt" message.
|
|
10
|
+
|
|
11
|
+
**Fix β pure resolver + guard, wired into the public API:**
|
|
12
|
+
|
|
13
|
+
- **`src/services/cron-resolver.ts` (new).** Two pure helpers:
|
|
14
|
+
- `resolveJobByNameOrId(jobs, query)` β priority: exact ID > exact name > unique case-insensitive name > `null` on miss/ambiguous.
|
|
15
|
+
- `runJobNowGuard(id, isRunning, run)` β higher-order re-entry check, testable without the scheduler loop.
|
|
16
|
+
- **`src/services/cron.ts` runJobNow**. Now returns a typed outcome (`not-found` | `already-running` | `ran`), consults the `runningJobs` set (previously only the scheduler loop did), and β when it actually runs β persists `lastAttemptAt` / `lastRunAt` / `runCount` / `lastResult` / `lastError` exactly like the scheduler path, so manual triggers show up in the timeline instead of vanishing.
|
|
17
|
+
- **`src/handlers/commands.ts /cron run`**. Matches against name OR ID, prints a helpful "Available:" list on miss, and announces the already-running case instead of silently double-firing.
|
|
18
|
+
- **10 new tests** (`test/cron-run-resolver.test.ts`) covering exact ID, exact name, case-insensitive, trimmed input, miss, ambiguity, ID-over-name preference, and both guard branches. **164 tests total.**
|
|
19
|
+
|
|
20
|
+
**What this also quietly fixes:** natural-language triggers ("Alvin, run the daily job alert"). When Claude invokes `/cron run Daily Job Alert` via its own turn, the command now succeeds on the first try β no retry cascade, no double execution.
|
|
21
|
+
|
|
5
22
|
## [4.9.0] β 2026-04-11
|
|
6
23
|
|
|
7
24
|
### π‘ Stability batch: crash-loop eliminated, cron jobs restart-resistant, cleaner logs
|
|
@@ -1441,17 +1441,28 @@ export function registerCommands(bot) {
|
|
|
1441
1441
|
}
|
|
1442
1442
|
return;
|
|
1443
1443
|
}
|
|
1444
|
-
// /cron run <id>
|
|
1444
|
+
// /cron run <name-or-id>
|
|
1445
1445
|
if (arg.startsWith("run ")) {
|
|
1446
|
-
const
|
|
1446
|
+
const nameOrId = arg.slice(4).trim();
|
|
1447
1447
|
await ctx.api.sendChatAction(ctx.chat.id, "typing");
|
|
1448
|
-
const
|
|
1449
|
-
if (
|
|
1450
|
-
|
|
1448
|
+
const outcome = await runJobNow(nameOrId);
|
|
1449
|
+
if (outcome.status === "not-found") {
|
|
1450
|
+
const jobs = listJobs();
|
|
1451
|
+
const hint = jobs.length > 0
|
|
1452
|
+
? `\n\nAvailable:\n${jobs.slice(0, 10).map(j => `β’ ${j.name}`).join("\n")}`
|
|
1453
|
+
: "";
|
|
1454
|
+
await ctx.reply(`β No job matches <code>${nameOrId}</code>.${hint}`, { parse_mode: "HTML" });
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (outcome.status === "already-running") {
|
|
1458
|
+
await ctx.reply(`β³ Job "${outcome.job.name}" is already running β not starting a duplicate. ` +
|
|
1459
|
+
`Wait for the current run to finish, or /subagents cancel to abort it.`);
|
|
1451
1460
|
return;
|
|
1452
1461
|
}
|
|
1453
|
-
const output =
|
|
1454
|
-
|
|
1462
|
+
const output = outcome.output
|
|
1463
|
+
? `\`\`\`\n${outcome.output.slice(0, 2000)}\n\`\`\``
|
|
1464
|
+
: "(no output)";
|
|
1465
|
+
await ctx.reply(`π§ Job "${outcome.job.name}" executed:\n${output}${outcome.error ? `\n\nβ ${outcome.error}` : ""}`, { parse_mode: "Markdown" });
|
|
1455
1466
|
return;
|
|
1456
1467
|
}
|
|
1457
1468
|
await ctx.reply("Unknown cron command. Use /cron for help.");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure cron-job name/ID resolver and re-entry guard.
|
|
3
|
+
*
|
|
4
|
+
* See test/cron-run-resolver.test.ts for the regressions this closes:
|
|
5
|
+
* - `/cron run Daily Job Alert` returned "Job not found" because the
|
|
6
|
+
* old runJobNow only matched on `job.id`. Real IDs are random
|
|
7
|
+
* base-36 strings, nobody types those.
|
|
8
|
+
* - Natural-language triggers double-ran jobs because runJobNow
|
|
9
|
+
* didn't consult the `runningJobs` set.
|
|
10
|
+
*
|
|
11
|
+
* Both helpers are pure (or pure-over-callbacks) so they can be unit-
|
|
12
|
+
* tested without touching the filesystem or the scheduler loop.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a user-facing query (name, case-insensitive name, or ID) to
|
|
16
|
+
* a specific job. Priority:
|
|
17
|
+
* 1. Exact ID match
|
|
18
|
+
* 2. Exact name match (case-sensitive)
|
|
19
|
+
* 3. Unique case-insensitive name match
|
|
20
|
+
* 4. null (miss or ambiguous)
|
|
21
|
+
*
|
|
22
|
+
* Trimmed whitespace on the query. Never mutates the input array.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveJobByNameOrId(jobs, query) {
|
|
25
|
+
const q = query.trim();
|
|
26
|
+
if (!q)
|
|
27
|
+
return null;
|
|
28
|
+
// 1. Exact ID match
|
|
29
|
+
const byId = jobs.find((j) => j.id === q);
|
|
30
|
+
if (byId)
|
|
31
|
+
return byId;
|
|
32
|
+
// 2. Exact name match
|
|
33
|
+
const byExactName = jobs.find((j) => j.name === q);
|
|
34
|
+
if (byExactName)
|
|
35
|
+
return byExactName;
|
|
36
|
+
// 3. Unique case-insensitive name match
|
|
37
|
+
const qLower = q.toLowerCase();
|
|
38
|
+
const ciMatches = jobs.filter((j) => j.name.toLowerCase() === qLower);
|
|
39
|
+
if (ciMatches.length === 1)
|
|
40
|
+
return ciMatches[0];
|
|
41
|
+
// 4. Ambiguous or not found
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Re-entry guard for runJobNow: only calls `run` when `isRunning`
|
|
46
|
+
* reports the job is idle. Otherwise reports back "already-running"
|
|
47
|
+
* so the caller can tell the user instead of silently double-firing.
|
|
48
|
+
*
|
|
49
|
+
* Kept as a higher-order function so the test doesn't need to stand
|
|
50
|
+
* up the whole cron loop β we mock the two callbacks.
|
|
51
|
+
*/
|
|
52
|
+
export async function runJobNowGuard(id, isRunning, run) {
|
|
53
|
+
if (isRunning(id)) {
|
|
54
|
+
return { status: "already-running" };
|
|
55
|
+
}
|
|
56
|
+
const result = await run(id);
|
|
57
|
+
return { status: "ran", output: result.output, error: result.error };
|
|
58
|
+
}
|
package/dist/services/cron.js
CHANGED
|
@@ -14,6 +14,7 @@ import { execSync } from "child_process";
|
|
|
14
14
|
import { dirname } from "path";
|
|
15
15
|
import { CRON_FILE, BOT_ROOT } from "../paths.js";
|
|
16
16
|
import { prepareForExecution, handleStartupCatchup, calculateNextRunFrom, } from "./cron-scheduling.js";
|
|
17
|
+
import { resolveJobByNameOrId } from "./cron-resolver.js";
|
|
17
18
|
// ββ Storage βββββββββββββββββββββββββββββββββββββββββββββ
|
|
18
19
|
function loadJobs() {
|
|
19
20
|
try {
|
|
@@ -391,11 +392,44 @@ export function toggleJob(id) {
|
|
|
391
392
|
saveJobs(jobs);
|
|
392
393
|
return job;
|
|
393
394
|
}
|
|
394
|
-
|
|
395
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Manual /cron run β resolves `nameOrId` against the job list, then
|
|
397
|
+
* executes the job while honouring the in-memory `runningJobs` guard
|
|
398
|
+
* so a simultaneous scheduler-trigger can't overlap.
|
|
399
|
+
*/
|
|
400
|
+
export async function runJobNow(nameOrId) {
|
|
401
|
+
const job = resolveJobByNameOrId(loadJobs(), nameOrId);
|
|
396
402
|
if (!job)
|
|
397
|
-
return
|
|
398
|
-
|
|
403
|
+
return { status: "not-found" };
|
|
404
|
+
if (runningJobs.has(job.id)) {
|
|
405
|
+
return { status: "already-running", job };
|
|
406
|
+
}
|
|
407
|
+
runningJobs.add(job.id);
|
|
408
|
+
try {
|
|
409
|
+
const result = await executeJob(job);
|
|
410
|
+
// Persist the manual run the same way the scheduler does so the
|
|
411
|
+
// timeline stays honest: lastAttemptAt + lastRunAt + runCount bump.
|
|
412
|
+
try {
|
|
413
|
+
const freshJobs = loadJobs();
|
|
414
|
+
const freshJob = freshJobs.find((j) => j.id === job.id);
|
|
415
|
+
if (freshJob) {
|
|
416
|
+
const now = Date.now();
|
|
417
|
+
freshJob.lastAttemptAt = now;
|
|
418
|
+
freshJob.lastRunAt = now;
|
|
419
|
+
freshJob.lastResult = result.output.slice(0, 4000);
|
|
420
|
+
freshJob.lastError = result.error || null;
|
|
421
|
+
freshJob.runCount++;
|
|
422
|
+
saveJobs(freshJobs);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
console.error("[cron] failed to persist manual run state:", err);
|
|
427
|
+
}
|
|
428
|
+
return { status: "ran", job, output: result.output, error: result.error };
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
runningJobs.delete(job.id);
|
|
432
|
+
}
|
|
399
433
|
}
|
|
400
434
|
/**
|
|
401
435
|
* Convert a cron expression or interval string to a human-readable German description.
|
package/package.json
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #13 β `/cron run` must resolve a job by name OR ID, and must
|
|
3
|
+
* reject a second concurrent run of the same job.
|
|
4
|
+
*
|
|
5
|
+
* Regressions this closes:
|
|
6
|
+
* (a) `/cron run Daily Job Alert` returned "β Job not found."
|
|
7
|
+
* because runJobNow() only matched against `job.id`. Real job
|
|
8
|
+
* IDs look like `mn90rrsndzto` β nobody types those.
|
|
9
|
+
* (b) Natural-language triggers through Claude ended up running the
|
|
10
|
+
* job twice because the main message handler retried after the
|
|
11
|
+
* first "Job not found" / parallel path succeeded. runJobNow()
|
|
12
|
+
* didn't consult `runningJobs`, so two concurrent calls both
|
|
13
|
+
* spawned sub-agents.
|
|
14
|
+
*
|
|
15
|
+
* Contract β pure resolver (tested here), side-effectful runner
|
|
16
|
+
* (integration-tested via runJobNowGuard below):
|
|
17
|
+
*
|
|
18
|
+
* resolveJobByNameOrId(jobs, query)
|
|
19
|
+
* - exact `job.id` match wins
|
|
20
|
+
* - exact `job.name` match wins next
|
|
21
|
+
* - case-insensitive `job.name` match third
|
|
22
|
+
* - returns `null` on ambiguous case-insensitive match or miss
|
|
23
|
+
*
|
|
24
|
+
* runJobNowGuard(id, isRunning, run)
|
|
25
|
+
* - calls `run(id)` only when `isRunning(id)` is false
|
|
26
|
+
* - returns `{ status: "already-running" }` otherwise
|
|
27
|
+
*/
|
|
28
|
+
import { describe, it, expect, vi } from "vitest";
|
|
29
|
+
import {
|
|
30
|
+
resolveJobByNameOrId,
|
|
31
|
+
runJobNowGuard,
|
|
32
|
+
} from "../src/services/cron-resolver.js";
|
|
33
|
+
import type { CronJob } from "../src/services/cron.js";
|
|
34
|
+
|
|
35
|
+
function makeJob(overrides: Partial<CronJob>): CronJob {
|
|
36
|
+
return {
|
|
37
|
+
id: "abc123",
|
|
38
|
+
name: "Test Job",
|
|
39
|
+
type: "ai-query",
|
|
40
|
+
schedule: "0 8 * * *",
|
|
41
|
+
oneShot: false,
|
|
42
|
+
payload: { prompt: "x" },
|
|
43
|
+
target: { platform: "telegram", chatId: "1" },
|
|
44
|
+
enabled: true,
|
|
45
|
+
createdAt: 0,
|
|
46
|
+
lastRunAt: null,
|
|
47
|
+
lastResult: null,
|
|
48
|
+
lastError: null,
|
|
49
|
+
nextRunAt: null,
|
|
50
|
+
runCount: 0,
|
|
51
|
+
createdBy: "test",
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("resolveJobByNameOrId (Fix #13)", () => {
|
|
57
|
+
const jobs = [
|
|
58
|
+
makeJob({ id: "mn90rrsndzto", name: "Daily Job Alert" }),
|
|
59
|
+
makeJob({ id: "abc123", name: "Weekly Stock Report" }),
|
|
60
|
+
makeJob({ id: "def456", name: "Perseus Health Check" }),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
it("matches by exact ID", () => {
|
|
64
|
+
const j = resolveJobByNameOrId(jobs, "mn90rrsndzto");
|
|
65
|
+
expect(j?.name).toBe("Daily Job Alert");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("matches by exact name", () => {
|
|
69
|
+
const j = resolveJobByNameOrId(jobs, "Daily Job Alert");
|
|
70
|
+
expect(j?.id).toBe("mn90rrsndzto");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("matches case-insensitive on name", () => {
|
|
74
|
+
const j = resolveJobByNameOrId(jobs, "daily job alert");
|
|
75
|
+
expect(j?.id).toBe("mn90rrsndzto");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("matches trimmed input", () => {
|
|
79
|
+
const j = resolveJobByNameOrId(jobs, " Daily Job Alert ");
|
|
80
|
+
expect(j?.id).toBe("mn90rrsndzto");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns null on miss", () => {
|
|
84
|
+
expect(resolveJobByNameOrId(jobs, "Nothing Like That")).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns null on ambiguous case-insensitive match", () => {
|
|
88
|
+
const dupes = [
|
|
89
|
+
makeJob({ id: "a", name: "test job" }),
|
|
90
|
+
makeJob({ id: "b", name: "Test Job" }),
|
|
91
|
+
makeJob({ id: "c", name: "TEST JOB" }),
|
|
92
|
+
];
|
|
93
|
+
// Exact-case match wins over ambiguous siblings
|
|
94
|
+
expect(resolveJobByNameOrId(dupes, "Test Job")?.id).toBe("b");
|
|
95
|
+
// Ambiguous query (no exact-case match) returns null
|
|
96
|
+
expect(resolveJobByNameOrId(dupes, "TeSt JoB")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("prefers ID match over an accidental name collision", () => {
|
|
100
|
+
const collision = [
|
|
101
|
+
makeJob({ id: "Daily Job Alert", name: "Something Else" }),
|
|
102
|
+
makeJob({ id: "mn90rrsndzto", name: "Daily Job Alert" }),
|
|
103
|
+
];
|
|
104
|
+
const j = resolveJobByNameOrId(collision, "Daily Job Alert");
|
|
105
|
+
expect(j?.id).toBe("Daily Job Alert"); // ID match wins
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("runJobNowGuard (Fix #13)", () => {
|
|
110
|
+
it("runs when the job is not already running", async () => {
|
|
111
|
+
const run = vi.fn(async () => ({ output: "ok" }));
|
|
112
|
+
const result = await runJobNowGuard("job-1", () => false, run);
|
|
113
|
+
expect(run).toHaveBeenCalledWith("job-1");
|
|
114
|
+
expect(result.status).toBe("ran");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects when the job is already running", async () => {
|
|
118
|
+
const run = vi.fn(async () => ({ output: "ok" }));
|
|
119
|
+
const result = await runJobNowGuard("job-1", () => true, run);
|
|
120
|
+
expect(run).not.toHaveBeenCalled();
|
|
121
|
+
expect(result.status).toBe("already-running");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("passes through the inner result on the ran path", async () => {
|
|
125
|
+
const run = vi.fn(async () => ({ output: "done", error: undefined }));
|
|
126
|
+
const result = await runJobNowGuard("job-1", () => false, run);
|
|
127
|
+
if (result.status === "ran") {
|
|
128
|
+
expect(result.output).toBe("done");
|
|
129
|
+
} else {
|
|
130
|
+
throw new Error("expected ran");
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|