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 +34 -12
- package/package.json +1 -1
- package/src/cli.ts +77 -4
- package/src/config.ts +29 -0
- package/src/oneTime.ts +92 -7
- package/src/platform/launchd.ts +19 -0
- package/src/runner.ts +55 -0
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
|
|
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
|
|
180
|
-
`~/Library/Application Support/cronfish/done/`
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
**
|
|
185
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
115
|
-
// heartbeat
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
package/src/platform/launchd.ts
CHANGED
|
@@ -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
|
}
|