alvin-bot 5.1.7 → 5.1.8
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 +22 -0
- package/dist/services/cron-scheduling.js +34 -6
- package/dist/services/cron.js +4 -1
- package/dist/services/subagents.js +3 -1
- package/dist/services/watchdog.js +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [5.1.8] — 2026-05-17
|
|
6
|
+
|
|
7
|
+
### Interrupted jobs auto-resume after a controlled restart
|
|
8
|
+
|
|
9
|
+
If an auto-update, `/update` or `/restart` interrupted a job while it
|
|
10
|
+
was running, the bot used to give up on that run and ask you to
|
|
11
|
+
re-trigger it manually. Now, when the interruption was a controlled
|
|
12
|
+
restart (not a crash) and it happened within the last 15 minutes, the
|
|
13
|
+
job is re-run automatically on the next tick after the bot is back.
|
|
14
|
+
Crash-loops are deliberately excluded — a job that keeps crashing the
|
|
15
|
+
bot will never resume itself — and each interrupted run is resumed at
|
|
16
|
+
most once. You can opt a single job out with `autoResume: false`.
|
|
17
|
+
|
|
18
|
+
### Sub-agents report a result, not a play-by-play
|
|
19
|
+
|
|
20
|
+
Finished sub-agents (background tasks, cron jobs) no longer dump a long
|
|
21
|
+
step-by-step recap of how they thought and what tools they called. You
|
|
22
|
+
get the compact status line (success/failed · duration · tokens) plus
|
|
23
|
+
the actual result — nothing more. Cron reports still arrive in full;
|
|
24
|
+
only the meta-narration is gone, since the orchestrator processes the
|
|
25
|
+
real output anyway.
|
|
26
|
+
|
|
5
27
|
## [5.1.7] — 2026-05-17
|
|
6
28
|
|
|
7
29
|
### Scheduled jobs no longer run twice after a restart
|
|
@@ -149,6 +149,15 @@ export function prepareForExecution(job, now) {
|
|
|
149
149
|
// ── Startup catch-up ───────────────────────────────────────
|
|
150
150
|
/** Default grace window for catching up an interrupted attempt on boot. */
|
|
151
151
|
export const DEFAULT_CATCHUP_GRACE_MS = 6 * 60 * 60 * 1000; // 6 h
|
|
152
|
+
/**
|
|
153
|
+
* Short window for *fast-resume*: when a controlled restart (auto-update,
|
|
154
|
+
* /update, /restart) interrupts a running job, re-run it immediately on
|
|
155
|
+
* the next boot instead of telling the user to re-trigger — but only if
|
|
156
|
+
* the interruption is this fresh. Deliberately minutes, not hours: a
|
|
157
|
+
* boot many hours later is the "surprise rerun" case slotAlreadyAttempted
|
|
158
|
+
* is designed to suppress; fast-resume is the immediate self-heal.
|
|
159
|
+
*/
|
|
160
|
+
export const DEFAULT_FAST_RESUME_MS = 15 * 60 * 1000; // 15 min
|
|
152
161
|
/**
|
|
153
162
|
* Returns true when the job's *current* schedule slot has already been
|
|
154
163
|
* attempted — i.e. the bot fired the job for this slot before the
|
|
@@ -195,7 +204,8 @@ function slotAlreadyAttempted(job, now) {
|
|
|
195
204
|
*
|
|
196
205
|
* PURE: returns a fresh array, never mutates the input.
|
|
197
206
|
*/
|
|
198
|
-
export function handleStartupCatchup(jobs, now, graceMs = DEFAULT_CATCHUP_GRACE_MS) {
|
|
207
|
+
export function handleStartupCatchup(jobs, now, graceMs = DEFAULT_CATCHUP_GRACE_MS, opts = {}) {
|
|
208
|
+
const fastResumeMs = opts.fastResumeMs ?? DEFAULT_FAST_RESUME_MS;
|
|
199
209
|
return jobs.map((job) => {
|
|
200
210
|
if (!job.enabled)
|
|
201
211
|
return job;
|
|
@@ -210,11 +220,29 @@ export function handleStartupCatchup(jobs, now, graceMs = DEFAULT_CATCHUP_GRACE_
|
|
|
210
220
|
return job; // clock weirdness — skip
|
|
211
221
|
if (ageMs > graceMs)
|
|
212
222
|
return job; // outside grace — give up
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
if (slotAlreadyAttempted(job, now)) {
|
|
224
|
+
// The slot was already attempted. Normally we leave it alone so the
|
|
225
|
+
// user doesn't get a surprise rerun hours later. EXCEPTION —
|
|
226
|
+
// fast-resume: a *controlled* restart interrupted it just minutes
|
|
227
|
+
// ago. Re-run immediately instead of "please re-trigger".
|
|
228
|
+
// • expectedRestart → never true after a crash, so a job that
|
|
229
|
+
// crashes the bot can't loop-resume itself.
|
|
230
|
+
// • autoResume === false → per-job opt-out.
|
|
231
|
+
// • fresh within fastResumeMs → not the "hours later" case.
|
|
232
|
+
// • lastFastResumeAttemptAt !== lastAttemptAt → resume a given
|
|
233
|
+
// interrupted attempt at most once.
|
|
234
|
+
const canFastResume = opts.expectedRestart === true &&
|
|
235
|
+
job.autoResume !== false &&
|
|
236
|
+
ageMs < fastResumeMs &&
|
|
237
|
+
job.lastFastResumeAttemptAt !== job.lastAttemptAt;
|
|
238
|
+
if (!canFastResume)
|
|
239
|
+
return job;
|
|
240
|
+
return {
|
|
241
|
+
...job,
|
|
242
|
+
nextRunAt: now,
|
|
243
|
+
lastFastResumeAttemptAt: job.lastAttemptAt,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
218
246
|
// Within grace, never completed, and current slot hasn't been
|
|
219
247
|
// attempted yet → catch up on next tick.
|
|
220
248
|
return { ...job, nextRunAt: now };
|
package/dist/services/cron.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolve, dirname } from "path";
|
|
|
15
15
|
import { CRON_FILE, BOT_ROOT } from "../paths.js";
|
|
16
16
|
import { prepareForExecution, handleStartupCatchup, calculateNextRunFrom, } from "./cron-scheduling.js";
|
|
17
17
|
import { resolveJobByNameOrId } from "./cron-resolver.js";
|
|
18
|
+
import { bootWasExpectedRestart } from "./watchdog.js";
|
|
18
19
|
// ── Storage ─────────────────────────────────────────────
|
|
19
20
|
function loadJobs() {
|
|
20
21
|
try {
|
|
@@ -343,7 +344,9 @@ export function startScheduler() {
|
|
|
343
344
|
// catch-up nextRunAt rewind is visible on the very next pass.
|
|
344
345
|
try {
|
|
345
346
|
const bootJobs = loadJobs();
|
|
346
|
-
const caught = handleStartupCatchup(bootJobs, Date.now()
|
|
347
|
+
const caught = handleStartupCatchup(bootJobs, Date.now(), undefined, {
|
|
348
|
+
expectedRestart: bootWasExpectedRestart(),
|
|
349
|
+
});
|
|
347
350
|
// Only persist if something actually changed to avoid needless writes
|
|
348
351
|
const mutated = caught.some((j, i) => j.nextRunAt !== bootJobs[i].nextRunAt);
|
|
349
352
|
if (mutated) {
|
|
@@ -286,7 +286,9 @@ async function runSubAgent(id, agentConfig, abort, resolvedName) {
|
|
|
286
286
|
const effectiveCwd = inheritCwd
|
|
287
287
|
? agentConfig.workingDir || os.homedir()
|
|
288
288
|
: os.homedir();
|
|
289
|
-
const systemPrompt = `You are a sub-agent named "${resolvedName}". Complete the following task autonomously
|
|
289
|
+
const systemPrompt = `You are a sub-agent named "${resolvedName}". Complete the following task autonomously. Working directory: ${effectiveCwd}
|
|
290
|
+
|
|
291
|
+
When done, return ONLY the final result/outcome, concisely. Do NOT narrate your intermediate steps, your reasoning, your tool calls, or a play-by-play of what you did — the orchestrator only needs the outcome (the answer, the report, the list, the artifact path), and on failure the error plus what was and wasn't done. No preamble, no "Here's what I did", no step-by-step recap. Run status, duration and token usage are reported separately, so don't restate them.`;
|
|
290
292
|
// v4.12.2 — Map the toolset preset to an explicit allowedTools list.
|
|
291
293
|
// The provider honors this override (see src/providers/claude-sdk-provider.ts
|
|
292
294
|
// line ~140). Passing undefined = full access (provider default).
|
|
@@ -39,6 +39,18 @@ const BEACON_INTERVAL_MS = 30_000; // write a beacon every 30 s
|
|
|
39
39
|
let beaconTimer = null;
|
|
40
40
|
let resetTimer = null;
|
|
41
41
|
let bootTime = 0;
|
|
42
|
+
/** Captured in startWatchdog(): did the previous process exit via a
|
|
43
|
+
* controlled restart? Read by the cron scheduler for fast-resume. */
|
|
44
|
+
let bootExpectedRestart = false;
|
|
45
|
+
/**
|
|
46
|
+
* True when this boot was preceded by a *controlled* restart
|
|
47
|
+
* (`markExpectedRestart` had set the beacon flag) rather than a crash.
|
|
48
|
+
* Returns false until startWatchdog() has run, and false after a crash —
|
|
49
|
+
* the safe default (no fast-resume) in both cases.
|
|
50
|
+
*/
|
|
51
|
+
export function bootWasExpectedRestart() {
|
|
52
|
+
return bootExpectedRestart;
|
|
53
|
+
}
|
|
42
54
|
function ensureStateDir() {
|
|
43
55
|
try {
|
|
44
56
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
@@ -155,6 +167,11 @@ export function startWatchdog() {
|
|
|
155
167
|
ensureStateDir();
|
|
156
168
|
bootTime = Date.now();
|
|
157
169
|
const previous = readBeacon();
|
|
170
|
+
// Capture whether the *previous* process exited via a controlled
|
|
171
|
+
// restart (markExpectedRestart set the flag) BEFORE writeBeacon below
|
|
172
|
+
// resets it. The cron scheduler uses this to fast-resume a job that a
|
|
173
|
+
// controlled restart interrupted, while never resuming after a crash.
|
|
174
|
+
bootExpectedRestart = previous?.expectedRestart === true;
|
|
158
175
|
const decision = decideBrakeAction(previous, bootTime);
|
|
159
176
|
if (decision.action === "brake") {
|
|
160
177
|
console.error(`[watchdog] crash-loop brake triggered: ${decision.reason}`);
|