@volter/supercode-orchestrator 0.4.4

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/cron.mjs ADDED
@@ -0,0 +1,967 @@
1
+ // The cron half of the tick (docs/ORCHESTRATOR-IR.md §4.3).
2
+ //
3
+ // cronTick(state, ev, effects) due jobs -> claim -> run; obligation retry; breakers
4
+ // fireFinal / fireFailed the worker events of a fire's own session
5
+ // nextRunAt(schedule, after, zone) once | interval | cron; a cron without a tz on the home's clock (homeZone)
6
+ //
7
+ // Pure, like the reducer it hooks into: no node module, no wall clock, no I/O.
8
+ // Time arrives as the event's `at`; delivery leaves as effects. The cron
9
+ // evaluator is ours (five fields, Vixie day-of-month/day-of-week semantics);
10
+ // zone arithmetic goes through `Intl`, which is a language builtin, not a
11
+ // module.
12
+
13
+ import { surfaceKeyString } from './ir.mjs';
14
+ import { homeChannel } from './ir.mjs';
15
+ import { endBinding, ensureRuntime, leaseId, newObligation, permissionResponse, reply } from './reducer.mjs';
16
+
17
+ /** Retry delays after the 1st, 2nd and 3rd send attempt (§4.3 "retry pending obligations"). */
18
+ export const BACKOFF_MINUTES = Object.freeze([1, 2, 4]);
19
+ /** Consecutive send failures on one adapter that open its breaker, and for how long (§2.8). */
20
+ export const BREAKER_FAILURES = 3;
21
+ export const BREAKER_MINUTES = 5;
22
+ export const MAX_ATTEMPTS = 3;
23
+
24
+ /**
25
+ * Hermes `_summarize_cron_failure_for_delivery`, transcribed: the one line a
26
+ * chat gets for a failed run (the full error stays in the run's output file).
27
+ * A no_agent job never reached a provider, so the provider branches are
28
+ * skipped for it. The fallback clause is Hermes's no-chain wording, the
29
+ * orchestrator having no fallback chain.
30
+ */
31
+ export function summarizeFailure(job, error) {
32
+ const name = job?.residue?.name || job?.id || 'cron job';
33
+ const text = String(error || 'unknown error').trim();
34
+ const lower = text.toLowerCase();
35
+ const provider = !(job?.residue?.no_agent === true);
36
+ const fallback = 'No fallback chain configured — add one with `hermes fallback add`, or set a cron fleet default via `cron.model` + `cron.model_provider` in config.yaml.';
37
+ if (lower.startsWith('script timed out')) return `⚠️ Cron '${name}' failed: script timed out. No model was invoked. Full details saved in cron output.`;
38
+ if (provider && (/\b429\b/.test(text) || lower.includes('rate limit') || lower.includes('usage limit'))) {
39
+ const reason = lower.includes('weekly usage limit') ? 'weekly usage limit' : lower.includes('quota') ? 'quota limit' : 'rate limit';
40
+ return `⚠️ Cron '${name}' failed: provider ${reason}. ${fallback} Full details saved in cron output.`;
41
+ }
42
+ if (/idle for \d+s\s*\(limit \d+s\)/.test(lower) || lower.startsWith('no activity from the worker')) {
43
+ return `⚠️ Cron '${name}' failed: the job itself stalled — no tool/API activity for the configured inactivity window. Not a provider or fallback-chain issue; check what the job was doing when it went quiet. Full details saved in cron output.`;
44
+ }
45
+ if (provider && (lower.includes('readtimeout') || lower.includes('timed out') || lower.includes('timeout'))) return `⚠️ Cron '${name}' failed: provider timeout. ${fallback} Full details saved in cron output.`;
46
+ if (provider && (/authenticat|authoriz/.test(lower) || /\b(401|403)\b/.test(text))) return `⚠️ Cron '${name}' failed: provider authentication error. Full details saved in cron output.`;
47
+ let cleaned = text.slice(0, 2000).replace(/^(RuntimeError|Exception|ValueError|HTTPStatusError):\s*/, '').replace(/\s+/g, ' ').trim();
48
+ if (cleaned.length > 180) cleaned = `${cleaned.slice(0, 177).trimEnd()}...`;
49
+ return `⚠️ Cron '${name}' failed: ${cleaned}`;
50
+ }
51
+
52
+ /**
53
+ * Hermes `is_autonomous_silence_response` (`gateway/response_filters.py`):
54
+ * a cron or webhook answer that is a silence marker (`[SILENT]`, `SILENT`,
55
+ * `NO_REPLY`, `NO REPLY`) as a whole, on its own first or last line, or the
56
+ * bracketed sentinel opening the response, is not delivered.
57
+ */
58
+ const SILENCE_MARKERS = new Set(['[SILENT]', 'SILENT', 'NO_REPLY', 'NO REPLY']);
59
+ export function isSilence(response) {
60
+ if (typeof response !== 'string') return false;
61
+ const stripped = response.trim();
62
+ if (!stripped) return false;
63
+ const token = (line) => SILENCE_MARKERS.has(line.trim().toUpperCase().split(/\s+/).join(' '));
64
+ if (token(stripped)) return true;
65
+ const lines = stripped.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/).filter((l) => l.trim());
66
+ if (lines.length && (token(lines[0]) || token(lines[lines.length - 1]))) return true;
67
+ return stripped.toUpperCase().startsWith('[SILENT]');
68
+ }
69
+
70
+ // ---------------------------------------------------------------- cron expressions
71
+
72
+ const MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
73
+ const DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
74
+
75
+ function fieldNumber(text, kind) {
76
+ const t = text.trim().toLowerCase();
77
+ if (kind === 'month' && MONTHS[t.slice(0, 3)] !== undefined && !/^\d+$/.test(t)) return MONTHS[t.slice(0, 3)];
78
+ if (kind === 'dow' && DAYS[t.slice(0, 3)] !== undefined && !/^\d+$/.test(t)) return DAYS[t.slice(0, 3)];
79
+ if (!/^\d+$/.test(t)) throw new TypeError(`cron: ${JSON.stringify(text)} is not a ${kind} value`);
80
+ return Number(t);
81
+ }
82
+
83
+ // One cron field -> the set of values it matches.
84
+ // Supports a star, `a`, `a-b`, star-slash-n, `a-b/n`, and comma lists.
85
+ function cronField(spec, kind, lo, hi) {
86
+ const out = new Set();
87
+ for (const item of String(spec).split(',')) {
88
+ const slash = item.indexOf('/');
89
+ const rangePart = slash < 0 ? item : item.slice(0, slash);
90
+ const step = slash < 0 ? 1 : Number(item.slice(slash + 1));
91
+ if (!Number.isInteger(step) || step < 1) throw new TypeError(`cron: bad step in ${JSON.stringify(item)}`);
92
+ let a; let b;
93
+ if (rangePart === '*') { a = lo; b = hi; }
94
+ else {
95
+ const dash = rangePart.indexOf('-', 1);
96
+ if (dash > 0) { a = fieldNumber(rangePart.slice(0, dash), kind); b = fieldNumber(rangePart.slice(dash + 1), kind); }
97
+ else { a = fieldNumber(rangePart, kind); b = slash < 0 ? a : hi; }
98
+ }
99
+ if (a < lo || b > hi || a > b) throw new TypeError(`cron: ${JSON.stringify(item)} out of range for ${kind}`);
100
+ for (let v = a; v <= b; v += step) out.add(kind === 'dow' ? v % 7 : v);
101
+ }
102
+ if (!out.size) throw new TypeError(`cron: ${JSON.stringify(spec)} matches nothing`);
103
+ return out;
104
+ }
105
+
106
+ /** `min hour dom month dow` -> matcher. */
107
+ export function parseCron(expr) {
108
+ const parts = String(expr ?? '').trim().split(/\s+/).filter(Boolean);
109
+ if (parts.length !== 5) throw new TypeError(`cron: expected five fields, got ${parts.length} in ${JSON.stringify(expr)}`);
110
+ return {
111
+ minute: cronField(parts[0], 'minute', 0, 59),
112
+ hour: cronField(parts[1], 'hour', 0, 23),
113
+ dom: cronField(parts[2], 'dom', 1, 31),
114
+ month: cronField(parts[3], 'month', 1, 12),
115
+ dow: cronField(parts[4], 'dow', 0, 7),
116
+ domRestricted: parts[2] !== '*',
117
+ dowRestricted: parts[4] !== '*',
118
+ };
119
+ }
120
+
121
+ // ---------------------------------------------------------------- zones
122
+
123
+ const FORMATTERS = new Map();
124
+ function zoneParts(tz, utcMs) {
125
+ let f = FORMATTERS.get(tz);
126
+ if (!f) {
127
+ f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
128
+ FORMATTERS.set(tz, f);
129
+ }
130
+ const p = {};
131
+ for (const part of f.formatToParts(new Date(utcMs))) if (part.type !== 'literal') p[part.type] = Number(part.value);
132
+ return p;
133
+ }
134
+
135
+ /**
136
+ * The clock a cron job without a zone runs on, as Hermes's (`hermes_time.get_timezone`): the home's
137
+ * `timezone`, else the host's zone; a name the host does not know is the host's zone, as Hermes falls back.
138
+ */
139
+ export function homeZone(profile) {
140
+ const host = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
141
+ const named = typeof profile?.residue?.config?.timezone === 'string' ? profile.residue.config.timezone.trim() : '';
142
+ if (!named) return host;
143
+ try { zoneParts(named, 0); return named; } catch { return host; }
144
+ }
145
+
146
+ /**
147
+ * Refuse a zone `Intl` does not know, by name. Unchecked, `Intl`'s own
148
+ * RangeError would surface from `nextRunAt` only after a fire had run.
149
+ */
150
+ function checkZone(tz) {
151
+ if (!tz || tz === 'UTC') return;
152
+ try { zoneParts(tz, 0); } catch {
153
+ throw new RangeError(`schedule.tz ${JSON.stringify(tz)} is not an IANA time zone (such as America/New_York)`);
154
+ }
155
+ }
156
+
157
+ /** `wall clock in tz` - `UTC`, in ms, at that instant. */
158
+ function zoneOffsetMs(tz, utcMs) {
159
+ if (!tz || tz === 'UTC') return 0;
160
+ const p = zoneParts(tz, utcMs);
161
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second) - Math.floor(utcMs / 1000) * 1000;
162
+ }
163
+
164
+ /** A wall-clock instant in `tz` (expressed as a UTC-shifted ms value) back to real UTC ms. */
165
+ function localToUtc(tz, localMs) {
166
+ if (!tz || tz === 'UTC') return localMs;
167
+ let guess = localMs - zoneOffsetMs(tz, localMs);
168
+ for (let i = 0; i < 2; i++) {
169
+ const next = localMs - zoneOffsetMs(tz, guess);
170
+ if (next === guess) break;
171
+ guess = next;
172
+ }
173
+ return guess;
174
+ }
175
+
176
+ function matchesDay(f, d) {
177
+ if (!f.month.has(d.getUTCMonth() + 1)) return false;
178
+ const domHit = f.dom.has(d.getUTCDate());
179
+ const dowHit = f.dow.has(d.getUTCDay());
180
+ if (f.domRestricted && f.dowRestricted) return domHit || dowHit; // Vixie: either, when both are given
181
+ if (f.domRestricted) return domHit;
182
+ if (f.dowRestricted) return dowHit;
183
+ return true;
184
+ }
185
+
186
+ /** The first instant strictly after `afterMs` that the expression matches, in `tz`. */
187
+ function nextCronMs(f, afterMs, tz) {
188
+ let local = Math.floor((afterMs + zoneOffsetMs(tz, afterMs)) / 60000) * 60000 + 60000;
189
+ const limit = local + 366 * 24 * 60 * 60 * 1000;
190
+ while (local <= limit) {
191
+ const d = new Date(local);
192
+ if (!matchesDay(f, d)) { local = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1); continue; }
193
+ if (!f.hour.has(d.getUTCHours())) { local = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours() + 1); continue; }
194
+ if (!f.minute.has(d.getUTCMinutes())) { local += 60000; continue; }
195
+ const utc = localToUtc(tz, local);
196
+ if (utc > afterMs) return utc;
197
+ local += 60000; // a wall-clock minute that repeats across a DST fall-back
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * The next run instant strictly after `after`, or null when the schedule is
204
+ * spent. `once` fires at `run_at` and then never again (a numeric `repeat`
205
+ * re-arms it — Hermes's "remaining runs"); `interval` counts from the last
206
+ * run, not from a fixed epoch; `cron` evaluates in `schedule.tz`, or without one in `zone` (the home's
207
+ * clock, `homeZone`).
208
+ */
209
+ export function nextRunAt(schedule, after, zone = 'UTC') {
210
+ const afterMs = Date.parse(after);
211
+ if (!Number.isFinite(afterMs)) throw new TypeError(`nextRunAt: ${JSON.stringify(after)} is not an instant`);
212
+ switch (schedule?.kind) {
213
+ case 'once': {
214
+ const at = Date.parse(schedule.run_at);
215
+ return Number.isFinite(at) && at > afterMs ? new Date(at).toISOString() : null;
216
+ }
217
+ case 'interval':
218
+ return new Date(afterMs + schedule.minutes * 60000).toISOString();
219
+ case 'cron': {
220
+ checkZone(schedule.tz);
221
+ const ms = nextCronMs(parseCron(schedule.expr), afterMs, schedule.tz || zone);
222
+ return ms === null ? null : new Date(ms).toISOString();
223
+ }
224
+ default:
225
+ throw new TypeError(`nextRunAt: unknown schedule kind ${JSON.stringify(schedule?.kind)}`);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Why a schedule cannot be evaluated, or null when it can: a `once` that is
231
+ * not an instant, an `interval` that is not positive, a cron expression that
232
+ * does not parse or a zone that does not exist. Every door a job enters by
233
+ * refuses on this, and the tick disables a job it finds already holding one.
234
+ */
235
+ export function scheduleError(schedule) {
236
+ switch (schedule?.kind) {
237
+ case 'once':
238
+ return Number.isFinite(Date.parse(schedule.run_at)) ? null : `schedule.run_at ${JSON.stringify(schedule.run_at)} is not an instant`;
239
+ case 'interval':
240
+ return schedule.minutes > 0 ? null : `schedule.minutes ${JSON.stringify(schedule.minutes)} is not a positive number`;
241
+ case 'cron':
242
+ try { parseCron(schedule.expr); checkZone(schedule.tz); return null; } catch (error) { return error.message; }
243
+ default:
244
+ return `schedule.kind ${JSON.stringify(schedule?.kind)} is not once | interval | cron`;
245
+ }
246
+ }
247
+
248
+ // ---------------------------------------------------------------- fires
249
+
250
+ const two = (n) => String(n).padStart(2, '0');
251
+
252
+ /** `YYYYMMDD_HHMMSS` in UTC — the stamp Hermes puts in a fire's session name. */
253
+ export function compactStamp(at) {
254
+ const d = new Date(at);
255
+ return `${d.getUTCFullYear()}${two(d.getUTCMonth() + 1)}${two(d.getUTCDate())}_${two(d.getUTCHours())}${two(d.getUTCMinutes())}${two(d.getUTCSeconds())}`;
256
+ }
257
+
258
+ /** Hermes's fire session name: `cron_<job_id>_<YYYYMMDD_HHMMSS>` (UTC). */
259
+ export function fireSessionName(jobId, at) {
260
+ return `cron_${jobId}_${compactStamp(at)}`;
261
+ }
262
+
263
+ /**
264
+ * A fire's id IS its session name, so the join survives the executions table
265
+ * unchanged (that table has no session column and the schema is frozen).
266
+ * `Fire.session_id` is set here for the live record; after a reload it is read
267
+ * back through this function.
268
+ */
269
+ export function fireSessionId(fire) {
270
+ return fire.session_id ?? (/^cron_.+_\d{8}_\d{6}$/.test(fire.id) ? fire.id : null);
271
+ }
272
+
273
+ /** Enabled jobs that are due and have no fire in flight (the at-most-once guard). */
274
+ export function dueJobs(profile, now) {
275
+ const t = Date.parse(now);
276
+ const inFlight = new Set(profile.fires.filter((f) => f.status === 'claimed' || f.status === 'running').map((f) => f.job_id));
277
+ return Object.values(profile.jobs)
278
+ .filter((j) => j.enabled && j.next_run_at && Date.parse(j.next_run_at) <= t && !inFlight.has(j.id))
279
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
280
+ }
281
+
282
+ /** Append the claim. Returns null when this exact fire already exists (a raced tick). */
283
+ export function claimFire(profile, job, now) {
284
+ const id = fireSessionName(job.id, now);
285
+ if (profile.fires.some((f) => f.id === id)) return null;
286
+ const fire = {
287
+ id, job_id: job.id, session_id: id, status: 'claimed',
288
+ claimed_at: now, started_at: null, finished_at: null, error: null, obligation_id: null, residue: {},
289
+ };
290
+ profile.fires.push(fire);
291
+ return fire;
292
+ }
293
+
294
+ /**
295
+ * Close a fire and re-arm its job. `status` is `succeeded` | `failed` | `timeout`.
296
+ * A job that cannot be re-armed is disabled and its failure target told
297
+ * (`scheduleBroken`); with no `effects` to carry the send, the notice waits
298
+ * `pending` for the next tick's retry.
299
+ */
300
+ export function finishFire(profile, fire, job, status, now, error = null, effects = null) {
301
+ fire.status = status;
302
+ fire.started_at ??= fire.claimed_at;
303
+ fire.finished_at = now;
304
+ fire.error = error;
305
+ if (!job) return fire;
306
+ // Hermes's scheduler records a failed run as an incident (`cron/incidents.py`), a run that went silent past
307
+ // its inactivity limit included; so does this loop
308
+ if ((status === 'failed' || status === 'timeout') && effects) effects.push({ type: 'record_incident', profile: profile.name, job_id: job.id, job_name: job.residue?.name ?? null, error: error ?? 'failed' });
309
+ // Hermes's \`mark_job_run\`: each run resets the delivery error; a delivery
310
+ // that then fails writes it again (the loop, when its obligation ends failed)
311
+ if (job.residue && 'last_delivery_error' in job.residue) job.residue.last_delivery_error = null;
312
+ job.last_run_at = fire.claimed_at;
313
+ job.last_status = status;
314
+ const broken = advanceSchedule(job, now, homeZone(profile));
315
+ if (broken) scheduleBroken(profile, job, broken, now, effects);
316
+ return fire;
317
+ }
318
+
319
+ /** Fail closed: the job is off, has no next run, and says why in Hermes's own `last_error`. */
320
+ function disableJob(job, why) {
321
+ job.enabled = false;
322
+ job.next_run_at = null;
323
+ (job.residue ??= {}).last_error = why;
324
+ return why;
325
+ }
326
+
327
+ /**
328
+ * A job whose schedule cannot be evaluated never stays due: it is disabled
329
+ * (`disableJob`) and one notice goes to its failure target — `failure_deliver`,
330
+ * else `deliver`, else the profile's home. A target that is a file, or a home
331
+ * that is not set, leaves the notice in the daemon log only.
332
+ */
333
+ function scheduleBroken(profile, job, why, now, effects) {
334
+ disableJob(job, why);
335
+ const text = `Cron job ${job.id} is disabled: its schedule cannot be evaluated (${why}). Correct the schedule and resume the job.`;
336
+ let target = resolveTarget(job, profile, job.failure_deliver ?? job.deliver ?? { kind: 'home' });
337
+ if (target.kind === 'local') target = resolveTarget(job, profile, { kind: 'home' });
338
+ effects?.push({ type: 'log', level: 'warn', message: text });
339
+ if (target.kind === 'local') return;
340
+ if (effects) reply(null, profile, target, text, now, effects);
341
+ else newObligation(profile, target, text, { kind: 'turn' }, now);
342
+ effects?.push({ type: 'save' });
343
+ }
344
+
345
+ /**
346
+ * `next_run_at` after a run. `repeat` is Hermes's `{times, completed}`
347
+ * (null times = forever): each run counts, a finite count that is reached
348
+ * spends the job. A `once` job re-arms only while a FINITE count has runs
349
+ * left — "forever" on a one-shot would refire every tick and is treated as
350
+ * spent (ORC-5). A schedule that cannot be evaluated fails closed: the job
351
+ * is disabled with no next run, and the reason is returned (null otherwise).
352
+ */
353
+ export function advanceSchedule(job, now, zone = 'UTC') {
354
+ // every run counts, bounded or not: Hermes keeps `{times: null, completed}`
355
+ // on a job that repeats forever, and so does the record here
356
+ if (!job.repeat || typeof job.repeat !== 'object') job.repeat = { times: null, completed: 0 };
357
+ const rep = job.repeat;
358
+ if (rep) {
359
+ rep.completed = (rep.completed ?? 0) + 1;
360
+ if (rep.times !== null && rep.times !== undefined && rep.completed >= rep.times) { job.next_run_at = null; job.enabled = false; return null; }
361
+ }
362
+ if (job.schedule.kind === 'once') {
363
+ const again = rep && rep.times !== null && rep.times !== undefined && rep.completed < rep.times;
364
+ job.next_run_at = again ? now : null;
365
+ // a spent one-shot is done, not merely unscheduled: Hermes disables it
366
+ if (!again) job.enabled = false;
367
+ return null;
368
+ }
369
+ try {
370
+ job.next_run_at = nextRunAt(job.schedule, now, zone);
371
+ } catch (error) {
372
+ return disableJob(job, error.message);
373
+ }
374
+ return null;
375
+ }
376
+
377
+ /**
378
+ * A delivery `Target` resolved against the job and the profile:
379
+ * origin → `job.origin`, home → a home channel (Hermes's `platforms.<p>.home_channel`), a bare platform
380
+ * → that platform's home channel, as Hermes delivers `deliver: telegram`, explicit → as given,
381
+ * local → the sentinel `{kind:'local'}` (a file, not a chat).
382
+ */
383
+ export function resolveTarget(job, profile, target = job?.deliver ?? null) {
384
+ const t = target ?? { kind: 'local' };
385
+ switch (t.kind) {
386
+ case 'origin': {
387
+ const o = job?.origin;
388
+ if (!o?.platform) return resolveTarget(job, profile, { kind: 'home' });
389
+ return chatKey(o.platform, o.chat_type, o.chat_id, o.thread_id);
390
+ }
391
+ case 'home': {
392
+ const h = homeChannel(profile);
393
+ if (!h) return { kind: 'local' };
394
+ return chatKey(h.platform, h.kind, h.chat_id, h.thread_id);
395
+ }
396
+ case 'explicit': {
397
+ if (!t.chat_id) {
398
+ // Hermes `_resolve_single_delivery_target`: the platform's home channel; else, for a job made on
399
+ // that platform, its origin chat; else no live delivery (the output is saved)
400
+ const h = homeChannel(profile, t.platform);
401
+ if (h) return chatKey(h.platform, h.kind, h.chat_id, h.thread_id);
402
+ const o = job?.origin;
403
+ if (o?.platform === t.platform && o.chat_id) return chatKey(o.platform, o.chat_type, o.chat_id, o.thread_id);
404
+ return { kind: 'local' };
405
+ }
406
+ return chatKey(t.platform, null, t.chat_id, t.thread_id);
407
+ }
408
+ case 'local':
409
+ default:
410
+ return { kind: 'local' };
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Hermes `cron/scheduler.py` `_preflight_check_delivery`, transcribed: why a
416
+ * job's delivery target cannot be reached, or null. As there, `local`,
417
+ * `origin` and `home` need no check; an explicit platform the daemon runs no
418
+ * adapter for blocks the run, in Hermes's words. The adapters are the ones the
419
+ * loop connected at start (each reports up or down as it comes up).
420
+ */
421
+ export function deliveryError(state, job, profile, spec = job?.deliver ?? null) {
422
+ if (spec?.kind !== 'explicit' || !spec.platform) return null;
423
+ const adapter = state.runtime?.adapters?.[spec.platform];
424
+ if (adapter && adapter.configured !== false) return null;
425
+ return `delivery platform '${spec.platform}' has no gateway credentials configured (not connected). Configure it via \`hermes setup\` or change the job's \`deliver\` target.`;
426
+ }
427
+
428
+ function chatKey(platform, chatType, chatId, threadId) {
429
+ const key = { platform, kind: chatType ?? 'dm' };
430
+ if (chatId) key.chat_id = String(chatId);
431
+ if (threadId) key.thread_id = String(threadId);
432
+ return key;
433
+ }
434
+
435
+ // ---------------------------------------------------------------- gates
436
+
437
+ /**
438
+ * Hermes's pre-model fields, kept in the job's residue as its `jobs.json`
439
+ * holds them: `script` (stdout feeds the prompt, or IS the job with
440
+ * `no_agent`), `monitor_script` / `monitor_url` (hash-suppressed change
441
+ * detection, `cron/monitor.py`). Null when the job has none.
442
+ */
443
+ export function jobGate(job) {
444
+ const r = job?.residue ?? {};
445
+ const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
446
+ const gate = { script: text(r.script), no_agent: r.no_agent === true, monitor_script: text(r.monitor_script), monitor_url: text(r.monitor_url) };
447
+ return gate.script || gate.no_agent || gate.monitor_script || gate.monitor_url ? gate : null;
448
+ }
449
+
450
+ /**
451
+ * The gate fields a job door accepts, in Hermes's `create_job` names, checked
452
+ * by its rules and returned for the job's residue (empty when none is given).
453
+ */
454
+ export function gateFields(args) {
455
+ const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
456
+ const fields = {};
457
+ if (text(args.script)) fields.script = text(args.script);
458
+ if (args.no_agent !== undefined && Boolean(args.no_agent)) fields.no_agent = true;
459
+ if (text(args.monitor_script)) fields.monitor_script = text(args.monitor_script);
460
+ if (text(args.monitor_url)) fields.monitor_url = text(args.monitor_url);
461
+ const gate = jobGate({ residue: fields });
462
+ const broken = gate && gateError(gate);
463
+ if (broken) throw new Error(broken);
464
+ return fields;
465
+ }
466
+
467
+ /**
468
+ * Hermes `_validate_cron_script_path` (`tools/cronjob_tools.py`), the model's
469
+ * door only: a script is a path relative to the profile's scripts/ folder,
470
+ * never absolute, home-relative or escaping by `..`.
471
+ */
472
+ export function scriptPathError(script) {
473
+ const raw = typeof script === 'string' ? script.trim() : '';
474
+ if (!raw) return null;
475
+ if (raw.startsWith('/') || raw.startsWith('~') || (raw.length >= 2 && raw[1] === ':')) {
476
+ return `Script path must be relative to the profile's scripts/ folder. Got absolute or home-relative path: ${JSON.stringify(raw)}. Place scripts in scripts/ and use just the filename.`;
477
+ }
478
+ const parts = [];
479
+ for (const part of raw.split(/[\\/]+/)) {
480
+ if (part === '..') { if (!parts.length) return `Script path escapes the scripts directory via traversal: ${JSON.stringify(raw)}`; parts.pop(); }
481
+ else if (part && part !== '.') parts.push(part);
482
+ }
483
+ return null;
484
+ }
485
+
486
+ /** Hermes's payload rule (`_PAYLOAD_FIELDS`): a job needs a prompt, a script, a skill, or no_agent with its script. */
487
+ export function payloadError(args, gate) {
488
+ const prompt = typeof args.prompt === 'string' && args.prompt.trim();
489
+ const skills = Array.isArray(args.skills) && args.skills.some((s) => String(s).trim());
490
+ return prompt || gate.script || skills ? null : 'a job needs a prompt, a script or skills';
491
+ }
492
+
493
+ /** Hermes `create_job`'s combination rules (`cron/jobs.py:2181-2205`), refused by name. */
494
+ export function gateError(gate) {
495
+ if (gate.monitor_script && gate.monitor_url) return 'monitor_script and monitor_url are mutually exclusive: a job watches one source';
496
+ if ((gate.monitor_script || gate.monitor_url) && gate.no_agent) return 'monitor_script/monitor_url cannot be combined with no_agent=True: a monitor decides whether the agent runs. Use a plain no_agent script job instead.';
497
+ if (gate.no_agent && !gate.script) return 'no_agent=True requires a script: with no agent and no script there is nothing to run';
498
+ return null;
499
+ }
500
+
501
+ /** Hermes `_parse_wake_gate`: a last stdout line `{"wakeAgent": false}` skips the agent. */
502
+ export function wakes(output) {
503
+ const lines = String(output ?? '').split('\n').filter((l) => l.trim());
504
+ if (!lines.length) return true;
505
+ try {
506
+ const gate = JSON.parse(lines[lines.length - 1].trim());
507
+ return !(gate && typeof gate === 'object' && !Array.isArray(gate) && gate.wakeAgent === false);
508
+ } catch { return true; }
509
+ }
510
+
511
+ /**
512
+ * Hermes's blocked run (`run_job` preflight, `mark_preflight_alerted`): no
513
+ * model, `last_status: blocked_config`, the run's document saved every time,
514
+ * and its one alert (Hermes's words) delivered only on the first blocked run.
515
+ */
516
+ function blockedFire(state, profile, job, fire, reason, now, effects) {
517
+ const alerted = job.residue?.preflight_alerted === true;
518
+ (job.residue ??= {}).preflight_alerted = true;
519
+ finishFire(profile, fire, job, 'failed', now, `${alerted ? '[blocked_config:silent]' : '[blocked_config]'} ${reason}`, effects);
520
+ job.last_status = 'blocked_config';
521
+ const name = job.residue?.name ?? job.id;
522
+ effects.push({ type: 'write_local', profile: profile.name, path: `cron/output/${job.id}/${compactStamp(now)}.md`, text: `# Cron Job: ${name}\n\n**Job ID:** ${job.id}\n**Run Time:** ${now}\n**Status:** BLOCKED (configuration)\n\nPre-dispatch validation found a configuration problem and the agent was NOT run (no tokens spent).\n\n**Reason:** ${reason}\n\nThe job will stay blocked (without re-alerting) until the configuration is fixed; the next healthy run clears this state.\n` });
523
+ const target = resolveTarget(job, profile, job.failure_deliver ?? job.deliver ?? null);
524
+ if (!alerted && target.kind !== 'local') {
525
+ const o = newObligation(profile, target, `⛔ Cron '${name}' blocked by configuration validation (no LLM call was made): ${reason} This alert is sent once; the job stays blocked until the configuration is fixed.`, { kind: 'fire', fire_id: fire.id }, now);
526
+ o.id = `obl-${fire.id}`;
527
+ fire.obligation_id = o.id;
528
+ effects.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
529
+ }
530
+ effects.push({ type: 'save' });
531
+ }
532
+
533
+ /** A run that ends without the model and without delivery: Hermes's SILENT runs. */
534
+ function silentFire(profile, job, fire, status, now, effects) {
535
+ finishFire(profile, fire, job, 'succeeded', now, null, effects);
536
+ fire.residue = { ...(fire.residue ?? {}), status };
537
+ fire.obligation_id = null;
538
+ effects.push({ type: 'write_local', profile: profile.name, path: `cron/output/${job.id}/${compactStamp(now)}.md`, text: `# Cron Job: ${job.residue?.name ?? job.id}\n\n**Job ID:** ${job.id}\n**Run Time:** ${now}\n**Status:** ${status}\n` });
539
+ effects.push({ type: 'save' });
540
+ }
541
+
542
+ /**
543
+ * The gate's answer (`gate_result`), in Hermes's order (`run_job`): a
544
+ * `no_agent` job delivers its script's stdout (empty or `wakeAgent:false` is
545
+ * silent; a failure is an alert); a monitor that failed is an error, one that
546
+ * is unchanged is a silent `no_change`, one that changed puts its block in the
547
+ * prompt's Run Context; a pre-run script's stdout leads the prompt (empty or
548
+ * `wakeAgent:false` skips the model; a failure is reported to it).
549
+ */
550
+ export function gateResult(state, ev, effects) {
551
+ const profile = state.profiles[ev.profile];
552
+ const rt = ensureRuntime(state);
553
+ delete rt.gates[`${ev.profile}:${ev.fire_id}`];
554
+ const fire = profile?.fires.find((f) => f.id === ev.fire_id);
555
+ if (!fire || fire.status !== 'running') return;
556
+ const job = profile.jobs[fire.job_id];
557
+ if (!job) { finishFire(profile, fire, null, 'failed', ev.at, 'the job was deleted while its gate ran', effects); return; }
558
+ const now = ev.at;
559
+ const name = job.residue?.name ?? job.id;
560
+ // the chat gets Hermes's one-line summary of the error (deliverResult); the error itself is the output
561
+ const fail = (why) => {
562
+ finishFire(profile, fire, job, 'failed', now, why, effects);
563
+ deliverResult(state, profile, { job, fire }, why, true, now, effects);
564
+ };
565
+ const script = ev.script ?? null;
566
+ if (jobGate(job)?.no_agent) {
567
+ if (!script?.ok) return fail(script?.output ?? 'the script did not run');
568
+ if (!wakes(script.output)) return silentFire(profile, job, fire, 'silent (wakeAgent=false)', now, effects);
569
+ if (!script.output.trim()) return silentFire(profile, job, fire, 'silent (empty output)', now, effects);
570
+ finishFire(profile, fire, job, 'succeeded', now, null, effects);
571
+ deliverResult(state, profile, { job, fire }, script.output, false, now, effects);
572
+ return;
573
+ }
574
+ let extra = null;
575
+ const monitor = ev.monitor ?? null;
576
+ if (monitor) {
577
+ if (!monitor.ok) return fail(monitor.error);
578
+ if (!monitor.changed) return silentFire(profile, job, fire, 'no_change (agent run suppressed)', now, effects);
579
+ // detection is the state boundary: persisted before the agent runs (the loop wrote the snapshot)
580
+ (job.residue ??= {}).monitor_state = { last_output_hash: monitor.hash, last_changed_at: now };
581
+ extra = monitor.block;
582
+ }
583
+ let prompt = firePrompt(job);
584
+ if (extra) prompt = `${prompt}\n\n## Run Context\n${extra}`;
585
+ if (script) {
586
+ if (script.ok && !script.output) return silentFire(profile, job, fire, 'silent (empty script output)', now, effects);
587
+ if (script.ok && !wakes(script.output)) return silentFire(profile, job, fire, 'silent (wakeAgent=false)', now, effects);
588
+ prompt = script.ok
589
+ ? `## Script Output\nThe following data was collected by a pre-run script. Use it as context for your analysis.\n\n\`\`\`\n${script.output}\n\`\`\`\n\n${prompt}`
590
+ : `## Script Error\nThe data-collection script failed. Report this to the user.\n\n\`\`\`\n${script.output}\n\`\`\`\n\n${prompt}`;
591
+ }
592
+ launchFire(state, profile, job, fire, prompt, now, effects);
593
+ }
594
+
595
+ // ---------------------------------------------------------------- history
596
+
597
+ /** Hermes's `int(x) or None` for a count: a positive integer, else null. */
598
+ function positiveInt(value) {
599
+ const n = cronNumber(value, 0);
600
+ return Math.trunc(n) > 0 ? Math.trunc(n) : null;
601
+ }
602
+
603
+ /**
604
+ * Hermes's own number parsing for a `cron:` key (`int(...)`/`float(...)` with
605
+ * the default on anything unreadable, null and "" included).
606
+ */
607
+ function cronNumber(value, fallback) {
608
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') return fallback;
609
+ const n = Number(value);
610
+ return Number.isFinite(n) ? n : fallback;
611
+ }
612
+
613
+ /**
614
+ * Hermes's retention, transcribed:
615
+ * - `cron.output_retention` run files per job (50, `_prune_job_output`; the
616
+ * loop prunes as it writes);
617
+ * - `cron.completed_retention_days` for a completed one-shot (7,
618
+ * `_completed_oneshot_retention_days`);
619
+ * - the executions ledger keeps its newest 1000 terminal rows across every
620
+ * job (`cron/executions.py` `MAX_TERMINAL_EXECUTIONS`, `_prune_unlocked`);
621
+ * - the delivery ledger drops delivered and abandoned rows older than 7 days,
622
+ * then keeps at most 500 rows, removing delivered, then abandoned, then
623
+ * pending, oldest first (`gateway/delivery_ledger.py` `_prune`).
624
+ * Zero or less disables the first two, as there.
625
+ */
626
+ export const MAX_TERMINAL_FIRES = 1000;
627
+ export const OBLIGATION_RETENTION_DAYS = 7;
628
+ export const MAX_OBLIGATIONS = 500;
629
+
630
+ export function historyRetention(profile) {
631
+ const cron = profile?.residue?.config?.cron ?? {};
632
+ return { outputs: Math.trunc(cronNumber(cron.output_retention, 50)), days: cronNumber(cron.completed_retention_days, 7) };
633
+ }
634
+
635
+ function pruneHistory(profile, now, effects) {
636
+ const { days } = historyRetention(profile);
637
+ const t = Date.parse(now);
638
+ let pruned = 0;
639
+ // completed one-shots (spent: disabled, nothing due, has run)
640
+ if (days > 0) {
641
+ const cutoff = t - days * 86_400_000;
642
+ for (const job of Object.values(profile.jobs)) {
643
+ const spent = job.schedule?.kind === 'once' && !job.enabled && !job.next_run_at && job.last_run_at;
644
+ if (spent && Date.parse(job.last_run_at) < cutoff) { delete profile.jobs[job.id]; pruned++; }
645
+ }
646
+ }
647
+ // the executions ledger: newest MAX_TERMINAL_FIRES terminal fires, in-flight ones always kept
648
+ const terminal = profile.fires.filter((f) => !['claimed', 'running'].includes(f.status));
649
+ if (terminal.length > MAX_TERMINAL_FIRES) {
650
+ const newest = (a, b) => (a.claimed_at === b.claimed_at ? (a.id < b.id ? 1 : -1) : a.claimed_at < b.claimed_at ? 1 : -1);
651
+ const drop = new Set(terminal.sort(newest).slice(MAX_TERMINAL_FIRES));
652
+ profile.fires = profile.fires.filter((f) => !drop.has(f));
653
+ pruned += drop.size;
654
+ }
655
+ // the delivery ledger (our `sent` is Hermes's delivered, `failed` its abandoned)
656
+ const before = profile.obligations.length;
657
+ const cutoff = t / 1000 - OBLIGATION_RETENTION_DAYS * 86_400;
658
+ profile.obligations = profile.obligations.filter((o) => !(['sent', 'failed'].includes(o.state) && Number(o.updated_at) < cutoff));
659
+ const excess = profile.obligations.length - MAX_OBLIGATIONS;
660
+ if (excess > 0) {
661
+ const rank = (o) => (o.state === 'sent' ? 0 : o.state === 'failed' ? 1 : 2);
662
+ const drop = new Set([...profile.obligations].sort((a, b) => rank(a) - rank(b) || Number(a.updated_at) - Number(b.updated_at)).slice(0, excess));
663
+ profile.obligations = profile.obligations.filter((o) => !drop.has(o));
664
+ }
665
+ pruned += before - profile.obligations.length;
666
+ if (pruned) effects.push({ type: 'save' });
667
+ }
668
+
669
+ // ---------------------------------------------------------------- the tick
670
+
671
+ /**
672
+ * The cron half of `handleTick`: close breakers whose window passed, claim and
673
+ * run due jobs, and re-emit `deliver` for pending obligations whose backoff
674
+ * elapsed. Called by the reducer after the expiry half.
675
+ */
676
+ export function cronTick(state, ev, effects) {
677
+ const now = ev.at;
678
+ const t = Date.parse(now);
679
+ const rt = ensureRuntime(state);
680
+ for (const adapter of Object.values(rt.adapters)) {
681
+ if (adapter.breaker?.until && t >= Date.parse(adapter.breaker.until)) adapter.breaker = { failures: 0 };
682
+ }
683
+ // Hermes's pool is one per process: the cap counts every profile's fires,
684
+ // from HERMES_CRON_MAX_PARALLEL, else the root profile's cron.max_parallel_jobs
685
+ const cap = ev.max_parallel ?? positiveInt(state.profiles.default?.residue?.config?.cron?.max_parallel_jobs);
686
+ const inFlight = () => Object.values(state.profiles).reduce((n, p) => n + p.fires.filter((f) => f.status === 'claimed' || f.status === 'running').length, 0);
687
+ for (const profile of Object.values(state.profiles)) {
688
+ reapStaleFires(state, profile, now, effects, ev.inactivity_s ?? 600);
689
+ pruneHistory(profile, now, effects);
690
+ for (const job of dueJobs(profile, now)) {
691
+ // past the cap, the rest stay due for the next tick (Hermes queues them
692
+ // in its pool within the tick); a job that starts nothing takes no slot
693
+ if (cap && inFlight() >= cap) break;
694
+ // a job that reached the folder by hand with a schedule nothing can
695
+ // evaluate is disabled before it runs, not after every run
696
+ const broken = scheduleError(job.schedule);
697
+ if (broken) scheduleBroken(profile, job, broken, now, effects);
698
+ else startFire(state, profile, job, now, effects);
699
+ }
700
+ retryObligations(state, profile, now, effects);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * A fire whose session went away without a result — the expiry half of the same
706
+ * tick just ended it, say — is `timeout`, delivered like any other failure.
707
+ * Runs before the claim so a job unblocks on the same tick it times out.
708
+ */
709
+ function reapStaleFires(state, profile, now, effects, limitS) {
710
+ const rt = ensureRuntime(state);
711
+ for (const fire of [...profile.fires]) {
712
+ if (fire.status !== 'claimed' && fire.status !== 'running') continue;
713
+ if (rt.hostedFires?.[`${profile.name}:${fire.id}`]) continue;
714
+ if (rt.gates[`${profile.name}:${fire.id}`]) continue; // its gate is still running (bounded by the script timeout)
715
+ // an ended binding lives under a started_at-qualified slot, so search by the fire it ran
716
+ const binding = Object.values(profile.bindings).find((b) => b.residue?.fire_id === fire.id);
717
+ if (binding && !binding.ended_at) {
718
+ // Hermes's inactivity watchdog, transcribed: a worker silent for the
719
+ // limit is a hung run. Without it the fire stays in flight and its job
720
+ // is never due again. Silence counts from the last worker event, the
721
+ // lease (a reattach after restart starts it afresh) or the claim.
722
+ if (!(limitS > 0)) continue;
723
+ const ks = surfaceKeyString(binding.key);
724
+ const lid = leaseId(profile.name, ks);
725
+ const lastAt = [rt.activity[lid], rt.leases[lid]?.since, fire.started_at, fire.claimed_at].filter(Boolean).sort().pop();
726
+ const idle = Math.round((Date.parse(now) - Date.parse(lastAt)) / 1000);
727
+ if (idle < limitS) continue;
728
+ const job = profile.jobs[fire.job_id] ?? null;
729
+ const why = `no activity from the worker for ${idle}s (limit ${limitS}s, HERMES_CRON_TIMEOUT; last activity ${lastAt}${rt.leases[lid] ? '' : ', the worker never started'}); the run was stopped`;
730
+ finishFire(profile, fire, job, 'timeout', now, why, effects);
731
+ deliverResult(state, profile, { job, fire, sub: null }, why, true, now, effects);
732
+ endBinding(state, profile.name, ks, 'error', now, effects); // the fire records the timeout; EndReason is closed
733
+ continue;
734
+ }
735
+ const job = profile.jobs[fire.job_id] ?? null;
736
+ const why = binding?.end_reason ? `the fire session ended (${binding.end_reason}) without a result`
737
+ : fire.residue?.gate && !binding ? 'the daemon restarted while the job\'s gate script ran; the run was abandoned'
738
+ : 'the fire session ended without a result';
739
+ finishFire(profile, fire, job, 'timeout', now, why, effects);
740
+ deliverResult(state, profile, { job, fire, sub: null }, why, true, now, effects);
741
+ }
742
+ }
743
+
744
+ /**
745
+ * The fire prompt is the job's prompt; `context_from` (job ids or "self") is
746
+ * carried beside it so the loop, which can read files, prepends those jobs'
747
+ * most recent output the way Hermes's scheduler does (`cron/scheduler.py:4823-4895`).
748
+ */
749
+ function firePrompt(job) {
750
+ return job.prompt ?? '';
751
+ }
752
+
753
+ function startFire(state, profile, job, now, effects) {
754
+ const fire = claimFire(profile, job, now);
755
+ if (!fire) return;
756
+ effects.push({ type: 'save' }); // the claim is durable before anything runs (at-most-once)
757
+ if (state.runtime?.hostedJobs) {
758
+ fire.started_at = now;
759
+ fire.status = 'running';
760
+ fire.session_id = null; // a host callback is not a fabricated native worker session
761
+ fire.residue = { execution: 'host_task' };
762
+ state.runtime.hostedFires[`${profile.name}:${fire.id}`] = true;
763
+ effects.push({ type: 'save' });
764
+ effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id });
765
+ return;
766
+ }
767
+ // A gate (a script, a monitor source) runs before any model, off the event
768
+ // queue: it may take Hermes's hour. Its answer comes back as `gate_result`.
769
+ const gate = jobGate(job);
770
+ if (gate) {
771
+ const broken = gateError(gate);
772
+ if (broken) {
773
+ finishFire(profile, fire, job, 'failed', now, broken, effects);
774
+ deliverResult(state, profile, { job, fire }, broken, true, now, effects);
775
+ return;
776
+ }
777
+ fire.started_at = now;
778
+ fire.status = 'running';
779
+ fire.residue = { ...(fire.residue ?? {}), gate: true };
780
+ ensureRuntime(state).gates[`${profile.name}:${fire.id}`] = true;
781
+ effects.push({ type: 'run_gate', profile: profile.name, job_id: job.id, fire_id: fire.id });
782
+ effects.push({ type: 'save' });
783
+ return;
784
+ }
785
+ launchFire(state, profile, job, fire, firePrompt(job), now, effects);
786
+ }
787
+
788
+ /**
789
+ * The fire's worker run: its own session, the prompt queued behind the lease.
790
+ * Hermes's delivery preflight runs here, after every gate (a silent script or
791
+ * monitor tick stays silent), just before a model would be paid for.
792
+ */
793
+ function launchFire(state, profile, job, fire, prompt, now, effects) {
794
+ const undeliverable = deliveryError(state, job, profile);
795
+ if (undeliverable) { blockedFire(state, profile, job, fire, undeliverable, now, effects); return; }
796
+ if (job.residue && 'preflight_alerted' in job.residue) delete job.residue.preflight_alerted; // a healthy run clears it
797
+ if (!profile.worker) {
798
+ finishFire(profile, fire, job, 'failed', now, `profile ${profile.name} has no worker configured`, effects);
799
+ deliverResult(state, profile, { job, fire }, fire.error, true, now, effects);
800
+ return;
801
+ }
802
+ const rt = ensureRuntime(state);
803
+
804
+ const key = { platform: 'cron', kind: 'dm', chat_id: job.id };
805
+ const ks = surfaceKeyString(key);
806
+ endBinding(state, profile.name, ks, 'new', now, effects); // a previous fire never blocks this one
807
+ profile.bindings[ks] = {
808
+ key,
809
+ worker: { harness: profile.worker.harness, session_id: fire.id, locator: null },
810
+ started_at: now, last_activity_at: now, ended_at: null, end_reason: null,
811
+ handoff: null, recurrence: { job_id: job.id }, residue: { fire_id: fire.id },
812
+ };
813
+ fire.started_at = now;
814
+ const worker = { ...profile.worker, model: job.model ?? profile.worker.model, cwd: job.workdir ?? profile.worker.cwd };
815
+ // The prompt rides the queue: `handleWorker`'s `started` case flushes it once
816
+ // the lease exists, the same path an inbound message takes.
817
+ (rt.queues[leaseId(profile.name, ks)] ??= []).push({ text: prompt, at: now, context_from: job.context_from ?? null, skills: job.skills ?? [], job_id: job.id });
818
+ effects.push({ type: 'runtime_start', profile: profile.name, keyString: ks, worker, prompt });
819
+ effects.push({ type: 'save' });
820
+ }
821
+
822
+ function retryObligations(state, profile, now, effects) {
823
+ const rt = ensureRuntime(state);
824
+ const t = Date.parse(now);
825
+ for (const o of profile.obligations) {
826
+ if (o.state !== 'pending' || o.attempts >= MAX_ATTEMPTS) continue;
827
+ // A transport we already KNOW is down (an `adapter` event said so, §2.8)
828
+ // is the breaker-open case before the breaker has counted to three. Retry
829
+ // it and the whole outage is spent on one obligation's three attempts,
830
+ // which then dies `failed` while the adapter is still down and is never
831
+ // sent — the one thing the breaker exists to prevent (ORC-8, day four).
832
+ if (rt.adapters[o.target.platform]?.state === 'down') continue;
833
+ const breaker = rt.adapters[o.target.platform]?.breaker;
834
+ if (breaker?.until && t < Date.parse(breaker.until)) continue; // open: pending, untouched
835
+ const wait = o.attempts === 0 ? 0 : BACKOFF_MINUTES[Math.min(o.attempts, BACKOFF_MINUTES.length) - 1] * 60000;
836
+ if (t - Number(o.updated_at) * 1000 < wait) continue;
837
+ effects.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
838
+ }
839
+ }
840
+
841
+ // ---------------------------------------------------------------- worker events on a fire's session
842
+
843
+ export const isSynthetic = (binding) => Boolean(binding?.recurrence || binding?.residue?.webhook);
844
+
845
+ function contextOf(profile, binding) {
846
+ if (binding.recurrence) {
847
+ const job = profile.jobs[binding.recurrence.job_id] ?? null;
848
+ const fire = profile.fires.find((f) => f.id === binding.residue?.fire_id)
849
+ ?? [...profile.fires].reverse().find((f) => f.job_id === binding.recurrence.job_id && (f.status === 'claimed' || f.status === 'running'))
850
+ ?? null;
851
+ return { job, fire, sub: null };
852
+ }
853
+ return { job: null, fire: null, sub: profile.subscriptions[binding.residue.webhook] ?? null };
854
+ }
855
+
856
+ /**
857
+ * A permission request on a fire's or a webhook turn's session has nobody to
858
+ * answer it — the synthetic surface has no adapter and no reader — so it is
859
+ * decided at once rather than left to `worker.permission.timeout_seconds`.
860
+ * Hermes's `approvals.cron_mode`, transcribed: `worker.permission.unattended`
861
+ * is `deny` unless it says `approve`, and the worker's matching option is
862
+ * chosen the way the timeout chooses one. One line saying what was decided
863
+ * goes to the job's `failure_deliver`, else `deliver`, else home (a webhook's:
864
+ * the subscription's `deliver`). Returns false for a chat binding, whose
865
+ * prompt is relayed (§4.2).
866
+ */
867
+ export function unattendedRequest(state, profile, binding, keyString, request, now, effects) {
868
+ if (!isSynthetic(binding)) return false;
869
+ // Hermes's `approvals.cron_mode` when Hermes judged the request, else the worker's own setting
870
+ const mode = request.hermes?.verdict === 'deny' ? 'deny' : profile.worker?.permission?.unattended === 'approve' ? 'approve' : 'deny';
871
+ effects.push({ type: 'respond', profile: profile.name, keyString, request_id: String(request.id), response: permissionResponse(request.options, mode === 'approve', request.hermes?.message ?? `Denied: nobody is attending this run to approve it (worker.permission.unattended: ${mode}). Find an alternative approach that avoids this action.`) });
872
+ const { job, fire, sub } = contextOf(profile, binding);
873
+ const subject = String(request.subject ?? 'a tool call').replace(/\s+/g, ' ').slice(0, 200);
874
+ const who = job ? `Cron job ${job.id}` : `Webhook ${sub?.name ?? binding.residue?.webhook ?? ''}`;
875
+ const rule = request.hermes?.verdict === 'deny' ? `Hermes: ${request.hermes.why ?? 'a dangerous command'}` : `worker.permission.unattended: ${mode}`;
876
+ const text = `${who}: ${mode === 'approve' ? 'approved' : 'denied'} a permission request with nobody attending (${rule}): ${subject}`;
877
+ let target = resolveTarget(job, profile, job ? (job.failure_deliver ?? job.deliver ?? { kind: 'home' }) : (sub?.deliver ?? { kind: 'home' }));
878
+ if (target.kind === 'local') target = resolveTarget(job, profile, { kind: 'home' });
879
+ if (target.kind === 'local') {
880
+ effects.push({ type: 'log', level: 'info', message: text });
881
+ return true;
882
+ }
883
+ reply(state, profile, target, text, now, effects, fire ? { kind: 'fire', fire_id: fire.id } : { kind: 'webhook', name: sub?.name ?? '' });
884
+ effects.push({ type: 'save' });
885
+ return true;
886
+ }
887
+
888
+ /**
889
+ * The `final` of a fire's (or a webhook turn's) own session: the obligation is
890
+ * created on the job's resolved delivery target, never on the synthetic key.
891
+ * Returns false when the binding is an ordinary conversation.
892
+ */
893
+ export function fireFinal(state, profile, binding, text, now, effects) {
894
+ if (!isSynthetic(binding)) return false;
895
+ const ctx = contextOf(profile, binding);
896
+ if (ctx.fire) finishFire(profile, ctx.fire, ctx.job, 'succeeded', now, null, effects);
897
+ deliverResult(state, profile, ctx, text, false, now, effects);
898
+ if (binding.recurrence) endBinding(state, profile.name, surfaceKeyString(binding.key), 'new', now, effects);
899
+ return true;
900
+ }
901
+
902
+ /** `error` / `ended` on a fire's session: `failure_deliver`, else `deliver` with a prefix. */
903
+ export function fireFailed(state, profile, binding, message, now, effects) {
904
+ if (!isSynthetic(binding)) return false;
905
+ const ctx = contextOf(profile, binding);
906
+ if (ctx.fire) finishFire(profile, ctx.fire, ctx.job, 'failed', now, message, effects);
907
+ deliverResult(state, profile, ctx, message, true, now, effects);
908
+ return true;
909
+ }
910
+
911
+ function deliverResult(state, profile, ctx, text, failed, now, effects) {
912
+ const { job, fire, sub } = ctx;
913
+ const said = text || '(no output)';
914
+ const body = failed && job ? summarizeFailure(job, said) : failed ? `Failed:\n\n${said}` : said;
915
+ const spec = failed
916
+ ? (job?.failure_deliver ?? job?.deliver ?? sub?.deliver ?? null)
917
+ : (job?.deliver ?? sub?.deliver ?? null);
918
+ const target = resolveTarget(job, profile, spec);
919
+ // Hermes `save_job_output` (`cron/jobs.py:4467`): every fire's output is
920
+ // saved under `cron/output/<job_id>/<stamp>.md`, which is what a later
921
+ // `context_from` reads. `local` delivery is that file and nothing more.
922
+ if (fire && job) {
923
+ effects.push({ type: 'write_local', profile: profile.name, path: `cron/output/${job.id}/${compactStamp(now)}.md`, text: said });
924
+ }
925
+ // a silence marker is kept in the output file and never delivered (Hermes's autonomous lanes)
926
+ if (!failed && isSilence(text)) {
927
+ if (fire) fire.obligation_id = null;
928
+ effects.push({ type: 'save' });
929
+ return null;
930
+ }
931
+ if (target.kind === 'local') {
932
+ if (!fire) effects.push({ type: 'write_local', profile: profile.name, path: `cron/output/webhook_${sub?.name ?? 'unknown'}_${compactStamp(now)}.md`, text: body });
933
+ if (fire) fire.obligation_id = null;
934
+ effects.push({ type: 'save' });
935
+ return null;
936
+ }
937
+ const source = fire ? { kind: 'fire', fire_id: fire.id } : { kind: 'webhook', name: sub?.name ?? '' };
938
+ const o = newObligation(profile, target, body, source, now);
939
+ if (fire) { o.id = `obl-${fire.id}`; fire.obligation_id = o.id; }
940
+ effects.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
941
+ effects.push({ type: 'save' });
942
+ return o;
943
+ }
944
+
945
+ /** A hosted task confirms dispatch/action execution, never a model's eventual answer. */
946
+ export function finishHostedFire(state, event, effects) {
947
+ const profile = state.profiles[event.profile];
948
+ const fire = profile?.fires.find((f) => f.id === event.fire_id);
949
+ if (!fire || !['claimed', 'running'].includes(fire.status)) return;
950
+ delete state.runtime.hostedFires[`${profile.name}:${fire.id}`];
951
+ const job = profile.jobs[fire.job_id];
952
+ finishFire(profile, fire, job, event.error ? 'failed' : 'succeeded', event.at, event.error ?? null, effects);
953
+ const text = event.error || event.receipt.output;
954
+ fire.residue.host_receipt = event.receipt ?? null;
955
+ const delivery = event.receipt?.delivery;
956
+ if (!event.error && delivery?.platform && delivery?.id) {
957
+ const obligation = newObligation(profile, { platform: delivery.platform, kind: 'dm', chat_id: delivery.chat_id || profile.name }, text, { kind: 'fire', fire_id: fire.id }, event.at);
958
+ obligation.state = 'sent';
959
+ obligation.attempts = 1;
960
+ obligation.delivered_at = obligation.updated_at;
961
+ obligation.posted = { message_id: delivery.id };
962
+ obligation.residue.native_id = delivery.id;
963
+ fire.obligation_id = obligation.id;
964
+ }
965
+ effects.push({ type: 'write_local', profile: profile.name, path: `cron/output/${fire.job_id}/${compactStamp(event.at)}.md`, text });
966
+ effects.push({ type: 'save' });
967
+ }