Skip to main content

mlua_swarm_server/
tasks.rs

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