alvin-bot 4.9.1 β 4.9.3
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 +59 -0
- package/dist/handlers/commands.js +93 -11
- package/dist/handlers/cron-progress.js +52 -0
- package/dist/index.js +6 -2
- package/dist/services/cron.js +15 -1
- package/dist/services/subagent-delivery.js +34 -3
- package/dist/services/watchdog.js +6 -2
- package/package.json +1 -1
- package/test/cron-progress-ticker.test.ts +76 -0
- package/test/cron-runjobnow-throw.test.ts +100 -0
- package/test/stress-scenarios.test.ts +356 -0
- package/test/subagent-delivery-markdown-fallback.test.ts +147 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.9.3] β 2026-04-11
|
|
6
|
+
|
|
7
|
+
### π Two UX bugs found in production after v4.9.2 β now closed
|
|
8
|
+
|
|
9
|
+
Ali triggered `/cron run Daily Job Alert` after the v4.9.2 deploy and saw 13 minutes of chat silence followed by nothing. Forensics on the live bot revealed two distinct problems on top of an already-successful run:
|
|
10
|
+
|
|
11
|
+
**1. `subagent-delivery` has been silently dropping every banner for days.** Err.log: `GrammyError: Call to 'sendMessage' failed! (400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 2636)`. The daily-job-alert sub-agent produces markdown-dense output (`|` tables, `**bold**`, `\|` escapes, mixed asterisks). Telegram's Markdown parser refuses it, `api.sendMessage(..., parse_mode: "Markdown")` throws, and the bare try/catch in `deliverSubAgentResult` logs + bails. **Result: the user has never seen a sub-agent-delivery banner, even when the underlying run succeeded perfectly and emailed the HTML report correctly.**
|
|
12
|
+
|
|
13
|
+
Fix in `src/services/subagent-delivery.ts`: new `sendWithMarkdownFallback()` helper that detects the "can't parse entities" pattern and retries the SAME text without `parse_mode`. All three code paths (file-upload case, single-message case, chunked case) now flow through the helper. 3 new tests drive the happy path, non-parse errors, and the chunked path.
|
|
14
|
+
|
|
15
|
+
**2. `/cron run` had zero proof-of-life for 13 minutes.** The handler used to `await runJobNow(...)` synchronously and reply only when finished. Telegram's typing indicator expires after 5s. Users saw: command sent β typing indicator blip β nothing β nothing β (much later, if at all) result. For cron jobs that take 10-15 min (daily job alert, Perseus health, Polyseus P&L), this is indistinguishable from a dead bot.
|
|
16
|
+
|
|
17
|
+
Fix β new handler flow:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
bot: π Started *Daily Job Alert* β workingβ¦ β instant ack
|
|
21
|
+
bot: π Running *Daily Job Alert* Β· 1m 0s elapsedβ¦ β edit every 60s
|
|
22
|
+
bot: π Running *Daily Job Alert* Β· 2m 0s elapsedβ¦ β edit
|
|
23
|
+
...
|
|
24
|
+
bot: β
Done β *Daily Job Alert* Β· 13m 17s β final edit
|
|
25
|
+
bot: β
*Daily Job Alert* completed Β· 13m Β· 2.6M/28k β subagent-delivery
|
|
26
|
+
[full report body, Markdown-safe with plain-text fallback]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The ticker uses a single `editMessageText` call per minute on the same message β zero notification spam, clean visual progress. Every edit is wrapped with `isHarmlessTelegramError` so the inevitable "message is not modified" races stay silent. The ack itself falls back to plain text if the first `reply` hits a parse error, and the final edit falls back to a fresh plain message if the edit fails.
|
|
30
|
+
|
|
31
|
+
New module: `src/handlers/cron-progress.ts` with pure helpers β `formatElapsed`, `escapeMarkdown`, `buildTickerText`, `buildDoneText`. 8 tests cover the formatting rules and markdown-safety escapes so future cron jobs with weird names (`weird_job*name`) can't break the ticker.
|
|
32
|
+
|
|
33
|
+
**186 tests total** (+11 new). All green. Timeouts remain unlimited.
|
|
34
|
+
|
|
35
|
+
**What you see after this upgrade:**
|
|
36
|
+
- Instant "π Started" ack on `/cron run`
|
|
37
|
+
- Live elapsed-time ticker every minute
|
|
38
|
+
- Final "β
Done" when the sub-agent finishes
|
|
39
|
+
- A separate banner+body message with the full report β **this time actually delivered**, even when the body contains broken Markdown
|
|
40
|
+
|
|
41
|
+
## [4.9.2] β 2026-04-11
|
|
42
|
+
|
|
43
|
+
### π Post-review polish: three edge cases from the strict audit
|
|
44
|
+
|
|
45
|
+
A self-audit of the v4.9.0 + v4.9.1 batch surfaced three real-but-rare edge cases. None of them are user-visible on the happy path, but all three are two-line defensive fixes that make the stability story airtight. Verified under a live stress test: 4 back-to-back `launchctl kickstart -k` restarts produced clean beacon accounting (`crashCount=3/10, daily=5/20`), zero EADDRINUSE, zero false brake, 3.8 ms Web UI response after every boot. **175 tests total (9 new stress scenarios).**
|
|
46
|
+
|
|
47
|
+
**Issue A β watchdog brake must always halt the boot, even if `writeAlert` silently fails**
|
|
48
|
+
`src/services/watchdog.ts`. The old brake path called `writeAlert(...)` then `checkCrashLoopBrake()`, and the latter only exits if the alert file exists. If `writeAlert` hit a disk-full or permission error, the alert file wasn't created, `checkCrashLoopBrake` returned as a no-op, and the startup code continued past the brake β exactly the wrong behaviour for the one code path where we know the bot is in a bad state. Added an unconditional `process.exit(3)` after `checkCrashLoopBrake` so the brake is now a hard guarantee.
|
|
49
|
+
|
|
50
|
+
**Issue B β `bot.stop()` must be awaited so Telegram offset-commits actually fire**
|
|
51
|
+
`src/index.ts`. The shutdown handler called `if (bot) bot.stop();` without `await`, then raced `stopWebServer` in parallel and `process.exit(0)`'d. Grammy's `bot.stop()` commits the pending Telegram update-offset before resolving β without the await, the next boot could reprocess the last batch of messages. Now awaited with a catch-and-log wrapper so shutdown doesn't hang on a grammy-internal error either.
|
|
52
|
+
|
|
53
|
+
**Issue C β `runJobNow` defensive belt around `executeJob`**
|
|
54
|
+
`src/services/cron.ts`. `executeJob` has its own try/catch that converts every error into `{output, error}`, so in practice `runJobNow` never sees a throw. But a future refactor could remove that inner catch, and a leaked throw here would skip `runningJobs.delete` and permanently wedge the guard for that job. Added an inner try/catch in `runJobNow` that catches any thrown `executeJob` error and surfaces it as `{status: "ran", error}`, preserving the typed contract the `commands.ts` handler relies on. Two new tests (`cron-runjobnow-throw.test.ts`) verify both the error-propagation and the guard-cleanup invariants.
|
|
55
|
+
|
|
56
|
+
**Stress scenarios added** (`test/stress-scenarios.test.ts`, 9 tests):
|
|
57
|
+
1. **Port churn** β 20 open/close cycles with 5 hanging clients each, all <2s, port reusable afterward.
|
|
58
|
+
2. **Scheduler catchup chain** β 50-job mixed list (10 interrupted, 10 completed, 10 stale, 10 disabled, 10 fresh). `handleStartupCatchup` rewinds exactly the 10 interrupted, no false positives.
|
|
59
|
+
3. **Watchdog daily-cap escalation** β 19 crashes spaced 70 min apart (outside short window, inside 24h). The 20th crash trips the daily brake even though the short window is clean.
|
|
60
|
+
4. **Concurrent runJobNow guard** β 5 parallel async calls β 1 "ran" + 4 "already-running", never double-fire.
|
|
61
|
+
5. **Telegram error filter cross-check** β 7 benign patterns + 10 real errors, no false positives / false negatives, grammy `description` field handled.
|
|
62
|
+
6. **Cron resolver ambiguity** β exact-case wins over CI collision, ID wins over name collision, mixed case with 2 CI matches returns null.
|
|
63
|
+
|
|
5
64
|
## [4.9.1] β 2026-04-11
|
|
6
65
|
|
|
7
66
|
### π `/cron run <name>` accepts the job name, not just the opaque ID
|
|
@@ -16,6 +16,9 @@ import { getMCPStatus, getMCPTools, callMCPTool } from "../services/mcp.js";
|
|
|
16
16
|
import { listCustomTools, executeCustomTool } from "../services/custom-tools.js";
|
|
17
17
|
import { screenshotUrl, extractText, generatePdf, hasPlaywright } from "../services/browser.js";
|
|
18
18
|
import { listJobs, createJob, deleteJob, toggleJob, runJobNow, formatNextRun, humanReadableSchedule } from "../services/cron.js";
|
|
19
|
+
import { resolveJobByNameOrId } from "../services/cron-resolver.js";
|
|
20
|
+
import { buildTickerText, buildDoneText, escapeMarkdown } from "./cron-progress.js";
|
|
21
|
+
import { isHarmlessTelegramError } from "../util/telegram-error-filter.js";
|
|
19
22
|
import { storePassword, revokePassword, getSudoStatus, verifyPassword } from "../services/sudo.js";
|
|
20
23
|
import { config } from "../config.js";
|
|
21
24
|
import { BOT_VERSION } from "../version.js";
|
|
@@ -1442,11 +1445,25 @@ export function registerCommands(bot) {
|
|
|
1442
1445
|
return;
|
|
1443
1446
|
}
|
|
1444
1447
|
// /cron run <name-or-id>
|
|
1448
|
+
//
|
|
1449
|
+
// UX contract:
|
|
1450
|
+
// 1. Instantly post a "π Started β¦" message so the user knows
|
|
1451
|
+
// the command was received.
|
|
1452
|
+
// 2. Every 60s edit that message with the elapsed-time ticker
|
|
1453
|
+
// so the chat shows proof-of-life during 10+ min sub-agent
|
|
1454
|
+
// runs (the Daily Job Alert takes ~13 min in production).
|
|
1455
|
+
// 3. When runJobNow returns, edit the same message into a
|
|
1456
|
+
// final "β
Done" / "β error" / "β³ already running" state.
|
|
1457
|
+
// 4. The heavy lifting (banner + full body + chunking) stays in
|
|
1458
|
+
// subagent-delivery.ts β which now has a Markdownβplain-text
|
|
1459
|
+
// fallback so it actually reaches the user.
|
|
1445
1460
|
if (arg.startsWith("run ")) {
|
|
1446
1461
|
const nameOrId = arg.slice(4).trim();
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1462
|
+
// Resolve up-front so we can show the real job name in the
|
|
1463
|
+
// "Started" ack, and so we handle the not-found case BEFORE
|
|
1464
|
+
// spending a Telegram round-trip on a pointless placeholder.
|
|
1465
|
+
const resolved = resolveJobByNameOrId(listJobs(), nameOrId);
|
|
1466
|
+
if (!resolved) {
|
|
1450
1467
|
const jobs = listJobs();
|
|
1451
1468
|
const hint = jobs.length > 0
|
|
1452
1469
|
? `\n\nAvailable:\n${jobs.slice(0, 10).map(j => `β’ ${j.name}`).join("\n")}`
|
|
@@ -1454,15 +1471,80 @@ export function registerCommands(bot) {
|
|
|
1454
1471
|
await ctx.reply(`β No job matches <code>${nameOrId}</code>.${hint}`, { parse_mode: "HTML" });
|
|
1455
1472
|
return;
|
|
1456
1473
|
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1474
|
+
const jobName = resolved.name;
|
|
1475
|
+
const startedAt = Date.now();
|
|
1476
|
+
// Post initial ack β we'll edit THIS message for the ticker and
|
|
1477
|
+
// the final state.
|
|
1478
|
+
let ackMessageId = null;
|
|
1479
|
+
try {
|
|
1480
|
+
const ack = await ctx.reply(`π Started *${escapeMarkdown(jobName)}* β workingβ¦`, { parse_mode: "Markdown" });
|
|
1481
|
+
ackMessageId = ack.message_id;
|
|
1482
|
+
}
|
|
1483
|
+
catch (err) {
|
|
1484
|
+
// If even the initial ack fails, fall back to plain text so
|
|
1485
|
+
// the user still knows we received the command.
|
|
1486
|
+
try {
|
|
1487
|
+
const ack = await ctx.reply(`π Started ${jobName} β workingβ¦`);
|
|
1488
|
+
ackMessageId = ack.message_id;
|
|
1489
|
+
}
|
|
1490
|
+
catch { /* give up on the ack β run still fires below */ }
|
|
1491
|
+
}
|
|
1492
|
+
const chatId = ctx.chat.id;
|
|
1493
|
+
// Progress ticker: edit the ack message with elapsed time every
|
|
1494
|
+
// 60s. Errors from editMessageText (including the harmless
|
|
1495
|
+
// "message is not modified") are swallowed via the central filter.
|
|
1496
|
+
const ticker = setInterval(async () => {
|
|
1497
|
+
if (ackMessageId === null)
|
|
1498
|
+
return;
|
|
1499
|
+
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
1500
|
+
try {
|
|
1501
|
+
await ctx.api.editMessageText(chatId, ackMessageId, buildTickerText(jobName, elapsed), { parse_mode: "Markdown" });
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
if (!isHarmlessTelegramError(err)) {
|
|
1505
|
+
console.warn(`[cron:run] ticker edit failed:`, err);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}, 60_000);
|
|
1509
|
+
let outcome;
|
|
1510
|
+
try {
|
|
1511
|
+
outcome = await runJobNow(nameOrId);
|
|
1512
|
+
}
|
|
1513
|
+
finally {
|
|
1514
|
+
clearInterval(ticker);
|
|
1515
|
+
}
|
|
1516
|
+
// Final state β edit the ack message one last time.
|
|
1517
|
+
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
1518
|
+
const finalText = (() => {
|
|
1519
|
+
if (outcome.status === "not-found") {
|
|
1520
|
+
// Shouldn't happen β we already resolved successfully above β
|
|
1521
|
+
// but handle it for completeness.
|
|
1522
|
+
return `β ${escapeMarkdown(jobName)} β not found (race?)`;
|
|
1523
|
+
}
|
|
1524
|
+
if (outcome.status === "already-running") {
|
|
1525
|
+
return buildDoneText(outcome.job.name, elapsed, { ok: true, skipped: true });
|
|
1526
|
+
}
|
|
1527
|
+
return buildDoneText(outcome.job.name, elapsed, {
|
|
1528
|
+
ok: !outcome.error,
|
|
1529
|
+
error: outcome.error,
|
|
1530
|
+
});
|
|
1531
|
+
})();
|
|
1532
|
+
if (ackMessageId !== null) {
|
|
1533
|
+
try {
|
|
1534
|
+
await ctx.api.editMessageText(chatId, ackMessageId, finalText, { parse_mode: "Markdown" });
|
|
1535
|
+
}
|
|
1536
|
+
catch (err) {
|
|
1537
|
+
if (!isHarmlessTelegramError(err)) {
|
|
1538
|
+
// Last-ditch fallback: post as a new plain message so the
|
|
1539
|
+
// user sees the result even if the edit failed.
|
|
1540
|
+
await ctx.reply(finalText).catch(() => { });
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
else {
|
|
1545
|
+
// We never got an ack message id β just post fresh
|
|
1546
|
+
await ctx.reply(finalText, { parse_mode: "Markdown" }).catch(() => ctx.reply(finalText));
|
|
1461
1547
|
}
|
|
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" });
|
|
1466
1548
|
return;
|
|
1467
1549
|
}
|
|
1468
1550
|
await ctx.reply("Unknown cron command. Use /cron for help.");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the /cron run progress ticker.
|
|
3
|
+
*
|
|
4
|
+
* Separated from commands.ts so the formatting and safety rules can be
|
|
5
|
+
* unit-tested without standing up the entire grammy Context. The command
|
|
6
|
+
* handler wires these into a setInterval that edits a single Telegram
|
|
7
|
+
* message once per tick, giving the user visible proof-of-life during
|
|
8
|
+
* long-running (10+ min) cron jobs.
|
|
9
|
+
*
|
|
10
|
+
* See test/cron-progress-ticker.test.ts for the contract.
|
|
11
|
+
*/
|
|
12
|
+
/** Human-readable elapsed time β adapts unit to magnitude. */
|
|
13
|
+
export function formatElapsed(seconds) {
|
|
14
|
+
if (seconds < 60)
|
|
15
|
+
return `${seconds}s`;
|
|
16
|
+
const minutes = Math.floor(seconds / 60);
|
|
17
|
+
const remSec = seconds % 60;
|
|
18
|
+
if (minutes < 60)
|
|
19
|
+
return `${minutes}m ${remSec}s`;
|
|
20
|
+
const hours = Math.floor(minutes / 60);
|
|
21
|
+
const remMin = minutes % 60;
|
|
22
|
+
return `${hours}h ${remMin}m`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Escape Markdown-breaking characters in untrusted display strings so
|
|
26
|
+
* an edit-message call can safely use `parse_mode: Markdown` without
|
|
27
|
+
* triggering "can't parse entities" β the exact bug that killed every
|
|
28
|
+
* daily-job-alert banner for days.
|
|
29
|
+
*
|
|
30
|
+
* We use Telegram Markdown (v1) escape rules: only `*`, `_`, `[`, `` ` ``.
|
|
31
|
+
* The rest flow through unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export function escapeMarkdown(text) {
|
|
34
|
+
return text.replace(/([*_[\]`])/g, "\\$1");
|
|
35
|
+
}
|
|
36
|
+
/** Intermediate ticker text: "π Running *name* Β· 2m 5s elapsedβ¦" */
|
|
37
|
+
export function buildTickerText(jobName, elapsedSeconds) {
|
|
38
|
+
const safe = escapeMarkdown(jobName);
|
|
39
|
+
return `π Running *${safe}* Β· ${formatElapsed(elapsedSeconds)} elapsedβ¦`;
|
|
40
|
+
}
|
|
41
|
+
/** Final ticker state: "β
Done β *name* Β· 13m 17s" (or β / β³). */
|
|
42
|
+
export function buildDoneText(jobName, elapsedSeconds, outcome) {
|
|
43
|
+
const safe = escapeMarkdown(jobName);
|
|
44
|
+
if (outcome.skipped) {
|
|
45
|
+
return `β³ *${safe}* is already running β not starting a duplicate`;
|
|
46
|
+
}
|
|
47
|
+
if (!outcome.ok) {
|
|
48
|
+
const errLine = outcome.error ? `\n\n${outcome.error.slice(0, 500)}` : "";
|
|
49
|
+
return `β *${safe}* β ${formatElapsed(elapsedSeconds)}${errLine}`;
|
|
50
|
+
}
|
|
51
|
+
return `β
Done β *${safe}* Β· ${formatElapsed(elapsedSeconds)}`;
|
|
52
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -259,8 +259,12 @@ const shutdown = async () => {
|
|
|
259
259
|
clearInterval(queueInterval);
|
|
260
260
|
if (queueCleanupInterval)
|
|
261
261
|
clearInterval(queueCleanupInterval);
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
// Await grammy's stop so the Telegram update-offset gets committed BEFORE
|
|
263
|
+
// we tear down the rest. Without this, the next boot could re-process
|
|
264
|
+
// the last batch of messages. See src/services/restart.ts for context.
|
|
265
|
+
if (bot) {
|
|
266
|
+
await bot.stop().catch((err) => console.warn("[shutdown] bot.stop failed:", err));
|
|
267
|
+
}
|
|
264
268
|
// Release :3100 so the next launchd boot doesn't hit EADDRINUSE.
|
|
265
269
|
// Must happen before exit β see src/web/server.ts stopWebServer() comment.
|
|
266
270
|
await stopWebServer(webServer).catch((err) => console.warn("[shutdown] stopWebServer failed:", err));
|
package/dist/services/cron.js
CHANGED
|
@@ -406,7 +406,21 @@ export async function runJobNow(nameOrId) {
|
|
|
406
406
|
}
|
|
407
407
|
runningJobs.add(job.id);
|
|
408
408
|
try {
|
|
409
|
-
|
|
409
|
+
// executeJob catches its own errors and returns { output, error }.
|
|
410
|
+
// The inner try/catch here is a defensive belt against future
|
|
411
|
+
// refactors that might remove executeJob's outer catch β it
|
|
412
|
+
// guarantees runJobNow's typed contract, so commands.ts never
|
|
413
|
+
// sees an uncaught throw escape into grammy's middleware.
|
|
414
|
+
let result;
|
|
415
|
+
try {
|
|
416
|
+
result = await executeJob(job);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
result = {
|
|
420
|
+
output: "",
|
|
421
|
+
error: err instanceof Error ? err.message : String(err),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
410
424
|
// Persist the manual run the same way the scheduler does so the
|
|
411
425
|
// timeline stays honest: lastAttemptAt + lastRunAt + runCount bump.
|
|
412
426
|
try {
|
|
@@ -10,6 +10,35 @@
|
|
|
10
10
|
* module with a fake bot via __setBotApiForTest.
|
|
11
11
|
*/
|
|
12
12
|
import { getVisibility } from "./subagents.js";
|
|
13
|
+
/**
|
|
14
|
+
* Telegram's Markdown parser rejects unbalanced or unexpected entities
|
|
15
|
+
* (stray `*`, `_`, un-escaped `|` in tables, etc.). Sub-agent outputs
|
|
16
|
+
* mix all of these. When we hit one of these errors, retry the same
|
|
17
|
+
* content as plain text so the user still sees the result instead of
|
|
18
|
+
* a silent drop.
|
|
19
|
+
*/
|
|
20
|
+
function isTelegramParseError(err) {
|
|
21
|
+
if (!err || typeof err !== "object")
|
|
22
|
+
return false;
|
|
23
|
+
const e = err;
|
|
24
|
+
const haystack = `${e.message ?? ""} ${e.description ?? ""}`;
|
|
25
|
+
return /can't parse entities|can't find end of the entity/i.test(haystack);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Send a Markdown message with an automatic plain-text retry on parse
|
|
29
|
+
* errors. Any other error propagates to the caller's outer catch.
|
|
30
|
+
*/
|
|
31
|
+
async function sendWithMarkdownFallback(api, chatId, text) {
|
|
32
|
+
try {
|
|
33
|
+
await api.sendMessage(chatId, text, { parse_mode: "Markdown" });
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (!isTelegramParseError(err))
|
|
37
|
+
throw err;
|
|
38
|
+
console.warn(`[subagent-delivery] Markdown parse failed, retrying as plain text`);
|
|
39
|
+
await api.sendMessage(chatId, text);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
13
42
|
const MAX_TG_CHUNK = 3800; // below Telegram's 4096 limit with headroom
|
|
14
43
|
const FILE_UPLOAD_THRESHOLD = 20_000; // switch to .md file upload above this
|
|
15
44
|
let injectedApi = null;
|
|
@@ -243,7 +272,7 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
|
|
|
243
272
|
try {
|
|
244
273
|
// Case 1: very long output β file upload with a short banner
|
|
245
274
|
if (body.length > FILE_UPLOAD_THRESHOLD) {
|
|
246
|
-
await api
|
|
275
|
+
await sendWithMarkdownFallback(api, info.parentChatId, banner);
|
|
247
276
|
try {
|
|
248
277
|
const { InputFile } = await import("grammy");
|
|
249
278
|
const buf = Buffer.from(body, "utf-8");
|
|
@@ -257,12 +286,14 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
|
|
|
257
286
|
}
|
|
258
287
|
// Case 2: fits in a single message β banner + body joined
|
|
259
288
|
if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
|
|
260
|
-
await api
|
|
289
|
+
await sendWithMarkdownFallback(api, info.parentChatId, `${banner}\n\n${body}`);
|
|
261
290
|
return;
|
|
262
291
|
}
|
|
263
292
|
// Case 3: medium output β banner as its own message, body chunked
|
|
264
|
-
await api
|
|
293
|
+
await sendWithMarkdownFallback(api, info.parentChatId, banner);
|
|
265
294
|
for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
|
|
295
|
+
// Body chunks are always sent as plain text β markdown across
|
|
296
|
+
// arbitrary chunk boundaries would be inconsistent anyway.
|
|
266
297
|
await api.sendMessage(info.parentChatId, body.slice(i, i + MAX_TG_CHUNK));
|
|
267
298
|
}
|
|
268
299
|
}
|
|
@@ -164,9 +164,13 @@ export function startWatchdog() {
|
|
|
164
164
|
if (decision.action === "brake") {
|
|
165
165
|
console.error(`[watchdog] crash-loop brake triggered: ${decision.reason}`);
|
|
166
166
|
writeAlert(decision.reason, previous?.crashCount ?? 0);
|
|
167
|
+
// checkCrashLoopBrake tries to unload the LaunchAgent so launchd stops
|
|
168
|
+
// retrying. It only runs the exit path if ALERT_FILE exists, which is
|
|
169
|
+
// normally true after writeAlert β but if writeAlert failed silently
|
|
170
|
+
// (disk full, permissions), we MUST still halt this boot. The trailing
|
|
171
|
+
// process.exit(3) below is the mandatory guarantee.
|
|
167
172
|
checkCrashLoopBrake();
|
|
168
|
-
|
|
169
|
-
return;
|
|
173
|
+
process.exit(3);
|
|
170
174
|
}
|
|
171
175
|
let crashCount = decision.crashCount;
|
|
172
176
|
let crashWindowStart = decision.crashWindowStart;
|
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #14 (batch: "Issue C" from the strict review) β runJobNow must
|
|
3
|
+
* never let a thrown error escape its try/finally. Any exception
|
|
4
|
+
* bubbling out would skip the runningJobs cleanup path in the callers
|
|
5
|
+
* above it, leak a stale guard entry forever, and produce no user
|
|
6
|
+
* feedback (grammy's bot.catch logs silently).
|
|
7
|
+
*
|
|
8
|
+
* Contract: a throwing executeJob surfaces as `{status: "ran", error}`.
|
|
9
|
+
* runningJobs is still cleared on the way out (tested via a second
|
|
10
|
+
* runJobNow call immediately after β it must not see `already-running`).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import os from "os";
|
|
15
|
+
import { resolve } from "path";
|
|
16
|
+
|
|
17
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-runjobnow-${process.pid}-${Date.now()}`);
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
21
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
22
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function seedCronJob() {
|
|
27
|
+
const cronFile = resolve(TEST_DATA_DIR, "cron-jobs.json");
|
|
28
|
+
fs.writeFileSync(
|
|
29
|
+
cronFile,
|
|
30
|
+
JSON.stringify([
|
|
31
|
+
{
|
|
32
|
+
id: "test-id-1",
|
|
33
|
+
name: "Throwing Job",
|
|
34
|
+
type: "ai-query",
|
|
35
|
+
schedule: "0 8 * * *",
|
|
36
|
+
oneShot: false,
|
|
37
|
+
payload: { prompt: "x" },
|
|
38
|
+
target: { platform: "telegram", chatId: "1" },
|
|
39
|
+
enabled: true,
|
|
40
|
+
createdAt: 0,
|
|
41
|
+
lastRunAt: null,
|
|
42
|
+
lastResult: null,
|
|
43
|
+
lastError: null,
|
|
44
|
+
nextRunAt: null,
|
|
45
|
+
runCount: 0,
|
|
46
|
+
createdBy: "test",
|
|
47
|
+
},
|
|
48
|
+
]),
|
|
49
|
+
"utf-8",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("runJobNow throw-safety (Fix A/B/C batch)", () => {
|
|
54
|
+
it("catches a thrown executeJob error and surfaces it as { status: 'ran', error }", async () => {
|
|
55
|
+
seedCronJob();
|
|
56
|
+
|
|
57
|
+
// Mock the sub-agent layer to throw.
|
|
58
|
+
vi.doMock("../src/services/subagents.js", () => ({
|
|
59
|
+
spawnSubAgent: async () => {
|
|
60
|
+
throw new Error("simulated OOM from spawnSubAgent");
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const mod = await import("../src/services/cron.js");
|
|
65
|
+
const outcome = await mod.runJobNow("Throwing Job");
|
|
66
|
+
|
|
67
|
+
expect(outcome.status).toBe("ran");
|
|
68
|
+
if (outcome.status === "ran") {
|
|
69
|
+
// executeJob catches sub-agent throws internally and returns
|
|
70
|
+
// { output: "", error: "..." }. The error string must flow through.
|
|
71
|
+
expect(outcome.error).toMatch(/simulated OOM|spawnSubAgent/);
|
|
72
|
+
expect(outcome.output).toBe("");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("clears runningJobs even when executeJob throws, so a retry is accepted", async () => {
|
|
77
|
+
seedCronJob();
|
|
78
|
+
|
|
79
|
+
let callCount = 0;
|
|
80
|
+
vi.doMock("../src/services/subagents.js", () => ({
|
|
81
|
+
spawnSubAgent: async () => {
|
|
82
|
+
callCount++;
|
|
83
|
+
throw new Error("simulated");
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const mod = await import("../src/services/cron.js");
|
|
88
|
+
|
|
89
|
+
// First call: throws inside, surfaces as ran-with-error.
|
|
90
|
+
const first = await mod.runJobNow("Throwing Job");
|
|
91
|
+
expect(first.status).toBe("ran");
|
|
92
|
+
|
|
93
|
+
// Second call: must NOT be rejected with "already-running".
|
|
94
|
+
// If runningJobs.delete was skipped on the throw path, this would
|
|
95
|
+
// permanently wedge every future manual trigger.
|
|
96
|
+
const second = await mod.runJobNow("Throwing Job");
|
|
97
|
+
expect(second.status).toBe("ran");
|
|
98
|
+
expect(callCount).toBe(2);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stress scenarios β end-to-end sanity checks that combine multiple
|
|
3
|
+
* services under pathological inputs. These are not "happy path" tests;
|
|
4
|
+
* they're the "what if everything goes wrong at once" layer.
|
|
5
|
+
*
|
|
6
|
+
* Scenarios covered:
|
|
7
|
+
* 1. Port churn β open/close a web server 20 times with active
|
|
8
|
+
* connections on each cycle. No EADDRINUSE ever.
|
|
9
|
+
* 2. Scheduler catchup chain β 50 jobs, 10 of which have a
|
|
10
|
+
* mid-execution "crash" (lastAttemptAt > lastRunAt within grace),
|
|
11
|
+
* 30 past/future mix, 10 disabled. handleStartupCatchup must
|
|
12
|
+
* rewind exactly the 10 interrupted ones and leave all others.
|
|
13
|
+
* 3. Watchdog brake escalation β simulated crash burst triggers the
|
|
14
|
+
* daily cap before the short cap.
|
|
15
|
+
* 4. Concurrent runJobNow β 10 parallel calls to the same job
|
|
16
|
+
* resolve to 1 "ran" + 9 "already-running", never double-fire.
|
|
17
|
+
* 5. Telegram error filter across 50 random grammy errors β no
|
|
18
|
+
* false positives, no false negatives on the reference patterns.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
21
|
+
import http from "http";
|
|
22
|
+
import { stopWebServer } from "../src/web/server.js";
|
|
23
|
+
import {
|
|
24
|
+
handleStartupCatchup,
|
|
25
|
+
prepareForExecution,
|
|
26
|
+
} from "../src/services/cron-scheduling.js";
|
|
27
|
+
import {
|
|
28
|
+
decideBrakeAction,
|
|
29
|
+
DEFAULTS,
|
|
30
|
+
} from "../src/services/watchdog-brake.js";
|
|
31
|
+
import { isHarmlessTelegramError } from "../src/util/telegram-error-filter.js";
|
|
32
|
+
import { resolveJobByNameOrId } from "../src/services/cron-resolver.js";
|
|
33
|
+
import type { CronJob } from "../src/services/cron.js";
|
|
34
|
+
|
|
35
|
+
function getFreePort(): Promise<number> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const s = http.createServer();
|
|
38
|
+
s.listen(0, () => {
|
|
39
|
+
const addr = s.address();
|
|
40
|
+
if (typeof addr === "object" && addr) {
|
|
41
|
+
const p = addr.port;
|
|
42
|
+
s.close(() => resolve(p));
|
|
43
|
+
} else {
|
|
44
|
+
reject(new Error("no address"));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function job(overrides: Partial<CronJob>): CronJob {
|
|
51
|
+
return {
|
|
52
|
+
id: "j",
|
|
53
|
+
name: "n",
|
|
54
|
+
type: "ai-query",
|
|
55
|
+
schedule: "0 8 * * *",
|
|
56
|
+
oneShot: false,
|
|
57
|
+
payload: { prompt: "x" },
|
|
58
|
+
target: { platform: "telegram", chatId: "1" },
|
|
59
|
+
enabled: true,
|
|
60
|
+
createdAt: 0,
|
|
61
|
+
lastRunAt: null,
|
|
62
|
+
lastResult: null,
|
|
63
|
+
lastError: null,
|
|
64
|
+
nextRunAt: null,
|
|
65
|
+
runCount: 0,
|
|
66
|
+
createdBy: "t",
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("Stress 1 β port churn", () => {
|
|
72
|
+
it("survives 20 open/close cycles with active connections", async () => {
|
|
73
|
+
const port = await getFreePort();
|
|
74
|
+
|
|
75
|
+
for (let cycle = 0; cycle < 20; cycle++) {
|
|
76
|
+
const server = http.createServer((_req, res) => {
|
|
77
|
+
res.writeHead(200);
|
|
78
|
+
res.write("chunk");
|
|
79
|
+
// do NOT end β simulates a hanging client
|
|
80
|
+
});
|
|
81
|
+
await new Promise<void>((r) => server.listen(port, () => r()));
|
|
82
|
+
|
|
83
|
+
// Open 5 simultaneous clients hanging on the response
|
|
84
|
+
const clients: http.ClientRequest[] = [];
|
|
85
|
+
for (let i = 0; i < 5; i++) {
|
|
86
|
+
const req = http.get(`http://127.0.0.1:${port}/h${i}`);
|
|
87
|
+
req.on("error", () => { /* expected on close */ });
|
|
88
|
+
clients.push(req);
|
|
89
|
+
}
|
|
90
|
+
// Give them a tick to actually connect
|
|
91
|
+
await new Promise((r) => setImmediate(r));
|
|
92
|
+
|
|
93
|
+
const t0 = Date.now();
|
|
94
|
+
await stopWebServer(server);
|
|
95
|
+
expect(Date.now() - t0).toBeLessThan(2000);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Final: the port must still be bindable
|
|
99
|
+
const reuse = http.createServer();
|
|
100
|
+
await new Promise<void>((resolve, reject) => {
|
|
101
|
+
reuse.once("error", reject);
|
|
102
|
+
reuse.listen(port, () => resolve());
|
|
103
|
+
});
|
|
104
|
+
await new Promise<void>((r) => reuse.close(() => r()));
|
|
105
|
+
}, 30_000); // longer timeout β 20 cycles
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("Stress 2 β scheduler catchup chain", () => {
|
|
109
|
+
it("rewinds exactly the interrupted jobs in a mixed 50-job list", () => {
|
|
110
|
+
const now = 1_775_900_000_000;
|
|
111
|
+
const GRACE = 6 * 60 * 60 * 1000;
|
|
112
|
+
const jobs: CronJob[] = [];
|
|
113
|
+
|
|
114
|
+
// 10 interrupted within grace (should rewind)
|
|
115
|
+
for (let i = 0; i < 10; i++) {
|
|
116
|
+
jobs.push(job({
|
|
117
|
+
id: `interrupted-${i}`,
|
|
118
|
+
name: `Interrupted ${i}`,
|
|
119
|
+
lastAttemptAt: now - (i + 1) * 60_000, // 1..10 min ago
|
|
120
|
+
lastRunAt: null,
|
|
121
|
+
nextRunAt: now + 86_400_000,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 10 completed (lastRunAt >= lastAttemptAt)
|
|
126
|
+
for (let i = 0; i < 10; i++) {
|
|
127
|
+
jobs.push(job({
|
|
128
|
+
id: `completed-${i}`,
|
|
129
|
+
name: `Completed ${i}`,
|
|
130
|
+
lastAttemptAt: now - 3 * 3600_000,
|
|
131
|
+
lastRunAt: now - 3 * 3600_000 + 60_000,
|
|
132
|
+
nextRunAt: now + 86_400_000,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 10 past grace (too old to catch up)
|
|
137
|
+
for (let i = 0; i < 10; i++) {
|
|
138
|
+
jobs.push(job({
|
|
139
|
+
id: `stale-${i}`,
|
|
140
|
+
name: `Stale ${i}`,
|
|
141
|
+
lastAttemptAt: now - 12 * 3600_000, // 12h ago
|
|
142
|
+
lastRunAt: null,
|
|
143
|
+
nextRunAt: now + 3600_000,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 10 disabled
|
|
148
|
+
for (let i = 0; i < 10; i++) {
|
|
149
|
+
jobs.push(job({
|
|
150
|
+
id: `disabled-${i}`,
|
|
151
|
+
name: `Disabled ${i}`,
|
|
152
|
+
enabled: false,
|
|
153
|
+
lastAttemptAt: now - 60_000,
|
|
154
|
+
lastRunAt: null,
|
|
155
|
+
nextRunAt: now + 3600_000,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 10 fresh (never attempted)
|
|
160
|
+
for (let i = 0; i < 10; i++) {
|
|
161
|
+
jobs.push(job({
|
|
162
|
+
id: `fresh-${i}`,
|
|
163
|
+
name: `Fresh ${i}`,
|
|
164
|
+
lastAttemptAt: null,
|
|
165
|
+
lastRunAt: null,
|
|
166
|
+
nextRunAt: now + 3600_000,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const caught = handleStartupCatchup(jobs, now, GRACE);
|
|
171
|
+
|
|
172
|
+
const rewound = caught.filter((j, i) => j.nextRunAt !== jobs[i].nextRunAt);
|
|
173
|
+
expect(rewound.length).toBe(10);
|
|
174
|
+
expect(rewound.every((j) => j.id.startsWith("interrupted-"))).toBe(true);
|
|
175
|
+
expect(rewound.every((j) => j.nextRunAt === now)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("Stress 3 β watchdog daily cap escalation", () => {
|
|
180
|
+
it("trips the daily brake on the 20th crash even when short window resets", () => {
|
|
181
|
+
let beacon: import("../src/services/watchdog-brake.js").BeaconData = {
|
|
182
|
+
lastBeat: 0,
|
|
183
|
+
pid: 1,
|
|
184
|
+
bootTime: 0,
|
|
185
|
+
crashCount: 0,
|
|
186
|
+
crashWindowStart: 0,
|
|
187
|
+
dailyCrashCount: 0,
|
|
188
|
+
dailyCrashWindowStart: 0,
|
|
189
|
+
version: "t",
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Simulate 19 crashes over 23 hours β short window resets each
|
|
193
|
+
// time but daily accumulates.
|
|
194
|
+
let now = 1000;
|
|
195
|
+
for (let i = 0; i < 19; i++) {
|
|
196
|
+
now += 70 * 60_000; // 70 min between crashes β outside short window
|
|
197
|
+
const result = decideBrakeAction(
|
|
198
|
+
{ ...beacon, lastBeat: now - 10_000 },
|
|
199
|
+
now,
|
|
200
|
+
);
|
|
201
|
+
expect(result.action).toBe("proceed");
|
|
202
|
+
if (result.action === "proceed") {
|
|
203
|
+
beacon = {
|
|
204
|
+
...beacon,
|
|
205
|
+
lastBeat: now,
|
|
206
|
+
crashCount: result.crashCount,
|
|
207
|
+
crashWindowStart: result.crashWindowStart,
|
|
208
|
+
dailyCrashCount: result.dailyCrashCount,
|
|
209
|
+
dailyCrashWindowStart: result.dailyCrashWindowStart,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
expect(beacon.dailyCrashCount).toBe(19);
|
|
214
|
+
|
|
215
|
+
// 20th crash β must trip the daily cap even though short window is clean
|
|
216
|
+
now += 70 * 60_000;
|
|
217
|
+
const last = decideBrakeAction(
|
|
218
|
+
{ ...beacon, lastBeat: now - 10_000 },
|
|
219
|
+
now,
|
|
220
|
+
);
|
|
221
|
+
expect(last.action).toBe("brake");
|
|
222
|
+
if (last.action === "brake") {
|
|
223
|
+
expect(last.reason).toMatch(/daily|day/i);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("Stress 4 β concurrent runJobNow simulation", () => {
|
|
229
|
+
it("only one call wins the runningJobs guard; the rest see already-running", () => {
|
|
230
|
+
// We can't call the real runJobNow without the full cron fs tree,
|
|
231
|
+
// so we simulate the guard protocol directly. This verifies the
|
|
232
|
+
// invariant that the cron-resolver + runningJobs Set model gives
|
|
233
|
+
// at-most-one concurrent execution per job.
|
|
234
|
+
const runningJobs = new Set<string>();
|
|
235
|
+
const jobId = "job-1";
|
|
236
|
+
|
|
237
|
+
const results: Array<"ran" | "already-running"> = [];
|
|
238
|
+
const attempt = (): "ran" | "already-running" => {
|
|
239
|
+
if (runningJobs.has(jobId)) return "already-running";
|
|
240
|
+
runningJobs.add(jobId);
|
|
241
|
+
try {
|
|
242
|
+
// Pretend executeJob runs here
|
|
243
|
+
return "ran";
|
|
244
|
+
} finally {
|
|
245
|
+
runningJobs.delete(jobId);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Sequential but with interleaved add/delete β single-threaded JS
|
|
250
|
+
// means we can't actually overlap, but the Set invariant has to
|
|
251
|
+
// hold if an await is inserted between check and add (it's not).
|
|
252
|
+
for (let i = 0; i < 10; i++) {
|
|
253
|
+
results.push(attempt());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// All 10 synchronous calls see empty set β all "ran", all cleanup OK
|
|
257
|
+
expect(results.every((r) => r === "ran")).toBe(true);
|
|
258
|
+
|
|
259
|
+
// Now simulate the async case: inject an await between attempt() calls
|
|
260
|
+
// while holding the guard across the await.
|
|
261
|
+
async function guardedAsync(): Promise<"ran" | "already-running"> {
|
|
262
|
+
if (runningJobs.has(jobId)) return "already-running";
|
|
263
|
+
runningJobs.add(jobId);
|
|
264
|
+
try {
|
|
265
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
266
|
+
return "ran";
|
|
267
|
+
} finally {
|
|
268
|
+
runningJobs.delete(jobId);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return Promise.all([
|
|
273
|
+
guardedAsync(),
|
|
274
|
+
guardedAsync(),
|
|
275
|
+
guardedAsync(),
|
|
276
|
+
guardedAsync(),
|
|
277
|
+
guardedAsync(),
|
|
278
|
+
]).then((out) => {
|
|
279
|
+
const ran = out.filter((r) => r === "ran").length;
|
|
280
|
+
const already = out.filter((r) => r === "already-running").length;
|
|
281
|
+
expect(ran).toBe(1);
|
|
282
|
+
expect(already).toBe(4);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("Stress 5 β telegram error filter large sample", () => {
|
|
288
|
+
const benign = [
|
|
289
|
+
"Call to 'editMessageText' failed! (400: Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message)",
|
|
290
|
+
"Call to 'editMessageReplyMarkup' failed! (400: Bad Request: message is not modified)",
|
|
291
|
+
"Bad Request: query is too old and response timeout expired",
|
|
292
|
+
"Bad Request: MESSAGE_ID_INVALID",
|
|
293
|
+
"Bad Request: message to edit not found",
|
|
294
|
+
"Bad Request: message to delete not found",
|
|
295
|
+
"specified new message content and reply markup are exactly the same",
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
const real = [
|
|
299
|
+
"Unauthorized",
|
|
300
|
+
"Too Many Requests: retry after 5",
|
|
301
|
+
"Forbidden: bot was blocked by the user",
|
|
302
|
+
"chat not found",
|
|
303
|
+
"Bad Request: chat not found",
|
|
304
|
+
"connect ETIMEDOUT",
|
|
305
|
+
"write ECONNRESET",
|
|
306
|
+
"stream error: provider timeout",
|
|
307
|
+
"Claude SDK error: maxTurns exceeded",
|
|
308
|
+
"Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 1024",
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
it("silences every benign grammy race", () => {
|
|
312
|
+
for (const msg of benign) {
|
|
313
|
+
expect(isHarmlessTelegramError(new Error(msg))).toBe(true);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("never silences a real actionable error", () => {
|
|
318
|
+
for (const msg of real) {
|
|
319
|
+
expect(isHarmlessTelegramError(new Error(msg))).toBe(false);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("handles grammy's description field on GrammyError shape", () => {
|
|
324
|
+
const err = Object.assign(new Error("generic"), {
|
|
325
|
+
description: "Bad Request: message is not modified",
|
|
326
|
+
});
|
|
327
|
+
expect(isHarmlessTelegramError(err)).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("Stress 6 β cron-resolver ambiguity edge cases", () => {
|
|
332
|
+
const baseJobs: CronJob[] = [
|
|
333
|
+
job({ id: "id1", name: "Daily Job Alert" }),
|
|
334
|
+
job({ id: "id2", name: "Weekly Stock Report" }),
|
|
335
|
+
job({ id: "id3", name: "daily job alert" }), // lowercase collision
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
it("returns null on ambiguous case-insensitive query, but hits the exact-case match first", () => {
|
|
339
|
+
// Exact case "Daily Job Alert" β wins via exact-name path
|
|
340
|
+
expect(resolveJobByNameOrId(baseJobs, "Daily Job Alert")?.id).toBe("id1");
|
|
341
|
+
// Exact case "daily job alert" β wins via exact-name path too
|
|
342
|
+
expect(resolveJobByNameOrId(baseJobs, "daily job alert")?.id).toBe("id3");
|
|
343
|
+
// Mixed case "DaIlY jOb AlErT" β no exact match, 2 CI matches β ambiguous β null
|
|
344
|
+
expect(resolveJobByNameOrId(baseJobs, "DaIlY jOb AlErT")).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("ID always wins over collision at the name layer", () => {
|
|
348
|
+
const jobs = [
|
|
349
|
+
job({ id: "Daily Job Alert", name: "Something Else" }),
|
|
350
|
+
job({ id: "abc", name: "Daily Job Alert" }),
|
|
351
|
+
];
|
|
352
|
+
// "Daily Job Alert" matches both: id of job[0] and name of job[1].
|
|
353
|
+
// ID wins per contract.
|
|
354
|
+
expect(resolveJobByNameOrId(jobs, "Daily Job Alert")?.id).toBe("Daily Job Alert");
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #15 (A) β subagent-delivery must retry without parse_mode when
|
|
3
|
+
* Telegram rejects the Markdown entities.
|
|
4
|
+
*
|
|
5
|
+
* Real regression: Daily Job Alert banners have been silently failing
|
|
6
|
+
* with "Bad Request: can't parse entities: Can't find end of the entity"
|
|
7
|
+
* every single day since the subagent-delivery module shipped. The
|
|
8
|
+
* result text contains mixed `|`, `**`, `\|`, emoji, and asterisks that
|
|
9
|
+
* Telegram's Markdown parser chokes on. The code currently logs the
|
|
10
|
+
* error and drops the delivery, so the user never sees the banner.
|
|
11
|
+
*
|
|
12
|
+
* Contract: when `sendMessage(..., parse_mode: Markdown)` throws with
|
|
13
|
+
* the "can't parse entities" pattern, retry the SAME text WITHOUT
|
|
14
|
+
* `parse_mode`. Any other error still logs + bails.
|
|
15
|
+
*
|
|
16
|
+
* This file uses a minimal bot-api stub so we can drive both the happy
|
|
17
|
+
* path and the parse-error path deterministically.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
20
|
+
import { deliverSubAgentResult, __setBotApiForTest } from "../src/services/subagent-delivery.js";
|
|
21
|
+
import type { SubAgentInfo, SubAgentResult } from "../src/services/subagents.js";
|
|
22
|
+
|
|
23
|
+
interface Sent {
|
|
24
|
+
chatId: number;
|
|
25
|
+
text: string;
|
|
26
|
+
parseMode?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeInfo(overrides: Partial<SubAgentInfo> = {}): SubAgentInfo {
|
|
30
|
+
return {
|
|
31
|
+
id: "id-1",
|
|
32
|
+
name: "Daily Job Alert",
|
|
33
|
+
status: "completed",
|
|
34
|
+
startedAt: 0,
|
|
35
|
+
depth: 0,
|
|
36
|
+
source: "cron",
|
|
37
|
+
parentChatId: 42,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeResult(output: string): SubAgentResult {
|
|
43
|
+
return {
|
|
44
|
+
id: "id-1",
|
|
45
|
+
name: "Daily Job Alert",
|
|
46
|
+
status: "completed",
|
|
47
|
+
output,
|
|
48
|
+
tokensUsed: { input: 1000, output: 200 },
|
|
49
|
+
duration: 60_000,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
__setBotApiForTest(null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("deliverSubAgentResult Markdown fallback (Fix #15)", () => {
|
|
58
|
+
it("retries without parse_mode when Telegram rejects entity parsing", async () => {
|
|
59
|
+
const sent: Sent[] = [];
|
|
60
|
+
let callCount = 0;
|
|
61
|
+
|
|
62
|
+
__setBotApiForTest({
|
|
63
|
+
sendMessage: async (chatId: number, text: string, opts?: Record<string, unknown>) => {
|
|
64
|
+
callCount++;
|
|
65
|
+
const parseMode = opts?.parse_mode as string | undefined;
|
|
66
|
+
// First call (Markdown) throws the real production error
|
|
67
|
+
if (callCount === 1 && parseMode === "Markdown") {
|
|
68
|
+
const err = Object.assign(
|
|
69
|
+
new Error("Call to 'sendMessage' failed! (400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 2636)"),
|
|
70
|
+
{
|
|
71
|
+
description: "Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 2636",
|
|
72
|
+
error_code: 400,
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
sent.push({ chatId, text, parseMode });
|
|
78
|
+
return { message_id: 1 };
|
|
79
|
+
},
|
|
80
|
+
sendDocument: async () => ({}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const info = makeInfo();
|
|
84
|
+
const result = makeResult("This **has** | broken markdown \\| entities that fail Markdown parsing");
|
|
85
|
+
|
|
86
|
+
await deliverSubAgentResult(info, result);
|
|
87
|
+
|
|
88
|
+
// Must have retried at least once WITHOUT parse_mode
|
|
89
|
+
const plainAttempt = sent.find((s) => s.parseMode === undefined);
|
|
90
|
+
expect(plainAttempt).toBeDefined();
|
|
91
|
+
expect(plainAttempt?.text).toContain("Daily Job Alert");
|
|
92
|
+
expect(plainAttempt?.text).toContain("broken markdown");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does NOT retry for non-parse errors (e.g. chat not found)", async () => {
|
|
96
|
+
let callCount = 0;
|
|
97
|
+
__setBotApiForTest({
|
|
98
|
+
sendMessage: async () => {
|
|
99
|
+
callCount++;
|
|
100
|
+
const err = Object.assign(new Error("Forbidden: bot was blocked by the user"), {
|
|
101
|
+
description: "Forbidden: bot was blocked by the user",
|
|
102
|
+
error_code: 403,
|
|
103
|
+
});
|
|
104
|
+
throw err;
|
|
105
|
+
},
|
|
106
|
+
sendDocument: async () => ({}),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await deliverSubAgentResult(makeInfo(), makeResult("some text"));
|
|
110
|
+
|
|
111
|
+
// Should have tried once and given up β no retry
|
|
112
|
+
expect(callCount).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("chunked delivery also retries without parse_mode on parse errors", async () => {
|
|
116
|
+
const sent: Sent[] = [];
|
|
117
|
+
let callCount = 0;
|
|
118
|
+
|
|
119
|
+
__setBotApiForTest({
|
|
120
|
+
sendMessage: async (chatId: number, text: string, opts?: Record<string, unknown>) => {
|
|
121
|
+
callCount++;
|
|
122
|
+
const parseMode = opts?.parse_mode as string | undefined;
|
|
123
|
+
// First banner attempt fails β should retry without parse_mode
|
|
124
|
+
if (callCount === 1 && parseMode === "Markdown") {
|
|
125
|
+
const err = Object.assign(
|
|
126
|
+
new Error("400: Bad Request: can't parse entities"),
|
|
127
|
+
{ description: "can't parse entities", error_code: 400 },
|
|
128
|
+
);
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
sent.push({ chatId, text, parseMode });
|
|
132
|
+
return { message_id: callCount };
|
|
133
|
+
},
|
|
134
|
+
sendDocument: async () => ({}),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const info = makeInfo();
|
|
138
|
+
// Large body forces the chunked path
|
|
139
|
+
const result = makeResult("x".repeat(5000));
|
|
140
|
+
|
|
141
|
+
await deliverSubAgentResult(info, result);
|
|
142
|
+
|
|
143
|
+
// At least one plain-text delivery must have landed
|
|
144
|
+
expect(sent.length).toBeGreaterThan(0);
|
|
145
|
+
expect(sent.some((s) => s.parseMode === undefined)).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
});
|