OXYGENHUB

A push no longer stops the default bookmark's running run (closes #369) 50928fab5ffc

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 9d3ac56764ad
When
· 2026-11-03 22:26 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
50928fab5ffc9cc83dbf663120be66957b234ce9b26b674587fe66b356ca003e
Depends on
19bb31ff1f5c 306aec1839aa 42fa95540468 745d225de344 93be9ce4653e a9f1c0bf403a b5668108487f
Depended on by
0bc95135954d
In bookmarks
main
In tags
v0.9.23 v0.9.24
Edits
10 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 +
- **A push no longer stops main's running run (#369):** a push cancels its bookmark's older push runs, and those already running too unless Settings › OxygenFlow keeps them; on the default bookmark, where releases are cut from the run of their commit, a running push run now always finishes, and only queued ones are cancelled. A release commit's run lost to the next push is what prompted it.
7 8
- **A board's columns from the API and `oh` (#366):** `POST /projects/{owner}/{n}/columns` adds one at the right, `PATCH …/columns/{column}` (by id or name) renames it or moves it to a `position`, and `DELETE` removes it, its cards going to the first remaining column; each answers the board. `oh project column add|rename|move|remove BOARD …` (`move --to N`, counting from 1 at the left, `--left`, `--right`), so a sprint column is renamed without the web page.
8 9
- **`oh run watch` and `oh run view` take the newest run (#367):** with no number they follow or show the newest run, or the newest of `--bookmark NAME`, so the run a push just started needs no lookup.
9 10
- **`oh issue close` and `reopen` say why (#368):** `-b`, `-F` and `--attach` post a comment first, in the same command.
…
451 unchanged lines
modified crates/hub/src/flow.rs +6 −3
…
548 unchanged lines
549 549
/// Cancel what run `number` supersedes ([`supersedes`]): the older runs of
550 550
/// `bookmark` started by the same `trigger` still queued, and those already
551 551
/// running unless the repository keeps them (`flow_keep_running`, Settings
552 -
/// › OxygenFlow). A push run's scope reaches back past them either way
553 -
/// ([`fdb::latest_on`]).
552 +
/// › OxygenFlow) or they are push runs of the default bookmark: a release
553 +
/// is cut from the run of its commit there, so a later push must not stop
554 +
/// it half way (#369). A push run's scope reaches back past them either
555 +
/// way ([`fdb::latest_on`]).
554 556
async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, trigger: &str, number: i64) {
555 -
    let Ok(older) = fdb::live_runs_before(&app.db, row.id, bookmark, trigger, number, !row.flow_keep_running).await else { return };
557 +
    let running_too = !row.flow_keep_running && !(trigger == "push" && bookmark == row.default_bookmark);
558 +
    let Ok(older) = fdb::live_runs_before(&app.db, row.id, bookmark, trigger, number, running_too).await else { return };
556 559
    for run in older {
557 560
        if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await {
558 561
            tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name);
…
2583 unchanged lines
modified crates/hub/tests/flow.rs +22 −0
…
2215 unchanged lines
2216 2216
    let h = hub().await;
2217 2217
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
2218 2218
    create(&h.app, "andy", "stop").await;
2219 +
    // main is not the default bookmark here: the default's running push
2220 +
    // runs always finish (#369), and this is about the others.
2221 +
    let stop = db::repos::get(&h.app.db, "andy", "stop").await.unwrap().unwrap();
2222 +
    db::repos::set_default_bookmark(&h.app.db, stop.id, "trunk").await.unwrap();
2219 2223
    let url = format!("{}/andy/stop", h.base);
2220 2224
    let work = tempfile::tempdir().unwrap();
2221 2225
    let c = client(&url, work.path());
…
4680 unchanged lines
6902 6906
    assert_eq!(start(row.clone(), "proposal/7", "proposal").await, 10);
6903 6907
    superseded_by(3, 10).await;
6904 6908
    superseded_by(9, 10).await;
6909 +
6910 +
    // The default bookmark's push runs already running finish whatever the
6911 +
    // setting (#369): a release is cut from them. Queued ones still go.
6912 +
    assert_eq!(row.default_bookmark, "main");
6913 +
    let eleven = start(row.clone(), "main", "push").await;
6914 +
    superseded_by(7, eleven).await;
6915 +
    let running = run(eleven).await;
6916 +
    sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = $1").bind(running.id).execute(&h.app.db).await.unwrap();
6917 +
    let twelve = start(row.clone(), "main", "push").await;
6918 +
    assert_eq!(run(eleven).await.status, "running", "main's running push run is kept");
6919 +
    let thirteen = start(row.clone(), "main", "push").await;
6920 +
    superseded_by(twelve, thirteen).await;
6921 +
    // Another bookmark's running push run is still superseded.
6922 +
    let other = start(row.clone(), "feature", "push").await;
6923 +
    let r = run(other).await;
6924 +
    sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = $1").bind(r.id).execute(&h.app.db).await.unwrap();
6925 +
    let next = start(row.clone(), "feature", "push").await;
6926 +
    superseded_by(other, next).await;
6905 6927
}
6906 6928
6907 6929
/// oxygenhub#322: runners are offered the build their nearest update
…
660 unchanged lines
modified docs/spec.md +1 −1
…
493 unchanged lines
494 494
495 495
### 12.1 API
496 496
497 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
497 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
498 498
499 499
### 12.2 Webhooks
500 500
…
398 unchanged lines
modified templates/repo_settings.html +1 −1
…
159 unchanged lines
160 160
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
161 161
  <h2>Newest run wins</h2>
162 162
  <p>A new push run cancels the older push runs of its bookmark still going, and a proposal's new run its older ones: they test states that have moved on, and each cancelled job says which run superseded it. Runs started by hand, by a schedule, by a tag or as a re-run are never cancelled this way.</p>
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>
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; push runs of the default bookmark always finish)</label>
164 164
  <button type="submit">SAVE</button>
165 165
</form>
166 166
<form class="card wide" id="flow-reuse" method="post" action="{{ repo.url }}/settings/flow-reuse">
…
194 unchanged lines