feat(cancel): allow cancel from FailedHolding, reboot to local disk
CI / Lint + build + test (push) Successful in 1m38s
Release / release (push) Successful in 6m10s

A held run sits indefinitely at an SSH prompt waiting for operator
investigation. Previously the only exits were Override (re-enter the
failed stage) or leaving the host on forever — Cancel rejected any
terminal state, including FailedHolding, and there was no button in
the UI anyway.

Add a dedicated exit path:
  - statemachine: TriggerOperatorCancelled now accepts FailedHolding
    as a valid source, transitioning to Cancelled like any other
    live state.
  - CancelRun handler: treats FailedHolding as cancellable even
    though IsTerminal reports true.
  - heartbeat: Cancelled runs fork on FailedStage. Set means the
    agent is parked in waitForOverride with no subprocess in
    flight, so cmd=reboot tells it to systemctl reboot; the host
    falls through iPXE's no-active-run script to the local disk.
    Empty FailedStage keeps the pre-existing cmd=cancel_stage path
    for mid-stage cancels (kill stage ctx, then power off).
  - UI: canCancel now returns true for FailedHolding, and the
    run-detail page renders a distinct "Cancel & reboot" button
    with a hold-specific confirm message so the action doesn't
    look identical to a mid-run cancel.

Tests cover the new statemachine transition, the heartbeat fork
(reboot vs cancel_stage), and keep the pre-existing mid-run cancel
behaviour locked in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 22:59:34 -04:00
parent 21014c1268
commit 62bddac110
9 changed files with 287 additions and 149 deletions
+13 -5
View File
@@ -284,12 +284,20 @@ func canStartIfOnline(r *model.Run) bool {
return r.State.IsTerminal()
}
// canCancel is true for any non-terminal run — the Cancel button shows
// whenever the pipeline is live (Queued through the stage states). The
// handler refuses the action once the run enters a terminal state, so
// the render decision just has to mirror that.
// canCancel is true for any non-terminal run, plus FailedHolding —
// a held run technically classifies as terminal for the pipeline but
// the host is still live on the SSH hold prompt, and the operator
// can walk away from it via Cancel (which reboots to local disk).
// Every other terminal state is truly done, so no Cancel button.
// The server-side CancelRun handler mirrors this predicate.
func canCancel(r *model.Run) bool {
return r != nil && !r.State.IsTerminal()
if r == nil {
return false
}
if !r.State.IsTerminal() {
return true
}
return r.State == model.StateFailedHolding
}
func tileStatus(r *model.Run) string {