@turmind/halo 2.1.6 → 2.2.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/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/1607-bf227fc5e4db45b7.js +1 -0
- package/admin-out/_next/static/chunks/1989-dab5d5c24b8f4c9f.js +1 -0
- package/admin-out/_next/static/chunks/5287.7ea08918388fa804.js +1 -0
- package/admin-out/_next/static/chunks/app/page-cca81a2ed69100f5.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-7f8156dbbdb54868.js → webpack-9532ec7f1fa9a757.js} +1 -1
- package/admin-out/_next/static/css/8fc43fce83e5ce5d.css +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/a2a.md +3 -3
- package/bundled-docs/design/agentcore.md +1 -1
- package/bundled-docs/design/architecture.md +3 -3
- package/bundled-docs/design/background-dispatch.md +1 -1
- package/bundled-docs/design/canvas-extensions.md +2 -2
- package/bundled-docs/design/cron.md +77 -53
- package/bundled-docs/design/evolution.md +111 -39
- package/bundled-docs/design/node.md +5 -12
- package/bundled-docs/design/observability.md +11 -11
- package/bundled-docs/design/prompt-system.md +1 -1
- package/bundled-docs/design/relay.md +2 -2
- package/bundled-docs/design/session.md +9 -7
- package/bundled-docs/design/slack.md +1 -1
- package/bundled-docs/design/storage.md +14 -11
- package/bundled-docs/design/telegram.md +1 -1
- package/bundled-docs/design/ws.md +3 -3
- package/bundled-docs/dev/api.md +16 -14
- package/bundled-docs/dev/env.md +2 -2
- package/bundled-docs/dev/logging.md +1 -1
- package/bundled-docs/dev/tools.md +2 -2
- package/bundled-docs/guide/cli.md +3 -3
- package/bundled-docs/guide/delegation-and-access.md +1 -1
- package/bundled-docs/guide/workspace.md +1 -1
- package/bundled-docs/requirements/command.md +1 -1
- package/bundled-docs/requirements/settings.md +2 -2
- package/bundled-docs/requirements/workspace.md +1 -1
- package/dist/evo-wrapper.js +913 -201
- package/dist/index.js +2626 -1762
- package/package.json +1 -1
- package/templates/agents/__apply_agent__/AGENT.md +10 -6
- package/templates/agents/__evo_agent__/AGENT.md +15 -4
- package/templates/agents/__score__/AGENT.md +4 -0
- package/templates/skills/cron/SKILL.md +37 -10
- package/templates/skills/cron/manage-cron.py +90 -15
- package/admin-out/_next/static/chunks/1101-d2f818895a3aa32e.js +0 -1
- package/admin-out/_next/static/chunks/1989-c24411a5e971b8b9.js +0 -1
- package/admin-out/_next/static/chunks/5058.8514aa6d6a947cce.js +0 -1
- package/admin-out/_next/static/chunks/app/page-84ab8d21c70823c0.js +0 -1
- package/admin-out/_next/static/css/6da86a077e119a6c.css +0 -1
- /package/admin-out/_next/static/{xg-EvYXc8NEUDwTlmbWvy → Rs9tIYSSITPLxv274DU9f}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{xg-EvYXc8NEUDwTlmbWvy → Rs9tIYSSITPLxv274DU9f}/_ssgManifest.js +0 -0
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
# Cron — Scheduled Tasks Design
|
|
2
2
|
|
|
3
|
-
Run an agent on a schedule (daily report, periodic broadcast) and fan the result out to channels. Jobs live in the global db; [croner](https://github.com/hexagon/croner) only computes their fire times, the runner's own timers fire them, and each fire
|
|
3
|
+
Run an agent on a schedule (daily report, periodic broadcast) or on each incoming webhook, and fan the result out to channels. Jobs live in the global db; [croner](https://github.com/hexagon/croner) only computes their fire times, the runner's own timers fire them, and each fire runs **in the server process** as a turn in the job's session (since 2.2; before, a fresh `halo cli` child).
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
cron_jobs (global db)
|
|
9
|
-
│
|
|
10
|
-
▼ reloadAll / reconcileFromDb
|
|
11
|
-
runner.ts
|
|
12
|
-
│
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
cron_runs (audit row + log file) ───────────────┘
|
|
8
|
+
cron_jobs (global db) POST /cron-hooks/:id registered at boot:
|
|
9
|
+
│ → cron_events ┌── slack/cron-dispatcher
|
|
10
|
+
▼ reloadAll / reconcileFromDb │ ├── feishu/cron-dispatcher
|
|
11
|
+
runner.ts ── timers / startOrInject┤ ├── telegram/cron-dispatcher
|
|
12
|
+
│ fire ├── wechat/cron-dispatcher
|
|
13
|
+
▼ └── wecom/cron-dispatcher
|
|
14
|
+
SessionManager (job's session, ▲
|
|
15
|
+
reply_to = { cron: runId }) │ registerCronDispatcher
|
|
16
|
+
│ tree quiet → relay CronRunDoor → finishRun dispatcher.ts (registry)
|
|
17
|
+
▼ ▲
|
|
18
|
+
cron_runs (audit row; the session is the log) ──────────┘ dispatchToTargets(text, targets, workspacePath)
|
|
20
19
|
```
|
|
21
20
|
|
|
22
21
|
Two halves, cleanly split:
|
|
23
|
-
- **runner.ts** — the scheduler. Owns the fire timers (croner only computes fire times),
|
|
22
|
+
- **runner.ts** — the scheduler. Owns the fire timers (croner only computes fire times), sends the fire into the job's session, wraps the run up, persists audit rows.
|
|
24
23
|
- **dispatcher.ts** — a channel-agnostic registry. Knows how to look up a dispatcher by `channelType` and hand off; knows nothing about any specific channel.
|
|
25
24
|
|
|
26
25
|
## Scheduling model
|
|
@@ -30,12 +29,12 @@ Schedules are **durable** — the source of truth is the `cron_jobs` table; in-m
|
|
|
30
29
|
The runner arms its own `setTimeout` toward each target in hops of at most 30 s; a hop that wakes early re-arms toward the **same** target, and the next target is computed (`nextRun(now)`) only after a fire — a sleep / clock jump over several fires runs once, no catch-up. Why not croner's own timers: in croner 10.0.1 a timer waking a fraction of a ms early re-plans from a fresh clock read, and when the clock crosses the fire instant mid-re-plan, an instance that has never fired (every job right after a restart, every one-shot) silently skips that fire — ~10% of first fires after a restart in production; the upstream fix is only in 11.0.0-dev (`test/cron-fire-race.test.ts`).
|
|
31
30
|
|
|
32
31
|
- **Recurring**: standard cron expression (`job.schedule`), optional `timezone`, optional **active window** `[activeFrom, activeUntil)` (epoch ms, `active_from` / `active_until`, NULL = unbounded; `CRON_MIGRATIONS` slot 2). The window is passed to croner as native `startAt` / `stopAt` (`activeWindowOptions` in `runner.ts`; `startAt` is `activeFrom − 1 ms` because croner's startAt is exclusive), so fires outside it simply never happen — no `cron_runs` row. "Pause until X" = push `activeFrom` to X; there is no skip-list for a gap in the middle. Past `activeUntil` the job stays `enabled` (so it can be extended later) but `scheduleJob` creates no croner and logs `past activeUntil; not scheduled` once — the id goes into `_expired` so reconcile's "fingerprint cached but not in `_active`" retry branch treats it as handled instead of re-scheduling (and re-logging) every 10s. A schedule or timezone croner rejects is handled the same way: one `schedule invalid` warn per fingerprint (the timezone in it), retried once the row changes. REST POST / PUT validate both: a non-string `timezone` → 400 `timezone must be a string`; a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>` (croner resolves zones through the same Intl call, so the two accept the same set; unset / `null` / `''` = host timezone). Only an out-of-band write reaches this path, and croner throws on a bad timezone only at `nextRun()`. Known gap: `manage-cron.py` stores `--timezone` unchecked (it resolves the zone through ZoneInfo only to parse `--active-from` / `--active-until`), so a typo there lands in this warn-once fallback. That's deliberate: Windows has no system tz database, so without the `tzdata` package ZoneInfo would reject valid zones. The REST `nextRunAt` uses the same options, so the list shows the first post-pause fire; the admin badges a job "active from …" / "ended". Run-now ignores the window. Recurring only: a window together with `runAt` is rejected at every write surface (REST on the merged row, skill helper).
|
|
33
|
-
- **One-shot**: `job.runAt` (epoch ms) — the runner's timer fires once at that instant (no croner). After it completes, `finalize` sets `enabled=0` so it never re-fires. A `runAt` already in the past at schedule time is marked `lastRunStatus='missed'` and disabled (rather than firing immediately or retrying every reconcile) — unless its own run is still in flight (`_inflight`, e.g. `POST /api/cron/reload` mid-run): `finalize` then records the real status. At boot,
|
|
34
|
-
- **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on,
|
|
32
|
+
- **One-shot**: `job.runAt` (epoch ms) — the runner's timer fires once at that instant (no croner). After it completes, `finalize` sets `enabled=0` so it never re-fires. A `runAt` already in the past at schedule time is marked `lastRunStatus='missed'` and disabled (rather than firing immediately or retrying every reconcile) — unless its own run is still in flight (`_inflight`, e.g. `POST /api/cron/reload` mid-run): `finalize` then records the real status. At boot, a one-shot whose run was cut off is still in `_inflight` (recovery holds it until the run resumes or is failed — either way `finalize` sets `enabled=0`), so the reload never sees it as missed. The `run_at` column is `CRON_MIGRATIONS` slot 0 in `db/cron-db.ts`, run through the shared `runMigrations` / `PRAGMA user_version` mechanism (see [storage.md](storage.md#schema-change-rules)).
|
|
33
|
+
- **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, set as the session's `working_dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires (each job keeps only its own newest 10 runs; per-run sessions are deleted along with their run rows, see [Retention](#retention)); mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5). `cron_runs.workspace_path` (slot 5, v6) — the workspace the run fired against (the job's at fire time), i.e. where a stateless run's session lives even after the job moves workspace or is deleted; v6 backfills it from the job row, leaving NULL on rows whose job was already gone.
|
|
35
34
|
|
|
36
35
|
### Trigger-mode exclusivity (the db contract)
|
|
37
36
|
|
|
38
|
-
Exactly **one** of `schedule` / `runAt` is set per job. `runner.ts` `scheduleJob` silently prefers `runAt` when both are present, and at-mode's post-fire auto-disable (`enabled=0`) would then kill the recurring schedule too — so the invariant is enforced at every write point, not just at create. `PUT /api/cron/jobs/:id` validates the **merged** row (partial PUT bodies make this non-obvious):
|
|
37
|
+
Exactly **one** of `schedule` / `runAt` / `webhookSecret` is set per job (webhook: see [Webhook trigger](#webhook-trigger) — `webhook: true` switches to it, a schedule / runAt alone switches away, `400 schedule, runAt or webhook required` when none would be left). `runner.ts` `scheduleJob` silently prefers `runAt` when both are present, and at-mode's post-fire auto-disable (`enabled=0`) would then kill the recurring schedule too — so the invariant is enforced at every write point, not just at create. `PUT /api/cron/jobs/:id` validates the **merged** row (partial PUT bodies make this non-obvious):
|
|
39
38
|
|
|
40
39
|
- Both sides in one body → `400 schedule and runAt are mutually exclusive`.
|
|
41
40
|
- One side only → treated as a **mode switch**: the other side is implicitly cleared (`schedule` set ⇒ `runAt=null`; `runAt` set ⇒ `schedule=''`).
|
|
@@ -49,53 +48,77 @@ The skill helper `manage-cron.py update` follows the same rules (`--schedule` /
|
|
|
49
48
|
REST route mutations call `scheduleJob` / `unscheduleJob` directly. But the `cron` skill (and manual ops) edit `cron.db` over a *different* sqlite connection. `reconcileFromDb` (10s timer) catches those:
|
|
50
49
|
|
|
51
50
|
- Fast path: `PRAGMA data_version` flips only when *another* connection commits. If unchanged since last pass, skip the full select entirely — one pragma read (~µs) vs. a select-all + per-row fingerprint compare. The pragma (and the run-pruning `NOT IN (subquery)` below) needs the raw better-sqlite3 handle, which the runner gets from the shared `rawSqlite(db)` helper (`db/raw-sqlite.ts`) instead of re-spelling drizzle's `$client` / `.session.client` cast at each site — it did, three times, each with its own hand-written method shape.
|
|
52
|
-
- On change: diff db rows against the in-memory `_fingerprint` map (`jobFingerprint`: enabled, schedule, runAt, timezone, activeFrom, activeUntil, agentId, userPrompt, workingDir, stateless); schedule new rows, unschedule deleted ones, re-instantiate croner only for rows whose fingerprint changed. Broadcasts a coalesced `cron:job_changed` per affected job.
|
|
51
|
+
- On change: diff db rows against the in-memory `_fingerprint` map (`jobFingerprint`: enabled, schedule, runAt, webhook, timezone, activeFrom, activeUntil, agentId, userPrompt, workingDir, stateless); schedule new rows, unschedule deleted ones, re-instantiate croner only for rows whose fingerprint changed. Broadcasts a coalesced `cron:job_changed` per affected job.
|
|
53
52
|
|
|
54
53
|
## Execution (`runJob`)
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
A fire runs **in-process**: the prompt goes into the job's session on the server's own `SessionManager` for that workspace (reached through `setCronSessionRegistry(registry)`, called in `index.ts` next to `setRelayRegistry`), and the run ends when relay's turn-end hook sees that session's tree go quiet. Before 2.2 each fire spawned a `halo cli` child; that is gone, except for the boot-time reaping of a leftover child (see [Recovery](#recovery-on-boot-recoverruns)). One writer per session also removes the old double-write window on a picked session.
|
|
57
56
|
|
|
58
|
-
1. **
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
57
|
+
1. **Job guard**: same `jobId` already running in this process (`_inflight`) → a `skipped` row, `previous run still in progress`. Applies to scheduled fires and run-now.
|
|
58
|
+
2. **Session guards** — the session is resolved first (`effectiveSessionId`: `job.sessionId`, else the job's own; a stateless job's fresh `perRunSessionId`, which these never hit), then `sessionBusy` on the workspace's manager (`registry.getOrCreate(ws)`):
|
|
59
|
+
- `reply_to` set — someone is owed this session's wrap-up → `session <id> busy: owed to <cron run X | relay from … | A2A task … | invocation …>`. `reply_to` lives in the workspace db, so this holds across restarts; it also replaces the old in-memory `_inflightSessions` for two jobs picking one session.
|
|
60
|
+
- a turn running anywhere in its tree (`hasActiveWorkInTree`) → `session <id> busy: a turn is running in it` (in practice someone chatting in a picked session).
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
A timer / run-now fire is only ever skipped, never injected into a busy session: its folder can't change mid-turn, its timeout would stop the other turn, and the reply would be whose? Webhook fires differ — see [Webhook trigger](#webhook-trigger).
|
|
63
|
+
3. Insert the `running` row (`cron_runs.session_id`, `workspace_path`), broadcast `cron:run_changed`. A missing workspace / folder (`resolveWorkingDir`) → `failed` (`workspace not found` / `folder not found: …`); a workspace db that won't open → `could not open workspace <ws>: …`.
|
|
64
|
+
4. **Create or resume** the session. Missing → `createSession(job.agentId, null, 'Cron: <label|id>', undefined, sid, folderAbs, null[, title])` — access `null` = full; the display name resolves from agent.yaml; a webhook job's session is titled `Webhook: <label|id>` up front (its first message is a payload), a scheduled one takes its title from the first message. Existing with another agent → `failed` (`session X runs agent Y, not the job's Z; …`). Existing with the same agent → `setWorkingDir(sid, folderAbs|null)` re-points its folder in place (changing a job's folder moves its session too) — except a webhook fire on a busy session, which leaves it for a later run (see [Webhook trigger](#webhook-trigger)); its **access level is kept** — the send passes no level.
|
|
65
|
+
5. **Re-check, then take the pointer** — past the awaits `sessionBusy` runs again, then in the same synchronous slice `takeReplyTo(db, sid, { cron: runId })`.
|
|
66
|
+
6. **Track** (`trackRun`, before the send — the turn can end before `sendUserMessage` resolves): `_runs` entry, the root event listener (notices, interrupted replies), the timeout timer.
|
|
67
|
+
7. **Send**: `appendUserMessage(sid, job.userPrompt)` (the UI row shows the prompt alone) and `sendUserMessage(sid, UNATTENDED_PREAMBLE + prompt, …, stamp)`. The `[Unattended cron run — nobody will answer questions …]` line is added on every fire: the model can't see the `cron-*` id, and prompts typed into the admin form carry no such notice. A send that throws → own pointer cleared, `failed`. `runJob` returns the runId here; it does not wait for the run.
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
**Session strategy.** A stateful job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, `CRON_MIGRATIONS` slot 1). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, returned by the REST list as `effectiveSessionId`. Older builds always prefixed (`cron-cron-…`); slot 3 (v4) pins every job that ever ran to that session via `session_id`. The conversation accumulates and is reviewable (live) in the Sessions tab. A **stateless** job (`cron_jobs.stateless`) gets a fresh session every fire.
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
**Agent lock.** A session's agent never changes, so once a stateful job's session exists the job's agent is fixed to it: REST create / update return `400 session <id> already runs agent <x>; …` when the **merged** row's agent differs (`sessionAgentConflict`; the skill helper exits with the same message). Only checked when the body changes one of its inputs (agent, session, stateless, workspace — stored values compared, the admin form resends every field), so a legacy mismatch still takes unrelated edits; each of its runs fails at step 4 until fixed. The list GET returns the session's agent as `sessionAgentId`, which the admin form locks on.
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
**Session lookup** (`readSessionRow`, behind `sessionAgentId` and the agent lock). It runs per job on every list GET and `cron:job_changed` push, so it never goes through `getWorkspaceDb` (realpath + scaffolding per call — a GET must not scaffold): one read-only better-sqlite3 handle per workspace path, rows cached and dropped whenever `PRAGMA data_version` moves. **Absent vs unreadable**: no `.halo/halo.db` or no row → null; any other failure throws `could not read session <id> from <ws>: …` — the REST write points return it as the `400` (the skill helper reads an unreadable db as no session); display reads it as null.
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
### Wrap-up: `reply_to = { cron: runId }`
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
The run's pointer is a `ReplyTo` branch next to relay / A2A / invocation (`agents/relay.ts`). relay reaches the runner through `CronRunDoor` (`setCronRunDoor({ owns, finish, cancel, released })`, set when `runner.ts` loads — relay can't import the runner, which imports relay); the CLI / TUI never load the runner, so there the door is null and a `{cron}` pointer is left as is.
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
- **`deliverRelayReport`** (the session's tree went quiet): `owns(runId)` = that row is `running` in **this process's** `cron.db` — a DEV server sharing workspace dbs with prod, or an AgentCore process, doesn't own it and leaves the pointer (`left for its owner`). Otherwise `finishRun`. `deliverRelayInterim` sends nothing for `{cron}` — final only.
|
|
80
|
+
- **`finishRun`** — db-driven (run row, job row, targets read now; the `RunCtx` only adds notices, interrupted replies and the timer, so a run resumed after a restart finishes the same way). Synchronous up to the dispatch: `_finishing` claim, untrack, own pointer cleared. Text = what `halo cli` printed: root notices as `ℹ️ <n>` lines (not a sub-session's, not the drain-time sibling-status line, not an out-of-band `notifySystem` one — the evo ticker's auto-apply line landing mid-run is marked `outOfBand` and stays in chat only), a blank line, then the reply — `finalOutput || output`, after a webhook run's replies to turns its events cut short (`answers`, see below). `turnError` → `failed` (500 chars; a refusal on a stateful job appends a hint — delete the job's own session, or for a picked session pick another / clear the pick, or turn on "new session every run" — the refusal sticks to that history); empty → `failed` `run produced no output (nothing to dispatch)`; else `succeeded`, then `dispatchToTargets(text, targets, run workspace)` — every target failing downgrades to `failed`. `finalize` is guarded (`WHERE status='running'`): timeout, stop, finish and supersede race, the first wins. `test/cron-cli-parity.test.ts` replays each run's event stream through the cli's `runCli` and asserts the same text.
|
|
81
|
+
- **Self-evolution**: a turn under a `{cron}` pointer never enqueues a pre-compact evo run (`enqueueEvoForCompactedTurn`), and a session that took webhook events enqueues later runs — pre-compact or `/evo` — with `source_access = 'webhook'`, so L2/L3 never auto-apply them (reason `source_webhook`; [evolution.md](evolution.md#auto-apply-l2--l3)). The evo ticker's auto-apply line (`notifySystem`, `outOfBand`) never joins a run's notices.
|
|
82
|
+
- **Stop / delete / archive** of the session here: `cancelOwedForSession(db, sid, 'stop'|'delete'|'archive')` (the renamed `cancelA2AForSession`, same 4 call sites in `session-manager.ts`) → `cancelRun`: `failed` `session stopped/deleted/archived before the run finished`, nothing dispatched — a Stop no longer ships the partial output.
|
|
83
|
+
- **Supersede**: `takeReplyTo` over a `{cron}` pointer (a relay / A2A taking the session) → the run `failed` `superseded by …`, the new pointer stays.
|
|
84
|
+
- **Timeout** (`job.timeoutSec ?? 3600`, the nullable `cron_jobs.timeout_sec` validated 60–21600 at every write surface): outcome first — own pointer cleared, `timeout` `run exceeded <t>s timeout`, nothing dispatched — then `stopSession`, raced against 30 s (`STOP_SETTLE_MS`); one that doesn't settle (a tool ignoring its abort) logs `stopSession did not settle in 30s; the turn keeps running detached`, and later fires skip on the busy session. The abort reaches `shell_exec` as a process-group SIGTERM → SIGKILL (`spawnGroupExec`); bwrap adds `--die-with-parent`. The old SIGKILL-the-tree fallback is gone (accepted risk).
|
|
85
|
+
- `exit_code` is NULL on every new row (status carries timeout); `log_path` NULL — the session is the log, live in the admin. The admin shows "Open session" for such runs; `logs/cron/` only ages out pre-2.2 files.
|
|
84
86
|
|
|
85
|
-
The
|
|
87
|
+
The `cron:job_changed` that `finalize` sends carries `workingDir` / `stateless` / `sessionAgentId` (`jobChangedFields`), read after the run.
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
## Recovery on boot (`recoverRuns`)
|
|
88
90
|
|
|
89
|
-
|
|
91
|
+
Runs are recorded in the run ledger like any root session (`runs.db`; see [session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-haloglobalrunsdb)), and the `{cron}` pointer survives in the workspace db, so a run cut off by a restart resumes: the ledger's boot sweep nudges the session, and the resumed turn's end reaches `finishRun` through the door as usual.
|
|
90
92
|
|
|
91
|
-
`startCronDaemon` calls `
|
|
93
|
+
`startCronDaemon` calls `recoverRuns` (not awaited) before `reloadAll`; its first synchronous slice puts the jobId of every `running` row into `_inflight`, so a timer that fires right away only skips. Per row:
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
2.
|
|
95
|
-
3. No pid / already dead / fingerprint mismatch / Windows: no kill, log only. Windows deliberately marks-only — there's no cheap command-line identity check (WMI is slow and sometimes policy-blocked), and a full-tree `taskkill /T /F` on a reused pid is irreversible while a double-written session is fixable; the orphan-survives-server-death window is also naturally smaller there.
|
|
96
|
-
4. Either way the row is finalized as `failed` (`failureReason` notes the orphan disposition, `completedAt` stamped) so the UI stops showing a ghost `running` — for a one-shot its job row too, as `finalize` would (`enabled=0`, `lastRunStatus='failed'`, `lastRun*` = this run, `cron:job_changed` pushed); a recurring job's row is left alone — and the jobId / session key are released once the orphan is confirmed dead (or immediately when nothing was killable).
|
|
95
|
+
- **In-process** (no pid): the workspace exists, the row has a session, the session's `reply_to` still names this run → wait for the ledger nudges of that workspace (`nudgesSettled`) → if the tree has work in flight, re-track the run with what's left of its timeout (`trackRun`; ≤ 0 times out right away) and redeliver its webhook events whose stamp never reached the model history (`rawHasStamp`, see below). Otherwise `failed` `interrupted by server restart (…)` — `not resumed: this server does not own the workspace runtime, or the session could not be woken` (a DEV server: ledger off), `before the run reached its session` (no session on the row), `workspace not found`; a pointer that no longer names the run (cut off before `takeReplyTo`, or superseded) → plain `interrupted by server restart`. A run whose resumed turn already ended during an await (this row's or an earlier row's) is left as recorded (`_finishing` / status re-checked) and its own end path settles its events and releases the job.
|
|
96
|
+
- **Legacy** (pid set — a pre-2.2 `halo cli` child, which can outlive its server outside a systemd cgroup): reaped as before — `ps -p <pid> -o args=` must carry the session id as a whole token (pid reuse), SIGTERM, SIGKILL the descendant tree after a 30 s grace, job + session (`_inflightSessions`) blocked meanwhile; never killed on Windows. Marked failed regardless. Remove one minor release after 2.2.
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
A run not resumed hands its webhook events back to pending; at the end every job with pending events gets `startOrInject`.
|
|
99
|
+
|
|
100
|
+
## Webhook trigger
|
|
101
|
+
|
|
102
|
+
The third trigger mode (`CRON_MIGRATIONS` slot 6, v7): `cron_jobs.webhook_secret` non-null = a webhook job — no schedule, no `runAt`, no timer (`scheduleJob` parks it in `_expired`; `jobFingerprint` includes the flag). Excludes a picked session (picked sessions are often owed to relay / A2A; events would wait with no wake-up point) and an active window, at every write surface.
|
|
103
|
+
|
|
104
|
+
**Receiver** — `POST /cron-hooks/:jobId` (`routes/cron-hooks.ts`, outside `/api`, public; mounted only when not AgentCore). Body ≤ 1 MB (413). Auth: GitHub `X-Hub-Signature-256` (HMAC-SHA256 of the raw body) or `Authorization: Bearer <secret>`, both `timingSafeEqual`; no job / not a webhook job / bad auth are one `404`. A GitHub `ping` → 200 before the enabled check (so creating the hook on a disabled job passes); disabled → 409. Delivery id = `X-GitHub-Delivery` / `Idempotency-Key` / `X-Request-Id`, else `sha256:<hex of body>#<receivedAt>` — matched by prefix within the last 10 minutes (`BODY_DEDUPE_MS`), so a header-less sender's retry burst is one event and the same body later a new one; the receipt time keeps it clear of `UNIQUE(job_id, delivery_id)`. A duplicate → 200 `{duplicate:true, eventId}` (insert `onConflictDoNothing` for a race) — except a repeat of a `failed` event: flipped back to `pending` (run / session / stamp cleared) and handled like a new one, 202 `{eventId, redelivered:true}`. More than 100 pending → 429. Stored → 202 `{eventId}`, then `startOrInject(jobId)` asynchronously.
|
|
105
|
+
|
|
106
|
+
**`cron_events`** — `id, job_id, delivery_id, event_type, received_at, state, run_id, session_id, stamp, payload`; `state`: `pending` → `injected` (handed to `run_id`'s session, `stamp` = its UI row / raw turn key) → `done` | `failed`. Indexed by job and by run.
|
|
107
|
+
|
|
108
|
+
**`startOrInject(jobId)`** — batching:
|
|
109
|
+
- A run of the job is tracked here and still owns its session → claim every pending event and inject each as its own message (`appendUserMessage` + `stampEvents` + `sendUserMessage` with the stamp): folded into the running turn or queued, so one run reports a batch. An inject that throws puts that event back to pending. A manual run (run-now) counts: events arriving during it are injected into it, though its opening message carried the prompt alone.
|
|
110
|
+
- Otherwise (job enabled and still a webhook job) `runJob(jobId, 'webhook')`: the events claimed right after `trackRun` ride the opening message (prompt + each event's text). Unlike a timer fire, a webhook fire **does not skip on a user's turn** (`sessionBusy(…, ownerOnly)`) — it queues behind it, leaving the session's folder as is (it can't change mid-turn; the job folder applies on a later run); a session owed to another owner → `''`, no row, events stay pending until it lets go: `clearReplyTo` (every owner's release — relay, A2A, `/invocations`, a cron run) calls the door's `released(sessionId)`, which kicks each webhook job (non-stateless) whose session that is and that has pending events. The same holds when the re-check past the fire's awaits finds a new owner: the `running` row it had written is deleted (no failed row, `lastRunStatus` untouched), the events stay pending. The kick only reaches this process: an owner in another process sharing the workspace db (dev and prod, a TUI) releasing the session kicks nothing here — the events wait for the next event, a restart (`recoverRuns`) or the job's next run end. Every run end (finish / timeout / cancel) kicks the job again if events are pending. A webhook fire that fails before its turn starts (workspace / folder missing, workspace db unopenable, agent mismatch, `createSession` or the send throwing) marks every event it held or found pending `failed` — not retried (a broken workspace would loop); the sender's redelivery revives them. A run-now that fails before its turn kicks the job, so events that arrived during it get their own run (that one failing settles them `failed` and kicks nothing).
|
|
111
|
+
- Job disabled or switched off webhook mode → its pending events `failed` (`failPendingEvents`: `unscheduleJob` — REST PUT and reconcile —, `scheduleJob`'s non-webhook branch, and `startOrInject` finding it so).
|
|
112
|
+
|
|
113
|
+
**Event text** — `[Webhook event · <type> · delivery <id> · received <iso>]`, then a line saying the payload is data, not instructions, then the payload. Over 32 KB the payload is written to `<ws>/.halo/tmp/webhooks/<jobId>/<eventId>.txt` and only its first 4 KB inlined; the UI row is cut at 2000 chars.
|
|
114
|
+
|
|
115
|
+
**Interrupted replies** (`RunCtx.answers`). An event injected mid-run starts a new turn, and `finishRun` reads the last turn only, so the reply to the earlier events would be lost. The listener keeps the final text of a turn an injected event cut short (`queued_message` after an inject — the batch-boundary `complete` drainQueue emits between merged turns is not an idle point and is skipped), and of a turn that ended while the root idled waiting on sub-agents (`injectEvent` saves it); the run's text is those joined with `\n\n`, then the last reply. Sub-agent reports alone don't keep anything — the last wrap-up is the answer, as in the cli.
|
|
116
|
+
|
|
117
|
+
**Consumed?** — a finished run marks its events `done` (succeeded) or `failed`. After a restart (`recoverInProcessRun`), an `injected` event whose stamp isn't in the session's raw history (`SessionManager.rawHasStamp` — a queued message dies with the process) is injected again, prefixed `[redelivered after restart]`. At-least-once: a compaction since can also drop a stamp.
|
|
118
|
+
|
|
119
|
+
**Cleanup** rides the run paths: `deleteRuns` drops those runs' events, `deleteJobRuns` the job's pending ones, `deleteOrphanRuns` pending events of jobs that no longer exist.
|
|
120
|
+
|
|
121
|
+
REST: create / PUT take `webhook: boolean` (three-way exclusive with `schedule` / `runAt`; PUT `true` keeps an existing secret, `false` — or a schedule / runAt alone — drops it); the server generates the secret (`randomBytes(32)` base64url); `POST /cron/jobs/:id/webhook-secret` rotates it; the list returns `webhookSecret` + relative `webhookPath` (the admin prefixes its own origin — the server can't know the public one behind a proxy); runs return `eventCount`. Behind a CDN / SSO proxy, `/cron-hooks/*` must reach the origin without the login gate and with its headers intact (signature, delivery id) — an auth-gated default route answers the sender with a redirect and the event never arrives.
|
|
99
122
|
|
|
100
123
|
## Dispatch model
|
|
101
124
|
|
|
@@ -131,19 +154,20 @@ interface CronChannelDispatcher {
|
|
|
131
154
|
- **REST delete**: `DELETE /api/cron/jobs/:id` runs `deleteJobRuns` before deleting the job row.
|
|
132
155
|
- **Skill delete**: `manage-cron.py delete` drops only the `cron_jobs` row. The server's next full `reconcileFromDb` pass then calls `deleteOrphanRuns` without awaiting it (failures are logged as warnings). That function handles every run whose `job_id` is no longer in `cron_jobs`, so rows and sessions go within about 10 s. A full pass runs after any other connection writes `cron.db`, and once on the first tick after boot, which also covers jobs deleted while the server was down.
|
|
133
156
|
- **Deleted mid-run** (REST or skill): the delete keeps the in-progress row and its session. After the run finalizes, `pruneOldRuns` finds the job row gone and calls `deleteJobRuns`, which removes both.
|
|
134
|
-
- **Logs**: files older than 30 days deleted on a daily timer (db row kept
|
|
157
|
+
- **Logs**: in-process runs write none; pre-2.2 files older than 30 days are deleted on a daily timer (db row kept). `cron_runs` rows older than 60 days are also dropped, through `deleteRuns`, so the session rule above applies.
|
|
135
158
|
|
|
136
159
|
## Key files
|
|
137
160
|
|
|
138
|
-
- `packages/server/src/cron/runner.ts` — scheduler: fire timers (croner computes times), `runJob`
|
|
161
|
+
- `packages/server/src/cron/runner.ts` — scheduler: fire timers (croner computes times), `runJob` / `finishRun` / timeout / `cancelRun`, `recoverRuns`, `startOrInject`, reconcile, retention; `effectiveSessionId` / `perRunSessionId`, the read-only session lookup (`readSessionRow`) behind `sessionAgentIdFor` / `sessionAgentConflict`.
|
|
139
162
|
- `packages/server/src/cron/dispatcher.ts` — channel-agnostic dispatch registry (`registerCronDispatcher`, `dispatchToTargets`, `listAllCronTargets`).
|
|
140
|
-
- `packages/server/src/
|
|
141
|
-
- `packages/server/src/
|
|
163
|
+
- `packages/server/src/cron/events.ts` — webhook event queue (`claimPendingEvents`, `settleRunEvents`, `requeueEvents`, `eventText`); `routes/cron-hooks.ts` — the receiver.
|
|
164
|
+
- `packages/server/src/db/cron-db.ts` — `cron_jobs` + `cron_runs` + `cron_events` schema, `CRON_MIGRATIONS`, `getCronDb`.
|
|
165
|
+
- `packages/server/src/agents/relay.ts` — the `{cron}` `ReplyTo` branch and `CronRunDoor`; `session-manager.ts` — `rawHasStamp` (webhook redelivery check).
|
|
142
166
|
- `packages/server/src/channels/<ch>/cron-dispatcher.ts` — per-channel `dispatch` + `listTargets`.
|
|
143
167
|
- `packages/server/templates/skills/cron/SKILL.md` — the agent-facing skill that CRUDs `cron_jobs` (edits the db directly; `reconcileFromDb` picks it up). It receives `{{channel.type/account_id/chat_id}}` placeholders, so a job created from a chat defaults its target to that chat, and "delete my cron" inside a chat reverse-looks-up its subscriptions with `list --chat-id <id>`.
|
|
144
168
|
|
|
145
169
|
## Scope
|
|
146
170
|
|
|
147
|
-
Supported: recurring (cron expr) + one-shot (`runAt`) schedules; per-timezone; optional active window for recurring jobs (`activeFrom` / `activeUntil` — "pause until", "send until"); per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own (id = job id) or a picked existing root session (`sessionId`) — or a fresh session every run (`stateless`); a per-job folder (`workingDir
|
|
171
|
+
Supported: recurring (cron expr) + one-shot (`runAt`) schedules + webhook triggers (batched events, redelivered after a restart); per-timezone; optional active window for recurring jobs (`activeFrom` / `activeUntil` — "pause until", "send until"); per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own (id = job id) or a picked existing root session (`sessionId`) — or a fresh session every run (`stateless`); a per-job folder (`workingDir`); run-now (manual trigger); runs resumed after a server restart; admin UI history with the run's session + dispatch results.
|
|
148
172
|
|
|
149
|
-
Not supported: sub-minute schedules below croner's resolution;
|
|
173
|
+
Not supported: sub-minute schedules below croner's resolution; running in a sub-session (`>` paths — root sessions only); changing a stateful job's agent once its session exists (agent lock); a webhook job on a picked session; running cron from the CLI / TUI (server only).
|