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.