Cancel a landed proposal's runs that have not started 2abea30809dd
They can no longer change anything, and they held the queue ahead of the target's own run (refs #263). On landing (web, API, auto-land, a push that holds the work, or the page finding it landed by another route), flow::moot_after_landing cancels the proposal's queued runs: its source bookmark's push runs, when that bookmark is in this repository, and its proposal/N runs, each job saying "moot: proposal #N landed". Runs that have started finish. Test landing_cancels_the_proposals_unstarted_runs.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 148493e01228
- When
- · 2026-10-18 21:49 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
2abea30809ddf37d992b1ab5743b0f2b0c7daa1ddef9ecc1bd93864948816602- Depends on
- 0b20f1ec4007 2a8b621676b4 42fa95540468 736f6813543d 745d225de344 86a69b53aff0 ac713df7b38b c889911bec12 d943160dafeb de505df5cf8d
- Depended on by
- 5588bb3631d2
- In bookmarks
- api-unknown-fields cancel-landed-runs 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
- 6 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/hub/src/db/flow.rs +14 −0
| … | 1093 unchanged lines |
||
| 1094 | 1094 | .await |
|
| 1095 | 1095 | } |
|
| 1096 | 1096 | ||
| 1097 | + | /// The runs on `bookmark` that no runner has started yet: queued, with |
|
| 1098 | + | /// the given trigger, created before `before_ms` (#263). |
|
| 1099 | + | pub async fn unstarted_runs_on(pool: &Pool, repo_id: i64, bookmark: &str, trigger: &str, before_ms: i64) -> sqlx::Result<Vec<Run>> { |
|
| 1100 | + | sqlx::query_as( |
|
| 1101 | + | "SELECT * FROM flow_runs WHERE repo_id = $1 AND bookmark = $2 AND trigger = $3 AND status = 'queued' AND created_ms <= $4 ORDER BY id", |
|
| 1102 | + | ) |
|
| 1103 | + | .bind(repo_id) |
|
| 1104 | + | .bind(bookmark) |
|
| 1105 | + | .bind(trigger) |
|
| 1106 | + | .bind(before_ms) |
|
| 1107 | + | .fetch_all(pool) |
|
| 1108 | + | .await |
|
| 1109 | + | } |
|
| 1110 | + | ||
| 1097 | 1111 | /// The newest run of exactly this state (heads joined with `+`, sorted). |
|
| 1098 | 1112 | pub async fn latest_for_heads(pool: &Pool, repo_id: i64, heads: &str) -> sqlx::Result<Option<Run>> { |
|
| 1099 | 1113 | sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = $1 AND heads = $2 ORDER BY id DESC LIMIT 1") |
|
| … | 181 unchanged lines |
modified crates/hub/src/flow.rs +20 −0
| … | 407 unchanged lines |
||
| 408 | 408 | } |
|
| 409 | 409 | } |
|
| 410 | 410 | ||
| 411 | + | /// A proposal landed (#263): its runs that no runner has started can no |
|
| 412 | + | /// longer change anything, and they hold the queue ahead of the target's |
|
| 413 | + | /// own run, so they are cancelled. Its source bookmark's push runs (when |
|
| 414 | + | /// that bookmark is in this repository and is not the target) and its |
|
| 415 | + | /// `proposal/N` runs; running ones are left to finish. |
|
| 416 | + | pub async fn moot_after_landing(app: &Shared, row: &db::repos::RepoRow, p: &db::proposals::ProposalRow) { |
|
| 417 | + | let now = db::now_ms(); |
|
| 418 | + | let mut moot = Vec::new(); |
|
| 419 | + | let same_repo = p.source_repo_id.is_none_or(|id| id == row.id); |
|
| 420 | + | if p.source_kind == "bookmark" && same_repo && !p.source_bookmark.is_empty() && p.source_bookmark != p.target { |
|
| 421 | + | moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &p.source_bookmark, "push", now).await.unwrap_or_default()); |
|
| 422 | + | } |
|
| 423 | + | moot.extend(fdb::unstarted_runs_on(&app.db, row.id, &format!("proposal/{}", p.number), "proposal", now).await.unwrap_or_default()); |
|
| 424 | + | for run in moot { |
|
| 425 | + | if let Err(e) = cancel_with(app, &run, &format!("moot: proposal #{} landed", p.number)).await { |
|
| 426 | + | tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name); |
|
| 427 | + | } |
|
| 428 | + | } |
|
| 429 | + | } |
|
| 430 | + | ||
| 411 | 431 | /// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`, |
|
| 412 | 432 | /// `schedule`, and `rerun:N:EVENT` repeating EVENT. |
|
| 413 | 433 | fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> { |
|
| … | 2142 unchanged lines |
modified crates/hub/src/web/proposals.rs +3 −0
| … | 542 unchanged lines |
||
| 543 | 543 | if let Ok(l) = load(app, &repo, p).await { |
|
| 544 | 544 | if l.row.state == "landed" && l.row.landed_by.is_empty() { |
|
| 545 | 545 | pdb::set_landed(&app.db, id, &l.target_state, actor, "push").await.ok(); |
|
| 546 | + | crate::flow::moot_after_landing(app, row, &l.row).await; |
|
| 546 | 547 | } else if l.row.is_open() && !l.hashes.is_empty() && crate::flow::enabled(app, row) { |
|
| 547 | 548 | // The target moved, so the proposal's projected state is |
|
| 548 | 549 | // new and its checks would be for an older one (#134): run |
|
| … | 227 unchanged lines |
||
| 776 | 777 | if loaded.landed_already && loaded.row.is_open() { |
|
| 777 | 778 | pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok(); |
|
| 778 | 779 | pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok(); |
|
| 780 | + | crate::flow::moot_after_landing(app, &row, &loaded.row).await; |
|
| 779 | 781 | } |
|
| 780 | 782 | let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone()); |
|
| 781 | 783 | let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 521 unchanged lines |
||
| 1303 | 1305 | }; |
|
| 1304 | 1306 | let state = result.map_err(|e| refused(e.to_string()))?; |
|
| 1305 | 1307 | pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?; |
|
| 1308 | + | crate::flow::moot_after_landing(app, row, &p).await; |
|
| 1306 | 1309 | let how = if v.approvals.is_empty() { format!("{via}, with no approval") } else { via.to_string() }; |
|
| 1307 | 1310 | pdb::add_event(&app.db, p.id, &me.username, "landed", &how).await.ok(); |
|
| 1308 | 1311 | crate::hooks::emit( |
|
| … | 698 unchanged lines |
modified crates/hub/tests/proposals.rs +86 −0
| … | 752 unchanged lines |
||
| 753 | 753 | .any(|a| a.action == "proposal.land_over_checks" && a.detail.contains("the runners are down") && a.detail.contains("flow/test"))); |
|
| 754 | 754 | } |
|
| 755 | 755 | ||
| 756 | + | /// A landed proposal's runs that no runner has started are cancelled |
|
| 757 | + | /// (#263): they hold the queue ahead of the target's own run. A running |
|
| 758 | + | /// one finishes; another bookmark's run is untouched. |
|
| 759 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 760 | + | async fn landing_cancels_the_proposals_unstarted_runs() { |
|
| 761 | + | let h = hub().await; |
|
| 762 | + | let app = &h.app; |
|
| 763 | + | let andy = register(app, "andy").await; |
|
| 764 | + | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 765 | + | app.repos.create(&repo, None).unwrap(); |
|
| 766 | + | db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 767 | + | let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 768 | + | let work = tempfile::tempdir().unwrap(); |
|
| 769 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 770 | + | client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
|
| 771 | + | client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
|
| 772 | + | client.config.set("remote.origin.token", "site").unwrap(); |
|
| 773 | + | client.save_config().unwrap(); |
|
| 774 | + | write(work.path(), "a.txt", b"one\n"); |
|
| 775 | + | client.snapshot("t").unwrap(); |
|
| 776 | + | let base = client.commit("base", &[]).unwrap(); |
|
| 777 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 778 | + | client.bookmark_create("feature", Some(&[base])).unwrap(); |
|
| 779 | + | client.bookmark_switch("feature", false).unwrap(); |
|
| 780 | + | write(work.path(), "b.txt", b"b\n"); |
|
| 781 | + | client.snapshot("t").unwrap(); |
|
| 782 | + | client.commit("the fix", &[]).unwrap(); |
|
| 783 | + | assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
|
| 784 | + | let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; |
|
| 785 | + | let r = post( |
|
| 786 | + | app, |
|
| 787 | + | "/andy/demo/proposals/new", |
|
| 788 | + | Some(&andy), |
|
| 789 | + | &format!("_csrf={}&title=Fix&body=&target=main&source=feature", csrf_of(&preview.body)), |
|
| 790 | + | ) |
|
| 791 | + | .await; |
|
| 792 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 793 | + | ||
| 794 | + | // Runs as the queue holds them: the source's push runs (one queued, |
|
| 795 | + | // one running), the proposal's own, and another bookmark's. |
|
| 796 | + | let run = |bookmark: &'static str, trigger: &'static str| { |
|
| 797 | + | let app = app.clone(); |
|
| 798 | + | let repo_id = row.id; |
|
| 799 | + | async move { |
|
| 800 | + | let job = db::flow::NewJob { |
|
| 801 | + | name: "test".into(), |
|
| 802 | + | depth: 0, |
|
| 803 | + | depends: vec![], |
|
| 804 | + | spec: "{}".into(), |
|
| 805 | + | sandbox: "{}".into(), |
|
| 806 | + | runtime: "process".into(), |
|
| 807 | + | need_disk_mib: None, |
|
| 808 | + | }; |
|
| 809 | + | let new = |
|
| 810 | + | db::flow::NewRun { repo_id, bookmark, state: "s", heads: "h", pipeline_hash: "", actor: "andy", trigger, error: None }; |
|
| 811 | + | db::flow::create_run(&app.db, new, &[job]).await.unwrap() |
|
| 812 | + | } |
|
| 813 | + | }; |
|
| 814 | + | let queued = run("feature", "push").await; |
|
| 815 | + | let running = run("feature", "push").await; |
|
| 816 | + | let own = run("proposal/1", "proposal").await; |
|
| 817 | + | let other = run("other", "push").await; |
|
| 818 | + | sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = $1").bind(running).execute(&app.db).await.unwrap(); |
|
| 819 | + | sqlx::query("UPDATE flow_jobs SET status = 'running' WHERE run_id = $1").bind(running).execute(&app.db).await.unwrap(); |
|
| 820 | + | ||
| 821 | + | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
|
| 822 | + | let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={}", csrf_of(&page.body))).await; |
|
| 823 | + | assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "landed", "{}", r.body); |
|
| 824 | + | ||
| 825 | + | let status = |id: i64| { |
|
| 826 | + | let app = app.clone(); |
|
| 827 | + | async move { |
|
| 828 | + | let run = db::flow::run_by_id(&app.db, id).await.unwrap().unwrap(); |
|
| 829 | + | let jobs = db::flow::jobs(&app.db, id).await.unwrap(); |
|
| 830 | + | (run.status, jobs[0].error.clone()) |
|
| 831 | + | } |
|
| 832 | + | }; |
|
| 833 | + | for id in [queued, own] { |
|
| 834 | + | let (st, why) = status(id).await; |
|
| 835 | + | assert_eq!(st, "cancelled"); |
|
| 836 | + | assert_eq!(why, "moot: proposal #1 landed"); |
|
| 837 | + | } |
|
| 838 | + | assert_eq!(status(running).await.0, "running", "a started run finishes"); |
|
| 839 | + | assert_eq!(status(other).await.0, "queued", "another bookmark's run is untouched"); |
|
| 840 | + | } |
|
| 841 | + | ||
| 756 | 842 | /// Land when checks pass (#261): set by someone who may land, it lands as |
|
| 757 | 843 | /// them once the required check passes on the projected state; a failing |
|
| 758 | 844 | /// check turns it off, with a note; it can be turned off. |
|
| … | 261 unchanged lines |