agent-accelerator 0.4.0 → 0.5.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/README.md +149 -57
- package/bunfig.toml +1 -0
- package/package.json +2 -3
- package/src/agent/agent.ts +414 -118
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +327 -99
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +58 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +47 -2
- package/src/providers/google.ts +338 -81
- package/src/providers/openai-compat.ts +171 -61
- package/src/providers/openai.ts +174 -44
- package/src/providers/openrouter.ts +129 -44
- package/src/providers/shared.ts +169 -17
- package/src/providers.ts +375 -11
- package/src/scheduler/scheduled-actions.md +41 -0
- package/src/scheduler/scheduler.ts +814 -0
- package/src/scheduler/store.ts +263 -0
- package/src/scheduler/time.ts +148 -0
- package/src/scheduler/tool.ts +151 -0
- package/src/session/store.ts +229 -113
- package/src/streaming/event-stream.ts +6 -4
- package/src/telemetry-file.ts +31 -0
- package/src/telemetry.ts +65 -0
- package/src/tools/executor.ts +163 -38
- package/src/tools/subagent-result.ts +60 -0
- package/src/types/agent.ts +74 -22
- package/src/types/model.ts +39 -0
- package/src/types/response.ts +9 -0
- package/src/types/scheduler.ts +81 -0
- package/src/types/tool.ts +15 -0
- package/src/utils/cache.ts +14 -5
- package/src/utils/documents.ts +112 -52
- package/src/utils/env.ts +21 -7
- package/src/utils/errors.ts +26 -1
- package/src/utils/headers.ts +15 -15
- package/src/utils/home.ts +30 -0
- package/src/utils/media.ts +57 -12
- package/src/utils/raw-capture.ts +76 -0
- package/src/utils/retry.ts +11 -3
- package/src/utils/safe-io.ts +252 -0
- package/src/utils/session.ts +7 -8
- package/src/data/README.md +0 -84
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled actions: dynamic sub-agents that run later (once or on repeat)
|
|
3
|
+
* and write their answer to a file instead of replying in the chat.
|
|
4
|
+
*
|
|
5
|
+
* One clock per home folder, ticking once a second against the machine's own
|
|
6
|
+
* time. Each tick only asks "what is overdue?", so drift, sleep or a clock
|
|
7
|
+
* change cannot skip a run. The files under `<home>/scheduled_actions/` are the
|
|
8
|
+
* source of truth (see `store.ts`); nothing waits in memory, and the timer is
|
|
9
|
+
* unref'd and runs only while something is pending.
|
|
10
|
+
*
|
|
11
|
+
* waiting --(deliver - cook time)--> cooking --> ready --(deliver time)--> delivered
|
|
12
|
+
* | |
|
|
13
|
+
* +--> failed (recorded, moves on) +--> waiting (repeat) | done
|
|
14
|
+
*/
|
|
15
|
+
import type { Agent } from "../agent/agent.ts";
|
|
16
|
+
import { createChildSessionId } from "../agent/delegation.ts";
|
|
17
|
+
import { buildDynamicWorkerConfig, grantWorkerTools, normalizeWorkerTimeoutValue } from "../agent/worker.ts";
|
|
18
|
+
import { telemetryBus } from "../telemetry.ts";
|
|
19
|
+
import { ConfigError, ValidationError } from "../types/errors.ts";
|
|
20
|
+
import type { ToolDefinition } from "../types/tool.ts";
|
|
21
|
+
import type { AgentResponse } from "../types/response.ts";
|
|
22
|
+
import type {
|
|
23
|
+
KillResult,
|
|
24
|
+
ScheduledActionInput,
|
|
25
|
+
ScheduledActionPatch,
|
|
26
|
+
ScheduledActionRecord,
|
|
27
|
+
ScheduledDelivery,
|
|
28
|
+
ScheduleSpec,
|
|
29
|
+
} from "../types/scheduler.ts";
|
|
30
|
+
import {
|
|
31
|
+
TASK_ID_PATTERN,
|
|
32
|
+
findRunFile,
|
|
33
|
+
migrateLegacyFolder,
|
|
34
|
+
readDeliveries,
|
|
35
|
+
readRun,
|
|
36
|
+
readTasks,
|
|
37
|
+
removeTaskFolder,
|
|
38
|
+
resolveHome,
|
|
39
|
+
runPath,
|
|
40
|
+
updateDeliveries,
|
|
41
|
+
updateTasks,
|
|
42
|
+
validateDelivery,
|
|
43
|
+
validateRecord,
|
|
44
|
+
writeRunFile,
|
|
45
|
+
type PendingDelivery,
|
|
46
|
+
type RunHeader,
|
|
47
|
+
} from "./store.ts";
|
|
48
|
+
import {
|
|
49
|
+
assertCookShorterThanRepeat,
|
|
50
|
+
cookTime,
|
|
51
|
+
durationMs,
|
|
52
|
+
nextAfter,
|
|
53
|
+
nextOccurrence,
|
|
54
|
+
parseLocalIso,
|
|
55
|
+
resolveDelivery,
|
|
56
|
+
toLocalIso,
|
|
57
|
+
} from "./time.ts";
|
|
58
|
+
|
|
59
|
+
/** Default lead time (10 minutes) when the developer API omits `startCookingInAdvance`. */
|
|
60
|
+
const DEFAULT_COOK_LEAD = "00:00:10";
|
|
61
|
+
const SLUG_MAX = 48;
|
|
62
|
+
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
63
|
+
|
|
64
|
+
/** Optional `{ home }` accepted by every public function except `createScheduledAction` (the agent knows its home). */
|
|
65
|
+
export interface HomeOptions {
|
|
66
|
+
home?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type PoolTools = Record<string, ToolDefinition>;
|
|
70
|
+
type Outcome = { res: AgentResponse } | { error: string };
|
|
71
|
+
interface RunningEntry {
|
|
72
|
+
/** Aborted by `kill`; every attempt of the run listens to it. */
|
|
73
|
+
controller: AbortController;
|
|
74
|
+
done: Promise<void>;
|
|
75
|
+
killed: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
79
|
+
|
|
80
|
+
function emit(code: string, message: string, data: Record<string, unknown>, level: "info" | "warn" = "info"): void {
|
|
81
|
+
telemetryBus.emit({ code: `scheduled_action.${code}`, message: `[Agent Accelerator] ${message}`, level, data });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function notFound(id: string): ValidationError {
|
|
85
|
+
return new ValidationError(`No scheduled action with id "${id}".`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Validation (collects EVERY problem so the model can fix a call in one go)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function collect(problems: string[], fn: () => void): void {
|
|
93
|
+
try {
|
|
94
|
+
fn();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
problems.push(errText(err));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function requireText(field: string, value: unknown): void {
|
|
101
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${field}: required, a non-empty string.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function checkTools(value: unknown, pool: PoolTools): void {
|
|
105
|
+
if (!Array.isArray(value) || value.some((n) => typeof n !== "string")) {
|
|
106
|
+
throw new ValidationError("tools: must be an array of worker tool names, e.g. [\"recent_news\"].");
|
|
107
|
+
}
|
|
108
|
+
const unknown = (value as string[]).filter((n) => Object.keys(grantWorkerTools(pool, [n])).length === 0);
|
|
109
|
+
if (unknown.length > 0) {
|
|
110
|
+
const names = Object.keys(pool);
|
|
111
|
+
throw new ValidationError(
|
|
112
|
+
`tools: unknown worker tool(s) ${unknown.map((n) => `"${n}"`).join(", ")}; ` +
|
|
113
|
+
(names.length > 0 ? `the pool is [${names.join(", ")}].` : "no worker tools are available, omit tools.")
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Schedule rules shared by create (resolve the delivery date) and update (merged fields). */
|
|
119
|
+
function checkSchedule(
|
|
120
|
+
sa: Partial<ScheduleSpec>,
|
|
121
|
+
now: Date,
|
|
122
|
+
problems: string[],
|
|
123
|
+
opts: { resolve: boolean; defaultLead: boolean }
|
|
124
|
+
): void {
|
|
125
|
+
if (opts.resolve) collect(problems, () => resolveDelivery(now, sa.timeToDeliver, sa.meridiem));
|
|
126
|
+
let cookMs: number | undefined;
|
|
127
|
+
const lead = sa.startCookingInAdvance ?? (opts.defaultLead ? DEFAULT_COOK_LEAD : undefined);
|
|
128
|
+
collect(problems, () => {
|
|
129
|
+
cookMs = durationMs(lead, "scheduled_action.startCookingInAdvance");
|
|
130
|
+
});
|
|
131
|
+
if (sa.repeatEvery !== undefined) {
|
|
132
|
+
collect(problems, () => {
|
|
133
|
+
const repeatMs = durationMs(sa.repeatEvery, "scheduled_action.repeatEvery");
|
|
134
|
+
if (repeatMs === 0) {
|
|
135
|
+
throw new ValidationError('scheduled_action.repeatEvery: must be greater than 00:00:00 (e.g. "01:00:00" = every day).');
|
|
136
|
+
}
|
|
137
|
+
if (cookMs !== undefined) assertCookShorterThanRepeat(cookMs, repeatMs);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (sa.retries !== undefined && !(Number.isInteger(sa.retries) && sa.retries >= 0)) {
|
|
141
|
+
problems.push("scheduled_action.retries: must be a whole number >= 0.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function checkOptionalFields(input: Pick<ScheduledActionPatch, "role" | "tools" | "timeoutMs">, pool: PoolTools | undefined, problems: string[]): void {
|
|
146
|
+
if (input.role !== undefined && typeof input.role !== "string") problems.push("role: must be a string.");
|
|
147
|
+
if (input.tools !== undefined && pool) collect(problems, () => checkTools(input.tools, pool));
|
|
148
|
+
if (input.timeoutMs !== undefined && !(typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0)) {
|
|
149
|
+
problems.push("timeoutMs: must be a number of milliseconds > 0.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every problem with a `create` input, or `[]` when it is valid. The LLM tool
|
|
155
|
+
* passes `requireLead` (the model must state `startCookingInAdvance`); the
|
|
156
|
+
* developer API defaults it to 10 minutes.
|
|
157
|
+
*/
|
|
158
|
+
export function validateInput(
|
|
159
|
+
input: ScheduledActionInput,
|
|
160
|
+
pool: PoolTools,
|
|
161
|
+
now: Date,
|
|
162
|
+
opts: { requireLead?: boolean } = {}
|
|
163
|
+
): string[] {
|
|
164
|
+
const problems: string[] = [];
|
|
165
|
+
const sa: Partial<ScheduledActionInput["scheduled_action"]> = input?.scheduled_action ?? {};
|
|
166
|
+
collect(problems, () => requireText("instructions", input?.instructions));
|
|
167
|
+
collect(problems, () => requireText("task", input?.task));
|
|
168
|
+
checkOptionalFields(input ?? {}, pool, problems);
|
|
169
|
+
collect(problems, () => requireText("scheduled_action.name", sa.name));
|
|
170
|
+
if (typeof sa.id !== "string" || sa.id.length > SLUG_MAX || !TASK_ID_PATTERN.test(sa.id)) {
|
|
171
|
+
problems.push(
|
|
172
|
+
`scheduled_action.id: lowercase kebab-case, at most ${SLUG_MAX} characters (e.g. "check-portfolio-returns"); got ${JSON.stringify(sa.id)}.`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
checkSchedule(sa, now, problems, { resolve: true, defaultLead: !opts.requireLead });
|
|
176
|
+
return problems;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const joinProblems = (what: string, problems: string[]): string =>
|
|
180
|
+
`${what} (${problems.length} problem${problems.length === 1 ? "" : "s"}): ${problems.map((p, i) => `(${i + 1}) ${p}`).join(" ")}`;
|
|
181
|
+
|
|
182
|
+
function randomSuffix(): string {
|
|
183
|
+
const bytes = new Uint8Array(6);
|
|
184
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
185
|
+
return Array.from(bytes, (b) => ID_ALPHABET.charAt(b % ID_ALPHABET.length)).join("");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Scheduler (one per home folder)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
export class Scheduler {
|
|
193
|
+
/** Live agents that may run actions, by name (latest registration wins). */
|
|
194
|
+
readonly agents = new Map<string, WeakRef<Agent>>();
|
|
195
|
+
/** Runs in flight, by action id. */
|
|
196
|
+
readonly running = new Map<string, RunningEntry>();
|
|
197
|
+
/** Injectable for tests. */
|
|
198
|
+
now: () => Date = () => new Date();
|
|
199
|
+
tickMs = 1000;
|
|
200
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
201
|
+
private swept = false;
|
|
202
|
+
private readonly warned = new Set<string>();
|
|
203
|
+
|
|
204
|
+
constructor(readonly home: string) {}
|
|
205
|
+
|
|
206
|
+
// ----- clock -------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/** Starts the clock if something is pending. The timer never keeps the process alive. */
|
|
209
|
+
ensureRunning(): void {
|
|
210
|
+
if (this.timer || !this.hasPending()) return;
|
|
211
|
+
this.timer = setInterval(() => this.tick(), this.tickMs);
|
|
212
|
+
(this.timer as { unref?: () => void }).unref?.();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
stop(): void {
|
|
216
|
+
if (this.timer) clearInterval(this.timer);
|
|
217
|
+
this.timer = undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Delivers what is due, starts what is due. Synchronous and never throws. */
|
|
221
|
+
tick(): void {
|
|
222
|
+
try {
|
|
223
|
+
const now = this.now();
|
|
224
|
+
const first = !this.swept;
|
|
225
|
+
if (first) {
|
|
226
|
+
this.swept = true;
|
|
227
|
+
this.startupSweep(now);
|
|
228
|
+
}
|
|
229
|
+
this.deliverDue(now, first);
|
|
230
|
+
this.startDue(now);
|
|
231
|
+
if (!this.hasPending()) this.stop();
|
|
232
|
+
} catch (err) {
|
|
233
|
+
telemetryBus.emitWarning("scheduled_action.error", `[Agent Accelerator] Scheduler tick failed: ${errText(err)}`, {});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private warnInvalid(id: string, problem: string, file = "tasks.json"): void {
|
|
238
|
+
const key = `${file}:${id}:${problem}`;
|
|
239
|
+
if (this.warned.has(key)) return;
|
|
240
|
+
this.warned.add(key);
|
|
241
|
+
telemetryBus.emitWarning(
|
|
242
|
+
"scheduled_action.invalid",
|
|
243
|
+
`[Agent Accelerator] Scheduled action "${id}" in ${file} is invalid and is skipped: ${problem}`,
|
|
244
|
+
{ id, problem, file }
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Valid records and deliveries (invalid ones are reported once and left untouched in their file). */
|
|
249
|
+
private snapshot(): { live: ScheduledActionRecord[]; pending: PendingDelivery[] } {
|
|
250
|
+
const live = readTasks(this.home).filter((t) => {
|
|
251
|
+
const problem = validateRecord(t);
|
|
252
|
+
if (problem) this.warnInvalid(String((t as { id?: unknown }).id), problem);
|
|
253
|
+
return !problem;
|
|
254
|
+
});
|
|
255
|
+
const pending = readDeliveries(this.home).filter((p) => {
|
|
256
|
+
const problem = validateDelivery(p);
|
|
257
|
+
if (problem) this.warnInvalid(String((p as { id?: unknown }).id), problem, "deliveries.json");
|
|
258
|
+
return !problem;
|
|
259
|
+
});
|
|
260
|
+
return { live, pending };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private hasPending(): boolean {
|
|
264
|
+
const { live, pending } = this.snapshot();
|
|
265
|
+
return this.running.size > 0 || pending.length > 0 || live.some((t) => t.state === "waiting" || t.state === "cooking");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ----- startup -----------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* First tick after start (not the constructor, so a listener attached right
|
|
272
|
+
* after creating the Agent still sees the events). Nothing runs by itself:
|
|
273
|
+
* anything whose moment passed while the process was down is only marked.
|
|
274
|
+
*/
|
|
275
|
+
private startupSweep(now: Date): void {
|
|
276
|
+
for (const t of this.snapshot().live) {
|
|
277
|
+
if (t.state === "cooking") this.recoverCooking(t);
|
|
278
|
+
else if (t.state === "waiting" && t.nextDeliverAt && parseLocalIso(t.nextDeliverAt).getTime() <= now.getTime()) {
|
|
279
|
+
this.markMissed(t.id, "process_down");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The process died mid-cook: rebuild the delivery if the run file was already written, else it is missed. */
|
|
285
|
+
private recoverCooking(t: ScheduledActionRecord): void {
|
|
286
|
+
const current = t.current!;
|
|
287
|
+
const file = findRunFile(this.home, t.id, parseLocalIso(current.deliverAt), current.cookStartedAt);
|
|
288
|
+
const run = file ? readRun(this.home, file) : undefined;
|
|
289
|
+
if (!file || !run) {
|
|
290
|
+
this.markMissed(t.id, "interrupted");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
updateDeliveries(this.home, (pending) => {
|
|
294
|
+
if (pending.some((p) => p.file === file)) return;
|
|
295
|
+
pending.push({
|
|
296
|
+
id: t.id,
|
|
297
|
+
file,
|
|
298
|
+
status: run.header["status"] === "failed" ? "failed" : "ok",
|
|
299
|
+
readyAt: run.header["finishedAt"] ?? current.deliverAt,
|
|
300
|
+
deliverAt: current.deliverAt,
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
updateTasks(this.home, (tasks) => {
|
|
304
|
+
const rec = tasks.find((x) => x.id === t.id);
|
|
305
|
+
if (!rec || rec.state !== "cooking") return;
|
|
306
|
+
delete rec.current;
|
|
307
|
+
rec.state = rec.nextDeliverAt ? "waiting" : "done";
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private markMissed(id: string, reason: string): void {
|
|
312
|
+
let name = id;
|
|
313
|
+
updateTasks(this.home, (tasks) => {
|
|
314
|
+
const t = tasks.find((x) => x.id === id);
|
|
315
|
+
if (!t) return;
|
|
316
|
+
name = t.name;
|
|
317
|
+
t.state = "missed";
|
|
318
|
+
delete t.current;
|
|
319
|
+
});
|
|
320
|
+
emit("missed", `"${name}" (${id}) missed its run (${reason}). Use run_now or skip.`, { id, name, reason }, "warn");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ----- delivery ----------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
private deliverDue(now: Date, first: boolean): void {
|
|
326
|
+
for (const entry of this.snapshot().pending) {
|
|
327
|
+
if (parseLocalIso(entry.deliverAt).getTime() <= now.getTime()) this.deliver(entry, now, first);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Removes the entry, records `lastRun`, THEN emits (whether or not anyone listens). */
|
|
332
|
+
private deliver(entry: PendingDelivery, now: Date, first: boolean): void {
|
|
333
|
+
updateDeliveries(this.home, (pending) => {
|
|
334
|
+
const i = pending.findIndex((p) => p.id === entry.id && p.file === entry.file);
|
|
335
|
+
if (i >= 0) pending.splice(i, 1);
|
|
336
|
+
});
|
|
337
|
+
const deliverAt = parseLocalIso(entry.deliverAt).getTime();
|
|
338
|
+
const late = first || now.getTime() - deliverAt > 2 * this.tickMs || parseLocalIso(entry.readyAt).getTime() > deliverAt;
|
|
339
|
+
let name = entry.id;
|
|
340
|
+
updateTasks(this.home, (tasks) => {
|
|
341
|
+
const t = tasks.find((x) => x.id === entry.id);
|
|
342
|
+
if (!t) return;
|
|
343
|
+
name = t.name;
|
|
344
|
+
t.lastRun = { status: entry.status, file: entry.file, deliverAt: entry.deliverAt, deliveredAt: toLocalIso(now), late };
|
|
345
|
+
});
|
|
346
|
+
const delivery: ScheduledDelivery = {
|
|
347
|
+
id: entry.id,
|
|
348
|
+
name,
|
|
349
|
+
status: entry.status,
|
|
350
|
+
file: runPath(this.home, entry.file),
|
|
351
|
+
readyAt: entry.readyAt,
|
|
352
|
+
deliverAt: entry.deliverAt,
|
|
353
|
+
late,
|
|
354
|
+
};
|
|
355
|
+
emit("delivered", `"${name}" is ready: ${delivery.file}`, { ...delivery });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ----- starting runs -----------------------------------------------------
|
|
359
|
+
|
|
360
|
+
private startDue(now: Date): void {
|
|
361
|
+
for (const t of this.snapshot().live) {
|
|
362
|
+
if (!t.nextCookAt || parseLocalIso(t.nextCookAt).getTime() > now.getTime()) continue;
|
|
363
|
+
if (t.state === "waiting") this.startCook(t.id, false);
|
|
364
|
+
else if (t.state === "cooking") this.skipOverlap(t.id, now);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Next delivery after a missed one: the following occurrence after `now`, or none for a one-shot. */
|
|
369
|
+
private afterMissed(t: ScheduledActionRecord, now: Date): { cook: string | null; deliver: string | null } {
|
|
370
|
+
if (!t.schedule.repeatEvery || !t.nextDeliverAt) return { cook: null, deliver: null };
|
|
371
|
+
const deliverAt = nextAfter(parseLocalIso(t.nextDeliverAt), t.schedule.repeatEvery, now);
|
|
372
|
+
const cookMs = durationMs(t.schedule.startCookingInAdvance, "schedule.startCookingInAdvance");
|
|
373
|
+
return { cook: toLocalIso(cookTime(deliverAt, cookMs)), deliver: toLocalIso(deliverAt) };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Re-reads and validates the action (the file is the truth), flips it to
|
|
378
|
+
* `cooking` and advances its next times BEFORE the run starts, then launches
|
|
379
|
+
* the worker without awaiting it. `manual` (run_now) delivers at `now` and
|
|
380
|
+
* leaves the schedule alone, except that it consumes a missed occurrence.
|
|
381
|
+
*/
|
|
382
|
+
private startCook(id: string, manual: boolean): ScheduledActionRecord | undefined {
|
|
383
|
+
const now = this.now();
|
|
384
|
+
const ctx: { agent?: Agent; noAgent?: boolean } = {};
|
|
385
|
+
const started = updateTasks(this.home, (tasks) => {
|
|
386
|
+
const t = tasks.find((x) => x.id === id);
|
|
387
|
+
if (!t) return undefined;
|
|
388
|
+
const problem = validateRecord(t);
|
|
389
|
+
if (problem) {
|
|
390
|
+
this.warnInvalid(id, problem);
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
if (t.state === "cooking") return undefined;
|
|
394
|
+
ctx.agent = this.agents.get(t.agent)?.deref();
|
|
395
|
+
if (!ctx.agent) {
|
|
396
|
+
t.state = "missed";
|
|
397
|
+
delete t.current;
|
|
398
|
+
ctx.noAgent = true;
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
const cookMs = durationMs(t.schedule.startCookingInAdvance, "schedule.startCookingInAdvance");
|
|
402
|
+
const deliverAt = manual || !t.nextDeliverAt ? now : parseLocalIso(t.nextDeliverAt);
|
|
403
|
+
let next = { cook: t.nextCookAt, deliver: t.nextDeliverAt };
|
|
404
|
+
if (!manual) {
|
|
405
|
+
const following = t.schedule.repeatEvery && t.nextDeliverAt ? nextOccurrence(deliverAt, t.schedule.repeatEvery) : undefined;
|
|
406
|
+
next = following
|
|
407
|
+
? { cook: toLocalIso(cookTime(following, cookMs)), deliver: toLocalIso(following) }
|
|
408
|
+
: { cook: null, deliver: null };
|
|
409
|
+
} else if (t.state === "missed") {
|
|
410
|
+
next = this.afterMissed(t, now);
|
|
411
|
+
}
|
|
412
|
+
t.state = "cooking";
|
|
413
|
+
t.current = { cookStartedAt: toLocalIso(now), deliverAt: toLocalIso(deliverAt) };
|
|
414
|
+
t.nextCookAt = next.cook;
|
|
415
|
+
t.nextDeliverAt = next.deliver;
|
|
416
|
+
return t;
|
|
417
|
+
});
|
|
418
|
+
if (ctx.noAgent) {
|
|
419
|
+
const name = this.nameOf(id);
|
|
420
|
+
emit("missed", `"${name}" (${id}) cannot run: no live Agent named in tasks.json is registered.`, { id, name, reason: "no_agent" }, "warn");
|
|
421
|
+
}
|
|
422
|
+
if (!started || !ctx.agent) return undefined;
|
|
423
|
+
emit("started", `"${started.name}" (${id}) started cooking.`, {
|
|
424
|
+
id,
|
|
425
|
+
name: started.name,
|
|
426
|
+
cookStartedAt: started.current!.cookStartedAt,
|
|
427
|
+
deliverAt: started.current!.deliverAt,
|
|
428
|
+
});
|
|
429
|
+
this.launch(started, ctx.agent);
|
|
430
|
+
return started;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private nameOf(id: string): string {
|
|
434
|
+
return readTasks(this.home).find((t) => t.id === id)?.name ?? id;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The next run is due while this one still cooks: never stack. Skip that occurrence. */
|
|
438
|
+
private skipOverlap(id: string, now: Date): void {
|
|
439
|
+
const ctx: { skipped?: { name: string; deliverAt: string } } = {};
|
|
440
|
+
updateTasks(this.home, (tasks) => {
|
|
441
|
+
const t = tasks.find((x) => x.id === id);
|
|
442
|
+
if (!t || t.state !== "cooking" || !t.nextCookAt || !t.nextDeliverAt) return;
|
|
443
|
+
ctx.skipped = { name: t.name, deliverAt: t.nextDeliverAt };
|
|
444
|
+
if (!t.schedule.repeatEvery) {
|
|
445
|
+
t.nextCookAt = t.nextDeliverAt = null;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const cookMs = durationMs(t.schedule.startCookingInAdvance, "schedule.startCookingInAdvance");
|
|
449
|
+
let deliverAt = parseLocalIso(t.nextDeliverAt);
|
|
450
|
+
while (cookTime(deliverAt, cookMs).getTime() <= now.getTime()) deliverAt = nextOccurrence(deliverAt, t.schedule.repeatEvery);
|
|
451
|
+
t.nextDeliverAt = toLocalIso(deliverAt);
|
|
452
|
+
t.nextCookAt = toLocalIso(cookTime(deliverAt, cookMs));
|
|
453
|
+
});
|
|
454
|
+
if (ctx.skipped) {
|
|
455
|
+
const { name, deliverAt } = ctx.skipped;
|
|
456
|
+
emit("skipped", `"${name}" (${id}) skipped a run: the previous one is still cooking.`, { id, name, reason: "overlap", deliverAt }, "warn");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private launch(task: ScheduledActionRecord, agent: Agent): void {
|
|
461
|
+
const entry: RunningEntry = { controller: new AbortController(), done: Promise.resolve(), killed: false };
|
|
462
|
+
this.running.set(task.id, entry);
|
|
463
|
+
entry.done = this.runWorker(task, agent, entry)
|
|
464
|
+
.catch((err) => {
|
|
465
|
+
telemetryBus.emitWarning("scheduled_action.error", `[Agent Accelerator] Run of "${task.id}" failed unexpectedly: ${errText(err)}`, { id: task.id });
|
|
466
|
+
})
|
|
467
|
+
.finally(() => {
|
|
468
|
+
this.running.delete(task.id);
|
|
469
|
+
});
|
|
470
|
+
this.ensureRunning();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ----- the run -----------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
private async runWorker(task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<void> {
|
|
476
|
+
const deliverAt = parseLocalIso(task.current!.deliverAt);
|
|
477
|
+
const outcome = await this.execute(task, agent, entry, deliverAt);
|
|
478
|
+
if (entry.killed) return; // killed: no run file, nothing delivered
|
|
479
|
+
this.finish(task, outcome, task.current!.cookStartedAt, deliverAt);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** A fresh stateless worker built from the saved fields; retries only while there is time before delivery. */
|
|
483
|
+
private async execute(task: ScheduledActionRecord, agent: Agent, entry: RunningEntry, deliverAt: Date): Promise<Outcome> {
|
|
484
|
+
let worker: Agent | undefined;
|
|
485
|
+
try {
|
|
486
|
+
const { Agent: AgentClass } = await import("../agent/agent.ts");
|
|
487
|
+
const pool = agent.dynamicSubagents?.tools ?? {};
|
|
488
|
+
const missing = task.tools.filter((n) => Object.keys(grantWorkerTools(pool, [n])).length === 0);
|
|
489
|
+
if (missing.length > 0) {
|
|
490
|
+
throw new Error(`worker tool(s) no longer in the pool: ${missing.join(", ")} (available: ${Object.keys(pool).join(", ") || "none"})`);
|
|
491
|
+
}
|
|
492
|
+
worker = new AgentClass(
|
|
493
|
+
buildDynamicWorkerConfig(agent, {
|
|
494
|
+
name: task.name,
|
|
495
|
+
role: task.role,
|
|
496
|
+
instructions: task.instructions,
|
|
497
|
+
sessionId: createChildSessionId(agent.sessionId, task.id),
|
|
498
|
+
tools: grantWorkerTools(pool, task.tools),
|
|
499
|
+
})
|
|
500
|
+
);
|
|
501
|
+
const attempts = 1 + (task.schedule.retries ?? 0);
|
|
502
|
+
for (let attempt = 1; ; attempt++) {
|
|
503
|
+
try {
|
|
504
|
+
return { res: await this.attempt(worker, task, agent, entry) };
|
|
505
|
+
} catch (err) {
|
|
506
|
+
if (entry.killed || attempt >= attempts || this.now().getTime() >= deliverAt.getTime()) throw err;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
} catch (err) {
|
|
510
|
+
return { error: errText(err) };
|
|
511
|
+
} finally {
|
|
512
|
+
try {
|
|
513
|
+
worker?.reset();
|
|
514
|
+
} catch {
|
|
515
|
+
// why: freeing the one-shot worker's chain/routing state is best-effort.
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private async attempt(worker: Agent, task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<AgentResponse> {
|
|
521
|
+
const ms = normalizeWorkerTimeoutValue(agent.dynamicSubagents?.timeout ?? 0, task.timeoutMs);
|
|
522
|
+
// One controller per attempt (the timeout is per attempt), fed by the entry's kill controller.
|
|
523
|
+
const controller = new AbortController();
|
|
524
|
+
const kill = () => controller.abort(entry.controller.signal.reason ?? new Error("killed"));
|
|
525
|
+
if (entry.controller.signal.aborted) kill();
|
|
526
|
+
else entry.controller.signal.addEventListener("abort", kill, { once: true });
|
|
527
|
+
const timer = ms > 0 ? setTimeout(() => controller.abort(new Error(`timed out after ${ms}ms`)), ms) : undefined;
|
|
528
|
+
// Raced so a timeout or kill ends the attempt even if the provider ignores the signal.
|
|
529
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
530
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? new Error("aborted")), { once: true });
|
|
531
|
+
});
|
|
532
|
+
try {
|
|
533
|
+
return await Promise.race([worker.run(task.task, { signal: controller.signal }), aborted]);
|
|
534
|
+
} finally {
|
|
535
|
+
clearTimeout(timer);
|
|
536
|
+
entry.controller.signal.removeEventListener("abort", kill);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Write order: run file, then the `deliveries.json` entry, then `tasks.json`. A crash in between is repaired on start. */
|
|
541
|
+
private finish(task: ScheduledActionRecord, outcome: Outcome, startedAt: string, deliverAt: Date): void {
|
|
542
|
+
const now = this.now();
|
|
543
|
+
const ok = "res" in outcome;
|
|
544
|
+
const finishedAt = toLocalIso(now);
|
|
545
|
+
const header: RunHeader = {
|
|
546
|
+
id: task.id,
|
|
547
|
+
name: task.name,
|
|
548
|
+
status: ok ? "ok" : "failed",
|
|
549
|
+
startedAt,
|
|
550
|
+
finishedAt,
|
|
551
|
+
deliverAt: toLocalIso(deliverAt),
|
|
552
|
+
late: now.getTime() > deliverAt.getTime(),
|
|
553
|
+
...(ok
|
|
554
|
+
? {
|
|
555
|
+
model: `${outcome.res.provider}/${outcome.res.model}`,
|
|
556
|
+
tokens: outcome.res.usage.totalTokens,
|
|
557
|
+
costUsd: outcome.res.usage.cost?.totalCost ?? "unknown",
|
|
558
|
+
}
|
|
559
|
+
: { error: outcome.error }),
|
|
560
|
+
};
|
|
561
|
+
const body = ok ? outcome.res.text.trim() || "[The worker returned no text.]" : `The run failed: ${outcome.error}`;
|
|
562
|
+
const file = writeRunFile(this.home, task.id, deliverAt, header, body);
|
|
563
|
+
updateDeliveries(this.home, (pending) => {
|
|
564
|
+
pending.push({ id: task.id, file, status: header.status, readyAt: finishedAt, deliverAt: header.deliverAt });
|
|
565
|
+
});
|
|
566
|
+
updateTasks(this.home, (tasks) => {
|
|
567
|
+
const t = tasks.find((x) => x.id === task.id);
|
|
568
|
+
if (!t || t.state !== "cooking") return;
|
|
569
|
+
delete t.current;
|
|
570
|
+
t.state = t.nextDeliverAt ? "waiting" : "done";
|
|
571
|
+
});
|
|
572
|
+
const data = { id: task.id, name: task.name, file: runPath(this.home, file), readyAt: finishedAt, deliverAt: header.deliverAt };
|
|
573
|
+
if (ok) emit("ready", `"${task.name}" (${task.id}) finished; its result is saved.`, data);
|
|
574
|
+
else emit("failed", `"${task.name}" (${task.id}) failed: ${(outcome as { error: string }).error}`, { ...data, error: (outcome as { error: string }).error }, "warn");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ----- commands ----------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
runNow(id: string): ScheduledActionRecord {
|
|
580
|
+
const rec = readTasks(this.home).find((t) => t.id === id);
|
|
581
|
+
if (!rec) throw notFound(id);
|
|
582
|
+
if (rec.state === "cooking" || this.running.has(id)) {
|
|
583
|
+
throw new ValidationError(`Scheduled action "${id}" is already cooking; wait for it to finish.`);
|
|
584
|
+
}
|
|
585
|
+
if (!this.agents.get(rec.agent)?.deref()) {
|
|
586
|
+
throw new ConfigError(
|
|
587
|
+
`[Agent Accelerator] No live Agent named "${rec.agent}" to run "${id}". Construct it (with dynamicSubagents.allowScheduledActions) first.`
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const started = this.startCook(id, true);
|
|
591
|
+
if (!started) throw new ValidationError(`Scheduled action "${id}" could not be started (see the scheduled_action.invalid warning).`);
|
|
592
|
+
return started;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
skip(id: string): ScheduledActionRecord {
|
|
596
|
+
const now = this.now();
|
|
597
|
+
const rec = updateTasks(this.home, (tasks) => {
|
|
598
|
+
const t = tasks.find((x) => x.id === id);
|
|
599
|
+
if (!t) throw notFound(id);
|
|
600
|
+
if (t.state !== "missed") throw new ValidationError(`Scheduled action "${id}" is ${t.state}, not missed: nothing to skip.`);
|
|
601
|
+
const next = this.afterMissed(t, now);
|
|
602
|
+
t.nextCookAt = next.cook;
|
|
603
|
+
t.nextDeliverAt = next.deliver;
|
|
604
|
+
t.state = next.deliver ? "waiting" : "done";
|
|
605
|
+
delete t.current;
|
|
606
|
+
return t;
|
|
607
|
+
});
|
|
608
|
+
emit("skipped", `"${rec.name}" (${id}) skipped its missed run.`, { id, name: rec.name, reason: "skip", nextDeliverAt: rec.nextDeliverAt });
|
|
609
|
+
this.ensureRunning();
|
|
610
|
+
return rec;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async kill(id: string, opts: { force?: boolean; purge?: boolean }): Promise<KillResult> {
|
|
614
|
+
const rec = readTasks(this.home).find((t) => t.id === id);
|
|
615
|
+
if (!rec) return { ok: false, reason: "not_found" };
|
|
616
|
+
const run = this.running.get(id);
|
|
617
|
+
if (run && !opts.force) {
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
reason: "running",
|
|
621
|
+
message: `"${id}" is cooking right now, so nothing was changed. Ask the user whether to abort it; if they agree, call kill again with force: true.`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (run) {
|
|
625
|
+
run.killed = true;
|
|
626
|
+
run.controller.abort(new Error("killed"));
|
|
627
|
+
await run.done;
|
|
628
|
+
}
|
|
629
|
+
updateTasks(this.home, (tasks) => {
|
|
630
|
+
const i = tasks.findIndex((t) => t.id === id);
|
|
631
|
+
if (i >= 0) tasks.splice(i, 1);
|
|
632
|
+
});
|
|
633
|
+
updateDeliveries(this.home, (pending) => {
|
|
634
|
+
for (let i = pending.length - 1; i >= 0; i--) if (pending[i]!.id === id) pending.splice(i, 1);
|
|
635
|
+
});
|
|
636
|
+
if (opts.purge) removeTaskFolder(this.home, id);
|
|
637
|
+
emit("killed", `"${rec.name}" (${id}) was killed.`, { id, name: rec.name, forced: !!run, purged: !!opts.purge });
|
|
638
|
+
return { ok: true };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Registry (one scheduler per resolved home) and agent registration
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
const schedulers = new Map<string, Scheduler>();
|
|
647
|
+
|
|
648
|
+
/** The scheduler of a RESOLVED home folder (created on first use; nothing runs until something is pending). */
|
|
649
|
+
export function getScheduler(home: string): Scheduler {
|
|
650
|
+
let s = schedulers.get(home);
|
|
651
|
+
if (!s) {
|
|
652
|
+
migrateLegacyFolder(home);
|
|
653
|
+
schedulers.set(home, (s = new Scheduler(home)));
|
|
654
|
+
}
|
|
655
|
+
return s;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Stops every clock and forgets all schedulers. Test hook. */
|
|
659
|
+
export function resetSchedulers(): void {
|
|
660
|
+
for (const s of schedulers.values()) s.stop();
|
|
661
|
+
schedulers.clear();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const schedulerFor = (opts?: HomeOptions): Scheduler => getScheduler(resolveHome(opts?.home));
|
|
665
|
+
|
|
666
|
+
/** Makes `agent` the live runner for actions created under its name, and starts the clock if work is pending. */
|
|
667
|
+
export function registerAgent(agent: Agent): void {
|
|
668
|
+
const s = getScheduler(agent.home);
|
|
669
|
+
s.agents.set(agent.name, new WeakRef(agent));
|
|
670
|
+
s.ensureRunning();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Public functions (exported from the barrel; the LLM tool is a thin wrapper)
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
/** Schedules a new action under `agent` (needs `dynamicSubagents.allowScheduledActions`). Returns the saved record. */
|
|
678
|
+
export function createScheduledAction(agent: Agent, input: ScheduledActionInput): ScheduledActionRecord {
|
|
679
|
+
const dyn = agent.dynamicSubagents;
|
|
680
|
+
if (!dyn?.allowScheduledActions) {
|
|
681
|
+
throw new ConfigError(
|
|
682
|
+
"[Agent Accelerator] Scheduled actions are off for this agent: set dynamicSubagents.allowScheduledActions: true (and a worker model)."
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
const s = getScheduler(agent.home);
|
|
686
|
+
const now = s.now();
|
|
687
|
+
const problems = validateInput(input, dyn.tools, now);
|
|
688
|
+
if (problems.length > 0) throw new ValidationError(joinProblems("Invalid scheduled action", problems));
|
|
689
|
+
|
|
690
|
+
const sa = input.scheduled_action;
|
|
691
|
+
const lead = sa.startCookingInAdvance ?? DEFAULT_COOK_LEAD;
|
|
692
|
+
const deliverAt = resolveDelivery(now, sa.timeToDeliver, sa.meridiem);
|
|
693
|
+
const record = updateTasks(s.home, (tasks) => {
|
|
694
|
+
const taken = new Set(tasks.map((t) => t.id));
|
|
695
|
+
let id: string;
|
|
696
|
+
do id = `${sa.id}-${randomSuffix()}`;
|
|
697
|
+
while (taken.has(id));
|
|
698
|
+
const rec: ScheduledActionRecord = {
|
|
699
|
+
id,
|
|
700
|
+
name: sa.name.trim(),
|
|
701
|
+
agent: agent.name,
|
|
702
|
+
...(input.role ? { role: input.role } : {}),
|
|
703
|
+
instructions: input.instructions,
|
|
704
|
+
task: input.task,
|
|
705
|
+
tools: input.tools ?? [],
|
|
706
|
+
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
707
|
+
schedule: {
|
|
708
|
+
timeToDeliver: sa.timeToDeliver,
|
|
709
|
+
meridiem: sa.meridiem,
|
|
710
|
+
startCookingInAdvance: lead,
|
|
711
|
+
...(sa.repeatEvery !== undefined ? { repeatEvery: sa.repeatEvery } : {}),
|
|
712
|
+
...(sa.retries !== undefined ? { retries: sa.retries } : {}),
|
|
713
|
+
},
|
|
714
|
+
state: "waiting",
|
|
715
|
+
nextCookAt: toLocalIso(cookTime(deliverAt, durationMs(lead, "scheduled_action.startCookingInAdvance"))),
|
|
716
|
+
nextDeliverAt: toLocalIso(deliverAt),
|
|
717
|
+
createdAt: toLocalIso(now),
|
|
718
|
+
};
|
|
719
|
+
tasks.push(rec);
|
|
720
|
+
return rec;
|
|
721
|
+
});
|
|
722
|
+
s.agents.set(agent.name, new WeakRef(agent));
|
|
723
|
+
emit("created", `"${record.name}" (${record.id}) scheduled for ${record.nextDeliverAt}.`, {
|
|
724
|
+
id: record.id,
|
|
725
|
+
name: record.name,
|
|
726
|
+
nextCookAt: record.nextCookAt,
|
|
727
|
+
nextDeliverAt: record.nextDeliverAt,
|
|
728
|
+
});
|
|
729
|
+
s.ensureRunning();
|
|
730
|
+
return record;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Every action with its full metadata and state, plus a `running` flag. */
|
|
734
|
+
export function listScheduledActions(opts?: HomeOptions): Array<ScheduledActionRecord & { running: boolean }> {
|
|
735
|
+
const s = schedulerFor(opts);
|
|
736
|
+
return readTasks(s.home).map((t) => ({ ...t, running: s.running.has(t.id) }));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Changes instructions, task, tools, timeout, role or any schedule field.
|
|
741
|
+
* `id` and the creating agent are immutable. Changing the delivery time
|
|
742
|
+
* recomputes the schedule (`DD` relative to now); schedule changes are
|
|
743
|
+
* refused while the action is cooking.
|
|
744
|
+
*/
|
|
745
|
+
export function updateScheduledAction(id: string, patch: ScheduledActionPatch, opts?: HomeOptions): ScheduledActionRecord {
|
|
746
|
+
const s = schedulerFor(opts);
|
|
747
|
+
const now = s.now();
|
|
748
|
+
const sa = patch.scheduled_action ?? {};
|
|
749
|
+
const { name, ...scheduleChange } = sa;
|
|
750
|
+
const rec = updateTasks(s.home, (tasks) => {
|
|
751
|
+
const t = tasks.find((x) => x.id === id);
|
|
752
|
+
if (!t) throw notFound(id);
|
|
753
|
+
const stored = validateRecord(t);
|
|
754
|
+
if (stored) throw new ValidationError(`Scheduled action "${id}" is invalid in tasks.json: ${stored}`);
|
|
755
|
+
const scheduleChanged = Object.values(scheduleChange).some((v) => v !== undefined);
|
|
756
|
+
if (scheduleChanged && t.state === "cooking") {
|
|
757
|
+
throw new ValidationError(`Scheduled action "${id}" is cooking right now; schedule changes are refused while it runs. Try again after it finishes.`);
|
|
758
|
+
}
|
|
759
|
+
const deliveryChanged = scheduleChange.timeToDeliver !== undefined || scheduleChange.meridiem !== undefined;
|
|
760
|
+
const merged = { ...t.schedule };
|
|
761
|
+
for (const [key, value] of Object.entries(scheduleChange)) if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
|
|
762
|
+
|
|
763
|
+
const problems: string[] = [];
|
|
764
|
+
const pool = s.agents.get(t.agent)?.deref()?.dynamicSubagents?.tools;
|
|
765
|
+
if (patch.instructions !== undefined) collect(problems, () => requireText("instructions", patch.instructions));
|
|
766
|
+
if (patch.task !== undefined) collect(problems, () => requireText("task", patch.task));
|
|
767
|
+
if (name !== undefined) collect(problems, () => requireText("scheduled_action.name", name));
|
|
768
|
+
checkOptionalFields(patch, pool, problems);
|
|
769
|
+
if (scheduleChanged) checkSchedule(merged, now, problems, { resolve: deliveryChanged, defaultLead: false });
|
|
770
|
+
if (problems.length > 0) throw new ValidationError(joinProblems("Invalid update", problems));
|
|
771
|
+
|
|
772
|
+
if (patch.role !== undefined) t.role = patch.role || undefined;
|
|
773
|
+
if (patch.instructions !== undefined) t.instructions = patch.instructions;
|
|
774
|
+
if (patch.task !== undefined) t.task = patch.task;
|
|
775
|
+
if (patch.tools !== undefined) t.tools = patch.tools;
|
|
776
|
+
if (patch.timeoutMs !== undefined) t.timeoutMs = patch.timeoutMs;
|
|
777
|
+
if (name !== undefined) t.name = name.trim();
|
|
778
|
+
if (scheduleChanged) {
|
|
779
|
+
t.schedule = merged;
|
|
780
|
+
const cookMs = durationMs(merged.startCookingInAdvance, "scheduled_action.startCookingInAdvance");
|
|
781
|
+
if (deliveryChanged) {
|
|
782
|
+
const deliverAt = resolveDelivery(now, merged.timeToDeliver, merged.meridiem);
|
|
783
|
+
t.nextDeliverAt = toLocalIso(deliverAt);
|
|
784
|
+
t.nextCookAt = toLocalIso(cookTime(deliverAt, cookMs));
|
|
785
|
+
t.state = "waiting";
|
|
786
|
+
} else if (t.nextDeliverAt) {
|
|
787
|
+
t.nextCookAt = toLocalIso(cookTime(parseLocalIso(t.nextDeliverAt), cookMs));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return t;
|
|
791
|
+
});
|
|
792
|
+
emit("updated", `"${rec.name}" (${id}) updated.`, { id, name: rec.name, nextCookAt: rec.nextCookAt, nextDeliverAt: rec.nextDeliverAt });
|
|
793
|
+
s.ensureRunning();
|
|
794
|
+
return rec;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Runs an action now, outside its schedule (also resolves a missed run). Needs the creating Agent to be alive. */
|
|
798
|
+
export function runScheduledAction(id: string, opts?: HomeOptions): ScheduledActionRecord {
|
|
799
|
+
return schedulerFor(opts).runNow(id);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Ignores a missed run and waits for the next scheduled time (a one-shot becomes `done`). */
|
|
803
|
+
export function skipScheduledAction(id: string, opts?: HomeOptions): ScheduledActionRecord {
|
|
804
|
+
return schedulerFor(opts).skip(id);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Deletes an action for good. While it is cooking nothing changes unless
|
|
809
|
+
* `force: true` (the run is aborted, then deleted). Past run files are kept
|
|
810
|
+
* unless `purge: true`.
|
|
811
|
+
*/
|
|
812
|
+
export function killScheduledAction(id: string, opts?: HomeOptions & { force?: boolean; purge?: boolean }): Promise<KillResult> {
|
|
813
|
+
return schedulerFor(opts).kill(id, { force: opts?.force, purge: opts?.purge });
|
|
814
|
+
}
|