@app-vitals/agent-runtime 1.2.4 → 1.2.5

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/boot.ts CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  HttpCronRunReporter,
64
64
  NoopCronRunReporter,
65
65
  } from "./cron-run-reporter.ts";
66
+ import { reconcileCronTasks, type ScheduledEntry } from "./cron-sync.ts";
66
67
  import { markdownToSlack } from "./format.ts";
67
68
  import { buildGitHubAuthDeps } from "./github-auth-deps.ts";
68
69
  import {
@@ -728,7 +729,10 @@ export async function startAgent(
728
729
 
729
730
  // ─── Step 6: Cron sync loop ───────────────────────────────────────────────────
730
731
 
731
- const cronTasks = new Map<string, ReturnType<typeof nodeCron.schedule>>();
732
+ const cronTasks = new Map<
733
+ string,
734
+ ScheduledEntry<ReturnType<typeof nodeCron.schedule>>
735
+ >();
732
736
 
733
737
  if (runtimeClient && agentId) {
734
738
  // Run-now requests (COS-42.1): honoured at most once each, in memory.
@@ -799,7 +803,7 @@ export async function startAgent(
799
803
  // kind (loop vs. generic). See planCronSchedule in agent-extension.ts and
800
804
  // the coding extension's loop-cron-classifier.ts for the full decision.
801
805
  const scheduled = planCronSchedule(extension, jobs);
802
- const desired = new Map(scheduled.map((entry) => [entry.job.id, entry]));
806
+ const desired = new Set(scheduled.map((entry) => entry.job.id));
803
807
  dispatchKinds = new Map(
804
808
  scheduled.map((entry) => [entry.job.id, entry.dispatch]),
805
809
  );
@@ -808,54 +812,43 @@ export async function startAgent(
808
812
  // Only scheduled (enabled) jobs: a manually disabled cron is not run.
809
813
  void honourRunNow(jobs.filter((job) => desired.has(job.id)));
810
814
 
811
- // Cancel removed/disabled jobs
812
- for (const [id, task] of cronTasks) {
813
- if (!desired.has(id)) {
814
- task.stop();
815
- cronTasks.delete(id);
816
- console.log(`[cron-sync] unscheduled ${id}`);
817
- }
818
- }
819
-
820
- // Schedule new jobs
821
- for (const [id, entry] of desired) {
822
- if (!cronTasks.has(id)) {
823
- const { job, dispatch } = entry;
824
- const task = nodeCron.schedule(job.schedule, async () => {
825
- console.log(`[cron] firing job ${id}`);
826
- try {
827
- if (dispatch === "loop") {
828
- if (!extension.dispatchLoop) {
829
- throw new Error(
830
- `cron job ${id} classified as loop but the agent extension has no dispatchLoop`,
831
- );
832
- }
833
- await extension.dispatchLoop(id);
834
- } else {
835
- await handleCronRequest(
836
- {
837
- jobId: id,
838
- prompt: job.prompt,
839
- channel: job.channel ?? undefined,
840
- user: job.user ?? undefined,
841
- silent: job.silent,
842
- preCheck: job.preCheck ?? undefined,
843
- },
844
- cronDeps,
815
+ // Unschedule removed/disabled jobs, schedule new ones, and reschedule any
816
+ // job whose schedule, prompt, channel, user, silent, preCheck or dispatch
817
+ // kind changed since the last tick.
818
+ reconcileCronTasks(cronTasks, scheduled, (job, dispatch) => {
819
+ const id = job.id;
820
+ return nodeCron.schedule(job.schedule, async () => {
821
+ console.log(`[cron] firing job ${id}`);
822
+ try {
823
+ if (dispatch === "loop") {
824
+ if (!extension.dispatchLoop) {
825
+ throw new Error(
826
+ `cron job ${id} classified as loop but the agent extension has no dispatchLoop`,
845
827
  );
846
828
  }
847
- } catch (err) {
848
- await reportCronFailure(id, err, {
849
- cronRunReporter: cronRunReporter ?? new NoopCronRunReporter(),
850
- sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
851
- clock: SystemClock(),
852
- });
829
+ await extension.dispatchLoop(id);
830
+ } else {
831
+ await handleCronRequest(
832
+ {
833
+ jobId: id,
834
+ prompt: job.prompt,
835
+ channel: job.channel ?? undefined,
836
+ user: job.user ?? undefined,
837
+ silent: job.silent,
838
+ preCheck: job.preCheck ?? undefined,
839
+ },
840
+ cronDeps,
841
+ );
853
842
  }
854
- });
855
- cronTasks.set(id, task);
856
- console.log(`[cron-sync] scheduled ${id} (${job.schedule})`);
857
- }
858
- }
843
+ } catch (err) {
844
+ await reportCronFailure(id, err, {
845
+ cronRunReporter: cronRunReporter ?? new NoopCronRunReporter(),
846
+ sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
847
+ clock: SystemClock(),
848
+ });
849
+ }
850
+ });
851
+ });
859
852
  }
860
853
 
861
854
  void syncCrons();
@@ -940,7 +933,7 @@ export async function startAgent(
940
933
  markSlackDisconnected(slackClock);
941
934
 
942
935
  // Stop cron tasks so no new Claude work fires during drain
943
- for (const [id, task] of cronTasks) {
936
+ for (const [id, { task }] of cronTasks) {
944
937
  task.stop();
945
938
  console.log(`[agent] cron unscheduled on shutdown: ${id}`);
946
939
  }
package/cron-sync.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * runtime/cron-sync.ts
3
+ * Reconciles scheduled cron tasks against the desired job list on each sync
4
+ * tick. A job whose schedule, prompt, channel, user, silent, preCheck or
5
+ * dispatch kind changed is stopped and rescheduled; unchanged jobs are left
6
+ * alone. The scheduler is injected so the logic is testable without node-cron.
7
+ */
8
+
9
+ import type {
10
+ CronDispatchKind,
11
+ ExtensionCronJob,
12
+ ScheduledCronJob,
13
+ } from "./agent-extension.ts";
14
+
15
+ export interface SyncableCronJob extends ExtensionCronJob {
16
+ schedule: string;
17
+ prompt: string;
18
+ channel: string | null;
19
+ user: string | null;
20
+ silent: boolean;
21
+ preCheck: string | null;
22
+ }
23
+
24
+ export interface StoppableTask {
25
+ stop(): void;
26
+ }
27
+
28
+ export interface ScheduledEntry<TTask extends StoppableTask> {
29
+ task: TTask;
30
+ signature: string;
31
+ }
32
+
33
+ /** Everything that, when edited, must take effect on the next sync tick. */
34
+ export function cronSignature(
35
+ job: SyncableCronJob,
36
+ dispatch: CronDispatchKind,
37
+ ): string {
38
+ return JSON.stringify([
39
+ job.schedule,
40
+ job.prompt,
41
+ job.channel,
42
+ job.user,
43
+ job.silent,
44
+ job.preCheck,
45
+ dispatch,
46
+ ]);
47
+ }
48
+
49
+ export function reconcileCronTasks<
50
+ TJob extends SyncableCronJob,
51
+ TTask extends StoppableTask,
52
+ >(
53
+ scheduled: Map<string, ScheduledEntry<TTask>>,
54
+ desired: ScheduledCronJob<TJob>[],
55
+ schedule: (job: TJob, dispatch: CronDispatchKind) => TTask,
56
+ log: (message: string) => void = console.log,
57
+ ): void {
58
+ const desiredIds = new Set(desired.map((entry) => entry.job.id));
59
+
60
+ for (const [id, existing] of scheduled) {
61
+ if (!desiredIds.has(id)) {
62
+ existing.task.stop();
63
+ scheduled.delete(id);
64
+ log(`[cron-sync] unscheduled ${id}`);
65
+ }
66
+ }
67
+
68
+ for (const { job, dispatch } of desired) {
69
+ const signature = cronSignature(job, dispatch);
70
+ const existing = scheduled.get(job.id);
71
+ if (existing?.signature === signature) continue;
72
+
73
+ existing?.task.stop();
74
+ scheduled.set(job.id, { task: schedule(job, dispatch), signature });
75
+ log(
76
+ existing
77
+ ? `[cron-sync] rescheduled ${job.id} (${job.schedule})`
78
+ : `[cron-sync] scheduled ${job.id} (${job.schedule})`,
79
+ );
80
+ }
81
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * runtime/cron-sync.unit.test.ts
3
+ * Reconcile logic for the 60s cron sync tick, with a fake scheduler.
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import type { CronDispatchKind } from "./agent-extension.ts";
8
+ import {
9
+ reconcileCronTasks,
10
+ type ScheduledEntry,
11
+ type SyncableCronJob,
12
+ } from "./cron-sync.ts";
13
+
14
+ function job(over: Partial<SyncableCronJob> = {}): SyncableCronJob {
15
+ return {
16
+ id: "job-1",
17
+ name: "Job",
18
+ enabled: true,
19
+ parentCronId: null,
20
+ schedule: "0 * * * *",
21
+ prompt: "do it",
22
+ channel: "C1",
23
+ user: "U1",
24
+ silent: false,
25
+ preCheck: null,
26
+ ...over,
27
+ };
28
+ }
29
+
30
+ function harness() {
31
+ const created: { id: string; schedule: string; stopped: boolean }[] = [];
32
+ const logs: string[] = [];
33
+ const scheduled = new Map<string, ScheduledEntry<{ stop(): void }>>();
34
+ const schedule = (j: SyncableCronJob) => {
35
+ const rec = { id: j.id, schedule: j.schedule, stopped: false };
36
+ created.push(rec);
37
+ return {
38
+ stop() {
39
+ rec.stopped = true;
40
+ },
41
+ };
42
+ };
43
+ const tick = (
44
+ jobs: SyncableCronJob[],
45
+ dispatch: CronDispatchKind = "generic",
46
+ ) =>
47
+ reconcileCronTasks(
48
+ scheduled,
49
+ jobs.map((j) => ({ job: j, dispatch })),
50
+ schedule,
51
+ (m) => logs.push(m),
52
+ );
53
+ return { created, logs, scheduled, tick };
54
+ }
55
+
56
+ describe("reconcileCronTasks", () => {
57
+ it("schedules a new job", () => {
58
+ const h = harness();
59
+ h.tick([job()]);
60
+ expect(h.created).toHaveLength(1);
61
+ expect(h.logs).toEqual(["[cron-sync] scheduled job-1 (0 * * * *)"]);
62
+ });
63
+
64
+ it("does not reschedule an unchanged job", () => {
65
+ const h = harness();
66
+ h.tick([job()]);
67
+ h.tick([job()]);
68
+ expect(h.created).toHaveLength(1);
69
+ expect(h.created[0]?.stopped).toBe(false);
70
+ });
71
+
72
+ it("reschedules when the schedule changes", () => {
73
+ const h = harness();
74
+ h.tick([job()]);
75
+ h.tick([job({ schedule: "*/5 * * * *" })]);
76
+ expect(h.created).toHaveLength(2);
77
+ expect(h.created[0]?.stopped).toBe(true);
78
+ expect(h.created[1]?.schedule).toBe("*/5 * * * *");
79
+ expect(h.logs.at(-1)).toContain("[cron-sync] rescheduled job-1");
80
+ });
81
+
82
+ it("reschedules on a prompt-only change", () => {
83
+ const h = harness();
84
+ h.tick([job()]);
85
+ h.tick([job({ prompt: "new prompt" })]);
86
+ expect(h.created).toHaveLength(2);
87
+ expect(h.created[0]?.stopped).toBe(true);
88
+ expect(h.created[1]?.stopped).toBe(false);
89
+ });
90
+
91
+ it("reschedules when channel, user, silent or preCheck change", () => {
92
+ const h = harness();
93
+ h.tick([job()]);
94
+ h.tick([job({ channel: "C2" })]);
95
+ h.tick([job({ channel: "C2", user: "U2" })]);
96
+ h.tick([job({ channel: "C2", user: "U2", silent: true })]);
97
+ h.tick([job({ channel: "C2", user: "U2", silent: true, preCheck: "x" })]);
98
+ expect(h.created).toHaveLength(5);
99
+ });
100
+
101
+ it("reschedules when the dispatch kind changes", () => {
102
+ const h = harness();
103
+ h.tick([job()], "generic");
104
+ h.tick([job()], "loop");
105
+ expect(h.created).toHaveLength(2);
106
+ expect(h.created[0]?.stopped).toBe(true);
107
+ });
108
+
109
+ it("unschedules a disabled job (absent from desired)", () => {
110
+ const h = harness();
111
+ h.tick([job()]);
112
+ h.tick([]);
113
+ expect(h.created[0]?.stopped).toBe(true);
114
+ expect(h.scheduled.size).toBe(0);
115
+ expect(h.logs.at(-1)).toBe("[cron-sync] unscheduled job-1");
116
+ });
117
+
118
+ it("unschedules a deleted job without touching its siblings", () => {
119
+ const h = harness();
120
+ h.tick([job(), job({ id: "job-2" })]);
121
+ h.tick([job({ id: "job-2" })]);
122
+ expect(h.created.find((c) => c.id === "job-1")?.stopped).toBe(true);
123
+ expect(h.created.find((c) => c.id === "job-2")?.stopped).toBe(false);
124
+ expect([...h.scheduled.keys()]).toEqual(["job-2"]);
125
+ });
126
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -40,8 +40,8 @@
40
40
  "./workspace-paths": "./workspace-paths.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@app-vitals/agent-admin": "1.2.4",
44
- "@app-vitals/agent-lib": "1.2.4",
43
+ "@app-vitals/agent-admin": "1.2.5",
44
+ "@app-vitals/agent-lib": "1.2.5",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",