OXYGENHUB

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