missions: retry failed phases + auto-purge on re-launch
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m30s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m40s

Every re-attempted phase now starts with a clean slate:

  - phase_runner::launch_phase DELETEs prior status IN ('failed',
    'cancelled') topology_runs for the phase before enqueuing the
    new ones. Completed runs are kept for audit; only the failure
    noise from earlier attempts goes.
  - POST /api/missions/{id}/phases/{phase_id}/retry — resets a
    failed/cancelled phase to 'pending' (auth-scoped to the calling
    workspace + guarded on mission.status='running'). phase_runner
    picks it up on the next 10s tick.
  - MissionCanvas phase card grows a coral 'Retry' button, visible
    only when phase.status='failed' and mission.status='running'.
    Click → resets + refreshes; the prior failed run rows disappear
    from the card as soon as phase_runner enqueues the new attempt.

Design: auto-purge in phase_runner rather than a separate 'clear
failed runs' endpoint. Users don't have to manually clean up before
retrying; the runner does it as part of the natural work of firing
a fresh attempt.

Verified: cargo check + tsc + eslint --quiet all green.
This commit is contained in:
Omar Sobh
2026-07-21 13:14:39 -07:00
parent a1d1097b52
commit 94fecb526c
5 changed files with 90 additions and 0 deletions
+4
View File
@@ -461,6 +461,10 @@ pub fn router(state: AppState) -> Router {
patch(routes::missions::set_description), patch(routes::missions::set_description),
) )
.route("/api/missions/{id}/runs", get(routes::missions::list_runs)) .route("/api/missions/{id}/runs", get(routes::missions::list_runs))
.route(
"/api/missions/{id}/phases/{phase_id}/retry",
post(routes::missions::retry_phase),
)
.route( .route(
"/api/missions/{id}/teams", "/api/missions/{id}/teams",
get(routes::missions::list_teams), get(routes::missions::list_teams),
+15
View File
@@ -144,6 +144,21 @@ async fn launch_phase(
let task = phase_task_text(kind, title, description); let task = phase_task_text(kind, title, description);
// Purge prior failed / cancelled runs for this phase so the card
// starts fresh on re-attempts. Completed runs are kept for
// auditability (a mission that succeeded once and got re-run
// still shows both), but the failure noise from earlier attempts
// doesn't clutter the retry.
sqlx::query(
"DELETE FROM topology_runs
WHERE mission_phase_id = $1
AND status IN ('failed', 'cancelled')",
)
.bind(phase_id)
.execute(pool)
.await
.map_err(|e| format!("purge prior failed runs for phase {phase_id}: {e}"))?;
for r in &team_rows { for r in &team_rows {
let team_id: Uuid = r.get("team_id"); let team_id: Uuid = r.get("team_id");
let graph: serde_json::Value = r.get("graph"); let graph: serde_json::Value = r.get("graph");
+33
View File
@@ -447,6 +447,39 @@ pub async fn list_teams(
Ok(Json(serde_json::json!({ "teams": teams }))) Ok(Json(serde_json::json!({ "teams": teams })))
} }
/// POST /api/missions/{id}/phases/{phase_id}/retry — reset a
/// failed / cancelled phase back to 'pending' so the phase_runner
/// picks it up on the next tick. The runner purges old failed
/// topology_runs for the phase before re-enqueuing, so the phase
/// card starts fresh on the retry.
pub async fn retry_phase(
State(state): State<AppState>,
Authed(user): Authed,
Path((id, phase_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Value>, ApiError> {
// Scope check on the mission.
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if mission.status != "running" {
return Err(ApiError::BadRequest);
}
let r = sqlx::query(
"UPDATE mission_phases
SET status = 'pending', started_at = NULL, completed_at = NULL
WHERE id = $1 AND mission_id = $2
AND status IN ('failed', 'cancelled')",
)
.bind(phase_id)
.bind(id)
.execute(&state.pool)
.await?;
if r.rows_affected() == 0 {
return Err(ApiError::NotFound);
}
Ok(Json(serde_json::json!({ "reset": true })))
}
/// GET /api/missions/{id}/runs — topology_runs bound to this mission, /// GET /api/missions/{id}/runs — topology_runs bound to this mission,
/// newest first. Used by the Live tab to subscribe to per-run SSE. /// newest first. Used by the Live tab to subscribe to per-run SSE.
pub async fn list_runs( pub async fn list_runs(
@@ -16,6 +16,7 @@ import {
getMission, getMission,
listMissionRuns, listMissionRuns,
refineMission, refineMission,
retryMissionPhase,
setMissionDescription, setMissionDescription,
setMissionStatus, setMissionStatus,
triggerBenchmark, triggerBenchmark,
@@ -759,6 +760,37 @@ export function MissionCanvas({
})()} })()}
{mission.status === "running" || mission.status === "completed" ? ( {mission.status === "running" || mission.status === "completed" ? (
<div style={{ display: "flex", gap: 6, marginTop: 6 }}> <div style={{ display: "flex", gap: 6, marginTop: 6 }}>
{p.status === "failed" && mission.status === "running" && (
<button
type="button"
onClick={async () => {
if (phaseBusy === `retry:${p.id}`) return;
setPhaseBusy(`retry:${p.id}`);
setError(null);
try {
await retryMissionPhase(mission.id, p.id);
await load();
} catch (e) {
setError(
e instanceof Error ? e.message : "retry failed",
);
} finally {
setPhaseBusy(null);
}
}}
disabled={phaseBusy === `retry:${p.id}`}
title="Reset this phase to pending; prior failed runs are purged. phase_runner picks it up in ≤10s."
style={{
...secondaryBtn,
borderColor: "rgba(255,138,122,.45)",
color: "#ff8a7a",
background: "rgba(255,138,122,.08)",
opacity: phaseBusy === `retry:${p.id}` ? 0.5 : 1,
}}
>
{phaseBusy === `retry:${p.id}` ? "Retrying…" : "Retry"}
</button>
)}
{p.kind === "security_scan" && ( {p.kind === "security_scan" && (
<button <button
type="button" type="button"
+6
View File
@@ -225,6 +225,12 @@ export interface MissionRunSummary {
export const listMissionRuns = (id: string) => export const listMissionRuns = (id: string) =>
api<{ runs: MissionRunSummary[] }>(`/api/missions/${id}/runs`); api<{ runs: MissionRunSummary[] }>(`/api/missions/${id}/runs`);
export const retryMissionPhase = (id: string, phaseId: string) =>
api<{ reset: boolean }>(
`/api/missions/${id}/phases/${phaseId}/retry`,
{ method: "POST" },
);
export interface UpdateMissionMetaRequest { export interface UpdateMissionMetaRequest {
title?: string; title?: string;
description?: string; description?: string;