Repository navigation
feat(workflows)!: scheduled workflows own their cron; delete hub scheduler (CL-8162) - #761
Merged
Merged
Conversation
A schedule-triggered workflow owns its own cron: it arms the next cron-flagged TimerSet the workflow-host scheduler seam fires, and re-arms after each tick. The seam drops a cron TimerSet whose fireAt has already passed (missed ticks are never replayed), so a tick observed late must arm the next future tick rather than the one it just missed — otherwise the schedule stalls for good. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG
The hub polled every deployed definition twice a minute, matched its ScheduleTrigger cron itself, and CAS'd `workflow_definition .schedule_claimed_minute` to make the fire exactly-once across replicas. That is a reimplementation of the platform's own scheduler seam, which durably fires TimerSet events and leaves the next cron tick to whoever owns the cron. Deleted: `apps/hub/src/workflow-scheduler.ts` and `cron-due.ts`, their wiring and teardown in `index.ts`, the `ROUTINE_SCHEDULER_POLL_INTERVAL_MS` knob, and `schedule_claimed_minute` (forward-only migration 0094 drops the column Workbench's own 0092 added). `runNowScheduledDefinition` moves next to the launch path it delegates to, so firing a routine by hand is unchanged. Added: `@corbits/workflow-schedule`, the cron grammar (moved out of `@corbits/workflows`, which now consumes it) plus `armCronTimer` — the next-tick arithmetic and the cron-flagged TimerSet payload a run commits to own its schedule. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG
TheGreatAxios
added a commit
that referenced
this pull request
Sep 17, 2026
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
added a commit
that referenced
this pull request
Sep 17, 2026
…ployments (CL-8169) (#765) * Add tests for the cron emitter's re-arm 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 * workflow-schedule: emit the cron tick for schedule-triggered deployments 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
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.
The hub polled every deployed definition twice a minute, matched each
ScheduleTriggercron itself, and CAS'dworkflow_definition.schedule_claimed_minuteto make the fire exactly-once across replicas. Interchange's workflow-host
scheduler seam already owns durable timers: it fires
TimerSetevents,skips (never replays) a missed cron tick, and leaves the next tick to
"whoever owns the cron" — the run. The hub loop was a reinvention, so it
is gone.
What changed
apps/hub/src/workflow-scheduler.tsandcron-due.ts, theirwiring/teardown in
apps/hub/src/index.ts, theROUTINE_SCHEDULER_POLL_INTERVAL_MSknob (hub config, config test, e2eharness), and both of their test suites.
schedule_claimed_minute: forward-only migration0094_drop_workflow_definition_schedule_claimed_minute.sqlin thevendored
@intx/dbtree, undoing Workbench's own delta migration 0092(the column is a Workbench addition, not upstream schema). Journal,
schema declaration, replay test, VENDORED-FROM delta line and the
kill-dates tree hash all updated.
runNowScheduledDefinitionmoved beside the launch path it delegatesto (
native-workflow-routine-launch.ts); firing a routine by hand isunchanged.
@corbits/workflow-schedule: the 5-field cron grammar (moved outof the app-facing
@corbits/workflows, which now consumes it) plusarmCronTimer— the next-tick arithmetic and the cron-flaggedTimerSetpayload a run commits to own its schedule. A tick observedlate arms the next future tick rather than a past-due one the seam
would drop.
0.3.0 vs upstream HEAD: the gap this cannot close yet
Verified in the vendored 0.3.0 tree and in an upstream HEAD clone — the
two agree, so this is not a re-pin question:
@intx/workflow-host's scheduler seam only consumes a cron-flaggedTimerSet. Nothing in@intx/workflow,@intx/agentor@intx/workflow-hostever emits one, and there is no cron parser ornext-fire computation anywhere in Interchange.
primitive is
sleep, which takes a literaldurationor a literaluntil(no selector, no expression), and a loop body may not containa
sleep. A definition therefore cannot express "re-arm my own cron"in either 0.3.0 or HEAD.
createWorkflowHostSchedulerat all.So
workflows/*packages are not converted in this PR: the native seamthey would use has no emitter to call. Scheduled definitions no longer
auto-fire; they fire through the existing manual run-now path until the
emitter lands (either upstream, or as a Corbits library built on
armCronTimer, which exists here for exactly that). No fallback loop isleft behind — that is the hard cutover, stated plainly rather than
hidden behind a second scheduler.
Also not done here, and worth its own change: the web schedule surfaces
(
workflow-detail-api.ts,workflow-detail-page.tsx,create-agent-panel.tsx) and the${TENANT_PREFIX}/workflows/scheduledlist/run-now routes still read the schedule from our own projection.
Those routes are a read model over Workbench's tables, not a cron tick,
and five web call sites depend on them; re-pointing them at the stock
definitions route is a separate cut.
Verification
bun run check:structural— green (killdates hash and tsconfigreferences regenerated).
bun test packages/workflow-schedule packages/workflows/src/schedule—green.
bun test apps/hub— green except twohub-error-handlerfailures that reproduce on
origin/mainand pass in isolation(cross-suite pollution, untouched here).
completed against the real, reachable noop-inference source."
bun run typecheck/bun run lint: the only failures arepre-existing on
origin/main(two@corbits/chatsubpath resolutionsin
chat-uitests; three unused imports inapps/hub/src/index.tsfrom concurrent work).
https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG