OXYGENHUB

Newest run wins: a proposal's new run supersedes its older ones a9f1c0bf403a

A push run already cancelled the older push runs of its bookmark still
going. Now every run is started through one rule (flow::supersedes): a
push run supersedes older push runs of its bookmark, and a proposal's run
(proposal/N) the older runs of that proposal, so proposal runs no longer
pile up three or four deep. Tag, manual, scheduled and rerun runs supersede
nothing and are never superseded. A proposal's run leaves its source
bookmark's push runs alone: they test another state.

Settings › OxygenFlow › Newest run wins (maintainers, audited as
repo.flow_keep_running) lets superseded runs already running finish,
cancelling only queued ones; migration 0084 on SQLite and Postgres.

Closes #267
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 9b773b2d114e
When
· 2026-10-19 11:34 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
a9f1c0bf403a835fdb6fd6cf2d3e7fead9a773090d5bd86a160110da3a4cd686
Depends on
107eaba52c9d 1870e4fa9808 19bb31ff1f5c 1ac84c62ba53 1edac4b68c6d 3b1bc27fe805 417184c73f4e 42fa95540468 4a68f1064b08 5d90e0e045e2 745d225de344 8aa5ef384ef1 93be9ce4653e a0d4bbfbedd1 a7e6e8e6094c b5668108487f c889911bec12 d23464c0ac60 d3b048b27277 e71583501b1d e8fa53694f68
Depended on by
50928fab5ffc 8501fd5815b9 5588bb3631d2 3b3a51afde59 367f34b8e590 c7067c7bda56 cb7b3cd45dc0 0ee5c3fb32eb b9eb70fb9509
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
22 atoms, 2 tree operations, 11 files

Browse the tree at this change

added crates/hub/migrations-pg/0084_flow_keep_running.sql +5 −0
1 +
-- Newest run wins (oxygenhub#267): a new push or proposal run cancels the
2 +
-- older ones of its bookmark or proposal still live. On, those already
3 +
-- running finish and only the queued ones are cancelled; off (the
4 +
-- default), running ones are cancelled too.
5 +
ALTER TABLE repositories ADD COLUMN flow_keep_running BOOLEAN NOT NULL DEFAULT FALSE;
added crates/hub/migrations/0084_flow_keep_running.sql +5 −0
1 +
-- Newest run wins (oxygenhub#267): a new push or proposal run cancels the
2 +
-- older ones of its bookmark or proposal still live. On, those already
3 +
-- running finish and only the queued ones are cancelled; off (the
4 +
-- default), running ones are cancelled too.
5 +
ALTER TABLE repositories ADD COLUMN flow_keep_running INTEGER NOT NULL DEFAULT 0;
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Newest run wins (#267):** a new push run cancels the older push runs of its bookmark still going, as before, and now a proposal's new run cancels that proposal's older runs too, so `proposal/N` runs no longer pile up three or four deep. Each cancelled job says which run superseded it. Runs started by hand, by a schedule, by a tag or as a re-run are never cancelled this way. Settings › OxygenFlow › Newest run wins lets maintainers keep superseded runs that are already running, cancelling only the queued ones.
7 8
- **Flow on your own runners (#225):** a repository's maintainers can turn OxygenFlow on for it themselves once it (or its owner) has a runner of its own: Settings › OxygenFlow › "on, own runners only". Its jobs then go only to those runners, never to the site's; letting the site's runners take them stays a site administrator's choice.
8 9
9 10
## 0.5.2 (2026-10-15)
…
203 unchanged lines
modified crates/hub/src/db/flow.rs +19 −6
…
9 unchanged lines
10 10
    /// The run's number within its repository, from 1 (migration 0019).
11 11
    #[sqlx(default)]
12 12
    pub number: i64,
13 -
    /// `push`, `manual` or `rerun:N` (migration 0020).
13 +
    /// `push`, `proposal`, `tag:NAME`, `manual`, `schedule` or
14 +
    /// `rerun:N:EVENT` (migration 0020).
14 15
    #[sqlx(default)]
15 16
    pub trigger: String,
16 17
    pub bookmark: String,
…
220 unchanged lines
237 238
    pub heads: &'a str,
238 239
    pub pipeline_hash: &'a str,
239 240
    pub actor: &'a str,
240 -
    /// `push`, `manual` or `rerun:N`.
241 +
    /// `push`, `proposal`, `tag:NAME`, `manual`, `schedule` or
242 +
    /// `rerun:N:EVENT`.
241 243
    pub trigger: &'a str,
242 244
    /// Problems when the pipeline is invalid: the run is recorded in error.
243 245
    pub error: Option<&'a str>,
…
821 unchanged lines
1065 1067
    Ok(out)
1066 1068
}
1067 1069
1068 -
/// The push runs of `bookmark` still going, older than run `number`.
1069 -
pub async fn live_push_runs_before(pool: &Pool, repo_id: i64, bookmark: &str, number: i64) -> sqlx::Result<Vec<Run>> {
1070 +
/// The runs of `bookmark` started by `trigger` still going, older than run
1071 +
/// `number`: those a newer run supersedes (#267). With `running_too`
1072 +
/// false, only those still queued.
1073 +
pub async fn live_runs_before(
1074 +
    pool: &Pool,
1075 +
    repo_id: i64,
1076 +
    bookmark: &str,
1077 +
    trigger: &str,
1078 +
    number: i64,
1079 +
    running_too: bool,
1080 +
) -> sqlx::Result<Vec<Run>> {
1070 1081
    sqlx::query_as(
1071 -
        "SELECT * FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND number < $3 AND trigger = 'push'
1072 -
         AND status IN ('queued', 'running') ORDER BY id",
1082 +
        "SELECT * FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND number < $3 AND trigger = $4
1083 +
         AND (status = 'queued' OR ($5 AND status = 'running')) ORDER BY id",
1073 1084
    )
1074 1085
    .bind(repo_id)
1075 1086
    .bind(bookmark)
1076 1087
    .bind(number)
1088 +
    .bind(trigger)
1089 +
    .bind(running_too)
1077 1090
    .fetch_all(pool)
1078 1091
    .await
1079 1092
}
…
158 unchanged lines
modified crates/hub/src/db/repos.rs +11 −0
…
50 unchanged lines
51 51
    /// Agents' tokens may approve or reject its approval gates (#258).
52 52
    #[sqlx(default)]
53 53
    pub agents_decide_gates: bool,
54 +
    /// Newest run wins (#267): a superseded run already running finishes;
55 +
    /// only queued ones are cancelled. Off by default.
56 +
    #[sqlx(default)]
57 +
    pub flow_keep_running: bool,
54 58
    /// Counts for the tabs; filled by `get` only.
55 59
    #[sqlx(default)]
56 60
    pub open_issues: i64,
…
279 unchanged lines
336 340
/// Whether agents' tokens may decide the repository's approval gates (#258).
337 341
pub async fn set_agents_decide_gates(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
338 342
    sqlx::query("UPDATE repositories SET agents_decide_gates = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?;
343 +
    Ok(())
344 +
}
345 +
346 +
/// Whether a new run leaves the older runs it supersedes that are already
347 +
/// running to finish (#267), cancelling only the queued ones.
348 +
pub async fn set_flow_keep_running(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
349 +
    sqlx::query("UPDATE repositories SET flow_keep_running = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?;
339 350
    Ok(())
340 351
}
341 352
…
39 unchanged lines
modified crates/hub/src/flow.rs +36 −9
…
39 unchanged lines
40 40
    let started = match bookmark.strip_prefix("tags/") {
41 41
        Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await,
42 42
        None => {
43 +
            // It supersedes the push runs still going on the bookmark (#267).
43 44
            let touched = touched_since_last_run(app, row, bookmark, heads).await;
44 -
            let started = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await;
45 -
            if let Ok(Some(n)) = started {
46 -
                supersede(app, row, bookmark, n).await;
47 -
            }
48 -
            started
45 +
            start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await
49 46
        }
50 47
    };
51 48
    if let Err(e) = started {
…
353 unchanged lines
405 402
        ended += 1;
406 403
    }
407 404
    Ok(ended)
405 +
}
406 +
407 +
/// Newest run wins (oxygenhub#267): whether a new run started by `trigger`
408 +
/// cancels the older runs of its bookmark still live.
409 +
///
410 +
/// - A **push** run supersedes the older push runs of its bookmark: they
411 +
///   test states the bookmark has moved past. Diffusion's bumps start push
412 +
///   runs too.
413 +
/// - A **proposal** run (bookmark `proposal/N`, started when the
414 +
///   proposal's target moved, `web::proposals::settle_after_push`)
415 +
///   supersedes the older proposal runs of the same proposal: they test
416 +
///   projected states it no longer has. It leaves the proposal's source
417 +
///   bookmark's push runs alone: those test the source head, a different
418 +
///   state that other proposals or the bookmark's own checks may want.
419 +
/// - **Tag**, **manual** ("Run pipeline" on a bookmark or a change),
420 +
///   **schedule** and **rerun** runs were each asked for in their own
421 +
///   right (a person or a cron), so they supersede nothing and nothing
422 +
///   supersedes them: a rerun of an old push run tests the state it names,
423 +
///   on purpose. They are cancelled by hand.
424 +
///
425 +
/// Only runs of the same trigger on the same bookmark are superseded, so a
426 +
/// push never cancels a rerun or a proposal's run, nor the other way round.
427 +
fn supersedes(trigger: &str) -> bool {
428 +
    matches!(trigger, "push" | "proposal")
408 429
}
409 430
410 -
/// A push makes the push runs still going on its bookmark moot: they are
411 -
/// cancelled, and the new run's scope reaches back past them.
412 -
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, number: i64) {
413 -
    let Ok(older) = fdb::live_push_runs_before(&app.db, row.id, bookmark, number).await else { return };
431 +
/// Cancel what run `number` supersedes ([`supersedes`]): the older runs of
432 +
/// `bookmark` started by the same `trigger` still queued, and those already
433 +
/// running unless the repository keeps them (`flow_keep_running`, Settings
434 +
/// › OxygenFlow). A push run's scope reaches back past them either way
435 +
/// ([`fdb::latest_on`]).
436 +
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, trigger: &str, number: i64) {
437 +
    let Ok(older) = fdb::live_runs_before(&app.db, row.id, bookmark, trigger, number, !row.flow_keep_running).await else { return };
414 438
    for run in older {
415 439
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
416 440
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
…
248 unchanged lines
665 689
        }
666 690
    };
667 691
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
692 +
    if supersedes(trigger) {
693 +
        supersede(app, row, bookmark, trigger, run.number).await;
694 +
    }
668 695
    Ok(Some(run.number))
669 696
}
670 697
…
1895 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
299 unchanged lines
300 300
        .route("/{owner}/{name}/settings/flow", post(repo_settings::flow))
301 301
        .route("/{owner}/{name}/settings/flow-priority", post(repo_settings::flow_priority))
302 302
        .route("/{owner}/{name}/settings/agent-gates", post(repo_settings::agent_gates))
303 +
        .route("/{owner}/{name}/settings/flow-supersede", post(repo_settings::flow_supersede))
303 304
        .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion))
304 305
        .route("/{owner}/{name}/settings/backup", post(repo_settings::backup))
305 306
        .route("/{owner}/{name}/settings/storage", post(repo_settings::storage))
…
493 unchanged lines
modified crates/hub/src/web/repo_settings.rs +36 −0
…
883 unchanged lines
884 884
}
885 885
886 886
#[derive(Deserialize)]
887 +
pub struct SupersedeForm {
888 +
    #[serde(rename = "_csrf")]
889 +
    csrf_token: String,
890 +
    /// `on` when the box is ticked: superseded runs already running are
891 +
    /// cancelled too.
892 +
    #[serde(default)]
893 +
    running: String,
894 +
}
895 +
896 +
/// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether
897 +
/// a new push or proposal run cancels the older runs it supersedes that are
898 +
/// already running, or only those still queued; for maintainers, audited.
899 +
pub async fn flow_supersede(
900 +
    State(app): State<Shared>,
901 +
    viewer: Viewer,
902 +
    Path((owner, name)): Path<(String, String)>,
903 +
    Form(f): Form<SupersedeForm>,
904 +
) -> Result<Page<RepoSettingsPage>, Error> {
905 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
906 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
907 +
    csrf(&viewer, &f.csrf_token, &shell)?;
908 +
    let keep = f.running != "on";
909 +
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
910 +
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
911 +
        .await
912 +
        .ok();
913 +
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
914 +
    let notice = if keep {
915 +
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
916 +
    } else {
917 +
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
918 +
    };
919 +
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
920 +
}
921 +
922 +
#[derive(Deserialize)]
887 923
pub struct PriorityForm {
888 924
    #[serde(rename = "_csrf")]
889 925
    csrf_token: String,
…
250 unchanged lines
modified crates/hub/tests/flow.rs +85 −0
…
5224 unchanged lines
5225 5225
    let list: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap();
5226 5226
    assert!(list[0]["revoked_ms"].is_i64() && list[0].get("token").is_none(), "{body}");
5227 5227
}
5228 +
5229 +
/// oxygenhub#267, newest run wins: a proposal's new run supersedes its
5230 +
/// older runs still live, as a push run does its bookmark's; a repository
5231 +
/// that keeps running runs has only the queued ones cancelled; manual
5232 +
/// runs and reruns supersede nothing and nothing supersedes them.
5233 +
#[tokio::test(flavor = "multi_thread")]
5234 +
async fn newest_run_wins() {
5235 +
    use oxy_core::hash::ChangeHash;
5236 +
    let h = hub().await;
5237 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
5238 +
    create(&h.app, "andy", "newest").await;
5239 +
    let url = format!("{}/andy/newest", h.base);
5240 +
    let work = tempfile::tempdir().unwrap();
5241 +
    let c = client(&url, work.path());
5242 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
5243 +
    write(work.path(), "test.sh", "echo ok\n");
5244 +
    c.commit("Start", &[]).unwrap();
5245 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
5246 +
    let pushed = runs_of(&h.app, "andy", "newest").await[0].clone();
5247 +
    assert_eq!((pushed.number, pushed.trigger.as_str()), (1, "push"));
5248 +
    let heads: Vec<ChangeHash> = pushed.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
5249 +
    let start = |row: db::repos::RepoRow, bookmark: &'static str, trigger: &'static str| {
5250 +
        let (app, state, heads) = (h.app.clone(), pushed.state.clone(), heads.clone());
5251 +
        async move {
5252 +
            oxygenhub::flow::start_run_scoped(&app, &row, bookmark, &state, &heads, "andy", trigger, None).await.unwrap().expect("a run")
5253 +
        }
5254 +
    };
5255 +
    let row = db::repos::get(&h.app.db, "andy", "newest").await.unwrap().unwrap();
5256 +
    let run = |n: i64| {
5257 +
        let (app, id) = (h.app.clone(), row.id);
5258 +
        async move { db::flow::run(&app.db, id, n).await.unwrap().unwrap() }
5259 +
    };
5260 +
    let superseded_by = |n: i64, by: i64| {
5261 +
        let app = h.app.clone();
5262 +
        let run = run(n);
5263 +
        async move {
5264 +
            let r = run.await;
5265 +
            assert_eq!(r.status, "cancelled", "run #{n} is superseded");
5266 +
            let jobs = db::flow::jobs(&app.db, r.id).await.unwrap();
5267 +
            let why = format!("superseded by run #{by}");
5268 +
            assert!(jobs.iter().all(|j| j.status == "cancelled" && j.error == why), "{jobs:?}");
5269 +
        }
5270 +
    };
5271 +
5272 +
    // A proposal's new run supersedes its older one, not another
5273 +
    // proposal's, nor the push run of a bookmark.
5274 +
    assert_eq!(start(row.clone(), "proposal/7", "proposal").await, 2);
5275 +
    assert_eq!(start(row.clone(), "proposal/7", "proposal").await, 3);
5276 +
    superseded_by(2, 3).await;
5277 +
    assert_eq!(start(row.clone(), "proposal/8", "proposal").await, 4);
5278 +
    assert!(run(3).await.live(), "another proposal's run is not superseded");
5279 +
    assert!(run(1).await.live(), "a proposal's run leaves push runs alone");
5280 +
5281 +
    // Manual runs and reruns supersede nothing, and a push supersedes
5282 +
    // only push runs.
5283 +
    assert_eq!(start(row.clone(), "main", "manual").await, 5);
5284 +
    assert_eq!(start(row.clone(), "main", "rerun:1:push").await, 6);
5285 +
    assert!(run(1).await.live(), "a manual run or a rerun supersedes nothing");
5286 +
    assert_eq!(start(row.clone(), "main", "push").await, 7);
5287 +
    superseded_by(1, 7).await;
5288 +
    assert!(run(5).await.live() && run(6).await.live(), "a push leaves manual runs and reruns alone");
5289 +
5290 +
    // Kept running: a superseded run already running finishes; queued
5291 +
    // ones are still cancelled.
5292 +
    db::repos::set_flow_keep_running(&h.app.db, row.id, true).await.unwrap();
5293 +
    let kept = db::repos::get(&h.app.db, "andy", "newest").await.unwrap().unwrap();
5294 +
    assert!(kept.flow_keep_running);
5295 +
    let three = run(3).await;
5296 +
    sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = $1").bind(three.id).execute(&h.app.db).await.unwrap();
5297 +
    sqlx::query("UPDATE flow_jobs SET status = 'running' WHERE run_id = $1 AND name = 'build'")
5298 +
        .bind(three.id)
5299 +
        .execute(&h.app.db)
5300 +
        .await
5301 +
        .unwrap();
5302 +
    assert_eq!(start(kept.clone(), "proposal/7", "proposal").await, 8);
5303 +
    assert_eq!(start(kept.clone(), "proposal/7", "proposal").await, 9);
5304 +
    assert_eq!(run(3).await.status, "running", "a running run is kept");
5305 +
    superseded_by(8, 9).await;
5306 +
    // The default again: running runs are cancelled too.
5307 +
    db::repos::set_flow_keep_running(&h.app.db, row.id, false).await.unwrap();
5308 +
    let row = db::repos::get(&h.app.db, "andy", "newest").await.unwrap().unwrap();
5309 +
    assert_eq!(start(row.clone(), "proposal/7", "proposal").await, 10);
5310 +
    superseded_by(3, 10).await;
5311 +
    superseded_by(9, 10).await;
5312 +
}
modified docs/spec.md +1 −0
…
492 unchanged lines
493 493
494 494
  Runs only ever take committed states already in the repository, never uncommitted work. A run writes no history: its receipts are signed statements about a state (Oxygen spec §14.6). Running a state again adds another statement. The environment hash shows whether it ran in the same environment, and a matching result is independent confirmation. The new run's checks replace the old ones for that state, and the run page says what started it (pushed by, started by, re-run of #N by).
495 495
- **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`.
496 +
- **Newest run wins** (oxygenhub#267). A new run cancels the older runs of the same thing still live, so runs do not pile up behind states that have moved on: a push run cancels the older push runs of its bookmark (Diffusion's bumps start push runs), and a proposal's run (bookmark `proposal/N`, started when its target moves) the older runs of that proposal. Each cancelled job says `superseded by run #N`, and the next push run's scope reaches back past what was cancelled. A proposal's run leaves its source bookmark's push runs alone: they test the source head, another state, whose checks the bookmark and other proposals may want. Tag, manual, scheduled and re-run runs were each asked for in their own right, so they supersede nothing and nothing supersedes them; they are cancelled by hand. A repository's maintainers choose under Settings › OxygenFlow › Newest run wins whether superseded runs already running are cancelled too (the default) or finish, with only the queued ones cancelled (`repositories.flow_keep_running`, audited as `repo.flow_keep_running`).
496 497
- **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it.
497 498
- **Runners** (Admin → Runners, site administrators).
498 499
  - A runner's page has a Settings form (#98): labels an administrator gives it beside those it reports (a job asking for a label matches either), and a job time limit in minutes below the site's, applied when it claims.
…
282 unchanged lines
modified templates/repo_settings.html +7 −0
…
95 unchanged lines
96 96
  </select>
97 97
  <button type="submit">SAVE</button>
98 98
</form>
99 +
<form class="card wide" id="flow-supersede" method="post" action="{{ repo.url }}/settings/flow-supersede">
100 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
101 +
  <h2>Newest run wins</h2>
102 +
  <p>A new push run cancels the older push runs of its bookmark still going, and a proposal's new run its older ones: they test states that have moved on, and each cancelled job says which run superseded it. Runs started by hand, by a schedule, by a tag or as a re-run are never cancelled this way.</p>
103 +
  <label class="check"><input type="checkbox" name="running"{% if !row.flow_keep_running %} checked{% endif %}> Cancel superseded runs already running too (untick to let them finish and cancel only those still queued)</label>
104 +
  <button type="submit">SAVE</button>
105 +
</form>
99 106
<form class="card wide" id="agent-gates" method="post" action="{{ repo.url }}/settings/agent-gates">
100 107
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
101 108
  <h2>Approval gates and agents</h2>
…
163 unchanged lines