talon-agent 3.33.4 → 3.34.1

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.
Files changed (98) hide show
  1. package/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -9,6 +9,7 @@ import {
9
9
  getAllTriggers,
10
10
  updateTrigger,
11
11
  RESTART_KILL_ERROR,
12
+ SHUTDOWN_KILL_ERROR,
12
13
  type Trigger,
13
14
  } from "../../../storage/trigger-store.js";
14
15
  import { log, logError } from "../../../util/log.js";
@@ -50,19 +51,25 @@ export async function resumeAfterRestart(): Promise<void> {
50
51
  // Late death notice. Two cases earn one:
51
52
  // - never fired at all (the old rule) — the chat heard nothing
52
53
  // from this trigger, so its termination is news; and
53
- // - killed by THIS restart (recoverInterrupted stamped the
54
- // marker error) — even a multi-fire watcher that signalled
55
- // mid-run was still an active promise when the process died,
56
- // and without this wake the chat never learns its watcher is
57
- // gone. (Previously gated on lastFireAt === undefined alone,
58
- // which silently dropped exactly those watchers.)
54
+ // - killed by THIS restart — either the crash path
55
+ // (recoverInterrupted stamped RESTART_KILL_ERROR) or the clean
56
+ // path (shutdownTriggers stamped SHUTDOWN_KILL_ERROR and
57
+ // finalizeExit deliberately skipped the wake because the
58
+ // backend pool was already going away). Even a multi-fire
59
+ // watcher that signalled mid-run was still an active promise
60
+ // when the process died, and without this wake the chat never
61
+ // learns its watcher is gone. (Previously gated on
62
+ // lastFireAt === undefined alone, which silently dropped
63
+ // exactly those watchers.)
59
64
  // Triggers that exited on their own already fired their terminal
60
65
  // wake (lastFireAt set, no marker) — they stay silent here.
61
66
  if (
62
67
  t.status === "terminated" &&
63
68
  t.endedAt &&
64
69
  Date.now() - t.endedAt < 5 * 60_000 &&
65
- (t.lastFireAt === undefined || t.lastError === RESTART_KILL_ERROR)
70
+ (t.lastFireAt === undefined ||
71
+ t.lastError === RESTART_KILL_ERROR ||
72
+ t.lastError === SHUTDOWN_KILL_ERROR)
66
73
  ) {
67
74
  await fireWake(t.id, "terminated", t.lastError, /* terminal */ true);
68
75
  }
@@ -20,6 +20,14 @@ export type TriggerDeps = {
20
20
  /** Reassignable on a holder object so submodules see the injected deps. */
21
21
  export const depsHolder: { deps: TriggerDeps | null } = { deps: null };
22
22
 
23
+ /**
24
+ * Set by shutdownTriggers for the rest of the process lifetime. finalizeExit
25
+ * consults it so the children it kills don't dispatch wakes into a backend
26
+ * pool that is being torn down at the same time. initTriggers clears it so a
27
+ * fresh lifecycle (next boot, or the next test) starts clean.
28
+ */
29
+ export const lifecycle = { shuttingDown: false };
30
+
23
31
  /** Live child handles, keyed by trigger id. */
24
32
  export const children = new Map<string, ChildProcess>();
25
33
  export const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
@@ -43,6 +51,7 @@ export const WARDEN_GRACE_SLACK_MS = 2_000;
43
51
 
44
52
  export function initTriggers(d: TriggerDeps): void {
45
53
  depsHolder.deps = d;
54
+ lifecycle.shuttingDown = false;
46
55
  log("triggers", "Initialized");
47
56
  }
48
57
 
@@ -14,6 +14,7 @@ import { log, logWarn } from "../../../util/log.js";
14
14
  import { roleHolder } from "./holders.js";
15
15
  import {
16
16
  pool,
17
+ initInFlight,
17
18
  bindings,
18
19
  listeners,
19
20
  ctx,
@@ -285,6 +286,7 @@ export async function cleanupBackendPool(): Promise<void> {
285
286
  /** Test-only state reset. */
286
287
  export function resetBackendPoolForTest(): void {
287
288
  pool.clear();
289
+ initInFlight.clear();
288
290
  bindings.clear();
289
291
  ctx.initCtx = null;
290
292
  ctx.poolConfig = null;
@@ -29,6 +29,8 @@ export interface PoolEntry {
29
29
 
30
30
  /** id → entry. */
31
31
  export const pool = new Map<string, PoolEntry>();
32
+ /** id → init in progress; concurrent callers await the same promise. */
33
+ export const initInFlight = new Map<string, Promise<PoolEntry>>();
32
34
  /** holder → backend id (only present for holders currently bound). */
33
35
  export const bindings = new Map<BackendHolder, string>();
34
36
 
@@ -56,6 +58,8 @@ export async function ensurePoolEntry(
56
58
  ): Promise<PoolEntry> {
57
59
  const existing = pool.get(id);
58
60
  if (existing) return existing;
61
+ const inFlight = initInFlight.get(id);
62
+ if (inFlight) return inFlight;
59
63
 
60
64
  const factory = getBackend(id);
61
65
  if (!factory) {
@@ -64,22 +68,31 @@ export async function ensurePoolEntry(
64
68
  .join(", ");
65
69
  throw new Error(`Unknown backend "${id}" — known: ${known}`);
66
70
  }
67
- if (!ctx.initCtx) {
71
+ const initCtx = ctx.initCtx;
72
+ if (!initCtx) {
68
73
  throw new Error(
69
74
  "Backend pool not initialised — call initBackendPool first",
70
75
  );
71
76
  }
72
- const instance = await factory.init(config, ctx.initCtx);
73
- const entry: PoolEntry = {
74
- id: factory.id,
75
- label: factory.label,
76
- backend: instance.backend,
77
- cleanup: instance.cleanup,
78
- holders: new Set(),
79
- };
80
- pool.set(factory.id, entry);
81
- log("backend-controller", `Pool init: ${factory.label} (${factory.id})`);
82
- return entry;
77
+ const init = (async (): Promise<PoolEntry> => {
78
+ const instance = await factory.init(config, initCtx);
79
+ const entry: PoolEntry = {
80
+ id: factory.id,
81
+ label: factory.label,
82
+ backend: instance.backend,
83
+ cleanup: instance.cleanup,
84
+ holders: new Set(),
85
+ };
86
+ pool.set(factory.id, entry);
87
+ log("backend-controller", `Pool init: ${factory.label} (${factory.id})`);
88
+ return entry;
89
+ })();
90
+ initInFlight.set(id, init);
91
+ try {
92
+ return await init;
93
+ } finally {
94
+ initInFlight.delete(id);
95
+ }
83
96
  }
84
97
 
85
98
  /** Drop a holder from an entry; clean up the entry if its refcount hits zero. */
@@ -8,14 +8,12 @@ import {
8
8
  getCronJobsForChat,
9
9
  updateCronJob,
10
10
  deleteCronJob,
11
- validateCronExpression,
12
11
  generateCronId,
13
12
  describeSchedule,
14
13
  nextRunAt,
15
- type CronJobType,
16
- type CatchupPolicy,
17
14
  } from "../../../storage/cron-store.js";
18
15
  import { runJobNow } from "../../background/cron.js";
16
+ import { parseCronSpec } from "../../background/cron-spec.js";
19
17
  import { log } from "../../../util/log.js";
20
18
  import type { Backend } from "../../agent-runtime/capabilities.js";
21
19
  import {
@@ -27,31 +25,6 @@ import {
27
25
  import { validateJobModelOverride } from "./shared.js";
28
26
  import type { SharedActionHandlers } from "./types.js";
29
27
 
30
- // ── Scheduler field parsing (shared by create/edit cron) ────────────────────
31
-
32
- /** The scheduler ticks once a minute, so sub-minute intervals are meaningless. */
33
- const MIN_INTERVAL_SECONDS = 60;
34
- const CATCHUP_POLICIES = new Set<CatchupPolicy>(["skip", "once", "all"]);
35
-
36
- /** True for a body field that was actually supplied (not absent/blank). */
37
- function provided(v: unknown): boolean {
38
- return v !== undefined && v !== null && v !== "";
39
- }
40
-
41
- /**
42
- * Parse an instant given as an ISO-8601 string or epoch-ms number into epoch
43
- * ms. Returns undefined when the field is absent or unparseable — callers
44
- * distinguish the two via `provided()`.
45
- */
46
- function parseInstant(v: unknown): number | undefined {
47
- if (!provided(v)) return undefined;
48
- if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
49
- const s = String(v).trim();
50
- if (/^\d+$/.test(s)) return Number(s);
51
- const d = Date.parse(s);
52
- return Number.isFinite(d) ? d : undefined;
53
- }
54
-
55
28
  /**
56
29
  * Validate a cron `query` job's model + optional provider override. Cron runs
57
30
  * isolated, so unlike triggers it may target a different provider — the backend
@@ -113,147 +86,38 @@ function validateCronBackgroundCapability(
113
86
 
114
87
  export const cronHandlers: SharedActionHandlers = {
115
88
  create_cron_job: async (body, chatId) => {
116
- const name = String(body.name ?? "Unnamed job");
117
- const jobType = (body.type as CronJobType) ?? "message";
118
- const content = String(body.content ?? "");
119
- const timezone = body.timezone ? String(body.timezone) : undefined;
120
- const model = body.model ? String(body.model) : undefined;
121
- const provider = body.provider ? String(body.provider) : undefined;
122
- const instructions = body.instructions
123
- ? String(body.instructions)
124
- : undefined;
125
-
126
- if (!content) return { ok: false, error: "Missing content" };
127
- if (content.length > 10_000)
128
- return { ok: false, error: "Content too long (max 10,000 chars)" };
129
-
130
- // Cadence: exactly one of `schedule` (cron expression) or
131
- // `every_seconds` (fixed interval).
132
- const schedule = provided(body.schedule)
133
- ? String(body.schedule)
134
- : undefined;
135
- const hasEvery = provided(body.every_seconds);
136
- if (!schedule && !hasEvery)
137
- return {
138
- ok: false,
139
- error:
140
- "Provide either 'schedule' (a cron expression) or 'every_seconds' (a fixed interval).",
141
- };
142
- if (schedule && hasEvery)
143
- return {
144
- ok: false,
145
- error: "Provide only one of 'schedule' or 'every_seconds', not both.",
146
- };
147
-
148
- let everyMs: number | undefined;
149
- if (hasEvery) {
150
- const everySeconds = Number(body.every_seconds);
151
- if (!Number.isFinite(everySeconds) || everySeconds < MIN_INTERVAL_SECONDS)
152
- return {
153
- ok: false,
154
- error: `'every_seconds' must be a number >= ${MIN_INTERVAL_SECONDS} (the scheduler ticks once a minute).`,
155
- };
156
- everyMs = Math.round(everySeconds * 1000);
157
- }
158
-
159
- if (schedule) {
160
- const validation = validateCronExpression(schedule, timezone);
161
- if (!validation.valid)
162
- return {
163
- ok: false,
164
- error: `Invalid cron expression: ${validation.error}`,
165
- };
166
- }
167
-
168
- // Lifecycle bounds.
169
- const startAt = parseInstant(body.start_at);
170
- if (provided(body.start_at) && startAt === undefined)
171
- return {
172
- ok: false,
173
- error:
174
- "Could not parse 'start_at' (use an ISO-8601 timestamp or epoch ms).",
175
- };
176
- const endAt = parseInstant(body.end_at);
177
- if (provided(body.end_at) && endAt === undefined)
178
- return {
179
- ok: false,
180
- error:
181
- "Could not parse 'end_at' (use an ISO-8601 timestamp or epoch ms).",
182
- };
183
- if (startAt !== undefined && endAt !== undefined && endAt <= startAt)
184
- return { ok: false, error: "'end_at' must be after 'start_at'." };
185
- if (endAt !== undefined && endAt <= Date.now())
186
- return {
187
- ok: false,
188
- error: "'end_at' is in the past — the job would never run.",
189
- };
190
-
191
- // Run cap. `once: true` is sugar for max_runs = 1 (one-shot).
192
- let maxRuns: number | undefined;
193
- if (body.once === true) maxRuns = 1;
194
- else if (provided(body.max_runs)) {
195
- const m = Number(body.max_runs);
196
- if (!Number.isInteger(m) || m < 1)
197
- return { ok: false, error: "'max_runs' must be a positive integer." };
198
- maxRuns = m;
199
- }
200
-
201
- // Missed-run catch-up policy. New jobs default to "once": a run that
202
- // came due while Talon was down (or while the scheduler was wedged)
203
- // replays a single time at startup instead of being lost silently — a
204
- // live audit found one-shot reminders that missed their date under the
205
- // old "skip" default and quietly rolled over a full year. Explicit
206
- // "skip" remains available for jobs where a late run is worthless.
207
- let catchup: CatchupPolicy = "once";
208
- if (provided(body.catchup)) {
209
- catchup = String(body.catchup) as CatchupPolicy;
210
- if (!CATCHUP_POLICIES.has(catchup))
211
- return {
212
- ok: false,
213
- error: "'catchup' must be one of: skip, once, all.",
214
- };
215
- }
216
-
217
- // The overrides only make sense for "query" jobs (a "message" job just
218
- // sends text — no model runs).
219
- if ((model || provider || instructions) && jobType !== "query")
220
- return {
221
- ok: false,
222
- error: "Model/provider/instructions only apply to 'query' jobs.",
223
- };
224
- if (provider && !model)
225
- return {
226
- ok: false,
227
- error: "A 'provider' override also requires a 'model'.",
228
- };
89
+ const parsed = parseCronSpec(body);
90
+ if (!parsed.ok) return { ok: false, error: parsed.error };
91
+ const spec = parsed.effective;
92
+ const {
93
+ name,
94
+ type: jobType,
95
+ schedule,
96
+ everyMs,
97
+ startAt,
98
+ endAt,
99
+ maxRuns,
100
+ } = spec;
229
101
 
230
102
  // Validate the target backend/model up front so a bad id or unsupported
231
103
  // backend is rejected here instead of silently failing at fire time.
232
104
  if (jobType === "query") {
233
- const modelErr = await validateCronModelOverride(chatId, model, provider);
105
+ const modelErr = await validateCronModelOverride(
106
+ chatId,
107
+ spec.model,
108
+ spec.provider,
109
+ );
234
110
  if (modelErr) return { ok: false, error: modelErr };
235
111
  }
236
112
 
237
113
  const id = generateCronId();
238
114
  addCronJob({
115
+ ...spec,
239
116
  id,
240
117
  chatId: String(chatId),
241
- type: jobType,
242
- content,
243
- name,
244
118
  enabled: true,
245
119
  createdAt: Date.now(),
246
120
  runCount: 0,
247
- timezone,
248
- ...(schedule ? { schedule } : {}),
249
- ...(everyMs !== undefined ? { everyMs } : {}),
250
- ...(startAt !== undefined ? { startAt } : {}),
251
- ...(endAt !== undefined ? { endAt } : {}),
252
- ...(maxRuns !== undefined ? { maxRuns } : {}),
253
- catchup,
254
- ...(model ? { model } : {}),
255
- ...(provider ? { provider } : {}),
256
- ...(instructions ? { instructions } : {}),
257
121
  });
258
122
  log(
259
123
  "gateway",
@@ -268,7 +132,7 @@ export const cronHandlers: SharedActionHandlers = {
268
132
  ? `starts: ${new Date(startAt).toISOString()}`
269
133
  : null,
270
134
  endAt !== undefined ? `ends: ${new Date(endAt).toISOString()}` : null,
271
- catchup ? `catch-up: ${catchup}` : null,
135
+ spec.catchup ? `catch-up: ${spec.catchup}` : null,
272
136
  ]
273
137
  .filter(Boolean)
274
138
  .join(", ");
@@ -335,157 +199,29 @@ export const cronHandlers: SharedActionHandlers = {
335
199
  if (job.chatId !== String(chatId))
336
200
  return { ok: false, error: "Job belongs to a different chat" };
337
201
 
338
- const updates: Record<string, unknown> = {};
339
- if (body.name !== undefined) updates.name = String(body.name);
340
- if (body.content !== undefined) updates.content = String(body.content);
341
- if (body.enabled !== undefined) updates.enabled = Boolean(body.enabled);
342
- if (body.type !== undefined) updates.type = String(body.type);
343
- if (body.timezone !== undefined)
344
- updates.timezone = body.timezone ? String(body.timezone) : undefined;
345
-
346
- // Cadence — schedule and every_seconds are mutually exclusive; setting
347
- // one switches mode and clears the other.
348
- const editSchedule = body.schedule !== undefined;
349
- const editEvery = provided(body.every_seconds);
350
- if (editSchedule && provided(body.schedule) && editEvery)
351
- return {
352
- ok: false,
353
- error: "Provide only one of 'schedule' or 'every_seconds', not both.",
354
- };
355
- if (editSchedule && provided(body.schedule)) {
356
- const tz = (updates.timezone as string | undefined) ?? job.timezone;
357
- const v = validateCronExpression(String(body.schedule), tz);
358
- if (!v.valid)
359
- return { ok: false, error: `Invalid cron expression: ${v.error}` };
360
- updates.schedule = String(body.schedule);
361
- updates.everyMs = undefined;
362
- }
363
- if (editEvery) {
364
- const everySeconds = Number(body.every_seconds);
365
- if (!Number.isFinite(everySeconds) || everySeconds < MIN_INTERVAL_SECONDS)
366
- return {
367
- ok: false,
368
- error: `'every_seconds' must be a number >= ${MIN_INTERVAL_SECONDS}.`,
369
- };
370
- updates.everyMs = Math.round(everySeconds * 1000);
371
- updates.schedule = undefined;
372
- }
202
+ const parsed = parseCronSpec(body, job);
203
+ if (!parsed.ok) return { ok: false, error: parsed.error };
204
+ const { updates, effective } = parsed;
373
205
 
374
- // Lifecycle bounds (pass null/"" to clear).
375
- if (body.start_at !== undefined) {
376
- if (!provided(body.start_at)) updates.startAt = undefined;
377
- else {
378
- const s = parseInstant(body.start_at);
379
- if (s === undefined)
380
- return { ok: false, error: "Could not parse 'start_at'." };
381
- updates.startAt = s;
382
- }
383
- }
384
- if (body.end_at !== undefined) {
385
- if (!provided(body.end_at)) updates.endAt = undefined;
386
- else {
387
- const e = parseInstant(body.end_at);
388
- if (e === undefined)
389
- return { ok: false, error: "Could not parse 'end_at'." };
390
- if (e <= Date.now())
391
- return {
392
- ok: false,
393
- error: "'end_at' is in the past — the job would never run.",
394
- };
395
- updates.endAt = e;
396
- }
397
- }
398
- if (body.once === true) updates.maxRuns = 1;
399
- else if (body.max_runs !== undefined) {
400
- if (!provided(body.max_runs)) updates.maxRuns = undefined;
401
- else {
402
- const m = Number(body.max_runs);
403
- if (!Number.isInteger(m) || m < 1)
404
- return {
405
- ok: false,
406
- error: "'max_runs' must be a positive integer.",
407
- };
408
- updates.maxRuns = m;
409
- }
410
- }
411
- if (body.catchup !== undefined) {
412
- const c = String(body.catchup) as CatchupPolicy;
413
- if (!CATCHUP_POLICIES.has(c))
414
- return {
415
- ok: false,
416
- error: "'catchup' must be one of: skip, once, all.",
417
- };
418
- updates.catchup = c;
419
- }
420
- if (body.model !== undefined) {
421
- updates.model = provided(body.model) ? String(body.model) : undefined;
422
- }
423
- if (body.provider !== undefined) {
424
- updates.provider = provided(body.provider)
425
- ? String(body.provider)
426
- : undefined;
427
- }
428
- if (body.instructions !== undefined) {
429
- updates.instructions = provided(body.instructions)
430
- ? String(body.instructions)
431
- : undefined;
432
- }
433
-
434
- // Query execution overrides only apply to 'query' jobs — mirror create.
435
206
  // Validate the effective post-edit target so provider+model edits are
436
207
  // checked together using cron's cross-provider rules.
437
- const effType = (updates.type as CronJobType | undefined) ?? job.type;
438
- const effModel =
439
- "model" in updates ? (updates.model as string | undefined) : job.model;
440
- const effProvider =
441
- "provider" in updates
442
- ? (updates.provider as string | undefined)
443
- : job.provider;
444
- const effInstructions =
445
- "instructions" in updates
446
- ? (updates.instructions as string | undefined)
447
- : job.instructions;
448
- if (effType !== "query" && (effModel || effProvider || effInstructions))
449
- return {
450
- ok: false,
451
- error: "Model/provider/instructions only apply to 'query' jobs.",
452
- };
453
- if (effProvider && !effModel)
454
- return {
455
- ok: false,
456
- error: "A 'provider' override also requires a 'model'.",
457
- };
458
208
  if (
459
- effType === "query" &&
209
+ effective.type === "query" &&
460
210
  ("model" in updates || "provider" in updates || "type" in updates)
461
211
  ) {
462
212
  const modelErr = await validateCronModelOverride(
463
213
  chatId,
464
- effModel,
465
- effProvider,
214
+ effective.model,
215
+ effective.provider,
466
216
  );
467
217
  if (modelErr) return { ok: false, error: modelErr };
468
218
  }
469
219
 
470
- // Reject a start/end window that can never fire, accounting for the merge.
471
- const effStart =
472
- "startAt" in updates
473
- ? (updates.startAt as number | undefined)
474
- : job.startAt;
475
- const effEnd =
476
- "endAt" in updates ? (updates.endAt as number | undefined) : job.endAt;
477
- if (effStart !== undefined && effEnd !== undefined && effEnd <= effStart)
478
- return { ok: false, error: "'end_at' must be after 'start_at'." };
479
-
480
220
  // If a lowered run cap already met/exceeds runCount, retire the job now so
481
221
  // it can't sneak one more run before enforceRunCap catches it post-run.
482
- const effMaxRuns =
483
- "maxRuns" in updates
484
- ? (updates.maxRuns as number | undefined)
485
- : job.maxRuns;
486
222
  if (
487
- effMaxRuns !== undefined &&
488
- job.runCount >= effMaxRuns &&
223
+ effective.maxRuns !== undefined &&
224
+ job.runCount >= effective.maxRuns &&
489
225
  updates.enabled !== true
490
226
  )
491
227
  updates.enabled = false;