talon-agent 3.33.3 → 3.34.0
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 +6 -5
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/plugins/github/provision.ts +1 -1
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schedule half of a cron job — cadence, lifecycle bounds, run cap,
|
|
3
|
+
* catch-up policy, and the query overrides — parsed and validated once for
|
|
4
|
+
* both `create_cron_job` and `edit_cron_job`.
|
|
5
|
+
*
|
|
6
|
+
* The two actions read the same body fields but with different absence
|
|
7
|
+
* semantics: on create an absent field takes its default, on edit an absent
|
|
8
|
+
* field is left alone and a blank one (`null` / `""`) clears the stored
|
|
9
|
+
* value. `parseCronSpec` folds both into one pass: pass `existing` to parse
|
|
10
|
+
* as an edit. Cross-field rules (end after start, provider needs model,
|
|
11
|
+
* overrides only on query jobs) are checked against the *effective* job —
|
|
12
|
+
* the merge of what's stored and what's changing — so a partial edit is
|
|
13
|
+
* judged on the job it produces, not the fields it touches.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
validateCronExpression,
|
|
18
|
+
type CatchupPolicy,
|
|
19
|
+
type CronJob,
|
|
20
|
+
type CronJobType,
|
|
21
|
+
} from "../../storage/cron-store.js";
|
|
22
|
+
|
|
23
|
+
/** The scheduler ticks once a minute, so sub-minute intervals are meaningless. */
|
|
24
|
+
const MIN_INTERVAL_SECONDS = 60;
|
|
25
|
+
const MAX_CONTENT_LENGTH = 10_000;
|
|
26
|
+
const CATCHUP_POLICIES = new Set<CatchupPolicy>(["skip", "once", "all"]);
|
|
27
|
+
|
|
28
|
+
/** The job fields the spec covers; everything else is identity or telemetry. */
|
|
29
|
+
type CronSpec = Pick<CronJob, "name" | "type" | "content" | "catchup"> &
|
|
30
|
+
Partial<
|
|
31
|
+
Pick<
|
|
32
|
+
CronJob,
|
|
33
|
+
| "enabled"
|
|
34
|
+
| "timezone"
|
|
35
|
+
| "schedule"
|
|
36
|
+
| "everyMs"
|
|
37
|
+
| "startAt"
|
|
38
|
+
| "endAt"
|
|
39
|
+
| "maxRuns"
|
|
40
|
+
| "model"
|
|
41
|
+
| "provider"
|
|
42
|
+
| "instructions"
|
|
43
|
+
>
|
|
44
|
+
>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The fields an edit touches, in body order. A key present with value
|
|
48
|
+
* `undefined` clears that field (`updateCronJob` has Object.assign
|
|
49
|
+
* semantics), so key *presence* is the signal — not truthiness.
|
|
50
|
+
*/
|
|
51
|
+
type CronSpecUpdates = Partial<CronSpec>;
|
|
52
|
+
|
|
53
|
+
export type ParsedCronSpec =
|
|
54
|
+
| {
|
|
55
|
+
ok: true;
|
|
56
|
+
/** What to write: the full spec on create, the touched fields on edit. */
|
|
57
|
+
updates: CronSpecUpdates;
|
|
58
|
+
/** The job as it will be after the write — what the cross-field rules saw. */
|
|
59
|
+
effective: CronSpec;
|
|
60
|
+
}
|
|
61
|
+
| { ok: false; error: string };
|
|
62
|
+
|
|
63
|
+
/** True for a body field that was actually supplied (not absent/blank). */
|
|
64
|
+
function provided(v: unknown): boolean {
|
|
65
|
+
return v !== undefined && v !== null && v !== "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse an instant given as an ISO-8601 string or epoch-ms number into epoch
|
|
70
|
+
* ms. Returns undefined when the field is absent or unparseable — callers
|
|
71
|
+
* distinguish the two via `provided()`.
|
|
72
|
+
*/
|
|
73
|
+
function parseInstant(v: unknown): number | undefined {
|
|
74
|
+
if (!provided(v)) return undefined;
|
|
75
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
|
|
76
|
+
const s = String(v).trim();
|
|
77
|
+
if (/^\d+$/.test(s)) return Number(s);
|
|
78
|
+
const d = Date.parse(s);
|
|
79
|
+
return Number.isFinite(d) ? d : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What one parse pass works on; each section parser fills `updates`. */
|
|
83
|
+
type Parse = {
|
|
84
|
+
body: Record<string, unknown>;
|
|
85
|
+
existing: CronJob | undefined;
|
|
86
|
+
editing: boolean;
|
|
87
|
+
/** On create a blank is absent; on edit it is a request to clear. */
|
|
88
|
+
touched: (key: string) => boolean;
|
|
89
|
+
updates: CronSpecUpdates;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** A section parser returns an error message, or null when its fields are fine. */
|
|
93
|
+
type Section = (p: Parse) => string | null;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The job the write produces. On create `updates` is the whole spec (name,
|
|
97
|
+
* type, content and catchup are always filled in), so the cast only papers
|
|
98
|
+
* over what the parse order already guarantees.
|
|
99
|
+
*/
|
|
100
|
+
const merge = (p: Parse) => ({ ...p.existing, ...p.updates }) as CronSpec;
|
|
101
|
+
|
|
102
|
+
const parsePayload: Section = ({ body, editing, touched, updates }) => {
|
|
103
|
+
if (editing) {
|
|
104
|
+
if (touched("name")) updates.name = String(body.name);
|
|
105
|
+
if (touched("content")) updates.content = String(body.content);
|
|
106
|
+
if (touched("enabled")) updates.enabled = Boolean(body.enabled);
|
|
107
|
+
if (touched("type")) updates.type = String(body.type) as CronJobType;
|
|
108
|
+
} else {
|
|
109
|
+
updates.name = String(body.name ?? "Unnamed job");
|
|
110
|
+
updates.type = (body.type as CronJobType) ?? "message";
|
|
111
|
+
updates.content = String(body.content ?? "");
|
|
112
|
+
if (!updates.content) return "Missing content";
|
|
113
|
+
if (updates.content.length > MAX_CONTENT_LENGTH)
|
|
114
|
+
return "Content too long (max 10,000 chars)";
|
|
115
|
+
}
|
|
116
|
+
if (touched("timezone"))
|
|
117
|
+
updates.timezone = body.timezone ? String(body.timezone) : undefined;
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Exactly one of `schedule` (cron expression) or `every_seconds` (fixed
|
|
123
|
+
* interval). On edit, setting one switches mode and clears the other.
|
|
124
|
+
*/
|
|
125
|
+
const parseCadence: Section = ({
|
|
126
|
+
body,
|
|
127
|
+
existing,
|
|
128
|
+
editing,
|
|
129
|
+
touched,
|
|
130
|
+
updates,
|
|
131
|
+
}) => {
|
|
132
|
+
const schedule = provided(body.schedule) ? String(body.schedule) : undefined;
|
|
133
|
+
const hasEvery = provided(body.every_seconds);
|
|
134
|
+
if (!editing && !schedule && !hasEvery)
|
|
135
|
+
return "Provide either 'schedule' (a cron expression) or 'every_seconds' (a fixed interval).";
|
|
136
|
+
if (schedule && hasEvery)
|
|
137
|
+
return "Provide only one of 'schedule' or 'every_seconds', not both.";
|
|
138
|
+
if (schedule) {
|
|
139
|
+
const timezone = touched("timezone")
|
|
140
|
+
? updates.timezone
|
|
141
|
+
: existing?.timezone;
|
|
142
|
+
const validation = validateCronExpression(schedule, timezone);
|
|
143
|
+
if (!validation.valid)
|
|
144
|
+
return `Invalid cron expression: ${validation.error}`;
|
|
145
|
+
updates.schedule = schedule;
|
|
146
|
+
if (editing) updates.everyMs = undefined;
|
|
147
|
+
}
|
|
148
|
+
if (hasEvery) {
|
|
149
|
+
const everySeconds = Number(body.every_seconds);
|
|
150
|
+
if (!Number.isFinite(everySeconds) || everySeconds < MIN_INTERVAL_SECONDS)
|
|
151
|
+
return `'every_seconds' must be a number >= ${MIN_INTERVAL_SECONDS}${
|
|
152
|
+
editing ? "" : " (the scheduler ticks once a minute)"
|
|
153
|
+
}.`;
|
|
154
|
+
updates.everyMs = Math.round(everySeconds * 1000);
|
|
155
|
+
if (editing) updates.schedule = undefined;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Lifecycle bounds; on edit pass null/"" to clear one. */
|
|
161
|
+
const parseBounds: Section = (p) => {
|
|
162
|
+
const { body, editing, touched, updates } = p;
|
|
163
|
+
const hint = editing ? "" : " (use an ISO-8601 timestamp or epoch ms)";
|
|
164
|
+
if (touched("start_at")) {
|
|
165
|
+
const startAt = parseInstant(body.start_at);
|
|
166
|
+
if (provided(body.start_at) && startAt === undefined)
|
|
167
|
+
return `Could not parse 'start_at'${hint}.`;
|
|
168
|
+
updates.startAt = startAt;
|
|
169
|
+
}
|
|
170
|
+
if (touched("end_at")) {
|
|
171
|
+
const endAt = parseInstant(body.end_at);
|
|
172
|
+
if (provided(body.end_at) && endAt === undefined)
|
|
173
|
+
return `Could not parse 'end_at'${hint}.`;
|
|
174
|
+
updates.endAt = endAt;
|
|
175
|
+
}
|
|
176
|
+
const { startAt, endAt } = merge(p);
|
|
177
|
+
if (startAt !== undefined && endAt !== undefined && endAt <= startAt)
|
|
178
|
+
return "'end_at' must be after 'start_at'.";
|
|
179
|
+
if (updates.endAt !== undefined && updates.endAt <= Date.now())
|
|
180
|
+
return "'end_at' is in the past — the job would never run.";
|
|
181
|
+
return null;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Run cap. `once: true` is sugar for max_runs = 1 (one-shot). */
|
|
185
|
+
const parseRunCap: Section = ({ body, touched, updates }) => {
|
|
186
|
+
if (body.once === true) {
|
|
187
|
+
updates.maxRuns = 1;
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
if (!touched("max_runs")) return null;
|
|
191
|
+
if (!provided(body.max_runs)) {
|
|
192
|
+
updates.maxRuns = undefined;
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const m = Number(body.max_runs);
|
|
196
|
+
if (!Number.isInteger(m) || m < 1)
|
|
197
|
+
return "'max_runs' must be a positive integer.";
|
|
198
|
+
updates.maxRuns = m;
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Missed-run catch-up policy. New jobs default to "once": a run that came
|
|
204
|
+
* due while Talon was down (or while the scheduler was wedged) replays a
|
|
205
|
+
* single time at startup instead of being lost silently — a live audit
|
|
206
|
+
* found one-shot reminders that missed their date under the old "skip"
|
|
207
|
+
* default and quietly rolled over a full year. Explicit "skip" remains
|
|
208
|
+
* available for jobs where a late run is worthless.
|
|
209
|
+
*/
|
|
210
|
+
const parseCatchup: Section = ({ body, editing, touched, updates }) => {
|
|
211
|
+
if (!touched("catchup")) {
|
|
212
|
+
if (!editing) updates.catchup = "once";
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const catchup = String(body.catchup) as CatchupPolicy;
|
|
216
|
+
if (!CATCHUP_POLICIES.has(catchup))
|
|
217
|
+
return "'catchup' must be one of: skip, once, all.";
|
|
218
|
+
updates.catchup = catchup;
|
|
219
|
+
return null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Model / provider / instructions only make sense for "query" jobs (a
|
|
224
|
+
* "message" job just sends text — no model runs), and a provider override
|
|
225
|
+
* needs a model to pick on it.
|
|
226
|
+
*/
|
|
227
|
+
const parseOverrides: Section = (p) => {
|
|
228
|
+
const { body, touched, updates } = p;
|
|
229
|
+
for (const key of ["model", "provider", "instructions"] as const) {
|
|
230
|
+
if (touched(key))
|
|
231
|
+
updates[key] = provided(body[key]) ? String(body[key]) : undefined;
|
|
232
|
+
}
|
|
233
|
+
const { type, model, provider, instructions } = merge(p);
|
|
234
|
+
if (type !== "query" && (model || provider || instructions))
|
|
235
|
+
return "Model/provider/instructions only apply to 'query' jobs.";
|
|
236
|
+
if (provider && !model)
|
|
237
|
+
return "A 'provider' override also requires a 'model'.";
|
|
238
|
+
return null;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const SECTIONS: Section[] = [
|
|
242
|
+
parsePayload,
|
|
243
|
+
parseCadence,
|
|
244
|
+
parseBounds,
|
|
245
|
+
parseRunCap,
|
|
246
|
+
parseCatchup,
|
|
247
|
+
parseOverrides,
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Validate a create/edit body's schedule fields and normalise them into job
|
|
252
|
+
* fields. Without `existing` this is a create: cadence is required, blanks
|
|
253
|
+
* are absent, defaults fill in. With `existing` it is an edit of that job:
|
|
254
|
+
* only supplied fields are emitted, blanks clear.
|
|
255
|
+
*/
|
|
256
|
+
export function parseCronSpec(
|
|
257
|
+
body: Record<string, unknown>,
|
|
258
|
+
existing?: CronJob,
|
|
259
|
+
): ParsedCronSpec {
|
|
260
|
+
const editing = existing !== undefined;
|
|
261
|
+
const p: Parse = {
|
|
262
|
+
body,
|
|
263
|
+
existing,
|
|
264
|
+
editing,
|
|
265
|
+
touched: (key) => (editing ? body[key] !== undefined : provided(body[key])),
|
|
266
|
+
updates: {},
|
|
267
|
+
};
|
|
268
|
+
for (const section of SECTIONS) {
|
|
269
|
+
const error = section(p);
|
|
270
|
+
if (error) return { ok: false, error };
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, updates: p.updates, effective: merge(p) };
|
|
273
|
+
}
|
|
@@ -12,7 +12,8 @@ import { toYMD } from "../../../util/time.js";
|
|
|
12
12
|
import { getDefaultModel } from "../../models/catalog.js";
|
|
13
13
|
import { loadSystemTemplate } from "../../prompt/templates.js";
|
|
14
14
|
import { formatGoal, getOpenGoals } from "../../../storage/goal-store.js";
|
|
15
|
-
import { taskTable } from "../../tasks/index.js";
|
|
15
|
+
import { taskTable, type TaskHandle } from "../../tasks/index.js";
|
|
16
|
+
import type { Backend } from "../../agent-runtime/capabilities.js";
|
|
16
17
|
import type { OneShotAgentParams } from "../../types.js";
|
|
17
18
|
import { resolveBackgroundEffort } from "../effort.js";
|
|
18
19
|
import { hb } from "./state.js";
|
|
@@ -102,29 +103,25 @@ function renderGoalsBlock(): { text: string; count: number } {
|
|
|
102
103
|
return { text, count };
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
/** Everything the seeded heartbeat.md template interpolates. */
|
|
107
|
+
type HeartbeatPromptInputs = {
|
|
108
|
+
lastRunIso: string;
|
|
109
|
+
runCount: number;
|
|
110
|
+
workspace: string;
|
|
111
|
+
logsDir: string;
|
|
112
|
+
memoryFile: string;
|
|
113
|
+
instructionsFile: string;
|
|
114
|
+
dailyMemoryFile: string;
|
|
115
|
+
};
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const instructionsFile = resolve(workspace, "heartbeat-instructions.md");
|
|
121
|
-
const dailyMemoryFile = resolve(dirs.dailyMemory, `${toYMD(new Date())}.md`);
|
|
122
|
-
|
|
123
|
-
// Load prompt template from the prompts directory (seeded to ~/.talon/prompts/)
|
|
117
|
+
/**
|
|
118
|
+
* Load the user's heartbeat.md (seeded to ~/.talon/prompts/) and fill its
|
|
119
|
+
* placeholders. Seeded copies are never rewritten once the user owns them,
|
|
120
|
+
* so two older vintages get their missing sections appended instead.
|
|
121
|
+
*/
|
|
122
|
+
function renderHeartbeatPrompt(inputs: HeartbeatPromptInputs): string {
|
|
124
123
|
const promptPath = resolve(dirs.prompts, "heartbeat.md");
|
|
125
|
-
|
|
126
124
|
const goalsBlock = renderGoalsBlock();
|
|
127
|
-
|
|
128
125
|
let prompt: string;
|
|
129
126
|
let hadGoalsVar: boolean;
|
|
130
127
|
let hadStateVar: boolean;
|
|
@@ -133,14 +130,14 @@ export async function runHeartbeatAgent(
|
|
|
133
130
|
hadGoalsVar = raw.includes("{{goals}}");
|
|
134
131
|
hadStateVar = raw.includes("{{stateFile}}");
|
|
135
132
|
prompt = raw
|
|
136
|
-
.replace(/\{\{workspace\}\}/g, workspace)
|
|
137
|
-
.replace(/\{\{logsDir\}\}/g, logsDir)
|
|
138
|
-
.replace(/\{\{lastRunIso\}\}/g, lastRunIso)
|
|
139
|
-
.replace(/\{\{memoryFile\}\}/g, memoryFile)
|
|
133
|
+
.replace(/\{\{workspace\}\}/g, inputs.workspace)
|
|
134
|
+
.replace(/\{\{logsDir\}\}/g, inputs.logsDir)
|
|
135
|
+
.replace(/\{\{lastRunIso\}\}/g, inputs.lastRunIso)
|
|
136
|
+
.replace(/\{\{memoryFile\}\}/g, inputs.memoryFile)
|
|
140
137
|
.replace(/\{\{stateFile\}\}/g, pathFiles.state)
|
|
141
|
-
.replace(/\{\{instructionsFile\}\}/g, instructionsFile)
|
|
142
|
-
.replace(/\{\{dailyMemoryFile\}\}/g, dailyMemoryFile)
|
|
143
|
-
.replace(/\{\{runCount\}\}/g, String(runCount))
|
|
138
|
+
.replace(/\{\{instructionsFile\}\}/g, inputs.instructionsFile)
|
|
139
|
+
.replace(/\{\{dailyMemoryFile\}\}/g, inputs.dailyMemoryFile)
|
|
140
|
+
.replace(/\{\{runCount\}\}/g, String(inputs.runCount))
|
|
144
141
|
.replace(/\{\{intervalMinutes\}\}/g, String(hb.intervalMinutesRef))
|
|
145
142
|
.replace(/\{\{goals\}\}/g, goalsBlock.text);
|
|
146
143
|
} catch {
|
|
@@ -161,8 +158,6 @@ export async function runHeartbeatAgent(
|
|
|
161
158
|
// Same vintage problem for the memory/state split: a seeded heartbeat.md
|
|
162
159
|
// from before it still instructs the agent to write memory.md, which is
|
|
163
160
|
// how status snapshots accreted in the durable store in the first place.
|
|
164
|
-
// Append the ownership rules so the split holds regardless of template
|
|
165
|
-
// vintage — the seeded copy is never rewritten once the user owns it.
|
|
166
161
|
if (!hadStateVar) {
|
|
167
162
|
logWarn(
|
|
168
163
|
"heartbeat",
|
|
@@ -172,33 +167,20 @@ export async function runHeartbeatAgent(
|
|
|
172
167
|
prompt += `\n\n${loadSystemTemplate("heartbeat-agent", {
|
|
173
168
|
mode: "state-fallback",
|
|
174
169
|
stateFile: pathFiles.state,
|
|
175
|
-
memoryFile,
|
|
170
|
+
memoryFile: inputs.memoryFile,
|
|
176
171
|
}).trim()}`;
|
|
177
172
|
}
|
|
173
|
+
return prompt;
|
|
174
|
+
}
|
|
178
175
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Effort is resolved against the heartbeat backend's catalog, not just
|
|
190
|
-
// copied from config — a level the model doesn't offer is dropped with a
|
|
191
|
-
// reason rather than handed to the SDK.
|
|
192
|
-
const effort = await resolveBackgroundEffort({
|
|
193
|
-
requested: config.heartbeatEffort,
|
|
194
|
-
model,
|
|
195
|
-
backend,
|
|
196
|
-
});
|
|
197
|
-
if (effort.dropped) {
|
|
198
|
-
logWarn("heartbeat", effort.dropped);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Set up heartbeat log file
|
|
176
|
+
/** Create this run's log file and write its header; returns the path. */
|
|
177
|
+
async function openHeartbeatLog(
|
|
178
|
+
runCount: number,
|
|
179
|
+
lastRunIso: string,
|
|
180
|
+
model: string,
|
|
181
|
+
effort: { effort?: string; dropped?: string },
|
|
182
|
+
prompt: string,
|
|
183
|
+
): Promise<string> {
|
|
202
184
|
const heartbeatLogFile = await createHeartbeatLogFile();
|
|
203
185
|
await appendHeartbeatLog(
|
|
204
186
|
heartbeatLogFile,
|
|
@@ -219,32 +201,23 @@ export async function runHeartbeatAgent(
|
|
|
219
201
|
heartbeatLogFile,
|
|
220
202
|
`**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`,
|
|
221
203
|
);
|
|
204
|
+
return heartbeatLogFile;
|
|
205
|
+
}
|
|
222
206
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
prompt,
|
|
238
|
-
systemPrompt: buildHeartbeatSystemPrompt(),
|
|
239
|
-
workspace,
|
|
240
|
-
model,
|
|
241
|
-
...(effort.effort ? { reasoningEffort: effort.effort } : {}),
|
|
242
|
-
contextLabel: "heartbeat",
|
|
243
|
-
abortController,
|
|
244
|
-
appendLog: (text) => appendHeartbeatLog(heartbeatLogFile, text),
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
// Timeout that requests eviction (graceful first, force-kill on grace exit).
|
|
207
|
+
/**
|
|
208
|
+
* Run the one-shot agent under the heartbeat's soft timeout. On timeout the
|
|
209
|
+
* abort signal goes out, the backend gets a bounded grace window to clean
|
|
210
|
+
* up, and a backend that ignores it is asked to evict its orphans; either
|
|
211
|
+
* way the error propagates so the caller releases the lock.
|
|
212
|
+
*/
|
|
213
|
+
async function runOneShotWithTimeout(
|
|
214
|
+
background: NonNullable<Backend["background"]>,
|
|
215
|
+
params: OneShotAgentParams,
|
|
216
|
+
abortController: AbortController,
|
|
217
|
+
task: TaskHandle,
|
|
218
|
+
runCount: number,
|
|
219
|
+
heartbeatLogFile: string,
|
|
220
|
+
): Promise<Awaited<ReturnType<typeof background.runOneShotAgent>>> {
|
|
248
221
|
let timeoutFired = false;
|
|
249
222
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
250
223
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
@@ -262,7 +235,7 @@ export async function runHeartbeatAgent(
|
|
|
262
235
|
});
|
|
263
236
|
|
|
264
237
|
const agentPromise = (async () => {
|
|
265
|
-
const usage = await background.runOneShotAgent(
|
|
238
|
+
const usage = await background.runOneShotAgent(params);
|
|
266
239
|
await appendHeartbeatLog(
|
|
267
240
|
heartbeatLogFile,
|
|
268
241
|
`\n---\n**Heartbeat #${runCount} completed at ${new Date().toISOString()}**\n`,
|
|
@@ -270,9 +243,8 @@ export async function runHeartbeatAgent(
|
|
|
270
243
|
return usage;
|
|
271
244
|
})();
|
|
272
245
|
|
|
273
|
-
let usage: Awaited<typeof agentPromise>;
|
|
274
246
|
try {
|
|
275
|
-
|
|
247
|
+
return await Promise.race([agentPromise, timeoutPromise]);
|
|
276
248
|
} catch (err) {
|
|
277
249
|
// Snapshot timeout state and clear the timer immediately, BEFORE any awaits
|
|
278
250
|
// in the error-handling path. Otherwise the timer can fire during the async
|
|
@@ -289,28 +261,7 @@ export async function runHeartbeatAgent(
|
|
|
289
261
|
`\n---\n**Heartbeat #${runCount} FAILED at ${new Date().toISOString()}:** ${err}\n`,
|
|
290
262
|
);
|
|
291
263
|
if (wasTimeout) {
|
|
292
|
-
|
|
293
|
-
// signal — but never wait indefinitely. If the backend ignores the abort,
|
|
294
|
-
// release the lock anyway and ask it to evict any orphan subprocesses.
|
|
295
|
-
const settled = await raceWithTimeout(
|
|
296
|
-
agentPromise.catch(() => "settled"),
|
|
297
|
-
heartbeatAbortGraceMs(),
|
|
298
|
-
);
|
|
299
|
-
if (settled === "timed_out") {
|
|
300
|
-
logWarn(
|
|
301
|
-
"heartbeat",
|
|
302
|
-
`Heartbeat #${runCount} backend ignored abort after ${heartbeatAbortGraceMs()}ms — releasing lock and evicting orphan subprocesses`,
|
|
303
|
-
);
|
|
304
|
-
// Fire-and-forget — we don't block the next heartbeat on subprocess
|
|
305
|
-
// cleanup. Backends that don't spawn per-run subprocesses leave
|
|
306
|
-
// evictOrphanSubprocesses unimplemented; that's fine.
|
|
307
|
-
const evict = background.evictOrphanSubprocesses;
|
|
308
|
-
if (evict) {
|
|
309
|
-
evict("heartbeat").catch((sweepErr: unknown) => {
|
|
310
|
-
logError("heartbeat", "Orphan subprocess sweep failed", sweepErr);
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}
|
|
264
|
+
await evictAfterIgnoredAbort(background, agentPromise, runCount);
|
|
314
265
|
} else {
|
|
315
266
|
// Non-timeout failure path — agentPromise has already settled.
|
|
316
267
|
await agentPromise.catch(() => {});
|
|
@@ -321,7 +272,119 @@ export async function runHeartbeatAgent(
|
|
|
321
272
|
// resolution of Promise.race() needs this too.
|
|
322
273
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
323
274
|
}
|
|
275
|
+
}
|
|
324
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Give the backend a bounded grace window to clean up after the abort
|
|
279
|
+
* signal — but never wait indefinitely. If the backend ignores the abort,
|
|
280
|
+
* release the lock anyway and ask it to evict any orphan subprocesses.
|
|
281
|
+
*/
|
|
282
|
+
async function evictAfterIgnoredAbort(
|
|
283
|
+
background: NonNullable<Backend["background"]>,
|
|
284
|
+
agentPromise: Promise<unknown>,
|
|
285
|
+
runCount: number,
|
|
286
|
+
): Promise<void> {
|
|
287
|
+
const settled = await raceWithTimeout(
|
|
288
|
+
agentPromise.catch(() => "settled"),
|
|
289
|
+
heartbeatAbortGraceMs(),
|
|
290
|
+
);
|
|
291
|
+
if (settled !== "timed_out") return;
|
|
292
|
+
logWarn(
|
|
293
|
+
"heartbeat",
|
|
294
|
+
`Heartbeat #${runCount} backend ignored abort after ${heartbeatAbortGraceMs()}ms — releasing lock and evicting orphan subprocesses`,
|
|
295
|
+
);
|
|
296
|
+
// Fire-and-forget — we don't block the next heartbeat on subprocess
|
|
297
|
+
// cleanup. Backends that don't spawn per-run subprocesses leave
|
|
298
|
+
// evictOrphanSubprocesses unimplemented; that's fine.
|
|
299
|
+
const evict = background.evictOrphanSubprocesses;
|
|
300
|
+
if (evict) {
|
|
301
|
+
evict("heartbeat").catch((sweepErr: unknown) => {
|
|
302
|
+
logError("heartbeat", "Orphan subprocess sweep failed", sweepErr);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function runHeartbeatAgent(
|
|
308
|
+
lastRunTimestamp: number,
|
|
309
|
+
runCount: number,
|
|
310
|
+
): Promise<string> {
|
|
311
|
+
const config = hb.config;
|
|
312
|
+
if (!config) {
|
|
313
|
+
throw new Error("Heartbeat agent not initialized");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const lastRunIso =
|
|
317
|
+
lastRunTimestamp > 0 ? new Date(lastRunTimestamp).toISOString() : "never";
|
|
318
|
+
const workspace = config.workspace ?? dirs.workspace;
|
|
319
|
+
const memoryFile = pathFiles.memory;
|
|
320
|
+
const prompt = renderHeartbeatPrompt({
|
|
321
|
+
lastRunIso,
|
|
322
|
+
runCount,
|
|
323
|
+
workspace,
|
|
324
|
+
logsDir: dirs.logs,
|
|
325
|
+
memoryFile,
|
|
326
|
+
instructionsFile: resolve(workspace, "heartbeat-instructions.md"),
|
|
327
|
+
dailyMemoryFile: resolve(dirs.dailyMemory, `${toYMD(new Date())}.md`),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const model = config.heartbeatModel ?? config.model ?? getDefaultModel();
|
|
331
|
+
const backend = config.getBackend?.() ?? null;
|
|
332
|
+
const background = backend?.background;
|
|
333
|
+
if (!background) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
"Heartbeat requires a backend that implements the background capability",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Effort is resolved against the heartbeat backend's catalog, not just
|
|
340
|
+
// copied from config — a level the model doesn't offer is dropped with a
|
|
341
|
+
// reason rather than handed to the SDK.
|
|
342
|
+
const effort = await resolveBackgroundEffort({
|
|
343
|
+
requested: config.heartbeatEffort,
|
|
344
|
+
model,
|
|
345
|
+
backend,
|
|
346
|
+
});
|
|
347
|
+
if (effort.dropped) {
|
|
348
|
+
logWarn("heartbeat", effort.dropped);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const heartbeatLogFile = await openHeartbeatLog(
|
|
352
|
+
runCount,
|
|
353
|
+
lastRunIso,
|
|
354
|
+
model,
|
|
355
|
+
effort,
|
|
356
|
+
prompt,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// AbortController is the canonical way to signal a cancellation to a backend.
|
|
360
|
+
// .abort() should tear down any spawned subprocess (Claude SDK) or stop
|
|
361
|
+
// streaming (Kilo/OpenCode). We defend against backends that ignore it — see
|
|
362
|
+
// heartbeatAbortGraceMs.
|
|
363
|
+
const abortController = new AbortController();
|
|
364
|
+
const task = taskTable.begin({
|
|
365
|
+
kind: "heartbeat",
|
|
366
|
+
label: `#${runCount}`,
|
|
367
|
+
abort: () => abortController.abort(),
|
|
368
|
+
});
|
|
369
|
+
task.bind({ model });
|
|
370
|
+
|
|
371
|
+
const usage = await runOneShotWithTimeout(
|
|
372
|
+
background,
|
|
373
|
+
{
|
|
374
|
+
prompt,
|
|
375
|
+
systemPrompt: buildHeartbeatSystemPrompt(),
|
|
376
|
+
workspace,
|
|
377
|
+
model,
|
|
378
|
+
...(effort.effort ? { reasoningEffort: effort.effort } : {}),
|
|
379
|
+
contextLabel: "heartbeat",
|
|
380
|
+
abortController,
|
|
381
|
+
appendLog: (text) => appendHeartbeatLog(heartbeatLogFile, text),
|
|
382
|
+
},
|
|
383
|
+
abortController,
|
|
384
|
+
task,
|
|
385
|
+
runCount,
|
|
386
|
+
heartbeatLogFile,
|
|
387
|
+
);
|
|
325
388
|
task.succeed(usage ?? undefined);
|
|
326
389
|
return heartbeatLogFile;
|
|
327
390
|
}
|
|
@@ -14,6 +14,7 @@ import { log, logWarn } from "../../../util/log.js";
|
|
|
14
14
|
import { roleHolder } from "./holders.js";
|
|
15
15
|
import {
|
|
16
16
|
pool,
|
|
17
|
+
initInFlight,
|
|
17
18
|
bindings,
|
|
18
19
|
listeners,
|
|
19
20
|
ctx,
|
|
@@ -285,6 +286,7 @@ export async function cleanupBackendPool(): Promise<void> {
|
|
|
285
286
|
/** Test-only state reset. */
|
|
286
287
|
export function resetBackendPoolForTest(): void {
|
|
287
288
|
pool.clear();
|
|
289
|
+
initInFlight.clear();
|
|
288
290
|
bindings.clear();
|
|
289
291
|
ctx.initCtx = null;
|
|
290
292
|
ctx.poolConfig = null;
|