OXYGENHUB

A landed proposal runs its target's pipeline f7bcee49bf24

Landing moved the target as a push does but started no run, so a
consumer following the repository when green waited for good on a
state that never ran. Every landing (web, API, Diffusion auto-land)
now starts the target's push run. The Diffusion test's runner loop
accepts a job a newer background run cancelled between claim and
finish, which the extra run made more likely under load.

Closes #197
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 15:21 UTC
Signature
✔ signed by Andy McCall
Hash
f7bcee49bf24a1b6aad89b20e0f7ce32d79e1a5bc2efd7ccca0b94ac2a307b44
Depends on
180dc43ad0e0 42fa95540468 4a4400c5812d a7e6e8e6094c acc85ac0f70b d943160dafeb de505df5cf8d
Depended on by
dd0788b756f2 2a8b621676b4
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.1 v0.5.2 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
4 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/hub/src/web/proposals.rs +4 −0
…
1251 unchanged lines
1252 1252
    .ok();
1253 1253
    // The target moved: its other open proposals are checked again (#134).
1254 1254
    settle_after_push(app, row, &p.target, &me.username).await;
1255 +
    // ...and its pipeline runs, as a push of the same heads would start it
1256 +
    // (#197): without the run a consumer following it when green would wait
1257 +
    // for a state that never runs.
1258 +
    crate::flow::on_push(app, row, &p.target, &state, &loaded.heads, &me.username).await;
1255 1259
    // A landing on the default bookmark closes the issues its changes (and
1256 1260
    // its description) say they close, as a push there would.
1257 1261
    if p.target == row.default_bookmark {
…
492 unchanged lines
modified crates/hub/tests/flow.rs +68 −4
…
3311 unchanged lines
3312 3312
            seen.lock().unwrap().push(a.clone());
3313 3313
            let id = a["job_id"].as_i64().unwrap();
3314 3314
            let (name, ph) = (a["job"]["name"].as_str().unwrap().to_string(), a["pipeline_hash"].as_str().unwrap().to_string());
3315 -
            assert_eq!(
3316 -
                finish(h_app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 10, "receipt": receipt(&name, &ph, 0) })).await,
3317 -
                StatusCode::NO_CONTENT
3318 -
            );
3315 +
            let st = finish(h_app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 10, "receipt": receipt(&name, &ph, 0) })).await;
3316 +
            // A run started in the background (a bump, a landing's run, #197)
3317 +
            // may supersede the one this job belongs to between its claim and
3318 +
            // its finish; then, and only then, the hub refuses the finish.
3319 +
            if st == StatusCode::CONFLICT {
3320 +
                let job = db::flow::job(&h_app.db, id).await.unwrap().unwrap();
3321 +
                assert_eq!(job.status, "cancelled", "refused only because a newer run cancelled it");
3322 +
                continue;
3323 +
            }
3324 +
            assert_eq!(st, StatusCode::NO_CONTENT);
3319 3325
        }
3320 3326
    };
3321 3327
    let api_work = tempfile::tempdir().unwrap();
…
748 unchanged lines
4070 4076
    // Made active again, past errors no longer count.
4071 4077
    db::runners::set_state(&h.app.db, id, "active").await.unwrap();
4072 4078
    assert_eq!(db::runners::erroring(&h.app.db, id, 5).await.unwrap(), None);
4079 +
}
4080 +
4081 +
/// #197: landing a proposal moves its target as a push does, so the
4082 +
/// target's pipeline runs on the landed state.
4083 +
#[tokio::test(flavor = "multi_thread")]
4084 +
async fn a_landed_proposal_runs_its_targets_pipeline() {
4085 +
    let h = hub().await;
4086 +
    create(&h.app, "andy", "demo").await;
4087 +
    let url = format!("{}/andy/demo", h.base);
4088 +
    let work = tempfile::tempdir().unwrap();
4089 +
    let mut c = client(&url, work.path());
4090 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
4091 +
    write(work.path(), "test.sh", "echo ok\n");
4092 +
    c.snapshot("test").unwrap();
4093 +
    c.commit("Add a pipeline", &[]).unwrap();
4094 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
4095 +
    assert_eq!(runs_of(&h.app, "andy", "demo").await.len(), 1);
4096 +
    c.bookmark_create("feature", None).unwrap();
4097 +
    c.bookmark_switch("feature", false).unwrap();
4098 +
    write(work.path(), "b.txt", "more\n");
4099 +
    c.snapshot("test").unwrap();
4100 +
    c.commit("More", &[]).unwrap();
4101 +
    assert!(c.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
4102 +
    let runs_before =
4103 +
        db::flow::runs(&h.app.db, db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap().id, 10, None).await.unwrap().len();
4104 +
4105 +
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
4106 +
    let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
4107 +
    db::proposals::create(
4108 +
        &h.app.db,
4109 +
        db::proposals::NewProposal {
4110 +
            repo_id: row.id,
4111 +
            title: "More",
4112 +
            body: "",
4113 +
            author_id: Some(andy.id),
4114 +
            author: "andy",
4115 +
            draft: false,
4116 +
            target: "main",
4117 +
            source_kind: "bookmark",
4118 +
            source_repo_id: Some(row.id),
4119 +
            source_bookmark: "feature",
4120 +
            source_changes: "",
4121 +
        },
4122 +
    )
4123 +
    .await
4124 +
    .unwrap();
4125 +
    let repo = RepoName::parse("andy/demo").unwrap();
4126 +
    let state = oxygenhub::web::proposals::land_as(&h.app, &repo, &row, &andy, 1, "web", false).await.ok().expect("landed");
4127 +
    let mut runs = vec![];
4128 +
    for _ in 0..100 {
4129 +
        runs = db::flow::runs(&h.app.db, row.id, 10, None).await.unwrap();
4130 +
        if runs.len() > runs_before {
4131 +
            break;
4132 +
        }
4133 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4134 +
    }
4135 +
    let landed = runs.iter().find(|r| r.bookmark == "main" && r.state == state).expect("a run of the landed state on main");
4136 +
    assert_eq!((landed.trigger.as_str(), landed.actor.as_str()), ("push", "andy"));
4073 4137
}