fix(missions): scheduled missions never fired — nothing read missions.schedule
deploy / test (push) Successful in 4m25s
deploy / build (push) Successful in 5m10s

The wizard has collected a cron since `0047_missions.sql` ("schedule JSONB
carries the trigger config (cron | one_shot | on_event)"), the frontend posts
`{kind:"cron", cron}`, and the API persists it faithfully. Nothing has ever read
it back: the only due-work enumerator in the codebase was `routines::claim_due`.
So every scheduled mission ever created sat in `draft` forever while the UI
reported it was on a schedule.

Proven before fixing, on the shipped build: a mission with `* * * * *` sat in
`draft` for 4m34s and started ZERO topology runs. After this change the same
mission launched on its next occurrence and recorded one `fired` row.

Two pieces were missing, and they are the two `routines` already had:

  - `missions.next_run_at` — schedule STATE. `schedule` is user intent and stays
    untouched; without somewhere to record which occurrence is owed there is
    nothing to put a `<= now()` predicate on, which is why no enumerator could
    be written against the JSONB alone.
  - `mission_fires` — one row per (mission, occurrence). 0063_routine_fires.sql
    called this exact case: "For a scheduled *mission* it costs a container, a
    repo checkout, and real money — which is why this lands before mission
    scheduling does."

`mission_schedule.rs` deliberately mirrors `cm-scheduler`'s shape rather than
inventing a second one: atomic `FOR UPDATE SKIP LOCKED` claim, reschedule
BEFORE dispatch so a failing launch cannot stall the clock, claim the slot
before launching so a crash mid-launch is retried rather than dropped, and a
fan-out cap. The cap is 5, not the scheduler's 25, because a mission firing is
a container and a checkout where a routine firing may be one turn.

The claim skips `status = 'running'`: a daily cron on a mission that takes
longer than a day must skip the occurrence, not stack a second crew on the same
workspace. Launch goes through `mission_orchestrator::on_launch` +
`missions::set_status`, the same path as the draft→running transition, so one
code path mints a crew. An unattended launch acts as the workspace owner
(`users::owner_of_workspace`) since missions carry no creator column; a
workspace without one settles the occurrence `failed` with the reason rather
than dropping it silently.

Backfill blast radius was MEASURED, not assumed: prod has zero missions with a
cron, this workstation had exactly one — the control created to prove the bug.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-17 14:10:39 -07:00
co-authored by Claude Opus 5
parent 3cc65c22c4
commit f87853ecf9
4 changed files with 406 additions and 0 deletions
+11
View File
@@ -381,6 +381,17 @@ async fn run() -> Result<(), String> {
// has to work, so it is checked on the days it does not.
cm_api::subscription::report_at_boot(runtime.clone());
cm_api::phase_runner::spawn(pool.clone(), runtime.clone(), node_hub.clone());
// Scheduled missions. `missions.schedule` has collected a cron from the
// wizard since 0047 and NOTHING read it back — every scheduled mission ever
// created sat in `draft` forever while the UI said it was on a schedule.
// 60s matches the finest cron granularity; the sweep claims atomically and
// records each occurrence in `mission_fires`, so replicas and restarts
// cannot double-launch a container.
cm_api::mission_schedule::spawn(
pool.clone(),
Some(node_hub.clone()),
std::time::Duration::from_secs(60),
);
// Per-mission runtime container sweeper (C3): tears down mission
// runtime containers 30 min after the mission reaches a terminal
// state so operators have a window to pull final artifacts.