OXYGENHUB

A push reuses a job's pass on the same state (closes #265) 5588bb3631d2

When main reaches a state a proposal's run already tested, a job that
passed there with the same compiled spec and sandbox is not run again: it
is created passed inside the run's transaction, noted reused from run #N,
with that pass's receipt, and reports its check and the run as any pass
does. Jobs with an approval gate, an environment, secrets or identity
tokens, or whose artifacts another job takes, always run; push runs only.
Settings > Runs turns it off (repositories.flow_reuse, migration 0090).
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2f8e2b706da6
When
· 2026-10-21 00:43 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
5588bb3631d2016d3b5365c4665ded2f18ab9982fc70daf1599091cc90077ddb
Depends on
070a7b64b368 0ee5c3fb32eb 19bb31ff1f5c 1c8276b90314 1edac4b68c6d 2abea30809dd 367f34b8e590 3b1bc27fe805 3b3a51afde59 42fa95540468 44a3d2ebaddd 501d8b268dd4 858eca0d03c4 9041db4e4d92 93be9ce4653e a0d4bbfbedd1 a7e6e8e6094c a9f1c0bf403a b5668108487f c7067c7bda56 d23464c0ac60 d7ce41acf22c de505df5cf8d e97500b70f9a
Depended on by
06b756358f7e 91c306aa4706 42cd46c9b09e 0468394734e9 ec84907043c2
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
26 atoms, 2 tree operations, 13 files

Browse the tree at this change

added crates/hub/migrations-pg/0090_flow_reuse.sql +4 −0
1 +
-- A push run reuses a job's pass on the same state, spec and sandbox
2 +
-- instead of running it again (oxygenhub#265); a maintainer can turn
3 +
-- that off for the repository.
4 +
ALTER TABLE repositories ADD COLUMN flow_reuse BOOLEAN NOT NULL DEFAULT TRUE;
added crates/hub/migrations/0090_flow_reuse.sql +4 −0
1 +
-- A push run reuses a job's pass on the same state, spec and sandbox
2 +
-- instead of running it again (oxygenhub#265); a maintainer can turn
3 +
-- that off for the repository.
4 +
ALTER TABLE repositories ADD COLUMN flow_reuse INTEGER NOT NULL DEFAULT 1;
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A push reuses a job's pass on the same state (#265):** when main reaches a state a proposal's run already tested (landing it), a job that passed there with the same spec and sandbox is not run again: it shows passed, reused from that run, with its receipt, and its check says so. Jobs with an approval gate, an environment, secrets or identity tokens, or whose artifacts another job takes, always run. Maintainers can turn reuse off under Settings › Runs.
7 8
- **Drops are backed up (#280):** an owner's backups now include its drops, each copied like a repository and restored as a drop (secret or public, with its title and description); Admin › Backups counts them with the repositories they cover.
8 9
- **A skip marker quoted in code does not skip:** a change message that mentions `[skip ci]` between backticks or in a fenced block (documenting the marker, say) now starts its run as usual; only a marker in the text itself skips it.
9 10
- **Administration has a sidebar, one page per section (#288):** the admin area was one long page plus separate pages for runners, images, Diffusion, backups and storage. Every page now has the same sidebar, grouped as Overview, People (accounts, registration and invitations, organisations), Sign-in (providers, security contact), Mail, Flow (runners, images, runner settings, insights, variables, secrets), Integrations (AI assistants, Diffusion), Data (storage and maintenance, backups) and Audit, each at its own address (`/admin/mail`, `/admin/audit`, …). `/admin` is an overview of the site's numbers. Forms keep their addresses and answer with their own page, and a kept `/admin#mail` link opens the Mail page.
…
273 unchanged lines
modified crates/hub/src/db/flow.rs +84 −3
…
171 unchanged lines
172 172
}
173 173
174 174
impl Job {
175 +
    /// The run whose pass this job reused (#265), if it did.
176 +
    pub fn reused_from(&self) -> Option<i64> {
177 +
        self.error.strip_prefix("reused from run #")?.parse().ok()
178 +
    }
179 +
175 180
    pub fn depends_on(&self) -> Vec<String> {
176 181
        serde_json::from_str(&self.depends).unwrap_or_default()
177 182
    }
…
21 unchanged lines
199 204
    pub runtime: String,
200 205
    /// The free disk it needs, from [`learned_disk_need`].
201 206
    pub need_disk_mib: Option<i64>,
207 +
    /// A pass on the same state to reuse instead of running it (#265): the
208 +
    /// job is created passed, with that pass's receipt.
209 +
    pub reused: Option<Reused>,
210 +
}
211 +
212 +
/// A job's earlier pass on the same state, spec and sandbox (#265).
213 +
#[derive(Clone, Debug)]
214 +
pub struct Reused {
215 +
    /// The run it passed in.
216 +
    pub run_number: i64,
217 +
    pub receipt: String,
218 +
    pub receipt_hash: String,
219 +
    pub receipt_attest: String,
220 +
    pub receipt_signer: String,
221 +
    pub receipt_note: String,
222 +
}
223 +
224 +
impl Reused {
225 +
    /// What the reused job says, and how [`Job::reused_from`] reads it back.
226 +
    pub fn note(&self) -> String {
227 +
        format!("reused from run #{}", self.run_number)
228 +
    }
229 +
}
230 +
231 +
/// The newest pass of job `name` with exactly this spec and sandbox on
232 +
/// `state` in this repository (#265).
233 +
pub async fn passed_on_state(
234 +
    pool: &Pool,
235 +
    repo_id: i64,
236 +
    state: &str,
237 +
    name: &str,
238 +
    spec: &str,
239 +
    sandbox: &str,
240 +
) -> sqlx::Result<Option<Reused>> {
241 +
    let row: Option<(i64, String, String, String, String, String)> = sqlx::query_as(
242 +
        "SELECT r.number, j.receipt, j.receipt_hash, j.receipt_attest, j.receipt_signer, j.receipt_note \
243 +
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \
244 +
         WHERE r.repo_id = $1 AND r.state = $2 AND j.name = $3 AND j.spec = $4 AND j.sandbox = $5 AND j.status = 'passed' \
245 +
         ORDER BY j.id DESC LIMIT 1",
246 +
    )
247 +
    .bind(repo_id)
248 +
    .bind(state)
249 +
    .bind(name)
250 +
    .bind(spec)
251 +
    .bind(sandbox)
252 +
    .fetch_optional(pool)
253 +
    .await?;
254 +
    Ok(row.map(|(run_number, receipt, receipt_hash, receipt_attest, receipt_signer, receipt_note)| Reused {
255 +
        run_number,
256 +
        receipt,
257 +
        receipt_hash,
258 +
        receipt_attest,
259 +
        receipt_signer,
260 +
        receipt_note,
261 +
    }))
202 262
}
203 263
204 264
/// Runs of a job whose peaks set its next need.
…
95 unchanged lines
300 360
    .fetch_one(&mut *tx)
301 361
    .await?;
302 362
    for j in jobs {
303 -
        sqlx::query(
363 +
        let job_id: i64 = sqlx::query_scalar(
304 364
            "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib, approval, environment, priority, queued_ms) \
305 -
             VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10, $11, $12)",
365 +
             VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10, $11, $12) RETURNING id",
306 366
        )
307 367
        .bind(id)
308 368
        .bind(&j.name)
…
7 unchanged lines
316 376
        .bind(environment_of(&j.spec))
317 377
        .bind(priority_of(&j.spec))
318 378
        .bind(now)
319 -
        .execute(&mut *tx)
379 +
        .fetch_one(&mut *tx)
320 380
        .await?;
381 +
        // A pass reused (#265): done before any runner can claim it.
382 +
        if let Some(r) = &j.reused {
383 +
            sqlx::query(
384 +
                "UPDATE flow_jobs SET status = 'passed', exit_code = 0, error = $1, receipt = $2, receipt_hash = $3, receipt_attest = $4, \
385 +
                 receipt_signer = $5, receipt_note = $6, started_ms = $7, finished_ms = $7 WHERE id = $8",
386 +
            )
387 +
            .bind(r.note())
388 +
            .bind(&r.receipt)
389 +
            .bind(&r.receipt_hash)
390 +
            .bind(&r.receipt_attest)
391 +
            .bind(&r.receipt_signer)
392 +
            .bind(&r.receipt_note)
393 +
            .bind(now)
394 +
            .bind(job_id)
395 +
            .execute(&mut *tx)
396 +
            .await?;
397 +
        }
398 +
    }
399 +
    // Every job reused: the run has passed already.
400 +
    if !jobs.is_empty() && jobs.iter().all(|j| j.reused.is_some()) {
401 +
        sqlx::query("UPDATE flow_runs SET status = 'passed', finished_ms = $1 WHERE id = $2").bind(now).bind(id).execute(&mut *tx).await?;
321 402
    }
322 403
    tx.commit().await?;
323 404
    Ok(id)
…
1010 unchanged lines
modified crates/hub/src/db/repos.rs +9 −0
…
58 unchanged lines
59 59
    /// 0088): `any` bookmark, `others` than the default one, or `never`.
60 60
    #[sqlx(default)]
61 61
    pub flow_skip: String,
62 +
    /// A push run reuses a job's pass on the same state (#265, migration
63 +
    /// 0090). On by default.
64 +
    #[sqlx(default)]
65 +
    pub flow_reuse: bool,
62 66
    /// Pushes that add possible secrets are refused (#268, migration
63 67
    /// 0085). On by default.
64 68
    #[sqlx(default)]
…
378 unchanged lines
443 447
444 448
/// Whether a new run leaves the older runs it supersedes that are already
445 449
/// running to finish (#267), cancelling only the queued ones.
450 +
pub async fn set_flow_reuse(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
451 +
    sqlx::query("UPDATE repositories SET flow_reuse = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?;
452 +
    Ok(())
453 +
}
454 +
446 455
pub async fn set_flow_skip(pool: &Pool, id: i64, skip: &str) -> sqlx::Result<()> {
447 456
    sqlx::query("UPDATE repositories SET flow_skip = $1 WHERE id = $2").bind(skip).bind(id).execute(pool).await?;
448 457
    Ok(())
…
46 unchanged lines
modified crates/hub/src/flow.rs +43 −2
…
59 unchanged lines
60 60
    }
61 61
}
62 62
63 +
/// Whether a job's earlier pass may stand for it on the same state (#265):
64 +
/// not a job that waits for a person, deploys to an environment, holds
65 +
/// secrets or asks for identity tokens, whose running is the point.
66 +
fn reusable(job: &flow_core::Job) -> bool {
67 +
    job.approval.is_none() && job.environment.is_none() && job.secrets.is_empty() && job.identities.is_empty()
68 +
}
69 +
63 70
/// The markers that ask a push not to start a run (#284), as the forges
64 71
/// spell them; matched in any case.
65 72
pub const SKIP_MARKERS: [&str; 3] = ["[skip flow]", "[skip ci]", "[ci skip]"];
…
730 unchanged lines
796 803
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
797 804
                        runtime: sb.runtime.as_str().into(),
798 805
                        need_disk_mib: None,
806 +
                        reused: None,
799 807
                    }
800 808
                })
801 809
                .collect();
…
4 unchanged lines
806 814
            if jobs.is_empty() {
807 815
                // Nothing this event starts (or this push touched).
808 816
                return Ok(None);
817 +
            }
818 +
            // A job that passed on this very state, with the same spec and
819 +
            // sandbox, is not run again by a push (#265): main after a
820 +
            // proposal landed it as tested. Never one that publishes, deploys
821 +
            // or holds secrets, nor one whose artifacts a job of this run
822 +
            // takes; and not where the repository turned reuse off.
823 +
            if trigger == "push" && row.flow_reuse {
824 +
                let consumed: std::collections::HashSet<&str> = p
825 +
                    .order()
826 +
                    .into_iter()
827 +
                    .filter(|j| keep.contains(&j.name))
828 +
                    .flat_map(|j| j.inputs.iter().map(|i| i.from.as_str()))
829 +
                    .collect();
830 +
                for j in &mut jobs {
831 +
                    let Some(spec) = p.jobs.get(&j.name) else { continue };
832 +
                    if !reusable(spec) || consumed.contains(j.name.as_str()) {
833 +
                        continue;
834 +
                    }
835 +
                    j.reused =
836 +
                        fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
837 +
                }
809 838
            }
810 839
            let hash = hex::encode(p.hash());
811 840
            let id =
812 841
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
813 842
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
814 -
                for j in &jobs {
843 +
                for j in jobs.iter().filter(|j| j.reused.is_none()) {
815 844
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
816 845
                }
846 +
                // Reused passes (#265) report as any pass does: their checks,
847 +
                // and a run that passed with them (Diffusion, landing a bump).
848 +
                if jobs.iter().any(|j| j.reused.is_some()) {
849 +
                    let reused: Vec<fdb::Job> =
850 +
                        fdb::jobs(&app.db, run.id).await.unwrap_or_default().into_iter().filter(|j| j.reused_from().is_some()).collect();
851 +
                    if let Err(e) = report_changed(app, run.id, reused).await {
852 +
                        tracing::warn!("flow: reporting reused passes of run #{}: {e}", run.number);
853 +
                    }
854 +
                }
817 855
                images_check(app, row, state, run.number, &jobs).await;
818 856
            }
819 857
            id
…
1335 unchanged lines
2155 2193
    }
2156 2194
    for j in changed {
2157 2195
        let summary = match j.status.as_str() {
2158 -
            "passed" => format!("passed in {}", duration(&j)),
2196 +
            "passed" => match j.reused_from() {
2197 +
                Some(n) => format!("passed in run #{n} on this state, reused"),
2198 +
                None => format!("passed in {}", duration(&j)),
2199 +
            },
2159 2200
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
2160 2201
            "skipped" => "skipped: a dependency did not pass".into(),
2161 2202
            "cancelled" => j.error.clone(),
…
562 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
345 unchanged lines
346 346
        .route("/{owner}/{name}/settings/secret-scan", post(repo_settings::secret_scan))
347 347
        .route("/{owner}/{name}/settings/flow-supersede", post(repo_settings::flow_supersede))
348 348
        .route("/{owner}/{name}/settings/flow-skip", post(repo_settings::flow_skip))
349 +
        .route("/{owner}/{name}/settings/flow-reuse", post(repo_settings::flow_reuse))
349 350
        .route("/{owner}/{name}/settings/diffusion", get(repo_settings::page).post(repo_settings::diffusion))
350 351
        .route("/{owner}/{name}/settings/backup", get(repo_settings::page).post(repo_settings::backup))
351 352
        .route("/{owner}/{name}/settings/storage", get(repo_settings::page).post(repo_settings::storage))
…
550 unchanged lines
modified crates/hub/src/web/repo_settings.rs +32 −0
…
1331 unchanged lines
1332 1332
}
1333 1333
1334 1334
#[derive(Deserialize)]
1335 +
pub struct ReuseForm {
1336 +
    #[serde(rename = "_csrf")]
1337 +
    csrf_token: String,
1338 +
    /// `on` when the box is ticked.
1339 +
    #[serde(default)]
1340 +
    reuse: String,
1341 +
}
1342 +
1343 +
/// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a
1344 +
/// push run reuses a job's pass on the same state; for maintainers,
1345 +
/// audited.
1346 +
pub async fn flow_reuse(
1347 +
    State(app): State<Shared>,
1348 +
    viewer: Viewer,
1349 +
    Path((owner, name)): Path<(String, String)>,
1350 +
    Form(f): Form<ReuseForm>,
1351 +
) -> Result<Response, Error> {
1352 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1353 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1354 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1355 +
    let on = f.reuse == "on";
1356 +
    db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1357 +
    accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok();
1358 +
    let notice = if on {
1359 +
        "A push run now reuses a job's pass on the same state."
1360 +
    } else {
1361 +
        "Every job of every push now runs, passed before or not."
1362 +
    };
1363 +
    back(&row, Section::Runs, Some(notice.into()), None)
1364 +
}
1365 +
1366 +
#[derive(Deserialize)]
1335 1367
pub struct SkipForm {
1336 1368
    #[serde(rename = "_csrf")]
1337 1369
    csrf_token: String,
…
276 unchanged lines
modified crates/hub/tests/flow.rs +63 −0
…
1891 unchanged lines
1892 1892
    assert!(!oxygenhub::flow::skip_allowed(&row, "feature"));
1893 1893
}
1894 1894
1895 +
/// oxygenhub#265: a push run on a state whose jobs already passed reuses
1896 +
/// those passes (same spec and sandbox), except a job behind a gate; a
1897 +
/// repository can turn reuse off.
1898 +
#[tokio::test(flavor = "multi_thread")]
1899 +
async fn a_push_reuses_a_pass_on_the_same_state() {
1900 +
    let h = hub().await;
1901 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
1902 +
    create(&h.app, "andy", "reuse").await;
1903 +
    let url = format!("{}/andy/reuse", h.base);
1904 +
    let work = tempfile::tempdir().unwrap();
1905 +
    let c = client(&url, work.path());
1906 +
    let gated = "job \"deploy\" {\n    sandbox \"alpine\"\n    depends-on \"test\"\n    approval {\n        by \"maintain\"\n    }\n    exec \"true\"\n}\n";
1907 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(gated));
1908 +
    write(work.path(), "test.sh", "echo ok\n");
1909 +
    c.commit("Start", &[]).unwrap();
1910 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1911 +
    let first = runs_of(&h.app, "andy", "reuse").await.remove(0);
1912 +
    // Its jobs pass, the gated one too, each with a receipt.
1913 +
    sqlx::query("UPDATE flow_jobs SET status = 'passed', exit_code = 0, receipt_hash = 'r-' || name WHERE run_id = $1")
1914 +
        .bind(first.id)
1915 +
        .execute(&h.app.db)
1916 +
        .await
1917 +
        .unwrap();
1918 +
    sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = $1").bind(first.id).execute(&h.app.db).await.unwrap();
1919 +
    let heads: Vec<oxy_core::hash::ChangeHash> = first.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
1920 +
    let row = db::repos::get(&h.app.db, "andy", "reuse").await.unwrap().unwrap();
1921 +
    assert!(row.flow_reuse, "on by default");
1922 +
    let again = |row: db::repos::RepoRow| {
1923 +
        let (app, state, heads) = (h.app.clone(), first.state.clone(), heads.clone());
1924 +
        async move {
1925 +
            let n =
1926 +
                oxygenhub::flow::start_run_scoped(&app, &row, "main", &state, &heads, "andy", "push", None).await.unwrap().expect("a run");
1927 +
            let run = db::flow::run(&app.db, row.id, n).await.unwrap().unwrap();
1928 +
            let jobs = db::flow::jobs(&app.db, run.id).await.unwrap();
1929 +
            (run, jobs)
1930 +
        }
1931 +
    };
1932 +
    let (run, jobs) = again(row.clone()).await;
1933 +
    let by = |name: &str| jobs.iter().find(|j| j.name == name).unwrap().clone();
1934 +
    for name in ["build", "test", "lint"] {
1935 +
        let j = by(name);
1936 +
        assert_eq!((j.status.as_str(), j.reused_from()), ("passed", Some(first.number)), "{name}: {j:?}");
1937 +
        assert_eq!(j.receipt_hash, format!("r-{name}"), "the pass's receipt stands for it");
1938 +
    }
1939 +
    let deploy = by("deploy");
1940 +
    assert!(deploy.status == "queued" && deploy.reused_from().is_none(), "a gated job runs again: {deploy:?}");
1941 +
    assert_eq!(run.status, "queued", "the run waits for the gated job");
1942 +
    let (_, page) = call(&h.app, "GET", &format!("/andy/reuse/flow/{}", run.number), None, None).await;
1943 +
    assert!(page.contains("data-reused") && page.contains(&format!("/andy/reuse/flow/{}", first.number)), "{page}");
1944 +
1945 +
    let checks = db::hooks::checks_for_state(&h.app.db, row.id, &first.state).await.unwrap();
1946 +
    assert!(
1947 +
        checks.iter().any(|c| c.name == "flow/build" && c.status == "passed" && c.summary.contains("reused")),
1948 +
        "a reused pass reports its check: {checks:?}"
1949 +
    );
1950 +
1951 +
    // Turned off: every job runs.
1952 +
    db::repos::set_flow_reuse(&h.app.db, row.id, false).await.unwrap();
1953 +
    let row = db::repos::get(&h.app.db, "andy", "reuse").await.unwrap().unwrap();
1954 +
    let (_, jobs) = again(row).await;
1955 +
    assert!(jobs.iter().all(|j| j.status == "queued" && j.reused_from().is_none()), "{jobs:?}");
1956 +
}
1957 +
1895 1958
#[test]
1896 1959
fn skip_markers_are_the_forges_spellings_in_any_case() {
1897 1960
    use oxygenhub::flow::skip_marker;
…
4313 unchanged lines
modified crates/hub/tests/proposals.rs +1 −0
…
805 unchanged lines
806 806
                sandbox: "{}".into(),
807 807
                runtime: "process".into(),
808 808
                need_disk_mib: None,
809 +
                reused: None,
809 810
            };
810 811
            let new =
811 812
                db::flow::NewRun { repo_id, bookmark, state: "s", heads: "h", pipeline_hash: "", actor: "andy", trigger, error: None };
…
295 unchanged lines
modified docs/spec.md +1 −0
…
517 unchanged lines
518 518
  Runs only ever take committed states already in the repository, never uncommitted work. A run writes no history: its receipts are signed statements about a state (Oxygen spec §14.6). Running a state again adds another statement. The environment hash shows whether it ran in the same environment, and a matching result is independent confirmation. The new run's checks replace the old ones for that state, and the run page says what started it (pushed by, started by, re-run of #N by).
519 519
- **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`.
520 520
- **Newest run wins** (oxygenhub#267). A new run cancels the older runs of the same thing still live, so runs do not pile up behind states that have moved on: a push run cancels the older push runs of its bookmark (Diffusion's bumps start push runs), and a proposal's run (bookmark `proposal/N`, started when its target moves) the older runs of that proposal. Each cancelled job says `superseded by run #N`, and the next push run's scope reaches back past what was cancelled. A proposal's run leaves its source bookmark's push runs alone: they test the source head, another state, whose checks the bookmark and other proposals may want. Tag, manual, scheduled and re-run runs were each asked for in their own right, so they supersede nothing and nothing supersedes them; they are cancelled by hand. A repository's maintainers choose under Settings › OxygenFlow › Newest run wins whether superseded runs already running are cancelled too (the default) or finish, with only the queued ones cancelled (`repositories.flow_keep_running`, audited as `repo.flow_keep_running`).
521 +
- **Reusing a pass** (oxygenhub#265). A push run does not run a job again when the same job, with the same compiled spec and sandbox (the image pin included), already passed on the very same state in this repository, as main does after a proposal lands its tested projection: the job is created passed, its `error` note `reused from run #N`, carrying that pass's receipt, hash and attestation, and its check reports `passed in run #N on this state, reused`. A run whose jobs are all reused has passed when created, and reports as any passing run (Diffusion's green bumps, a bump's landing). Never reused: a job with an approval gate, an environment, secrets or identity tokens, or one whose artifacts another job of the run takes as inputs; manual runs, re-runs, schedules and tags always run. Maintainers turn it off under Settings › Runs (`repositories.flow_reuse`, audited as `repo.flow_reuse`).
521 522
- **Pushing without a run** (oxygenhub#284). A push whose newest change (by its clock) has `[skip flow]`, `[skip ci]` or `[ci skip]` in its message, in any case and outside code (a marker between backticks or in a fenced block only mentions one), starts no jobs: the hub records a run with status `skipped`, finished at once, whose note names the marker, the change and who pushed it, so the run list shows the gap. The marker is in a signed message, so who skipped is verifiable. A skipped run is never a later run's baseline: the next push run's scope reaches back past it, so the changes it brought are still tested. A pushed tag always runs. A repository's maintainers choose under Settings › Runs where skipping is allowed: on any bookmark (the default), on any but the default bookmark, or never (`repositories.flow_skip`: `any`, `others`, `never`; audited as `repo.flow_skip`).
522 523
- **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it.
523 524
- **Runners** (Admin → Runners, site administrators).
…
364 unchanged lines
modified templates/flow_run.html +1 −0
…
62 unchanged lines
63 63
  <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span>
64 64
{% endfor %}</pre>
65 65
  {% if !l.j.placed.is_empty() %}<p class="small mute pad">Ran on {{ l.j.runner }}: {{ l.j.placed }}.</p>{% endif %}
66 +
  {% if l.j.status == "passed" %}{% if let Some(n) = l.j.reused_from() %}<p class="small mute pad" data-reused>Not run again: this job, with the same spec and sandbox, passed on this very state in <a href="{{ repo.url }}/flow/{{ n }}">run #{{ n }}</a>, whose log it would repeat; that pass's receipt stands for it here.</p>{% endif %}{% endif %}
66 67
  {% if l.j.status == "queued" && !l.j.waiting.is_empty() %}<p class="small warn pad" data-waiting data-queued-note>Waiting: {{ l.j.waiting }}.</p>{% else if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad" data-queued-note>Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %}
67 68
  {% if l.lines.is_empty() && l.j.status == "skipped" %}<p class="small mute pad">Skipped: a job it depends on did not pass.</p>{% endif %}
68 69
</details>
…
3 unchanged lines
modified templates/repo_settings.html +7 −0
…
162 unchanged lines
163 163
  <label class="check"><input type="checkbox" name="running"{% if !row.flow_keep_running %} checked{% endif %}> Cancel superseded runs already running too (untick to let them finish and cancel only those still queued)</label>
164 164
  <button type="submit">SAVE</button>
165 165
</form>
166 +
<form class="card wide" id="flow-reuse" method="post" action="{{ repo.url }}/settings/flow-reuse">
167 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
168 +
  <h2>Reuse passes</h2>
169 +
  <p>A push run doesn't run a job again when the same job, with the same spec and sandbox, already passed on the very same state, as main does after a proposal lands: the job shows as passed, reused from that run, with its receipt. A job that waits for an approval, deploys to an environment, holds secrets or asks for identity tokens always runs, as does one whose artifacts another job of the run takes.</p>
170 +
  <label class="check"><input type="checkbox" name="reuse"{% if row.flow_reuse %} checked{% endif %}> Reuse a job's pass on the same state (untick to run every job of every push, for flaky or time-dependent tests)</label>
171 +
  <button type="submit">SAVE</button>
172 +
</form>
166 173
<form class="card wide" id="flow-skip" method="post" action="{{ repo.url }}/settings/flow-skip">
167 174
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
168 175
  <h2>Pushing without a run</h2>
…
185 unchanged lines