agent-accelerator 0.5.9 → 0.6.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 +3 -1
- package/examples/prompts/SYSTEM_PROMPT.md +2 -0
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
- package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
- package/package.json +4 -1
- package/src/agent/agent.ts +366 -161
- package/src/agent/architecture.md +13 -12
- package/src/agent/config-blocks.ts +39 -12
- package/src/agent/context.ts +8 -3
- package/src/agent/delegation.ts +82 -29
- package/src/agent/loop.ts +444 -365
- package/src/agent/subagent.ts +1 -3
- package/src/agent/subagents-tool.ts +45 -7
- package/src/agent/subagents.md +3 -0
- package/src/agent/turns.ts +3 -1
- package/src/agent/worker.ts +56 -27
- package/src/agent/workers.ts +113 -20
- package/src/architecture.md +16 -15
- package/src/index.ts +34 -10
- package/src/models/architecture.md +4 -3
- package/src/models/catalog-cache.ts +6 -5
- package/src/models/catalog.ts +39 -30
- package/src/providers/architecture.md +10 -8
- package/src/providers/google.ts +147 -59
- package/src/providers/openai-compat.ts +57 -49
- package/src/providers/openai.ts +54 -37
- package/src/providers/openrouter.ts +85 -30
- package/src/providers/registry.ts +6 -21
- package/src/providers/shared.ts +87 -33
- package/src/providers.ts +56 -115
- package/src/scheduler/architecture.md +7 -6
- package/src/scheduler/scheduled-actions.md +4 -0
- package/src/scheduler/scheduler.ts +186 -60
- package/src/scheduler/store.ts +23 -10
- package/src/scheduler/time.ts +17 -3
- package/src/scheduler/tool.ts +33 -7
- package/src/session/architecture.md +7 -6
- package/src/session/journal.ts +38 -12
- package/src/session/store.ts +122 -61
- package/src/session/usage.ts +58 -9
- package/src/streaming/architecture.md +4 -3
- package/src/streaming/event-stream.ts +26 -12
- package/src/telemetry.ts +6 -2
- package/src/tools/architecture.md +8 -7
- package/src/tools/executor.ts +50 -48
- package/src/tools/schema.ts +10 -6
- package/src/tools/subagent-result.ts +6 -1
- package/src/tools/tool.ts +9 -11
- package/src/types/agent.ts +2 -21
- package/src/types/architecture.md +11 -10
- package/src/types/core.ts +1 -9
- package/src/types/errors.ts +26 -4
- package/src/types/model.ts +0 -4
- package/src/types/tool.ts +12 -2
- package/src/update-models.ts +5 -5
- package/src/utils/architecture.md +16 -15
- package/src/utils/cache.ts +1 -3
- package/src/utils/durable.ts +17 -5
- package/src/utils/env.ts +9 -7
- package/src/utils/errors.ts +6 -14
- package/src/utils/headers.ts +3 -10
- package/src/utils/media.ts +23 -8
- package/src/utils/raw-capture.ts +9 -2
- package/src/utils/retry.ts +15 -3
- package/src/utils/safe-io.ts +29 -7
- package/src/utils/serialization.ts +1 -4
- package/src/utils/session.ts +1 -2
|
@@ -42,7 +42,6 @@ import {
|
|
|
42
42
|
readTask,
|
|
43
43
|
readTasks,
|
|
44
44
|
removeTaskFolder,
|
|
45
|
-
resolveHome,
|
|
46
45
|
runPath,
|
|
47
46
|
touchHeartbeat,
|
|
48
47
|
updateDeliveries,
|
|
@@ -54,6 +53,7 @@ import {
|
|
|
54
53
|
type PendingDelivery,
|
|
55
54
|
type RunHeader,
|
|
56
55
|
} from "./store.ts";
|
|
56
|
+
import { resolveHome } from "../utils/home.ts";
|
|
57
57
|
import {
|
|
58
58
|
assertCookShorterThanRepeat,
|
|
59
59
|
cookTime,
|
|
@@ -150,7 +150,7 @@ function requireText(field: string, value: unknown): void {
|
|
|
150
150
|
// Throws unless every requested worker tool name exists in the developer's pool; in: create/update validation, out: collect.
|
|
151
151
|
function checkTools(value: unknown, pool: PoolTools): void {
|
|
152
152
|
if (!Array.isArray(value) || value.some((n) => typeof n !== "string")) {
|
|
153
|
-
throw new ValidationError(
|
|
153
|
+
throw new ValidationError('tools: must be an array of worker tool names, e.g. ["recent_news"].');
|
|
154
154
|
}
|
|
155
155
|
const unknown = (value as string[]).filter((n) => Object.keys(grantWorkerTools(pool, [n])).length === 0);
|
|
156
156
|
if (unknown.length > 0) {
|
|
@@ -179,7 +179,9 @@ function checkSchedule(
|
|
|
179
179
|
collect(problems, () => {
|
|
180
180
|
const repeatMs = durationMs(sa.repeatEvery, "scheduled_action.repeatEvery");
|
|
181
181
|
if (repeatMs === 0) {
|
|
182
|
-
throw new ValidationError(
|
|
182
|
+
throw new ValidationError(
|
|
183
|
+
'scheduled_action.repeatEvery: must be greater than 00:00:00 (e.g. "01:00:00" = every day).'
|
|
184
|
+
);
|
|
183
185
|
}
|
|
184
186
|
if (cookMs !== undefined) assertCookShorterThanRepeat(cookMs, repeatMs);
|
|
185
187
|
});
|
|
@@ -188,15 +190,24 @@ function checkSchedule(
|
|
|
188
190
|
problems.push("scheduled_action.retries: must be a whole number >= 0.");
|
|
189
191
|
}
|
|
190
192
|
if (sa.onMissed !== undefined && sa.onMissed !== "skip" && sa.onMissed !== "run") {
|
|
191
|
-
problems.push(
|
|
193
|
+
problems.push(
|
|
194
|
+
`scheduled_action.onMissed: must be "skip" or "run" (omit it to decide later with run_now or skip); got ${JSON.stringify(sa.onMissed)}.`
|
|
195
|
+
);
|
|
192
196
|
}
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
// Validates role, tools and timeoutMs of a create or update; in: validateInput/updateScheduledAction, out: the problems list.
|
|
196
|
-
function checkOptionalFields(
|
|
200
|
+
function checkOptionalFields(
|
|
201
|
+
input: Pick<ScheduledActionPatch, "role" | "tools" | "timeoutMs">,
|
|
202
|
+
pool: PoolTools | undefined,
|
|
203
|
+
problems: string[]
|
|
204
|
+
): void {
|
|
197
205
|
if (input.role !== undefined && typeof input.role !== "string") problems.push("role: must be a string.");
|
|
198
206
|
if (input.tools !== undefined && pool) collect(problems, () => checkTools(input.tools, pool));
|
|
199
|
-
if (
|
|
207
|
+
if (
|
|
208
|
+
input.timeoutMs !== undefined &&
|
|
209
|
+
!(typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0)
|
|
210
|
+
) {
|
|
200
211
|
problems.push("timeoutMs: must be a number of milliseconds > 0.");
|
|
201
212
|
}
|
|
202
213
|
}
|
|
@@ -290,7 +301,11 @@ export class Scheduler {
|
|
|
290
301
|
this.startDue(now);
|
|
291
302
|
if (!this.hasPending()) this.stop();
|
|
292
303
|
} catch (err) {
|
|
293
|
-
telemetryBus.emitWarning(
|
|
304
|
+
telemetryBus.emitWarning(
|
|
305
|
+
"scheduled_action.error",
|
|
306
|
+
`[Agent Accelerator] Scheduler tick failed: ${errText(err)}`,
|
|
307
|
+
{}
|
|
308
|
+
);
|
|
294
309
|
}
|
|
295
310
|
}
|
|
296
311
|
|
|
@@ -324,7 +339,9 @@ export class Scheduler {
|
|
|
324
339
|
// True while anything keeps the clock useful (a run in flight, a saved delivery, a waiting or cooking action); in: ensureRunning and tick, out: start/stop of the interval.
|
|
325
340
|
private hasPending(): boolean {
|
|
326
341
|
const { live, pending } = this.snapshot();
|
|
327
|
-
return
|
|
342
|
+
return (
|
|
343
|
+
this.running.size > 0 || pending.length > 0 || live.some((t) => t.state === "waiting" || t.state === "cooking")
|
|
344
|
+
);
|
|
328
345
|
}
|
|
329
346
|
|
|
330
347
|
// ----- startup -----------------------------------------------------------
|
|
@@ -412,8 +429,18 @@ export class Scheduler {
|
|
|
412
429
|
return true;
|
|
413
430
|
});
|
|
414
431
|
if (!changed) return;
|
|
415
|
-
const next =
|
|
416
|
-
|
|
432
|
+
const next =
|
|
433
|
+
policy === "skip"
|
|
434
|
+
? "Skipping it (onMissed: skip)."
|
|
435
|
+
: policy === "run"
|
|
436
|
+
? "Running it now (onMissed: run)."
|
|
437
|
+
: "Use run_now or skip.";
|
|
438
|
+
emit(
|
|
439
|
+
"missed",
|
|
440
|
+
`"${name}" (${id}) missed its run (${reason}). ${next}`,
|
|
441
|
+
{ id, name, reason, ...(policy ? { onMissed: policy } : {}) },
|
|
442
|
+
"warn"
|
|
443
|
+
);
|
|
417
444
|
if (policy === "skip") this.skip(id);
|
|
418
445
|
else if (policy === "run") this.startCook(id, true);
|
|
419
446
|
}
|
|
@@ -439,12 +466,21 @@ export class Scheduler {
|
|
|
439
466
|
});
|
|
440
467
|
if (!removed) return; // another process took this delivery: exactly one process delivers it
|
|
441
468
|
const deliverAt = parseLocalIso(entry.deliverAt).getTime();
|
|
442
|
-
const late =
|
|
469
|
+
const late =
|
|
470
|
+
entry.late === true ||
|
|
471
|
+
now.getTime() - deliverAt > 2 * this.tickMs ||
|
|
472
|
+
parseLocalIso(entry.readyAt).getTime() > deliverAt;
|
|
443
473
|
let name = entry.id;
|
|
444
474
|
updateTask(this.home, entry.id, (t) => {
|
|
445
475
|
if (!t) return;
|
|
446
476
|
name = t.name;
|
|
447
|
-
t.lastRun = {
|
|
477
|
+
t.lastRun = {
|
|
478
|
+
status: entry.status,
|
|
479
|
+
file: entry.file,
|
|
480
|
+
deliverAt: entry.deliverAt,
|
|
481
|
+
deliveredAt: toLocalIso(now),
|
|
482
|
+
late,
|
|
483
|
+
};
|
|
448
484
|
});
|
|
449
485
|
const delivery: ScheduledDelivery = {
|
|
450
486
|
id: entry.id,
|
|
@@ -502,7 +538,8 @@ export class Scheduler {
|
|
|
502
538
|
}
|
|
503
539
|
if (t.state === "cooking") return undefined;
|
|
504
540
|
// Another process may have started (and even finished) this run since the caller looked: a scheduled start needs the action to be waiting and due right now.
|
|
505
|
-
if (!manual && (t.state !== "waiting" || !t.nextCookAt || parseLocalIso(t.nextCookAt).getTime() > now.getTime()))
|
|
541
|
+
if (!manual && (t.state !== "waiting" || !t.nextCookAt || parseLocalIso(t.nextCookAt).getTime() > now.getTime()))
|
|
542
|
+
return undefined;
|
|
506
543
|
ctx.replacesMissed = manual && t.state === "missed";
|
|
507
544
|
ctx.agent = this.agents.get(t.agent)?.deref();
|
|
508
545
|
if (!ctx.agent) {
|
|
@@ -515,7 +552,8 @@ export class Scheduler {
|
|
|
515
552
|
const deliverAt = manual || !t.nextDeliverAt ? now : parseLocalIso(t.nextDeliverAt);
|
|
516
553
|
let next = { cook: t.nextCookAt, deliver: t.nextDeliverAt };
|
|
517
554
|
if (!manual) {
|
|
518
|
-
const following =
|
|
555
|
+
const following =
|
|
556
|
+
t.schedule.repeatEvery && t.nextDeliverAt ? nextOccurrence(deliverAt, t.schedule.repeatEvery) : undefined;
|
|
519
557
|
next = following
|
|
520
558
|
? { cook: toLocalIso(cookTime(following, cookMs)), deliver: toLocalIso(following) }
|
|
521
559
|
: { cook: null, deliver: null };
|
|
@@ -531,7 +569,12 @@ export class Scheduler {
|
|
|
531
569
|
});
|
|
532
570
|
if (ctx.noAgent) {
|
|
533
571
|
const name = this.nameOf(id);
|
|
534
|
-
emit(
|
|
572
|
+
emit(
|
|
573
|
+
"missed",
|
|
574
|
+
`"${name}" (${id}) cannot run: no live Agent named in its task.json is registered.`,
|
|
575
|
+
{ id, name, reason: "no_agent" },
|
|
576
|
+
"warn"
|
|
577
|
+
);
|
|
535
578
|
}
|
|
536
579
|
if (!started || !ctx.agent) return undefined;
|
|
537
580
|
emit("started", `"${started.name}" (${id}) started cooking.`, {
|
|
@@ -561,23 +604,39 @@ export class Scheduler {
|
|
|
561
604
|
}
|
|
562
605
|
const cookMs = durationMs(t.schedule.startCookingInAdvance, "schedule.startCookingInAdvance");
|
|
563
606
|
let deliverAt = parseLocalIso(t.nextDeliverAt);
|
|
564
|
-
while (cookTime(deliverAt, cookMs).getTime() <= now.getTime())
|
|
607
|
+
while (cookTime(deliverAt, cookMs).getTime() <= now.getTime())
|
|
608
|
+
deliverAt = nextOccurrence(deliverAt, t.schedule.repeatEvery);
|
|
565
609
|
t.nextDeliverAt = toLocalIso(deliverAt);
|
|
566
610
|
t.nextCookAt = toLocalIso(cookTime(deliverAt, cookMs));
|
|
567
611
|
});
|
|
568
612
|
if (ctx.skipped) {
|
|
569
613
|
const { name, deliverAt } = ctx.skipped;
|
|
570
|
-
emit(
|
|
614
|
+
emit(
|
|
615
|
+
"skipped",
|
|
616
|
+
`"${name}" (${id}) skipped a run: the previous one is still cooking.`,
|
|
617
|
+
{ id, name, reason: "overlap", deliverAt },
|
|
618
|
+
"warn"
|
|
619
|
+
);
|
|
571
620
|
}
|
|
572
621
|
}
|
|
573
622
|
|
|
574
623
|
// Starts one run without awaiting it and tracks it until it ends; in: startCook, out: this.running + the run's files.
|
|
575
624
|
private launch(task: ScheduledActionRecord, agent: Agent, manual: boolean, missed: boolean): void {
|
|
576
|
-
const entry: RunningEntry = {
|
|
625
|
+
const entry: RunningEntry = {
|
|
626
|
+
controller: new AbortController(),
|
|
627
|
+
done: Promise.resolve(),
|
|
628
|
+
deleted: false,
|
|
629
|
+
manual,
|
|
630
|
+
missed,
|
|
631
|
+
};
|
|
577
632
|
this.running.set(task.id, entry);
|
|
578
633
|
entry.done = this.runWorker(task, agent, entry)
|
|
579
634
|
.catch((err) => {
|
|
580
|
-
telemetryBus.emitWarning(
|
|
635
|
+
telemetryBus.emitWarning(
|
|
636
|
+
"scheduled_action.error",
|
|
637
|
+
`[Agent Accelerator] Run of "${task.id}" failed unexpectedly: ${errText(err)}`,
|
|
638
|
+
{ id: task.id }
|
|
639
|
+
);
|
|
581
640
|
})
|
|
582
641
|
.finally(() => {
|
|
583
642
|
this.running.delete(task.id);
|
|
@@ -597,14 +656,21 @@ export class Scheduler {
|
|
|
597
656
|
}
|
|
598
657
|
|
|
599
658
|
/** A fresh stateless worker built from the saved fields; retries only while there is time before delivery. */
|
|
600
|
-
private async execute(
|
|
659
|
+
private async execute(
|
|
660
|
+
task: ScheduledActionRecord,
|
|
661
|
+
agent: Agent,
|
|
662
|
+
entry: RunningEntry,
|
|
663
|
+
deliverAt: Date
|
|
664
|
+
): Promise<Outcome> {
|
|
601
665
|
let worker: Agent | undefined;
|
|
602
666
|
try {
|
|
603
667
|
const { Agent: AgentClass } = await import("../agent/agent.ts");
|
|
604
668
|
const pool = agent.dynamicSubagents?.tools ?? {};
|
|
605
669
|
const missing = task.tools.filter((n) => Object.keys(grantWorkerTools(pool, [n])).length === 0);
|
|
606
670
|
if (missing.length > 0) {
|
|
607
|
-
throw new Error(
|
|
671
|
+
throw new Error(
|
|
672
|
+
`worker tool(s) no longer in the pool: ${missing.join(", ")} (available: ${Object.keys(pool).join(", ") || "none"})`
|
|
673
|
+
);
|
|
608
674
|
}
|
|
609
675
|
worker = new AgentClass(
|
|
610
676
|
buildDynamicWorkerConfig(agent, {
|
|
@@ -635,7 +701,12 @@ export class Scheduler {
|
|
|
635
701
|
}
|
|
636
702
|
|
|
637
703
|
// One worker run with a per-attempt timeout and the delete signal, raced so a hung provider cannot hold it; in: execute, out: the worker's AgentResponse.
|
|
638
|
-
private async attempt(
|
|
704
|
+
private async attempt(
|
|
705
|
+
worker: Agent,
|
|
706
|
+
task: ScheduledActionRecord,
|
|
707
|
+
agent: Agent,
|
|
708
|
+
entry: RunningEntry
|
|
709
|
+
): Promise<AgentResponse> {
|
|
639
710
|
const ms = scheduledRunTimeoutMs(agent.dynamicSubagents?.workerTimeoutMs ?? Infinity, task.timeoutMs);
|
|
640
711
|
// One controller per attempt (the timeout is per attempt), fed by the entry's delete controller.
|
|
641
712
|
const controller = new AbortController();
|
|
@@ -645,7 +716,9 @@ export class Scheduler {
|
|
|
645
716
|
const timer = ms > 0 ? setTimeout(() => controller.abort(new Error(`timed out after ${ms}ms`)), ms) : undefined;
|
|
646
717
|
// Raced so a timeout or delete ends the attempt even if the provider ignores the signal.
|
|
647
718
|
const aborted = new Promise<never>((_, reject) => {
|
|
648
|
-
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? new Error("aborted")), {
|
|
719
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? new Error("aborted")), {
|
|
720
|
+
once: true,
|
|
721
|
+
});
|
|
649
722
|
});
|
|
650
723
|
try {
|
|
651
724
|
return await Promise.race([worker.run(task.task, { signal: controller.signal }), aborted]);
|
|
@@ -656,7 +729,13 @@ export class Scheduler {
|
|
|
656
729
|
}
|
|
657
730
|
|
|
658
731
|
// Writes a finished run: run file, then the `delivery.json` entry, then `task.json` (a crash in between is repaired on start); a manual run is due when it finishes and is late only if it replaced a missed occurrence, a scheduled one is late only past its second; in: runWorker, out: the files and the ready/failed event.
|
|
659
|
-
private finish(
|
|
732
|
+
private finish(
|
|
733
|
+
task: ScheduledActionRecord,
|
|
734
|
+
outcome: Outcome,
|
|
735
|
+
startedAt: string,
|
|
736
|
+
deliverAt: Date,
|
|
737
|
+
run: Pick<RunningEntry, "manual" | "missed">
|
|
738
|
+
): void {
|
|
660
739
|
const now = this.now();
|
|
661
740
|
const ok = "res" in outcome;
|
|
662
741
|
const finishedAt = toLocalIso(now);
|
|
@@ -677,16 +756,35 @@ export class Scheduler {
|
|
|
677
756
|
const body = ok ? outcome.res.text.trim() || "[The worker returned no text.]" : `The run failed: ${outcome.error}`;
|
|
678
757
|
const file = writeRunFile(this.home, task.id, deliverAt, header, body);
|
|
679
758
|
updateDeliveries(this.home, task.id, (pending) => {
|
|
680
|
-
pending.push({
|
|
759
|
+
pending.push({
|
|
760
|
+
id: task.id,
|
|
761
|
+
file,
|
|
762
|
+
status: header.status,
|
|
763
|
+
readyAt: finishedAt,
|
|
764
|
+
deliverAt: header.deliverAt,
|
|
765
|
+
...(run.missed ? { late: true } : {}),
|
|
766
|
+
});
|
|
681
767
|
});
|
|
682
768
|
updateTask(this.home, task.id, (t) => {
|
|
683
769
|
if (!t || t.state !== "cooking") return;
|
|
684
770
|
delete t.current;
|
|
685
771
|
t.state = t.nextDeliverAt ? "waiting" : "done";
|
|
686
772
|
});
|
|
687
|
-
const data = {
|
|
773
|
+
const data = {
|
|
774
|
+
id: task.id,
|
|
775
|
+
name: task.name,
|
|
776
|
+
file: runPath(this.home, file),
|
|
777
|
+
readyAt: finishedAt,
|
|
778
|
+
deliverAt: header.deliverAt,
|
|
779
|
+
};
|
|
688
780
|
if (ok) emit("ready", `"${task.name}" (${task.id}) finished; its result is saved.`, data);
|
|
689
|
-
else
|
|
781
|
+
else
|
|
782
|
+
emit(
|
|
783
|
+
"failed",
|
|
784
|
+
`"${task.name}" (${task.id}) failed: ${(outcome as { error: string }).error}`,
|
|
785
|
+
{ ...data, error: (outcome as { error: string }).error },
|
|
786
|
+
"warn"
|
|
787
|
+
);
|
|
690
788
|
}
|
|
691
789
|
|
|
692
790
|
// ----- commands ----------------------------------------------------------
|
|
@@ -704,7 +802,10 @@ export class Scheduler {
|
|
|
704
802
|
);
|
|
705
803
|
}
|
|
706
804
|
const started = this.startCook(id, true);
|
|
707
|
-
if (!started)
|
|
805
|
+
if (!started)
|
|
806
|
+
throw new ValidationError(
|
|
807
|
+
`Scheduled action "${id}" could not be started (see the scheduled_action.invalid warning).`
|
|
808
|
+
);
|
|
708
809
|
return started;
|
|
709
810
|
}
|
|
710
811
|
|
|
@@ -713,7 +814,8 @@ export class Scheduler {
|
|
|
713
814
|
const now = this.now();
|
|
714
815
|
const rec = updateTask(this.home, id, (t) => {
|
|
715
816
|
if (!t) throw notFound(id);
|
|
716
|
-
if (t.state !== "missed")
|
|
817
|
+
if (t.state !== "missed")
|
|
818
|
+
throw new ValidationError(`Scheduled action "${id}" is ${t.state}, not missed: nothing to skip.`);
|
|
717
819
|
const next = this.afterMissed(t, now);
|
|
718
820
|
t.nextCookAt = next.cook;
|
|
719
821
|
t.nextDeliverAt = next.deliver;
|
|
@@ -721,7 +823,12 @@ export class Scheduler {
|
|
|
721
823
|
delete t.current;
|
|
722
824
|
return t;
|
|
723
825
|
});
|
|
724
|
-
emit("skipped", `"${rec.name}" (${id}) skipped its missed run.`, {
|
|
826
|
+
emit("skipped", `"${rec.name}" (${id}) skipped its missed run.`, {
|
|
827
|
+
id,
|
|
828
|
+
name: rec.name,
|
|
829
|
+
reason: "skip",
|
|
830
|
+
nextDeliverAt: rec.nextDeliverAt,
|
|
831
|
+
});
|
|
725
832
|
this.ensureRunning();
|
|
726
833
|
return rec;
|
|
727
834
|
}
|
|
@@ -809,31 +916,35 @@ export function createScheduledAction(agent: Agent, input: ScheduledActionInput)
|
|
|
809
916
|
const sa = input.scheduled_action;
|
|
810
917
|
const lead = sa.startCookingInAdvance ?? DEFAULT_COOK_LEAD;
|
|
811
918
|
const deliverAt = resolveDelivery(now, sa.timeToDeliver, sa.meridiem);
|
|
812
|
-
const record = createTask(
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
919
|
+
const record = createTask(
|
|
920
|
+
s.home,
|
|
921
|
+
() => `${sa.id}-${randomSuffix()}`,
|
|
922
|
+
(id) => {
|
|
923
|
+
const rec: ScheduledActionRecord = {
|
|
924
|
+
id,
|
|
925
|
+
name: sa.name.trim(),
|
|
926
|
+
agent: agent.name,
|
|
927
|
+
...(input.role ? { role: input.role } : {}),
|
|
928
|
+
instructions: input.instructions,
|
|
929
|
+
task: input.task,
|
|
930
|
+
tools: input.tools ?? [],
|
|
931
|
+
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
932
|
+
schedule: {
|
|
933
|
+
timeToDeliver: sa.timeToDeliver,
|
|
934
|
+
meridiem: sa.meridiem,
|
|
935
|
+
startCookingInAdvance: lead,
|
|
936
|
+
...(sa.repeatEvery !== undefined ? { repeatEvery: sa.repeatEvery } : {}),
|
|
937
|
+
...(sa.retries !== undefined ? { retries: sa.retries } : {}),
|
|
938
|
+
...(sa.onMissed !== undefined ? { onMissed: sa.onMissed } : {}),
|
|
939
|
+
},
|
|
940
|
+
state: "waiting",
|
|
941
|
+
nextCookAt: toLocalIso(cookTime(deliverAt, durationMs(lead, "scheduled_action.startCookingInAdvance"))),
|
|
942
|
+
nextDeliverAt: toLocalIso(deliverAt),
|
|
943
|
+
createdAt: toLocalIso(now),
|
|
944
|
+
};
|
|
945
|
+
return rec;
|
|
946
|
+
}
|
|
947
|
+
);
|
|
837
948
|
s.agents.set(agent.name, new WeakRef(agent));
|
|
838
949
|
emit("created", `"${record.name}" (${record.id}) scheduled for ${record.nextDeliverAt}.`, {
|
|
839
950
|
id: record.id,
|
|
@@ -857,7 +968,11 @@ export function listScheduledActions(opts?: HomeOptions): Array<ScheduledActionR
|
|
|
857
968
|
* recomputes the schedule (`DD` relative to now); schedule changes are
|
|
858
969
|
* refused while the action is cooking.
|
|
859
970
|
*/
|
|
860
|
-
export function updateScheduledAction(
|
|
971
|
+
export function updateScheduledAction(
|
|
972
|
+
id: string,
|
|
973
|
+
patch: ScheduledActionPatch,
|
|
974
|
+
opts?: HomeOptions
|
|
975
|
+
): ScheduledActionRecord {
|
|
861
976
|
const s = schedulerFor(opts);
|
|
862
977
|
const now = s.now();
|
|
863
978
|
const sa = patch.scheduled_action ?? {};
|
|
@@ -868,11 +983,14 @@ export function updateScheduledAction(id: string, patch: ScheduledActionPatch, o
|
|
|
868
983
|
if (stored) throw new ValidationError(`Scheduled action "${id}" is invalid in its task.json: ${stored}`);
|
|
869
984
|
const scheduleChanged = Object.values(scheduleChange).some((v) => v !== undefined);
|
|
870
985
|
if (scheduleChanged && t.state === "cooking") {
|
|
871
|
-
throw new ValidationError(
|
|
986
|
+
throw new ValidationError(
|
|
987
|
+
`Scheduled action "${id}" is cooking right now; schedule changes are refused while it runs. Try again after it finishes.`
|
|
988
|
+
);
|
|
872
989
|
}
|
|
873
990
|
const deliveryChanged = scheduleChange.timeToDeliver !== undefined || scheduleChange.meridiem !== undefined;
|
|
874
991
|
const merged = { ...t.schedule };
|
|
875
|
-
for (const [key, value] of Object.entries(scheduleChange))
|
|
992
|
+
for (const [key, value] of Object.entries(scheduleChange))
|
|
993
|
+
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
|
|
876
994
|
|
|
877
995
|
const problems: string[] = [];
|
|
878
996
|
const pool = s.agents.get(t.agent)?.deref()?.dynamicSubagents?.tools;
|
|
@@ -903,7 +1021,12 @@ export function updateScheduledAction(id: string, patch: ScheduledActionPatch, o
|
|
|
903
1021
|
}
|
|
904
1022
|
return t;
|
|
905
1023
|
});
|
|
906
|
-
emit("updated", `"${rec.name}" (${id}) updated.`, {
|
|
1024
|
+
emit("updated", `"${rec.name}" (${id}) updated.`, {
|
|
1025
|
+
id,
|
|
1026
|
+
name: rec.name,
|
|
1027
|
+
nextCookAt: rec.nextCookAt,
|
|
1028
|
+
nextDeliverAt: rec.nextDeliverAt,
|
|
1029
|
+
});
|
|
907
1030
|
s.ensureRunning();
|
|
908
1031
|
return rec;
|
|
909
1032
|
}
|
|
@@ -923,6 +1046,9 @@ export function skipScheduledAction(id: string, opts?: HomeOptions): ScheduledAc
|
|
|
923
1046
|
* `force: true` (the run is aborted, then deleted). Past run files are kept
|
|
924
1047
|
* unless `purge: true`.
|
|
925
1048
|
*/
|
|
926
|
-
export function deleteScheduledAction(
|
|
1049
|
+
export function deleteScheduledAction(
|
|
1050
|
+
id: string,
|
|
1051
|
+
opts?: HomeOptions & { force?: boolean; purge?: boolean }
|
|
1052
|
+
): Promise<DeleteResult> {
|
|
927
1053
|
return schedulerFor(opts).delete(id, { force: opts?.force, purge: opts?.purge });
|
|
928
1054
|
}
|
package/src/scheduler/store.ts
CHANGED
|
@@ -17,8 +17,6 @@ import * as path from "node:path";
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { readWithBackup, withFileMutex, writeFileDurable } from "../utils/durable.ts";
|
|
19
19
|
import { telemetryBus } from "../telemetry.ts";
|
|
20
|
-
import { resolveHome } from "../utils/home.ts";
|
|
21
|
-
export { resolveHome };
|
|
22
20
|
import { ValidationError } from "../types/errors.ts";
|
|
23
21
|
import type { CostSource } from "../session/usage.ts";
|
|
24
22
|
import type { ScheduledActionRecord, ScheduledRunStatus } from "../types/scheduler.ts";
|
|
@@ -106,7 +104,9 @@ export interface Heartbeat {
|
|
|
106
104
|
|
|
107
105
|
/** Marks an action as running in this process (pid, host, token); written before the record flips to `cooking`; in: startCook, out: `<id>/.heartbeat`. */
|
|
108
106
|
export function writeHeartbeat(home: string, id: string, token: string): void {
|
|
109
|
-
fs.writeFileSync(heartbeatFile(taskDir(home, id)), JSON.stringify({ pid: process.pid, host: os.hostname(), token }), {
|
|
107
|
+
fs.writeFileSync(heartbeatFile(taskDir(home, id)), JSON.stringify({ pid: process.pid, host: os.hostname(), token }), {
|
|
108
|
+
mode: 0o600,
|
|
109
|
+
});
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** Refreshes the heartbeat's modification time; false when the file is gone; in: the scheduler tick for each run in flight. */
|
|
@@ -147,7 +147,8 @@ const isMissingFolder = (err: unknown): boolean => (err as { code?: string } | n
|
|
|
147
147
|
// Deletes the leftover temp files of a task.json / delivery.json write that never finished (a killed process); only called while holding the action's mutex, which is what makes it safe (run files are written outside the mutex, so they are never touched); in: the three mutating helpers, out: nothing.
|
|
148
148
|
function sweepShardTemps(dir: string): void {
|
|
149
149
|
try {
|
|
150
|
-
for (const name of fs.readdirSync(dir))
|
|
150
|
+
for (const name of fs.readdirSync(dir))
|
|
151
|
+
if (/^(task|delivery)\.json\.tmp-\d+-[0-9a-z]+$/.test(name)) fs.rmSync(path.join(dir, name), { force: true });
|
|
151
152
|
} catch {
|
|
152
153
|
// why: an unreadable folder or an undeletable leftover is harmless.
|
|
153
154
|
}
|
|
@@ -288,7 +289,8 @@ export function updateTask<T>(home: string, id: string, fn: (rec: ScheduledActio
|
|
|
288
289
|
const rec = readTask(home, id);
|
|
289
290
|
const before = rec ? JSON.stringify(rec) : "";
|
|
290
291
|
const result = fn(rec);
|
|
291
|
-
if (rec && JSON.stringify(rec) !== before)
|
|
292
|
+
if (rec && JSON.stringify(rec) !== before)
|
|
293
|
+
writeFileDurable(taskFile(dir), JSON.stringify({ version: 2, task: rec }, null, 2) + "\n");
|
|
292
294
|
return result;
|
|
293
295
|
});
|
|
294
296
|
} catch (err) {
|
|
@@ -301,7 +303,11 @@ export function updateTask<T>(home: string, id: string, fn: (rec: ScheduledActio
|
|
|
301
303
|
* Creates an action in a new folder. The folder is made with a create-only `mkdir`, so two processes can never take the
|
|
302
304
|
* same id; `nextId` is called again until a free one is found, then `build` makes the record for it.
|
|
303
305
|
*/
|
|
304
|
-
export function createTask(
|
|
306
|
+
export function createTask(
|
|
307
|
+
home: string,
|
|
308
|
+
nextId: () => string,
|
|
309
|
+
build: (id: string) => ScheduledActionRecord
|
|
310
|
+
): ScheduledActionRecord {
|
|
305
311
|
fs.mkdirSync(baseDir(home), { recursive: true, mode: 0o700 });
|
|
306
312
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
307
313
|
const id = nextId();
|
|
@@ -330,7 +336,8 @@ export function deleteTask(home: string, id: string): boolean {
|
|
|
330
336
|
try {
|
|
331
337
|
removed = withFileMutex(mutexFile(dir), () => {
|
|
332
338
|
const had = fs.existsSync(taskFile(dir)) || fs.existsSync(deliveryFile(dir));
|
|
333
|
-
for (const f of [taskFile(dir), deliveryFile(dir)])
|
|
339
|
+
for (const f of [taskFile(dir), deliveryFile(dir)])
|
|
340
|
+
fs.rmSync(f, { force: true }), fs.rmSync(`${f}.bak`, { force: true });
|
|
334
341
|
fs.rmSync(heartbeatFile(dir), { force: true });
|
|
335
342
|
return had;
|
|
336
343
|
});
|
|
@@ -352,7 +359,8 @@ export function readDeliveries(home: string): PendingDelivery[] {
|
|
|
352
359
|
for (const folder of actionFolders(home)) {
|
|
353
360
|
if (!TASK_ID_PATTERN.test(folder) || folder.length > MAX_TASK_ID_LENGTH) continue;
|
|
354
361
|
const file = deliveryFile(taskDir(home, folder));
|
|
355
|
-
for (const entry of readShard(file, parsePending) ?? [])
|
|
362
|
+
for (const entry of readShard(file, parsePending) ?? [])
|
|
363
|
+
if (inOwnFolder(file, folder, entry["id"])) out.push(entry as unknown as PendingDelivery);
|
|
356
364
|
}
|
|
357
365
|
return out.sort((a, b) => String(a.deliverAt).localeCompare(String(b.deliverAt)));
|
|
358
366
|
}
|
|
@@ -441,7 +449,9 @@ const deliverySchema = z.object({
|
|
|
441
449
|
/** `undefined` when a `delivery.json` entry is usable, else a one-line reason. Never throws. */
|
|
442
450
|
export function validateDelivery(raw: unknown): string | undefined {
|
|
443
451
|
const parsed = deliverySchema.safeParse(raw);
|
|
444
|
-
return parsed.success
|
|
452
|
+
return parsed.success
|
|
453
|
+
? undefined
|
|
454
|
+
: parsed.error.issues.map((i) => `${i.path.join(".") || "entry"}: ${i.message}`).join("; ");
|
|
445
455
|
}
|
|
446
456
|
|
|
447
457
|
// ---------------------------------------------------------------------------
|
|
@@ -472,7 +482,10 @@ export function writeRunFile(home: string, id: string, deliverAt: Date, header:
|
|
|
472
482
|
}
|
|
473
483
|
|
|
474
484
|
/** Reads a run file (header fields as strings + result text). `undefined` when missing or outside the folder. */
|
|
475
|
-
export function readRun(
|
|
485
|
+
export function readRun(
|
|
486
|
+
home: string,
|
|
487
|
+
relativeFile: string
|
|
488
|
+
): { header: Record<string, string>; body: string } | undefined {
|
|
476
489
|
const file = runPath(home, relativeFile);
|
|
477
490
|
if (!file.startsWith(baseDir(home) + path.sep)) return undefined;
|
|
478
491
|
let text: string;
|
package/src/scheduler/time.ts
CHANGED
|
@@ -7,8 +7,18 @@
|
|
|
7
7
|
import { ValidationError } from "../types/errors.ts";
|
|
8
8
|
|
|
9
9
|
const MONTHS = [
|
|
10
|
-
"january",
|
|
11
|
-
"
|
|
10
|
+
"january",
|
|
11
|
+
"february",
|
|
12
|
+
"march",
|
|
13
|
+
"april",
|
|
14
|
+
"may",
|
|
15
|
+
"june",
|
|
16
|
+
"july",
|
|
17
|
+
"august",
|
|
18
|
+
"september",
|
|
19
|
+
"october",
|
|
20
|
+
"november",
|
|
21
|
+
"december",
|
|
12
22
|
];
|
|
13
23
|
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
14
24
|
|
|
@@ -21,7 +31,11 @@ const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
|
21
31
|
const show = (v: unknown): string => JSON.stringify(v) ?? String(v);
|
|
22
32
|
|
|
23
33
|
/** Parses `DD:HH:MM` (DD >= 2 digits, MM < 60). HH is range-checked by the caller. */
|
|
24
|
-
export function parseDDHHMM(
|
|
34
|
+
export function parseDDHHMM(
|
|
35
|
+
value: unknown,
|
|
36
|
+
field: string,
|
|
37
|
+
example = "00:07:00"
|
|
38
|
+
): { dd: number; hh: number; mm: number } {
|
|
25
39
|
const m = typeof value === "string" ? DDHHMM.exec(value) : null;
|
|
26
40
|
if (!m || Number(m[3]) > 59) {
|
|
27
41
|
throw new ValidationError(
|