Skip to content

Commit

A run that is still going offers Cancel run, and not the re-runs, on every page of it. Which buttons a run's page shows is worked out in one place (runControls, lib/run-controls.ts): the run is going while its latest attempt is, as the attempt list's last entry says, whichever attempt the page shows, and on the latest attempt a job that has not finished (waiting for the jobs it needs, held for an environment's approval, queued for a runner, running) keeps it going whatever the run's own status says. So an earlier attempt's page, and its ?job= links, offer Cancel run while a later attempt runs, where they offered nothing, and keep refreshing until it ends; the re-runs show only on the latest attempt of a finished run. A page left open on a finished run asked for nothing and heard nothing when the run was re-run from somewhere else (the API, the MCP tool, another tab): it kept showing the attempt that had ended, with its Re-run buttons and no Cancel run, until a job of the new attempt started or finished. A re-run now tells pages open on the run, as a job starting or finishing does. The run view's status for its current attempt is read by one function (shown_status), with tests that a job queued for a self-hosted runner leaves its run in progress and a job held at an environment makes it wait. The Actions guide says when Cancel run shows.

syntaqxcommitted Parent8e79bb8Browse files
6 files+189−130/6 viewed
+10−2
13741374
13751375 ### Cancelling
13761376
1377−**Cancel run** cancels jobs that have not started at once. A job that is
1378−running is stopped the way GitHub stops one:
1377+**Cancel run** is at the top of the run's page while any job of its latest
1378+attempt has not finished: waiting for the jobs it needs, held for an
1379+environment's approval, queued for a runner (a self-hosted job waits
1380+until a runner with its labels takes it), or running. It is there on an
1381+earlier attempt's page too, and on a job's, and the re-run buttons appear
1382+only once the run has finished. A page left open on a finished run hears
1383+when it is re-run, from anywhere, and offers Cancel run.
1384+
1385+It cancels jobs that have not started at once. A job that is running is
1386+stopped the way GitHub stops one:
13791387
13801388 1. The step it is on gets `SIGINT`, then `SIGTERM` 7.5 seconds later, and
13811389 is killed 2.5 seconds after that. Signals reach the processes the step
+82−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { Job, RunAttempt, WorkflowRun } from "@g1t/contracts";
5+import { runControls } from "./run-controls.ts";
6+
7+type Run = Pick<WorkflowRun, "attempt" | "status" | "conclusion" | "error">;
8+type Attempt = Pick<RunAttempt, "attempt" | "status">;
9+
10+const run = (more: Partial<Run> = {}): Run => ({ attempt: 1, status: "completed", conclusion: "success", error: null, ...more });
11+const jobs = (...statuses: Job["status"][]) => statuses.map((status) => ({ status }));
12+/** Attempts 1 to `latest`, the earlier ones finished, the latest as `status`. */
13+function attempts(latest: number, status: Attempt["status"]): Attempt[] {
14+ return Array.from({ length: latest }, (_, i) => ({ attempt: i + 1, status: i + 1 === latest ? status : "completed" }));
15+}
16+
17+test("a re-run of failed jobs whose job waits for a self-hosted runner can be cancelled, not re-run", () => {
18+ // Attempt 6 of 6: its first job succeeded, the one that needs it is queued.
19+ const controls = runControls({
20+ run: run({ attempt: 6, status: "in_progress", conclusion: null }),
21+ jobs: jobs("completed", "queued"),
22+ attempts: attempts(6, "in_progress"),
23+ member: true,
24+ });
25+ assert.deepEqual(controls, { latest: true, going: true, cancelling: false, canCancel: true, canRerun: false });
26+});
27+
28+test("an earlier attempt's page offers Cancel run while the latest attempt runs", () => {
29+ // `?attempt=5`: attempt 5 as it ended, every job finished.
30+ const controls = runControls({
31+ run: run({ attempt: 5, status: "completed", conclusion: "failure" }),
32+ jobs: jobs("completed", "completed"),
33+ attempts: attempts(6, "in_progress"),
34+ member: true,
35+ });
36+ assert.equal(controls.latest, false);
37+ assert.equal(controls.going, true);
38+ assert.equal(controls.canCancel, true);
39+ assert.equal(controls.canRerun, false);
40+ // How attempt 5 ended says nothing of attempt 6 being cancelled.
41+ assert.equal(runControls({ run: run({ attempt: 5, conclusion: "cancelled" }), jobs: [], attempts: attempts(6, "queued"), member: true }).cancelling, false);
42+});
43+
44+test("a job not finished keeps the latest attempt going, whatever the run's own status says", () => {
45+ for (const status of ["waiting", "pending", "queued", "in_progress", "calling"] as const) {
46+ const controls = runControls({ run: run({ attempt: 2, status: "completed" }), jobs: jobs("completed", status), attempts: attempts(2, "completed"), member: true });
47+ assert.equal(controls.canCancel, true, status);
48+ assert.equal(controls.canRerun, false, status);
49+ }
50+});
51+
52+test("every status a run waits or runs in can be cancelled", () => {
53+ for (const status of ["pending", "action_required", "queued", "in_progress", "waiting"] as const) {
54+ const controls = runControls({ run: run({ status, conclusion: null }), jobs: [], attempts: attempts(1, status), member: true });
55+ assert.equal(controls.canCancel, true, status);
56+ assert.equal(controls.canRerun, false, status);
57+ }
58+});
59+
60+test("a finished run is re-run from its latest attempt only", () => {
61+ const latest = runControls({ run: run({ attempt: 2, conclusion: "failure" }), jobs: jobs("completed"), attempts: attempts(2, "completed"), member: true });
62+ assert.deepEqual(latest, { latest: true, going: false, cancelling: false, canCancel: false, canRerun: true });
63+ const earlier = runControls({ run: run({ attempt: 1, conclusion: "failure" }), jobs: jobs("completed"), attempts: attempts(2, "completed"), member: true });
64+ assert.equal(earlier.canRerun, false);
65+ assert.equal(earlier.canCancel, false);
66+ // A run of one attempt has no list to look through.
67+ assert.equal(runControls({ run: run(), jobs: jobs("completed"), attempts: [], member: true }).canRerun, true);
68+});
69+
70+test("a run being cancelled shows Force cancel", () => {
71+ const controls = runControls({ run: run({ status: "in_progress", conclusion: "cancelled" }), jobs: jobs("in_progress"), attempts: attempts(1, "in_progress"), member: true });
72+ assert.equal(controls.cancelling, true);
73+ assert.equal(controls.canCancel, true);
74+});
75+
76+test("neither without Write, nor for a run whose workflow file could not be used", () => {
77+ const going = { run: run({ status: "in_progress", conclusion: null }), jobs: jobs("queued"), attempts: attempts(1, "in_progress") };
78+ assert.equal(runControls({ ...going, member: false }).canCancel, false);
79+ const broken = runControls({ run: run({ conclusion: "failure", error: "Its file is not YAML." }), jobs: [], attempts: [], member: true });
80+ assert.equal(broken.canCancel, false);
81+ assert.equal(broken.canRerun, false);
82+});
+42−0
1+/**
2+ * Which of a run's buttons its page shows: Cancel run (or Force cancel)
3+ * while any of it is still going, the re-runs once it has finished. Pure;
4+ * the page is routes/repo/actions-run.tsx.
5+ *
6+ * A run is going while its latest attempt is: the attempt list's last
7+ * entry says how it stands now, whichever attempt the page shows, so an
8+ * earlier attempt's page (`?attempt=2`, and its `?job=` links) offers
9+ * Cancel run while a later one runs. On the latest attempt a job that has
10+ * not finished (waiting, pending approval, queued for a runner, running)
11+ * keeps the run going too, whatever the run's own status says.
12+ */
13+import type { Job, RunAttempt, WorkflowRun } from "@g1t/contracts";
14+
15+export type RunControls = {
16+ /** The attempt shown is the run's latest. */
17+ latest: boolean;
18+ /** The run, at its latest attempt, has not finished. */
19+ going: boolean;
20+ /** Cancelled, while its jobs run their cleanup steps: Force cancel in place of Cancel run. */
21+ cancelling: boolean;
22+ canCancel: boolean;
23+ canRerun: boolean;
24+};
25+
26+export function runControls(input: {
27+ run: Pick<WorkflowRun, "attempt" | "status" | "conclusion" | "error">;
28+ jobs: Pick<Job, "status">[];
29+ attempts: Pick<RunAttempt, "attempt" | "status">[];
30+ /** The viewer may cancel and re-run (Write). */
31+ member: boolean;
32+}): RunControls {
33+ const { run, jobs, attempts, member } = input;
34+ const newest = attempts[attempts.length - 1];
35+ const latest = newest == null || run.attempt === newest.attempt;
36+ const going = latest ? run.status !== "completed" || jobs.some((job) => job.status !== "completed") : newest.status !== "completed";
37+ // Only the latest attempt's own status says it is being cancelled; an
38+ // earlier attempt's conclusion is how that attempt ended.
39+ const cancelling = going && latest && run.conclusion === "cancelled";
40+ const allowed = member && !run.error;
41+ return { latest, going, cancelling, canCancel: allowed && going, canRerun: allowed && !going && latest };
42+}
+7−7
5757 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
5858 import { accessTo, refusal } from "../../lib/access.server";
5959 import { useRefreshWhile } from "../../lib/refresh";
60+import { runControls } from "../../lib/run-controls";
6061 import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph";
6162 import { repoPages } from "../../lib/repo-live";
6263 import { useRepoLive } from "../../lib/use-repo-live";
12221223 const { run, jobs, notes } = detail;
12231224 const deployments = detail.pendingDeployments ?? [];
12241225 const attempts = detail.attempts ?? [];
1225− const latest = attempts.length === 0 || run.attempt === attempts[attempts.length - 1]!.attempt;
12261226 const base = `/${params.owner}/${params.repo}`;
12271227 const [search] = useSearchParams();
12281228 // One of the run's buttons is working: the others wait for it.
12291229 const busy = usePending();
1230+ // The attempt shown is still going.
12301231 const live = run.status !== "completed";
1231− // Cancelled, while its jobs run their cleanup steps.
1232− const cancelling = live && run.conclusion === "cancelled";
1233− // Re-running is for the latest attempt of a finished run.
1234− const canRerun = member && !run.error && !live && latest;
1232+ // Cancel run while any of the run is going, from whichever attempt's
1233+ // page; re-running is for the latest attempt of a finished run.
1234+ const { latest, going, cancelling, canCancel, canRerun } = runControls({ run, jobs, attempts, member });
12351235 // A mirror standing by runs nothing; in CI failover it runs them again.
12361236 const rerunBlocked = useWorkflowReason();
12371237 const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`;
12401240 // streams on its own), every few seconds without it. Waiting on a person
12411241 // needs no quick refresh.
12421242 const { live: heard } = useRepoLive({ match: repoPages.run(run.id, run.sha) });
1243− useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500);
1243+ useRefreshWhile(going, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500);
12441244
12451245 // The job asked for; none is the run's summary.
12461246 const selected = jobs.find((job) => job.id === search.get("job")) ?? null;
12751275 </Button>
12761276 </Hint>
12771277 )}
1278− {member && !run.error && live && (
1278+ {canCancel && (
12791279 <Form method="post" className="flex gap-2">
12801280 {cancelling ? (
12811281 <Hint label="Stop its jobs now, without waiting for their cleanup steps">
+5−0
22272227 self.report_pending(&run).await?;
22282228 }
22292229 self.advance(&run.id).await?;
2230+ // A page left open on the attempt that ended, which asks for nothing
2231+ // while its run is over, hears that it is going again: re-run from
2232+ // the API, the MCP tool, a pull request or another tab, as well as
2233+ // from that page, it offers Cancel run and not the re-runs.
2234+ self.tell_live(&run.repo_id, &run.id, Some(&run.sha), run.pull).await;
22302235 self.run_summary(&run.id).await
22312236 }
22322237
+43−4
322322 }));
323323 }
324324 let pending_deployments = self.pending_for(&run, &a.viewer).await?;
325− // Jobs held at an environment's rules: the run waits, as on GitHub.
326− if matches!(summary.status.as_str(), "queued" | "in_progress") && jobs.iter().any(|job| job.status == "pending") {
327− summary.status = "waiting".to_owned();
328− }
325+ summary.status = shown_status(&summary.status, jobs.iter().map(|job| job.status.as_str()));
329326 Ok(Outcome::Ok(RunDetail {
330327 notes: notes(&run.source),
331328 run: summary,
580577
581578 /// The most runs `check_runs` reads for a set of commits.
582579 const CHECK_RUNS_LIMIT: u32 = 200;
580+
581+/// How the run's current attempt stands on its page: as its row says, and
582+/// `waiting`, as on GitHub, while a job is held at an environment's rules.
583+/// A job queued for a runner, or waiting for the jobs it needs, leaves a
584+/// run that has started `in_progress`: it is still going.
585+fn shown_status<'a>(status: &str, mut jobs: impl Iterator<Item = &'a str>) -> String {
586+ if matches!(status, "queued" | "in_progress") && jobs.any(|job| job == "pending") {
587+ "waiting".to_owned()
588+ } else {
589+ status.to_owned()
590+ }
591+}
592+
593+#[cfg(test)]
594+mod tests {
595+ use super::*;
596+
597+ fn shown(status: &str, jobs: &[&str]) -> String {
598+ shown_status(status, jobs.iter().copied())
599+ }
600+
601+ #[test]
602+ fn a_job_queued_for_a_self_hosted_runner_keeps_its_run_going() {
603+ // A re-run of failed jobs: the job that failed now succeeded, and the
604+ // one that needs it waits for a runner with labels no runner has.
605+ assert_eq!(shown("in_progress", &["completed", "queued"]), "in_progress");
606+ assert_eq!(shown("queued", &["waiting", "waiting"]), "queued");
607+ }
608+
609+ #[test]
610+ fn a_job_held_at_an_environment_makes_its_run_wait() {
611+ assert_eq!(shown("in_progress", &["completed", "pending"]), "waiting");
612+ assert_eq!(shown("queued", &["pending"]), "waiting");
613+ }
614+
615+ #[test]
616+ fn a_finished_or_held_run_reads_as_its_row_says() {
617+ assert_eq!(shown("completed", &["completed"]), "completed");
618+ assert_eq!(shown("action_required", &["waiting"]), "action_required");
619+ assert_eq!(shown("pending", &["pending"]), "pending");
620+ }
621+}