Repository navigation
feat(workflow-schedule): emit cron TimerSet for schedule-triggered deployments (CL-8169) - #765
Merged
Merged
Conversation
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
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
force-pushed
the
cl-8169-cron-emitter
branch
from
September 17, 2026 17:43
4e5f11d to
cf5aecb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes CL-8169.
PR #761 deleted the hub's cron poller and created
@corbits/workflow-schedulewith the cron grammar andarmCronTimer, 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-hostscheduler 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 ofTimerFiredinto one workflow-run event log. ATimerFiredunblocks awaitForTimerinside a run that is already executing.apps/sidecar/src/workflow-substrate-factory/index.ts— the seam is constructed inside each workflow-process child, withlistActiveDeployments: () => [workflowRunRepoId]and aworkflow-processprincipal, 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-onlysleep— a definition cannot re-arm its own cron.So a seam
TimerSetcannot 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 mailtriggerNativeWorkflowRoutineRundelivers throughSidecarRouter.routeMail(apps/hub/src/native-workflow-routine-launch.ts) — exactly whatrunNowScheduledDefinitionalready 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 viaarmCronTimer, 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 injectslistCronDeployments,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'snewTimerIdnow 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 keepsstoppedrows is unchanged.apps/hub/src/index.ts— one wiring block plus onecronEmitter.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-digestdeployed throughPOST /workflows/deploymentswith itsScheduleTriggercron rewritten to* * * * *, no run-now anywhere in the script. Hub log, emitter state sampled every 15s: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 throughonError. The emitter in isolation against a real wall clock:What the live run did NOT prove, stated plainly: each fire delivered its trigger mail and
routeMailaccepted it, but no newworkflow_runrow appeared for the deployment inside the window. A definition whose only trigger is aScheduleTriggerdeclares no mail address, so the native mail trigger has nothing to match — which is the same pathrunNowScheduledDefinitionalready 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, andtimerIdForTickgives 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