Skip to content

feat(workflows)!: scheduled workflows own their cron; delete hub scheduler (CL-8162) - #761

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-8162-native-cron
Sep 17, 2026
Merged

TheGreatAxios merged 2 commits into
mainfrom
cl-8162-native-cron

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

The hub polled every deployed definition twice a minute, matched each
ScheduleTrigger cron itself, and CAS'd workflow_definition.schedule_claimed_minute
to make the fire exactly-once across replicas. Interchange's workflow-host
scheduler seam already owns durable timers: it fires TimerSet events,
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

  • Deleted apps/hub/src/workflow-scheduler.ts and cron-due.ts, their
    wiring/teardown in apps/hub/src/index.ts, the
    ROUTINE_SCHEDULER_POLL_INTERVAL_MS knob (hub config, config test, e2e
    harness), and both of their test suites.
  • Dropped schedule_claimed_minute: forward-only migration
    0094_drop_workflow_definition_schedule_claimed_minute.sql in the
    vendored @intx/db tree, 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.
  • runNowScheduledDefinition moved beside the launch path it delegates
    to (native-workflow-routine-launch.ts); firing a routine by hand is
    unchanged.
  • New @corbits/workflow-schedule: the 5-field cron grammar (moved out
    of the app-facing @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. A tick observed
    late 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-flagged
    TimerSet. Nothing in @intx/workflow, @intx/agent or
    @intx/workflow-host ever emits one, and there is no cron parser or
    next-fire computation anywhere in Interchange.
  • A workflow definition is pure JSON data. The only timer-arming
    primitive is sleep, which takes a literal duration or a literal
    until (no selector, no expression), and a loop body may not contain
    a sleep. A definition therefore cannot express "re-arm my own cron"
    in either 0.3.0 or HEAD.
  • No host in this repo wires createWorkflowHostScheduler at all.

So workflows/* packages are not converted in this PR: the native seam
they 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 is
left 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/scheduled
list/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 tsconfig
    references regenerated).
  • bun test packages/workflow-schedule packages/workflows/src/schedule —
    green. bun test apps/hub — green except two hub-error-handler
    failures that reproduce on origin/main and pass in isolation
    (cross-suite pollution, untouched here).
  • Heartbeat e2e against Postgres: 1 pass, 25s — "gate achieved: a run
    completed against the real, reachable noop-inference source."
  • bun run typecheck / bun run lint: the only failures are
    pre-existing on origin/main (two @corbits/chat subpath resolutions
    in chat-ui tests; three unused imports in apps/hub/src/index.ts
    from concurrent work).

https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG

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
@linear-code

linear-code Bot commented Sep 17, 2026

Copy link
Copy Markdown

CL-8162

@TheGreatAxios
TheGreatAxios merged commit c9930d1 into main Sep 17, 2026
22 of 24 checks passed
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
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