Skip to main content

mlua_swarm_server/
tasks.rs

1//! HTTP surface for the Task/Run persistence axis (issue #13 ID-hierarchy
2//! reconciliation: Blueprint → Task → Run → Step → Attempt).
3//!
4//! - `GET  /v1/tasks`          — list every persisted `TaskRecord`, newest first.
5//! - `GET  /v1/tasks/:id`      — a `TaskRecord` plus every `RunRecord` kicked from it.
6//! - `POST /v1/tasks/:id/runs` — re-kick an existing Task: mints a fresh `RunId`,
7//!   re-resolves the stored `blueprint_ref` (refreshing `Blueprint.default_init_ctx`
8//!   exactly like original launch time — issue #19 ST4), 3-layer-merges it with
9//!   `TaskRecord.input_ctx` and an **optional** [`RunKickRequest`] body's
10//!   `init_ctx_override` (see [`merge_init_ctx_3layer`]), dispatches through
11//!   `TaskApplication::handle_with_run`, and returns the new `{task_id, run_id}`
12//!   pair. A body-less request (or one that omits both fields) preserves the
13//!   pre-#19 rekick behavior byte-for-byte.
14//! - `GET  /v1/runs/:id`       — a single `RunRecord` (`step_entries` trace included).
15//! - `GET  /v1/runs/:id/bindings` — requested/effective binding explain from
16//!   the immutable launch snapshot (never from the current Blueprint).
17//! - `POST /v1/runs/:id/resume` — resume an `Interrupted` Run under the SAME
18//!   `run_id` (replay cursor + stored launch-input snapshot).
19//! - `POST /v1/runs/:id/rerun-from` — GH #71 Layer A. Rerun a terminal Run
20//!   (`Done` / `Failed` / `Interrupted`) from a caller-specified step under
21//!   the SAME `run_id`; physically truncates the replay log at the cut
22//!   point so re-dispatch does not collide with the pre-rerun rows. See
23//!   [`run_rerun_from`] for the full contract + Known Limitations.
24//!
25//! `POST /v1/tasks` itself (the flow-eval entry point, `tasks_start` /
26//! `run_flow_form`) stays in `crate::lib` — it is the pre-existing
27//! Operator-inject-aware dispatch path this module's handlers re-kick
28//! through, not a new one. This module owns the read/list/re-kick surface
29//! plus the [`finalize_run`] persistence helper both paths share.
30//!
31//! Authorization follows the same convention as the existing `POST /v1/tasks`
32//! entry: no `Authorization` header is required (the route is open), and the
33//! only Operator-session correlation available is the request-body-level
34//! `operator_sid` (see `crate::TaskLaunchRequest` doc) — this module invents no
35//! new auth mechanism.
36
37use axum::{
38    extract::{Path, Query, State},
39    http::StatusCode,
40    Json,
41};
42use futures_util::FutureExt;
43use mlua_swarm::application::{
44    BlueprintRef, TaskApplicationError, TaskApplicationInput, TaskApplicationOutput,
45};
46use mlua_swarm::blueprint::{BindRequest, BindingAttestation, BoundAgent};
47use mlua_swarm::core::config::CheckPolicy;
48use mlua_swarm::service::merge_init_ctx_3layer;
49use mlua_swarm::service::TaskLaunchError;
50use mlua_swarm::store::replay::ReplayCursor;
51use mlua_swarm::store::run::{
52    RunContext, RunListFilter, RunRecord, RunStatus, RunStoreError, SnapshotOrigin, StepEntry,
53};
54use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
55use mlua_swarm::store::trace::{kind as trace_kind, TraceEvent, TraceHandle, TraceQuery};
56use mlua_swarm::{
57    validate_bound_agent_snapshots, OperatorKind, Role, RunId, TaskId, TaskInputSpec,
58};
59use serde::{Deserialize, Serialize};
60use serde_json::{json, Value};
61use std::collections::HashMap;
62use std::panic::AssertUnwindSafe;
63use std::sync::{Arc, Mutex};
64use std::time::Duration;
65
66use crate::{ApiError, AppState};
67
68/// Current Unix time in whole seconds. `TaskRecord` / `RunRecord` timestamps
69/// are `u64` seconds (not milliseconds) — see their field docs in
70/// `mlua_swarm::store::task` / `mlua_swarm::store::run`.
71pub(crate) fn now_secs() -> u64 {
72    std::time::SystemTime::now()
73        .duration_since(std::time::UNIX_EPOCH)
74        .map(|d| d.as_secs())
75        .unwrap_or(0)
76}
77
78/// Serializable mirror of [`TaskApplicationInput`] — the launch-input
79/// snapshot persisted into `RunRecord.input_json` at Run-creation time so a
80/// later `POST /v1/runs/:id/resume` can rebuild the exact input and re-run
81/// the flow under the SAME `run_id`.
82///
83/// [`TaskApplicationInput`] itself is deliberately not `Serialize`/
84/// `Deserialize` (its doc comment explains why — keeping the exhaustive
85/// `TaskApplicationInput { .. }` struct literal in the MCP adapter
86/// compiling), so this is a dedicated snapshot type with the exact same
87/// field set. Every field type already derives serde
88/// (`BlueprintRef` / `Role` / `Duration` / `OperatorKind` / `TaskInputSpec`
89/// / `CheckPolicy`), so the mirror is total — no field is dropped, and an
90/// operator-injected launch round-trips as faithfully as a plain one.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub(crate) struct RunLaunchSnapshot {
93    blueprint: BlueprintRef,
94    operator_id: String,
95    role: Role,
96    ttl: Duration,
97    init_ctx: Value,
98    operator_kind: Option<OperatorKind>,
99    bridge_id: Option<String>,
100    hook_id: Option<String>,
101    operator_backend_id: Option<String>,
102    /// Run-scoped Operator session pin. `#[serde(default)]` so a Run
103    /// snapshotted before the field existed still decodes (it resumes
104    /// unpinned, exactly as it launched).
105    #[serde(default)]
106    operator_pin: Option<String>,
107    #[serde(default)]
108    operator_kind_overrides: HashMap<String, OperatorKind>,
109    task_input: Option<TaskInputSpec>,
110    check_policy: Option<CheckPolicy>,
111}
112
113impl RunLaunchSnapshot {
114    /// Capture a launch input as a snapshot (clones each field — the
115    /// original is still dispatched).
116    fn from_input(input: &TaskApplicationInput) -> Self {
117        Self {
118            blueprint: input.blueprint.clone(),
119            operator_id: input.operator_id.clone(),
120            role: input.role,
121            ttl: input.ttl,
122            init_ctx: input.init_ctx.clone(),
123            operator_kind: input.operator_kind,
124            bridge_id: input.bridge_id.clone(),
125            hook_id: input.hook_id.clone(),
126            operator_backend_id: input.operator_backend_id.clone(),
127            operator_pin: input.operator_pin.clone(),
128            operator_kind_overrides: input.operator_kind_overrides.clone(),
129            task_input: input.task_input.clone(),
130            check_policy: input.check_policy,
131        }
132    }
133
134    /// Rebuild the launch input from a snapshot for resume.
135    fn into_input(self) -> TaskApplicationInput {
136        TaskApplicationInput {
137            blueprint: self.blueprint,
138            operator_id: self.operator_id,
139            role: self.role,
140            ttl: self.ttl,
141            init_ctx: self.init_ctx,
142            operator_kind: self.operator_kind,
143            bridge_id: self.bridge_id,
144            hook_id: self.hook_id,
145            operator_backend_id: self.operator_backend_id,
146            operator_pin: self.operator_pin,
147            operator_kind_overrides: self.operator_kind_overrides,
148            task_input: self.task_input,
149            check_policy: self.check_policy,
150        }
151    }
152}
153
154/// Serialize a launch input into the opaque `RunRecord.input_json` blob.
155/// Shared by both Run-creation sites (`run_flow_form` in `crate::lib` and
156/// [`task_rekick`]) so every persisted Run carries the snapshot resume
157/// needs. A serialization failure is a `400` — it means the caller handed
158/// in a value the snapshot cannot round-trip, which must surface before the
159/// Run is dispatched, not silently.
160pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
161    serde_json::to_string(&RunLaunchSnapshot::from_input(input))
162        .map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
163}
164
165/// Shared finalize step for a dispatched kick: updates the Run's
166/// `result_ref` + status and the owning Task's coarse status based on the
167/// `TaskApplication::handle_with_run` outcome, then returns that same
168/// outcome unchanged so callers keep shaping their own wire response /
169/// error.
170///
171/// Secondary persistence failures (the store call itself erroring) are
172/// logged via `tracing::warn!` and otherwise swallowed — they must not mask
173/// the primary dispatch outcome the caller already has in hand.
174pub(crate) async fn finalize_run(
175    state: &AppState,
176    task_id: &TaskId,
177    run_id: &RunId,
178    outcome: Result<TaskApplicationOutput, TaskApplicationError>,
179) -> Result<TaskApplicationOutput, TaskApplicationError> {
180    match &outcome {
181        Ok(out) => {
182            if let Err(e) = state
183                .run_store
184                .set_result(run_id, out.final_ctx.clone())
185                .await
186            {
187                tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
188            }
189            if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
190                tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
191            }
192            if let Err(e) = state
193                .task_store
194                .update_status(task_id, TaskRecordStatus::Done)
195                .await
196            {
197                tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
198            }
199        }
200        Err(e) => {
201            // GH #76 error surface: persist a structured failure envelope into
202            // `RunRecord.result_ref` so the async poll path (`GET
203            // /v1/runs/:id`) can surface `failed_step` / `verdict_value` /
204            // `partial_ctx` symmetric to the sync path's `ApiError`
205            // `details` field. Envelope shape (documented for consumer
206            // disambiguation from the Ok arm's raw `final_ctx`):
207            //
208            // ```json
209            // {
210            //   "error": {
211            //     "message": <string>,
212            //     "failed_step": <string|null>,
213            //     "verdict_value": <value|null>
214            //   },
215            //   "partial_ctx": <value|null>
216            // }
217            // ```
218            //
219            // Consumers detect failure via the top-level `"error"` key
220            // (present iff this arm fired; the Ok arm stores the raw
221            // `final_ctx` verbatim, which is either a scalar or an object
222            // with the user's own keys — never a top-level `"error"`
223            // sibling of `"partial_ctx"`). Non-`FlowEval` errors (e.g.
224            // `TaskApplicationError::Store` / `NoStore` — dispatch never
225            // reached the flow eval boundary) still get an envelope, but
226            // with the structural fields `null` (the underlying error
227            // simply carries no `failed_step` semantic).
228            let envelope = match e {
229                TaskApplicationError::Launch(TaskLaunchError::FlowEval {
230                    message,
231                    failed_step,
232                    verdict_value,
233                    partial_ctx,
234                }) => json!({
235                    "error": {
236                        "message": message,
237                        "failed_step": failed_step,
238                        "verdict_value": verdict_value,
239                    },
240                    "partial_ctx": partial_ctx,
241                }),
242                other => json!({
243                    "error": {
244                        "message": other.to_string(),
245                        "failed_step": Value::Null,
246                        "verdict_value": Value::Null,
247                    },
248                    "partial_ctx": Value::Null,
249                }),
250            };
251            if let Err(store_err) = state.run_store.set_result(run_id, envelope).await {
252                tracing::warn!(%run_id, error = %store_err, "finalize_run: set_result (failure envelope) failed");
253            }
254            if let Err(store_err) = state
255                .run_store
256                .update_status(run_id, RunStatus::Failed)
257                .await
258            {
259                tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
260            }
261            if let Err(store_err) = state
262                .task_store
263                .update_status(task_id, TaskRecordStatus::Failed)
264                .await
265            {
266                tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
267            }
268            tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
269        }
270    }
271    // Trace rail: mark the Run's terminal status on the stream (the
272    // `core.run_started` counterpart appended at the launch sites).
273    // Best-effort like every other persistence in this fn.
274    let status = if outcome.is_ok() { "done" } else { "failed" };
275    TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
276        .append(
277            trace_kind::RUN_FINISHED,
278            None,
279            None,
280            json!({ "status": status }),
281        )
282        .await;
283    outcome
284}
285
286/// Render a caught panic payload as a human-readable string. `panic!` with
287/// a literal yields `&'static str`, a formatted `panic!` yields `String`;
288/// anything else (a `panic_any` with a custom type) has no textual form, so
289/// it is reported by shape rather than dropped silently.
290fn panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
291    if let Some(s) = payload.downcast_ref::<&'static str>() {
292        (*s).to_string()
293    } else if let Some(s) = payload.downcast_ref::<String>() {
294        s.clone()
295    } else {
296        "non-string panic payload".to_string()
297    }
298}
299
300/// Terminal-stamp a Run whose driver future panicked: `Interrupted` plus a
301/// structured `{"error": "run driver panicked at <site>: <payload>"}`
302/// result envelope — the same terminal shape the boot sweep and the
303/// shutdown drain stamp, so the Run stays resumable via
304/// `POST /v1/runs/:id/resume` (which only accepts `Interrupted`).
305///
306/// The status flip goes through [`RunStore::try_transition`], so a Run that
307/// already reached a terminal status is left alone: a panic raised *after*
308/// `finalize_run` persisted `Done` / `Failed` (for example inside the trace
309/// tail) must not rewrite that verdict.
310///
311/// Best-effort like [`finalize_run`]: every secondary store error is logged
312/// and swallowed — the panic itself is the primary signal, already logged by
313/// [`catch_run_panic`].
314pub(crate) async fn mark_run_interrupted_by_panic(
315    state: &AppState,
316    task_id: &TaskId,
317    run_id: &RunId,
318    site: &str,
319    payload: &str,
320) {
321    match state
322        .run_store
323        .try_transition(run_id, RunStatus::Running, RunStatus::Interrupted)
324        .await
325    {
326        Ok(true) => {}
327        Ok(false) => {
328            tracing::warn!(
329                %run_id,
330                site,
331                "run driver panicked, but the Run is no longer `Running` — leaving its terminal status untouched"
332            );
333            return;
334        }
335        Err(e) => {
336            tracing::warn!(%run_id, error = %e, "panic guard: run try_transition(Running -> Interrupted) failed");
337            return;
338        }
339    }
340
341    let envelope = json!({ "error": format!("run driver panicked at {site}: {payload}") });
342    if let Err(e) = state.run_store.set_result(run_id, envelope).await {
343        tracing::warn!(%run_id, error = %e, "panic guard: set_result failed");
344    }
345    if let Err(e) = state
346        .task_store
347        .update_status(task_id, TaskRecordStatus::Interrupted)
348        .await
349    {
350        tracing::warn!(%task_id, error = %e, "panic guard: task update_status(Interrupted) failed");
351    }
352    // This path never reaches `finalize_run`, so the trace stream gets its
353    // terminal marker here.
354    TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
355        .append(
356            trace_kind::RUN_FINISHED,
357            None,
358            None,
359            json!({ "status": "interrupted", "reason": "driver panic" }),
360        )
361        .await;
362}
363
364/// Wrap a run driver future so a panic inside it terminates the Run instead
365/// of vanishing with the task.
366///
367/// Without this, a panic in a detached driver unwinds the whole spawned task
368/// — including the `tokio::time::timeout` combinator that wraps it, so the
369/// TTL ceiling never fires either — and the `RunRecord` is stranded in
370/// `Running` with no recovery path. On the synchronous paths the same panic
371/// propagates into the hyper connection task and drops the connection
372/// mid-request. Here the panic is caught, the Run is marked `Interrupted`
373/// via [`mark_run_interrupted_by_panic`], and the caller gets the payload
374/// string back to shape its own response.
375///
376/// Note this relies on unwinding: a future `[profile.release] panic =
377/// "abort"` would make the guard a no-op.
378pub(crate) async fn catch_run_panic<T, F>(
379    state: &AppState,
380    task_id: &TaskId,
381    run_id: &RunId,
382    site: &str,
383    fut: F,
384) -> Result<T, String>
385where
386    F: std::future::Future<Output = T>,
387{
388    match AssertUnwindSafe(fut).catch_unwind().await {
389        Ok(value) => Ok(value),
390        Err(payload) => {
391            let message = panic_payload_to_string(payload);
392            tracing::error!(
393                %task_id,
394                %run_id,
395                site,
396                payload = %message,
397                "run driver panicked — marking the Run Interrupted"
398            );
399            mark_run_interrupted_by_panic(state, task_id, run_id, site, &message).await;
400            Err(message)
401        }
402    }
403}
404
405/// Query params for `GET /v1/tasks`.
406#[derive(Debug, Deserialize, Default)]
407pub struct TasksListQuery {
408    /// Caps the returned list to the first N entries (already newest-first
409    /// per `TaskStore::list`). Omitted = no cap.
410    #[serde(default)]
411    pub limit: Option<usize>,
412}
413
414/// `GET /v1/tasks?limit=N`. Lists every persisted `TaskRecord`, newest first.
415pub async fn tasks_list(
416    State(state): State<AppState>,
417    Query(q): Query<TasksListQuery>,
418) -> Result<Json<Vec<TaskRecord>>, ApiError> {
419    let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
420    if let Some(limit) = q.limit {
421        records.truncate(limit);
422    }
423    Ok(Json(records))
424}
425
426/// Response body for `GET /v1/tasks/:id`.
427#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
428pub struct TaskDetailResponse {
429    /// The Task's own record.
430    pub task: TaskRecord,
431    /// Every Run kicked from this Task, oldest first (`RunStore::list_by_task` order).
432    pub runs: Vec<RunRecord>,
433}
434
435/// `GET /v1/tasks/:id`. Returns the `TaskRecord` plus every `RunRecord`
436/// kicked from it (`RunStore::list_by_task`, oldest kick first).
437pub async fn task_get(
438    State(state): State<AppState>,
439    Path(id): Path<String>,
440) -> Result<Json<TaskDetailResponse>, ApiError> {
441    let task_id =
442        TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
443    let task = state
444        .task_store
445        .get(&task_id)
446        .await
447        .map_err(map_task_store_err)?;
448    let runs = state
449        .run_store
450        .list_by_task(&task_id)
451        .await
452        .map_err(ApiError::engine)?;
453    Ok(Json(TaskDetailResponse { task, runs }))
454}
455
456/// Request body for `POST /v1/tasks/:id/runs` (issue #19 ST4) — every
457/// field is optional, and the body itself is optional (see
458/// [`task_rekick`]'s `Option<Json<Self>>` parameter); a caller that sends
459/// no body, or `{}`, or omits a field gets exactly today's rekick
460/// behavior for that layer.
461#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
462pub struct RunKickRequest {
463    /// Per-Run override for the flow-ir initial ctx. Merged on top of
464    /// `TaskRecord.input_ctx` (itself already merged on top of
465    /// `Blueprint.default_init_ctx` at original launch time) via
466    /// [`merge_init_ctx_3layer`] — Run wins on key collision, same
467    /// shallow-merge / non-Object-fully-replaces rule as every other
468    /// layer in the cascade. `None` (absent field, or no body at all) is
469    /// a no-op: the BP+Task merge alone seeds this kick, identical to
470    /// pre-#19 rekick.
471    #[serde(default)]
472    #[schemars(with = "Option<Value>")]
473    pub init_ctx_override: Option<Value>,
474    /// Per-Run override for the Task-level canonical fields
475    /// (`project_root` / `work_dir` / `task_metadata`). `None` falls back
476    /// to `TaskRecord.task_input_spec` (the spec resolved and snapshotted
477    /// at original `POST /v1/tasks` time); `Some` replaces it wholesale
478    /// for this kick only — the stored `TaskRecord.task_input_spec` is
479    /// never mutated by a rekick.
480    #[serde(default)]
481    pub task_input_override: Option<TaskInputSpec>,
482    /// Per-Run ceiling (seconds) for this kick's synchronous dispatch
483    /// await (issue #35 ST3 — GH #33 Guard 2 parity). `Some(0)` is
484    /// rejected (400). `None` falls back to `AppState.sync_timeout_secs`
485    /// (the server-wide default), same cascade as
486    /// `TaskLaunchRequest.timeout_secs` (`lib.rs:818-826`).
487    #[serde(default)]
488    pub timeout_secs: Option<u64>,
489    /// GH #37: opt into the detached (asynchronous) rekick — same
490    /// semantics as `TaskLaunchRequest.detach`. `false` (default) keeps
491    /// the synchronous dispatch; `true` spawns the flow eval as a
492    /// detached background task bounded by the run TTL alone and returns
493    /// `202 Accepted` with `status: "running"` immediately. Mutually
494    /// exclusive with `timeout_secs` (`400` when combined).
495    #[serde(default)]
496    pub detach: bool,
497    /// Per-Run pin to a live Operator session (rekick parity with
498    /// `POST /v1/tasks`' `operator_sid` — see
499    /// `crate::TaskLaunchRequest::operator_sid` for the full
500    /// disconnected-vs-unknown / last-write-wins contract). Resolved
501    /// before any Task/Run store write: an unknown sid fails fast with a
502    /// `400`, never silently falling back to the BP-level alias lookup.
503    /// `Some(sid)` becomes this kick's `operator_backend_id` (this handler
504    /// carries no other Operator-override field) and is persisted verbatim
505    /// into `RunRecord.operator_sid`; `None` (absent field, or no body at
506    /// all) preserves the pre-existing Operator-default rekick path
507    /// byte-for-byte.
508    #[serde(default)]
509    pub operator_sid: Option<String>,
510}
511
512/// Response body for `POST /v1/tasks/:id/runs`.
513#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
514pub struct RunKickResponse {
515    /// The re-kicked Task's id (echoes the path param).
516    #[schemars(with = "String")]
517    pub task_id: TaskId,
518    /// The freshly minted Run id for this kick.
519    #[schemars(with = "String")]
520    pub run_id: RunId,
521    /// Kick outcome at response time (GH #37). The synchronous path
522    /// reports the dispatched run's terminal-side status (`done`); a
523    /// detached kick reports `running` — poll `GET /v1/runs/:id` for the
524    /// terminal status and result.
525    pub status: RunStatus,
526}
527
528/// `POST /v1/tasks/:id/runs`. Re-kicks an existing Task: reads its stored
529/// `blueprint_ref`, re-resolves it through [`TaskApplication::resolve`]
530/// (issue #19 ST4 — refreshes `Blueprint.default_init_ctx` exactly like
531/// original launch time, rather than replaying a launch-time-only
532/// snapshot), 3-layer-merges `{bp default, TaskRecord.input_ctx, an
533/// optional per-Run override}` via [`merge_init_ctx_3layer`], resolves the
534/// Task-level canonical fields (`RunKickRequest.task_input_override`,
535/// falling back to `TaskRecord.task_input_spec`), mints a fresh `RunId`,
536/// dispatches through `TaskApplication::handle_with_run` (Operator-default
537/// unless the caller pins a live session via `RunKickRequest.operator_sid`
538/// — the rekick parity for `POST /v1/tasks`' own `operator_sid`; the
539/// stored Task carries no persisted Operator preference of its own)
540/// plus a freshly-built `RunContext` (issue #13 run_id propagation, so
541/// this kick's steps get their own `step_entries` trace), and persists the
542/// outcome via [`finalize_run`].
543///
544/// The body is optional (`Option<Json<RunKickRequest>>`) — no body, or a
545/// body with both fields absent, preserves the pre-#19 rekick behavior
546/// byte-for-byte (`must_not_simplify #3`).
547///
548/// Issue #35 ST3 ports the GH #33 sync-hang guards from `run_flow_form` to
549/// this handler, both checked before any Task/Run store write: Guard 1
550/// (503) fails fast when the resolved Blueprint declares the
551/// `operator_delegate` spawner-hint layer and no operator is attached;
552/// Guard 2 (504) wraps the dispatch await in `RunKickRequest.timeout_secs`
553/// (falling back to the server-wide `sync_timeout_secs`), marking the
554/// Run/Task `Failed` rather than leaving them `Running` forever on expiry.
555pub async fn task_rekick(
556    State(state): State<AppState>,
557    Path(id): Path<String>,
558    body: Option<Json<RunKickRequest>>,
559) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
560    let task_id =
561        TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
562    let task = state
563        .task_store
564        .get(&task_id)
565        .await
566        .map_err(map_task_store_err)?;
567
568    let blueprint_ref: mlua_swarm::application::BlueprintRef =
569        serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
570            ApiError::bad_request(format!(
571                "task {task_id}: stored blueprint_ref failed to decode: {e}"
572            ))
573        })?;
574
575    // issue #19 ST4 (must_not_simplify #5): re-resolve the Blueprint the
576    // same way `run_flow_form`'s TTL cascade does, so a store-backed
577    // `BlueprintRef::Id` gets its *current* `default_init_ctx` on every
578    // rekick rather than whatever was true at original launch time. The
579    // Inline path is a pure pass-through, so this is a no-op there.
580    let (resolved_bp, _bound_version) = state
581        .task_app
582        .resolve(&blueprint_ref)
583        .await
584        .map_err(|e| ApiError::from_task_resolve(&e, &format!("task {task_id}: bp resolve")))?;
585
586    let req = body.map(|Json(r)| r).unwrap_or_default();
587
588    // S2 parity with `run_flow_form` (`lib.rs:1035-1048`): an explicit
589    // `operator_sid` pins this rekick to a live Operator session,
590    // resolved *before* any Task/Run store write so an unknown sid fails
591    // fast with a `400` rather than minting records for a kick that
592    // references a session nothing can serve. Unlike `run_flow_form` this
593    // handler has no other Operator-override field, so the resolved sid
594    // flows straight into `TaskApplicationInput.operator_backend_id`
595    // (below) and is persisted verbatim into `RunRecord.operator_sid`. See
596    // `crate::TaskLaunchRequest::operator_sid` for the disconnected-vs-
597    // unknown distinction.
598    let operator_backend_id = match &req.operator_sid {
599        Some(sid) => {
600            let known_ids = state.engine.list_operator_ids().await;
601            if !known_ids.iter().any(|id| id == sid) {
602                return Err(ApiError::bad_request(format!(
603                    "operator_sid: no such registered operator session '{sid}'"
604                )));
605            }
606            Some(sid.clone())
607        }
608        None => None,
609    };
610
611    // GH #33 Guard 2 ceiling resolution (issue #35 ST3 — mirrors
612    // `run_flow_form`'s `lib.rs:813-826` cascade): request field > server
613    // config > built-in default. Validated up front, before Guard 1 and
614    // before any Task/Run store writes, so a caller-supplied `Some(0)`
615    // fails fast with `400` rather than minting records for a rekick that
616    // was never going to dispatch.
617    // GH #37: `detach: true` makes the sync ceiling meaningless (the
618    // detached kick is bounded by the run TTL alone) — combining the two
619    // is rejected here, same fail-fast-before-side-effects ordering.
620    let detach = req.detach;
621    let sync_timeout_secs = match (detach, req.timeout_secs) {
622        (true, Some(_)) => {
623            return Err(ApiError::bad_request(
624                "timeout_secs is the synchronous rekick ceiling and does not apply to a \
625                 detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
626                 timeout_secs"
627                    .into(),
628            ));
629        }
630        (false, Some(0)) => {
631            return Err(ApiError::bad_request(
632                "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
633            ));
634        }
635        (false, Some(v)) => v,
636        (_, None) => state.sync_timeout_secs,
637    };
638
639    // GH #33 Guard 1 (issue #35 — adapted signal): the
640    // per-request `operator_sid` above already fail-fasts an *unknown*
641    // sid, but a rekick with no `operator_sid` still has no per-request
642    // "operator backend referenced" signal of its own (unlike
643    // `run_flow_form`, whose `op_req.operator_backend_id` is sourced from
644    // `TaskLaunchRequest.operator`). The adapted signal is the Blueprint's
645    // own `spawner_hints.layers`: when the resolved Blueprint declares the
646    // `operator_delegate` layer and zero operators are attached at all,
647    // fail fast rather than dispatching into a session nothing can serve.
648    // Same ordering invariant `run_flow_form` observes: this check runs
649    // before any Task/Run row is touched (no side effects on the 503
650    // path).
651    if resolved_bp
652        .spawner_hints
653        .layers
654        .iter()
655        .any(|l| l == "operator_delegate")
656    {
657        let attached = state.engine.list_operator_ids().await;
658        if attached.is_empty() {
659            return Err(ApiError::unavailable(format!(
660                "no operator attached to serve this rekick (task {task_id}'s \
661                 Blueprint declares the operator_delegate layer): attach an \
662                 operator via POST /v1/operators + WS, or use the poll-style \
663                 flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
664            )));
665        }
666    }
667
668    let merged_init_ctx = merge_init_ctx_3layer(
669        resolved_bp.default_init_ctx.as_ref(),
670        &task.input_ctx,
671        req.init_ctx_override.as_ref(),
672    );
673
674    // must_not_simplify #4: `task_input_override` wins for this kick only;
675    // falling back to the Task-level snapshot never mutates
676    // `TaskRecord.task_input_spec` itself.
677    let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
678        Some(over) => Some(over),
679        None => task
680            .task_input_spec
681            .as_ref()
682            .map(|v| serde_json::from_value(v.clone()))
683            .transpose()
684            .map_err(|e| {
685                ApiError::bad_request(format!(
686                    "task {task_id}: stored task_input_spec failed to decode: {e}"
687                ))
688            })?,
689    };
690
691    let run_id = RunId::new();
692    let now = now_secs();
693
694    let input = TaskApplicationInput {
695        blueprint: blueprint_ref,
696        operator_id: "http-run".to_string(),
697        role: Role::Operator,
698        ttl: Duration::from_secs(crate::default_run_ttl()),
699        init_ctx: merged_init_ctx,
700        operator_kind: None,
701        bridge_id: None,
702        hook_id: None,
703        operator_backend_id,
704        // Same value, second axis: the sid also binds this rekick's
705        // AgentSpec-axis Operator agents (and their manifest attestation) to
706        // that session, so a rekick lands on the session the caller named
707        // rather than on whichever session currently holds the role.
708        operator_pin: req.operator_sid.clone(),
709        operator_kind_overrides: HashMap::new(),
710        task_input: task_input_spec,
711        // This legacy `POST /v1/tasks/:id/runs`-style path does not carry a
712        // per-request check_policy override; `None` preserves the
713        // server-wide default (backward compat).
714        check_policy: None,
715    };
716    // Persist a launch-input snapshot so this kick's Run can be resumed
717    // under the same run_id if it is later interrupted
718    // (`POST /v1/runs/:id/resume`). Built from `input` before it is moved
719    // into the dispatch below.
720    let input_json = Some(snapshot_launch_input(&input)?);
721
722    state
723        .task_store
724        .update_status(&task_id, TaskRecordStatus::Running)
725        .await
726        .map_err(ApiError::engine)?;
727    state
728        .run_store
729        .create(RunRecord {
730            id: run_id.clone(),
731            task_id: task_id.clone(),
732            status: RunStatus::Running,
733            step_entries: Vec::new(),
734            degradations: Vec::new(),
735            operator_sid: req.operator_sid.clone(),
736            result_ref: None,
737            input_json,
738            created_at: now,
739            updated_at: now,
740        })
741        .await
742        .map_err(ApiError::engine)?;
743
744    let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
745    trace
746        .append(
747            trace_kind::RUN_STARTED,
748            None,
749            None,
750            json!({"mode": "rekick"}),
751        )
752        .await;
753    let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
754        .with_replay_store(state.replay_store.clone())
755        .with_trace(trace);
756
757    // GH #37 detached rekick: same driver-detach semantics as
758    // `run_flow_form` — the eval runs in its own spawned task bounded by
759    // the run TTL alone, `finalize_run` (or the ttl-expiry `Failed`
760    // marking) is owned by that task, and this handler returns `202
761    // Accepted` immediately.
762    if detach {
763        let ttl_secs = crate::default_run_ttl();
764        let bg_state = state.clone();
765        let bg_task_id = task_id.clone();
766        let bg_run_id = run_id.clone();
767        // Panic guard — see `catch_run_panic`.
768        let guard_state = state.clone();
769        let guard_task_id = task_id.clone();
770        let guard_run_id = run_id.clone();
771        tokio::spawn(async move {
772            let driver = async move {
773                let outcome = match tokio::time::timeout(
774                    Duration::from_secs(ttl_secs),
775                    bg_state.task_app.handle_with_run(input, Some(run_ctx)),
776                )
777                .await
778                {
779                    Ok(outcome) => outcome,
780                    Err(_elapsed) => {
781                        let reason = serde_json::json!({
782                            "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
783                        });
784                        if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
785                            tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
786                        }
787                        if let Err(e) = bg_state
788                            .run_store
789                            .update_status(&bg_run_id, RunStatus::Failed)
790                            .await
791                        {
792                            tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
793                        }
794                        if let Err(e) = bg_state
795                            .task_store
796                            .update_status(&bg_task_id, TaskRecordStatus::Failed)
797                            .await
798                        {
799                            tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
800                        }
801                        // This arm never reaches `finalize_run`, so the trace
802                        // stream gets its terminal marker here.
803                        TraceHandle::new(bg_run_id.clone(), bg_state.run_trace_store.clone())
804                        .append(
805                            trace_kind::RUN_FINISHED,
806                            None,
807                            None,
808                            json!({ "status": "failed", "reason": format!("ttl {ttl_secs}s exceeded") }),
809                        )
810                        .await;
811                        return;
812                    }
813                };
814                // `finalize_run` persists both the Ok and Err outcomes itself;
815                // the passthrough return value has no consumer here.
816                let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
817            };
818            let _ = catch_run_panic(
819                &guard_state,
820                &guard_task_id,
821                &guard_run_id,
822                "rekick.detach",
823                driver,
824            )
825            .await;
826        });
827        return Ok((
828            StatusCode::ACCEPTED,
829            Json(RunKickResponse {
830                task_id,
831                run_id,
832                status: RunStatus::Running,
833            }),
834        ));
835    }
836
837    // GH #33 Guard 2 (issue #35 ST3 — mirrors `run_flow_form`'s sync
838    // branch exactly, including its driver-detach shape):
839    // the driver runs in a spawned task bounded by the sync ceiling, and
840    // this handler only awaits its verdict over a `oneshot`. A client
841    // disconnect drops the wait, not the run, so a `/v1/worker/submit`
842    // landing after the disconnect still has a driver to fold it into. On
843    // ceiling expiry the timed-out future is dropped, cancelling the
844    // in-process flow eval — the flow is abandoned, not resumed. Best
845    // effort: mark the Run/Task so they do not stay `Running` forever.
846    // Wrapped in the panic guard (`catch_run_panic`) — same rationale as the
847    // sync launch path in `crate::lib`.
848    let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), ApiError>>();
849    let bg_state = state.clone();
850    let bg_task_id = task_id.clone();
851    let bg_run_id = run_id.clone();
852    let guard_state = state.clone();
853    let guard_task_id = task_id.clone();
854    let guard_run_id = run_id.clone();
855    tokio::spawn(async move {
856        let driver = async move {
857            let outcome = match tokio::time::timeout(
858                Duration::from_secs(sync_timeout_secs),
859                bg_state.task_app.handle_with_run(input, Some(run_ctx)),
860            )
861            .await
862            {
863                Ok(outcome) => outcome,
864                Err(_elapsed) => {
865                    let reason = serde_json::json!({
866                        "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
867                    });
868                    if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
869                        tracing::warn!(%bg_run_id, error = %e, "task_rekick: timeout set_result failed");
870                    }
871                    if let Err(e) = bg_state
872                        .run_store
873                        .update_status(&bg_run_id, RunStatus::Failed)
874                        .await
875                    {
876                        tracing::warn!(%bg_run_id, error = %e, "task_rekick: timeout run update_status failed");
877                    }
878                    if let Err(e) = bg_state
879                        .task_store
880                        .update_status(&bg_task_id, TaskRecordStatus::Failed)
881                        .await
882                    {
883                        tracing::warn!(%bg_task_id, error = %e, "task_rekick: timeout task update_status failed");
884                    }
885                    return Err(ApiError::timeout(format!(
886                        "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {bg_task_id}, run {bg_run_id}"
887                    )));
888                }
889            };
890            finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome)
891                .await
892                .map(|_| ())
893                .map_err(|e| ApiError::bad_request(format!("run: {e}")))
894        };
895        let reply = match catch_run_panic(
896            &guard_state,
897            &guard_task_id,
898            &guard_run_id,
899            "rekick.sync",
900            driver,
901        )
902        .await
903        {
904            Ok(reply) => reply,
905            Err(msg) => Err(ApiError::engine(format!(
906                "run driver panicked: {msg}; the run was marked Interrupted and can be resumed \
907                 via POST /v1/runs/{guard_run_id}/resume"
908            ))),
909        };
910        // A disconnected client leaves no receiver; the run is already
911        // persisted, so the undeliverable reply is dropped.
912        let _ = tx.send(reply);
913    });
914
915    // Only reachable if the spawned task died without sending — a panic
916    // outside the guard, or a runtime shutdown.
917    rx.await.map_err(|_| {
918        ApiError::engine(format!(
919            "run driver task ended without reporting an outcome; see GET /v1/runs/{run_id} \
920             for the run's persisted status"
921        ))
922    })??;
923
924    Ok((
925        StatusCode::CREATED,
926        Json(RunKickResponse {
927            task_id,
928            run_id,
929            status: RunStatus::Done,
930        }),
931    ))
932}
933
934/// Response body for `POST /v1/runs/:id/resume`.
935#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
936pub struct RunResumeResponse {
937    /// The resumed Run's id — echoes the path param. Resume never mints a
938    /// new `RunId`; the interrupted Run is re-run in place so its
939    /// replay-entry Ctx snapshots (which bake this id into
940    /// `meta.runtime[run_id]`) stay consistent.
941    #[schemars(with = "String")]
942    pub run_id: RunId,
943    /// The Task this Run belongs to.
944    #[schemars(with = "String")]
945    pub task_id: TaskId,
946    /// Count of already-completed steps handed to the replay cursor — the
947    /// engine returns each of these verbatim (no re-dispatch) before
948    /// resuming fresh work. `0` = the Run was interrupted before any step
949    /// completed, so it re-runs from scratch under the same `run_id`.
950    pub replayed_steps: usize,
951}
952
953/// `POST /v1/runs/:id/resume`. Resumes an `Interrupted` Run under the SAME
954/// `run_id` (no new `RunId` is minted): the stored launch-input snapshot
955/// (`RunRecord.input_json`) is rebuilt into a `TaskApplicationInput`, a
956/// `ReplayCursor` is built from the Run's logged step snapshots
957/// (`ReplayStore::list_by_run`), and the flow is re-dispatched with both
958/// wired into a fresh `RunContext`. On dispatch the engine's replay path
959/// returns each already-completed step's stored value verbatim (cursor hit,
960/// no Adapter spawn) and dispatches only the steps that never finished —
961/// reconstructing the same final Ctx a restart-free run would have reached.
962///
963/// Status codes:
964/// - `404` — no Run with this id.
965/// - `409` — the Run is not `Interrupted` (already `Running` / `Done` /
966///   `Failed` / `Pending`), OR a concurrent resume already won the
967///   `Interrupted -> Running` compare-and-set (double-resume guard).
968/// - `422` — the Run has no recorded launch-input snapshot, so it cannot be
969///   resumed (an older row predating resume support, or a path that does
970///   not persist one).
971/// - `202 Accepted` — resume accepted; the flow re-runs in a detached
972///   background task (same `tokio::spawn` + run-TTL ceiling shape as a
973///   detached rekick). Poll `GET /v1/runs/:id` for the terminal status.
974///
975/// The launch-input decode and the `422` check run BEFORE the
976/// compare-and-set so a non-resumable Run is never flipped to `Running`
977/// and stranded without a driver behind it.
978pub async fn run_resume(
979    State(state): State<AppState>,
980    Path(id): Path<String>,
981) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
982    let run_id =
983        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
984
985    // 404 when the Run does not exist.
986    let run = state
987        .run_store
988        .get(&run_id)
989        .await
990        .map_err(map_run_store_err)?;
991
992    // Status gate: only an `Interrupted` Run can be resumed.
993    if run.status != RunStatus::Interrupted {
994        return Err(ApiError::conflict(format!(
995            "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
996            run.status
997        )));
998    }
999
1000    // Decode the launch-input snapshot BEFORE the compare-and-set: a Run
1001    // with no recorded input can never be resumed, and returning `422`
1002    // here — before flipping the status — avoids stranding it in `Running`
1003    // with no driver behind it.
1004    let Some(input_json) = run.input_json.clone() else {
1005        return Err(ApiError::unprocessable(format!(
1006            "run {run_id} cannot be resumed: no launch input was recorded for it (it \
1007             predates resume support, or was created by a path that does not persist one)"
1008        )));
1009    };
1010    let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
1011        ApiError::unprocessable(format!(
1012            "run {run_id}: stored launch input failed to decode: {e}"
1013        ))
1014    })?;
1015    validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
1016    let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
1017        ApiError::unprocessable(format!(
1018            "run {run_id}: stored launch input failed to decode: {e}"
1019        ))
1020    })?;
1021
1022    // Atomically flip Interrupted -> Running. A racing double resume loses
1023    // the compare-and-set and gets a `409` rather than dispatching a second
1024    // driver over the same Run.
1025    let won = state
1026        .run_store
1027        .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
1028        .await
1029        .map_err(ApiError::engine)?;
1030    if !won {
1031        return Err(ApiError::conflict(format!(
1032            "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
1033             no longer resumable"
1034        )));
1035    }
1036
1037    // Build the replay cursor from the Run's logged step snapshots. An
1038    // empty log is fine — the cursor has zero hits and every step is
1039    // dispatched fresh (a from-scratch re-run under the same run_id).
1040    let entries = state
1041        .replay_store
1042        .list_by_run(&run_id)
1043        .await
1044        .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
1045    let replayed_steps = entries.len();
1046    let cursor = ReplayCursor::from_entries(entries);
1047
1048    // RunContext for the SAME run_id — run_store + replay_store +
1049    // replay_cursor all wired. No new RunRecord is minted. `with_resume()`
1050    // marks this as a resume so any binding backfill is stamped
1051    // `resume_backfill` (and, D2, keeps legacy replay keys).
1052    let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
1053    trace
1054        .append(
1055            trace_kind::RUN_STARTED,
1056            None,
1057            None,
1058            json!({"mode": "resume"}),
1059        )
1060        .await;
1061    let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1062        .with_replay_store(state.replay_store.clone())
1063        .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1064        .with_resume()
1065        .with_trace(trace);
1066
1067    let input = snapshot.into_input();
1068    let task_id = run.task_id.clone();
1069
1070    // A resumed Task is running again; finalize_run resets it to
1071    // Done/Failed at the end, same as the rekick path.
1072    state
1073        .task_store
1074        .update_status(&task_id, TaskRecordStatus::Running)
1075        .await
1076        .map_err(ApiError::engine)?;
1077
1078    // Detached dispatch — same `tokio::spawn` + run-TTL-ceiling shape as
1079    // the detached rekick path; `finalize_run` (or the ttl-expiry `Failed`
1080    // marking) owns the terminal persistence.
1081    let ttl_secs = crate::default_run_ttl();
1082    let bg_state = state.clone();
1083    let bg_task_id = task_id.clone();
1084    let bg_run_id = run_id.clone();
1085    // Panic guard — see `catch_run_panic`.
1086    let guard_state = state.clone();
1087    let guard_task_id = task_id.clone();
1088    let guard_run_id = run_id.clone();
1089    tokio::spawn(async move {
1090        let driver = async move {
1091            let outcome = match tokio::time::timeout(
1092                Duration::from_secs(ttl_secs),
1093                bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1094            )
1095            .await
1096            {
1097                Ok(outcome) => outcome,
1098                Err(_elapsed) => {
1099                    let reason = serde_json::json!({
1100                        "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
1101                    });
1102                    if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1103                        tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
1104                    }
1105                    if let Err(e) = bg_state
1106                        .run_store
1107                        .update_status(&bg_run_id, RunStatus::Failed)
1108                        .await
1109                    {
1110                        tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
1111                    }
1112                    if let Err(e) = bg_state
1113                        .task_store
1114                        .update_status(&bg_task_id, TaskRecordStatus::Failed)
1115                        .await
1116                    {
1117                        tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
1118                    }
1119                    return;
1120                }
1121            };
1122            // `finalize_run` persists both the Ok and Err outcomes itself.
1123            let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1124        };
1125        let _ = catch_run_panic(
1126            &guard_state,
1127            &guard_task_id,
1128            &guard_run_id,
1129            "resume.detach",
1130            driver,
1131        )
1132        .await;
1133    });
1134
1135    Ok((
1136        StatusCode::ACCEPTED,
1137        Json(RunResumeResponse {
1138            run_id,
1139            task_id,
1140            replayed_steps,
1141        }),
1142    ))
1143}
1144
1145/// Request body for `POST /v1/runs/:id/rerun-from` (GH #71 Layer A).
1146#[derive(Debug, Deserialize, schemars::JsonSchema)]
1147pub struct RunRerunFromRequest {
1148    /// The step to re-execute. This is a raw `step_ref` (the agent name the
1149    /// dispatcher recorded as `ReplayEntry.step_ref`), NOT a projection
1150    /// canonical name. See [`run_rerun_from`] doc for the Known Limitations
1151    /// this carries (loop bodies match the first occurrence,
1152    /// `AgentMeta.projection_name` is not resolved, `BlueprintRef::Inline`
1153    /// re-decodes the frozen inline BP).
1154    pub from_step: String,
1155}
1156
1157/// Response body for `POST /v1/runs/:id/rerun-from` (GH #71 Layer A).
1158#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
1159pub struct RunRerunFromResponse {
1160    /// The rerun's Run id — echoes the path param. Rerun-from-step never
1161    /// mints a new `RunId`; it re-runs in place so the replay-entry Ctx
1162    /// snapshots (which bake this id into `meta.runtime[run_id]`) stay
1163    /// consistent.
1164    #[schemars(with = "String")]
1165    pub run_id: RunId,
1166    /// The Task this Run belongs to.
1167    #[schemars(with = "String")]
1168    pub task_id: TaskId,
1169    /// Count of pre-cut entries handed to the replay cursor — each is
1170    /// returned verbatim by the engine before fresh dispatch resumes at
1171    /// the cut point.
1172    pub replayed_steps: usize,
1173    /// Count of entries physically dropped from the replay store — the
1174    /// target step's row plus every downstream row.
1175    pub dropped_steps: usize,
1176}
1177
1178/// `POST /v1/runs/:id/rerun-from` — GH #71 Layer A. Re-executes a specific
1179/// step (and every downstream step) of a terminal Run under the SAME
1180/// `run_id`. Mirrors [`run_resume`], with two deltas: it accepts any
1181/// terminal status (`Done` / `Failed` / `Interrupted`) rather than only
1182/// `Interrupted`, and it physically truncates the replay log at the cut
1183/// point (via [`crate::AppState::replay_store`]'s `delete_from`) so that
1184/// re-dispatch's `append` does not collide with the pre-rerun row and so
1185/// `list_by_run` reflects the rerun's real history rather than the
1186/// pre-rerun ghost.
1187///
1188/// # Status codes
1189///
1190/// - `400` — invalid `run_id`, malformed body, or launch-snapshot decode failure.
1191/// - `404` — no Run with this id.
1192/// - `409` — the Run is `Running` / `Pending` (would race the in-flight
1193///   driver), OR a concurrent transition won the compare-and-set.
1194/// - `422` — the Run has no recorded launch-input snapshot, OR `from_step`
1195///   is not present in this Run's replay log, OR the run's replay log is
1196///   empty next to a non-empty `RunRecord.step_entries` trace (a prior
1197///   `rerun-from` reached the truncate stage and consumed the log), OR
1198///   the current-head Blueprint fails to compile (unresolved
1199///   `operator_ref` etc.) — the deterministic pre-flight gate that keeps
1200///   the replay log untouched on a compile-fail.
1201/// - `202 Accepted` — accepted; the flow re-runs in a detached background
1202///   task (same `tokio::spawn` + run-TTL ceiling shape as [`run_resume`]).
1203///   Poll `GET /v1/runs/:id` for the terminal status.
1204///
1205/// # Order of operations
1206///
1207/// The compare-and-set runs BEFORE the `delete_from` on purpose: a losing
1208/// cas returns `409` without ever touching the store, so a lost race can
1209/// never leave the store truncated while the status stayed at its old
1210/// terminal value. The compile pre-check runs BEFORE the compare-and-set
1211/// for the same reason: a deterministic compile failure fires a `422`
1212/// that leaves both `status` and the replay log untouched, so the caller
1213/// can fix the Blueprint and retry against the same run.
1214///
1215/// 1. 404 check.
1216/// 2. Status gate (fast 409 for `Running` / `Pending`).
1217/// 3. Decode launch snapshot (fast 400 / 422).
1218/// 4. Compute cut index via `list_by_run` + `.position(step_ref == from_step)`
1219///    (fast 422 when the step is not present, with a distinct message when
1220///    the log is empty but `RunRecord.step_entries` shows the run did
1221///    trace steps — a consumed log from a prior `rerun-from`).
1222/// 5. Pre-flight compile check via `TaskApplication::precompile` against
1223///    the launch snapshot's Blueprint (fast 422 on any `CompileError`).
1224///    Prevents compile-fail-inside-`tokio::spawn` from consuming the
1225///    replay log via step 7's `delete_from`.
1226/// 6. Atomic transition `<current terminal> -> Running` (409 on loss).
1227/// 7. Physical `delete_from(cut)` on the replay store — safe now because we
1228///    won the cas and own the Run.
1229/// 8. Build `ReplayCursor` from the truncated entries.
1230/// 9. Detached dispatch, same `tokio::spawn` + `default_run_ttl` shape as
1231///    [`run_resume`].
1232///
1233/// # Known limitations (Layer A)
1234///
1235/// 1. **`from_step` is a raw `step_ref` (agent name)** — projection alias
1236///    resolution via `StepNaming` is Layer B territory. For undeclared
1237///    steps `step_ref == canonical` so this is only visible when
1238///    `AgentMeta.projection_name` is in use.
1239/// 2. **`BlueprintRef::Inline` freezes the BP in the launch snapshot** —
1240///    the rerun re-decodes the same inline BP, so agent-definition edits
1241///    landed on disk between the original dispatch and the rerun are NOT
1242///    honored for inline runs. Use `BlueprintRef::Id` for the
1243///    iterate-and-rerun workflow.
1244/// 3. **Loop bodies match the first occurrence** — `step_ref` is the agent
1245///    name, so `.position(|e| e.step_ref == from_step)` finds the FIRST
1246///    occurrence and truncates from there. Rerunning a specific loop
1247///    iteration needs Layer B semantics.
1248/// 4. **Structural BP change is out of scope** — if steps were added /
1249///    removed / reordered between the original dispatch and the rerun,
1250///    the flow-ir re-eval will naturally miss the step or dispatch a
1251///    different downstream. Start a fresh run in that case.
1252pub async fn run_rerun_from(
1253    State(state): State<AppState>,
1254    Path(id): Path<String>,
1255    Json(req): Json<RunRerunFromRequest>,
1256) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
1257    let run_id =
1258        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1259
1260    if req.from_step.trim().is_empty() {
1261        return Err(ApiError::bad_request(
1262            "from_step must be a non-empty step ref".to_string(),
1263        ));
1264    }
1265
1266    // 404 when the Run does not exist.
1267    let run = state
1268        .run_store
1269        .get(&run_id)
1270        .await
1271        .map_err(map_run_store_err)?;
1272
1273    // Status gate — reject in-flight statuses that would race the driver
1274    // already dispatching against this run_id.
1275    let current = run.status;
1276    match current {
1277        RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted | RunStatus::Cancelled => { /* ok */
1278        }
1279        RunStatus::Running | RunStatus::Pending => {
1280            return Err(ApiError::conflict(format!(
1281                "run {run_id} is {current:?}; rerun-from requires a terminal run \
1282                 (Done / Failed / Interrupted / Cancelled)"
1283            )));
1284        }
1285    }
1286
1287    // Decode the launch-input snapshot BEFORE the compare-and-set: a Run
1288    // with no recorded input can never be rerun-from, and returning `422`
1289    // here — before flipping the status — avoids stranding it in `Running`
1290    // with no driver behind it.
1291    let Some(input_json) = run.input_json.clone() else {
1292        return Err(ApiError::unprocessable(format!(
1293            "run {run_id} cannot be rerun: no launch input was recorded for it (it \
1294             predates resume/rerun support, or was created by a path that does not \
1295             persist one)"
1296        )));
1297    };
1298    let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
1299        ApiError::unprocessable(format!(
1300            "run {run_id}: stored launch input failed to decode: {e}"
1301        ))
1302    })?;
1303    validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
1304    let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
1305        ApiError::unprocessable(format!(
1306            "run {run_id}: stored launch input failed to decode: {e}"
1307        ))
1308    })?;
1309
1310    // Load the replay log and locate the cut point via first-match on
1311    // `step_ref`. See §Known limitations #3 (loop bodies).
1312    let entries = state
1313        .replay_store
1314        .list_by_run(&run_id)
1315        .await
1316        .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
1317    let cut = entries
1318        .iter()
1319        .position(|e| e.step_ref == req.from_step)
1320        .ok_or_else(|| {
1321            // Distinguish two shapes of miss: (a) the log carries entries
1322            // but none match `from_step` (typo or wrong step name); (b) the
1323            // log is empty while `RunRecord.step_entries` still traces
1324            // steps — which means a prior `rerun-from` reached the
1325            // `delete_from` stage and consumed the log, and no further
1326            // `rerun-from` against the same run is recoverable. `run.
1327            // step_entries` and `replay_store` are physically separate
1328            // tables (the dispatcher writes to both), so an empty log next
1329            // to a non-empty trace is the reliable tell.
1330            if entries.is_empty() && !run.step_entries.is_empty() {
1331                ApiError::unprocessable(format!(
1332                    "run {run_id}: replay log is empty but {} step entries are traced \
1333                     on the RunRecord — the log was consumed by a prior rerun-from \
1334                     that reached the truncate stage. This run can no longer be \
1335                     rerun-from; start a fresh run via POST /v1/tasks.",
1336                    run.step_entries.len()
1337                ))
1338            } else {
1339                ApiError::unprocessable(format!(
1340                    "run {run_id}: from_step {:?} not present in this run's replay log \
1341                     (nothing to rerun-from)",
1342                    req.from_step
1343                ))
1344            }
1345        })?;
1346
1347    // Pre-flight compile check against the current-head Blueprint the
1348    // rerun will actually launch against. Compile is deterministic — an
1349    // `UnresolvedOperatorRef` / `UnresolvedMetaRef` / `UnresolvedAuditAgent`
1350    // / verdict-cond shape violation fails the same way every attempt —
1351    // so surfacing it here as a 422, BEFORE the compare-and-set and
1352    // BEFORE `delete_from`, converts an otherwise irrecoverable replay-
1353    // loss (compile fails INSIDE the detached `tokio::spawn` AFTER the
1354    // truncation has physically dropped the pre-cut rows) into a fast
1355    // rejection that leaves the run's status and replay log entirely
1356    // untouched. Runtime-only failures (spawner error, worker submit
1357    // failure) are still able to consume the log — inherent to any
1358    // path that can only be discovered mid-dispatch — but that class
1359    // needs a different fix (Layer B territory).
1360    if let Err(e) = state.task_app.precompile(&snapshot.blueprint).await {
1361        return Err(ApiError::unprocessable(format!(
1362            "run {run_id} cannot be rerun: current-head Blueprint fails to compile — {e}"
1363        )));
1364    }
1365
1366    // Atomically flip the current terminal status -> Running. A racing
1367    // rerun (or a boot-time recovery sweep, or a concurrent resume) loses
1368    // the compare-and-set and gets `409` rather than dispatching a second
1369    // driver over the same Run.
1370    let won = state
1371        .run_store
1372        .try_transition(&run_id, current, RunStatus::Running)
1373        .await
1374        .map_err(ApiError::engine)?;
1375    if !won {
1376        return Err(ApiError::conflict(format!(
1377            "run {run_id} was concurrently transitioned (or left the {current:?} state); \
1378             it is no longer rerunnable"
1379        )));
1380    }
1381
1382    // We own the run now — physically truncate the replay log at the cut
1383    // so the rerun dispatch's `append` cannot collide with the pre-rerun
1384    // row and `list_by_run` reflects the rerun's real history rather than
1385    // the pre-rerun ghost.
1386    let dropped_steps = state
1387        .replay_store
1388        .delete_from(&run_id, cut)
1389        .await
1390        .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
1391
1392    // Cursor is built from the pre-cut prefix; every retained entry hits
1393    // verbatim in the engine's replay path.
1394    let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
1395    let replayed_steps = kept.len();
1396    let cursor = ReplayCursor::from_entries(kept);
1397
1398    // `with_resume()` — a rerun-from re-derives its snapshot from the current
1399    // Blueprint exactly like resume, so a binding backfill here is stamped
1400    // `resume_backfill` (and keeps legacy replay keys, D2).
1401    let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
1402    trace
1403        .append(
1404            trace_kind::RUN_STARTED,
1405            None,
1406            None,
1407            json!({"mode": "rerun_from"}),
1408        )
1409        .await;
1410    let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1411        .with_replay_store(state.replay_store.clone())
1412        .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1413        .with_resume()
1414        .with_trace(trace);
1415
1416    let input = snapshot.into_input();
1417    let task_id = run.task_id.clone();
1418
1419    // A rerun-from is a running Run again; finalize_run resets it to
1420    // Done/Failed at the end, same as the rekick / resume paths.
1421    state
1422        .task_store
1423        .update_status(&task_id, TaskRecordStatus::Running)
1424        .await
1425        .map_err(ApiError::engine)?;
1426
1427    let ttl_secs = crate::default_run_ttl();
1428    let bg_state = state.clone();
1429    let bg_task_id = task_id.clone();
1430    let bg_run_id = run_id.clone();
1431    // Panic guard — see `catch_run_panic`.
1432    let guard_state = state.clone();
1433    let guard_task_id = task_id.clone();
1434    let guard_run_id = run_id.clone();
1435    tokio::spawn(async move {
1436        let driver = async move {
1437            let outcome = match tokio::time::timeout(
1438                Duration::from_secs(ttl_secs),
1439                bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1440            )
1441            .await
1442            {
1443                Ok(outcome) => outcome,
1444                Err(_elapsed) => {
1445                    let reason = serde_json::json!({
1446                        "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1447                    });
1448                    if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1449                        tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1450                    }
1451                    if let Err(e) = bg_state
1452                        .run_store
1453                        .update_status(&bg_run_id, RunStatus::Failed)
1454                        .await
1455                    {
1456                        tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1457                    }
1458                    if let Err(e) = bg_state
1459                        .task_store
1460                        .update_status(&bg_task_id, TaskRecordStatus::Failed)
1461                        .await
1462                    {
1463                        tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1464                    }
1465                    return;
1466                }
1467            };
1468            let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1469        };
1470        let _ = catch_run_panic(
1471            &guard_state,
1472            &guard_task_id,
1473            &guard_run_id,
1474            "rerun_from.detach",
1475            driver,
1476        )
1477        .await;
1478    });
1479
1480    Ok((
1481        StatusCode::ACCEPTED,
1482        Json(RunRerunFromResponse {
1483            run_id,
1484            task_id,
1485            replayed_steps,
1486            dropped_steps,
1487        }),
1488    ))
1489}
1490
1491/// `GET /v1/runs/:id`. Returns a single `RunRecord` (its `step_entries`
1492/// trace included).
1493pub async fn run_get(
1494    State(state): State<AppState>,
1495    Path(id): Path<String>,
1496) -> Result<Json<RunRecord>, ApiError> {
1497    let run_id =
1498        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1499    let run = state
1500        .run_store
1501        .get(&run_id)
1502        .await
1503        .map_err(map_run_store_err)?;
1504    Ok(Json(run))
1505}
1506
1507/// Query params for `GET /v1/runs` (the Run collection read).
1508#[derive(Debug, Deserialize, Default)]
1509pub struct RunsListQuery {
1510    /// Only Runs kicked from this Task.
1511    #[serde(default)]
1512    pub task_id: Option<String>,
1513    /// Only Runs currently in this status (`pending` / `running` / `done`
1514    /// / `failed` / `interrupted`).
1515    #[serde(default)]
1516    pub status: Option<String>,
1517    /// Page size cap. Omitted = no cap.
1518    #[serde(default)]
1519    pub limit: Option<usize>,
1520    /// Skip the first N matching rows (after newest-first ordering).
1521    #[serde(default)]
1522    pub offset: Option<usize>,
1523}
1524
1525/// Response body for `GET /v1/runs`.
1526#[derive(Debug, Serialize)]
1527pub struct RunsListResponse {
1528    /// Matching Runs, newest-first.
1529    pub runs: Vec<RunRecord>,
1530}
1531
1532/// `GET /v1/runs?task_id=&status=&limit=&offset=` — filtered Run
1533/// collection, newest-first. The collection read that was missing from
1534/// the Run CRUD surface (only `GET /v1/runs/:id` existed before the
1535/// per-step run stats work).
1536pub async fn runs_list(
1537    State(state): State<AppState>,
1538    Query(q): Query<RunsListQuery>,
1539) -> Result<Json<RunsListResponse>, ApiError> {
1540    let task_id = q
1541        .task_id
1542        .map(TaskId::parse)
1543        .transpose()
1544        .map_err(|e| ApiError::bad_request(format!("invalid task_id: {e}")))?;
1545    let status = q
1546        .status
1547        .as_deref()
1548        .map(|s| {
1549            serde_json::from_value::<RunStatus>(Value::String(s.to_string())).map_err(|_| {
1550                ApiError::bad_request(format!(
1551                    "invalid status {s:?} (expected pending/running/done/failed/interrupted)"
1552                ))
1553            })
1554        })
1555        .transpose()?;
1556    let runs = state
1557        .run_store
1558        .list(&RunListFilter {
1559            task_id,
1560            status,
1561            limit: q.limit,
1562            offset: q.offset,
1563        })
1564        .await
1565        .map_err(map_run_store_err)?;
1566    Ok(Json(RunsListResponse { runs }))
1567}
1568
1569/// Response body for `GET /v1/runs/:id/steps`.
1570///
1571/// `JsonSchema` is derived so `mse://api/http-endpoints` can publish the
1572/// per-step stats surface without restating [`StepEntry`]'s field list.
1573#[derive(Debug, Serialize, schemars::JsonSchema)]
1574pub struct RunStepsResponse {
1575    /// The Run the steps belong to.
1576    pub run_id: String,
1577    /// Terminal per-step stats entries, in append (dispatch) order.
1578    pub steps: Vec<StepEntry>,
1579}
1580
1581/// `GET /v1/runs/:id/steps` — the Run's terminal per-step stats
1582/// (`StepEntry` list) as a standalone sub-resource. Same data
1583/// `GET /v1/runs/:id` embeds; split out so stats consumers don't drag
1584/// the full RunRecord (launch snapshot etc.) per poll.
1585pub async fn run_steps(
1586    State(state): State<AppState>,
1587    Path(id): Path<String>,
1588) -> Result<Json<RunStepsResponse>, ApiError> {
1589    let run_id =
1590        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1591    let run = state
1592        .run_store
1593        .get(&run_id)
1594        .await
1595        .map_err(map_run_store_err)?;
1596    Ok(Json(RunStepsResponse {
1597        run_id: run.id.to_string(),
1598        steps: run.step_entries,
1599    }))
1600}
1601
1602/// Query params for `GET /v1/runs/:id/trace` — see
1603/// `mlua_swarm::store::trace::TraceQuery` for semantics (`latest` wins
1604/// over `after`; `kind` entries are comma-separated prefix matches).
1605#[derive(Debug, Deserialize, Default)]
1606pub struct RunTraceQuery {
1607    /// Forward-paging cursor: only events with `seq > after`.
1608    #[serde(default)]
1609    pub after: Option<u64>,
1610    /// Page size cap.
1611    #[serde(default)]
1612    pub limit: Option<usize>,
1613    /// Tail mode: the LAST n matching events (ascending order).
1614    #[serde(default)]
1615    pub latest: Option<usize>,
1616    /// Comma-separated kind filters, prefix match (e.g. `kind=mw.` or
1617    /// `kind=core.step_completed,worker.`).
1618    #[serde(default)]
1619    pub kind: Option<String>,
1620    /// Exact `step_ref` filter.
1621    #[serde(default)]
1622    pub step: Option<String>,
1623    /// Exact attempt filter.
1624    #[serde(default)]
1625    pub attempt: Option<u32>,
1626}
1627
1628/// Response body for `GET /v1/runs/:id/trace`.
1629#[derive(Debug, Serialize)]
1630pub struct RunTraceResponse {
1631    /// The Run the events belong to.
1632    pub run_id: String,
1633    /// Matching trace events, ascending by `seq`.
1634    pub events: Vec<TraceEvent>,
1635}
1636
1637/// `GET /v1/runs/:id/trace?after=&limit=&latest=&kind=&step=&attempt=` —
1638/// the Run's TraceEvent stream (the RunTrace rail). Note the trace rail
1639/// is deliberately uncoupled from `RunStore` (a trace can outlive or
1640/// precede its Run row), so an unknown Run id returns an empty list, not
1641/// 404.
1642pub async fn run_trace(
1643    State(state): State<AppState>,
1644    Path(id): Path<String>,
1645    Query(q): Query<RunTraceQuery>,
1646) -> Result<Json<RunTraceResponse>, ApiError> {
1647    let run_id =
1648        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1649    let query = TraceQuery {
1650        after: q.after,
1651        limit: q.limit,
1652        latest: q.latest,
1653        kinds: q
1654            .kind
1655            .as_deref()
1656            .map(|s| {
1657                s.split(',')
1658                    .map(str::trim)
1659                    .filter(|k| !k.is_empty())
1660                    .map(str::to_string)
1661                    .collect()
1662            })
1663            .unwrap_or_default(),
1664        step_ref: q.step,
1665        attempt: q.attempt,
1666    };
1667    let events = state
1668        .run_trace_store
1669        .list(&run_id, &query)
1670        .await
1671        .map_err(|e| ApiError::engine(format!("trace list: {e}")))?;
1672    Ok(Json(RunTraceResponse {
1673        run_id: run_id.to_string(),
1674        events,
1675    }))
1676}
1677
1678/// `POST /v1/runs/:id/cancel` — record a cancel request on the Run's
1679/// trace stream (`core.cancel_requested`) and mark the Run's status
1680/// to `Cancelled` for still-in-flight rows. Idempotent: repeat calls
1681/// re-append the trace event but keep the status setter idempotent
1682/// on the store side. In-flight abort itself remains a v3 carry — the
1683/// current effect is observational + status marker, matching the
1684/// `swarm_cancel` MCP tool's local semantics but reflected onto the
1685/// server-side `RunTraceStore` so `GET /v1/runs/:id/trace` reflects
1686/// it too.
1687pub async fn run_cancel(
1688    State(state): State<AppState>,
1689    Path(id): Path<String>,
1690) -> Result<axum::http::StatusCode, ApiError> {
1691    let run_id =
1692        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1693    // Load the row to prove it exists; a missing row is a 404 (aligned
1694    // with `run_delete` — cancel needs an addressable Run).
1695    let record = state
1696        .run_store
1697        .get(&run_id)
1698        .await
1699        .map_err(map_run_store_err)?;
1700    // Best-effort trace append (never gates the response) — the
1701    // authoritative record is the run_store status update below.
1702    TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
1703        .append(trace_kind::CANCEL_REQUESTED, None, None, json!({}))
1704        .await;
1705    // Flip the Run's status to Cancelled when it's still non-terminal.
1706    // Terminal Runs (Done / Failed / Interrupted / already Cancelled)
1707    // keep their outcome — cancel arriving after finalize is an
1708    // observation, not a rewrite.
1709    if matches!(record.status, RunStatus::Pending | RunStatus::Running) {
1710        if let Err(e) = state
1711            .run_store
1712            .update_status(&run_id, RunStatus::Cancelled)
1713            .await
1714        {
1715            tracing::warn!(%run_id, error = %e, "run_cancel: update_status(Cancelled) failed");
1716        }
1717    }
1718    Ok(axum::http::StatusCode::NO_CONTENT)
1719}
1720
1721/// `DELETE /v1/runs/:id` — retention prune: deletes the Run row and its
1722/// trace stream together (`404` when the Run row is absent; the trace
1723/// stream is pruned best-effort either way). Replay rows are untouched —
1724/// `ReplayStore` has its own truncation semantics owned by the
1725/// rerun-from path.
1726///
1727/// Trust tier: same auth-free open-router posture as every other route
1728/// in this module (`POST /v1/tasks` included) — the server is a
1729/// local-first, loopback-bound single-operator daemon. Flagged in
1730/// holistic review as the surface's first unauthenticated destructive
1731/// verb; acceptable under the loopback bind, revisit if the bind ever
1732/// goes non-local.
1733pub async fn run_delete(
1734    State(state): State<AppState>,
1735    Path(id): Path<String>,
1736) -> Result<axum::http::StatusCode, ApiError> {
1737    let run_id =
1738        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1739    state
1740        .run_store
1741        .delete(&run_id)
1742        .await
1743        .map_err(map_run_store_err)?;
1744    if let Err(e) = state.run_trace_store.delete_run(&run_id).await {
1745        tracing::warn!(%run_id, error = %e, "run_delete: trace delete_run failed (run row already deleted)");
1746    }
1747    Ok(axum::http::StatusCode::NO_CONTENT)
1748}
1749
1750/// Whether a Run-scoped binding has only a declaration or also carries a
1751/// provider attestation accepted by Core.
1752#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1753#[serde(rename_all = "snake_case")]
1754pub enum RunBindingStatus {
1755    /// No provider attestation was recorded; `requested` is still the exact
1756    /// declaration pinned at launch time.
1757    DeclarationOnly,
1758    /// Core accepted and pinned the provider's effective capability report.
1759    Attested,
1760}
1761
1762/// Mechanical requested/effective comparison for one immutable binding.
1763#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1764pub struct RunBindingDifference {
1765    /// Whether the requested model string and resolved model string differ.
1766    pub model_changed: bool,
1767    /// Requested tools absent from the effective grant. Accepted attestations
1768    /// normally leave this empty because launch validation is fail-closed.
1769    pub missing_requested_tools: Vec<String>,
1770    /// Effective tools not present in the minimum requested grant.
1771    pub additional_effective_tools: Vec<String>,
1772    /// Whether the requested and effective launch variants differ.
1773    pub launch_variant_changed: bool,
1774}
1775
1776/// Explain view for one agent, derived exclusively from the persisted Run
1777/// snapshot rather than from the current Blueprint registry.
1778#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1779pub struct RunBindingExplainEntry {
1780    /// Logical agent name.
1781    pub agent: String,
1782    /// Declaration tier that selected the Runner.
1783    pub runner_source: mlua_swarm::blueprint::RunnerResolutionSource,
1784    /// Provider-attestation state.
1785    pub status: RunBindingStatus,
1786    /// Exact platform-neutral request reconstructed from the pinned snapshot.
1787    pub requested: Option<BindRequest>,
1788    /// Core-validated provider report, when one was accepted at launch.
1789    pub effective: Option<BindingAttestation>,
1790    /// Mechanical difference between `requested` and `effective`; absent for
1791    /// declaration-only bindings.
1792    pub difference: Option<RunBindingDifference>,
1793    /// Final immutable replay identity, including the attestation when present.
1794    pub binding_digest: mlua_swarm::blueprint::BindingDigest,
1795}
1796
1797/// Response body for `GET /v1/runs/:id/bindings`.
1798#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1799pub struct RunBindingsExplainResponse {
1800    /// Run whose launch snapshot was inspected.
1801    #[schemars(with = "String")]
1802    pub run_id: RunId,
1803    /// Owning Task recorded on that Run.
1804    #[schemars(with = "String")]
1805    pub task_id: TaskId,
1806    /// Provenance of the inspected `bound_agents` snapshot. `launch` means the
1807    /// bindings were pinned at the Run's initial launch; `resume_backfill`
1808    /// means they were re-derived from the current Blueprint when a
1809    /// pre-binding-snapshot Run was resumed/reran — so they carry no
1810    /// launch-time pin guarantee. A snapshot that carries `bound_agents` but
1811    /// no origin marker reports `resume_backfill` (the safe side — see
1812    /// [`SnapshotOrigin::from_snapshot`]).
1813    pub snapshot_origin: SnapshotOrigin,
1814    /// Every agent snapshot in Blueprint declaration order.
1815    pub bindings: Vec<RunBindingExplainEntry>,
1816}
1817
1818fn requested_binding(bound: &BoundAgent) -> Option<BindRequest> {
1819    mlua_swarm::binding_request_for_snapshot(bound)
1820}
1821
1822fn binding_difference(
1823    requested: &BindRequest,
1824    effective: &BindingAttestation,
1825) -> RunBindingDifference {
1826    let missing_requested_tools = requested
1827        .requested_tools
1828        .iter()
1829        .filter(|tool| !effective.effective_tools.contains(tool))
1830        .cloned()
1831        .collect();
1832    let additional_effective_tools = effective
1833        .effective_tools
1834        .iter()
1835        .filter(|tool| !requested.requested_tools.contains(tool))
1836        .cloned()
1837        .collect();
1838    RunBindingDifference {
1839        model_changed: requested.requested_model != effective.resolved_model,
1840        missing_requested_tools,
1841        additional_effective_tools,
1842        launch_variant_changed: requested.launch_variant != effective.launch_variant,
1843    }
1844}
1845
1846fn validated_bound_agents_from_snapshot(
1847    run_id: &RunId,
1848    snapshot: &Value,
1849) -> Result<Option<Vec<BoundAgent>>, ApiError> {
1850    let Some(bound_value) = snapshot.get("bound_agents") else {
1851        return Ok(None);
1852    };
1853    let bound_agents: Vec<BoundAgent> =
1854        serde_json::from_value(bound_value.clone()).map_err(|e| {
1855            ApiError::unprocessable(format!(
1856                "run {run_id} contains an invalid binding snapshot: {e}"
1857            ))
1858        })?;
1859    validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
1860        ApiError::unprocessable(format!(
1861            "run {run_id} contains an inconsistent binding snapshot: {error}"
1862        ))
1863    })?;
1864    Ok(Some(bound_agents))
1865}
1866
1867/// `GET /v1/runs/:id/bindings`. Explains the exact immutable agent bindings
1868/// used by this Run. The handler never reads or resolves the current Blueprint;
1869/// old Runs without a binding snapshot return `422` instead of guessed state.
1870pub async fn run_bindings_explain(
1871    State(state): State<AppState>,
1872    Path(id): Path<String>,
1873) -> Result<Json<RunBindingsExplainResponse>, ApiError> {
1874    let run_id =
1875        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1876    let run = state
1877        .run_store
1878        .get(&run_id)
1879        .await
1880        .map_err(map_run_store_err)?;
1881    let input_json = run.input_json.as_deref().ok_or_else(|| {
1882        ApiError::unprocessable(format!(
1883            "run {run_id} has no launch snapshot; binding explain is unavailable"
1884        ))
1885    })?;
1886    let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
1887        ApiError::unprocessable(format!(
1888            "run {run_id} launch snapshot is invalid JSON; binding explain is unavailable: {e}"
1889        ))
1890    })?;
1891    let bound_agents = validated_bound_agents_from_snapshot(&run_id, &snapshot)?.ok_or_else(|| {
1892        ApiError::unprocessable(format!(
1893            "run {run_id} predates immutable binding snapshots; current Blueprint state was not consulted"
1894        ))
1895    })?;
1896
1897    let bindings = bound_agents
1898        .into_iter()
1899        .map(|bound| {
1900            let requested = requested_binding(&bound);
1901            let effective = bound.attestation.clone();
1902            let difference = requested
1903                .as_ref()
1904                .zip(effective.as_ref())
1905                .map(|(request, attestation)| binding_difference(request, attestation));
1906            RunBindingExplainEntry {
1907                agent: bound.agent.name,
1908                runner_source: bound.runner_source,
1909                status: if effective.is_some() {
1910                    RunBindingStatus::Attested
1911                } else {
1912                    RunBindingStatus::DeclarationOnly
1913                },
1914                requested,
1915                effective,
1916                difference,
1917                binding_digest: bound.binding_digest,
1918            }
1919        })
1920        .collect();
1921
1922    Ok(Json(RunBindingsExplainResponse {
1923        run_id: run.id,
1924        task_id: run.task_id,
1925        snapshot_origin: SnapshotOrigin::from_snapshot(&snapshot),
1926        bindings,
1927    }))
1928}
1929
1930/// `pub(crate)` so `crate::projection`'s `GET /v1/tasks/:id/ctx` handler can
1931/// reuse this module's existing-Task-existence-check error mapping (same
1932/// 404-vs-500 split `task_get` already applies).
1933pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1934    match e {
1935        TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1936        other => ApiError::engine(other),
1937    }
1938}
1939
1940fn map_run_store_err(e: RunStoreError) -> ApiError {
1941    match e {
1942        RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1943        other => ApiError::engine(other),
1944    }
1945}
1946
1947// ──────────────────────────────────────────────────────────────────────────
1948// UT
1949// ──────────────────────────────────────────────────────────────────────────
1950
1951#[cfg(test)]
1952mod tests {
1953    use super::*;
1954    use mlua_swarm::application::BlueprintRef;
1955    use mlua_swarm::blueprint::{
1956        current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1957        CompilerStrategy, Runner,
1958    };
1959    use mlua_swarm::core::config::EngineCfg;
1960    use mlua_swarm::core::engine::Engine;
1961    use mlua_swarm::store::output::InMemoryOutputStore;
1962    use mlua_swarm::store::run::InMemoryRunStore;
1963    use mlua_swarm::store::task::InMemoryTaskStore;
1964    use std::collections::HashMap;
1965    use std::sync::Arc;
1966    use tokio::sync::Mutex;
1967
1968    /// A single-step flow.ir Blueprint that always succeeds: `Step { ref:
1969    /// "identity", in: lit("hello"), out: $.out }` against the baseline
1970    /// `RustFn` identity worker (same shape as `seed_blueprint` in
1971    /// `mlua-swarm-cli`'s `serve.rs`, self-contained here rather than
1972    /// importing a binary crate).
1973    fn identity_blueprint() -> Blueprint {
1974        Blueprint {
1975            schema_version: current_schema_version(),
1976            id: "tasks-test-bp".into(),
1977            flow: serde_json::from_value(serde_json::json!({
1978                "kind": "step",
1979                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1980                "in": {"op": "lit", "value": "hello"},
1981                "out": {"op": "path", "at": "$.out"},
1982            }))
1983            .expect("flow parse"),
1984            agents: vec![AgentDef {
1985                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1986                kind: AgentKind::RustFn,
1987                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1988                profile: None,
1989                meta: None,
1990                runner: None,
1991                runner_ref: None,
1992                verdict: None,
1993                lints: None,
1994            }],
1995            operators: vec![],
1996            metas: vec![],
1997            hints: CompilerHints::default(),
1998            strategy: CompilerStrategy::default(),
1999            metadata: BlueprintMetadata::default(),
2000            spawner_hints: Default::default(),
2001            default_agent_kind: AgentKind::Operator,
2002            default_operator_kind: None,
2003            default_init_ctx: None,
2004            default_agent_ctx: None,
2005            default_context_policy: None,
2006            projection_placement: None,
2007            audits: vec![],
2008            degradation_policy: None,
2009            runners: vec![],
2010            default_runner: None,
2011            subprocesses: vec![],
2012            check_policy: None,
2013            blueprint_ref_includes: Vec::new(),
2014        }
2015    }
2016
2017    /// Minimal `AppState` for handler-level tests — mirrors the construction
2018    /// `build_router_full` does internally, but skips the `Router` wrapper so
2019    /// tests can call handler functions directly (this crate's established
2020    /// unit-test convention; see e.g. `operator_ws::login`'s tests).
2021    fn test_state() -> AppState {
2022        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
2023        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
2024        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
2025        AppState {
2026            engine,
2027            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
2028            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
2029            ws_operator_factory: None,
2030            data_store: Arc::new(InMemoryOutputStore::new()),
2031            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
2032            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
2033            task_store: Arc::new(InMemoryTaskStore::new()),
2034            run_store: Arc::new(InMemoryRunStore::new()),
2035            replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
2036            run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
2037            base_url: None,
2038            sync_timeout_secs: 300,
2039        }
2040    }
2041
2042    fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
2043        crate::TaskLaunchRequest {
2044            blueprint: BlueprintRef::Inline {
2045                value: Box::new(identity_blueprint()),
2046            },
2047            init_ctx: serde_json::json!({"in": "hello"}),
2048            project_root: None,
2049            work_dir: None,
2050            task_metadata: None,
2051            ttl_secs: None,
2052            operator: None,
2053            operator_sid: None,
2054            timeout_secs: None,
2055            goal: Some(goal.to_string()),
2056            detach: false,
2057            check_policy: None,
2058        }
2059    }
2060
2061    #[test]
2062    fn task_id_serializes_as_bare_string() {
2063        // Sanity check for the newtype-struct transparency relied on
2064        // throughout this module's response shapes (`TaskId` / `RunId`
2065        // serialize as plain JSON strings, not `{"0": "..."}`).
2066        let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
2067        assert_eq!(v, serde_json::json!("T-abc"));
2068    }
2069
2070    #[tokio::test]
2071    async fn post_then_get_drill_down() {
2072        let state = test_state();
2073
2074        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
2075            .await
2076            .expect("tasks_start")
2077            .0;
2078        let task_id = posted.task_id.clone();
2079        let run_id = posted.run_id.clone();
2080
2081        // GET /v1/tasks lists it.
2082        let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
2083            .await
2084            .expect("tasks_list")
2085            .0;
2086        assert!(
2087            list.iter().any(|t| t.id == task_id),
2088            "task {task_id} missing from list of {} tasks",
2089            list.len()
2090        );
2091
2092        // GET /v1/tasks/:id drills down to the Task + its Run.
2093        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2094            .await
2095            .expect("task_get")
2096            .0;
2097        assert_eq!(detail.task.id, task_id);
2098        assert_eq!(detail.task.goal, "smoke goal");
2099        assert_eq!(detail.task.status, TaskRecordStatus::Done);
2100        assert_eq!(detail.runs.len(), 1);
2101        assert_eq!(detail.runs[0].id, run_id);
2102        assert_eq!(detail.runs[0].status, RunStatus::Done);
2103
2104        // GET /v1/runs/:id returns the same Run directly.
2105        let run = run_get(State(state.clone()), Path(run_id.to_string()))
2106            .await
2107            .expect("run_get")
2108            .0;
2109        assert_eq!(run.id, run_id);
2110        assert_eq!(run.task_id, task_id);
2111        assert_eq!(run.result_ref, Some(posted.final_ctx));
2112
2113        // issue #13 run_id propagation: `POST /v1/tasks` (`run_flow_form`)
2114        // wires a `RunContext` into `TaskApplication::handle_with_run`, so
2115        // the single dispatched step must be traced into `step_entries`.
2116        assert_eq!(
2117            run.step_entries.len(),
2118            1,
2119            "expected one step_entry for the 1-step identity Blueprint, got {:?}",
2120            run.step_entries
2121        );
2122        assert_eq!(
2123            run.step_entries[0].step_ref,
2124            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2125        );
2126        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2127    }
2128
2129    // ──────────────────────────────────────────────────────────────────
2130    // GH #33 — sync-hang guards (readiness precheck / timeout ceiling)
2131    // ──────────────────────────────────────────────────────────────────
2132
2133    /// Same 1-step identity flow as [`identity_blueprint`], but opts into
2134    /// the Blueprint-global Operator delegate axis
2135    /// (`spawner_hints.layers = ["operator_delegate"]`) so a registered
2136    /// `Operator` backend can be exercised end-to-end through the real
2137    /// `tasks_start` dispatch path (`OperatorDelegateMiddleware` bypasses
2138    /// `inner.spawn` and calls `operator.execute` instead — see
2139    /// `mlua_swarm::middleware::OperatorDelegateMiddleware` doc).
2140    fn identity_blueprint_with_operator_delegate() -> Blueprint {
2141        Blueprint {
2142            spawner_hints: mlua_swarm::SpawnerHints {
2143                layers: vec!["operator_delegate".to_string()],
2144            },
2145            ..identity_blueprint()
2146        }
2147    }
2148
2149    /// `Operator` stub whose `execute` never resolves — the GH #33 Guard 2
2150    /// fixture ("a registered-but-never-acking operator").
2151    struct StallingOperator;
2152
2153    #[async_trait::async_trait]
2154    impl mlua_swarm::Operator for StallingOperator {
2155        async fn execute(
2156            &self,
2157            _ctx: &mlua_swarm::Ctx,
2158            _system: Option<String>,
2159            _prompt: Value,
2160            _worker: Option<mlua_swarm::WorkerBinding>,
2161            _worker_token: mlua_swarm::CapToken,
2162        ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
2163            std::future::pending::<()>().await;
2164            unreachable!("StallingOperator.execute must never resolve")
2165        }
2166    }
2167
2168    /// A launch request that references an operator backend by id (via
2169    /// `operator.operator_backend_id`, the coarse Guard 1 signal) against
2170    /// [`identity_blueprint_with_operator_delegate`].
2171    fn operator_launch_req(
2172        backend_id: &str,
2173        timeout_secs: Option<u64>,
2174    ) -> crate::TaskLaunchRequest {
2175        crate::TaskLaunchRequest {
2176            blueprint: BlueprintRef::Inline {
2177                value: Box::new(identity_blueprint_with_operator_delegate()),
2178            },
2179            init_ctx: serde_json::json!({"in": "hello"}),
2180            project_root: None,
2181            work_dir: None,
2182            task_metadata: None,
2183            ttl_secs: None,
2184            operator: Some(crate::OperatorReq {
2185                operator_backend_id: Some(backend_id.to_string()),
2186                ..Default::default()
2187            }),
2188            operator_sid: None,
2189            timeout_secs,
2190            goal: Some("operator delegate test goal".to_string()),
2191            detach: false,
2192            check_policy: None,
2193        }
2194    }
2195
2196    /// Guard 1: an operator-requiring launch with zero attached operators
2197    /// must fail immediately with a structured `503`, not hang waiting on
2198    /// a session nothing can serve.
2199    #[tokio::test]
2200    async fn sync_launch_zero_operators_fails_fast() {
2201        let state = test_state();
2202        // No `state.engine.register_operator(...)` call — zero operators
2203        // attached, matching `list_operator_ids()` being empty.
2204        let req = operator_launch_req("nonexistent-op", None);
2205
2206        let started = std::time::Instant::now();
2207        let result = crate::tasks_start(State(state), Json(req)).await;
2208        let elapsed = started.elapsed();
2209
2210        let err = match result {
2211            Err(e) => e,
2212            Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
2213        };
2214        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2215        assert!(
2216            err.message.contains("no operator attached"),
2217            "error message must mention the missing operator: {}",
2218            err.message
2219        );
2220        assert!(
2221            elapsed < Duration::from_secs(1),
2222            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2223        );
2224    }
2225
2226    /// Guard 2: a launch that resolves to a registered-but-stalled
2227    /// operator session must return a structured `504` within the
2228    /// requested `timeout_secs` ceiling, not hang the request forever.
2229    #[tokio::test]
2230    async fn sync_launch_stalled_times_out() {
2231        let state = test_state();
2232        state
2233            .engine
2234            .register_operator("stall-op", Arc::new(StallingOperator))
2235            .await;
2236        let req = operator_launch_req("stall-op", Some(1));
2237
2238        let started = std::time::Instant::now();
2239        // Outer safety-net timeout: if guard 2 itself regressed into an
2240        // infinite hang, fail this test loudly instead of stalling `cargo
2241        // test` indefinitely.
2242        let result = tokio::time::timeout(
2243            Duration::from_secs(5),
2244            crate::tasks_start(State(state), Json(req)),
2245        )
2246        .await
2247        .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
2248        let elapsed = started.elapsed();
2249
2250        let err = match result {
2251            Err(e) => e,
2252            Ok(_) => panic!("a stalled operator session must time out, not succeed"),
2253        };
2254        assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
2255        assert!(
2256            err.message.contains('1'),
2257            "error message must mention the configured 1s ceiling: {}",
2258            err.message
2259        );
2260        assert!(
2261            elapsed < Duration::from_secs(3),
2262            "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2263        );
2264    }
2265
2266    /// Invariant 2: a launch that never references an operator backend
2267    /// must never be rejected by guard 1 — the simplest existing passing
2268    /// fixture (`post_tasks_req`) still succeeds unaffected.
2269    #[tokio::test]
2270    async fn sync_launch_without_operator_path_unaffected() {
2271        let state = test_state();
2272        let result = crate::tasks_start(
2273            State(state),
2274            Json(post_tasks_req("non-operator launch goal")),
2275        )
2276        .await;
2277        if let Err(e) = &result {
2278            panic!(
2279                "non-operator launch must succeed unaffected by guard 1: {}",
2280                e.message
2281            );
2282        }
2283    }
2284
2285    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid
2286    /// (design doc: "0 = reject with 400 or treat as invalid — pick one
2287    /// and test it") — rejected fast, before any Task/Run side effects.
2288    #[tokio::test]
2289    async fn sync_launch_zero_timeout_secs_rejected() {
2290        let state = test_state();
2291        let mut req = post_tasks_req("zero timeout goal");
2292        req.timeout_secs = Some(0);
2293
2294        let result = crate::tasks_start(State(state), Json(req)).await;
2295        let err = match result {
2296            Err(e) => e,
2297            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2298        };
2299        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2300        assert!(
2301            err.message.contains("timeout_secs"),
2302            "error message must reference timeout_secs: {}",
2303            err.message
2304        );
2305    }
2306
2307    // ──────────────────────────────────────────────────────────────────
2308    // GH #37 — detached launch / rekick (driver decoupled from request)
2309    // ──────────────────────────────────────────────────────────────────
2310
2311    /// Polls the run store until the given Run reaches a terminal status,
2312    /// panicking after ~5s — the detached paths complete in the
2313    /// background, so tests must wait on the store rather than the
2314    /// response.
2315    async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
2316        for _ in 0..50 {
2317            let rec = state.run_store.get(run_id).await.expect("run get");
2318            if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
2319                return rec;
2320            }
2321            tokio::time::sleep(Duration::from_millis(100)).await;
2322        }
2323        panic!("run {run_id} did not reach a terminal status within ~5s");
2324    }
2325
2326    /// GH #37: `detach: true` returns `202 Accepted` immediately with
2327    /// `status: "running"` and a null `final_ctx`; the eval completes in
2328    /// the background and the Run/Task reach `Done` with the result and
2329    /// step trace persisted — the same terminal state the sync path
2330    /// produces.
2331    #[tokio::test]
2332    async fn detached_launch_returns_202_and_completes_in_background() {
2333        let state = test_state();
2334        let mut req = post_tasks_req("detached goal");
2335        req.detach = true;
2336
2337        let reply = crate::tasks_start(State(state.clone()), Json(req))
2338            .await
2339            .expect("tasks_start (detached)");
2340        assert_eq!(reply.1, StatusCode::ACCEPTED);
2341        let posted = reply.0;
2342        assert_eq!(posted.status, RunStatus::Running);
2343        assert_eq!(
2344            posted.final_ctx,
2345            serde_json::Value::Null,
2346            "a detached launch has no final_ctx at response time"
2347        );
2348
2349        let rec = wait_for_terminal_run(&state, &posted.run_id).await;
2350        assert_eq!(rec.status, RunStatus::Done);
2351        assert!(
2352            rec.result_ref.is_some(),
2353            "finalize_run must persist the background eval's final_ctx"
2354        );
2355        assert_eq!(
2356            rec.step_entries.len(),
2357            1,
2358            "the background eval must trace its step_entries like the sync path: {:?}",
2359            rec.step_entries
2360        );
2361        let task = state
2362            .task_store
2363            .get(&posted.task_id)
2364            .await
2365            .expect("task get");
2366        assert_eq!(task.status, TaskRecordStatus::Done);
2367    }
2368
2369    /// GH #37: `detach: true` + `timeout_secs` is contradictory (the sync
2370    /// ceiling has no meaning for a detached run) — rejected with `400`
2371    /// before any Task/Run side effects.
2372    #[tokio::test]
2373    async fn detached_launch_with_timeout_secs_rejected() {
2374        let state = test_state();
2375        let mut req = post_tasks_req("detached + ceiling goal");
2376        req.detach = true;
2377        req.timeout_secs = Some(60);
2378
2379        let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
2380            Err(e) => e,
2381            Ok(_) => panic!("detach + timeout_secs must be rejected"),
2382        };
2383        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2384        assert!(
2385            err.message.contains("detach"),
2386            "error message must explain the detach/timeout_secs conflict: {}",
2387            err.message
2388        );
2389        let tasks = state.task_store.list().await.expect("task list");
2390        assert!(
2391            tasks.is_empty(),
2392            "the 400 must fire before any TaskRecord is minted"
2393        );
2394    }
2395
2396    /// GH #37: a detached rekick returns `202 Accepted` with `status:
2397    /// "running"` immediately and completes in the background, adding a
2398    /// second `Done` Run to the same Task.
2399    #[tokio::test]
2400    async fn rekick_detached_returns_202_and_completes_in_background() {
2401        let state = test_state();
2402        let posted = crate::tasks_start(
2403            State(state.clone()),
2404            Json(post_tasks_req("detached rekick goal")),
2405        )
2406        .await
2407        .expect("tasks_start")
2408        .0;
2409
2410        let (status, rekicked) = task_rekick(
2411            State(state.clone()),
2412            Path(posted.task_id.to_string()),
2413            Some(Json(RunKickRequest {
2414                init_ctx_override: None,
2415                task_input_override: None,
2416                timeout_secs: None,
2417                detach: true,
2418                operator_sid: None,
2419            })),
2420        )
2421        .await
2422        .expect("task_rekick (detached)");
2423        assert_eq!(status, StatusCode::ACCEPTED);
2424        assert_eq!(rekicked.0.status, RunStatus::Running);
2425        assert_ne!(rekicked.0.run_id, posted.run_id);
2426
2427        let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
2428        assert_eq!(rec.status, RunStatus::Done);
2429        assert!(
2430            rec.result_ref.is_some(),
2431            "finalize_run must persist the background rekick's final_ctx"
2432        );
2433    }
2434
2435    /// GH #37: `detach: true` + `timeout_secs` on the rekick path is the
2436    /// same contradiction as on the launch path — `400`, no new Run
2437    /// minted.
2438    #[tokio::test]
2439    async fn rekick_detached_with_timeout_secs_rejected() {
2440        let state = test_state();
2441        let posted = crate::tasks_start(
2442            State(state.clone()),
2443            Json(post_tasks_req("detached rekick ceiling goal")),
2444        )
2445        .await
2446        .expect("tasks_start")
2447        .0;
2448
2449        let err = match task_rekick(
2450            State(state.clone()),
2451            Path(posted.task_id.to_string()),
2452            Some(Json(RunKickRequest {
2453                init_ctx_override: None,
2454                task_input_override: None,
2455                timeout_secs: Some(60),
2456                detach: true,
2457                operator_sid: None,
2458            })),
2459        )
2460        .await
2461        {
2462            Err(e) => e,
2463            Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
2464        };
2465        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2466        assert!(
2467            err.message.contains("detach"),
2468            "error message must explain the detach/timeout_secs conflict: {}",
2469            err.message
2470        );
2471        let runs = state
2472            .run_store
2473            .list_by_task(&posted.task_id)
2474            .await
2475            .expect("runs list");
2476        assert_eq!(
2477            runs.len(),
2478            1,
2479            "the 400 must fire before a second Run is minted"
2480        );
2481    }
2482
2483    // ──────────────────────────────────────────────────────────────────
2484    // Run driver panic guard (`catch_run_panic`)
2485    // ──────────────────────────────────────────────────────────────────
2486
2487    /// Seeds a `Running` Task + Run pair directly in the stores — the panic
2488    /// guard operates on an already-dispatched Run, so these tests do not
2489    /// need a real dispatch to reach it.
2490    async fn seed_running_run(state: &AppState) -> (TaskId, RunId) {
2491        let now = now_secs();
2492        let task_id = TaskId::new();
2493        let run_id = RunId::new();
2494        state
2495            .task_store
2496            .create(TaskRecord {
2497                id: task_id.clone(),
2498                goal: "panic guard goal".into(),
2499                blueprint_ref: json!({}),
2500                input_ctx: json!({}),
2501                task_input_spec: None,
2502                status: TaskRecordStatus::Running,
2503                created_at: now,
2504                updated_at: now,
2505            })
2506            .await
2507            .expect("task create");
2508        state
2509            .run_store
2510            .create(RunRecord {
2511                id: run_id.clone(),
2512                task_id: task_id.clone(),
2513                status: RunStatus::Running,
2514                step_entries: Vec::new(),
2515                degradations: Vec::new(),
2516                operator_sid: None,
2517                result_ref: None,
2518                input_json: None,
2519                created_at: now,
2520                updated_at: now,
2521            })
2522            .await
2523            .expect("run create");
2524        (task_id, run_id)
2525    }
2526
2527    async fn run_finished_events(state: &AppState, run_id: &RunId) -> Vec<TraceEvent> {
2528        state
2529            .run_trace_store
2530            .list(run_id, &TraceQuery::default())
2531            .await
2532            .expect("trace list")
2533            .into_iter()
2534            .filter(|e| e.kind == trace_kind::RUN_FINISHED)
2535            .collect()
2536    }
2537
2538    /// A panicking driver terminates its Run instead of stranding it: the
2539    /// Run goes `Interrupted` (resumable) with a structured reason naming
2540    /// the site and carrying the panic payload, the Task follows, and the
2541    /// trace stream gets exactly one terminal marker.
2542    #[tokio::test]
2543    async fn panicking_driver_marks_run_interrupted() {
2544        let state = test_state();
2545        let (task_id, run_id) = seed_running_run(&state).await;
2546
2547        let outcome: Result<(), String> =
2548            catch_run_panic(&state, &task_id, &run_id, "test.detach", async {
2549                panic!("boom");
2550            })
2551            .await;
2552        let message = outcome.expect_err("a panicking driver must report the panic to its caller");
2553        assert!(
2554            message.contains("boom"),
2555            "the panic payload must survive as the caller-visible message: {message}"
2556        );
2557
2558        let rec = state.run_store.get(&run_id).await.expect("run get");
2559        assert_eq!(
2560            rec.status,
2561            RunStatus::Interrupted,
2562            "a panicked Run must be resumable, not left Running or marked Failed"
2563        );
2564        let reason = rec
2565            .result_ref
2566            .as_ref()
2567            .and_then(|v| v.get("error"))
2568            .and_then(Value::as_str)
2569            .expect("a structured {\"error\": ...} envelope");
2570        assert!(
2571            reason.contains("boom") && reason.contains("test.detach"),
2572            "the reason must name both the panic payload and the site: {reason}"
2573        );
2574
2575        let task = state.task_store.get(&task_id).await.expect("task get");
2576        assert_eq!(task.status, TaskRecordStatus::Interrupted);
2577
2578        let finished = run_finished_events(&state, &run_id).await;
2579        assert_eq!(finished.len(), 1, "expected one terminal trace marker");
2580        assert_eq!(
2581            finished[0].payload.get("status").and_then(Value::as_str),
2582            Some("interrupted")
2583        );
2584        assert_eq!(
2585            finished[0].payload.get("reason").and_then(Value::as_str),
2586            Some("driver panic")
2587        );
2588    }
2589
2590    /// The guard is compare-and-set: a panic raised after the Run already
2591    /// finalized (say inside the trace tail that follows `finalize_run`)
2592    /// must not rewrite the terminal verdict or its result.
2593    #[tokio::test]
2594    async fn panic_guard_does_not_clobber_a_finalized_run() {
2595        let state = test_state();
2596        let (task_id, run_id) = seed_running_run(&state).await;
2597        state
2598            .run_store
2599            .set_result(&run_id, json!({"kept": true}))
2600            .await
2601            .expect("set_result");
2602        state
2603            .run_store
2604            .update_status(&run_id, RunStatus::Done)
2605            .await
2606            .expect("update_status");
2607
2608        let outcome: Result<(), String> =
2609            catch_run_panic(&state, &task_id, &run_id, "test.detach", async {
2610                panic!("late boom");
2611            })
2612            .await;
2613        assert!(
2614            outcome.is_err(),
2615            "the panic is still reported to the caller"
2616        );
2617
2618        let rec = state.run_store.get(&run_id).await.expect("run get");
2619        assert_eq!(rec.status, RunStatus::Done, "the CAS must have refused");
2620        assert_eq!(rec.result_ref, Some(json!({"kept": true})));
2621        let finished = run_finished_events(&state, &run_id).await;
2622        assert!(
2623            finished.is_empty(),
2624            "a refused CAS must not append a second terminal marker: {finished:?}"
2625        );
2626    }
2627
2628    /// The synchronous launch/rekick shape: the driver is wrapped
2629    /// `timeout(..)`-and-all, so a panic surfaces as an `Err` the handler
2630    /// maps to a `500` (`ApiError::engine`) — instead of unwinding into the
2631    /// connection task and dropping the response — while the Run is left
2632    /// `Interrupted` and therefore resumable.
2633    #[tokio::test]
2634    async fn sync_panic_returns_err_and_interrupts_run() {
2635        let state = test_state();
2636        let (task_id, run_id) = seed_running_run(&state).await;
2637
2638        let timed = catch_run_panic(
2639            &state,
2640            &task_id,
2641            &run_id,
2642            "launch.sync",
2643            tokio::time::timeout(Duration::from_secs(30), async {
2644                panic!("sync boom");
2645            }),
2646        )
2647        .await;
2648        let message = timed.expect_err("the sync path must observe the panic as an Err");
2649        assert!(message.contains("sync boom"), "payload lost: {message}");
2650
2651        let err = ApiError::engine(format!("run driver panicked: {message}"));
2652        assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
2653
2654        let rec = state.run_store.get(&run_id).await.expect("run get");
2655        assert_eq!(rec.status, RunStatus::Interrupted);
2656    }
2657
2658    #[tokio::test]
2659    async fn rekick_adds_a_second_run_to_the_same_task() {
2660        let state = test_state();
2661        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
2662            .await
2663            .expect("tasks_start")
2664            .0;
2665        let task_id = posted.task_id.clone();
2666        let first_run_id = posted.run_id.clone();
2667
2668        let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
2669            .await
2670            .expect("task_rekick");
2671        assert_eq!(status, StatusCode::CREATED);
2672        let second_run_id = rekicked.0.run_id.clone();
2673        assert_ne!(first_run_id, second_run_id);
2674
2675        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2676            .await
2677            .expect("task_get")
2678            .0;
2679        assert_eq!(
2680            detail.runs.len(),
2681            2,
2682            "expected 2 runs, got {:?}",
2683            detail.runs
2684        );
2685        let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
2686        assert!(ids.contains(&&first_run_id));
2687        assert!(ids.contains(&&second_run_id));
2688
2689        // issue #13 run_id propagation: each kick's own `EngineDispatcher`
2690        // (built fresh per `TaskApplication::handle_with_run` call) must
2691        // trace its own dispatched step into its own `RunRecord` —
2692        // independent `step_entries`, not shared/accumulated across kicks.
2693        let first_run = detail
2694            .runs
2695            .iter()
2696            .find(|r| r.id == first_run_id)
2697            .expect("first run present in detail.runs");
2698        let second_run = detail
2699            .runs
2700            .iter()
2701            .find(|r| r.id == second_run_id)
2702            .expect("second run present in detail.runs");
2703        assert_eq!(
2704            first_run.step_entries.len(),
2705            1,
2706            "first run step_entries: {:?}",
2707            first_run.step_entries
2708        );
2709        assert_eq!(
2710            second_run.step_entries.len(),
2711            1,
2712            "second run step_entries: {:?}",
2713            second_run.step_entries
2714        );
2715        assert_eq!(
2716            first_run.step_entries[0].step_ref,
2717            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2718        );
2719        assert_eq!(
2720            second_run.step_entries[0].step_ref,
2721            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2722        );
2723        assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
2724        assert_eq!(
2725            second_run.step_entries[0].status,
2726            Some("passed".to_string())
2727        );
2728        assert_ne!(
2729            first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
2730            "each kick dispatches its own StepId — runs must not share step_entries"
2731        );
2732    }
2733
2734    #[tokio::test]
2735    async fn rekick_unknown_task_returns_404() {
2736        let state = test_state();
2737        // `.expect_err()` needs the Ok variant to be `Debug`; `Json<T>`'s
2738        // `Debug` impl is not guaranteed for every `T` across axum versions,
2739        // so a plain match sidesteps that bound entirely.
2740        match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
2741            Ok(_) => panic!("expected 404 for an unknown task"),
2742            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2743        }
2744    }
2745
2746    // ──────────────────────────────────────────────────────────────────
2747    // issue #19 ST4: `RunKickRequest` (optional body / 3-layer merge)
2748    // ──────────────────────────────────────────────────────────────────
2749
2750    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
2751    /// `$.out` — unlike [`identity_blueprint`] (a fixed `lit("hello")`
2752    /// input), this one reads its `Step.in` from `ctx`, so it observes
2753    /// whichever `init_ctx` layer actually won the merge.
2754    fn greeting_blueprint() -> Blueprint {
2755        Blueprint {
2756            schema_version: current_schema_version(),
2757            id: "tasks-test-greeting-bp".into(),
2758            flow: serde_json::from_value(serde_json::json!({
2759                "kind": "step",
2760                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
2761                "in": {"op": "path", "at": "$.greeting"},
2762                "out": {"op": "path", "at": "$.out"},
2763            }))
2764            .expect("flow parse"),
2765            agents: vec![AgentDef {
2766                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
2767                kind: AgentKind::RustFn,
2768                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
2769                profile: None,
2770                meta: None,
2771                runner: None,
2772                runner_ref: None,
2773                verdict: None,
2774                lints: None,
2775            }],
2776            operators: vec![],
2777            metas: vec![],
2778            hints: CompilerHints::default(),
2779            strategy: CompilerStrategy::default(),
2780            metadata: BlueprintMetadata::default(),
2781            spawner_hints: Default::default(),
2782            default_agent_kind: AgentKind::Operator,
2783            default_operator_kind: None,
2784            default_init_ctx: None,
2785            default_agent_ctx: None,
2786            default_context_policy: None,
2787            projection_placement: None,
2788            audits: vec![],
2789            degradation_policy: None,
2790            runners: vec![],
2791            default_runner: None,
2792            subprocesses: vec![],
2793            check_policy: None,
2794            blueprint_ref_includes: Vec::new(),
2795        }
2796    }
2797
2798    fn post_greeting_task_req(
2799        greeting: &str,
2800        project_root: Option<&str>,
2801    ) -> crate::TaskLaunchRequest {
2802        crate::TaskLaunchRequest {
2803            blueprint: BlueprintRef::Inline {
2804                value: Box::new(greeting_blueprint()),
2805            },
2806            init_ctx: serde_json::json!({ "greeting": greeting }),
2807            project_root: project_root.map(str::to_string),
2808            work_dir: None,
2809            task_metadata: None,
2810            ttl_secs: None,
2811            operator: None,
2812            operator_sid: None,
2813            timeout_secs: None,
2814            goal: Some("st4 rekick goal".to_string()),
2815            detach: false,
2816            check_policy: None,
2817        }
2818    }
2819
2820    #[tokio::test]
2821    async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2822        // must_not_simplify #3: a body-less rekick must behave exactly
2823        // like pre-#19 — the Task's own `input_ctx` alone seeds the kick.
2824        let state = test_state();
2825        let posted = crate::tasks_start(
2826            State(state.clone()),
2827            Json(post_greeting_task_req("from-task", None)),
2828        )
2829        .await
2830        .expect("tasks_start")
2831        .0;
2832        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2833
2834        let (status, rekicked) =
2835            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2836                .await
2837                .expect("task_rekick");
2838        assert_eq!(status, StatusCode::CREATED);
2839
2840        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2841            .await
2842            .expect("run_get")
2843            .0;
2844        assert_eq!(
2845            run.result_ref.expect("result_ref present")["out"]["echoed"],
2846            "from-task"
2847        );
2848    }
2849
2850    #[tokio::test]
2851    async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2852        let state = test_state();
2853        let posted = crate::tasks_start(
2854            State(state.clone()),
2855            Json(post_greeting_task_req("from-task", None)),
2856        )
2857        .await
2858        .expect("tasks_start")
2859        .0;
2860        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2861
2862        let (status, rekicked) = task_rekick(
2863            State(state.clone()),
2864            Path(posted.task_id.to_string()),
2865            Some(Json(RunKickRequest {
2866                init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2867                task_input_override: None,
2868                timeout_secs: None,
2869                detach: false,
2870                operator_sid: None,
2871            })),
2872        )
2873        .await
2874        .expect("task_rekick");
2875        assert_eq!(status, StatusCode::CREATED);
2876
2877        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2878            .await
2879            .expect("run_get")
2880            .0;
2881        assert_eq!(
2882            run.result_ref.expect("result_ref present")["out"]["echoed"],
2883            "from-run",
2884            "Run's init_ctx_override must win over the stored Task input_ctx"
2885        );
2886    }
2887
2888    #[tokio::test]
2889    async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2890        // Done Criteria: "Task record が task-level canonical fields を
2891        // 保持している時の rekick test". A Task created with
2892        // `project_root` set gets a `task_input_spec` snapshot; a
2893        // body-less rekick must both dispatch successfully (the stored
2894        // spec decodes and resolves without erroring) and leave
2895        // `TaskRecord.task_input_spec` untouched (must_not_simplify #4 —
2896        // a rekick never mutates the stored Task-level snapshot).
2897        let state = test_state();
2898        let posted = crate::tasks_start(
2899            State(state.clone()),
2900            Json(post_greeting_task_req("from-task", Some("/repo"))),
2901        )
2902        .await
2903        .expect("tasks_start")
2904        .0;
2905
2906        let before = state
2907            .task_store
2908            .get(&posted.task_id)
2909            .await
2910            .expect("task fetch");
2911        let before_spec: Option<TaskInputSpec> = before
2912            .task_input_spec
2913            .as_ref()
2914            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2915        assert_eq!(
2916            before_spec,
2917            Some(TaskInputSpec {
2918                project_root: Some("/repo".to_string()),
2919                work_dir: None,
2920                task_metadata: None,
2921            })
2922        );
2923
2924        let (status, _rekicked) =
2925            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2926                .await
2927                .expect("task_rekick");
2928        assert_eq!(status, StatusCode::CREATED);
2929
2930        let after = state
2931            .task_store
2932            .get(&posted.task_id)
2933            .await
2934            .expect("task fetch");
2935        assert_eq!(
2936            after.task_input_spec, before.task_input_spec,
2937            "rekick must not mutate the stored Task-level task_input_spec snapshot"
2938        );
2939    }
2940
2941    #[tokio::test]
2942    async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2943        // must_not_simplify #4: `task_input_override` wins for this kick
2944        // only — the stored `TaskRecord.task_input_spec` is untouched.
2945        let state = test_state();
2946        let posted = crate::tasks_start(
2947            State(state.clone()),
2948            Json(post_greeting_task_req("from-task", Some("/repo"))),
2949        )
2950        .await
2951        .expect("tasks_start")
2952        .0;
2953
2954        let (status, _rekicked) = task_rekick(
2955            State(state.clone()),
2956            Path(posted.task_id.to_string()),
2957            Some(Json(RunKickRequest {
2958                init_ctx_override: None,
2959                task_input_override: Some(TaskInputSpec {
2960                    project_root: Some("/override".to_string()),
2961                    work_dir: None,
2962                    task_metadata: None,
2963                }),
2964                timeout_secs: None,
2965                detach: false,
2966                operator_sid: None,
2967            })),
2968        )
2969        .await
2970        .expect("task_rekick");
2971        assert_eq!(status, StatusCode::CREATED);
2972
2973        let after = state
2974            .task_store
2975            .get(&posted.task_id)
2976            .await
2977            .expect("task fetch");
2978        let after_spec: Option<TaskInputSpec> = after
2979            .task_input_spec
2980            .as_ref()
2981            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2982        assert_eq!(
2983            after_spec,
2984            Some(TaskInputSpec {
2985                project_root: Some("/repo".to_string()),
2986                work_dir: None,
2987                task_metadata: None,
2988            }),
2989            "a per-Run task_input_override must not leak into the stored TaskRecord"
2990        );
2991    }
2992
2993    // ──────────────────────────────────────────────────────────────────
2994    // GH #33 → task_rekick — sync-hang guards (issue #35 ST3 parity)
2995    // ──────────────────────────────────────────────────────────────────
2996
2997    /// A launch request for [`identity_blueprint_with_operator_delegate`]
2998    /// that does **not** reference an operator backend (`operator: None`)
2999    /// — used to create a rekick-able Task without tripping
3000    /// `run_flow_form`'s own Guard 1 at initial-launch time (the launch
3001    /// itself dispatches through the plain baseline path since
3002    /// `ctx.operator.operator` stays unset either way; the BP's
3003    /// `operator_delegate` layer only matters to `task_rekick`'s Guard 1,
3004    /// which reads `resolved_bp.spawner_hints.layers` directly rather than
3005    /// a per-request field).
3006    fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
3007        crate::TaskLaunchRequest {
3008            blueprint: BlueprintRef::Inline {
3009                value: Box::new(identity_blueprint_with_operator_delegate()),
3010            },
3011            init_ctx: serde_json::json!({"in": "hello"}),
3012            project_root: None,
3013            work_dir: None,
3014            task_metadata: None,
3015            ttl_secs: None,
3016            operator: None,
3017            operator_sid: None,
3018            timeout_secs: None,
3019            goal: Some(goal.to_string()),
3020            detach: false,
3021            check_policy: None,
3022        }
3023    }
3024
3025    /// Guard 1 (adapted signal): a Task whose stored Blueprint declares
3026    /// the `operator_delegate` layer, rekicked with zero attached
3027    /// operators, must fail immediately with a structured `503` — not
3028    /// dispatch and not hang waiting on a session nothing can serve.
3029    #[tokio::test]
3030    async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
3031        let state = test_state();
3032        let posted = crate::tasks_start(
3033            State(state.clone()),
3034            Json(delegate_launch_req("operator delegate rekick goal")),
3035        )
3036        .await
3037        .expect("tasks_start (no operator referenced, dispatches through baseline)")
3038        .0;
3039        // No `state.engine.register_operator(...)` call — zero operators
3040        // attached, matching `list_operator_ids()` being empty.
3041
3042        let started = std::time::Instant::now();
3043        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
3044        let elapsed = started.elapsed();
3045
3046        let err = match result {
3047            Err(e) => e,
3048            Ok(_) => panic!(
3049                "rekicking a Task whose Blueprint declares operator_delegate with zero \
3050                 attached operators must fail, not dispatch"
3051            ),
3052        };
3053        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
3054        assert!(
3055            err.message.contains("no operator attached"),
3056            "error message must mention the missing operator: {}",
3057            err.message
3058        );
3059        assert!(
3060            elapsed < Duration::from_secs(1),
3061            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
3062        );
3063    }
3064
3065    /// Guard 2: a rekick with a `timeout_secs` ceiling shorter than the
3066    /// dispatch takes must return a structured `504` within the outer
3067    /// safety-net timeout, not hang the request forever.
3068    #[tokio::test]
3069    async fn rekick_stalled_operator_times_out() {
3070        let state = test_state();
3071        state
3072            .engine
3073            .register_operator("stall-op", Arc::new(StallingOperator))
3074            .await;
3075        let posted = crate::tasks_start(
3076            State(state.clone()),
3077            Json(delegate_launch_req("stalled rekick goal")),
3078        )
3079        .await
3080        .expect("tasks_start")
3081        .0;
3082
3083        let started = std::time::Instant::now();
3084        // Outer safety-net timeout: if guard 2 itself regressed into an
3085        // infinite hang, fail this test loudly instead of stalling `cargo
3086        // test` indefinitely.
3087        let result = tokio::time::timeout(
3088            Duration::from_secs(5),
3089            task_rekick(
3090                State(state),
3091                Path(posted.task_id.to_string()),
3092                Some(Json(RunKickRequest {
3093                    init_ctx_override: None,
3094                    task_input_override: None,
3095                    timeout_secs: Some(1),
3096                    detach: false,
3097                    operator_sid: None,
3098                })),
3099            ),
3100        )
3101        .await
3102        .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
3103        let elapsed = started.elapsed();
3104
3105        match &result {
3106            Err(e) => {
3107                assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
3108                assert!(
3109                    e.message.contains('1'),
3110                    "error message must mention the configured 1s ceiling: {}",
3111                    e.message
3112                );
3113                assert!(
3114                    elapsed < Duration::from_secs(3),
3115                    "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
3116                );
3117            }
3118            Ok(_) => {
3119                // This rekick passes no `operator_sid`, so its
3120                // `operator_backend_id` stays `None` and the
3121                // registered-but-unattached `StallingOperator` is never
3122                // actually engaged by the dispatch; the flow resolves
3123                // through the plain baseline path instead. Guard 2's
3124                // `tokio::time::timeout`
3125                // wrap is exercised (and does not falsely fire) rather
3126                // than tripped — assert the fast-success shape so a
3127                // regression that makes rekick dispatch slow (or that
3128                // makes Guard 2 falsely trip on a fast dispatch) is still
3129                // caught by the elapsed-time assertion below.
3130                assert!(
3131                    elapsed < Duration::from_secs(1),
3132                    "a rekick that never engages an Operator (task_rekick has no \
3133                     per-request operator override) must resolve fast, not stall: took {elapsed:?}"
3134                );
3135            }
3136        }
3137    }
3138
3139    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid —
3140    /// rejected fast, before any Task/Run side effects (the pre-existing
3141    /// run count for the rekicked Task is unchanged).
3142    #[tokio::test]
3143    async fn rekick_timeout_secs_zero_rejected() {
3144        let state = test_state();
3145        let posted = crate::tasks_start(
3146            State(state.clone()),
3147            Json(post_tasks_req("zero timeout rekick goal")),
3148        )
3149        .await
3150        .expect("tasks_start")
3151        .0;
3152
3153        let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
3154            .await
3155            .expect("task_get")
3156            .0;
3157        let runs_before = before.runs.len();
3158
3159        let result = task_rekick(
3160            State(state.clone()),
3161            Path(posted.task_id.to_string()),
3162            Some(Json(RunKickRequest {
3163                init_ctx_override: None,
3164                task_input_override: None,
3165                timeout_secs: Some(0),
3166                detach: false,
3167                operator_sid: None,
3168            })),
3169        )
3170        .await;
3171        let err = match result {
3172            Err(e) => e,
3173            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
3174        };
3175        assert_eq!(err.status, StatusCode::BAD_REQUEST);
3176        assert!(
3177            err.message.contains("timeout_secs"),
3178            "error message must reference timeout_secs: {}",
3179            err.message
3180        );
3181
3182        let after = task_get(State(state), Path(posted.task_id.to_string()))
3183            .await
3184            .expect("task_get")
3185            .0;
3186        assert_eq!(
3187            after.runs.len(),
3188            runs_before,
3189            "a rejected timeout_secs: Some(0) rekick must not create a new Run"
3190        );
3191    }
3192
3193    /// Invariant: a plain (non-`operator_delegate`) Task rekick must
3194    /// never be rejected by Guard 1 — the simplest existing passing
3195    /// rekick fixture still succeeds unaffected.
3196    #[tokio::test]
3197    async fn rekick_non_operator_path_unaffected_by_guard_1() {
3198        let state = test_state();
3199        let posted = crate::tasks_start(
3200            State(state.clone()),
3201            Json(post_tasks_req("non-operator rekick goal")),
3202        )
3203        .await
3204        .expect("tasks_start")
3205        .0;
3206
3207        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
3208        if let Err(e) = &result {
3209            panic!(
3210                "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
3211                 guard 1: {}",
3212                e.message
3213            );
3214        }
3215    }
3216
3217    // ──────────────────────────────────────────────────────────────────
3218    // B-1: rekick operator_sid pin (parity with POST /v1/tasks)
3219    // ──────────────────────────────────────────────────────────────────
3220
3221    /// A rekick pinning an unknown `operator_sid` fails fast with a `400`
3222    /// before any Task/Run store write — no new Run is minted (S2 parity
3223    /// with `run_flow_form`'s `operator_sid` fail-fast).
3224    #[tokio::test]
3225    async fn rekick_unknown_operator_sid_rejected_before_side_effects() {
3226        let state = test_state();
3227        let posted = crate::tasks_start(
3228            State(state.clone()),
3229            Json(post_tasks_req("unknown operator_sid rekick goal")),
3230        )
3231        .await
3232        .expect("tasks_start")
3233        .0;
3234
3235        let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
3236            .await
3237            .expect("task_get")
3238            .0;
3239        let runs_before = before.runs.len();
3240
3241        let result = task_rekick(
3242            State(state.clone()),
3243            Path(posted.task_id.to_string()),
3244            Some(Json(RunKickRequest {
3245                init_ctx_override: None,
3246                task_input_override: None,
3247                timeout_secs: None,
3248                detach: false,
3249                operator_sid: Some("S-not-registered".to_string()),
3250            })),
3251        )
3252        .await;
3253        let err = match result {
3254            Err(e) => e,
3255            Ok(_) => panic!("an unknown operator_sid must be rejected, not dispatched"),
3256        };
3257        assert_eq!(err.status, StatusCode::BAD_REQUEST);
3258        assert!(
3259            err.message.contains("operator_sid"),
3260            "error message must reference operator_sid: {}",
3261            err.message
3262        );
3263
3264        let after = task_get(State(state), Path(posted.task_id.to_string()))
3265            .await
3266            .expect("task_get")
3267            .0;
3268        assert_eq!(
3269            after.runs.len(),
3270            runs_before,
3271            "a rejected unknown-operator_sid rekick must not create a new Run"
3272        );
3273    }
3274
3275    /// A rekick pinning a *registered* `operator_sid` dispatches
3276    /// successfully and persists the sid verbatim onto the new
3277    /// `RunRecord.operator_sid`. The Task's stored Blueprint is the plain
3278    /// baseline (no `operator_delegate` layer), so the registered Operator
3279    /// is never actually engaged — the kick resolves through the baseline
3280    /// path and the assertion is purely on the persisted correlation
3281    /// field.
3282    #[tokio::test]
3283    async fn rekick_with_registered_operator_sid_persists_it_on_the_run() {
3284        let state = test_state();
3285        // Register an Operator whose sid the rekick can pin. It is never
3286        // engaged (plain Blueprint does not delegate), so a `StallingOperator`
3287        // is a fine stand-in for "a live, registered session".
3288        state
3289            .engine
3290            .register_operator("S-live-op", Arc::new(StallingOperator))
3291            .await;
3292        let posted = crate::tasks_start(
3293            State(state.clone()),
3294            Json(post_tasks_req("registered operator_sid rekick goal")),
3295        )
3296        .await
3297        .expect("tasks_start")
3298        .0;
3299
3300        let (status, rekicked) = task_rekick(
3301            State(state.clone()),
3302            Path(posted.task_id.to_string()),
3303            Some(Json(RunKickRequest {
3304                init_ctx_override: None,
3305                task_input_override: None,
3306                timeout_secs: None,
3307                detach: false,
3308                operator_sid: Some("S-live-op".to_string()),
3309            })),
3310        )
3311        .await
3312        .expect("task_rekick with a registered operator_sid");
3313        assert_eq!(status, StatusCode::CREATED);
3314
3315        let run = state
3316            .run_store
3317            .get(&rekicked.0.run_id)
3318            .await
3319            .expect("run get");
3320        assert_eq!(
3321            run.operator_sid,
3322            Some("S-live-op".to_string()),
3323            "the pinned operator_sid must be persisted verbatim on the RunRecord"
3324        );
3325    }
3326
3327    /// A pinned rekick feeds BOTH axes from the one `operator_sid`: the
3328    /// delegate layer's session backend (unchanged behaviour) and the new
3329    /// run-scoped pin the compiler / binding provider read. The launch
3330    /// snapshot is where a resumed Run picks the pin back up, so that is
3331    /// what the assertion reads.
3332    #[tokio::test]
3333    async fn rekick_pin_reaches_both_axes_and_survives_in_the_launch_snapshot() {
3334        let state = test_state();
3335        state
3336            .engine
3337            .register_operator("S-live-op", Arc::new(StallingOperator))
3338            .await;
3339        let posted = crate::tasks_start(
3340            State(state.clone()),
3341            Json(post_tasks_req("pinned rekick snapshot goal")),
3342        )
3343        .await
3344        .expect("tasks_start")
3345        .0;
3346
3347        let (_status, rekicked) = task_rekick(
3348            State(state.clone()),
3349            Path(posted.task_id.to_string()),
3350            Some(Json(RunKickRequest {
3351                init_ctx_override: None,
3352                task_input_override: None,
3353                timeout_secs: None,
3354                detach: false,
3355                operator_sid: Some("S-live-op".to_string()),
3356            })),
3357        )
3358        .await
3359        .expect("task_rekick with a registered operator_sid");
3360
3361        let run = state
3362            .run_store
3363            .get(&rekicked.0.run_id)
3364            .await
3365            .expect("run get");
3366        let snapshot: Value = serde_json::from_str(
3367            run.input_json
3368                .as_deref()
3369                .expect("a rekicked Run persists its launch snapshot"),
3370        )
3371        .expect("snapshot json");
3372        assert_eq!(
3373            snapshot["operator_backend_id"],
3374            serde_json::json!("S-live-op"),
3375            "the delegate axis keeps receiving the sid exactly as before: {snapshot}"
3376        );
3377        assert_eq!(
3378            snapshot["operator_pin"],
3379            serde_json::json!("S-live-op"),
3380            "the same sid must also pin the AgentSpec axis: {snapshot}"
3381        );
3382    }
3383
3384    /// An unpinned launch leaves both fields absent — the pre-pin snapshot
3385    /// shape, byte-for-byte.
3386    #[tokio::test]
3387    async fn unpinned_launch_snapshot_carries_neither_axis() {
3388        let state = test_state();
3389        let posted = crate::tasks_start(
3390            State(state.clone()),
3391            Json(post_tasks_req("unpinned snapshot goal")),
3392        )
3393        .await
3394        .expect("tasks_start")
3395        .0;
3396        let run = state.run_store.get(&posted.run_id).await.expect("run get");
3397        let snapshot: Value =
3398            serde_json::from_str(run.input_json.as_deref().expect("launch snapshot"))
3399                .expect("snapshot json");
3400        assert_eq!(snapshot["operator_backend_id"], Value::Null);
3401        assert_eq!(snapshot["operator_pin"], Value::Null);
3402        assert_eq!(
3403            run.operator_sid, None,
3404            "an unpinned launch records no session on the Run"
3405        );
3406    }
3407
3408    /// A snapshot written before the pin field existed still decodes, and
3409    /// resumes unpinned.
3410    #[test]
3411    fn pre_pin_launch_snapshot_still_decodes() {
3412        let snapshot = serde_json::json!({
3413            "blueprint": { "kind": "inline", "value": identity_blueprint() },
3414            "operator_id": "http-run",
3415            "role": "operator",
3416            "ttl": { "secs": 60, "nanos": 0 },
3417            "init_ctx": {},
3418            "operator_kind": null,
3419            "bridge_id": null,
3420            "hook_id": null,
3421            "operator_backend_id": null,
3422            "task_input": null,
3423            "check_policy": null,
3424        });
3425        let decoded: RunLaunchSnapshot =
3426            serde_json::from_value(snapshot).expect("a pre-pin snapshot must still decode");
3427        assert!(decoded.into_input().operator_pin.is_none());
3428    }
3429
3430    #[tokio::test]
3431    async fn run_get_unknown_id_returns_404() {
3432        let state = test_state();
3433        match run_get(State(state), Path("R-does-not-exist".to_string())).await {
3434            Ok(_) => panic!("expected 404 for an unknown run"),
3435            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3436        }
3437    }
3438
3439    #[tokio::test]
3440    async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
3441        let state = test_state();
3442        let posted = crate::tasks_start(
3443            State(state.clone()),
3444            Json(post_tasks_req("binding explain")),
3445        )
3446        .await
3447        .expect("tasks_start")
3448        .0;
3449        let run = state
3450            .run_store
3451            .get(&posted.run_id)
3452            .await
3453            .expect("stored run");
3454        let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3455        let mut bound_agents: Vec<BoundAgent> =
3456            serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
3457        let bound = &mut bound_agents[0];
3458        bound.runner = Some(Runner::WsClaudeCode {
3459            variant: "coder".to_string(),
3460            tools: vec!["Read".to_string()],
3461        });
3462        bound.recompute_binding_digest().unwrap();
3463        let request_digest = bound.binding_digest.clone();
3464        bound
3465            .set_attestation(BindingAttestation {
3466                request_digest: request_digest.clone(),
3467                provider_id: "operator-manifest".to_string(),
3468                provider_revision: Some("claude-code-1.2".to_string()),
3469                resolved_model: Some("claude-sonnet-4".to_string()),
3470                effective_tools: vec!["Bash".to_string(), "Read".to_string()],
3471                launch_variant: Some("coder".to_string()),
3472                capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
3473                    b"manifest-v1",
3474                )),
3475            })
3476            .unwrap();
3477        snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
3478        state
3479            .run_store
3480            .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3481            .await
3482            .unwrap();
3483
3484        let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3485            .await
3486            .expect("binding explain")
3487            .0;
3488        let entry = &explained.bindings[0];
3489        assert_eq!(entry.status, RunBindingStatus::Attested);
3490        assert_eq!(
3491            entry.requested.as_ref().unwrap().request_digest,
3492            request_digest
3493        );
3494        assert_eq!(
3495            entry
3496                .effective
3497                .as_ref()
3498                .unwrap()
3499                .provider_revision
3500                .as_deref(),
3501            Some("claude-code-1.2")
3502        );
3503        assert_eq!(
3504            entry
3505                .difference
3506                .as_ref()
3507                .unwrap()
3508                .additional_effective_tools,
3509            vec!["Bash"]
3510        );
3511        assert!(entry
3512            .difference
3513            .as_ref()
3514            .unwrap()
3515            .missing_requested_tools
3516            .is_empty());
3517        assert_ne!(entry.binding_digest, request_digest);
3518    }
3519
3520    #[tokio::test]
3521    async fn run_bindings_explain_reports_snapshot_origin() {
3522        let state = test_state();
3523        let posted =
3524            crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
3525                .await
3526                .expect("tasks_start")
3527                .0;
3528
3529        // An initial launch pins `origin = launch`.
3530        let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3531            .await
3532            .expect("binding explain")
3533            .0;
3534        assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
3535
3536        // Flip the persisted marker to `resume_backfill` → explain reflects it.
3537        let run = state.run_store.get(&posted.run_id).await.unwrap();
3538        let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3539        snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
3540        state
3541            .run_store
3542            .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3543            .await
3544            .unwrap();
3545        let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3546            .await
3547            .expect("binding explain")
3548            .0;
3549        assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3550
3551        // A snapshot with `bound_agents` but NO origin marker maps to the
3552        // safe side (`resume_backfill`) and still returns 200 — the 422 is
3553        // reserved for snapshots lacking `bound_agents` entirely.
3554        snapshot
3555            .as_object_mut()
3556            .unwrap()
3557            .remove("bound_agents_origin");
3558        state
3559            .run_store
3560            .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3561            .await
3562            .unwrap();
3563        let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3564            .await
3565            .expect("explain still 200 without an origin marker")
3566            .0;
3567        assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3568    }
3569
3570    #[tokio::test]
3571    async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
3572        let state = test_state();
3573        let posted = crate::tasks_start(
3574            State(state.clone()),
3575            Json(post_tasks_req("legacy binding explain")),
3576        )
3577        .await
3578        .expect("tasks_start")
3579        .0;
3580        state
3581            .run_store
3582            .set_input_json(&posted.run_id, "{}".to_string())
3583            .await
3584            .unwrap();
3585
3586        let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3587            .await
3588            .expect_err("legacy run must not be re-resolved");
3589        assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3590        assert!(error
3591            .message
3592            .contains("current Blueprint state was not consulted"));
3593    }
3594
3595    #[tokio::test]
3596    async fn run_bindings_explain_rejects_a_tampered_snapshot() {
3597        let state = test_state();
3598        let posted = crate::tasks_start(
3599            State(state.clone()),
3600            Json(post_tasks_req("tampered binding explain")),
3601        )
3602        .await
3603        .expect("tasks_start")
3604        .0;
3605        let run = state.run_store.get(&posted.run_id).await.unwrap();
3606        let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3607        snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
3608        state
3609            .run_store
3610            .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3611            .await
3612            .unwrap();
3613
3614        let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3615            .await
3616            .expect_err("digest drift must fail closed");
3617        assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3618        assert!(error.message.contains("inconsistent binding snapshot"));
3619    }
3620
3621    #[tokio::test]
3622    async fn task_get_unknown_id_returns_404() {
3623        let state = test_state();
3624        match task_get(State(state), Path("T-does-not-exist".to_string())).await {
3625            Ok(_) => panic!("expected 404 for an unknown task"),
3626            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3627        }
3628    }
3629
3630    // ──────────────────────────────────────────────────────────────────
3631    // GH #76 error surface: finalize_run Err arm populates result_ref with the
3632    // structured failure envelope; run_get surfaces it.
3633    // ──────────────────────────────────────────────────────────────────
3634
3635    /// Seed a Task + Run row so `finalize_run` can update them.
3636    async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
3637        let task_id = TaskId::new();
3638        let run_id = RunId::new();
3639        state
3640            .task_store
3641            .create(TaskRecord {
3642                id: task_id.clone(),
3643                goal: "finalize-run-err-envelope".to_string(),
3644                blueprint_ref: json!("inline"),
3645                input_ctx: Value::Null,
3646                task_input_spec: None,
3647                status: TaskRecordStatus::Running,
3648                created_at: 0,
3649                updated_at: 0,
3650            })
3651            .await
3652            .expect("seed TaskRecord");
3653        state
3654            .run_store
3655            .create(RunRecord {
3656                id: run_id.clone(),
3657                task_id: task_id.clone(),
3658                status: RunStatus::Running,
3659                step_entries: Vec::new(),
3660                degradations: Vec::new(),
3661                operator_sid: None,
3662                result_ref: None,
3663                input_json: Some("{}".to_string()),
3664                created_at: 0,
3665                updated_at: 0,
3666            })
3667            .await
3668            .expect("seed RunRecord");
3669        (task_id, run_id)
3670    }
3671
3672    #[tokio::test]
3673    async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
3674        let state = test_state();
3675        let (task_id, run_id) = seed_task_and_run(&state).await;
3676
3677        let err: Result<TaskApplicationOutput, TaskApplicationError> =
3678            Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3679                message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
3680                failed_step: Some("gate".to_string()),
3681                verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
3682                partial_ctx: Some(
3683                    json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
3684                ),
3685            }));
3686
3687        let _ = finalize_run(&state, &task_id, &run_id, err).await;
3688
3689        let run = state.run_store.get(&run_id).await.expect("run present");
3690        assert_eq!(run.status, RunStatus::Failed);
3691        let envelope = run
3692            .result_ref
3693            .as_ref()
3694            .expect("result_ref must be Some on Err arm");
3695        assert_eq!(
3696            envelope["error"]["message"],
3697            "blocked: {\"verdict\":\"BLOCKED\"}"
3698        );
3699        assert_eq!(envelope["error"]["failed_step"], "gate");
3700        assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
3701        assert_eq!(
3702            envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
3703            "blocked"
3704        );
3705
3706        // Owning Task status also flipped.
3707        let task = state.task_store.get(&task_id).await.expect("task present");
3708        assert_eq!(task.status, TaskRecordStatus::Failed);
3709    }
3710
3711    #[tokio::test]
3712    async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
3713        let state = test_state();
3714        let (task_id, run_id) = seed_task_and_run(&state).await;
3715
3716        // A non-FlowEval error (e.g. NoStore) still lands the envelope
3717        // shape with `error.message` populated; the structural fields go
3718        // to JSON `null` (no breadcrumb source available).
3719        let err: Result<TaskApplicationOutput, TaskApplicationError> =
3720            Err(TaskApplicationError::NoStore);
3721
3722        let _ = finalize_run(&state, &task_id, &run_id, err).await;
3723        let run = state.run_store.get(&run_id).await.expect("run present");
3724        let envelope = run
3725            .result_ref
3726            .as_ref()
3727            .expect("result_ref must be Some on Err arm");
3728        assert!(envelope["error"]["message"]
3729            .as_str()
3730            .expect("message string")
3731            .contains("store"));
3732        assert_eq!(envelope["error"]["failed_step"], Value::Null);
3733        assert_eq!(envelope["error"]["verdict_value"], Value::Null);
3734        assert_eq!(envelope["partial_ctx"], Value::Null);
3735    }
3736
3737    /// Regression: the Ok arm still stores the raw `final_ctx` verbatim
3738    /// (NOT an envelope) — consumers that never saw a failure keep their
3739    /// pre-#76 shape. The disambiguation is the top-level `"error"` key:
3740    /// present iff the Err arm fired.
3741    #[tokio::test]
3742    async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
3743        let state = test_state();
3744        let (task_id, run_id) = seed_task_and_run(&state).await;
3745
3746        let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
3747            token: mlua_swarm::CapToken {
3748                agent_id: "ut".to_string(),
3749                role: mlua_swarm::Role::Operator,
3750                scopes: vec!["*".to_string()],
3751                issued_at: 0,
3752                expire_at: u64::MAX,
3753                max_uses: None,
3754                nonce: "ut-nonce".to_string(),
3755                sig_hex: String::new(),
3756            },
3757            final_ctx: json!({"out": {"echoed": "hi"}}),
3758            bound_version: None,
3759        });
3760
3761        let _ = finalize_run(&state, &task_id, &run_id, ok).await;
3762        let run = state.run_store.get(&run_id).await.expect("run present");
3763        assert_eq!(run.status, RunStatus::Done);
3764        let stored = run.result_ref.as_ref().expect("result_ref Some");
3765        // Raw final_ctx verbatim — NOT an envelope; no top-level "error" key.
3766        assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
3767        assert!(
3768            stored.get("error").is_none(),
3769            "Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
3770        );
3771    }
3772
3773    /// `GET /v1/runs/:id` returns the `RunRecord` verbatim, so after a
3774    /// finalize_run Err arm the structured envelope surfaces through the
3775    /// existing handler — no new response type needed. Failure detection
3776    /// via the top-level `"error"` key inside `result_ref`.
3777    #[tokio::test]
3778    async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
3779        let state = test_state();
3780        let (_task_id, run_id) = seed_task_and_run(&state).await;
3781        let err: Result<TaskApplicationOutput, TaskApplicationError> =
3782            Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3783                message: "blocked: bad verdict".to_string(),
3784                failed_step: Some("scout".to_string()),
3785                verdict_value: Some(json!("BLOCKED")),
3786                partial_ctx: Some(json!({"steps": {}})),
3787            }));
3788        let _ = finalize_run(&state, &_task_id, &run_id, err).await;
3789
3790        let Json(run) = run_get(State(state), Path(run_id.to_string()))
3791            .await
3792            .expect("run_get");
3793        assert_eq!(run.status, RunStatus::Failed);
3794        let envelope = run.result_ref.expect("result_ref Some");
3795        assert_eq!(envelope["error"]["failed_step"], "scout");
3796        assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
3797    }
3798}