OXYGENHUB

Expediting a run moves the queue at once (closes #291) 794ff94f194a

Expediting places every queued job again straight away, runs whose jobs'
notes change update live (the placement pass publishes them too), and a
job an expedited run moved down says which run went before it.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0100a7b0faae
When
· 2026-10-24 22:45 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
794ff94f194a4de38bbbd80498747a270cf8ced1f95e4decc09be5cff5fca18a
Depends on
107eaba52c9d 42fa95540468 4d2e44d79b03 58cbf42447bc 67040b158ae2 92a797b20912 a7e6e8e6094c b5668108487f ba0a55bb5b3c
Depended on by
b1860d6090ec 456f4197ade3
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
10 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Expediting a run moves the queue at once (#291):** a run's jobs kept the place they had ("9 of 9 jobs come before") until the next placement pass and some other change on the page. Expediting now places every queued job again straight away, every run whose jobs' notes changed updates live, and a job an expedited run moved down says so ("2 ready jobs come first (after expedited run andy/demo #12)").
7 8
- **Create and withdraw tags on the website (#295, #301):** a writer creates a tag on the Tags page (or `oh tag create`, `POST …/tags`), signed by the hub as their delegate, under the policy, and its tag jobs run; a maintainer withdraws one there (or `oh tag delete`, `DELETE …/tags/{tag}`, or `oxy push --delete tags/NAME`) with a signed withdrawal, never while a release uses it. The withdrawal is logged, audited and listed on the page with why, and clones drop the tag on their next fetch. Tags never move, and a withdrawn name is not used again. Pins oxygen with tag withdrawal and with keyless grants that survive their owner's key rotation (oxygen#135).
8 9
- **An agent's changes verify in a new repository (oxygen#134):** a keyless agent's change in a repository its owner had never pushed to read INVALID ("grant is by an unknown author"). The hub now sends the grant's signer with each certificate, and with oxy's fix the agent keeps it and pushes it beside the certificate, so the repository can check the grant.
9 10
- **A mistyped address shows the not-found page (#294):** an address the hub could not read, such as a project or issue number that is not a number, answered with a raw parse error ("Invalid URL: Cannot parse value …"); it now shows the site's not-found page, signed-in header and all.
…
287 unchanged lines
modified crates/hub/src/flow.rs +36 −2
…
1502 unchanged lines
1503 1503
    declines: &[fdb::DeclineRow],
1504 1504
    running: &std::collections::HashMap<i64, i64>,
1505 1505
    ahead: usize,
1506 +
    behind_urgent: &[String],
1506 1507
) -> String {
1507 1508
    let fleet = Fleet { image_archs: fleet.0, hub_images: fleet.1, now: fleet.2 };
1508 1509
    let mut busy = Vec::new();
…
14 unchanged lines
1523 1524
        parts.push(format!("the runners that can take it are busy: {}", busy.join("; ")));
1524 1525
    }
1525 1526
    if ahead > 0 {
1526 -
        parts.push(format!("{ahead} ready {} first", if ahead == 1 { "job comes" } else { "jobs come" }));
1527 +
        let after = match behind_urgent {
1528 +
            [] => String::new(),
1529 +
            [one] => format!(" (after expedited run {one})"),
1530 +
            many => format!(" (after expedited runs {})", many.join(", ")),
1531 +
        };
1532 +
        parts.push(format!("{ahead} ready {} first{after}", if ahead == 1 { "job comes" } else { "jobs come" }));
1527 1533
    }
1528 1534
    parts.join("; ")
1529 1535
}
…
101 unchanged lines
1631 1637
    let running = fdb::running_by_runner(&app.db).await?;
1632 1638
    // Claim order, roughly: a better rank first, then the older job.
1633 1639
    let order: Vec<(i64, i64)> = ready.iter().map(|(j, _, _)| (ranks.get(&j.id).copied().unwrap_or(2), j.id)).collect();
1640 +
    let run_of: std::collections::HashMap<i64, i64> = ready.iter().map(|(j, _, _)| (j.id, j.run_id)).collect();
1641 +
    // Expedited runs (rank 0) by name, for the jobs they moved down (#291).
1642 +
    let mut urgent: std::collections::BTreeMap<i64, String> = std::collections::BTreeMap::new();
1643 +
    for (j, _, _) in ready.iter().filter(|(j, _, _)| ranks.get(&j.id) == Some(&0)) {
1644 +
        if urgent.contains_key(&j.run_id) {
1645 +
            continue;
1646 +
        }
1647 +
        if let Some(run) = fdb::run_by_id(&app.db, j.run_id).await? {
1648 +
            if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1649 +
                urgent.insert(j.run_id, format!("{}/{} #{}", row.owner, row.name, run.number));
1650 +
            }
1651 +
        }
1652 +
    }
1653 +
    // Runs whose waiting notes changed: their pages hear of it now.
1654 +
    let mut changed = std::collections::BTreeSet::new();
1634 1655
    for (job, owner, repo_id) in ready {
1635 1656
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
1636 1657
        let mut why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1637 1658
        if why.is_empty() {
1638 1659
            let mine = (ranks.get(&job.id).copied().unwrap_or(2), job.id);
1639 1660
            let ahead = order.iter().filter(|o| **o < mine).count();
1640 -
            why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead);
1661 +
            let mut behind: Vec<String> = Vec::new();
1662 +
            if mine.0 > 0 {
1663 +
                for o in order.iter().filter(|o| o.0 == 0 && **o < mine) {
1664 +
                    let Some(r) = run_of.get(&o.1).filter(|r| **r != job.run_id) else { continue };
1665 +
                    if let Some(label) = urgent.get(r).filter(|l| !behind.contains(l)) {
1666 +
                        behind.push(label.clone());
1667 +
                    }
1668 +
                }
1669 +
            }
1670 +
            why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead, &behind);
1641 1671
        }
1642 1672
        if why == job.waiting {
1643 1673
            continue;
1644 1674
        }
1645 1675
        fdb::set_waiting(&app.db, job.id, &why).await?;
1676 +
        changed.insert(job.run_id);
1646 1677
        // The job's check says it too, so a proposal shows why it is stuck
1647 1678
        // (oxygenhub#28); only when the reason changes in kind, not in its
1648 1679
        // counts (#259: a queue position moves every pass, and each check
…
6 unchanged lines
1655 1686
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
1656 1687
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
1657 1688
        }
1689 +
    }
1690 +
    for run_id in changed {
1691 +
        app.bus.live(&crate::events::topic::run(run_id), "jobs");
1658 1692
    }
1659 1693
    Ok(())
1660 1694
}
…
1158 unchanged lines
modified crates/hub/src/web/flow.rs +7 −0
…
289 unchanged lines
290 290
        db::accounts::audit(&app.db, Some(&me.account), "flow.expedite", &format!("{owner}/{name}"), &format!("#{number}: {reason}"))
291 291
            .await
292 292
            .ok();
293 +
        // Every queued job's place changes now, not at the next pass
294 +
        // (#291): this run's page and the pages of the runs it moved down
295 +
        // hear of it.
296 +
        if let Err(e) = crate::flow::explain_waits(&app).await {
297 +
            tracing::warn!("flow: placing after an expedite: {e}");
298 +
        }
299 +
        app.bus.live(&crate::events::topic::run(run.id), "jobs");
293 300
    }
294 301
    Ok(Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response())
295 302
}
…
706 unchanged lines
modified crates/hub/tests/flow.rs +73 −0
…
2875 unchanged lines
2876 2876
    assert!(build.waiting.contains("earth-9 has a free slot (1 of 2) but not the memory or disk for it now"), "{}", build.waiting);
2877 2877
}
2878 2878
2879 +
/// #291: expediting a run places the queue again at once, and a job it
2880 +
/// moved down says which expedited run went before it.
2881 +
#[tokio::test(flavor = "multi_thread")]
2882 +
async fn an_expedited_run_moves_the_queue_at_once() {
2883 +
    let h = hub().await;
2884 +
    for name in ["first", "urgent"] {
2885 +
        create(&h.app, "andy", name).await;
2886 +
        let work = tempfile::tempdir().unwrap();
2887 +
        let c = client(&format!("{}/andy/{name}", h.base), work.path());
2888 +
        write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
2889 +
        write(work.path(), "test.sh", "echo ok\n");
2890 +
        c.commit("Add a pipeline", &[]).unwrap();
2891 +
        assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2892 +
        // In order: the second run is the newer.
2893 +
        assert_eq!(runs_of(&h.app, "andy", name).await.len(), 1);
2894 +
    }
2895 +
    // One runner holding the image, busy with something else.
2896 +
    let r = db::runners::configured(&h.app.db, &blake3::hash(b"busy").to_hex(), "earth-9").await.unwrap();
2897 +
    db::runners::seen(&h.app.db, r.id, None, None).await.unwrap();
2898 +
    db::runners::set_offer(&h.app.db, r.id, &["microvm".to_string()], Some(&[ROOTFS.to_string()]), false, "p").await.unwrap();
2899 +
    let first = runs_of(&h.app, "andy", "first").await.remove(0);
2900 +
    sqlx::query(
2901 +
        "UPDATE flow_jobs SET status = 'running', runner_id = $1, runner = 'earth-9', started_ms = $2 WHERE run_id = $3 AND name = 'lint'",
2902 +
    )
2903 +
    .bind(r.id)
2904 +
    .bind(db::now_ms())
2905 +
    .bind(first.id)
2906 +
    .execute(&h.app.db)
2907 +
    .await
2908 +
    .unwrap();
2909 +
    oxygenhub::flow::explain_waits(&h.app).await.unwrap();
2910 +
    let db = h.app.db.clone();
2911 +
    let build = |run: i64| {
2912 +
        let db = db.clone();
2913 +
        async move { db::flow::jobs(&db, run).await.unwrap().into_iter().find(|j| j.name == "build").unwrap() }
2914 +
    };
2915 +
    let urgent = runs_of(&h.app, "andy", "urgent").await.remove(0);
2916 +
    assert!(build(urgent.id).await.waiting.contains("1 ready job comes first"), "{}", build(urgent.id).await.waiting);
2917 +
2918 +
    // Expedited from its page: the places change before any pass runs.
2919 +
    let res = oxygenhub::router(h.app.clone())
2920 +
        .oneshot(
2921 +
            Request::post("/register")
2922 +
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2923 +
                .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
2924 +
                .unwrap(),
2925 +
        )
2926 +
        .await
2927 +
        .unwrap();
2928 +
    let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string();
2929 +
    let page = oxygenhub::router(h.app.clone())
2930 +
        .oneshot(Request::get("/andy/urgent/flow/1").header(header::COOKIE, cookie.clone()).body(Body::empty()).unwrap())
2931 +
        .await
2932 +
        .unwrap();
2933 +
    let page = String::from_utf8_lossy(&page.into_body().collect().await.unwrap().to_bytes()).into_owned();
2934 +
    let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len();
2935 +
    let csrf = page[i..].split('"').next().unwrap().to_string();
2936 +
    let res = oxygenhub::router(h.app.clone())
2937 +
        .oneshot(
2938 +
            Request::post("/andy/urgent/flow/1/expedite")
2939 +
                .header(header::COOKIE, cookie.clone())
2940 +
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2941 +
                .body(Body::from(format!("_csrf={csrf}&reason=incident")))
2942 +
                .unwrap(),
2943 +
        )
2944 +
        .await
2945 +
        .unwrap();
2946 +
    assert!(res.status().is_redirection(), "{}", res.status());
2947 +
    assert!(!build(urgent.id).await.waiting.contains("come"), "first now: {}", build(urgent.id).await.waiting);
2948 +
    let w = build(first.id).await.waiting;
2949 +
    assert!(w.contains("2 ready jobs come first (after expedited run andy/urgent #1)"), "{w}");
2950 +
}
2951 +
2879 2952
async fn put_bytes(app: &Shared, path: &str, bearer: &str, bytes: Vec<u8>) -> (StatusCode, String) {
2880 2953
    let req = Request::put(path)
2881 2954
        .header(header::AUTHORIZATION, format!("Bearer {bearer}"))
…
3455 unchanged lines