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 |