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 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 id = arg.slice(4).trim();
1446
+ const nameOrId = arg.slice(4).trim();
1447
1447
  await ctx.api.sendChatAction(ctx.chat.id, "typing");
1448
- const result = await (runJobNow(id) || Promise.resolve(null));
1449
- if (!result) {
1450
- await ctx.reply(`❌ Job not found.`);
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 = result.output ? `\`\`\`\n${result.output.slice(0, 2000)}\n\`\`\`` : "(no output)";
1454
- await ctx.reply(`πŸ”§ Job executed:\n${output}${result.error ? `\n\n❌ ${result.error}` : ""}`, { parse_mode: "Markdown" });
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
+ }
@@ -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
- export function runJobNow(id) {
395
- const job = getJob(id);
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 null;
398
- return executeJob(job);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.9.0",
3
+ "version": "4.9.1",
4
4
  "description": "Alvin Bot β€” Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
+ });