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
+33
View File
@@ -447,6 +447,39 @@ pub async fn list_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,
/// newest first. Used by the Live tab to subscribe to per-run SSE.
pub async fn list_runs(