talon-agent 1.40.1 → 1.40.2
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,7 @@ import { getDefaultModel } from "../models/catalog.js";
|
|
|
22
22
|
import type { OneShotAgentParams } from "../types.js";
|
|
23
23
|
import type { Backend } from "../agent-runtime/capabilities.js";
|
|
24
24
|
import { getSoul } from "../soul/service.js";
|
|
25
|
+
import { FailureBackoff } from "./failure-backoff.js";
|
|
25
26
|
|
|
26
27
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -53,6 +54,13 @@ const DREAM_LOGS_DIR = resolve(dirs.logs, "dreams");
|
|
|
53
54
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
54
55
|
|
|
55
56
|
let dreaming = false; // in-process guard (one dream at a time)
|
|
57
|
+
/**
|
|
58
|
+
* A failed dream does not advance last_run, and maybeStartDream runs on
|
|
59
|
+
* every invocation — without a backoff a broken backend gets re-fired on
|
|
60
|
+
* every message (observed live: a model outage produced 403 identical
|
|
61
|
+
* consolidation failures). Exported for tests.
|
|
62
|
+
*/
|
|
63
|
+
export const dreamFailureBackoff = new FailureBackoff();
|
|
56
64
|
let configRef: {
|
|
57
65
|
model?: string;
|
|
58
66
|
dreamModel?: string;
|
|
@@ -101,6 +109,9 @@ export function initDream(cfg: {
|
|
|
101
109
|
export function maybeStartDream(): void {
|
|
102
110
|
if (dreaming) return;
|
|
103
111
|
if (configRef?.enabled === false) return;
|
|
112
|
+
// Inside a failure backoff window — stay quiet instead of re-firing the
|
|
113
|
+
// same error on every invocation. (Logged once, when the window was set.)
|
|
114
|
+
if (dreamFailureBackoff.active()) return;
|
|
104
115
|
|
|
105
116
|
const state = readDreamState();
|
|
106
117
|
const now = Date.now();
|
|
@@ -134,6 +145,7 @@ async function executeDream(trigger: "auto" | "forced"): Promise<void> {
|
|
|
134
145
|
try {
|
|
135
146
|
const dreamLogPath = await runDreamAgent(state?.last_run ?? 0);
|
|
136
147
|
writeDreamState({ last_run: Date.now(), status: "idle" });
|
|
148
|
+
dreamFailureBackoff.succeed();
|
|
137
149
|
log(
|
|
138
150
|
"dream",
|
|
139
151
|
`Memory consolidation complete (${trigger}), log: ${dreamLogPath}`,
|
|
@@ -145,6 +157,14 @@ async function executeDream(trigger: "auto" | "forced"): Promise<void> {
|
|
|
145
157
|
} catch (err) {
|
|
146
158
|
logError("dream", `Memory consolidation failed (${trigger})`, err);
|
|
147
159
|
writeDreamState({ last_run: state?.last_run ?? 0, status: "idle" });
|
|
160
|
+
// A failed dream keeps the old last_run, so it would re-fire on the very
|
|
161
|
+
// next invocation — back off instead (forceDream bypasses the window).
|
|
162
|
+
const until = dreamFailureBackoff.fail(err);
|
|
163
|
+
logWarn(
|
|
164
|
+
"dream",
|
|
165
|
+
`Backing off until ${new Date(until).toISOString()} ` +
|
|
166
|
+
`after ${dreamFailureBackoff.failures} consecutive failure(s)`,
|
|
167
|
+
);
|
|
148
168
|
if (trigger === "forced") throw err;
|
|
149
169
|
} finally {
|
|
150
170
|
dreaming = false;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure backoff for background agents (heartbeat, dream, …).
|
|
3
|
+
*
|
|
4
|
+
* A failed background run typically does NOT advance its `last_run` marker,
|
|
5
|
+
* so whatever cadence check re-evaluates "is a run due?" re-fires the same
|
|
6
|
+
* failing run on every tick — observed live as hundreds of identical
|
|
7
|
+
* failures minutes apart (session-limit nights, model outages, auth
|
|
8
|
+
* breakage). This module centralizes the cure:
|
|
9
|
+
*
|
|
10
|
+
* - generic failures back off exponentially (5 → 10 → 20 → 40 → 60min cap)
|
|
11
|
+
* - session/rate-limit errors that state their own reset time
|
|
12
|
+
* ("… resets 12:20am (UTC)") wait until then (+buffer) instead of
|
|
13
|
+
* guessing
|
|
14
|
+
* - a success clears the window
|
|
15
|
+
*
|
|
16
|
+
* Backoff state is in-memory only: a process restart forgets it, which is
|
|
17
|
+
* fine — the first post-restart attempt either works or re-arms the window.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
|
|
21
|
+
const FAILURE_BACKOFF_MAX_MS = 60 * 60 * 1000;
|
|
22
|
+
/** Safety margin added past a parsed limit-reset time (clock skew, rollout). */
|
|
23
|
+
const LIMIT_RESET_BUFFER_MS = 2 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse the reset wall-clock time out of a session/rate-limit error message,
|
|
27
|
+
* e.g. "You've hit your session limit · resets 12:20am (UTC)" or
|
|
28
|
+
* "… resets 3pm (UTC)". Returns the epoch ms of the NEXT occurrence of that
|
|
29
|
+
* UTC time after `now`, or null when the message doesn't carry one.
|
|
30
|
+
*/
|
|
31
|
+
export function parseSessionLimitResetMs(
|
|
32
|
+
message: string,
|
|
33
|
+
now: number,
|
|
34
|
+
): number | null {
|
|
35
|
+
const m = /resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i.exec(
|
|
36
|
+
message,
|
|
37
|
+
);
|
|
38
|
+
if (!m) return null;
|
|
39
|
+
const rawHour = Number(m[1]);
|
|
40
|
+
const minute = m[2] ? Number(m[2]) : 0;
|
|
41
|
+
if (rawHour < 1 || rawHour > 12 || minute > 59) return null;
|
|
42
|
+
let hour = rawHour % 12;
|
|
43
|
+
if (m[3].toLowerCase() === "pm") hour += 12;
|
|
44
|
+
const d = new Date(now);
|
|
45
|
+
d.setUTCHours(hour, minute, 0, 0);
|
|
46
|
+
let t = d.getTime();
|
|
47
|
+
if (t <= now) t += 24 * 60 * 60 * 1000;
|
|
48
|
+
return t;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* When to next allow a run after the Nth consecutive failure (1-based).
|
|
53
|
+
* Limit errors with a stated reset time wait until then (+buffer);
|
|
54
|
+
* everything else doubles from 5min up to a 60min cap.
|
|
55
|
+
*/
|
|
56
|
+
export function failureBackoffUntil(
|
|
57
|
+
err: unknown,
|
|
58
|
+
consecutiveFailures: number,
|
|
59
|
+
now: number,
|
|
60
|
+
): number {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
if (/session limit|rate limit/i.test(message)) {
|
|
63
|
+
const reset = parseSessionLimitResetMs(message, now);
|
|
64
|
+
if (reset !== null) return reset + LIMIT_RESET_BUFFER_MS;
|
|
65
|
+
}
|
|
66
|
+
const exp = Math.min(
|
|
67
|
+
FAILURE_BACKOFF_BASE_MS * 2 ** Math.max(0, consecutiveFailures - 1),
|
|
68
|
+
FAILURE_BACKOFF_MAX_MS,
|
|
69
|
+
);
|
|
70
|
+
return now + exp;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Per-component backoff holder. Cadence checks gate on `active()`; run
|
|
75
|
+
* completions call `succeed()` / `fail(err)`.
|
|
76
|
+
*/
|
|
77
|
+
export class FailureBackoff {
|
|
78
|
+
private consecutiveFailures = 0;
|
|
79
|
+
private until = 0;
|
|
80
|
+
|
|
81
|
+
/** True while inside the backoff window — the caller should skip firing. */
|
|
82
|
+
active(now = Date.now()): boolean {
|
|
83
|
+
return now < this.until;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Record a failure; returns the epoch ms the window is armed until. */
|
|
87
|
+
fail(err: unknown, now = Date.now()): number {
|
|
88
|
+
this.consecutiveFailures += 1;
|
|
89
|
+
this.until = failureBackoffUntil(err, this.consecutiveFailures, now);
|
|
90
|
+
return this.until;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Record a success — clears the window and the failure streak. */
|
|
94
|
+
succeed(): void {
|
|
95
|
+
this.consecutiveFailures = 0;
|
|
96
|
+
this.until = 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get failures(): number {
|
|
100
|
+
return this.consecutiveFailures;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -22,65 +22,6 @@ 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
|
-
|
|
84
25
|
export function initHeartbeat(cfg: HeartbeatConfig): void {
|
|
85
26
|
hb.config = cfg;
|
|
86
27
|
}
|
|
@@ -130,7 +71,7 @@ function runIfDue(intervalMs: number, startup: boolean): void {
|
|
|
130
71
|
if (hb.running) return;
|
|
131
72
|
// Inside a failure backoff window — stay quiet instead of re-firing the
|
|
132
73
|
// same error every due check. (Logged once, when the window was set.)
|
|
133
|
-
if (
|
|
74
|
+
if (hb.failureBackoff.active()) return;
|
|
134
75
|
const lastRun = readHeartbeatState()?.last_run ?? 0;
|
|
135
76
|
if (lastRun <= 0) {
|
|
136
77
|
// Never ran on this install — fire now to establish the cadence.
|
|
@@ -240,8 +181,7 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
|
|
|
240
181
|
status: "idle",
|
|
241
182
|
run_count: previousRunCount + 1,
|
|
242
183
|
});
|
|
243
|
-
hb.
|
|
244
|
-
hb.backoffUntil = 0;
|
|
184
|
+
hb.failureBackoff.succeed();
|
|
245
185
|
log(
|
|
246
186
|
"heartbeat",
|
|
247
187
|
`Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`,
|
|
@@ -267,16 +207,11 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
|
|
|
267
207
|
// spaces the next attempt. Every other failure retries against the same
|
|
268
208
|
// last_run — back off so the due check doesn't hammer it every minute.
|
|
269
209
|
if (!isTimeout) {
|
|
270
|
-
hb.
|
|
271
|
-
hb.backoffUntil = failureBackoffUntil(
|
|
272
|
-
err,
|
|
273
|
-
hb.consecutiveFailures,
|
|
274
|
-
Date.now(),
|
|
275
|
-
);
|
|
210
|
+
const until = hb.failureBackoff.fail(err);
|
|
276
211
|
logWarn(
|
|
277
212
|
"heartbeat",
|
|
278
|
-
`Backing off until ${new Date(
|
|
279
|
-
`after ${hb.
|
|
213
|
+
`Backing off until ${new Date(until).toISOString()} ` +
|
|
214
|
+
`after ${hb.failureBackoff.failures} consecutive failure(s)`,
|
|
280
215
|
);
|
|
281
216
|
}
|
|
282
217
|
if (trigger === "forced") throw err;
|
|
@@ -10,6 +10,7 @@ import { files as pathFiles } from "../../../util/paths.js";
|
|
|
10
10
|
import { kvGet, kvSet } from "../../../storage/kv.js";
|
|
11
11
|
import { importLegacyJson } from "../../../storage/legacy-import.js";
|
|
12
12
|
import type { Backend } from "../../agent-runtime/capabilities.js";
|
|
13
|
+
import { FailureBackoff } from "../failure-backoff.js";
|
|
13
14
|
|
|
14
15
|
export type HeartbeatState = {
|
|
15
16
|
/** Unix millisecond timestamp of the last successfully completed heartbeat run. */
|
|
@@ -71,10 +72,8 @@ export const hb: {
|
|
|
71
72
|
intervalMinutesRef: number;
|
|
72
73
|
config: HeartbeatConfig | null;
|
|
73
74
|
logFileSequence: number;
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
/** Epoch ms before which auto runs are suppressed after a failure. */
|
|
77
|
-
backoffUntil: number;
|
|
75
|
+
/** Suppresses auto runs after failures (see failure-backoff.ts). */
|
|
76
|
+
failureBackoff: FailureBackoff;
|
|
78
77
|
} = {
|
|
79
78
|
running: false,
|
|
80
79
|
currentRunPromise: null,
|
|
@@ -83,8 +82,7 @@ export const hb: {
|
|
|
83
82
|
intervalMinutesRef: 60,
|
|
84
83
|
config: null,
|
|
85
84
|
logFileSequence: 0,
|
|
86
|
-
|
|
87
|
-
backoffUntil: 0,
|
|
85
|
+
failureBackoff: new FailureBackoff(),
|
|
88
86
|
};
|
|
89
87
|
|
|
90
88
|
// ── State-file I/O ───────────────────────────────────────────────────────────
|