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
+318
View File
@@ -0,0 +1,318 @@
//! Launching missions that are due.
//!
//! `missions.schedule` has carried a cron since `0047_missions.sql` — the
//! wizard collects it, the API persists it — and until this module nothing 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. Measured
//! before this was written: a mission with `* * * * *` did not move for four
//! minutes and started no runs.
//!
//! The shape here is deliberately `cm-scheduler`'s, not a second invention:
//!
//! - **Claim atomically** (`FOR UPDATE SKIP LOCKED`) so replicas fire once.
//! - **Advance the clock before dispatching**, so a failing launch cannot stall
//! the schedule.
//! - **Record the claim first** in `mission_fires`, keyed by the occurrence's
//! own timestamp, so a crash between those two is retried rather than
//! silently dropped — and a slot already launched is never launched twice.
//! - **Cap the fan-out**, because a backlog would otherwise start one container
//! per missed occurrence.
//!
//! The one thing it does NOT share with routines is the launch itself: a due
//! mission goes through `mission_orchestrator::on_launch` and
//! `missions::set_status`, exactly as the draft→running transition in
//! `routes::missions::set_status` does, so there is one path that mints a crew.
use cm_db::repo::missions as missions_repo;
use sqlx::{PgPool, Row};
use time::OffsetDateTime;
use uuid::Uuid;
/// Most missions one tick will launch.
///
/// Lower than the scheduler's 25: a mission firing is a container, a repo
/// checkout and real model spend, where a routine firing may be a single turn.
/// The remainder stays due and is taken by the next tick.
const MAX_LAUNCHES_PER_TICK: usize = 5;
/// A mission whose occurrence has come due and been claimed.
#[derive(Debug)]
pub struct DueMission {
pub id: Uuid,
pub workspace_id: Uuid,
pub title: String,
pub cron: Option<String>,
/// The occurrence that came due — the value `next_run_at` held. Identifies
/// the slot in `mission_fires`, so it must not be re-read from the clock.
pub slot: OffsetDateTime,
}
/// Claim every mission due at `now`, atomically.
///
/// `next_run_at` is cleared by the claim. The caller recomputes it from the
/// cron and writes it back; a mission whose cron no longer yields an occurrence
/// simply stays cleared and stops firing, which is the correct end state for
/// a one-shot or an exhausted schedule.
pub async fn claim_due(pool: &PgPool, now: OffsetDateTime) -> Result<Vec<DueMission>, String> {
let rows = sqlx::query(
"UPDATE missions SET next_run_at = NULL
WHERE id IN (
SELECT id FROM missions
WHERE next_run_at IS NOT NULL
AND next_run_at <= $1
-- Never relaunch a mission that is mid-flight. 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.
AND status <> 'running'
FOR UPDATE SKIP LOCKED
)
RETURNING id, workspace_id, title, schedule ->> 'cron' AS cron, $1::timestamptz AS slot",
)
.bind(now)
.fetch_all(pool)
.await
.map_err(|e| format!("claim due missions: {e}"))?;
Ok(rows
.into_iter()
.map(|r| DueMission {
id: r.get("id"),
workspace_id: r.get("workspace_id"),
title: r.get("title"),
cron: r.get("cron"),
slot: r.get("slot"),
})
.collect())
}
/// Record that this occurrence was taken. `false` means another replica (or an
/// earlier attempt) already has it and this one must not launch.
async fn claim_slot(pool: &PgPool, mission_id: Uuid, slot: OffsetDateTime) -> Result<bool, String> {
let inserted = sqlx::query(
"INSERT INTO mission_fires (mission_id, scheduled_at, status)
VALUES ($1, $2, 'claimed')
ON CONFLICT (mission_id, scheduled_at) DO NOTHING",
)
.bind(mission_id)
.bind(slot)
.execute(pool)
.await
.map_err(|e| format!("claim mission fire: {e}"))?;
Ok(inserted.rows_affected() == 1)
}
async fn settle_slot(
pool: &PgPool,
mission_id: Uuid,
slot: OffsetDateTime,
status: &str,
detail: Option<&str>,
) {
if let Err(e) = sqlx::query(
"UPDATE mission_fires SET status = $3, detail = $4, completed_at = now()
WHERE mission_id = $1 AND scheduled_at = $2",
)
.bind(mission_id)
.bind(slot)
.bind(status)
.bind(detail)
.execute(pool)
.await
{
eprintln!("mission_schedule: settling {mission_id} @ {slot} as {status}: {e}");
}
}
/// Compute and persist the next occurrence.
///
/// A cron that will not parse is reported and the mission left un-scheduled
/// rather than skipped in silence — the whole point of this module is that a
/// schedule which does nothing must never look like a schedule that works.
async fn reschedule(pool: &PgPool, m: &DueMission, after: OffsetDateTime) {
let Some(cron) = m.cron.as_deref().map(str::trim).filter(|c| !c.is_empty()) else {
return;
};
match cm_runtime::scheduling::next_occurrence(cron, after) {
Ok(next) => {
if let Err(e) = sqlx::query("UPDATE missions SET next_run_at = $2 WHERE id = $1")
.bind(m.id)
.bind(next)
.execute(pool)
.await
{
eprintln!("mission_schedule: could not set next_run_at for {}: {e}", m.id);
}
}
Err(e) => eprintln!(
"mission_schedule: mission {} ({}) has an unusable cron {cron:?} — it will NOT run \
again until the schedule is corrected: {e}",
m.id, m.title
),
}
}
/// One pass. Returns how many missions were launched.
pub async fn tick(
pool: &PgPool,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
now: OffsetDateTime,
) -> Result<usize, String> {
let due = claim_due(pool, now).await?;
let mut launched = 0usize;
for m in due.iter().take(MAX_LAUNCHES_PER_TICK) {
// Clock first: a launch that fails must not stall the schedule.
reschedule(pool, m, now).await;
if !claim_slot(pool, m.id, m.slot).await? {
continue;
}
// An unattended launch still needs an actor. Missions carry no creator
// column, so the workspace owner stands in — the same identity the
// audit trail already attributes workspace-level action to.
let workspace = cm_domain::WorkspaceId::from(m.workspace_id);
let owner = match cm_db::repo::users::owner_of_workspace(pool, workspace).await {
Ok(u) => u,
Err(e) => {
// `fetch_one`, so "no owner" arrives as RowNotFound rather than
// None. Either way the occurrence is settled `failed` with the
// reason, never dropped quietly.
let why = format!("no owner to launch as: {e}");
eprintln!("mission_schedule: cannot launch {}{why}", m.id);
settle_slot(pool, m.id, m.slot, "failed", Some(&why)).await;
continue;
}
};
match crate::mission_orchestrator::on_launch(
pool,
workspace,
owner,
m.id,
node_hub.clone(),
)
.await
{
Ok(_) => {
if let Err(e) =
missions_repo::set_status(pool, m.id, m.workspace_id, "running").await
{
let why = format!("launched but could not mark running: {e}");
eprintln!("mission_schedule: {}{why}", m.id);
settle_slot(pool, m.id, m.slot, "failed", Some(&why)).await;
continue;
}
settle_slot(pool, m.id, m.slot, "fired", None).await;
launched += 1;
eprintln!(
"mission_schedule: launched {} ({}) for occurrence {}",
m.id, m.title, m.slot
);
}
Err(e) => {
eprintln!("mission_schedule: on_launch failed for {}: {e}", m.id);
settle_slot(pool, m.id, m.slot, "failed", Some(&e)).await;
}
}
}
if due.len() > MAX_LAUNCHES_PER_TICK {
eprintln!(
"mission_schedule: {} due, launched {} this tick (cap {}); the rest stay due",
due.len(),
launched,
MAX_LAUNCHES_PER_TICK
);
}
Ok(launched)
}
/// Spawn the sweep.
pub fn spawn(
pool: PgPool,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
interval: std::time::Duration,
) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// Skip the immediate first tick so a restart loop cannot become a
// launch loop.
ticker.tick().await;
loop {
ticker.tick().await;
let now = OffsetDateTime::now_utc();
match tick(&pool, node_hub.clone(), now).await {
Ok(n) if n > 0 => eprintln!("mission_schedule: launched {n} due mission(s)"),
Ok(_) => {}
Err(e) => eprintln!("mission_schedule: sweep failed: {e}"),
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The cap is what stops a backlog turning into a container stampede. A
/// clock jump or a cron that resolves to "every minute" can leave hundreds
/// of occurrences owed; each mission launch is a container, a checkout and
/// real model spend, so this must stay well below the routine scheduler's
/// 25.
#[test]
fn the_launch_cap_is_conservative() {
assert!(
MAX_LAUNCHES_PER_TICK <= 5,
"a mission firing costs far more than a routine firing"
);
assert!(MAX_LAUNCHES_PER_TICK >= 1, "a cap of zero never launches");
}
/// The claim must never pick up a mission that is already running.
///
/// A daily cron on a mission that takes longer than a day would otherwise
/// stack a second crew on the same workspace — two containers, two vault
/// branches, and a seen-set race. Asserted against the SQL text because the
/// predicate is the whole safety property and it lives only in the query.
#[test]
fn the_claim_skips_missions_that_are_still_running() {
// Re-read the source of the query this module issues.
let src = include_str!("mission_schedule.rs");
let claim = src
.split("pub async fn claim_due")
.nth(1)
.expect("claim_due exists");
let body = &claim[..claim.find("fetch_all").unwrap_or(claim.len())];
assert!(
body.contains("status <> 'running'"),
"claim_due must not relaunch a mission that is mid-flight"
);
assert!(
body.contains("FOR UPDATE SKIP LOCKED"),
"the claim must be atomic or replicas double-launch"
);
assert!(
body.contains("next_run_at <= $1"),
"only occurrences that have come due may be claimed"
);
}
/// The clock advances BEFORE the launch, and the slot is claimed before the
/// launch too. Both orderings matter: reschedule-first means a failing
/// launch cannot stall the schedule; claim-first means a crash mid-launch
/// is retried rather than dropped.
#[test]
fn the_clock_advances_before_the_launch_is_attempted() {
let src = include_str!("mission_schedule.rs");
let tick = src.split("pub async fn tick").nth(1).expect("tick exists");
let resched = tick.find("reschedule(pool, m, now)").expect("reschedules");
let claim = tick.find("claim_slot(pool, m.id, m.slot)").expect("claims");
let launch = tick.find("on_launch(").expect("launches");
assert!(
resched < claim && claim < launch,
"order must be reschedule -> claim -> launch (got {resched}, {claim}, {launch})"
);
}
}