cronfish 0.12.1 → 0.14.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 +77 -29
- package/package.json +1 -1
- package/src/cli.ts +77 -4
- package/src/config.ts +29 -0
- package/src/models.ts +42 -1
- package/src/oneTime.ts +92 -7
- package/src/platform/launchd.ts +19 -0
- package/src/runner.ts +58 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">cronfish 🐟</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center"><strong>
|
|
7
|
+
<p align="center"><strong>Write a cron job in Markdown — an LLM runs it on a schedule. Or drop a <code>.ts</code>/<code>.sh</code> script for the deterministic stuff. launchd fires all three.</strong></p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/cronfish"><img src="https://img.shields.io/npm/v/cronfish?logo=npm" alt="npm"></a>
|
|
@@ -14,9 +14,24 @@
|
|
|
14
14
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"></a>
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
The pitch in one file. This is a complete, scheduled cron job:
|
|
18
|
+
|
|
19
|
+
```markdown
|
|
20
|
+
---
|
|
21
|
+
schedule: "every morning at 8"
|
|
22
|
+
model: sonnet
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Read my calendar for today, check the weather, and post a
|
|
26
|
+
one-paragraph brief to the #daily Slack channel.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Drop it in `cron/`, run `cronfish sync`, and launchd runs it every morning — the body _is_ the job,
|
|
30
|
+
handed to an LLM at fire time. No script to write, no glue code. **Markdown is a valid cron job.**
|
|
31
|
+
|
|
32
|
+
When the work _is_ deterministic — a backup, a sync, a healthcheck — write it as `.ts` (Bun) or
|
|
33
|
+
`.sh` (bash) instead. Same frontmatter, same `cronfish sync`, no LLM in the loop. One scheduler,
|
|
34
|
+
three tiers; reach for the lightest that does the job.
|
|
20
35
|
|
|
21
36
|
It makes the file the job: frontmatter is the schedule, the path is the slug — no hand-written
|
|
22
37
|
plists, no registration step. You also get per-run logs, retries, concurrency guards, failure
|
|
@@ -31,20 +46,24 @@ every kind.
|
|
|
31
46
|
|
|
32
47
|
## Why cronfish?
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
Two kinds of scheduler exist today, and neither does both halves:
|
|
50
|
+
|
|
51
|
+
- **cron / launchd** run scripts, never prose. A natural-language job isn't expressible.
|
|
52
|
+
- **Claude's native scheduled agents** run prose, but _only_ prose — every job is an LLM job on
|
|
53
|
+
Anthropic's managed runtime, at their rates.
|
|
54
|
+
|
|
55
|
+
cronfish runs both from one folder:
|
|
37
56
|
|
|
38
|
-
- **
|
|
39
|
-
|
|
40
|
-
- **
|
|
41
|
-
|
|
42
|
-
- **
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
- **Markdown jobs are natural-language cron.** The body is the instruction; cronfish hands it to an
|
|
58
|
+
agent CLI (Claude Code by default) at fire time. No other scheduler runs a prose job.
|
|
59
|
+
- **Script jobs stay deterministic.** `.ts` and `.sh` run code you wrote — the standard cron trust
|
|
60
|
+
model, first-class alongside `.md`.
|
|
61
|
+
- **Any harness, any model, local or hosted.** `.md` jobs shell out to a CLI you choose — point them
|
|
62
|
+
at hosted Claude, a local Ollama model, or a LAN LiteLLM box. No managed-runtime markup; your data
|
|
63
|
+
stays on your hardware.
|
|
45
64
|
|
|
46
65
|
Reach for Claude's scheduler when you want zero-ops and a single LLM task. Reach for cronfish when
|
|
47
|
-
you
|
|
66
|
+
you want a mix of prose and scripts, care about cost, or want to choose the model.
|
|
48
67
|
|
|
49
68
|
## Quickstart
|
|
50
69
|
|
|
@@ -79,7 +98,7 @@ you can document a folder of crons without the README getting parsed as a job.
|
|
|
79
98
|
```markdown
|
|
80
99
|
---
|
|
81
100
|
schedule: "every 5 minutes" # see below for all accepted shapes
|
|
82
|
-
model: haiku # claude alias | raw ID | local:<name>
|
|
101
|
+
model: haiku # claude alias | raw ID | local:<name> | subconscious/<name>
|
|
83
102
|
enabled: true # default true
|
|
84
103
|
timeout: 300 # seconds; runner kills past this
|
|
85
104
|
retries: 0 # retry count on non-zero exit
|
|
@@ -129,7 +148,7 @@ invokes the file as `/bin/bash <path>` with `cwd = consumer repo root`; stdout/s
|
|
|
129
148
|
per-run log. **A `.sh` file with no frontmatter block fails at discovery** — cronfish prints the
|
|
130
149
|
error in `list`/`sync` so you know to add one.
|
|
131
150
|
|
|
132
|
-
## `model:` — claude alias, raw ID, or
|
|
151
|
+
## `model:` — claude alias, raw ID, local, or subconscious
|
|
133
152
|
|
|
134
153
|
For Anthropic-hosted models, use the aliases `haiku` / `sonnet` / `opus` (resolve to the latest
|
|
135
154
|
pinned IDs), or pass a raw ID like `claude-sonnet-4-6` verbatim.
|
|
@@ -153,6 +172,13 @@ will hallucinate tool calls. Use 14B+ for any agentic loop; 32B is the practical
|
|
|
153
172
|
multi-step work. Local providers serve one request at a time — set `concurrency: queue` on
|
|
154
173
|
overlapping jobs.
|
|
155
174
|
|
|
175
|
+
For a **[Subconscious](https://subconscious.dev)-hosted model**, use the id verbatim with its
|
|
176
|
+
`subconscious/` prefix — e.g. `subconscious/glm-5.2`. Same mechanics as `local:` (base URL +
|
|
177
|
+
slot overrides injected at spawn), pointed at `https://api.subconscious.dev` and authenticated
|
|
178
|
+
with `SUBCONSCIOUS_API_KEY` from the consumer `.env` (scope it into the job with
|
|
179
|
+
`env: [SUBCONSCIOUS_API_KEY]`). Override the endpoint with `SUBCONSCIOUS_BASE_URL`. The job
|
|
180
|
+
fails with a clear error if the key is unset.
|
|
181
|
+
|
|
156
182
|
## One-shot jobs — `cron/one-time/`
|
|
157
183
|
|
|
158
184
|
Drop a `.md`, `.ts`, or `.sh` under `cron/one-time/` to schedule a job that
|
|
@@ -173,23 +199,34 @@ Sync behavior:
|
|
|
173
199
|
| ----------------------------- | ------------------------------------------------- |
|
|
174
200
|
| Future | plist installed with `StartCalendarInterval` for the exact minute |
|
|
175
201
|
| Within `grace_seconds` of now | plist installed with `RunAtLoad: true` — fires on bootstrap |
|
|
176
|
-
| Past `grace_seconds` | **refused**; sentinel written
|
|
202
|
+
| Past `grace_seconds` | **refused**; sentinel written + file archived out of `cron/one-time/` |
|
|
177
203
|
| `executed_at:` already set | skipped (file should already be archived) |
|
|
178
204
|
|
|
179
|
-
After firing, the runner stamps `executed_at: <ISO
|
|
180
|
-
`~/Library/Application Support/cronfish/done/`
|
|
181
|
-
|
|
182
|
-
|
|
205
|
+
After firing, the runner stamps `executed_at: <ISO>`, moves the file to
|
|
206
|
+
`~/Library/Application Support/cronfish/done/` (outside the repo, so the audit
|
|
207
|
+
trail doesn't bloat git), and **removes its own plist** so a reboot/login in
|
|
208
|
+
the window before the next `cronfish sync` can't reload and re-fire it. A
|
|
209
|
+
`flock` plus the `executed_at` re-check guard against double-fires.
|
|
210
|
+
|
|
211
|
+
The runner also **re-checks `grace_seconds` at fire time**: launchd runs a
|
|
212
|
+
`StartCalendarInterval` job once on wake if the machine slept through the
|
|
213
|
+
scheduled minute (a coalesced missed fire), which can land long after `run_at`.
|
|
214
|
+
A fire that arrives past grace is refused (sentinel) instead of running late.
|
|
183
215
|
|
|
184
|
-
**One-time jobs must be idempotent.**
|
|
185
|
-
system unsleep, or load spikes; the flock + `executed_at` guard catches the
|
|
216
|
+
**One-time jobs must be idempotent.** The guards above catch the common
|
|
186
217
|
double-fire, but only after the file is stamped. Anything destructive between
|
|
187
218
|
"start" and "stamp" can repeat. Write handlers that tolerate two invocations.
|
|
188
219
|
|
|
189
|
-
**Failure surface — `cron/.errors/`.** Any
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
220
|
+
**Failure surface — `cron/.errors/`.** Any refusal (past-grace, bad YAML,
|
|
221
|
+
missing `run_at`) and any runner-side failure (archive failed, `executed_at`
|
|
222
|
+
write failed) writes a sentinel there with slug, timestamp, and reason. Wire a
|
|
223
|
+
heartbeat cron to alert on non-empty. Two properties keep the folder bounded:
|
|
224
|
+
sentinels **dedup** (the same recurring error overwrites one file, not one per
|
|
225
|
+
sync), and sync-time sentinels **self-heal** — the next clean `cronfish sync`
|
|
226
|
+
clears any whose error no longer occurs. Inspect or clear by hand with
|
|
227
|
+
`cronfish errors` / `cronfish errors --clear [slug]`. cronfish only manages
|
|
228
|
+
files it wrote (`*.cronfish.txt`); a consumer can drop its own sentinels in the
|
|
229
|
+
same folder without them being reaped.
|
|
193
230
|
|
|
194
231
|
Smoke-test template: `templates/_examples/one-time/echo-at.md`.
|
|
195
232
|
|
|
@@ -207,6 +244,11 @@ Smoke-test template: `templates/_examples/one-time/echo-at.md`.
|
|
|
207
244
|
fires them. Use it for scheduling candidates — jobs you're staging in `cron/` before flipping on
|
|
208
245
|
a real schedule. Pure on-demand scripts that aren't scheduling candidates belong outside `cron/`.
|
|
209
246
|
|
|
247
|
+
**Sub-10s schedules don't work.** launchd enforces a ~10s floor between relaunches of the same
|
|
248
|
+
job (its implicit `ThrottleInterval`). A `schedule:` faster than `10s` fires no quicker than every
|
|
249
|
+
10s; `cronfish sync` warns when it sees one. Need true high-frequency work? Run a long-lived loop
|
|
250
|
+
as a single job instead of many fast fires.
|
|
251
|
+
|
|
210
252
|
## Config — `.cronfish.json` (optional, at repo root)
|
|
211
253
|
|
|
212
254
|
```json
|
|
@@ -245,6 +287,7 @@ cronfish enable <slug> flip enabled, then sync
|
|
|
245
287
|
cronfish disable <slug> flip disabled, then sync
|
|
246
288
|
cronfish delete <slug> --yes bootout + remove plist + job file
|
|
247
289
|
cronfish status [slug] launchctl print + tail of latest log
|
|
290
|
+
cronfish errors [--clear] [slug] list error sentinels (cron/.errors/); --clear removes them
|
|
248
291
|
cronfish run <slug> invoke runner directly (no launchd) — for testing
|
|
249
292
|
cronfish watchdog detect missed schedules → fire alerts
|
|
250
293
|
cronfish alerts test [adapter] send a test alert via the named (or default) adapter
|
|
@@ -531,10 +574,15 @@ via plist `WorkingDirectory`), so no shell wrapper is needed.
|
|
|
531
574
|
|
|
532
575
|
- macOS (launchd). Linux (systemd) and Windows (Task Scheduler) are on the backlog.
|
|
533
576
|
- Bun ≥ 1.0.
|
|
577
|
+
- **A logged-in GUI (Aqua) session.** cronfish installs per-user LaunchAgents under
|
|
578
|
+
`~/Library/LaunchAgents`, which only load while the user is logged into the desktop. On a
|
|
579
|
+
headless box reached only over SSH (no console login), agents never load and jobs never fire —
|
|
580
|
+
enable auto-login, or keep a desktop session active. (A system-wide `LaunchDaemon` backend that
|
|
581
|
+
runs without a login session is on the backlog.)
|
|
534
582
|
|
|
535
583
|
## Status
|
|
536
584
|
|
|
537
|
-
v0.
|
|
585
|
+
v0.x — used in production by the author. API may still break before v1. File issues if you hit
|
|
538
586
|
something rough.
|
|
539
587
|
|
|
540
588
|
## 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/models.ts
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
// Messages natively; LiteLLM proxies it for everything else). The model
|
|
9
9
|
// ID after the `local:` prefix is passed verbatim to `--model` and as
|
|
10
10
|
// the three slot overrides so sub-agents route locally too.
|
|
11
|
+
//
|
|
12
|
+
// `subconscious/<id>` models route the same way to api.subconscious.dev
|
|
13
|
+
// (a hosted Anthropic-compatible endpoint), authenticated with
|
|
14
|
+
// SUBCONSCIOUS_API_KEY from the job env.
|
|
11
15
|
|
|
12
|
-
export type Provider = "anthropic" | "local";
|
|
16
|
+
export type Provider = "anthropic" | "local" | "subconscious";
|
|
13
17
|
|
|
14
18
|
export interface Resolved {
|
|
15
19
|
provider: Provider;
|
|
@@ -27,6 +31,11 @@ export function resolveModel(input: string | undefined): Resolved {
|
|
|
27
31
|
if (raw.startsWith("local:")) {
|
|
28
32
|
return { provider: "local", id: raw.slice("local:".length) };
|
|
29
33
|
}
|
|
34
|
+
if (raw.startsWith("subconscious/")) {
|
|
35
|
+
// Subconscious's model namespace includes the prefix (e.g.
|
|
36
|
+
// subconscious/glm-5.2) — pass the id through verbatim.
|
|
37
|
+
return { provider: "subconscious", id: raw };
|
|
38
|
+
}
|
|
30
39
|
if (ALIASES[raw]) return { provider: "anthropic", id: ALIASES[raw] };
|
|
31
40
|
return { provider: "anthropic", id: raw };
|
|
32
41
|
}
|
|
@@ -39,6 +48,38 @@ export function localClaudeEnv(modelId: string): Record<string, string> {
|
|
|
39
48
|
const baseUrl =
|
|
40
49
|
process.env.CRONFISH_LOCAL_BASE_URL ?? "http://localhost:11434";
|
|
41
50
|
const authToken = process.env.CRONFISH_LOCAL_AUTH_TOKEN ?? "ollama";
|
|
51
|
+
return backendClaudeEnv(baseUrl, authToken, modelId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Env block for a `subconscious/*` model — api.subconscious.dev speaks the
|
|
55
|
+
// Anthropic Messages API. Requires SUBCONSCIOUS_API_KEY in the job env
|
|
56
|
+
// (consumer .env; scope with frontmatter `env: [SUBCONSCIOUS_API_KEY]`).
|
|
57
|
+
export function subconsciousClaudeEnv(modelId: string): Record<string, string> {
|
|
58
|
+
const baseUrl = (
|
|
59
|
+
process.env.SUBCONSCIOUS_BASE_URL ?? "https://api.subconscious.dev"
|
|
60
|
+
).replace(/\/$/, "");
|
|
61
|
+
const authToken = process.env.SUBCONSCIOUS_API_KEY;
|
|
62
|
+
if (!authToken) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"[models] SUBCONSCIOUS_API_KEY is not set — required for subconscious/* models",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return backendClaudeEnv(baseUrl, authToken, modelId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Env block for spawning `claude` against the given provider, or undefined
|
|
71
|
+
// for api.anthropic.com models. Single dispatch point for the runner.
|
|
72
|
+
export function claudeEnvFor(r: Resolved): Record<string, string> | undefined {
|
|
73
|
+
if (r.provider === "local") return localClaudeEnv(r.id);
|
|
74
|
+
if (r.provider === "subconscious") return subconsciousClaudeEnv(r.id);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function backendClaudeEnv(
|
|
79
|
+
baseUrl: string,
|
|
80
|
+
authToken: string,
|
|
81
|
+
modelId: string,
|
|
82
|
+
): Record<string, string> {
|
|
42
83
|
return {
|
|
43
84
|
ANTHROPIC_BASE_URL: baseUrl,
|
|
44
85
|
ANTHROPIC_AUTH_TOKEN: authToken,
|
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
|
@@ -35,16 +35,20 @@ import {
|
|
|
35
35
|
type InvocationTrigger,
|
|
36
36
|
} from "./db.ts";
|
|
37
37
|
import { loadJob, slugFromPath, type JobMeta } from "./jobs.ts";
|
|
38
|
-
import { resolveModel,
|
|
38
|
+
import { resolveModel, claudeEnvFor } 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 {
|
|
@@ -363,11 +367,11 @@ async function execMarkdown(
|
|
|
363
367
|
appendLog(fd, `[runner] read-only: deny [${READ_ONLY_DENY.join(", ")}]`);
|
|
364
368
|
}
|
|
365
369
|
const cmd = buildClaudeArgs(CLAUDE_BIN, job, model.id, prompt);
|
|
366
|
-
const env =
|
|
370
|
+
const env = claudeEnvFor(model);
|
|
367
371
|
if (env) {
|
|
368
372
|
appendLog(
|
|
369
373
|
fd,
|
|
370
|
-
`[runner]
|
|
374
|
+
`[runner] ${model.provider} base_url=${env.ANTHROPIC_BASE_URL} model=${model.id}`,
|
|
371
375
|
);
|
|
372
376
|
}
|
|
373
377
|
return runSpawn({ cmd, cwd: consumerRoot(), env }, fd, timeoutS);
|
|
@@ -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
|
}
|