OXYGENHUB

Runner updates: a build that rolls back on one runner is held faf5246c900e

When a runner reports it rolled back from flow X, the hub offers X to
no other runner until an administrator lifts the hold, listed with the
runner and the reason on Admin > Runners > Updates. Migration 0097.

closes #326
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e661096694ef
When
· 2026-10-26 04:35 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
faf5246c900e9ff6804832bff4ab4fc8188bad6a7220901e6209658ac70261c8
Depends on
1000b40a2629 3b1bc27fe805 42fa95540468 8501fd5815b9 a7e6e8e6094c b5668108487f b6dfcb61cf5e d23464c0ac60 dc5c64296b0a
Depended on by
1ec29691b67a 9bbca80f777a
In bookmarks
api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
15 atoms, 2 tree operations, 10 files

Browse the tree at this change

added crates/hub/migrations-pg/0097_runner_update_holds.sql +9 −0
1 +
-- Builds held back from runners (oxygenhub#326): one rolled back on a
2 +
-- runner (it did not reach the hub after updating), so no other runner is
3 +
-- offered it until an administrator lifts the hold.
4 +
CREATE TABLE runner_update_holds (
5 +
    version TEXT PRIMARY KEY,
6 +
    runner  TEXT NOT NULL,
7 +
    why     TEXT NOT NULL,
8 +
    at_ms   BIGINT NOT NULL
9 +
);
added crates/hub/migrations/0097_runner_update_holds.sql +9 −0
1 +
-- Builds held back from runners (oxygenhub#326): one rolled back on a
2 +
-- runner (it did not reach the hub after updating), so no other runner is
3 +
-- offered it until an administrator lifts the hold.
4 +
CREATE TABLE runner_update_holds (
5 +
    version TEXT PRIMARY KEY,
6 +
    runner  TEXT NOT NULL,
7 +
    why     TEXT NOT NULL,
8 +
    at_ms   INTEGER NOT NULL
9 +
);
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A build that rolls back on one runner is held for the rest (#326):** when a runner reports it rolled back from flow X (it did not reach the hub after updating, or would not start), the hub offers X to no other runner, saying why, until an administrator lifts the hold on Admin › Runners › Updates, which lists each held build with the runner and the reason. One failed canary halts the rollout. Migration 0097.
7 8
- **Finished work moves to Done (#325):** a card whose issue closes (on its page, through the API, or by a change's `closes #N`), or whose proposal lands or closes, moves to its board's column for finished work. A new board's Done is that column, and so is each existing board's column named Done; a column's menu turns it on or off (one column of a board). Archived cards stay archived.
8 9
- **A WASM job's placement names no image (#323):** a job on the WASM tier was placed saying its runner "fetches the image from the hub"; it has none, so the line now starts with what the runner is doing (andymccall/windows-runner-demo run #1).
9 10
- **Runner updates: policy, test groups and the offer (#322):** Admin › Runners › Updates sets where runner builds come from (a repository whose releases carry `flow-linux-ARCH` files, and `flow-windows-x86_64.exe` for Windows runners) and who takes them. Updates are off unless chosen; the site, an owner, a test group and a runner may each set updates on or off, a channel (`stable`, `canary` or a version), a window of hours (UTC) and dry run, and the nearest setting wins, each setting on its own. A test group is chosen runners or a percentage of the site's (or an owner's) runners, by a stable hash of each runner's id, so a share is the same machines every rollout and 10% sits inside 50%. `GET /api/v1/flow/runner/update` tells a runner its policy, where each setting came from, and the build to install (its BLAKE3, to check the download against); `POST` takes what the runner did, shown on the page and in the audit log. An organisation's owners set the same for their own runners in the organisation's Settings › Runner updates: the organisation's setting, test groups of its runners (chosen, or a percentage), and each runner's own; nothing of the site's or another owner's. Pins OxygenFlow's main.
…
337 unchanged lines
modified crates/hub/src/db/runner_updates.rs +35 −0
…
124 unchanged lines
125 125
        .await?;
126 126
    Ok(())
127 127
}
128 +
129 +
/// A build held back from every runner (#326).
130 +
#[derive(Clone, Debug, PartialEq, Eq, FromRow)]
131 +
pub struct Hold {
132 +
    pub version: String,
133 +
    /// The runner it rolled back on.
134 +
    pub runner: String,
135 +
    pub why: String,
136 +
    pub at_ms: i64,
137 +
}
138 +
139 +
/// Hold `version` back, the first time it rolls back.
140 +
pub async fn hold(pool: &Pool, version: &str, runner: &str, why: &str) -> sqlx::Result<()> {
141 +
    sqlx::query("INSERT INTO runner_update_holds (version, runner, why, at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (version) DO NOTHING")
142 +
        .bind(version)
143 +
        .bind(runner)
144 +
        .bind(why)
145 +
        .bind(now_ms())
146 +
        .execute(pool)
147 +
        .await?;
148 +
    Ok(())
149 +
}
150 +
151 +
pub async fn holds(pool: &Pool) -> sqlx::Result<Vec<Hold>> {
152 +
    sqlx::query_as("SELECT * FROM runner_update_holds ORDER BY at_ms DESC").fetch_all(pool).await
153 +
}
154 +
155 +
pub async fn held(pool: &Pool, version: &str) -> sqlx::Result<Option<Hold>> {
156 +
    sqlx::query_as("SELECT * FROM runner_update_holds WHERE version = $1").bind(version).fetch_optional(pool).await
157 +
}
158 +
159 +
/// Lift the hold on `version`; whether there was one.
160 +
pub async fn lift(pool: &Pool, version: &str) -> sqlx::Result<bool> {
161 +
    Ok(sqlx::query("DELETE FROM runner_update_holds WHERE version = $1").bind(version).execute(pool).await?.rows_affected() > 0)
162 +
}
modified crates/hub/src/flow.rs +6 −0
…
1519 unchanged lines
1520 1520
        return Err(ApiError(StatusCode::BAD_REQUEST, "say what happened (note)".into()));
1521 1521
    }
1522 1522
    db::runner_updates::report(&app.db, me.id, note).await?;
1523 +
    // A build that rolled back here is held back from every runner (#326).
1524 +
    if let Some(v) = crate::runner_updates::rolled_back_version(note) {
1525 +
        let why = note.split_once(": ").map(|(_, w)| w).unwrap_or("rolled back");
1526 +
        db::runner_updates::hold(&app.db, &v, &me.name, why).await?;
1527 +
        db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("rolled back on {}: {why}", me.name)).await.ok();
1528 +
    }
1523 1529
    db::accounts::audit(&app.db, None, "runner.update", &me.name, note).await.ok();
1524 1530
    Ok(StatusCode::NO_CONTENT)
1525 1531
}
…
1518 unchanged lines
modified crates/hub/src/runner_updates.rs +18 −0
…
139 unchanged lines
140 140
    parts(a) < parts(b)
141 141
}
142 142
143 +
/// The version a runner's report says rolled back: `rolled back from flow
144 +
/// 0.4.0 to 0.3.1: …` (the runner's words, oxygenflow spec §11.5.3) → `0.4.0`.
145 +
pub fn rolled_back_version(note: &str) -> Option<String> {
146 +
    let v = note.strip_prefix("rolled back from flow ")?.split_whitespace().next()?;
147 +
    v.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).then(|| v.to_string())
148 +
}
149 +
143 150
/// The release asset a runner on `os` and `arch` runs: `flow-linux-x86_64`,
144 151
/// `flow-windows-x86_64.exe`.
145 152
pub fn asset_for(os: &str, arch: &str) -> String {
…
27 unchanged lines
173 180
    let (want, have) = (version_of(&release.tag), version_of(&r.version));
174 181
    if want == have {
175 182
        return Ok((None, format!("up to date: flow {have} ({} channel)", policy.channel)));
183 +
    }
184 +
    if let Some(h) = rdb::held(pool, &want).await? {
185 +
        return Ok((
186 +
            None,
187 +
            format!("flow {want} is held: it rolled back on {} ({}); lift the hold on Admin › Runners › Updates", h.runner, h.why),
188 +
        ));
176 189
    }
177 190
    let going_back = older(&want, &have);
178 191
    if going_back && matches!(policy.channel.as_str(), "stable" | "canary") {
…
102 unchanged lines
281 294
        assert_eq!(version_of("0.3.1 (local build)"), "0.3.1");
282 295
        assert_eq!(asset_for("linux", "riscv64"), "flow-linux-riscv64");
283 296
        assert_eq!(asset_for("windows", "x86_64"), "flow-windows-x86_64.exe");
297 +
        assert_eq!(
298 +
            rolled_back_version("rolled back from flow 0.4.0 to 0.3.1: 3 starts without reaching the hub").as_deref(),
299 +
            Some("0.4.0")
300 +
        );
301 +
        assert_eq!(rolled_back_version("runs flow 0.4.0 (was 0.3.1); proven"), None);
284 302
    }
285 303
}
modified crates/hub/src/web/mod.rs +1 −0
…
171 unchanged lines
172 172
        .route("/admin/runners/updates/source", post(runner_updates::save_source))
173 173
        .route("/admin/runners/updates/policy", post(runner_updates::save_policy))
174 174
        .route("/admin/runners/updates/groups", post(runner_updates::create_group))
175 +
        .route("/admin/runners/updates/holds/lift", post(runner_updates::lift_hold))
175 176
        .route("/admin/runners/updates/groups/{id}", post(runner_updates::save_group))
176 177
        .route("/admin/runners/updates/groups/{id}/delete", post(runner_updates::delete_group))
177 178
        .route("/admin/insights", get(insights::site))
…
784 unchanged lines
modified crates/hub/src/web/runner_updates.rs +24 −1
…
123 unchanged lines
124 124
    owners: Vec<PolicyView>,
125 125
    groups: Vec<GroupView>,
126 126
    runners: Vec<RunnerView>,
127 +
    /// Builds held back after rolling back on a runner (#326).
128 +
    holds: Vec<rdb::Hold>,
127 129
    notice: Option<String>,
128 130
    error: Option<String>,
129 131
}
…
81 unchanged lines
211 213
    let e = |e| Error::from_db(shell.clone(), e);
212 214
    let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default();
213 215
    let v = views(app, &Reach::Site).await.map_err(e)?;
214 -
    Ok(Page(UpdatesPage { shell, repo, site: v.site, owners: v.owners, groups: v.groups, runners: v.runners, notice, error }))
216 +
    let holds = rdb::holds(&app.db).await.map_err(e)?;
217 +
    Ok(Page(UpdatesPage { shell, repo, site: v.site, owners: v.owners, groups: v.groups, runners: v.runners, holds, notice, error }))
215 218
}
216 219
217 220
/// What a change did, to say on the page: `Ok` a notice, `Err` what was wrong.
…
274 unchanged lines
492 495
    let outcome = apply_delete_group(&app, &me, &Reach::Site, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
493 496
    page_after(&app, shell, outcome).await
494 497
}
498 +
499 +
#[derive(Deserialize)]
500 +
pub struct LiftForm {
501 +
    #[serde(rename = "_csrf")]
502 +
    csrf_token: String,
503 +
    version: String,
504 +
}
505 +
506 +
/// `POST /admin/runners/updates/holds/lift`: offer a held build again (#326).
507 +
pub async fn lift_hold(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LiftForm>) -> Result<Page<UpdatesPage>, Error> {
508 +
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
509 +
    let me = require_admin(&viewer, &shell)?;
510 +
    csrf(&viewer, &f.csrf_token, &shell)?;
511 +
    let v = f.version.trim();
512 +
    if !rdb::lift(&app.db, v).await.map_err(|e| Error::from_db(shell.clone(), e))? {
513 +
        return page(&app, shell, None, Some(format!("flow {v} is not held."))).await;
514 +
    }
515 +
    accounts::audit(&app.db, Some(&me), "runner.update.lifted", v, "offered again").await.ok();
516 +
    page(&app, shell, Some(format!("flow {v} is offered again where the settings say so.")), None).await
517 +
}
modified crates/hub/tests/flow.rs +13 −0
…
6700 unchanged lines
6701 6701
    assert!(page.contains("dry run: flow 0.2.0 checks out") && page.contains(">canary</span>"), "{page}");
6702 6702
    assert!(page.contains("updates on (group canary)") && page.contains("updates on (runner) · channel 0.1.0 (runner)"), "{page}");
6703 6703
6704 +
    // #326: a build that rolls back on one runner is held for every other.
6705 +
    let report = serde_json::json!({ "note": "rolled back from flow 0.2.0 to 0.1.0: 3 starts without reaching the hub" }).to_string();
6706 +
    assert_eq!(call(&h.app, "POST", "/api/v1/flow/runner/update", Some(&creds[0]), Some(report)).await.0, StatusCode::NO_CONTENT);
6707 +
    let a = ask(0).await;
6708 +
    assert!(a.offer.is_none() && a.note.contains("flow 0.2.0 is held: it rolled back on plex (3 starts"), "{a:?}");
6709 +
    let (_, page) = web("GET", "/admin/runners/updates", None).await;
6710 +
    assert!(page.contains("Held builds") && page.contains("<strong>flow 0.2.0</strong>"), "{page}");
6711 +
    let (_, page) = web("POST", "/admin/runners/updates/holds/lift", Some(format!("_csrf={csrf}&version=0.2.0"))).await;
6712 +
    assert!(page.contains("flow 0.2.0 is offered again"), "{page}");
6713 +
    assert!(ask(0).await.note.contains("waits for its window"), "lifted: back to what its settings say");
6714 +
    let (_, page) = web("POST", "/admin/runners/updates/holds/lift", Some(format!("_csrf={csrf}&version=0.2.0"))).await;
6715 +
    assert!(page.contains("flow 0.2.0 is not held."), "{page}");
6716 +
6704 6717
    // A group of a share: removed, its runners follow the levels above.
6705 6718
    let (_, page) =
6706 6719
        web("POST", &format!("/admin/runners/updates/groups/{}", group.id), Some(format!("_csrf={csrf}&mode=percent&percent=100"))).await;
…
130 unchanged lines
modified templates/admin_runner_updates.html +11 −0
…
24 unchanged lines
25 25
  <p class="small mute">Each release offers one file per machine, <code>flow-linux-x86_64</code>, <code>flow-linux-aarch64</code>, <code>flow-linux-riscv64</code>, <code>flow-windows-x86_64.exe</code>. <em>stable</em> is its newest release, <em>canary</em> its newest including pre-releases; a version (<code>0.3.4</code>) pins it, going back if need be.</p>
26 26
  <button type="submit">Save</button>
27 27
</form>
28 +
{% if !holds.is_empty() %}
29 +
<section class="panel strip warn" id="holds">
30 +
  <h2>Held builds</h2>
31 +
  <p class="small">A build that rolled back on a runner is offered to no other until you lift the hold.</p>
32 +
  {% for h in holds %}
33 +
  <form class="pol-form" method="post" action="/admin/runners/updates/holds/lift"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="version" value="{{ h.version }}">
34 +
    <strong>flow {{ h.version }}</strong><span class="small">rolled back on {{ h.runner }}: {{ h.why }} · <time title="{{ h.at_ms|when_i }}">{{ h.at_ms|ago_i }}</time></span><button type="submit" class="small">LIFT</button>
35 +
  </form>
36 +
  {% endfor %}
37 +
</section>
38 +
{% endif %}
28 39
<form class="card wide" method="post" action="/admin/runners/updates/policy">
29 40
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="site">
30 41
  <h2>The site</h2>
…
80 unchanged lines