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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.40.0",
3
+ "version": "1.40.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -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 ───────────────────────────────────────────────────────────