@app-vitals/agent-runtime 0.3.0 → 0.4.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/boot.ts CHANGED
@@ -58,6 +58,7 @@ import { createContextStampReader } from "./context-stamp.ts";
58
58
  import { reportCronFailure } from "./cron-failure-reporter.ts";
59
59
  import type { CronHandlerDeps } from "./cron-handler.ts";
60
60
  import { handleCronRequest } from "./cron-handler.ts";
61
+ import { createRunNowHonourer } from "./cron-run-now.ts";
61
62
  import {
62
63
  HttpCronRunReporter,
63
64
  NoopCronRunReporter,
@@ -684,6 +685,54 @@ export async function startAgent(
684
685
  const cronTasks = new Map<string, ReturnType<typeof nodeCron.schedule>>();
685
686
 
686
687
  if (runtimeClient && agentId) {
688
+ // Run-now requests (COS-42.1): honoured at most once each, in memory.
689
+ // Dispatch kind per job from the latest sync, so run-now takes the same
690
+ // path a scheduled tick would (loop-classified crons go through dispatchLoop).
691
+ let dispatchKinds = new Map<string, "loop" | "generic">();
692
+ const honourRunNow = createRunNowHonourer({
693
+ clock: SystemClock(),
694
+ dispatch: async (req) => {
695
+ if (dispatchKinds.get(req.jobId) === "loop") {
696
+ if (!extension.dispatchLoop) {
697
+ throw new Error(
698
+ `cron job ${req.jobId} classified as loop but the agent extension has no dispatchLoop`,
699
+ );
700
+ }
701
+ // dispatchLoop creates no run-now run of its own, so record one here:
702
+ // creating it clears the stamp and flips the audit row to honoured.
703
+ const reporter = cronRunReporter ?? new NoopCronRunReporter();
704
+ const startedAt = SystemClock().now();
705
+ const runId = await reporter.createRun(
706
+ req.jobId,
707
+ startedAt,
708
+ undefined,
709
+ undefined,
710
+ undefined,
711
+ req.commandVariant,
712
+ );
713
+ let outcome: "completed" | "failed" = "completed";
714
+ try {
715
+ await extension.dispatchLoop(req.jobId);
716
+ } catch (err) {
717
+ outcome = "failed";
718
+ throw err;
719
+ } finally {
720
+ await reporter
721
+ .completeRun(req.jobId, runId, SystemClock().now(), outcome)
722
+ .catch(() => {});
723
+ }
724
+ return;
725
+ }
726
+ await handleCronRequest(req, cronDeps);
727
+ },
728
+ onError: (jobId, err) =>
729
+ reportCronFailure(jobId, err, {
730
+ cronRunReporter: cronRunReporter ?? new NoopCronRunReporter(),
731
+ sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
732
+ clock: SystemClock(),
733
+ }),
734
+ });
735
+
687
736
  async function syncCrons() {
688
737
  if (!runtimeClient || !agentId) return;
689
738
  let jobs: Awaited<ReturnType<typeof runtimeClient.listAgentCronJobs>>;
@@ -705,6 +754,13 @@ export async function startAgent(
705
754
  // the coding extension's loop-cron-classifier.ts for the full decision.
706
755
  const scheduled = planCronSchedule(extension, jobs);
707
756
  const desired = new Map(scheduled.map((entry) => [entry.job.id, entry]));
757
+ dispatchKinds = new Map(
758
+ scheduled.map((entry) => [entry.job.id, entry.dispatch]),
759
+ );
760
+
761
+ // Fire-and-forget: a run can take minutes and must not hold up the sync.
762
+ // Only scheduled (enabled) jobs: a manually disabled cron is not run.
763
+ void honourRunNow(jobs.filter((job) => desired.has(job.id)));
708
764
 
709
765
  // Cancel removed/disabled jobs
710
766
  for (const [id, task] of cronTasks) {
package/cron-handler.ts CHANGED
@@ -162,6 +162,8 @@ interface CronRequest {
162
162
  user?: string;
163
163
  silent?: boolean;
164
164
  preCheck?: string;
165
+ /** Run-attribution tag, e.g. "run-now" (COS-42.1); passed to createRun. */
166
+ commandVariant?: string;
165
167
  }
166
168
 
167
169
  export interface CronHandlerDeps {
@@ -266,7 +268,14 @@ export async function handleCronRequest(
266
268
 
267
269
  // CREATE run at start (before preCheck)
268
270
  const runId = cronRunReporter
269
- ? await cronRunReporter.createRun(jobId, startedAt)
271
+ ? await cronRunReporter.createRun(
272
+ jobId,
273
+ startedAt,
274
+ undefined,
275
+ undefined,
276
+ undefined,
277
+ req.commandVariant,
278
+ )
270
279
  : null;
271
280
 
272
281
  // Best-effort redacted transcript upload (COS-43.1). Runs only after the
@@ -1176,6 +1176,34 @@ describe("handleCronRequest — telemetry forwarding", () => {
1176
1176
  });
1177
1177
  });
1178
1178
 
1179
+ describe("handleCronRequest — commandVariant (COS-42.1)", () => {
1180
+ test("a run-now request creates its run with commandVariant run-now", async () => {
1181
+ const created: unknown[][] = [];
1182
+ const reporter: CronRunReporter = {
1183
+ async createRun(...args: unknown[]) {
1184
+ created.push(args);
1185
+ return "run-now-1";
1186
+ },
1187
+ async completeRun() {},
1188
+ async skipRun() {},
1189
+ async recordProgress() {},
1190
+ async recordSessionId() {},
1191
+ };
1192
+ await handleCronRequest(
1193
+ {
1194
+ jobId: "j-now",
1195
+ prompt: "hello",
1196
+ silent: true,
1197
+ commandVariant: "run-now",
1198
+ },
1199
+ { ...deps, cronRunReporter: reporter },
1200
+ );
1201
+ expect(created).toHaveLength(1);
1202
+ expect(created[0]?.[0]).toBe("j-now");
1203
+ expect(created[0]?.[5]).toBe("run-now");
1204
+ });
1205
+ });
1206
+
1179
1207
  // ─── transcript upload hook (COS-43.1) ───────────────────────────────────────
1180
1208
 
1181
1209
  describe("handleCronRequest — transcript upload (COS-43.1)", () => {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * runtime/cron-run-now.ts
3
+ *
4
+ * Honours cron run-now requests (COS-42.1). The admin stamps
5
+ * `runRequestedAt` on a stored cron job; on each cron sync tick the runtime
6
+ * runs the job once when the stamp is strictly newer than the last one it
7
+ * honoured (held in memory) and at most 15 minutes old. An older stamp is
8
+ * logged as expired and never run. The request is dispatched with the job's
9
+ * own stored fields, never a prompt from anywhere else, and tagged
10
+ * commandVariant "run-now" so run creation clears the stamp admin-side.
11
+ */
12
+
13
+ import type { Clock } from "./clock.ts";
14
+
15
+ /** A request older than this is expired and never run. */
16
+ export const RUN_NOW_FRESH_MS = 15 * 60_000;
17
+
18
+ /** The stored cron job fields the honourer reads. */
19
+ export interface RunNowJob {
20
+ id: string;
21
+ prompt: string;
22
+ channel?: string | null;
23
+ user?: string | null;
24
+ silent?: boolean;
25
+ preCheck?: string | null;
26
+ /** A manually disabled job is never run, even with a fresh stamp. */
27
+ enabled?: boolean;
28
+ /** ISO string on the wire (typed Date by the generated client). */
29
+ runRequestedAt?: string | Date | null;
30
+ }
31
+
32
+ export interface RunNowRequest {
33
+ jobId: string;
34
+ prompt: string;
35
+ channel?: string;
36
+ user?: string;
37
+ silent?: boolean;
38
+ preCheck?: string;
39
+ commandVariant: "run-now";
40
+ }
41
+
42
+ interface RunNowHonourerDeps {
43
+ clock: Clock;
44
+ /** Runs the job (handleCronRequest in production). */
45
+ dispatch: (req: RunNowRequest) => Promise<void>;
46
+ /** Called when a dispatch throws; the request is not retried. */
47
+ onError?: (jobId: string, err: unknown) => void | Promise<void>;
48
+ log?: (line: string) => void;
49
+ }
50
+
51
+ /**
52
+ * Returns a function to call with the fresh job list on every sync tick. It
53
+ * resolves once every dispatch it started has settled; callers that must not
54
+ * wait on long runs simply do not await it.
55
+ */
56
+ export function createRunNowHonourer(
57
+ deps: RunNowHonourerDeps,
58
+ ): (jobs: readonly RunNowJob[]) => Promise<void> {
59
+ const { clock, dispatch, onError, log = console.log } = deps;
60
+ // jobId -> last honoured / last expired-logged runRequestedAt (epoch ms).
61
+ const lastHonoured = new Map<string, number>();
62
+ const expiredLogged = new Map<string, number>();
63
+
64
+ return async (jobs) => {
65
+ const running: Promise<void>[] = [];
66
+ for (const job of jobs) {
67
+ if (!job.runRequestedAt || job.enabled === false) continue;
68
+ const requestedMs = new Date(job.runRequestedAt).getTime();
69
+ if (Number.isNaN(requestedMs)) continue;
70
+ const requested = new Date(requestedMs).toISOString();
71
+
72
+ const seen = lastHonoured.get(job.id);
73
+ if (seen !== undefined && requestedMs <= seen) continue;
74
+
75
+ if (clock.now().getTime() - requestedMs > RUN_NOW_FRESH_MS) {
76
+ if (expiredLogged.get(job.id) !== requestedMs) {
77
+ expiredLogged.set(job.id, requestedMs);
78
+ log(
79
+ JSON.stringify({
80
+ event: "cron-run-now",
81
+ status: "expired",
82
+ jobId: job.id,
83
+ requestedAt: requested,
84
+ }),
85
+ );
86
+ }
87
+ continue;
88
+ }
89
+
90
+ // Recorded before dispatch so a slow or failing run is never repeated.
91
+ lastHonoured.set(job.id, requestedMs);
92
+ log(
93
+ JSON.stringify({
94
+ event: "cron-run-now",
95
+ status: "honouring",
96
+ jobId: job.id,
97
+ requestedAt: requested,
98
+ }),
99
+ );
100
+ running.push(
101
+ dispatch({
102
+ jobId: job.id,
103
+ prompt: job.prompt,
104
+ channel: job.channel ?? undefined,
105
+ user: job.user ?? undefined,
106
+ silent: job.silent,
107
+ preCheck: job.preCheck ?? undefined,
108
+ commandVariant: "run-now",
109
+ }).catch(async (err) => {
110
+ await onError?.(job.id, err);
111
+ }),
112
+ );
113
+ }
114
+ await Promise.all(running);
115
+ };
116
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * runtime/cron-run-now.unit.test.ts
3
+ * Honour / expire / once logic for cron run-now requests (COS-42.1), with an
4
+ * injected clock and dispatch.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import {
9
+ createRunNowHonourer,
10
+ RUN_NOW_FRESH_MS,
11
+ type RunNowJob,
12
+ } from "./cron-run-now.ts";
13
+
14
+ const NOW = new Date("2026-10-26T12:00:00.000Z");
15
+
16
+ function job(over: Partial<RunNowJob> = {}): RunNowJob {
17
+ return {
18
+ id: "job-1",
19
+ prompt: "stored prompt",
20
+ channel: "C1",
21
+ user: null,
22
+ silent: false,
23
+ preCheck: null,
24
+ runRequestedAt: null,
25
+ ...over,
26
+ };
27
+ }
28
+
29
+ function setup() {
30
+ let nowMs = NOW.getTime();
31
+ const dispatched: Array<Record<string, unknown>> = [];
32
+ const logs: string[] = [];
33
+ const honour = createRunNowHonourer({
34
+ clock: { now: () => new Date(nowMs) },
35
+ dispatch: async (req) => {
36
+ dispatched.push(req as unknown as Record<string, unknown>);
37
+ },
38
+ log: (line) => logs.push(line),
39
+ });
40
+ return {
41
+ honour,
42
+ dispatched,
43
+ logs,
44
+ advance: (ms: number) => {
45
+ nowMs += ms;
46
+ },
47
+ };
48
+ }
49
+
50
+ const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
51
+
52
+ describe("createRunNowHonourer", () => {
53
+ it("never runs a manually disabled job", async () => {
54
+ const t = setup();
55
+ await t.honour([job({ enabled: false, runRequestedAt: ago(1000) })]);
56
+ expect(t.dispatched).toHaveLength(0);
57
+ });
58
+
59
+ it("runs a fresh request once with the stored job fields and run-now variant", async () => {
60
+ const { honour, dispatched } = setup();
61
+ await honour([job({ runRequestedAt: ago(60_000) })]);
62
+ expect(dispatched).toEqual([
63
+ {
64
+ jobId: "job-1",
65
+ prompt: "stored prompt",
66
+ channel: "C1",
67
+ user: undefined,
68
+ silent: false,
69
+ preCheck: undefined,
70
+ commandVariant: "run-now",
71
+ },
72
+ ]);
73
+ });
74
+
75
+ it("does not run the same request twice across ticks", async () => {
76
+ const { honour, dispatched, advance } = setup();
77
+ const j = job({ runRequestedAt: ago(60_000) });
78
+ await honour([j]);
79
+ advance(60_000);
80
+ await honour([j]);
81
+ await honour([j]);
82
+ expect(dispatched).toHaveLength(1);
83
+ });
84
+
85
+ it("runs again for a strictly newer request", async () => {
86
+ const { honour, dispatched, advance } = setup();
87
+ await honour([job({ runRequestedAt: ago(60_000) })]);
88
+ advance(120_000);
89
+ await honour([job({ runRequestedAt: ago(10_000) })]);
90
+ expect(dispatched).toHaveLength(2);
91
+ });
92
+
93
+ it("ignores an older-or-equal stamp than the last honoured", async () => {
94
+ const { honour, dispatched } = setup();
95
+ await honour([job({ runRequestedAt: ago(10_000) })]);
96
+ await honour([job({ runRequestedAt: ago(30_000) })]);
97
+ expect(dispatched).toHaveLength(1);
98
+ });
99
+
100
+ it("ignores a request older than 15 minutes and logs expired once", async () => {
101
+ const { honour, dispatched, logs } = setup();
102
+ const j = job({ runRequestedAt: ago(RUN_NOW_FRESH_MS + 1) });
103
+ await honour([j]);
104
+ await honour([j]);
105
+ expect(dispatched).toEqual([]);
106
+ const expired = logs.filter((l) => l.includes("expired"));
107
+ expect(expired).toHaveLength(1);
108
+ expect(logs.join("\n")).not.toContain("stored prompt");
109
+ });
110
+
111
+ it("a request exactly 15 minutes old is still fresh", async () => {
112
+ const { honour, dispatched } = setup();
113
+ await honour([job({ runRequestedAt: ago(RUN_NOW_FRESH_MS) })]);
114
+ expect(dispatched).toHaveLength(1);
115
+ });
116
+
117
+ it("a request that ages out between ticks without being seen expires", async () => {
118
+ const { honour, dispatched, logs, advance } = setup();
119
+ const j = job({ runRequestedAt: ago(0) });
120
+ advance(RUN_NOW_FRESH_MS + 60_000);
121
+ await honour([j]);
122
+ expect(dispatched).toEqual([]);
123
+ expect(logs.some((l) => l.includes("expired"))).toBe(true);
124
+ });
125
+
126
+ it("skips jobs with no request", async () => {
127
+ const { honour, dispatched, logs } = setup();
128
+ await honour([job(), job({ id: "job-2" })]);
129
+ expect(dispatched).toEqual([]);
130
+ expect(logs).toEqual([]);
131
+ });
132
+
133
+ it("a failing dispatch does not stop the other jobs and is not retried", async () => {
134
+ const dispatched: string[] = [];
135
+ const failures: string[] = [];
136
+ const honour = createRunNowHonourer({
137
+ clock: { now: () => NOW },
138
+ dispatch: async (req) => {
139
+ dispatched.push(req.jobId);
140
+ if (req.jobId === "bad") throw new Error("boom");
141
+ },
142
+ onError: (jobId) => {
143
+ failures.push(jobId);
144
+ },
145
+ log: () => {},
146
+ });
147
+ const jobs = [
148
+ job({ id: "bad", runRequestedAt: ago(1000) }),
149
+ job({ id: "good", runRequestedAt: ago(1000) }),
150
+ ];
151
+ await honour(jobs);
152
+ await honour(jobs);
153
+ expect(dispatched).toEqual(["bad", "good"]);
154
+ expect(failures).toEqual(["bad"]);
155
+ });
156
+ });
@@ -35,6 +35,8 @@ function makeCron(id: string): AgentCronJob {
35
35
  system: false,
36
36
  parentCronId: null,
37
37
  lockdownDisabledAt: null,
38
+ runRequestedAt: null,
39
+ runRequestedBy: null,
38
40
  };
39
41
  }
40
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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": "0.3.0",
44
- "@app-vitals/agent-lib": "0.3.0",
43
+ "@app-vitals/agent-admin": "0.4.0",
44
+ "@app-vitals/agent-lib": "0.4.0",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",
@@ -53,6 +53,8 @@ const SAMPLE_CRONS: AgentCronJob[] = [
53
53
  system: false,
54
54
  parentCronId: null,
55
55
  lockdownDisabledAt: null,
56
+ runRequestedAt: null,
57
+ runRequestedBy: null,
56
58
  createdAt: new Date("2025-01-01T00:00:00Z"),
57
59
  updatedAt: new Date("2025-01-01T00:00:00Z"),
58
60
  },