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