talon-agent 1.40.0 → 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
|
+
}
|
|
@@ -69,6 +69,9 @@ export function startHeartbeatTimer(intervalMinutes: number): void {
|
|
|
69
69
|
*/
|
|
70
70
|
function runIfDue(intervalMs: number, startup: boolean): void {
|
|
71
71
|
if (hb.running) return;
|
|
72
|
+
// Inside a failure backoff window — stay quiet instead of re-firing the
|
|
73
|
+
// same error every due check. (Logged once, when the window was set.)
|
|
74
|
+
if (hb.failureBackoff.active()) return;
|
|
72
75
|
const lastRun = readHeartbeatState()?.last_run ?? 0;
|
|
73
76
|
if (lastRun <= 0) {
|
|
74
77
|
// Never ran on this install — fire now to establish the cadence.
|
|
@@ -178,6 +181,7 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
|
|
|
178
181
|
status: "idle",
|
|
179
182
|
run_count: previousRunCount + 1,
|
|
180
183
|
});
|
|
184
|
+
hb.failureBackoff.succeed();
|
|
181
185
|
log(
|
|
182
186
|
"heartbeat",
|
|
183
187
|
`Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`,
|
|
@@ -199,6 +203,17 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
|
|
|
199
203
|
status: "idle",
|
|
200
204
|
run_count: isTimeout ? previousRunCount + 1 : previousRunCount,
|
|
201
205
|
});
|
|
206
|
+
// Timeouts advance last_run (budget consumed), so the cadence itself
|
|
207
|
+
// spaces the next attempt. Every other failure retries against the same
|
|
208
|
+
// last_run — back off so the due check doesn't hammer it every minute.
|
|
209
|
+
if (!isTimeout) {
|
|
210
|
+
const until = hb.failureBackoff.fail(err);
|
|
211
|
+
logWarn(
|
|
212
|
+
"heartbeat",
|
|
213
|
+
`Backing off until ${new Date(until).toISOString()} ` +
|
|
214
|
+
`after ${hb.failureBackoff.failures} consecutive failure(s)`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
202
217
|
if (trigger === "forced") throw err;
|
|
203
218
|
} finally {
|
|
204
219
|
hb.running = false;
|
|
@@ -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,6 +72,8 @@ export const hb: {
|
|
|
71
72
|
intervalMinutesRef: number;
|
|
72
73
|
config: HeartbeatConfig | null;
|
|
73
74
|
logFileSequence: number;
|
|
75
|
+
/** Suppresses auto runs after failures (see failure-backoff.ts). */
|
|
76
|
+
failureBackoff: FailureBackoff;
|
|
74
77
|
} = {
|
|
75
78
|
running: false,
|
|
76
79
|
currentRunPromise: null,
|
|
@@ -79,6 +82,7 @@ export const hb: {
|
|
|
79
82
|
intervalMinutesRef: 60,
|
|
80
83
|
config: null,
|
|
81
84
|
logFileSequence: 0,
|
|
85
|
+
failureBackoff: new FailureBackoff(),
|
|
82
86
|
};
|
|
83
87
|
|
|
84
88
|
// ── State-file I/O ───────────────────────────────────────────────────────────
|