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