talon-agent 1.40.0 → 1.40.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/package.json
CHANGED
|
@@ -22,6 +22,65 @@ import { HeartbeatTimeoutError, runHeartbeatAgent } from "./agent.js";
|
|
|
22
22
|
const STARTUP_DELAY_MS = 5 * 60 * 1000; // 5-minute delay before first run
|
|
23
23
|
const DUE_CHECK_INTERVAL_MS = 60 * 1000;
|
|
24
24
|
|
|
25
|
+
// Failure backoff. A failed run does NOT advance last_run, so without a
|
|
26
|
+
// backoff the every-minute due check re-fires the heartbeat 60×/hour against
|
|
27
|
+
// the same error (observed live: a session-limit night produced hundreds of
|
|
28
|
+
// identical failures a minute apart). Generic failures back off
|
|
29
|
+
// exponentially; session/rate-limit errors that state their own reset time
|
|
30
|
+
// wait for it instead of guessing.
|
|
31
|
+
const FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
|
|
32
|
+
const FAILURE_BACKOFF_MAX_MS = 60 * 60 * 1000;
|
|
33
|
+
/** Safety margin added past a parsed limit-reset time (clock skew, rollout). */
|
|
34
|
+
const LIMIT_RESET_BUFFER_MS = 2 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse the reset wall-clock time out of a session/rate-limit error message,
|
|
38
|
+
* e.g. "You've hit your session limit · resets 12:20am (UTC)" or
|
|
39
|
+
* "… resets 3pm (UTC)". Returns the epoch ms of the NEXT occurrence of that
|
|
40
|
+
* UTC time after `now`, or null when the message doesn't carry one.
|
|
41
|
+
*/
|
|
42
|
+
export function parseSessionLimitResetMs(
|
|
43
|
+
message: string,
|
|
44
|
+
now: number,
|
|
45
|
+
): number | null {
|
|
46
|
+
const m = /resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i.exec(
|
|
47
|
+
message,
|
|
48
|
+
);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const rawHour = Number(m[1]);
|
|
51
|
+
const minute = m[2] ? Number(m[2]) : 0;
|
|
52
|
+
if (rawHour < 1 || rawHour > 12 || minute > 59) return null;
|
|
53
|
+
let hour = rawHour % 12;
|
|
54
|
+
if (m[3].toLowerCase() === "pm") hour += 12;
|
|
55
|
+
const d = new Date(now);
|
|
56
|
+
d.setUTCHours(hour, minute, 0, 0);
|
|
57
|
+
let t = d.getTime();
|
|
58
|
+
if (t <= now) t += 24 * 60 * 60 * 1000;
|
|
59
|
+
return t;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* When to next allow an auto heartbeat after the Nth consecutive failure
|
|
64
|
+
* (1-based). Limit errors with a stated reset time wait until then (+buffer);
|
|
65
|
+
* everything else doubles from 5min up to a 60min cap.
|
|
66
|
+
*/
|
|
67
|
+
export function failureBackoffUntil(
|
|
68
|
+
err: unknown,
|
|
69
|
+
consecutiveFailures: number,
|
|
70
|
+
now: number,
|
|
71
|
+
): number {
|
|
72
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
73
|
+
if (/session limit|rate limit/i.test(message)) {
|
|
74
|
+
const reset = parseSessionLimitResetMs(message, now);
|
|
75
|
+
if (reset !== null) return reset + LIMIT_RESET_BUFFER_MS;
|
|
76
|
+
}
|
|
77
|
+
const exp = Math.min(
|
|
78
|
+
FAILURE_BACKOFF_BASE_MS * 2 ** Math.max(0, consecutiveFailures - 1),
|
|
79
|
+
FAILURE_BACKOFF_MAX_MS,
|
|
80
|
+
);
|
|
81
|
+
return now + exp;
|
|
82
|
+
}
|
|
83
|
+
|
|
25
84
|
export function initHeartbeat(cfg: HeartbeatConfig): void {
|
|
26
85
|
hb.config = cfg;
|
|
27
86
|
}
|
|
@@ -69,6 +128,9 @@ export function startHeartbeatTimer(intervalMinutes: number): void {
|
|
|
69
128
|
*/
|
|
70
129
|
function runIfDue(intervalMs: number, startup: boolean): void {
|
|
71
130
|
if (hb.running) return;
|
|
131
|
+
// Inside a failure backoff window — stay quiet instead of re-firing the
|
|
132
|
+
// same error every due check. (Logged once, when the window was set.)
|
|
133
|
+
if (Date.now() < hb.backoffUntil) return;
|
|
72
134
|
const lastRun = readHeartbeatState()?.last_run ?? 0;
|
|
73
135
|
if (lastRun <= 0) {
|
|
74
136
|
// Never ran on this install — fire now to establish the cadence.
|
|
@@ -178,6 +240,8 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
|
|
|
178
240
|
status: "idle",
|
|
179
241
|
run_count: previousRunCount + 1,
|
|
180
242
|
});
|
|
243
|
+
hb.consecutiveFailures = 0;
|
|
244
|
+
hb.backoffUntil = 0;
|
|
181
245
|
log(
|
|
182
246
|
"heartbeat",
|
|
183
247
|
`Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`,
|
|
@@ -199,6 +263,22 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
|
|
|
199
263
|
status: "idle",
|
|
200
264
|
run_count: isTimeout ? previousRunCount + 1 : previousRunCount,
|
|
201
265
|
});
|
|
266
|
+
// Timeouts advance last_run (budget consumed), so the cadence itself
|
|
267
|
+
// spaces the next attempt. Every other failure retries against the same
|
|
268
|
+
// last_run — back off so the due check doesn't hammer it every minute.
|
|
269
|
+
if (!isTimeout) {
|
|
270
|
+
hb.consecutiveFailures += 1;
|
|
271
|
+
hb.backoffUntil = failureBackoffUntil(
|
|
272
|
+
err,
|
|
273
|
+
hb.consecutiveFailures,
|
|
274
|
+
Date.now(),
|
|
275
|
+
);
|
|
276
|
+
logWarn(
|
|
277
|
+
"heartbeat",
|
|
278
|
+
`Backing off until ${new Date(hb.backoffUntil).toISOString()} ` +
|
|
279
|
+
`after ${hb.consecutiveFailures} consecutive failure(s)`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
202
282
|
if (trigger === "forced") throw err;
|
|
203
283
|
} finally {
|
|
204
284
|
hb.running = false;
|
|
@@ -71,6 +71,10 @@ export const hb: {
|
|
|
71
71
|
intervalMinutesRef: number;
|
|
72
72
|
config: HeartbeatConfig | null;
|
|
73
73
|
logFileSequence: number;
|
|
74
|
+
/** Consecutive failed runs — drives the failure backoff curve. */
|
|
75
|
+
consecutiveFailures: number;
|
|
76
|
+
/** Epoch ms before which auto runs are suppressed after a failure. */
|
|
77
|
+
backoffUntil: number;
|
|
74
78
|
} = {
|
|
75
79
|
running: false,
|
|
76
80
|
currentRunPromise: null,
|
|
@@ -79,6 +83,8 @@ export const hb: {
|
|
|
79
83
|
intervalMinutesRef: 60,
|
|
80
84
|
config: null,
|
|
81
85
|
logFileSequence: 0,
|
|
86
|
+
consecutiveFailures: 0,
|
|
87
|
+
backoffUntil: 0,
|
|
82
88
|
};
|
|
83
89
|
|
|
84
90
|
// ── State-file I/O ───────────────────────────────────────────────────────────
|