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