OXYGENHUB

Claims say how long a job is expected to take (refs andymccall/oxygenflow#76) 3b65065298b7

expected_ms is the median of the job's last five passing runs in the
repository, reused passes left out, so a runner leaving with notice
finishes a job whose rest fits and hands back the others. Pins
OxygenFlow's main (leave_within, the Claim field).
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 27564b72139a
When
· 2026-10-25 21:34 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
3b65065298b7cb1b82505b4fc6e4240d6d9a099549b32aa96af3db086729d53e
Depends on
380996db84e5 42fa95540468 554ccacfc123 80642fde09cc a7e6e8e6094c ab4df6aae179 b5668108487f cd437c4f5f8d fbd7c8551932
Depended on by
e4203e141e90 8bb7baeb4557
In bookmarks
api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
5 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Claims say how long a job is expected to take (oxygenflow#76):** `expected_ms`, the median of the job's last five passing runs in the repository (reused passes left out), so a runner leaving with notice finishes a job that fits it and hands back the rest. Pins OxygenFlow's main.
7 8
- **The hub's CI writes a test report and coverage (#307):** `test` runs under cargo-nextest and keeps a JUnit report (whether or not the tests pass); a new `coverage` job runs the tests instrumented beside it, with its own cached target, and keeps LCOV coverage. `coverage` is not a required check.
8 9
- **The new proposal form lines up (#315):** as wide as the changes above it; Draft with Claude a secondary button under the editor (it fills the fields, it does not open the proposal); the draft checkbox and Open proposal on one row.
9 10
- **Draft with Claude proposes a title too (#316):** for a proposal not yet opened, the assistant begins its draft with a title in the style of the changes' own titles; it fills the title field unless you wrote one (the page's own suggestion does not count), and stays out of the description.
…
321 unchanged lines
modified crates/hub/src/db/flow.rs +18 −0
…
905 unchanged lines
906 906
    Ok(Some(n))
907 907
}
908 908
909 +
/// How long job `name` of repository `repo_id` is expected to take: the
910 +
/// median of its last five passing runs (claimed to finished), passes
911 +
/// reused from another run left out (oxygenflow#76). `None` before it has
912 +
/// passed: a runner leaving then hands it back rather than guess.
913 +
pub async fn expected_ms(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Option<i64>> {
914 +
    let mut times: Vec<i64> = sqlx::query_scalar(
915 +
        "SELECT j.finished_ms - j.started_ms FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \
916 +
         WHERE r.repo_id = $1 AND j.name = $2 AND j.status = 'passed' AND j.error = '' \
917 +
         AND j.started_ms IS NOT NULL AND j.finished_ms IS NOT NULL ORDER BY j.id DESC LIMIT 5",
918 +
    )
919 +
    .bind(repo_id)
920 +
    .bind(name)
921 +
    .fetch_all(pool)
922 +
    .await?;
923 +
    times.sort_unstable();
924 +
    Ok(times.get(times.len() / 2).copied().filter(|ms| *ms > 0))
925 +
}
926 +
909 927
/// Jobs running on a runner the hub has not heard from since `before`
910 928
/// (claims, heartbeats, logs): their leases lapsed (oxygenflow#76).
911 929
pub async fn running_on_silent_runners(pool: &Pool, before: i64) -> sqlx::Result<Vec<(Job, String)>> {
…
581 unchanged lines
modified crates/hub/src/flow.rs +3 −0
…
1163 unchanged lines
1164 1164
        env: crate::variables::env_for_repo(&app.db, &row).await?,
1165 1165
        secrets: vec![],
1166 1166
        inputs: vec![],
1167 +
        // For a runner that must leave: finish it, or hand it back
1168 +
        // (oxygenflow#76).
1169 +
        expected_ms: fdb::expected_ms(&app.db, row.id, &job.name).await?.map(|ms| ms as u64),
1167 1170
    };
1168 1171
    let mut claim = claim;
1169 1172
    let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await;
…
1763 unchanged lines
modified crates/hub/tests/flow.rs +36 −0
…
2692 unchanged lines
2693 2693
    assert_eq!(claim_as("runner-two", "plex-1").await, Some(low), "another runner takes it");
2694 2694
}
2695 2695
2696 +
/// oxygenflow#76: a claim says how long the job is expected to take, once
2697 +
/// it has passed: the median of its recent passing runs in the repository,
2698 +
/// so a runner leaving with notice can tell whether to finish it.
2699 +
#[tokio::test(flavor = "multi_thread")]
2700 +
async fn a_claim_carries_the_job_s_expected_time() {
2701 +
    let h = hub().await;
2702 +
    create(&h.app, "andy", "eta").await;
2703 +
    let url = format!("{}/andy/eta", h.base);
2704 +
    let work = tempfile::tempdir().unwrap();
2705 +
    let c = client(&url, work.path());
2706 +
    let one = "schema-version 1\nsandbox \"alpine\" {\n    rootfs \"cas://blob/ROOTFS\"\n}\njob \"build\" {\n    sandbox \"alpine\"\n    exec \"make\"\n}\n".replace("ROOTFS", ROOTFS);
2707 +
    write(work.path(), ".flow/pipeline.kdl", &one);
2708 +
    c.commit("One job", &[]).unwrap();
2709 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2710 +
    runs_of(&h.app, "andy", "eta").await;
2711 +
    let first = claim(&h.app).await.expect("a job");
2712 +
    assert!(first.get("expected_ms").is_none(), "never passed: no estimate, {first}");
2713 +
    let id = first["job_id"].as_i64().unwrap();
2714 +
    assert_eq!(finish(&h.app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 700 })).await, StatusCode::NO_CONTENT);
2715 +
    // It took 42 s from its claim.
2716 +
    sqlx::query("UPDATE flow_jobs SET started_ms = finished_ms - 42000 WHERE id = $1").bind(id).execute(&h.app.db).await.unwrap();
2717 +
    write(work.path(), "README", "again\n");
2718 +
    c.commit("Again", &[]).unwrap();
2719 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2720 +
    let mut next = None;
2721 +
    for _ in 0..100 {
2722 +
        if let Some(j) = claim(&h.app).await {
2723 +
            next = Some(j);
2724 +
            break;
2725 +
        }
2726 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2727 +
    }
2728 +
    let next = next.expect("the second run's job");
2729 +
    assert_eq!(next["expected_ms"], 42_000, "{next}");
2730 +
}
2731 +
2696 2732
/// oxygenhub#113: a job's artifact named `coverage` (LCOV) is read when
2697 2733
/// it is uploaded. The job's log says what it found; the Coverage tab,
2698 2734
/// the file view, the badge and the run page show it.
…
3815 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.