fix(missions): scheduled missions never fired — nothing read missions.schedule
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:
co-authored by
Claude Opus 5
parent
3cc65c22c4
commit
f87853ecf9
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user