@alphacoder-v0/pi-loops 0.17.3

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 (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
@@ -0,0 +1,981 @@
1
+ /**
2
+ * The heartbeat. One pi process on the machine owns the timer at a time
3
+ * (leadership lives in scheduler.<host>.json with a heartbeat); every other process
4
+ * with this extension stands by and takes over when the owner exits or dies.
5
+ * Loop jobs are therefore host-agnostic: close the pi that created a loop and
6
+ * any other open pi keeps it ticking; restart pi and missed ticks are caught up
7
+ * (once, collapsed) unless the job opted out.
8
+ *
9
+ * Inject-mode jobs are session-bound: they only fire from the process whose
10
+ * current session is the one that created them.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import { PresenceRegistry, type PresenceEntry, type PresenceKind, type PresenceSelf } from "./presence.ts";
15
+ import * as path from "node:path";
16
+ import { Inbox } from "./inbox.ts";
17
+ import { pidAlive, withFileLock, writeFileAtomic } from "./lock.ts";
18
+ import { composeCheckerPrompt, composeLoopPrompt, parseCheckerOutput, parseRunOutput, stripProtocolTags } from "./protocol.ts";
19
+ import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
20
+ import { previewRedacted, redact } from "./redact.ts";
21
+ import { type SubagentSlot, SubagentSlots } from "./slots.ts";
22
+ import { backoffWaitMs, computeDue, computeNext, formatLocal, formatLocalZoned, formatSchedule, isValidSchedule, stamp } from "./schedule.ts";
23
+ import { type CheckerRecord, JobStore, type LoopJob, type RunRecord, hostFileTag, newId } from "./store.ts";
24
+
25
+ export const DEFAULT_TICK_MS = 30_000;
26
+ export const LEADER_STALE_MS = 90_000;
27
+ /** Consecutive failures before a job is retried on a widening gap rather than every due tick. */
28
+ export const FAILURE_BACKOFF_AFTER = 3;
29
+ export const FAILURE_BACKOFF_BASE_MS = 5 * 60_000;
30
+ export const FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60_000;
31
+ /** A run claimed by another machine (shared $HOME) is only assumed dead after this long. */
32
+ export const FOREIGN_RUN_STALE_MS = 24 * 60 * 60 * 1000;
33
+ export const DEFAULT_RUN_TIMEOUT_MS = 15 * 60_000;
34
+ export const MAX_CONCURRENT_RUNS = 3;
35
+
36
+ export interface SessionSnapshot {
37
+ sessionId?: string;
38
+ cwd: string;
39
+ model?: string;
40
+ thinking?: string;
41
+ /** The interactive pi trusted this project (`pi --approve` / trust prompt); children in the same cwd inherit it. */
42
+ trusted?: boolean;
43
+ }
44
+
45
+ export interface SchedulerSettings {
46
+ /**
47
+ * Sub-agents in flight at once (`[cron] max_concurrent_runs`) — loop runs, trigger checks and the
48
+ * /goal evaluator together, not loop runs alone. Only read when no shared `slots` pool is injected.
49
+ */
50
+ maxConcurrentRuns: number;
51
+ /** Stop dispatching once today's automation has cost this much. 0 or absent = no cap. */
52
+ dailyBudgetUsd?: number;
53
+ /** Global switch for firing ticks missed while no pi was open (`[cron] catch_up`); per-job `catchUp` still applies. */
54
+ catchUp: boolean;
55
+ }
56
+
57
+ export interface RunOutcome {
58
+ job: LoopJob;
59
+ record: RunRecord;
60
+ findings: string[];
61
+ result: RunnerResult;
62
+ }
63
+
64
+ export interface SchedulerHooks {
65
+ /** Inject-mode job is due: deliver `prompt` to the current session. `runId` is the id in its `[Trigger …]` prefix. */
66
+ onInject?: (job: LoopJob, prompt: string, runId: string) => void | Promise<void>;
67
+ onRunStart?: (job: LoopJob, runId: string) => void;
68
+ /** A run is being fired for a tick that was missed while no pi was open. */
69
+ onCatchUp?: (job: LoopJob, dueAt: number) => void;
70
+ onRunFinished?: (outcome: RunOutcome) => void;
71
+ onInboxChanged?: () => void;
72
+ /** Every tick, after leadership is settled. Other subsystems (dynamic triggers) piggyback on it. */
73
+ onTick?: (now: number, leader: boolean) => void | Promise<void>;
74
+ /** Leadership gained or lost. */
75
+ onLeadership?: (leader: boolean) => void | Promise<void>;
76
+ /** A whole tick failed: nothing ran. A failure that needs a person, where `log` is a diagnostic at either level. */
77
+ onSchedulerError?: (message: string) => void;
78
+ /** Today's spend passed the configured cap; nothing more will be dispatched today. */
79
+ onBudgetExceeded?: (spent: number, cap: number) => void;
80
+ /**
81
+ * A diagnostic, with how serious it is. Only the scheduler knows whether a line is routine
82
+ * (taking the timer over) or a failure, and the caller was reading it back off the wording with
83
+ * a regex over these strings. Absent `level` means `"warning"`: a line that does not say it is
84
+ * routine is not.
85
+ */
86
+ log?: (message: string, level?: "info" | "warning") => void;
87
+ }
88
+
89
+ export interface SchedulerOptions {
90
+ dir: string;
91
+ getSession: () => SessionSnapshot;
92
+ hooks?: SchedulerHooks;
93
+ tickMs?: number;
94
+ /** Runs loop and checker sub-agents (in-process through pi's SDK in production; tests inject a fake). */
95
+ runner: SubagentRunner;
96
+ now?: () => number;
97
+ /** Trigger hop of this process; sub-agents get hop + 1. */
98
+ hop?: number;
99
+ /** Live settings (re-read on every use so a config reload takes effect). */
100
+ getSettings?: () => SchedulerSettings;
101
+ /** Does pi still have this session? Plain jobs of deleted sessions are disabled (and `gc()` removes them). */
102
+ sessionExists?: (sessionId: string) => boolean;
103
+ /** "host" = the headless keeper of the clock; any interactive pi preempts it. Default "interactive". */
104
+ kind?: PresenceKind;
105
+ /**
106
+ * The process-wide sub-agent admission counter, shared with the trigger runtime and the /goal
107
+ * evaluator (src/slots.ts). Omitted, the scheduler bounds only itself against
108
+ * `maxConcurrentRuns` — which is the bug this exists to close, so production always passes one.
109
+ */
110
+ slots?: SubagentSlots;
111
+ }
112
+
113
+ export const DEAD_SESSION_MARKER = "no longer exists (/cron gc removes it)";
114
+ const DEAD_SESSION_SCAN_MS = 10 * 60_000;
115
+
116
+ /**
117
+ * How long a job's `cwd` may be missing before the job is disabled rather than kept waiting.
118
+ *
119
+ * Long enough for the slowest thing that legitimately arrives late — a network mount at boot — and
120
+ * short enough that a genuinely deleted project stops being retried the same day.
121
+ */
122
+ const CWD_GRACE_MS = 30 * 60_000;
123
+
124
+ interface LeaderRecord {
125
+ pid: number;
126
+ host: string;
127
+ /** Distinguishes scheduler instances inside one process (reload, tests). */
128
+ instance: string;
129
+ sessionId?: string;
130
+ kind?: PresenceKind;
131
+ startedAt: string;
132
+ heartbeatAt: string;
133
+ }
134
+
135
+ export class LoopScheduler {
136
+ readonly store: JobStore;
137
+ readonly inbox: Inbox;
138
+ private readonly dir: string;
139
+ private readonly leaderFile: string;
140
+ private readonly leaderLock: string;
141
+ private readonly getSession: () => SessionSnapshot;
142
+ private readonly hooks: SchedulerHooks;
143
+ private readonly tickMs: number;
144
+ private readonly runner: SubagentRunner;
145
+ private readonly now: () => number;
146
+ private readonly hop: number;
147
+ private readonly getSettings: () => SchedulerSettings;
148
+ /** Every sub-agent this process starts takes a slot from here, loop runs and trigger checks alike. */
149
+ readonly slots: SubagentSlots;
150
+ private readonly sessionExists?: (sessionId: string) => boolean;
151
+ readonly kind: PresenceKind;
152
+ private lastDeadSessionScan = 0;
153
+ /** The last set of next runs written, so an unchanged tick writes nothing. */
154
+ private lastNextRuns: string | undefined;
155
+ private readonly nextRunsFile: string;
156
+ private timer: NodeJS.Timeout | undefined;
157
+ private ticking = false;
158
+ private leader = false;
159
+ private startedAt = 0;
160
+ private readonly inflight = new Map<string, { ctrl: AbortController; label: string; jobId: string; startedAt: string; promptPreview: string; sessionFile?: string }>();
161
+ /** Promises of runs in flight, so stop() can wait for their records to land. */
162
+ private readonly runs = new Set<Promise<void>>();
163
+ private stopped = false;
164
+ private tickPromise?: Promise<void>;
165
+ private readonly instance = newId("sched");
166
+ /** This process in the machine-wide presence registry (which pi is open where). */
167
+ readonly presence: PresenceRegistry;
168
+ readonly self: PresenceSelf;
169
+
170
+ constructor(opts: SchedulerOptions) {
171
+ this.dir = opts.dir;
172
+ this.store = new JobStore(opts.dir);
173
+ this.inbox = new Inbox(opts.dir);
174
+ // One leader per host: machines sharing a $HOME must not elect each other.
175
+ this.leaderFile = path.join(opts.dir, `scheduler.${hostFileTag()}.json`);
176
+ // Per host, like the leader record beside it, and for the same reason. Leadership is per
177
+ // host; two machines sharing a `$HOME` are both leaders, and a cron expression is matched
178
+ // against local time — so one file would be two machines writing different answers over each
179
+ // other, and a panel showing whichever wrote last.
180
+ this.nextRunsFile = path.join(opts.dir, `next-runs.${hostFileTag()}.json`);
181
+ this.leaderLock = path.join(opts.dir, "scheduler.lock");
182
+ this.getSession = opts.getSession;
183
+ this.hooks = opts.hooks ?? {};
184
+ this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS;
185
+ this.runner = opts.runner;
186
+ this.now = opts.now ?? Date.now;
187
+ this.hop = opts.hop ?? 0;
188
+ this.getSettings = opts.getSettings ?? (() => ({ maxConcurrentRuns: MAX_CONCURRENT_RUNS, catchUp: true }));
189
+ this.slots = opts.slots ?? new SubagentSlots(() => this.getSettings().maxConcurrentRuns);
190
+ this.sessionExists = opts.sessionExists;
191
+ this.kind = opts.kind ?? "interactive";
192
+ const session = opts.getSession();
193
+ this.self = { pid: process.pid, host: os.hostname(), instance: this.instance, sessionId: session.sessionId, cwd: session.cwd, kind: this.kind };
194
+ this.presence = new PresenceRegistry(opts.dir, this.self);
195
+ }
196
+
197
+ /** Live pi processes on every host (stale entries pruned). */
198
+ presenceList(now = this.now()): PresenceEntry[] {
199
+ return this.presence.list(now);
200
+ }
201
+
202
+ get isLeader(): boolean {
203
+ return this.leader;
204
+ }
205
+
206
+ get runningCount(): number {
207
+ return this.inflight.size;
208
+ }
209
+
210
+ /** Labels of the loops this process is running right now. */
211
+ runningLabels(): string[] {
212
+ return [...this.inflight.values()].map((r) => r.label);
213
+ }
214
+
215
+ /** In-flight runs with the details `/triggers running` shows. */
216
+ runningRuns(): Array<{ runId: string; label: string; jobId: string; startedAt: string; promptPreview: string; sessionFile?: string }> {
217
+ return [...this.inflight.entries()].map(([runId, r]) => ({ runId, label: r.label, jobId: r.jobId, startedAt: r.startedAt, promptPreview: r.promptPreview, sessionFile: r.sessionFile }));
218
+ }
219
+
220
+ /** Abort one in-flight run (kills the sub-agent). */
221
+ abortRun(runId: string): boolean {
222
+ const r = this.inflight.get(runId);
223
+ if (!r) return false;
224
+ r.ctrl.abort();
225
+ return true;
226
+ }
227
+
228
+ /** Idempotent. Starts the tick timer and takes leadership if free. */
229
+ start(): void {
230
+ if (this.timer) return;
231
+ this.stopped = false;
232
+ this.startedAt = this.now();
233
+ fs.mkdirSync(this.dir, { recursive: true });
234
+ // `tick` catches everything it can, but its own error path calls back into hooks and logging,
235
+ // which are the caller's code. Nothing above this has a handler, so it ends here.
236
+ const safeTick = () => void this.tick().catch((err: any) => this.log(`tick failed: ${err?.message ?? err}`));
237
+ this.timer = setInterval(safeTick, this.tickMs);
238
+ this.timer.unref();
239
+ safeTick();
240
+ }
241
+
242
+ /** Idempotent. Stops the timer, releases leadership, aborts in-flight runs. */
243
+ async stop(): Promise<void> {
244
+ this.stopped = true;
245
+ if (this.timer) {
246
+ clearInterval(this.timer);
247
+ this.timer = undefined;
248
+ }
249
+ await this.tickPromise; // a tick past its leadership claim must not launch after we leave
250
+ for (const { ctrl } of this.inflight.values()) ctrl.abort();
251
+ // What is lost past this is the record, not the run: the sub-agent is already aborted, and
252
+ // waiting longer only delays the quit that asked for it.
253
+ const DRAIN_TIMEOUT_MS = 5_000;
254
+ await this.drain(DRAIN_TIMEOUT_MS);
255
+ await this.releaseLeadership();
256
+ this.presence.remove();
257
+ }
258
+
259
+ readLeader(): LeaderRecord | undefined {
260
+ try {
261
+ return JSON.parse(fs.readFileSync(this.leaderFile, "utf8")) as LeaderRecord;
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
266
+
267
+ private isMine(rec: LeaderRecord | undefined): boolean {
268
+ return !!rec && rec.pid === process.pid && rec.host === os.hostname() && rec.instance === this.instance;
269
+ }
270
+
271
+ private leaderIsStale(rec: LeaderRecord | undefined, now: number): boolean {
272
+ if (!rec) return true;
273
+ if (this.isMine(rec)) return false;
274
+ // The headless host only keeps the clock while nobody is around: an interactive pi takes it
275
+ // back the moment it opens, and the host exits when it sees that (see host.ts).
276
+ if (this.kind === "interactive" && rec.kind === "host") return true;
277
+ const age = now - Date.parse(rec.heartbeatAt);
278
+ if (Number.isNaN(age) || age > LEADER_STALE_MS) return true;
279
+ if (rec.host === os.hostname() && !pidAlive(rec.pid)) return true;
280
+ return false;
281
+ }
282
+
283
+ private async claimOrRenewLeadership(now: number): Promise<boolean> {
284
+ return withFileLock(this.leaderLock, () => {
285
+ const rec = this.readLeader();
286
+ const mine = this.isMine(rec);
287
+ if (!mine && !this.leaderIsStale(rec, now)) {
288
+ this.leader = false;
289
+ return false;
290
+ }
291
+ const next: LeaderRecord = {
292
+ pid: process.pid,
293
+ host: os.hostname(),
294
+ instance: this.instance,
295
+ sessionId: this.getSession().sessionId,
296
+ kind: this.kind,
297
+ startedAt: mine && rec ? rec.startedAt : stamp(now),
298
+ heartbeatAt: stamp(now),
299
+ };
300
+ writeFileAtomic(this.leaderFile, `${JSON.stringify(next, null, 2)}\n`);
301
+ // Routine: which process holds the timer is bookkeeping, not something gone wrong.
302
+ if (!this.leader) this.log(`took over the loop scheduler (pid ${process.pid})`, "info");
303
+ this.leader = true;
304
+ return true;
305
+ });
306
+ }
307
+
308
+ private async releaseLeadership(): Promise<void> {
309
+ if (!this.leader) return;
310
+ this.leader = false;
311
+ try {
312
+ await this.hooks.onLeadership?.(false);
313
+ } catch (err: any) {
314
+ this.log(`leadership hook failed: ${err?.message ?? err}`);
315
+ }
316
+ try {
317
+ await withFileLock(this.leaderLock, () => {
318
+ if (this.isMine(this.readLeader())) fs.rmSync(this.leaderFile, { force: true });
319
+ });
320
+ } catch {
321
+ /* best effort */
322
+ }
323
+ }
324
+
325
+ private log(message: string, level?: "info" | "warning"): void {
326
+ this.hooks.log?.(message, level);
327
+ }
328
+
329
+ /**
330
+ * What automation has spent since local midnight, and whether that is over the cap. A headless
331
+ * host runs unattended for days; without this the only thing bounding the bill is the schedule.
332
+ */
333
+ budgetState(now = this.now()): { spent: number; cap: number; over: boolean } {
334
+ const cap = this.getSettings().dailyBudgetUsd ?? 0;
335
+ if (cap <= 0) return { spent: 0, cap: 0, over: false };
336
+ const midnight = new Date(now);
337
+ midnight.setHours(0, 0, 0, 0);
338
+ try {
339
+ const spent = this.store.spend(midnight.getTime()).total;
340
+ return { spent, cap, over: spent >= cap };
341
+ } catch {
342
+ return { spent: 0, cap, over: false }; // an unreadable run log must not stop the clock
343
+ }
344
+ }
345
+
346
+ /** One scheduler pass. Public so tests can drive it without timers. */
347
+ async tick(): Promise<void> {
348
+ try {
349
+ await this.tickInner();
350
+ } catch (err: any) {
351
+ // Last line of defence: whatever failed, the clock keeps its next appointment. This is a
352
+ // warning, not a log line: a tick that keeps failing means nothing is running at all.
353
+ this.hooks.onSchedulerError?.(`tick failed: ${err?.message ?? err}`);
354
+ this.log(`tick failed: ${err?.message ?? err}`);
355
+ }
356
+ }
357
+
358
+ private async tickInner(): Promise<void> {
359
+ if (this.ticking || this.stopped) return;
360
+ this.ticking = true;
361
+ let finish!: () => void;
362
+ this.tickPromise = new Promise<void>((r) => (finish = r));
363
+ try {
364
+ const now = this.now();
365
+ let leader = false;
366
+ const wasLeader = this.leader;
367
+ try {
368
+ leader = await this.claimOrRenewLeadership(now);
369
+ } catch (err: any) {
370
+ this.log(`leadership check failed: ${err?.message ?? err}`);
371
+ }
372
+ // A UI hook that throws must not escape the tick: `start()` runs it as `void this.tick()`
373
+ // and pi installs no unhandledRejection handler, so an unguarded throw here kills pi.
374
+ if (leader !== wasLeader) {
375
+ try {
376
+ await this.hooks.onLeadership?.(leader);
377
+ } catch (err: any) {
378
+ this.log(`leadership hook failed: ${err?.message ?? err}`);
379
+ }
380
+ }
381
+ const session = this.getSession();
382
+ try {
383
+ this.presence.heartbeat(now, { cwd: session.cwd, sessionId: session.sessionId });
384
+ } catch (err: any) {
385
+ this.log(`presence heartbeat failed: ${err?.message ?? err}`);
386
+ }
387
+ try {
388
+ await this.hooks.onTick?.(now, leader);
389
+ } catch (err: any) {
390
+ this.log(`tick hook failed: ${err?.message ?? err}`);
391
+ }
392
+ let jobs: LoopJob[];
393
+ try {
394
+ // `mutate` writes only when the list really changed, so a tick with nothing to clear
395
+ // costs one read and no write (store.ts).
396
+ jobs = await this.store.mutate((all) => {
397
+ this.clearStaleRunning(all);
398
+ return { jobs: all, result: all };
399
+ });
400
+ } catch (err: any) {
401
+ this.log(`cannot read jobs: ${err?.message ?? err}`);
402
+ return;
403
+ }
404
+ // What the store cannot say and every reader wants: when each job runs next. It is a pure
405
+ // function of the job, but computing it needs the cron evaluator — which the browser
406
+ // front end does not have and should not grow a second copy of. The leader writes the
407
+ // answers where that front end already reads pi-loops' own files, and only when one of
408
+ // them changes, which for a nightly job is once a day.
409
+ if (leader) this.writeNextRuns(jobs, now);
410
+ if (leader && this.sessionExists && now - this.lastDeadSessionScan >= DEAD_SESSION_SCAN_MS) {
411
+ this.lastDeadSessionScan = now;
412
+ try {
413
+ await this.disableDeadSessionJobs(jobs);
414
+ } catch (err: any) {
415
+ this.log(`dead-session check failed: ${err?.message ?? err}`);
416
+ }
417
+ }
418
+ const host = os.hostname();
419
+ for (const job of jobs) {
420
+ if (!job.enabled) continue;
421
+ if (job.host && job.host !== host) continue; // another machine's job (shared $HOME)
422
+ const owned = job.stateful ? leader : !!session.sessionId && job.sessionId === session.sessionId;
423
+ if (!owned) continue;
424
+ // One unusable job must never stop the clock for the others: a schedule that cannot be
425
+ // evaluated disables that job and says why, instead of throwing out of the tick (pi
426
+ // installs no unhandledRejection handler, so that would take the whole session down).
427
+ try {
428
+ if (!isValidSchedule(job.schedule)) throw new Error("unusable schedule");
429
+ const due = computeDue(
430
+ {
431
+ schedule: job.schedule,
432
+ createdAt: Date.parse(job.createdAt),
433
+ lastDueAt: job.lastDueAt ? Date.parse(job.lastDueAt) : undefined,
434
+ lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined,
435
+ },
436
+ now,
437
+ );
438
+ if (due === undefined) continue;
439
+ await this.dispatch(job, due, now, session);
440
+ } catch (err: any) {
441
+ const message = `disabled: ${err?.message ?? err} (${formatSchedule(job.schedule)})`;
442
+ this.log(`loop ${job.id}: ${message}`);
443
+ await this.store.update(job.id, (j) => {
444
+ j.enabled = false;
445
+ j.lastError = message;
446
+ });
447
+ }
448
+ }
449
+ } finally {
450
+ this.ticking = false;
451
+ finish();
452
+ }
453
+ }
454
+
455
+ /**
456
+ * A plain job whose session pi no longer has can never inject again; they are parked with
457
+ * the session's sidecars; here they are disabled with a marker and `/cron gc` removes them.
458
+ */
459
+ private async disableDeadSessionJobs(jobs: LoopJob[]): Promise<void> {
460
+ const exists = this.sessionExists;
461
+ if (!exists) return;
462
+ // Open somewhere (--no-session, --session-dir, another sessions root)? Then it is alive whatever the disk says.
463
+ const live = new Set(this.presence.list(this.now()).map((e) => e.sessionId).filter(Boolean));
464
+ const dead = jobs.filter((j) => j.enabled && !j.stateful && j.sessionId && (!j.host || j.host === os.hostname()) && !live.has(j.sessionId) && !exists(j.sessionId));
465
+ for (const job of dead) {
466
+ await this.store.update(job.id, (j) => {
467
+ j.enabled = false;
468
+ j.lastError = `disabled: session ${job.sessionId!.slice(0, 8)} ${DEAD_SESSION_MARKER}`;
469
+ });
470
+ this.log(`cron ${job.name ?? job.id}: disabled, its session ${job.sessionId!.slice(0, 8)} no longer exists`);
471
+ }
472
+ }
473
+
474
+ /**
475
+ * `next-runs.json`: job id → when it next runs, as the scheduler itself computes it.
476
+ *
477
+ * Derived, so it lives beside the store rather than in it — a next run is not a fact about the
478
+ * job, it is an answer about the clock, and `jobs.json` is what several processes take a lock to
479
+ * edit. Best effort throughout: a reader that finds it missing or stale shows no next run, which
480
+ * is what it showed before this existed.
481
+ */
482
+ private writeNextRuns(jobs: LoopJob[], now: number): void {
483
+ const next: Record<string, string> = {};
484
+ const host = os.hostname();
485
+ for (const job of jobs) {
486
+ if (!job.enabled || !isValidSchedule(job.schedule)) continue;
487
+ // The same filter `dispatch` applies: this file is per host, and a next run for a job this
488
+ // machine never dispatches (shared $HOME) is a promise nothing here keeps.
489
+ if (job.host && job.host !== host) continue;
490
+ const at = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
491
+ if (at !== undefined) next[job.id] = stamp(at);
492
+ }
493
+ const text = `${JSON.stringify({ at: stamp(now), next }, null, 1)}\n`;
494
+ // The timestamp moves every tick and the answers do not, so the comparison ignores it: this
495
+ // writes when a job fires or is edited, not 2880 times a day.
496
+ const fingerprint = JSON.stringify(next);
497
+ if (fingerprint === this.lastNextRuns) return;
498
+ this.lastNextRuns = fingerprint;
499
+ try {
500
+ writeFileAtomic(this.nextRunsFile, text);
501
+ } catch {
502
+ /* a derived file is a courtesy; never a reason to disturb the tick */
503
+ }
504
+ }
505
+
506
+ /** Remove the jobs `disableDeadSessionJobs` parked; returns them. */
507
+ async gc(inScope: (job: LoopJob) => boolean = () => true): Promise<LoopJob[]> {
508
+ return this.store.removeWhere((j) => inScope(j) && !j.enabled && !!j.lastError?.endsWith(DEAD_SESSION_MARKER));
509
+ }
510
+
511
+ /** Clear running markers left behind by processes that are gone. Mutates `jobs` in place. */
512
+ private clearStaleRunning(jobs: LoopJob[]): void {
513
+ // The wall clock, not `this.now()`: `os.uptime()` is measured against it, so the boot instant
514
+ // is only meaningful in the same terms — and so is the `recycled` comparison against it below,
515
+ // which is why a test cannot move that one. The foreign-host age check does go through
516
+ // `this.now()`: it compares a stamp against our own notion of now, with no machine fact in it.
517
+ const booted = Date.now() - os.uptime() * 1000;
518
+ for (const job of jobs) {
519
+ if (!job.running) continue;
520
+ const { pid, runId, startedAt } = job.running;
521
+ // Another machine's pid table says nothing about this run (shared $HOME); leave it to the
522
+ // host that owns it, and fall back to a generous age check so it cannot stick forever.
523
+ if (job.running.host && job.running.host !== this.self.host) {
524
+ if (Date.parse(startedAt) > this.now() - FOREIGN_RUN_STALE_MS) continue;
525
+ } else {
526
+ const isMine = pid === process.pid;
527
+ // A pid is only evidence while it can still be the same process: after a reboot the
528
+ // numbers are handed out again, and a recycled pid would park the job forever.
529
+ const recycled = Number.isFinite(booted) && Date.parse(startedAt) < booted - 60_000;
530
+ const alive = isMine ? this.inflight.has(runId) : !recycled && pidAlive(pid);
531
+ if (alive) continue;
532
+ }
533
+ // The run that died is owed again: roll the bookkeeping back so the next tick re-fires it
534
+ // (collapsed like any other missed tick) instead of silently skipping to the next slot.
535
+ job.running = undefined;
536
+ job.lastDueAt = undefined;
537
+ job.lastFiredAt = undefined;
538
+ job.lastError = "cleared stale running state (previous pi process ended mid-run); the run will be retried";
539
+ }
540
+ }
541
+
542
+ private async dispatch(job: LoopJob, due: number, now: number, session: SessionSnapshot): Promise<void> {
543
+ const dueIso = stamp(due);
544
+ if (job.stateful && !fs.existsSync(job.cwd)) {
545
+ /**
546
+ * The checkout is not there. That is usually a deleted worktree or a moved project — but
547
+ * at boot it is just as often a directory that has not arrived yet: a network mount, an
548
+ * external disk, an encrypted volume, all of which come up *after* the first pi does.
549
+ * Disabling on the first miss meant a nightly job died silently for being twenty seconds
550
+ * early, stayed disabled when the mount appeared, and was only ever noticed by the work
551
+ * not happening.
552
+ *
553
+ * So the slot is owed rather than consumed — `lastDueAt` is untouched, as it is for a job
554
+ * held back by the budget — and the job is disabled only once the directory has been
555
+ * missing for the whole grace period. A mount that turns up inside it runs the owed slot
556
+ * on the next tick.
557
+ */
558
+ const since = Date.parse(job.cwdMissingSince ?? "") || now;
559
+ const gone = now - since >= CWD_GRACE_MS;
560
+ await this.store.update(job.id, (j) => {
561
+ j.cwdMissingSince = stamp(since);
562
+ if (gone) {
563
+ j.enabled = false;
564
+ j.lastDueAt = dueIso;
565
+ j.lastError = `disabled: cwd ${job.cwd} has been missing since ${formatLocal(since)} (re-enable after /cron add --cwd or restoring it)`;
566
+ } else {
567
+ j.lastError = `waiting: cwd ${job.cwd} is not there yet (since ${formatLocal(since)}; disabled if it stays missing)`;
568
+ }
569
+ });
570
+ if (gone) this.log(`cron ${job.name ?? job.id}: disabled, cwd ${job.cwd} missing since ${formatLocal(since)}`);
571
+ return;
572
+ }
573
+ // Back, and the run that was owed is about to happen: the marker must not outlive the outage.
574
+ if (job.cwdMissingSince) {
575
+ await this.store.update(job.id, (j) => {
576
+ j.cwdMissingSince = undefined;
577
+ if (j.lastError?.startsWith("waiting: cwd ")) j.lastError = undefined;
578
+ });
579
+ }
580
+ if (job.running) {
581
+ await this.store.update(job.id, (j) => {
582
+ j.skippedOverlap++;
583
+ j.lastDueAt = dueIso;
584
+ j.lastError = "skipped: previous run still active";
585
+ });
586
+ return;
587
+ }
588
+ const missedWhileDown = due < this.startedAt - this.tickMs;
589
+ if (missedWhileDown && !(job.catchUp && this.getSettings().catchUp)) {
590
+ await this.store.update(job.id, (j) => {
591
+ j.lastDueAt = dueIso;
592
+ j.lastError = `missed ${formatLocal(due)} while no pi was running (catch-up disabled)`;
593
+ });
594
+ return;
595
+ }
596
+ const budget = this.budgetState(now);
597
+ if (budget.over) {
598
+ // Plain jobs are gated too: injecting one makes the parent agent take a turn, which is
599
+ // billed like any other. The slot stays owed: `lastDueAt` is untouched, so whatever is due runs once the day
600
+ // rolls over or the cap is raised, instead of being silently skipped.
601
+ const message = `paused: today's automation has cost $${budget.spent.toFixed(2)} of the $${budget.cap.toFixed(2)} budget ([limits] daily_budget_usd)`;
602
+ if (job.lastError !== message) {
603
+ await this.store.update(job.id, (j) => {
604
+ j.lastError = message;
605
+ });
606
+ this.hooks.onBudgetExceeded?.(budget.spent, budget.cap);
607
+ }
608
+ return;
609
+ }
610
+ if (!job.stateful) {
611
+ await this.injectPlainJob(job, dueIso, now, missedWhileDown ? `\n(catching up a run due ${formatLocal(due)})` : "");
612
+ return;
613
+ }
614
+ // A failing job is retried with a widening gap instead of at every due tick: a loop whose
615
+ // sub-agent kills the process used to re-fire on the very next start, in a loop.
616
+ const failures = job.consecutiveFailures ?? 0;
617
+ if (failures >= FAILURE_BACKOFF_AFTER && job.lastCompletedAt) {
618
+ const wait = backoffWaitMs(failures, FAILURE_BACKOFF_AFTER, FAILURE_BACKOFF_BASE_MS, FAILURE_BACKOFF_MAX_MS);
619
+ if (now - Date.parse(job.lastCompletedAt) < wait) return;
620
+ }
621
+ // The counter is shared with trigger checks and the /goal evaluator (src/slots.ts): the cap is
622
+ // on sub-agents, not on loop runs, so "already in flight" counts theirs too.
623
+ const slot = this.slots.acquire();
624
+ if (!slot) {
625
+ // Deferred, not skipped: `lastDueAt` stays untouched so the slot is still owed and the next
626
+ // tick tries again. Without a trace a starved loop is indistinguishable from one that never
627
+ // ran; `launch` clears `lastError` again as soon as it gets through.
628
+ const { inUseCount, limit } = this.slots;
629
+ await this.store.update(job.id, (j) => {
630
+ j.lastError = `deferred: ${inUseCount} sub-agent(s) already in flight (max ${limit})`;
631
+ });
632
+ return;
633
+ }
634
+ if (this.stopped) {
635
+ slot.release();
636
+ return;
637
+ }
638
+ if (missedWhileDown) this.hooks.onCatchUp?.(job, due);
639
+ // Never await a run inside a tick: heartbeats, leadership, presence and trigger checks keep
640
+ // going while sub-agents work, and several loops really do run at once.
641
+ this.track(this.launch(job, dueIso, now, session, missedWhileDown), slot);
642
+ }
643
+
644
+ /**
645
+ * A plain job firing: the bookkeeping, the injection, and the retirement of a one-shot. Both the
646
+ * timer (`dispatch`) and `/cron run` come through here — they were two copies, and the second one
647
+ * had drifted: no `once` retirement, no `lastDueAt` / `lastCompletedAt`, `lastError` never cleared.
648
+ *
649
+ * `dueIso` is the slot being spent, absent when the run was asked for rather than scheduled;
650
+ * `note` is appended to the injected text (the catch-up line). Returns whether the job was
651
+ * actually injected, so `/cron run` does not report a run that never happened.
652
+ */
653
+ private async injectPlainJob(job: LoopJob, dueIso: string | undefined, now: number, note = ""): Promise<boolean> {
654
+ // A plain job's whole point is to land in a chat, and the headless host has none. The
655
+ // ownership test in `dispatch` already excludes these (the host's snapshot has no sessionId), so
656
+ // this is belt and braces: the bookkeeping below commits before `onInject` runs, and a
657
+ // host that ever gained a session id would otherwise record an undelivered job as a
658
+ // completed run and delete a one-shot unfired.
659
+ if (this.kind === "host") return false;
660
+ await this.store.update(job.id, (j) => {
661
+ if (dueIso) j.lastDueAt = dueIso;
662
+ j.lastFiredAt = stamp(now);
663
+ j.lastCompletedAt = j.lastFiredAt;
664
+ // Only an enabled job's error is stale news. A disabled one's is why it is disabled — and
665
+ // for a job the dead-session sweep parked it is the marker `gc()` matches on.
666
+ if (j.enabled) j.lastError = undefined;
667
+ j.runCount++;
668
+ });
669
+ // Inject-and-run: the job's action lands in the parent chat as a user message with
670
+ // the engine-enforced `[Trigger <run id>] ` prefix. The id is handed over as well: it is minted
671
+ // here, and the caller used to read it back out of the string this just formatted.
672
+ const runId = newId("run");
673
+ await this.hooks.onInject?.(job, `[Trigger ${runId}] ${job.prompt}${note}`, runId);
674
+ if (job.schedule.kind === "once") await this.store.remove(job.id);
675
+ return true;
676
+ }
677
+
678
+ private track(run: Promise<void>, slot: SubagentSlot): void {
679
+ const tracked = run
680
+ .catch((err: any) => this.log(`run failed: ${err?.message ?? err}`))
681
+ .finally(() => {
682
+ // Every run lands here — resolved, rejected or aborted — which is what keeps a run that
683
+ // throws from leaking its slot for the life of the process.
684
+ slot.release();
685
+ this.runs.delete(tracked);
686
+ });
687
+ this.runs.add(tracked);
688
+ }
689
+
690
+ /** Wait (bounded) for in-flight runs to finish writing their records. */
691
+ async drain(timeoutMs: number): Promise<boolean> {
692
+ if (!this.runs.size) return true;
693
+ let timer: NodeJS.Timeout | undefined;
694
+ const timeout = new Promise<false>((r) => {
695
+ timer = setTimeout(() => r(false), timeoutMs);
696
+ });
697
+ const done = Promise.allSettled([...this.runs]).then(() => true as const);
698
+ const result = await Promise.race([done, timeout]);
699
+ if (timer) clearTimeout(timer);
700
+ return result;
701
+ }
702
+
703
+ /**
704
+ * Fire a job now, ignoring its schedule. Returns `true`, or the reason it did not run — phrased
705
+ * to follow the job's name, which is the shape `/cron run` has always reported a refusal in
706
+ * ("nightly is already running").
707
+ */
708
+ async runNow(jobId: string): Promise<true | string> {
709
+ const job = this.store.load().find((j) => j.id === jobId);
710
+ if (!job) return "is no longer in the store";
711
+ if (job.running) return "is already running";
712
+ if (this.stopped) return "cannot run: this session's scheduler has stopped";
713
+ const now = this.now();
714
+ if (!job.stateful) {
715
+ // A disabled plain job stays parked. Its prompt would land in whatever chat is open now
716
+ // rather than the one it was written for, and the injection clears `lastError` — which for a
717
+ // job the dead-session sweep parked is the marker `gc()` matches on, so running it once left
718
+ // a job nothing could fire and nothing could collect.
719
+ if (!job.enabled) return "is disabled (/cron enable it first)";
720
+ // No `dueIso`: this run was asked for, so whatever slot the schedule owes is still owed.
721
+ if (!(await this.injectPlainJob(job, undefined, now))) return "has no chat to run in (the background host has no session)";
722
+ return true;
723
+ }
724
+ // `/cron run` is a direct instruction, so a busy machine does not get to refuse it — but it
725
+ // still occupies a slot, so the shared count stays honest even when that overruns the limit.
726
+ this.track(this.launch(job, undefined, now, this.getSession(), false), this.slots.occupy());
727
+ return true;
728
+ }
729
+
730
+ private async launch(job: LoopJob, dueIso: string | undefined, now: number, session: SessionSnapshot, catchingUp: boolean): Promise<void> {
731
+ const runId = newId("run");
732
+ const startedAt = stamp(now);
733
+ // Kept so an aborted run can hand its slot back rather than skipping to the next one.
734
+ const priorDueAt = job.lastDueAt;
735
+ const priorFiredAt = job.lastFiredAt;
736
+ const claimed = await this.store.update(job.id, (j) => {
737
+ if (j.running) return;
738
+ j.running = { runId, pid: process.pid, host: this.self.host, startedAt };
739
+ if (dueIso) j.lastDueAt = dueIso;
740
+ j.lastFiredAt = startedAt;
741
+ j.lastError = undefined;
742
+ });
743
+ if (!claimed || claimed.running?.runId !== runId) return;
744
+
745
+ const ctrl = new AbortController();
746
+ this.inflight.set(runId, { ctrl, label: job.name ?? job.id, jobId: job.id, startedAt, promptPreview: previewRedacted(job.prompt, 120) });
747
+ try {
748
+ this.hooks.onRunStart?.(claimed, runId);
749
+ } catch (err: any) {
750
+ this.hooks.log?.(`onRunStart hook failed: ${err?.message ?? err}`);
751
+ }
752
+ try {
753
+ await this.runOnce(job, claimed, runId, startedAt, ctrl, session, now, catchingUp, { dueAt: priorDueAt, firedAt: priorFiredAt });
754
+ } finally {
755
+ // Whatever failed (a state file that cannot be read, a hook that threw), the run id is
756
+ // released — otherwise `clearStaleRunning` would call the job alive forever.
757
+ if (this.inflight.delete(runId)) {
758
+ await this.store
759
+ .update(job.id, (j) => {
760
+ if (j.running?.runId === runId) j.running = undefined;
761
+ })
762
+ .catch(() => undefined);
763
+ }
764
+ }
765
+ }
766
+
767
+ /** The body of one run: everything between claiming the job and writing its record. */
768
+ private async runOnce(job: LoopJob, claimed: LoopJob, runId: string, startedAt: string, ctrl: AbortController, session: SessionSnapshot, now: number, catchingUp: boolean, prior: { dueAt?: string; firedAt?: string }): Promise<void> {
769
+ const previousState = this.store.readState(job.id);
770
+ const prompt = composeLoopPrompt(job.prompt, previousState, {
771
+ name: job.name,
772
+ // Zoned, unlike everything shown on a screen: this one goes into a sub-agent's prompt,
773
+ // where a model is asked to reason about how long ago the last run was and has no
774
+ // surrounding context to tell it which clock this came off. It used to be a bare local
775
+ // time that the docs described as UTC.
776
+ runAt: `${formatLocalZoned(now)}${catchingUp ? " (catching up a missed tick)" : ""}`,
777
+ });
778
+ const model = job.model ?? session.model;
779
+ const thinking = job.thinking ?? session.thinking;
780
+
781
+ let result: RunnerResult;
782
+ try {
783
+ result = await this.runner({
784
+ cwd: job.cwd,
785
+ prompt,
786
+ model,
787
+ thinking,
788
+ tools: job.tools,
789
+ timeoutMs: job.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS,
790
+ signal: ctrl.signal,
791
+ sessionDir: this.store.sessionDirFor(job.id),
792
+ hop: this.hop + 1,
793
+ parentSessionId: session.sessionId,
794
+ parentCwd: session.cwd,
795
+ kind: "loop",
796
+ jobId: job.id,
797
+ runId,
798
+ onSessionFile: (file) => {
799
+ const entry = this.inflight.get(runId);
800
+ if (entry) entry.sessionFile = file;
801
+ },
802
+ });
803
+ } catch (err: any) {
804
+ result = failedRun(err?.message ?? String(err));
805
+ }
806
+ // NOTE: the run stays in `inflight` until the checker is done too. Releasing it here would
807
+ // make `clearStaleRunning` see "my pid, not in flight", clear the running marker, roll the
808
+ // schedule back and re-fire the same job while its checker is still working.
809
+
810
+ // Tag extraction never fails a run: malformed/missing tags leave the state untouched.
811
+ const parsed = parseRunOutput(result.text);
812
+ let stateUpdated = false;
813
+ if (result.ok && parsed.state !== undefined) {
814
+ try {
815
+ this.store.writeState(job.id, parsed.state);
816
+ stateUpdated = true;
817
+ } catch (err: any) {
818
+ this.log(`loop ${job.id}: state write failed: ${err?.message ?? err}`);
819
+ }
820
+ }
821
+ // Maker/checker: with verify=true a second, adversarial sub-agent reviews the
822
+ // findings before they reach the inbox. Fail-open: if the checker itself fails, findings
823
+ // still enter the inbox, but marked unverified — a broken checker must not silence the loop.
824
+ let checker: CheckerRecord | undefined;
825
+ let reviewed: Array<{ text: string; verified?: boolean; reason?: string }> = parsed.findings.map((text) => ({ text }));
826
+ if (result.ok && job.verify && parsed.findings.length && !ctrl.signal.aborted) {
827
+ const outcome = await this.runChecker(job, runId, parsed.state ?? previousState, parsed.findings, ctrl.signal, job.checkerModel ?? model, thinking);
828
+ checker = outcome.record;
829
+ reviewed = outcome.reviewed;
830
+ }
831
+ const findings: string[] = [];
832
+ if (result.ok) {
833
+ // cron:<name>, else cron:<id prefix> — "cron-" plus 8 hex, the width /inbox shows.
834
+ const source = `cron:${job.name ?? job.id.slice(0, "cron-".length + 8)}`;
835
+ for (const f of reviewed) {
836
+ try {
837
+ await this.inbox.append({ source, text: f.text, runId, jobId: job.id, cwd: job.cwd, verified: f.verified, verifiedReason: f.reason });
838
+ findings.push(f.text);
839
+ } catch (err: any) {
840
+ this.log(`loop ${job.id}: inbox append failed: ${err?.message ?? err}`);
841
+ }
842
+ }
843
+ }
844
+
845
+ const finishedAt = stamp(this.now());
846
+ const record: RunRecord = {
847
+ runId,
848
+ jobId: job.id,
849
+ jobName: job.name,
850
+ stateful: job.stateful,
851
+ cwd: job.cwd,
852
+ pid: process.pid,
853
+ startedAt,
854
+ finishedAt,
855
+ ok: result.ok,
856
+ exitCode: result.exitCode,
857
+ error: result.ok ? undefined : redact(result.errorMessage ?? "unknown error"),
858
+ findings: findings.length,
859
+ droppedFindings: parsed.droppedFindings,
860
+ stateUpdated,
861
+ model: result.model ?? model,
862
+ usage: result.usage,
863
+ warning: result.warning,
864
+ retries: result.retries,
865
+ compactions: result.compactions,
866
+ summary: previewRedacted(stripProtocolTags(result.text), 400) || undefined,
867
+ sessionId: result.sessionId,
868
+ sessionFile: result.sessionFile,
869
+ checker,
870
+ };
871
+ this.store.pruneSessions(job.id);
872
+ try {
873
+ this.store.appendRun(record);
874
+ } catch (err: any) {
875
+ this.log(`run log append failed: ${err?.message ?? err}`);
876
+ }
877
+ // An abort is not a run: quitting, a session swap or `/cron abort` interrupted it, so the
878
+ // slot it claimed is given back and the next tick re-fires it (a tick is never lost this
879
+ // way because its runs die with the session that owned them).
880
+ const aborted = !result.ok && (result.stopReason === "aborted" || ctrl.signal.aborted);
881
+ const updated = await this.store.update(job.id, (j) => {
882
+ if (j.running?.runId === runId) j.running = undefined;
883
+ j.lastCompletedAt = finishedAt;
884
+ j.lastError = result.ok ? undefined : record.error;
885
+ if (aborted) {
886
+ j.lastDueAt = prior.dueAt;
887
+ j.lastFiredAt = prior.firedAt;
888
+ } else {
889
+ j.runCount++;
890
+ // A job that fails every time costs money every time. Count the streak so `dispatch`
891
+ // can back off, and clear it the moment one run works.
892
+ j.consecutiveFailures = result.ok ? undefined : (j.consecutiveFailures ?? 0) + 1;
893
+ }
894
+ });
895
+ if (updated && !result.ok && (updated.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER) {
896
+ this.hooks.onSchedulerError?.(`${updated.name ?? updated.id} has failed ${updated.consecutiveFailures} times in a row; backing off (last: ${record.error ?? "unknown"})`);
897
+ }
898
+ // Only now: while the run id is in `inflight`, `clearStaleRunning` treats the job as alive,
899
+ // which is exactly right until its `running` marker is gone from the store.
900
+ this.inflight.delete(runId);
901
+ // A one-shot is retired once its single slot is resolved. A failure gets exactly one more
902
+ // attempt — transient model/network errors are the common case and the user asked for the run,
903
+ // not for a job — and is then retired too, with the error kept in the run log and reported by
904
+ // `onRunFinished`. Anything else leaves a failed `in 10m` enabled forever with no next run.
905
+ if (updated && job.schedule.kind === "once" && !aborted) {
906
+ if (result.ok || updated.runCount >= 2) await this.store.remove(job.id);
907
+ else
908
+ await this.store.update(job.id, (j) => {
909
+ j.lastDueAt = undefined;
910
+ j.lastFiredAt = undefined;
911
+ j.lastError = `${record.error ?? "run failed"} (one-shot: retrying once)`;
912
+ });
913
+ }
914
+
915
+ try {
916
+ if (findings.length) this.hooks.onInboxChanged?.();
917
+ this.hooks.onRunFinished?.({ job: updated ?? job, record, findings, result });
918
+ } catch (err: any) {
919
+ this.hooks.log?.(`onRunFinished hook failed: ${err?.message ?? err}`);
920
+ }
921
+ }
922
+
923
+ private async runChecker(
924
+ job: LoopJob,
925
+ runId: string,
926
+ makerState: string | undefined,
927
+ findings: string[],
928
+ signal: AbortSignal,
929
+ model: string | undefined,
930
+ thinking: string | undefined,
931
+ ): Promise<{ record: CheckerRecord; reviewed: Array<{ text: string; verified?: boolean; reason?: string }> }> {
932
+ const started = this.now();
933
+ let result: RunnerResult;
934
+ try {
935
+ const session = this.getSession();
936
+ result = await this.runner({
937
+ cwd: job.cwd,
938
+ prompt: composeCheckerPrompt(job.prompt, makerState, findings, { name: job.name }),
939
+ model,
940
+ thinking,
941
+ tools: job.tools,
942
+ timeoutMs: job.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS,
943
+ signal,
944
+ sessionDir: this.store.sessionDirFor(job.id),
945
+ hop: this.hop + 1,
946
+ parentSessionId: session.sessionId,
947
+ parentCwd: session.cwd,
948
+ kind: "checker",
949
+ jobId: job.id,
950
+ runId,
951
+ });
952
+ } catch (err: any) {
953
+ result = failedRun(err?.message ?? String(err));
954
+ }
955
+ const verdicts = result.ok ? parseCheckerOutput(result.text) : new Map();
956
+ const reviewed: Array<{ text: string; verified?: boolean; reason?: string }> = [];
957
+ const dropped: CheckerRecord["dropped"] = [];
958
+ let unreviewed = 0;
959
+ findings.forEach((text, i) => {
960
+ const v = verdicts.get(i + 1);
961
+ if (!v) {
962
+ unreviewed++;
963
+ reviewed.push({ text, verified: undefined, reason: result.ok ? "checker gave no verdict" : `checker failed: ${result.errorMessage ?? "unknown error"}` });
964
+ } else if (v.verdict === "keep") reviewed.push({ text: v.rewrite ?? text, verified: true, reason: v.reason || undefined });
965
+ else dropped.push({ text, reason: v.reason || "no reason given" });
966
+ });
967
+ const record: CheckerRecord = {
968
+ ok: result.ok,
969
+ error: result.ok ? undefined : redact(result.errorMessage ?? "unknown error"),
970
+ kept: reviewed.filter((r) => r.verified).length,
971
+ dropped: dropped.map((d) => ({ text: previewRedacted(d.text, 500), reason: previewRedacted(d.reason, 300) })),
972
+ unreviewed,
973
+ durationMs: this.now() - started,
974
+ cost: result.usage.cost,
975
+ model: result.model ?? model,
976
+ sessionFile: result.sessionFile,
977
+ };
978
+ if (!result.ok) this.log(`loop ${job.name ?? job.id}: checker failed (${record.error}); findings enter the inbox unverified`);
979
+ return { record, reviewed };
980
+ }
981
+ }