cronfish 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -173,23 +173,34 @@ Sync behavior:
173
173
  | ----------------------------- | ------------------------------------------------- |
174
174
  | Future | plist installed with `StartCalendarInterval` for the exact minute |
175
175
  | Within `grace_seconds` of now | plist installed with `RunAtLoad: true` — fires on bootstrap |
176
- | Past `grace_seconds` | **refused**; sentinel written to `cron/.errors/` |
176
+ | Past `grace_seconds` | **refused**; sentinel written + file archived out of `cron/one-time/` |
177
177
  | `executed_at:` already set | skipped (file should already be archived) |
178
178
 
179
- After firing, the runner stamps `executed_at: <ISO>` and moves the file to
180
- `~/Library/Application Support/cronfish/done/` outside the repo, so the
181
- audit trail doesn't bloat git. A `flock` plus the `executed_at` re-check guard
182
- against double-fires. The next `cronfish sync` removes the orphaned plist.
179
+ After firing, the runner stamps `executed_at: <ISO>`, moves the file to
180
+ `~/Library/Application Support/cronfish/done/` (outside the repo, so the audit
181
+ trail doesn't bloat git), and **removes its own plist** so a reboot/login in
182
+ the window before the next `cronfish sync` can't reload and re-fire it. A
183
+ `flock` plus the `executed_at` re-check guard against double-fires.
183
184
 
184
- **One-time jobs must be idempotent.** launchd can re-fire on machine restart,
185
- system unsleep, or load spikes; the flock + `executed_at` guard catches the
185
+ The runner also **re-checks `grace_seconds` at fire time**: launchd runs a
186
+ `StartCalendarInterval` job once on wake if the machine slept through the
187
+ scheduled minute (a coalesced missed fire), which can land long after `run_at`.
188
+ A fire that arrives past grace is refused (sentinel) instead of running late.
189
+
190
+ **One-time jobs must be idempotent.** The guards above catch the common
186
191
  double-fire, but only after the file is stamped. Anything destructive between
187
192
  "start" and "stamp" can repeat. Write handlers that tolerate two invocations.
188
193
 
189
- **Failure surface — `cron/.errors/`.** Any sync-time refusal (past-grace, bad
190
- YAML, missing `run_at`) writes a sentinel file there with the slug, timestamp,
191
- and reason. Runner-side failures (archive failed, executed_at write failed)
192
- also land here. Wire a heartbeat cron to alert on non-empty.
194
+ **Failure surface — `cron/.errors/`.** Any refusal (past-grace, bad YAML,
195
+ missing `run_at`) and any runner-side failure (archive failed, `executed_at`
196
+ write failed) writes a sentinel there with slug, timestamp, and reason. Wire a
197
+ heartbeat cron to alert on non-empty. Two properties keep the folder bounded:
198
+ sentinels **dedup** (the same recurring error overwrites one file, not one per
199
+ sync), and sync-time sentinels **self-heal** — the next clean `cronfish sync`
200
+ clears any whose error no longer occurs. Inspect or clear by hand with
201
+ `cronfish errors` / `cronfish errors --clear [slug]`. cronfish only manages
202
+ files it wrote (`*.cronfish.txt`); a consumer can drop its own sentinels in the
203
+ same folder without them being reaped.
193
204
 
194
205
  Smoke-test template: `templates/_examples/one-time/echo-at.md`.
195
206
 
@@ -207,6 +218,11 @@ Smoke-test template: `templates/_examples/one-time/echo-at.md`.
207
218
  fires them. Use it for scheduling candidates — jobs you're staging in `cron/` before flipping on
208
219
  a real schedule. Pure on-demand scripts that aren't scheduling candidates belong outside `cron/`.
209
220
 
221
+ **Sub-10s schedules don't work.** launchd enforces a ~10s floor between relaunches of the same
222
+ job (its implicit `ThrottleInterval`). A `schedule:` faster than `10s` fires no quicker than every
223
+ 10s; `cronfish sync` warns when it sees one. Need true high-frequency work? Run a long-lived loop
224
+ as a single job instead of many fast fires.
225
+
210
226
  ## Config — `.cronfish.json` (optional, at repo root)
211
227
 
212
228
  ```json
@@ -245,6 +261,7 @@ cronfish enable <slug> flip enabled, then sync
245
261
  cronfish disable <slug> flip disabled, then sync
246
262
  cronfish delete <slug> --yes bootout + remove plist + job file
247
263
  cronfish status [slug] launchctl print + tail of latest log
264
+ cronfish errors [--clear] [slug] list error sentinels (cron/.errors/); --clear removes them
248
265
  cronfish run <slug> invoke runner directly (no launchd) — for testing
249
266
  cronfish watchdog detect missed schedules → fire alerts
250
267
  cronfish alerts test [adapter] send a test alert via the named (or default) adapter
@@ -531,10 +548,15 @@ via plist `WorkingDirectory`), so no shell wrapper is needed.
531
548
 
532
549
  - macOS (launchd). Linux (systemd) and Windows (Task Scheduler) are on the backlog.
533
550
  - Bun ≥ 1.0.
551
+ - **A logged-in GUI (Aqua) session.** cronfish installs per-user LaunchAgents under
552
+ `~/Library/LaunchAgents`, which only load while the user is logged into the desktop. On a
553
+ headless box reached only over SSH (no console login), agents never load and jobs never fire —
554
+ enable auto-login, or keep a desktop session active. (A system-wide `LaunchDaemon` backend that
555
+ runs without a login session is on the backlog.)
534
556
 
535
557
  ## Status
536
558
 
537
- v0.11 — used in production by the author. API may still break before v1. File issues if you hit
559
+ v0.x — used in production by the author. API may still break before v1. File issues if you hit
538
560
  something rough.
539
561
 
540
562
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronfish",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "A file-based job scheduler for macOS. Drop a script in a folder; launchd runs it on a schedule.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -15,7 +15,15 @@ import { basename, join } from "node:path";
15
15
  import { setFrontmatterKey, setShellFrontmatterKey } from "./frontmatter.ts";
16
16
  import { discoverJobs, findJobFile, loadJob, type JobMeta } from "./jobs.ts";
17
17
  import { dispatchSchedule, type Dispatched } from "./schedule.ts";
18
- import { resolveOneTime, writeSentinel } from "./oneTime.ts";
18
+ import {
19
+ archiveOneTime,
20
+ clearSentinels,
21
+ listSentinels,
22
+ reapStaleSentinels,
23
+ resolveOneTime,
24
+ writeSentinel,
25
+ } from "./oneTime.ts";
26
+ import { defaultBundlePrefix } from "./config.ts";
19
27
  import { platform } from "./platform/index.ts";
20
28
  import { loadState, rememberPrefix } from "./state.ts";
21
29
  import { dbPath, markDeleted, openDb, upsertJob } from "./db.ts";
@@ -97,7 +105,7 @@ function parseRetention(raw: unknown): RetentionConfig | undefined {
97
105
 
98
106
  function loadConfig(): CronfishConfig {
99
107
  const path = join(CONSUMER_ROOT, ".cronfish.json");
100
- const defaultPrefix = `com.cronfish.${basename(CONSUMER_ROOT)}`;
108
+ const defaultPrefix = defaultBundlePrefix(CONSUMER_ROOT);
101
109
  if (!existsSync(path)) return { bundle_prefix: defaultPrefix };
102
110
  let parsed: Partial<CronfishConfig>;
103
111
  try {
@@ -327,6 +335,9 @@ function loadRunnerNames(): Set<string> {
327
335
  function cmdSync(): void {
328
336
  const p = platform();
329
337
  const { jobs, errors } = discoverJobs(CRON_DIR);
338
+ // "sync"-class sentinel filenames written this run. Anything still on disk
339
+ // from a prior sync that ISN'T in here gets reaped at the end (self-heal).
340
+ const writtenSentinels = new Set<string>();
330
341
  for (const e of errors) {
331
342
  console.error(`[cronfish] ${e.path}: ${e.message}`);
332
343
  // Bad YAML / invalid run_at on a one-time file → sentinel. Any other
@@ -334,7 +345,13 @@ function cmdSync(): void {
334
345
  // cron/one-time/ since silent-skip is the failure mode we're killing.
335
346
  if (e.path.includes(`/${"one-time"}/`)) {
336
347
  const slug = e.path.split("/").pop() ?? "unknown";
337
- writeSentinel(CRON_DIR, slug, `discovery error: ${e.message}`);
348
+ const written = writeSentinel(
349
+ CRON_DIR,
350
+ slug,
351
+ `discovery error: ${e.message}`,
352
+ "sync",
353
+ );
354
+ writtenSentinels.add(basename(written));
338
355
  }
339
356
  }
340
357
 
@@ -349,6 +366,15 @@ function cmdSync(): void {
349
366
  `[cronfish] WARN ${j.slug}: runner "${j.runner}" not in .cronfish.json#runners — known: ${known}`,
350
367
  );
351
368
  }
369
+ // launchd enforces a ~10s floor between relaunches (its implicit
370
+ // ThrottleInterval). A faster `schedule:` silently fires no quicker than
371
+ // every 10s, so warn rather than let it look like it works.
372
+ const d = safeDispatch(j.schedule);
373
+ if (d.kind === "seconds" && d.value < 10) {
374
+ console.error(
375
+ `[cronfish] WARN ${j.slug}: schedule ${d.value}s is below launchd's ~10s relaunch floor — it will fire no faster than every 10s`,
376
+ );
377
+ }
352
378
  }
353
379
 
354
380
  const state = rememberPrefix(CONSUMER_ROOT, PREFIX);
@@ -367,7 +393,20 @@ function cmdSync(): void {
367
393
  ) {
368
394
  console.error(`[cronfish] ${j.slug}: ${decision.reason}`);
369
395
  if (j.oneTime && decision.reason?.startsWith("one-time past grace:")) {
370
- writeSentinel(CRON_DIR, j.slug, decision.reason);
396
+ // A past-grace one-time file would otherwise be re-discovered on
397
+ // EVERY sync and re-write a sentinel forever. Record one durable
398
+ // ("run"-class, never reaped) sentinel so the missed window is
399
+ // visible, then archive the file out of cron/one-time/ so it stops
400
+ // recurring.
401
+ writeSentinel(CRON_DIR, j.slug, decision.reason, "run");
402
+ try {
403
+ const dest = archiveOneTime(j.path);
404
+ console.error(`[cronfish] ${j.slug}: archived past-grace one-time → ${dest}`);
405
+ } catch (e) {
406
+ console.error(
407
+ `[cronfish] ${j.slug}: archive of past-grace one-time failed: ${(e as Error).message}`,
408
+ );
409
+ }
371
410
  }
372
411
  }
373
412
  }
@@ -440,6 +479,11 @@ function cmdSync(): void {
440
479
  }
441
480
  }
442
481
 
482
+ // Self-heal: drop any "sync"-class sentinel that was NOT re-written this
483
+ // run (its error no longer occurs). "run"-class + foreign files survive.
484
+ const reaped = reapStaleSentinels(CRON_DIR, writtenSentinels);
485
+ if (reaped > 0) console.log(`[cronfish] cleared ${reaped} resolved sentinel(s)`);
486
+
443
487
  console.log("[cronfish] sync complete");
444
488
  }
445
489
 
@@ -949,6 +993,28 @@ function cmdInit(): void {
949
993
  );
950
994
  }
951
995
 
996
+ function cmdErrors(clear: boolean, slug?: string): void {
997
+ if (clear) {
998
+ const n = clearSentinels(CRON_DIR, slug);
999
+ console.log(
1000
+ `[cronfish] cleared ${n} sentinel(s)${slug ? ` for ${slug}` : ""}`,
1001
+ );
1002
+ return;
1003
+ }
1004
+ const files = listSentinels(CRON_DIR);
1005
+ if (files.length === 0) {
1006
+ console.log("[cronfish] no error sentinels");
1007
+ return;
1008
+ }
1009
+ console.log(
1010
+ `[cronfish] ${files.length} error sentinel(s) in cron/.errors/ (clear with \`cronfish errors --clear\`):\n`,
1011
+ );
1012
+ for (const f of files.sort()) {
1013
+ const body = readFileSync(join(CRON_DIR, ".errors", f), "utf-8").trim();
1014
+ console.log(`--- ${f} ---\n${body}\n`);
1015
+ }
1016
+ }
1017
+
952
1018
  function usage(): void {
953
1019
  console.log(
954
1020
  `cronfish ${VERSION} — drop a file, schedule it.
@@ -964,6 +1030,7 @@ usage:
964
1030
  cronfish disable <slug> flip disabled, then sync
965
1031
  cronfish delete <slug> --yes bootout + remove plist + job file
966
1032
  cronfish status [slug] all jobs (no arg) or one slug's launchctl + log tail
1033
+ cronfish errors [--clear] [slug] list error sentinels (cron/.errors/); --clear removes them
967
1034
  cronfish run <slug> invoke runner directly (no launchd)
968
1035
  cronfish watchdog check enabled jobs for missed schedules → fire alerts
969
1036
  cronfish alerts test [adapter] send a test alert via the named (or default) adapter
@@ -1055,6 +1122,12 @@ async function main(): Promise<void> {
1055
1122
  case "status":
1056
1123
  cmdStatus(rest[0]);
1057
1124
  return;
1125
+ case "errors": {
1126
+ const clear = rest.includes("--clear");
1127
+ const slug = rest.find((a) => !a.startsWith("-"));
1128
+ cmdErrors(clear, slug);
1129
+ return;
1130
+ }
1058
1131
  case "run":
1059
1132
  if (!rest[0]) throw new Error("usage: cronfish run <slug>");
1060
1133
  await cmdRun(rest[0]);
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ // Shared config helpers. The bundle-prefix formula has to be identical
2
+ // everywhere a launchd label is computed — cli.ts (install/bootout) and
3
+ // runner.ts (one-time self-removal) both derive labels from it, so it lives
4
+ // here rather than being duplicated and drifting.
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { basename, join } from "node:path";
8
+
9
+ export function defaultBundlePrefix(consumerRoot: string): string {
10
+ return `com.cronfish.${basename(consumerRoot)}`;
11
+ }
12
+
13
+ // Tolerant prefix read for non-CLI callers (the runner). Returns the default
14
+ // on a missing/unparseable .cronfish.json — the strict validation that the
15
+ // CLI applies at install time has already gated the value by the time a job
16
+ // is running, so the runner only needs to reproduce the same string.
17
+ export function loadBundlePrefix(consumerRoot: string): string {
18
+ const def = defaultBundlePrefix(consumerRoot);
19
+ const path = join(consumerRoot, ".cronfish.json");
20
+ if (!existsSync(path)) return def;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as {
23
+ bundle_prefix?: string;
24
+ };
25
+ return (parsed.bundle_prefix ?? "").trim() || def;
26
+ } catch {
27
+ return def;
28
+ }
29
+ }
package/src/oneTime.ts CHANGED
@@ -13,12 +13,15 @@ import {
13
13
  mkdirSync,
14
14
  openSync,
15
15
  readFileSync,
16
+ readdirSync,
16
17
  renameSync,
18
+ rmSync,
17
19
  statSync,
18
20
  writeFileSync,
19
21
  writeSync,
20
22
  fsyncSync,
21
23
  } from "node:fs";
24
+ import { createHash } from "node:crypto";
22
25
  import { homedir } from "node:os";
23
26
  import { basename, join } from "node:path";
24
27
  import { dlopen, FFIType, suffix } from "bun:ffi";
@@ -111,23 +114,105 @@ export function resolveOneTime(
111
114
 
112
115
  // --- Error sentinel surface ---
113
116
  //
114
- // Any sync-time failure for a one-time job writes a sentinel here. The
115
- // heartbeat cron (Phase 2) sweeps this folder and alerts on non-empty.
117
+ // A failure for a one-time job writes a sentinel here; consumers (e.g. a
118
+ // heartbeat job) sweep the folder and alert on non-empty. Two invariants
119
+ // keep the folder from running away:
120
+ //
121
+ // 1. Dedup — the filename is derived from (slug, hash(reason)), so the
122
+ // SAME error recurring on every sync overwrites one file instead of
123
+ // writing a new one each minute. An unfixed config error is 1 file,
124
+ // not thousands.
125
+ // 2. Ownership + class — cronfish-written files carry a `.<class>.cronfish.txt`
126
+ // suffix so a consumer can drop its own sentinels alongside without
127
+ // cronfish reaping them. Class "sync" = re-derived every reconcile
128
+ // (self-heals: reapStaleSentinels removes it once the error stops
129
+ // recurring). Class "run" = a one-shot runtime failure that is NOT
130
+ // re-detected at sync, so it persists until `cronfish errors --clear`.
131
+
132
+ export type SentinelClass = "sync" | "run";
133
+
134
+ const SENTINEL_EXT = ".cronfish.txt";
116
135
 
117
- export function writeSentinel(cronDir: string, slug: string, reason: string): string {
136
+ function safeSlug(slug: string): string {
137
+ return slug.replace(/[^A-Za-z0-9._-]/g, "_");
138
+ }
139
+
140
+ // Deterministic dedup key: same (slug, reason) → same filename → overwrite.
141
+ export function sentinelFilename(
142
+ slug: string,
143
+ reason: string,
144
+ cls: SentinelClass,
145
+ ): string {
146
+ const hash = createHash("sha1").update(reason).digest("hex").slice(0, 8);
147
+ return `${safeSlug(slug)}--${hash}.${cls}${SENTINEL_EXT}`;
148
+ }
149
+
150
+ export function writeSentinel(
151
+ cronDir: string,
152
+ slug: string,
153
+ reason: string,
154
+ cls: SentinelClass = "run",
155
+ ): string {
118
156
  const dir = errorsDir(cronDir);
119
157
  mkdirSync(dir, { recursive: true });
120
- const ts = Date.now();
121
- const safeSlug = slug.replace(/[^A-Za-z0-9._-]/g, "_");
122
- const path = join(dir, `${ts}-${safeSlug}.txt`);
158
+ const path = join(dir, sentinelFilename(slug, reason, cls));
123
159
  const body =
124
160
  `slug: ${slug}\n` +
125
- `at: ${new Date(ts).toISOString()}\n` +
161
+ `at: ${new Date().toISOString()}\n` +
126
162
  `reason:\n${reason}\n`;
127
163
  writeFileSync(path, body, "utf-8");
128
164
  return path;
129
165
  }
130
166
 
167
+ // Every file currently in cron/.errors/ (basenames), or [] when absent.
168
+ export function listSentinels(cronDir: string): string[] {
169
+ const dir = errorsDir(cronDir);
170
+ if (!existsSync(dir)) return [];
171
+ return readdirSync(dir).filter((f) => f.endsWith(".txt"));
172
+ }
173
+
174
+ // Remove sentinels. With no slug, clears the whole folder (cronfish-owned
175
+ // AND any legacy/consumer .txt files — the manual `errors --clear` escape
176
+ // hatch). With a slug, clears only files for that slug. Returns the count.
177
+ export function clearSentinels(cronDir: string, slug?: string): number {
178
+ const dir = errorsDir(cronDir);
179
+ if (!existsSync(dir)) return 0;
180
+ const prefix = slug ? `${safeSlug(slug)}--` : null;
181
+ let n = 0;
182
+ for (const f of readdirSync(dir)) {
183
+ if (!f.endsWith(".txt")) continue;
184
+ if (prefix && !f.startsWith(prefix)) continue;
185
+ try {
186
+ rmSync(join(dir, f));
187
+ n++;
188
+ } catch {}
189
+ }
190
+ return n;
191
+ }
192
+
193
+ // Self-heal for "sync"-class sentinels: after a reconcile, delete any
194
+ // cronfish "sync" sentinel whose filename was NOT (re)written this run —
195
+ // i.e. the error it recorded no longer occurs. "run"-class and foreign
196
+ // files are never touched here.
197
+ export function reapStaleSentinels(
198
+ cronDir: string,
199
+ keep: Set<string>,
200
+ ): number {
201
+ const dir = errorsDir(cronDir);
202
+ if (!existsSync(dir)) return 0;
203
+ const suffix = `.sync${SENTINEL_EXT}`;
204
+ let n = 0;
205
+ for (const f of readdirSync(dir)) {
206
+ if (!f.endsWith(suffix)) continue;
207
+ if (keep.has(f)) continue;
208
+ try {
209
+ rmSync(join(dir, f));
210
+ n++;
211
+ } catch {}
212
+ }
213
+ return n;
214
+ }
215
+
131
216
  // --- flock-based re-fire guard ---
132
217
  //
133
218
  // Real OS-level advisory lock via libc flock(2). Returns the fd on success
@@ -360,6 +360,25 @@ export function uninstall(prefix: string, slug: string): boolean {
360
360
  return existed;
361
361
  }
362
362
 
363
+ // Self-removal for a one-time job from inside its own runner process. The
364
+ // runner IS the launchd-managed process here, so `bootout` may SIGTERM us
365
+ // mid-call — remove the plist FILE first so that even if we're killed before
366
+ // bootout returns, launchd can't reload (and RunAtLoad-refire) the job on the
367
+ // next login/reboot. The live bootout is best-effort cleanup of the already-
368
+ // fired service.
369
+ export function removeOneTimeSelf(prefix: string, slug: string): boolean {
370
+ const label = labelFor(prefix, slug);
371
+ const dest = plistPathFor(label);
372
+ const existed = existsSync(dest) || isLoaded(label);
373
+ if (existsSync(dest)) {
374
+ try {
375
+ rmSync(dest);
376
+ } catch {}
377
+ }
378
+ sh(["launchctl", "bootout", `${gui()}/${label}`]);
379
+ return existed;
380
+ }
381
+
363
382
  export function statusOf(prefix: string, slug: string): string {
364
383
  const label = labelFor(prefix, slug);
365
384
  const { code, out, err } = sh(["launchctl", "print", `${gui()}/${label}`]);
package/src/runner.ts CHANGED
@@ -37,14 +37,18 @@ import {
37
37
  import { loadJob, slugFromPath, type JobMeta } from "./jobs.ts";
38
38
  import { resolveModel, localClaudeEnv } from "./models.ts";
39
39
  import {
40
+ DEFAULT_GRACE_SECONDS,
40
41
  archiveOneTime,
41
42
  releaseFlock,
43
+ resolveOneTime,
42
44
  setTsExecutedAt,
43
45
  tryFlockExclusive,
44
46
  writeAndFsync,
45
47
  writeSentinel,
46
48
  type FlockHandle,
47
49
  } from "./oneTime.ts";
50
+ import { loadBundlePrefix } from "./config.ts";
51
+ import { platform } from "./platform/index.ts";
48
52
  import { setFrontmatterKey, setShellFrontmatterKey } from "./frontmatter.ts";
49
53
  import { parseLastResult } from "./result.ts";
50
54
  import {
@@ -550,6 +554,27 @@ function stampExecutedAt(job: JobMeta, iso: string): void {
550
554
  writeAndFsync(job.path, next);
551
555
  }
552
556
 
557
+ // Remove this one-time job's own launchd plist after it has fired. Without
558
+ // this, a fire-now plist (RunAtLoad=true, no calendar) lingers in
559
+ // ~/Library/LaunchAgents until the NEXT `cronfish sync` boots it out — and a
560
+ // reboot/login in that window reloads it and re-fires (against an already-
561
+ // archived file). Self-removing here closes that window regardless of when
562
+ // the consumer next syncs. Best-effort: failures never block completion.
563
+ function selfRemoveOneTimePlist(job: JobMeta): void {
564
+ // Logs to console (→ launchd.out/err), not the per-run fd: this runs as the
565
+ // last act after the log fd is already closed, and the bootout it performs
566
+ // may terminate this process, so it must follow all ledger/alert work.
567
+ try {
568
+ const prefix = loadBundlePrefix(consumerRoot());
569
+ const existed = platform().removeOneTimeSelf(prefix, job.slug);
570
+ console.log(`[runner] one-time: removed own plist (existed=${existed})`);
571
+ } catch (e) {
572
+ console.error(
573
+ `[runner] one-time: self-remove plist failed: ${(e as Error).message}`,
574
+ );
575
+ }
576
+ }
577
+
553
578
  function completeOneTime(job: JobMeta, fd: number): void {
554
579
  if (!job.oneTime) return;
555
580
  const iso = new Date().toISOString();
@@ -642,6 +667,33 @@ async function main(): Promise<void> {
642
667
  } catch {
643
668
  // re-parse failed; proceed with the original meta.
644
669
  }
670
+
671
+ // Re-assert grace at RUN time. launchd fires a StartCalendarInterval job
672
+ // once on wake if the machine was asleep/off through the scheduled minute
673
+ // (coalesced) — which can land long past run_at. Grace is checked at sync
674
+ // time, but nothing re-checks it when launchd actually fires. Refuse a
675
+ // stale wake-up fire so a one-time job can't run hours/days late, and
676
+ // remove the plist so it won't try again on the next wake.
677
+ if (job.runAtMs !== undefined) {
678
+ const grace = job.graceSeconds ?? DEFAULT_GRACE_SECONDS;
679
+ const status = resolveOneTime(job.runAtMs, grace, Date.now(), undefined);
680
+ if (status.kind === "past-grace") {
681
+ console.error(
682
+ `[runner] one-time: ${job.slug} fired past grace — refusing. ${status.reason}`,
683
+ );
684
+ try {
685
+ writeSentinel(
686
+ join(consumerRoot(), "cron"),
687
+ job.slug,
688
+ `runtime past grace: ${status.reason}`,
689
+ "run",
690
+ );
691
+ } catch {}
692
+ if (oneTimeLock) releaseFlock(oneTimeLock);
693
+ selfRemoveOneTimePlist(job);
694
+ process.exit(0);
695
+ }
696
+ }
645
697
  }
646
698
 
647
699
  if (job.concurrency) {
@@ -761,6 +813,9 @@ async function main(): Promise<void> {
761
813
  try {
762
814
  db?.close();
763
815
  } catch {}
816
+ // LAST act for a one-time job: remove its own plist. May SIGTERM us via
817
+ // bootout, so everything above (ledger, alert) is already done.
818
+ if (job.oneTime) selfRemoveOneTimePlist(job);
764
819
  }
765
820
  process.exit(lastResult.code);
766
821
  }