missions: retry failed phases + auto-purge on re-launch
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:
@@ -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),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user