OXYGENHUB

Alerts: a failed run tells its pusher and watchers; an erroring runner is drained 4a4400c5812d

oxygenflow#30. earth-1 once failed every job on the rust image for three
hours and nobody was told.

A run that ends failed now tells its pusher and the repository's
watchers, in their inboxes and by email as each chose (flow.failed, from
the oxygenflow account, which cannot sign in), naming the jobs that did
not pass. A runner whose last [flow] drain_after_errors jobs (5; 0 never)
since its state last changed all ended in an infrastructure error (status
error, not a failing test) is set to draining and the site
administrators are told with the last error (flow.runner_drained).
Making it active again starts the count afresh: runners keep when their
state changed (migration 0064).

Tested: the pusher and a watcher are told of a failed run, with its
jobs; five errors in a row drain the runner and tell the administrator
only; made active again, the old errors no longer count.

refs andymccall/oxygenflow#30
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 04:33 UTC
Signature
✔ signed by Andy McCall
Hash
4a4400c5812d3e9fccd65dadec5258ab21a8d8fadacf9c848515360435981738
Depends on
070a7b64b368 42fa95540468 9008bf6c2b8c 93be9ce4653e b6b7e4cf6b24 d23464c0ac60 e8fa53694f68
Depended on by
b1860d6090ec 53a0fb2e4ac6 107eaba52c9d 3b1bc27fe805 ea14c4ec5c3f 5d90e0e045e2 dc59d1b56c3d 1870e4fa9808 f7bcee49bf24 bff9106edcba
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
13 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/hub/migrations/0064_runner_state_ms.sql +4 −0
1 +
-- Alerts (oxygenflow#30): when a runner's state last changed, so the
2 +
-- infrastructure errors that drain it are counted only since an
3 +
-- administrator last made it active.
4 +
ALTER TABLE flow_runners ADD COLUMN state_ms INTEGER NOT NULL DEFAULT 0;
modified crates/hub/src/config.rs +5 −1
…
85 unchanged lines
86 86
    /// `owner/*` or `owner/name`: repositories whose pushes start runs.
87 87
    pub repos: Vec<String>,
88 88
    pub job_timeout_minutes: u64,
89 +
    /// A runner whose last this many jobs all ended in an infrastructure
90 +
    /// error (status `error`, not `failed`) is drained and the
91 +
    /// administrators told (oxygenflow#30); 0 never drains one.
92 +
    pub drain_after_errors: u32,
89 93
}
90 94
91 95
impl Default for Flow {
92 96
    fn default() -> Self {
93 -
        Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60 }
97 +
        Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60, drain_after_errors: 5 }
94 98
    }
95 99
}
96 100
…
304 unchanged lines
modified crates/hub/src/db/runners.rs +24 −1
…
242 unchanged lines
243 243
}
244 244
245 245
pub async fn set_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
246 -
    sqlx::query("UPDATE flow_runners SET state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?;
246 +
    sqlx::query("UPDATE flow_runners SET state = ?, state_ms = ? WHERE id = ?")
247 +
        .bind(state)
248 +
        .bind(super::now_ms())
249 +
        .bind(id)
250 +
        .execute(pool)
251 +
        .await?;
247 252
    Ok(())
253 +
}
254 +
255 +
/// Whether runner `id`'s last `n` jobs since its state last changed all
256 +
/// ended in an infrastructure error (oxygenflow#30), with the last one's.
257 +
pub async fn erroring(pool: &Pool, id: i64, n: u32) -> sqlx::Result<Option<String>> {
258 +
    if n == 0 {
259 +
        return Ok(None);
260 +
    }
261 +
    let last: Vec<(String, String)> = sqlx::query_as(
262 +
        "SELECT j.status, j.error FROM flow_jobs j JOIN flow_runners r ON r.id = j.runner_id
263 +
         WHERE j.runner_id = ? AND j.finished_ms IS NOT NULL AND j.finished_ms >= r.state_ms
264 +
         ORDER BY j.finished_ms DESC, j.id DESC LIMIT ?",
265 +
    )
266 +
    .bind(id)
267 +
    .bind(n as i64)
268 +
    .fetch_all(pool)
269 +
    .await?;
270 +
    Ok((last.len() == n as usize && last.iter().all(|(s, _)| s == "error")).then(|| last[0].1.clone()))
248 271
}
249 272
250 273
/// Forget a runner: its credential stops working. Its jobs keep its name.
…
176 unchanged lines
modified crates/hub/src/flow.rs +84 −0
…
1600 unchanged lines
1601 1601
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
1602 1602
        return Ok(());
1603 1603
    };
1604 +
    let mut errored_on: Vec<i64> = Vec::new();
1604 1605
    for j in changed {
1605 1606
        let summary = match j.status.as_str() {
1606 1607
            "passed" => format!("passed in {}", duration(&j)),
…
3 unchanged lines
1610 1611
            _ => format!("could not run: {}", j.error),
1611 1612
        };
1612 1613
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
1614 +
        if j.status == "error" {
1615 +
            if let Some(r) = runner_of(app, j.id).await {
1616 +
                errored_on.push(r);
1617 +
            }
1618 +
        }
1619 +
    }
1620 +
    errored_on.dedup();
1621 +
    for r in errored_on {
1622 +
        drain_if_erroring(app, r).await;
1623 +
    }
1624 +
    if let Some(done) = fdb::run_by_id(&app.db, run_id).await? {
1625 +
        if done.status == "failed" && !done.live() {
1626 +
            tell_failed(app, &row, &done).await;
1627 +
        }
1613 1628
    }
1614 1629
    // A passing run of an upstream's default bookmark: consumers following
1615 1630
    // it when green are bumped (Diffusion, #148).
…
10 unchanged lines
1626 1641
        }
1627 1642
    }
1628 1643
    Ok(())
1644 +
}
1645 +
1646 +
/// The account OxygenFlow's notices come from, made on first use with no
1647 +
/// way to sign in.
1648 +
pub const ACCOUNT: &str = "oxygenflow";
1649 +
1650 +
async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> {
1651 +
    if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await {
1652 +
        return Some(a);
1653 +
    }
1654 +
    db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok()
1655 +
}
1656 +
1657 +
async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> {
1658 +
    sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = ?").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten()
1659 +
}
1660 +
1661 +
/// A failed run (oxygenflow#30): its pusher and the repository's watchers
1662 +
/// are told, in their inboxes and by email as they chose, naming the jobs
1663 +
/// that did not pass.
1664 +
async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
1665 +
    let Some(me) = flow_account(&app.db).await else { return };
1666 +
    let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default();
1667 +
    let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect();
1668 +
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1669 +
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
1670 +
    crate::notify::deliver(
1671 +
        app,
1672 +
        crate::notify::Event {
1673 +
            kind: "flow.failed",
1674 +
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
1675 +
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
1676 +
            actor: &me,
1677 +
            repo_id: row.id,
1678 +
            thread: None,
1679 +
            mention_text: "",
1680 +
            direct: pusher.into_iter().map(|a| a.id).collect(),
1681 +
        },
1682 +
    )
1683 +
    .await;
1684 +
}
1685 +
1686 +
/// A runner whose last jobs all ended in infrastructure errors is drained,
1687 +
/// so it stops taking jobs it fails before running them, and the
1688 +
/// administrators are told (oxygenflow#30).
1689 +
async fn drain_if_erroring(app: &Shared, runner_id: i64) {
1690 +
    let n = app.config.flow.drain_after_errors;
1691 +
    let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return };
1692 +
    let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return };
1693 +
    if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() {
1694 +
        return;
1695 +
    }
1696 +
    tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name);
1697 +
    let Some(me) = flow_account(&app.db).await else { return };
1698 +
    let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = 1").fetch_all(&app.db).await.unwrap_or_default();
1699 +
    crate::notify::deliver(
1700 +
        app,
1701 +
        crate::notify::Event {
1702 +
            kind: "flow.runner_drained",
1703 +
            title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name),
1704 +
            url: format!("/admin/runners/{runner_id}"),
1705 +
            actor: &me,
1706 +
            repo_id: 0,
1707 +
            thread: None,
1708 +
            mention_text: "",
1709 +
            direct: admins,
1710 +
        },
1711 +
    )
1712 +
    .await;
1629 1713
}
1630 1714
1631 1715
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
…
370 unchanged lines
modified crates/hub/tests/flow.rs +78 −0
…
3992 unchanged lines
3993 3993
        call(&h.app, "POST", &format!("/api/v1/repos/andy/flaky/flow/runs/{}/jobs/deploy/rerun", run.number), Some("site"), None).await;
3994 3994
    assert_eq!(st, StatusCode::CONFLICT, "live again, and no such job either way");
3995 3995
}
3996 +
3997 +
/// oxygenflow#30: a failed run tells its pusher and the repository's
3998 +
/// watchers; a runner whose last five jobs ended in infrastructure errors
3999 +
/// is drained and the administrators told, counting only since an
4000 +
/// administrator last made it active.
4001 +
#[tokio::test(flavor = "multi_thread")]
4002 +
async fn failed_runs_are_told_and_an_erroring_runner_is_drained() {
4003 +
    let h = hub().await;
4004 +
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
4005 +
    let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
4006 +
    create(&h.app, "andy", "alerts").await;
4007 +
    let row = db::repos::get(&h.app.db, "andy", "alerts").await.unwrap().unwrap();
4008 +
    db::notify::set_watch(&h.app.db, bob.id, row.id, true).await.unwrap();
4009 +
    let work = tempfile::tempdir().unwrap();
4010 +
    let c = client(&format!("{}/andy/alerts", h.base), work.path());
4011 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
4012 +
    write(work.path(), "test.sh", "exit 1\n");
4013 +
    c.commit("Add a pipeline", &[]).unwrap();
4014 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
4015 +
    let run = runs_of(&h.app, "andy", "alerts").await[0].clone();
4016 +
    sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = ?").bind(run.id).execute(&h.app.db).await.unwrap();
4017 +
    while let Some(j) = claim(&h.app).await {
4018 +
        let id = j["job_id"].as_i64().unwrap();
4019 +
        let (name, ph) = (j["job"]["name"].as_str().unwrap().to_string(), j["pipeline_hash"].as_str().unwrap().to_string());
4020 +
        let exit = if name == "test" { 1 } else { 0 };
4021 +
        finish(&h.app, id, serde_json::json!({ "exit_code": exit, "duration_ms": 10, "receipt": receipt(&name, &ph, exit as u32) })).await;
4022 +
    }
4023 +
    let told = |id: i64| {
4024 +
        let db = h.app.db.clone();
4025 +
        async move { db::notify::inbox(&db, id, 20).await.unwrap().into_iter().filter(|n| n.kind == "flow.failed").collect::<Vec<_>>() }
4026 +
    };
4027 +
    let (for_andy, for_bob) = (told(andy.id).await, told(bob.id).await);
4028 +
    assert_eq!((for_andy.len(), for_bob.len()), (1, 1), "the pusher and a watcher");
4029 +
    assert!(for_andy[0].title.contains(&format!("run #{} on main failed (test)", run.number)), "{}", for_andy[0].title);
4030 +
    assert_eq!(for_andy[0].url, format!("/andy/alerts/flow/{}", run.number));
4031 +
4032 +
    // Five infrastructure errors in a row drain the runner.
4033 +
    let runner_id = || {
4034 +
        let db = h.app.db.clone();
4035 +
        async move { sqlx::query_scalar::<_, i64>("SELECT id FROM flow_runners WHERE name = 'test-1'").fetch_one(&db).await.unwrap() }
4036 +
    };
4037 +
    let mut errors = 0;
4038 +
    while errors < 5 {
4039 +
        let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/alerts/flow/runs", Some("site"), Some("{}".into())).await;
4040 +
        assert_eq!(st, StatusCode::CREATED);
4041 +
        while let Some(j) = claim(&h.app).await {
4042 +
            let id = j["job_id"].as_i64().unwrap();
4043 +
            let st = finish(
4044 +
                &h.app,
4045 +
                id,
4046 +
                serde_json::json!({ "exit_code": null, "duration_ms": 10, "error": "the microVM did not start: /dev/kvm busy" }),
4047 +
            )
4048 +
            .await;
4049 +
            assert_eq!(st, StatusCode::NO_CONTENT);
4050 +
            errors += 1;
4051 +
            if errors == 5 {
4052 +
                break;
4053 +
            }
4054 +
        }
4055 +
    }
4056 +
    let state = |id: i64| {
4057 +
        let db = h.app.db.clone();
4058 +
        async move { db::runners::get(&db, id).await.unwrap().unwrap().state }
4059 +
    };
4060 +
    let id = runner_id().await;
4061 +
    assert_eq!(state(id).await, "draining");
4062 +
    let drained: Vec<_> =
4063 +
        db::notify::inbox(&h.app.db, andy.id, 50).await.unwrap().into_iter().filter(|n| n.kind == "flow.runner_drained").collect();
4064 +
    assert_eq!(drained.len(), 1, "the administrator is told");
4065 +
    assert!(drained[0].title.contains("/dev/kvm busy"), "{}", drained[0].title);
4066 +
    assert!(
4067 +
        !told(bob.id).await.is_empty()
4068 +
            && db::notify::inbox(&h.app.db, bob.id, 50).await.unwrap().iter().all(|n| n.kind != "flow.runner_drained")
4069 +
    );
4070 +
    // Made active again, past errors no longer count.
4071 +
    db::runners::set_state(&h.app.db, id, "active").await.unwrap();
4072 +
    assert_eq!(db::runners::erroring(&h.app.db, id, 5).await.unwrap(), None);
4073 +
}
modified docs/spec.md +2 −1
…
477 unchanged lines
478 478
479 479
Pipelines run in **OxygenFlow**, a separate project and specification (`oxygenflow/docs/spec.md`; the first slice is `oxygenflow/docs/m0.md`). A pipeline is `.flow/pipeline.kdl` in the state being built; jobs run in Flow's isolation tiers (MicroVM by default, a syscall sandbox, WASM), never in containers on the hub. The hub's side:
480 480
481 -
- **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs by default; empty means none) and `job_timeout_minutes` (60).
481 +
- **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs by default; empty means none) `job_timeout_minutes` (60) and `drain_after_errors` (5, 0 for never: see Alerts).
482 +
- **Alerts** (oxygenflow#30). A run that ends failed tells its pusher and the repository's watchers, in their inboxes and by email as each chose (`flow.failed`, from the `oxygenflow` account, which cannot sign in), naming the jobs that did not pass. A runner whose last `drain_after_errors` jobs since its state last changed all ended in an infrastructure error (status `error`: it could not run them, not a failing test) is set to draining and the site administrators are told (`flow.runner_drained`, with the last error); making it active again on Admin › Runners starts the count afresh (migration 0064, `state_ms`).
482 483
- **Per repository.** A site administrator turns OxygenFlow on or off for one repository under Settings → OxygenFlow, which overrides `repos` in either direction; "site default" follows it again. Runs use the site's runners, so repository administrators see the setting but cannot change it. Changes are audited (`repo.flow`) and apply from the next push, with no restart.
483 484
- **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state, numbered within its repository from 1 (the run's page is `/{owner}/{name}/flow/{number}`): the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs. A run waiting for a runner says where it stands: on the Flow tab its place in line, on its page also the jobs running now and those queued ahead of it (named when their repository is this one or public, otherwise counted), and in the API `queue_position`. Writers also start runs without moving a bookmark: **Run pipeline** runs a committed state without a push:
484 485
  - On the Flow tab and beside each bookmark on the bookmarks page, it runs a bookmark's current state.
…
272 unchanged lines