Skip to content

feat(workflow-schedule): emit cron TimerSet for schedule-triggered deployments (CL-8169) - #765

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-8169-cron-emitter
Sep 17, 2026
Merged

TheGreatAxios merged 2 commits into
mainfrom
cl-8169-cron-emitter

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Closes CL-8169.

PR #761 deleted the hub's cron poller and created @corbits/workflow-schedule with the cron grammar and armCronTimer, but nothing armed a tick — so a schedule-triggered workflow (digest, morning brief, recurring task, topic watch) only fired via run-now.

Where the emitter lives, and why

In @corbits/workflow-schedule, mounted once from the hub composition root, firing through the deployment's own native trigger.

The @intx/workflow-host scheduler seam is not reachable as a launch mechanism from a host process:

  • vendor/intx/workflow-host/src/seams/scheduler.ts — the seam is the single writer of TimerFired into one workflow-run event log. A TimerFired unblocks a waitForTimer inside a run that is already executing.
  • apps/sidecar/src/workflow-substrate-factory/index.ts — the seam is constructed inside each workflow-process child, with listActiveDeployments: () => [workflowRunRepoId] and a workflow-process principal, against a proxy store whose comment states the supervisor "is the sole writer of the workflow-run ref."
  • vendor/intx/workflow/src/definition/triggers.ts + the runtime's literal-only sleep — a definition cannot re-arm its own cron.

So a seam TimerSet cannot start a fresh run, and a hub-side writer would race the supervisor for the run ref. A tick that must launch fires the stock native primitive instead: the signed trigger mail triggerNativeWorkflowRoutineRun delivers through SidecarRouter.routeMail (apps/hub/src/native-workflow-routine-launch.ts) — exactly what runNowScheduledDefinition already uses. A scheduled tick and a run-now are now one primitive with a different clock.

What landed

  • packages/workflow-schedule/src/emitter.ts — createCronEmitter(deps): reads the live schedule-triggered deployments, arms each one's next tick via armCronTimer, fires it, re-arms from the tick's own instant (a slow or late fire skips what it ran through rather than arming a past-due tick the seam would drop). Host-agnostic: no @intx/*, no DB, no HTTP — the host injects listCronDeployments, fire, onError, clock.
  • timerIdForTick(deploymentId, fireAt) — a tick's identity is derived, not minted, so two emitters arming the same tick name it the same thing. armCronTimer's newTimerId now receives the instant so the identity can be derived from it.
  • packages/workflows/src/schedule/deployed-cron-deployments.ts — listDeployedCronDefinitions, the firing list (deployed + authored + valid cron + a creator principal and tenant domain to send as). The ops-table sibling that keeps stopped rows is unchanged.
  • apps/hub/src/index.ts — one wiring block plus one cronEmitter.stop() in teardown. No route, no new table; the exactly-once claim column feat(workflows)!: scheduled workflows own their cron; delete hub scheduler (CL-8162) #761 dropped stays dropped.

Live-fire evidence

Real hub (spawned process, real Postgres, HUB_DATA_DIR=$(mktemp -d)), workbench-digest deployed through POST /workflows/deployments with its ScheduleTrigger cron rewritten to * * * * *, no run-now anywhere in the script. Hub log, emitter state sampled every 15s:

[CRONDBG] armed=[]                                        # boot rescan, before deploy
[CRONDBG] armed=[... fireAt":"2026-09-17T17:51:00.000Z]   # first rescan after deploy arms the next tick
[CRONDBG] armed=[... fireAt":"2026-09-17T17:51:00.000Z]
[CRONDBG] armed=[... fireAt":"2026-09-17T17:52:00.000Z]   # 17:51 tick fired and re-armed the next minute

The armed tick only advances through fireTick -> arm(firedAt), so that transition is the fire plus the re-arm, on consecutive minutes, with no error reported through onError. The emitter in isolation against a real wall clock:

armed: [{"timerId":"cron:d1:2026-09-17T17:47:00.000Z","fireAt":"2026-09-17T17:47:00.000Z","cron":"* * * * *"}]
FIRE 1 at 2026-09-17T17:47:00.015Z tick=2026-09-17T17:47:00.000Z
FIRE 2 at 2026-09-17T17:48:00.001Z tick=2026-09-17T17:48:00.000Z

What the live run did NOT prove, stated plainly: each fire delivered its trigger mail and routeMail accepted it, but no new workflow_run row appeared for the deployment inside the window. A definition whose only trigger is a ScheduleTrigger declares no mail address, so the native mail trigger has nothing to match — which is the same path runNowScheduledDefinition already takes for these definitions (apps/hub/src/native-workflow-routine-launch.ts), so this looks like a pre-existing gap in how a schedule-triggered native deployment is fired, not something this PR introduced. The clock half is proven end to end; the launch half needs its own look (likely: a schedule-triggered deployment must also carry the deployment's mail trigger, or the fire must go through a different native entry point). Flagging rather than papering over it.

The throwaway e2e driver used for the above is not committed: it fails today on the launch half described here, and a red suite in CI would be noise, not signal.

Open

Cross-replica exactly-once. #761 deliberately dropped workflow_definition.schedule_claimed_minute, and the seam's timer ledger is per-run so it cannot serve as the claim for a launch. Today's hub is single-instance, and timerIdForTick gives any future durable sink a stable key to dedupe on — but two hub replicas would currently double-fire a tick. Flagged rather than solved, since solving it with a Workbench table is exactly what this ticket forbids; the upstream ask (Interchange owns the emitter, with a deployment-scoped timer ledger) is the real fix — recorded on CL-7575.

https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG

A tick that fires re-arms from its own instant, so a slow or late fire
lands on the next tick after the one it ran rather than on one that has
already passed — the scheduler seam drops a past-due cron TimerSet as a
missed tick, so arming one would stall the schedule for good.

Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG
@linear-code

linear-code Bot commented Sep 17, 2026

Copy link
Copy Markdown

CL-8169

PR #761 deleted the hub's cron poller and left `@corbits/workflow-schedule`
with the grammar and `armCronTimer` but no emitter, so a schedule-triggered
workflow only fired via run-now.

`createCronEmitter` is that emitter: it reads the live schedule-triggered
deployments, arms each one's next tick, fires it, and re-arms from the
instant the tick fell on. A tick's identity is derived, not minted —
`timerIdForTick(deploymentId, fireAt)` is a pure function, so two emitters
arming the same tick name it the same thing.

Where it lives and why the fire is a trigger, not a seam TimerFired: the
`@intx/workflow-host` scheduler seam
(`vendor/intx/workflow-host/src/seams/scheduler.ts`) is constructed per
workflow-run child, scoped to that child's own run repo — see
`apps/sidecar/src/workflow-substrate-factory/index.ts`, which builds it
with `listActiveDeployments: () => [workflowRunRepoId]` — and the
supervisor is the sole writer of that ref. A `TimerFired` there unblocks a
`waitForTimer` inside a run that is already executing; it cannot start a
fresh one, and a definition cannot re-arm its own cron. So a tick that must
launch a run fires through the deployment's own native trigger, the same
signed-mail primitive `runNowScheduledDefinition` uses
(`apps/hub/src/native-workflow-routine-launch.ts`): a scheduled tick and a
run-now are now one primitive with a different clock.

The hub composition root supplies only the two host bindings the emitter
cannot own — which deployments are live
(`listDeployedCronDefinitions`, new in `@corbits/workflows`) and how a tick
fires — and no DB claim column comes back: the exactly-once column #761
dropped stays dropped.

Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG
@TheGreatAxios
TheGreatAxios merged commit 233744e into main Sep 17, 2026
20 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant