cronfish 0.13.0 β 0.15.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 +49 -17
- package/examples/.cronfish.json +1 -0
- package/package.json +1 -1
- package/src/alerts/index.ts +1 -0
- package/src/alerts/registry.ts +2 -0
- package/src/alerts/slack-bot.ts +80 -0
- package/src/alerts/types.ts +8 -0
- package/src/models.ts +42 -1
- package/src/runner.ts +26 -5
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.
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
- **
|
|
41
|
-
agent
|
|
42
|
-
- **
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
cronfish runs both from one folder:
|
|
56
|
+
|
|
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
|
|
@@ -234,6 +260,7 @@ as a single job instead of many fast fires.
|
|
|
234
260
|
"on_failure": { "notify": "slack" },
|
|
235
261
|
"default": "slack",
|
|
236
262
|
"slack": { "webhook_url_env": "CRONFISH_SLACK_WEBHOOK" },
|
|
263
|
+
"slack_bot": { "bot_token_env": "CRONFISH_SLACK_BOT_TOKEN", "channel": "C0123456789" },
|
|
237
264
|
"shell": { "command": "/Users/you/bin/cronfish-pushover.sh" }
|
|
238
265
|
}
|
|
239
266
|
}
|
|
@@ -279,6 +306,7 @@ Every failed (`fail` / `timeout` / `crashed`) scheduled run pings the configured
|
|
|
279
306
|
Adapters ship with cronfish:
|
|
280
307
|
|
|
281
308
|
- **`slack`** β POSTs Block Kit to an incoming webhook. Reads the URL from the env var named in `alerts.slack.webhook_url_env` (default `CRONFISH_SLACK_WEBHOOK`).
|
|
309
|
+
- **`slack_bot`** β posts the same Block Kit via `chat.postMessage` with a bot token instead of a webhook. One token reaches any channel (with `chat:write.public`, no invite needed), so you skip the per-channel browser-OAuth webhook mint. Reads the token from `alerts.slack_bot.bot_token_env` (default `CRONFISH_SLACK_BOT_TOKEN`) and the target channel from `alerts.slack_bot.channel` (a `Cβ¦` id or `#name`) or `alerts.slack_bot.channel_env`. Unlike a webhook, `chat.postMessage` returns HTTP 200 on logical errors, so the adapter inspects the JSON `ok` field and fails on `ok:false` (e.g. `channel_not_found`, `not_in_channel`).
|
|
282
310
|
- **`shell`** β runs an arbitrary command from `alerts.shell.command` with the payload as env vars (`CRONFISH_ALERT_SLUG`, `β¦_STATUS`, `β¦_EXIT_CODE`, `β¦_DURATION_MS`, `β¦_STARTED_AT`, `β¦_UI_URL`, `β¦_LOG_TAIL`) plus the JSON payload on stdin. Use this for Pushover/ntfy/osascript.
|
|
283
311
|
|
|
284
312
|
Two knobs in `.cronfish.json`, two distinct jobs:
|
|
@@ -309,6 +337,10 @@ Sanity check:
|
|
|
309
337
|
```
|
|
310
338
|
export CRONFISH_SLACK_WEBHOOK=https://hooks.slack.com/services/...
|
|
311
339
|
cronfish alerts test slack
|
|
340
|
+
|
|
341
|
+
# or, bot-token path (no webhook to mint):
|
|
342
|
+
export CRONFISH_SLACK_BOT_TOKEN=xoxb-...
|
|
343
|
+
cronfish alerts test slack_bot
|
|
312
344
|
```
|
|
313
345
|
|
|
314
346
|
## Always-on dashboard
|
package/examples/.cronfish.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"on_failure": { "notify": "slack" },
|
|
13
13
|
"default": "slack",
|
|
14
14
|
"slack": { "webhook_url_env": "CRONFISH_SLACK_WEBHOOK" },
|
|
15
|
+
"slack_bot": { "bot_token_env": "CRONFISH_SLACK_BOT_TOKEN", "channel": "C0123456789" },
|
|
15
16
|
"shell": { "command": "/usr/local/bin/my-notify.sh" }
|
|
16
17
|
}
|
|
17
18
|
}
|
package/package.json
CHANGED
package/src/alerts/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { buildRegistry, type AdapterRegistry } from "./registry.ts";
|
|
2
2
|
export { safeNotify, type AlertOutcome, type AlertOutcomeStatus } from "./safe.ts";
|
|
3
3
|
export { createSlackAdapter, buildSlackBlocks } from "./slack.ts";
|
|
4
|
+
export { createSlackBotAdapter, type SlackBotAdapterOptions } from "./slack-bot.ts";
|
|
4
5
|
export { createShellAdapter, payloadEnv } from "./shell.ts";
|
|
5
6
|
export {
|
|
6
7
|
alertStatusFor,
|
package/src/alerts/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createShellAdapter } from "./shell.ts";
|
|
2
|
+
import { createSlackBotAdapter } from "./slack-bot.ts";
|
|
2
3
|
import { createSlackAdapter } from "./slack.ts";
|
|
3
4
|
import type { Adapter, AlertsConfig } from "./types.ts";
|
|
4
5
|
|
|
@@ -11,6 +12,7 @@ export interface AdapterRegistry {
|
|
|
11
12
|
export function buildRegistry(cfg: AlertsConfig = {}): AdapterRegistry {
|
|
12
13
|
const map = new Map<string, Adapter>();
|
|
13
14
|
map.set("slack", createSlackAdapter(cfg.slack));
|
|
15
|
+
map.set("slack_bot", createSlackBotAdapter(cfg.slack_bot));
|
|
14
16
|
map.set("shell", createShellAdapter(cfg.shell));
|
|
15
17
|
return {
|
|
16
18
|
get(name) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Bot-token Slack adapter β the API-driveable twin of the webhook `slack`
|
|
2
|
+
// adapter. Where `slack` POSTs Block Kit to a per-channel incoming webhook (URL
|
|
3
|
+
// bound to one channel at mint time, only creatable via browser OAuth), this
|
|
4
|
+
// posts via `chat.postMessage` with a bot token: one credential reaches any
|
|
5
|
+
// channel by id/name (with `chat:write.public`, without being invited). Same
|
|
6
|
+
// Block Kit body β it reuses `buildSlackBlocks`, so alerts render identically.
|
|
7
|
+
//
|
|
8
|
+
// Unlike an incoming webhook (which signals failure with a non-2xx status),
|
|
9
|
+
// `chat.postMessage` returns HTTP 200 even on logical errors and carries the
|
|
10
|
+
// real outcome in the JSON `ok`/`error` fields β so this adapter parses the
|
|
11
|
+
// body and throws on `ok:false`, not just on a bad status.
|
|
12
|
+
|
|
13
|
+
import { buildSlackBlocks } from "./slack.ts";
|
|
14
|
+
import type { Adapter, AlertPayload, AlertsConfig } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TOKEN_ENV = "CRONFISH_SLACK_BOT_TOKEN";
|
|
17
|
+
const POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
|
|
18
|
+
|
|
19
|
+
export interface SlackBotAdapterOptions {
|
|
20
|
+
botToken?: string;
|
|
21
|
+
channel?: string;
|
|
22
|
+
fetchFn?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createSlackBotAdapter(
|
|
26
|
+
cfg: AlertsConfig["slack_bot"] = {},
|
|
27
|
+
opts: SlackBotAdapterOptions = {},
|
|
28
|
+
): Adapter {
|
|
29
|
+
const tokenEnv = cfg.bot_token_env ?? DEFAULT_TOKEN_ENV;
|
|
30
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
31
|
+
return {
|
|
32
|
+
name: "slack_bot",
|
|
33
|
+
async notify(payload: AlertPayload): Promise<void> {
|
|
34
|
+
const token = opts.botToken ?? process.env[tokenEnv];
|
|
35
|
+
if (!token) {
|
|
36
|
+
throw new Error(`slack-bot adapter: env var ${tokenEnv} not set`);
|
|
37
|
+
}
|
|
38
|
+
const channel =
|
|
39
|
+
opts.channel ??
|
|
40
|
+
(cfg.channel_env ? process.env[cfg.channel_env] : undefined) ??
|
|
41
|
+
cfg.channel;
|
|
42
|
+
if (!channel) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"slack-bot adapter: no channel configured " +
|
|
45
|
+
"(set alerts.slack_bot.channel or alerts.slack_bot.channel_env)",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const blocks = buildSlackBlocks(payload);
|
|
49
|
+
const res = await fetchFn(POST_MESSAGE_URL, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"content-type": "application/json; charset=utf-8",
|
|
53
|
+
authorization: `Bearer ${token}`,
|
|
54
|
+
},
|
|
55
|
+
// `text` is the notification/accessibility fallback Slack shows when
|
|
56
|
+
// blocks can't render (push notifications, screen readers).
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
channel,
|
|
59
|
+
text: `${payload.status.toUpperCase()} β ${payload.slug}`,
|
|
60
|
+
blocks,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const body = await res.text().catch(() => "");
|
|
65
|
+
throw new Error(
|
|
66
|
+
`slack-bot adapter: chat.postMessage returned ${res.status} ${body.slice(0, 200)}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
70
|
+
ok?: boolean;
|
|
71
|
+
error?: string;
|
|
72
|
+
};
|
|
73
|
+
if (!data.ok) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`slack-bot adapter: chat.postMessage failed: ${data.error ?? "unknown error"}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/alerts/types.ts
CHANGED
|
@@ -28,6 +28,14 @@ export interface AlertsConfig {
|
|
|
28
28
|
slack?: {
|
|
29
29
|
webhook_url_env?: string;
|
|
30
30
|
};
|
|
31
|
+
slack_bot?: {
|
|
32
|
+
// Env var holding the bot token (xoxb-β¦). Default CRONFISH_SLACK_BOT_TOKEN.
|
|
33
|
+
bot_token_env?: string;
|
|
34
|
+
// Target channel β id (Cβ¦) or #name. Literal valueβ¦
|
|
35
|
+
channel?: string;
|
|
36
|
+
// β¦or the env var to read it from (takes precedence over `channel`).
|
|
37
|
+
channel_env?: string;
|
|
38
|
+
};
|
|
31
39
|
shell?: {
|
|
32
40
|
command?: string;
|
|
33
41
|
};
|
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/runner.ts
CHANGED
|
@@ -35,7 +35,7 @@ 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
40
|
DEFAULT_GRACE_SECONDS,
|
|
41
41
|
archiveOneTime,
|
|
@@ -367,11 +367,11 @@ async function execMarkdown(
|
|
|
367
367
|
appendLog(fd, `[runner] read-only: deny [${READ_ONLY_DENY.join(", ")}]`);
|
|
368
368
|
}
|
|
369
369
|
const cmd = buildClaudeArgs(CLAUDE_BIN, job, model.id, prompt);
|
|
370
|
-
const env =
|
|
370
|
+
const env = claudeEnvFor(model);
|
|
371
371
|
if (env) {
|
|
372
372
|
appendLog(
|
|
373
373
|
fd,
|
|
374
|
-
`[runner]
|
|
374
|
+
`[runner] ${model.provider} base_url=${env.ANTHROPIC_BASE_URL} model=${model.id}`,
|
|
375
375
|
);
|
|
376
376
|
}
|
|
377
377
|
return runSpawn({ cmd, cwd: consumerRoot(), env }, fd, timeoutS);
|
|
@@ -708,6 +708,23 @@ async function main(): Promise<void> {
|
|
|
708
708
|
}
|
|
709
709
|
}
|
|
710
710
|
|
|
711
|
+
// Install lock-release signal handlers the instant a lock exists. There is
|
|
712
|
+
// real setup below (DB init, log open) before the full `cleanup` handler is
|
|
713
|
+
// registered, and a SIGTERM in that window would otherwise kill the process
|
|
714
|
+
// by default disposition β no handler runs, and the concurrency lockfile is
|
|
715
|
+
// orphaned (unlike the flock, it is not reclaimed by the OS on exit). This
|
|
716
|
+
// minimal handler is superseded by `cleanup` once the invocation state exists.
|
|
717
|
+
let releasing = false;
|
|
718
|
+
const earlyCleanup = (sig: NodeJS.Signals): void => {
|
|
719
|
+
if (releasing) return;
|
|
720
|
+
releasing = true;
|
|
721
|
+
if (job.concurrency) releaseLock(job.slug);
|
|
722
|
+
if (oneTimeLock) releaseFlock(oneTimeLock);
|
|
723
|
+
process.exit(sig === "SIGTERM" ? 143 : 130);
|
|
724
|
+
};
|
|
725
|
+
process.on("SIGTERM", earlyCleanup);
|
|
726
|
+
process.on("SIGINT", earlyCleanup);
|
|
727
|
+
|
|
711
728
|
const db = tryOpenDb();
|
|
712
729
|
|
|
713
730
|
// Open the log file BEFORE we know the invocation id (we need a real
|
|
@@ -739,8 +756,12 @@ async function main(): Promise<void> {
|
|
|
739
756
|
appendLog(fd, `[runner] invocation_id=${invocationId} trigger=${trigger}`);
|
|
740
757
|
}
|
|
741
758
|
|
|
742
|
-
// Signal handlers β release lock + record crash on launchd shutdown.
|
|
743
|
-
|
|
759
|
+
// Signal handlers β release lock + record crash on launchd shutdown. This
|
|
760
|
+
// supersedes earlyCleanup now that the invocation row exists, so a SIGTERM
|
|
761
|
+
// also records the run as crashed. The offβon swap is synchronous (no await
|
|
762
|
+
// between), so no signal can slip through with zero handlers installed.
|
|
763
|
+
process.off("SIGTERM", earlyCleanup);
|
|
764
|
+
process.off("SIGINT", earlyCleanup);
|
|
744
765
|
const cleanup = (sig: NodeJS.Signals): void => {
|
|
745
766
|
if (releasing) return;
|
|
746
767
|
releasing = true;
|