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::from_task_resolve(&e, &format!("task {task_id}: bp resolve")))?;
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            subprocesses: vec![],
1454            check_policy: None,
1455            blueprint_ref_includes: Vec::new(),
1456        }
1457    }
1458
1459    /// Minimal `AppState` for handler-level tests — mirrors the construction
1460    /// `build_router_full` does internally, but skips the `Router` wrapper so
1461    /// tests can call handler functions directly (this crate's established
1462    /// unit-test convention; see e.g. `operator_ws::login`'s tests).
1463    fn test_state() -> AppState {
1464        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1465        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1466        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1467        AppState {
1468            engine,
1469            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1470            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1471            ws_operator_factory: None,
1472            data_store: Arc::new(InMemoryOutputStore::new()),
1473            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1474            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1475            task_store: Arc::new(InMemoryTaskStore::new()),
1476            run_store: Arc::new(InMemoryRunStore::new()),
1477            replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1478            base_url: None,
1479            sync_timeout_secs: 300,
1480        }
1481    }
1482
1483    fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
1484        crate::TaskLaunchRequest {
1485            blueprint: BlueprintRef::Inline {
1486                value: Box::new(identity_blueprint()),
1487            },
1488            init_ctx: serde_json::json!({"in": "hello"}),
1489            project_root: None,
1490            work_dir: None,
1491            task_metadata: None,
1492            ttl_secs: None,
1493            operator: None,
1494            operator_sid: None,
1495            timeout_secs: None,
1496            goal: Some(goal.to_string()),
1497            detach: false,
1498            check_policy: None,
1499        }
1500    }
1501
1502    #[test]
1503    fn task_id_serializes_as_bare_string() {
1504        // Sanity check for the newtype-struct transparency relied on
1505        // throughout this module's response shapes (`TaskId` / `RunId`
1506        // serialize as plain JSON strings, not `{"0": "..."}`).
1507        let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
1508        assert_eq!(v, serde_json::json!("T-abc"));
1509    }
1510
1511    #[tokio::test]
1512    async fn post_then_get_drill_down() {
1513        let state = test_state();
1514
1515        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
1516            .await
1517            .expect("tasks_start")
1518            .0;
1519        let task_id = posted.task_id.clone();
1520        let run_id = posted.run_id.clone();
1521
1522        // GET /v1/tasks lists it.
1523        let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
1524            .await
1525            .expect("tasks_list")
1526            .0;
1527        assert!(
1528            list.iter().any(|t| t.id == task_id),
1529            "task {task_id} missing from list of {} tasks",
1530            list.len()
1531        );
1532
1533        // GET /v1/tasks/:id drills down to the Task + its Run.
1534        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1535            .await
1536            .expect("task_get")
1537            .0;
1538        assert_eq!(detail.task.id, task_id);
1539        assert_eq!(detail.task.goal, "smoke goal");
1540        assert_eq!(detail.task.status, TaskRecordStatus::Done);
1541        assert_eq!(detail.runs.len(), 1);
1542        assert_eq!(detail.runs[0].id, run_id);
1543        assert_eq!(detail.runs[0].status, RunStatus::Done);
1544
1545        // GET /v1/runs/:id returns the same Run directly.
1546        let run = run_get(State(state.clone()), Path(run_id.to_string()))
1547            .await
1548            .expect("run_get")
1549            .0;
1550        assert_eq!(run.id, run_id);
1551        assert_eq!(run.task_id, task_id);
1552        assert_eq!(run.result_ref, Some(posted.final_ctx));
1553
1554        // issue #13 run_id propagation: `POST /v1/tasks` (`run_flow_form`)
1555        // wires a `RunContext` into `TaskApplication::handle_with_run`, so
1556        // the single dispatched step must be traced into `step_entries`.
1557        assert_eq!(
1558            run.step_entries.len(),
1559            1,
1560            "expected one step_entry for the 1-step identity Blueprint, got {:?}",
1561            run.step_entries
1562        );
1563        assert_eq!(
1564            run.step_entries[0].step_ref,
1565            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1566        );
1567        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1568    }
1569
1570    // ──────────────────────────────────────────────────────────────────
1571    // GH #33 — sync-hang guards (readiness precheck / timeout ceiling)
1572    // ──────────────────────────────────────────────────────────────────
1573
1574    /// Same 1-step identity flow as [`identity_blueprint`], but opts into
1575    /// the Blueprint-global Operator delegate axis
1576    /// (`spawner_hints.layers = ["operator_delegate"]`) so a registered
1577    /// `Operator` backend can be exercised end-to-end through the real
1578    /// `tasks_start` dispatch path (`OperatorDelegateMiddleware` bypasses
1579    /// `inner.spawn` and calls `operator.execute` instead — see
1580    /// `mlua_swarm::middleware::OperatorDelegateMiddleware` doc).
1581    fn identity_blueprint_with_operator_delegate() -> Blueprint {
1582        Blueprint {
1583            spawner_hints: mlua_swarm::SpawnerHints {
1584                layers: vec!["operator_delegate".to_string()],
1585            },
1586            ..identity_blueprint()
1587        }
1588    }
1589
1590    /// `Operator` stub whose `execute` never resolves — the GH #33 Guard 2
1591    /// fixture ("a registered-but-never-acking operator").
1592    struct StallingOperator;
1593
1594    #[async_trait::async_trait]
1595    impl mlua_swarm::Operator for StallingOperator {
1596        async fn execute(
1597            &self,
1598            _ctx: &mlua_swarm::Ctx,
1599            _system: Option<String>,
1600            _prompt: Value,
1601            _worker: Option<mlua_swarm::WorkerBinding>,
1602            _worker_token: mlua_swarm::CapToken,
1603        ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1604            std::future::pending::<()>().await;
1605            unreachable!("StallingOperator.execute must never resolve")
1606        }
1607    }
1608
1609    /// A launch request that references an operator backend by id (via
1610    /// `operator.operator_backend_id`, the coarse Guard 1 signal) against
1611    /// [`identity_blueprint_with_operator_delegate`].
1612    fn operator_launch_req(
1613        backend_id: &str,
1614        timeout_secs: Option<u64>,
1615    ) -> crate::TaskLaunchRequest {
1616        crate::TaskLaunchRequest {
1617            blueprint: BlueprintRef::Inline {
1618                value: Box::new(identity_blueprint_with_operator_delegate()),
1619            },
1620            init_ctx: serde_json::json!({"in": "hello"}),
1621            project_root: None,
1622            work_dir: None,
1623            task_metadata: None,
1624            ttl_secs: None,
1625            operator: Some(crate::OperatorReq {
1626                operator_backend_id: Some(backend_id.to_string()),
1627                ..Default::default()
1628            }),
1629            operator_sid: None,
1630            timeout_secs,
1631            goal: Some("operator delegate test goal".to_string()),
1632            detach: false,
1633            check_policy: None,
1634        }
1635    }
1636
1637    /// Guard 1: an operator-requiring launch with zero attached operators
1638    /// must fail immediately with a structured `503`, not hang waiting on
1639    /// a session nothing can serve.
1640    #[tokio::test]
1641    async fn sync_launch_zero_operators_fails_fast() {
1642        let state = test_state();
1643        // No `state.engine.register_operator(...)` call — zero operators
1644        // attached, matching `list_operator_ids()` being empty.
1645        let req = operator_launch_req("nonexistent-op", None);
1646
1647        let started = std::time::Instant::now();
1648        let result = crate::tasks_start(State(state), Json(req)).await;
1649        let elapsed = started.elapsed();
1650
1651        let err = match result {
1652            Err(e) => e,
1653            Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1654        };
1655        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1656        assert!(
1657            err.message.contains("no operator attached"),
1658            "error message must mention the missing operator: {}",
1659            err.message
1660        );
1661        assert!(
1662            elapsed < Duration::from_secs(1),
1663            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1664        );
1665    }
1666
1667    /// Guard 2: a launch that resolves to a registered-but-stalled
1668    /// operator session must return a structured `504` within the
1669    /// requested `timeout_secs` ceiling, not hang the request forever.
1670    #[tokio::test]
1671    async fn sync_launch_stalled_times_out() {
1672        let state = test_state();
1673        state
1674            .engine
1675            .register_operator("stall-op", Arc::new(StallingOperator))
1676            .await;
1677        let req = operator_launch_req("stall-op", Some(1));
1678
1679        let started = std::time::Instant::now();
1680        // Outer safety-net timeout: if guard 2 itself regressed into an
1681        // infinite hang, fail this test loudly instead of stalling `cargo
1682        // test` indefinitely.
1683        let result = tokio::time::timeout(
1684            Duration::from_secs(5),
1685            crate::tasks_start(State(state), Json(req)),
1686        )
1687        .await
1688        .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1689        let elapsed = started.elapsed();
1690
1691        let err = match result {
1692            Err(e) => e,
1693            Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1694        };
1695        assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1696        assert!(
1697            err.message.contains('1'),
1698            "error message must mention the configured 1s ceiling: {}",
1699            err.message
1700        );
1701        assert!(
1702            elapsed < Duration::from_secs(3),
1703            "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1704        );
1705    }
1706
1707    /// Invariant 2: a launch that never references an operator backend
1708    /// must never be rejected by guard 1 — the simplest existing passing
1709    /// fixture (`post_tasks_req`) still succeeds unaffected.
1710    #[tokio::test]
1711    async fn sync_launch_without_operator_path_unaffected() {
1712        let state = test_state();
1713        let result = crate::tasks_start(
1714            State(state),
1715            Json(post_tasks_req("non-operator launch goal")),
1716        )
1717        .await;
1718        if let Err(e) = &result {
1719            panic!(
1720                "non-operator launch must succeed unaffected by guard 1: {}",
1721                e.message
1722            );
1723        }
1724    }
1725
1726    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid
1727    /// (design doc: "0 = reject with 400 or treat as invalid — pick one
1728    /// and test it") — rejected fast, before any Task/Run side effects.
1729    #[tokio::test]
1730    async fn sync_launch_zero_timeout_secs_rejected() {
1731        let state = test_state();
1732        let mut req = post_tasks_req("zero timeout goal");
1733        req.timeout_secs = Some(0);
1734
1735        let result = crate::tasks_start(State(state), Json(req)).await;
1736        let err = match result {
1737            Err(e) => e,
1738            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1739        };
1740        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1741        assert!(
1742            err.message.contains("timeout_secs"),
1743            "error message must reference timeout_secs: {}",
1744            err.message
1745        );
1746    }
1747
1748    // ──────────────────────────────────────────────────────────────────
1749    // GH #37 — detached launch / rekick (driver decoupled from request)
1750    // ──────────────────────────────────────────────────────────────────
1751
1752    /// Polls the run store until the given Run reaches a terminal status,
1753    /// panicking after ~5s — the detached paths complete in the
1754    /// background, so tests must wait on the store rather than the
1755    /// response.
1756    async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1757        for _ in 0..50 {
1758            let rec = state.run_store.get(run_id).await.expect("run get");
1759            if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1760                return rec;
1761            }
1762            tokio::time::sleep(Duration::from_millis(100)).await;
1763        }
1764        panic!("run {run_id} did not reach a terminal status within ~5s");
1765    }
1766
1767    /// GH #37: `detach: true` returns `202 Accepted` immediately with
1768    /// `status: "running"` and a null `final_ctx`; the eval completes in
1769    /// the background and the Run/Task reach `Done` with the result and
1770    /// step trace persisted — the same terminal state the sync path
1771    /// produces.
1772    #[tokio::test]
1773    async fn detached_launch_returns_202_and_completes_in_background() {
1774        let state = test_state();
1775        let mut req = post_tasks_req("detached goal");
1776        req.detach = true;
1777
1778        let reply = crate::tasks_start(State(state.clone()), Json(req))
1779            .await
1780            .expect("tasks_start (detached)");
1781        assert_eq!(reply.1, StatusCode::ACCEPTED);
1782        let posted = reply.0;
1783        assert_eq!(posted.status, RunStatus::Running);
1784        assert_eq!(
1785            posted.final_ctx,
1786            serde_json::Value::Null,
1787            "a detached launch has no final_ctx at response time"
1788        );
1789
1790        let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1791        assert_eq!(rec.status, RunStatus::Done);
1792        assert!(
1793            rec.result_ref.is_some(),
1794            "finalize_run must persist the background eval's final_ctx"
1795        );
1796        assert_eq!(
1797            rec.step_entries.len(),
1798            1,
1799            "the background eval must trace its step_entries like the sync path: {:?}",
1800            rec.step_entries
1801        );
1802        let task = state
1803            .task_store
1804            .get(&posted.task_id)
1805            .await
1806            .expect("task get");
1807        assert_eq!(task.status, TaskRecordStatus::Done);
1808    }
1809
1810    /// GH #37: `detach: true` + `timeout_secs` is contradictory (the sync
1811    /// ceiling has no meaning for a detached run) — rejected with `400`
1812    /// before any Task/Run side effects.
1813    #[tokio::test]
1814    async fn detached_launch_with_timeout_secs_rejected() {
1815        let state = test_state();
1816        let mut req = post_tasks_req("detached + ceiling goal");
1817        req.detach = true;
1818        req.timeout_secs = Some(60);
1819
1820        let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1821            Err(e) => e,
1822            Ok(_) => panic!("detach + timeout_secs must be rejected"),
1823        };
1824        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1825        assert!(
1826            err.message.contains("detach"),
1827            "error message must explain the detach/timeout_secs conflict: {}",
1828            err.message
1829        );
1830        let tasks = state.task_store.list().await.expect("task list");
1831        assert!(
1832            tasks.is_empty(),
1833            "the 400 must fire before any TaskRecord is minted"
1834        );
1835    }
1836
1837    /// GH #37: a detached rekick returns `202 Accepted` with `status:
1838    /// "running"` immediately and completes in the background, adding a
1839    /// second `Done` Run to the same Task.
1840    #[tokio::test]
1841    async fn rekick_detached_returns_202_and_completes_in_background() {
1842        let state = test_state();
1843        let posted = crate::tasks_start(
1844            State(state.clone()),
1845            Json(post_tasks_req("detached rekick goal")),
1846        )
1847        .await
1848        .expect("tasks_start")
1849        .0;
1850
1851        let (status, rekicked) = task_rekick(
1852            State(state.clone()),
1853            Path(posted.task_id.to_string()),
1854            Some(Json(RunKickRequest {
1855                init_ctx_override: None,
1856                task_input_override: None,
1857                timeout_secs: None,
1858                detach: true,
1859            })),
1860        )
1861        .await
1862        .expect("task_rekick (detached)");
1863        assert_eq!(status, StatusCode::ACCEPTED);
1864        assert_eq!(rekicked.0.status, RunStatus::Running);
1865        assert_ne!(rekicked.0.run_id, posted.run_id);
1866
1867        let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1868        assert_eq!(rec.status, RunStatus::Done);
1869        assert!(
1870            rec.result_ref.is_some(),
1871            "finalize_run must persist the background rekick's final_ctx"
1872        );
1873    }
1874
1875    /// GH #37: `detach: true` + `timeout_secs` on the rekick path is the
1876    /// same contradiction as on the launch path — `400`, no new Run
1877    /// minted.
1878    #[tokio::test]
1879    async fn rekick_detached_with_timeout_secs_rejected() {
1880        let state = test_state();
1881        let posted = crate::tasks_start(
1882            State(state.clone()),
1883            Json(post_tasks_req("detached rekick ceiling goal")),
1884        )
1885        .await
1886        .expect("tasks_start")
1887        .0;
1888
1889        let err = match task_rekick(
1890            State(state.clone()),
1891            Path(posted.task_id.to_string()),
1892            Some(Json(RunKickRequest {
1893                init_ctx_override: None,
1894                task_input_override: None,
1895                timeout_secs: Some(60),
1896                detach: true,
1897            })),
1898        )
1899        .await
1900        {
1901            Err(e) => e,
1902            Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1903        };
1904        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1905        assert!(
1906            err.message.contains("detach"),
1907            "error message must explain the detach/timeout_secs conflict: {}",
1908            err.message
1909        );
1910        let runs = state
1911            .run_store
1912            .list_by_task(&posted.task_id)
1913            .await
1914            .expect("runs list");
1915        assert_eq!(
1916            runs.len(),
1917            1,
1918            "the 400 must fire before a second Run is minted"
1919        );
1920    }
1921
1922    #[tokio::test]
1923    async fn rekick_adds_a_second_run_to_the_same_task() {
1924        let state = test_state();
1925        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1926            .await
1927            .expect("tasks_start")
1928            .0;
1929        let task_id = posted.task_id.clone();
1930        let first_run_id = posted.run_id.clone();
1931
1932        let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1933            .await
1934            .expect("task_rekick");
1935        assert_eq!(status, StatusCode::CREATED);
1936        let second_run_id = rekicked.0.run_id.clone();
1937        assert_ne!(first_run_id, second_run_id);
1938
1939        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1940            .await
1941            .expect("task_get")
1942            .0;
1943        assert_eq!(
1944            detail.runs.len(),
1945            2,
1946            "expected 2 runs, got {:?}",
1947            detail.runs
1948        );
1949        let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1950        assert!(ids.contains(&&first_run_id));
1951        assert!(ids.contains(&&second_run_id));
1952
1953        // issue #13 run_id propagation: each kick's own `EngineDispatcher`
1954        // (built fresh per `TaskApplication::handle_with_run` call) must
1955        // trace its own dispatched step into its own `RunRecord` —
1956        // independent `step_entries`, not shared/accumulated across kicks.
1957        let first_run = detail
1958            .runs
1959            .iter()
1960            .find(|r| r.id == first_run_id)
1961            .expect("first run present in detail.runs");
1962        let second_run = detail
1963            .runs
1964            .iter()
1965            .find(|r| r.id == second_run_id)
1966            .expect("second run present in detail.runs");
1967        assert_eq!(
1968            first_run.step_entries.len(),
1969            1,
1970            "first run step_entries: {:?}",
1971            first_run.step_entries
1972        );
1973        assert_eq!(
1974            second_run.step_entries.len(),
1975            1,
1976            "second run step_entries: {:?}",
1977            second_run.step_entries
1978        );
1979        assert_eq!(
1980            first_run.step_entries[0].step_ref,
1981            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1982        );
1983        assert_eq!(
1984            second_run.step_entries[0].step_ref,
1985            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1986        );
1987        assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1988        assert_eq!(
1989            second_run.step_entries[0].status,
1990            Some("passed".to_string())
1991        );
1992        assert_ne!(
1993            first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1994            "each kick dispatches its own StepId — runs must not share step_entries"
1995        );
1996    }
1997
1998    #[tokio::test]
1999    async fn rekick_unknown_task_returns_404() {
2000        let state = test_state();
2001        // `.expect_err()` needs the Ok variant to be `Debug`; `Json<T>`'s
2002        // `Debug` impl is not guaranteed for every `T` across axum versions,
2003        // so a plain match sidesteps that bound entirely.
2004        match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
2005            Ok(_) => panic!("expected 404 for an unknown task"),
2006            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2007        }
2008    }
2009
2010    // ──────────────────────────────────────────────────────────────────
2011    // issue #19 ST4: `RunKickRequest` (optional body / 3-layer merge)
2012    // ──────────────────────────────────────────────────────────────────
2013
2014    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
2015    /// `$.out` — unlike [`identity_blueprint`] (a fixed `lit("hello")`
2016    /// input), this one reads its `Step.in` from `ctx`, so it observes
2017    /// whichever `init_ctx` layer actually won the merge.
2018    fn greeting_blueprint() -> Blueprint {
2019        Blueprint {
2020            schema_version: current_schema_version(),
2021            id: "tasks-test-greeting-bp".into(),
2022            flow: serde_json::from_value(serde_json::json!({
2023                "kind": "step",
2024                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
2025                "in": {"op": "path", "at": "$.greeting"},
2026                "out": {"op": "path", "at": "$.out"},
2027            }))
2028            .expect("flow parse"),
2029            agents: vec![AgentDef {
2030                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
2031                kind: AgentKind::RustFn,
2032                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
2033                profile: None,
2034                meta: None,
2035                runner: None,
2036                runner_ref: None,
2037                verdict: None,
2038            }],
2039            operators: vec![],
2040            metas: vec![],
2041            hints: CompilerHints::default(),
2042            strategy: CompilerStrategy::default(),
2043            metadata: BlueprintMetadata::default(),
2044            spawner_hints: Default::default(),
2045            default_agent_kind: AgentKind::Operator,
2046            default_operator_kind: None,
2047            default_init_ctx: None,
2048            default_agent_ctx: None,
2049            default_context_policy: None,
2050            projection_placement: None,
2051            audits: vec![],
2052            degradation_policy: None,
2053            runners: vec![],
2054            default_runner: None,
2055            subprocesses: vec![],
2056            check_policy: None,
2057            blueprint_ref_includes: Vec::new(),
2058        }
2059    }
2060
2061    fn post_greeting_task_req(
2062        greeting: &str,
2063        project_root: Option<&str>,
2064    ) -> crate::TaskLaunchRequest {
2065        crate::TaskLaunchRequest {
2066            blueprint: BlueprintRef::Inline {
2067                value: Box::new(greeting_blueprint()),
2068            },
2069            init_ctx: serde_json::json!({ "greeting": greeting }),
2070            project_root: project_root.map(str::to_string),
2071            work_dir: None,
2072            task_metadata: None,
2073            ttl_secs: None,
2074            operator: None,
2075            operator_sid: None,
2076            timeout_secs: None,
2077            goal: Some("st4 rekick goal".to_string()),
2078            detach: false,
2079            check_policy: None,
2080        }
2081    }
2082
2083    #[tokio::test]
2084    async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2085        // must_not_simplify #3: a body-less rekick must behave exactly
2086        // like pre-#19 — the Task's own `input_ctx` alone seeds the kick.
2087        let state = test_state();
2088        let posted = crate::tasks_start(
2089            State(state.clone()),
2090            Json(post_greeting_task_req("from-task", None)),
2091        )
2092        .await
2093        .expect("tasks_start")
2094        .0;
2095        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2096
2097        let (status, rekicked) =
2098            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2099                .await
2100                .expect("task_rekick");
2101        assert_eq!(status, StatusCode::CREATED);
2102
2103        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2104            .await
2105            .expect("run_get")
2106            .0;
2107        assert_eq!(
2108            run.result_ref.expect("result_ref present")["out"]["echoed"],
2109            "from-task"
2110        );
2111    }
2112
2113    #[tokio::test]
2114    async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2115        let state = test_state();
2116        let posted = crate::tasks_start(
2117            State(state.clone()),
2118            Json(post_greeting_task_req("from-task", None)),
2119        )
2120        .await
2121        .expect("tasks_start")
2122        .0;
2123        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2124
2125        let (status, rekicked) = task_rekick(
2126            State(state.clone()),
2127            Path(posted.task_id.to_string()),
2128            Some(Json(RunKickRequest {
2129                init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2130                task_input_override: None,
2131                timeout_secs: None,
2132                detach: false,
2133            })),
2134        )
2135        .await
2136        .expect("task_rekick");
2137        assert_eq!(status, StatusCode::CREATED);
2138
2139        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2140            .await
2141            .expect("run_get")
2142            .0;
2143        assert_eq!(
2144            run.result_ref.expect("result_ref present")["out"]["echoed"],
2145            "from-run",
2146            "Run's init_ctx_override must win over the stored Task input_ctx"
2147        );
2148    }
2149
2150    #[tokio::test]
2151    async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2152        // Done Criteria: "Task record が task-level canonical fields を
2153        // 保持している時の rekick test". A Task created with
2154        // `project_root` set gets a `task_input_spec` snapshot; a
2155        // body-less rekick must both dispatch successfully (the stored
2156        // spec decodes and resolves without erroring) and leave
2157        // `TaskRecord.task_input_spec` untouched (must_not_simplify #4 —
2158        // a rekick never mutates the stored Task-level snapshot).
2159        let state = test_state();
2160        let posted = crate::tasks_start(
2161            State(state.clone()),
2162            Json(post_greeting_task_req("from-task", Some("/repo"))),
2163        )
2164        .await
2165        .expect("tasks_start")
2166        .0;
2167
2168        let before = state
2169            .task_store
2170            .get(&posted.task_id)
2171            .await
2172            .expect("task fetch");
2173        let before_spec: Option<TaskInputSpec> = before
2174            .task_input_spec
2175            .as_ref()
2176            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2177        assert_eq!(
2178            before_spec,
2179            Some(TaskInputSpec {
2180                project_root: Some("/repo".to_string()),
2181                work_dir: None,
2182                task_metadata: None,
2183            })
2184        );
2185
2186        let (status, _rekicked) =
2187            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2188                .await
2189                .expect("task_rekick");
2190        assert_eq!(status, StatusCode::CREATED);
2191
2192        let after = state
2193            .task_store
2194            .get(&posted.task_id)
2195            .await
2196            .expect("task fetch");
2197        assert_eq!(
2198            after.task_input_spec, before.task_input_spec,
2199            "rekick must not mutate the stored Task-level task_input_spec snapshot"
2200        );
2201    }
2202
2203    #[tokio::test]
2204    async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2205        // must_not_simplify #4: `task_input_override` wins for this kick
2206        // only — the stored `TaskRecord.task_input_spec` is untouched.
2207        let state = test_state();
2208        let posted = crate::tasks_start(
2209            State(state.clone()),
2210            Json(post_greeting_task_req("from-task", Some("/repo"))),
2211        )
2212        .await
2213        .expect("tasks_start")
2214        .0;
2215
2216        let (status, _rekicked) = task_rekick(
2217            State(state.clone()),
2218            Path(posted.task_id.to_string()),
2219            Some(Json(RunKickRequest {
2220                init_ctx_override: None,
2221                task_input_override: Some(TaskInputSpec {
2222                    project_root: Some("/override".to_string()),
2223                    work_dir: None,
2224                    task_metadata: None,
2225                }),
2226                timeout_secs: None,
2227                detach: false,
2228            })),
2229        )
2230        .await
2231        .expect("task_rekick");
2232        assert_eq!(status, StatusCode::CREATED);
2233
2234        let after = state
2235            .task_store
2236            .get(&posted.task_id)
2237            .await
2238            .expect("task fetch");
2239        let after_spec: Option<TaskInputSpec> = after
2240            .task_input_spec
2241            .as_ref()
2242            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2243        assert_eq!(
2244            after_spec,
2245            Some(TaskInputSpec {
2246                project_root: Some("/repo".to_string()),
2247                work_dir: None,
2248                task_metadata: None,
2249            }),
2250            "a per-Run task_input_override must not leak into the stored TaskRecord"
2251        );
2252    }
2253
2254    // ──────────────────────────────────────────────────────────────────
2255    // GH #33 → task_rekick — sync-hang guards (issue #35 ST3 parity)
2256    // ──────────────────────────────────────────────────────────────────
2257
2258    /// A launch request for [`identity_blueprint_with_operator_delegate`]
2259    /// that does **not** reference an operator backend (`operator: None`)
2260    /// — used to create a rekick-able Task without tripping
2261    /// `run_flow_form`'s own Guard 1 at initial-launch time (the launch
2262    /// itself dispatches through the plain baseline path since
2263    /// `ctx.operator.operator` stays unset either way; the BP's
2264    /// `operator_delegate` layer only matters to `task_rekick`'s Guard 1,
2265    /// which reads `resolved_bp.spawner_hints.layers` directly rather than
2266    /// a per-request field).
2267    fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
2268        crate::TaskLaunchRequest {
2269            blueprint: BlueprintRef::Inline {
2270                value: Box::new(identity_blueprint_with_operator_delegate()),
2271            },
2272            init_ctx: serde_json::json!({"in": "hello"}),
2273            project_root: None,
2274            work_dir: None,
2275            task_metadata: None,
2276            ttl_secs: None,
2277            operator: None,
2278            operator_sid: None,
2279            timeout_secs: None,
2280            goal: Some(goal.to_string()),
2281            detach: false,
2282            check_policy: None,
2283        }
2284    }
2285
2286    /// Guard 1 (adapted signal): a Task whose stored Blueprint declares
2287    /// the `operator_delegate` layer, rekicked with zero attached
2288    /// operators, must fail immediately with a structured `503` — not
2289    /// dispatch and not hang waiting on a session nothing can serve.
2290    #[tokio::test]
2291    async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
2292        let state = test_state();
2293        let posted = crate::tasks_start(
2294            State(state.clone()),
2295            Json(delegate_launch_req("operator delegate rekick goal")),
2296        )
2297        .await
2298        .expect("tasks_start (no operator referenced, dispatches through baseline)")
2299        .0;
2300        // No `state.engine.register_operator(...)` call — zero operators
2301        // attached, matching `list_operator_ids()` being empty.
2302
2303        let started = std::time::Instant::now();
2304        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2305        let elapsed = started.elapsed();
2306
2307        let err = match result {
2308            Err(e) => e,
2309            Ok(_) => panic!(
2310                "rekicking a Task whose Blueprint declares operator_delegate with zero \
2311                 attached operators must fail, not dispatch"
2312            ),
2313        };
2314        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2315        assert!(
2316            err.message.contains("no operator attached"),
2317            "error message must mention the missing operator: {}",
2318            err.message
2319        );
2320        assert!(
2321            elapsed < Duration::from_secs(1),
2322            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2323        );
2324    }
2325
2326    /// Guard 2: a rekick with a `timeout_secs` ceiling shorter than the
2327    /// dispatch takes must return a structured `504` within the outer
2328    /// safety-net timeout, not hang the request forever.
2329    #[tokio::test]
2330    async fn rekick_stalled_operator_times_out() {
2331        let state = test_state();
2332        state
2333            .engine
2334            .register_operator("stall-op", Arc::new(StallingOperator))
2335            .await;
2336        let posted = crate::tasks_start(
2337            State(state.clone()),
2338            Json(delegate_launch_req("stalled rekick goal")),
2339        )
2340        .await
2341        .expect("tasks_start")
2342        .0;
2343
2344        let started = std::time::Instant::now();
2345        // Outer safety-net timeout: if guard 2 itself regressed into an
2346        // infinite hang, fail this test loudly instead of stalling `cargo
2347        // test` indefinitely.
2348        let result = tokio::time::timeout(
2349            Duration::from_secs(5),
2350            task_rekick(
2351                State(state),
2352                Path(posted.task_id.to_string()),
2353                Some(Json(RunKickRequest {
2354                    init_ctx_override: None,
2355                    task_input_override: None,
2356                    timeout_secs: Some(1),
2357                    detach: false,
2358                })),
2359            ),
2360        )
2361        .await
2362        .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
2363        let elapsed = started.elapsed();
2364
2365        match &result {
2366            Err(e) => {
2367                assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
2368                assert!(
2369                    e.message.contains('1'),
2370                    "error message must mention the configured 1s ceiling: {}",
2371                    e.message
2372                );
2373                assert!(
2374                    elapsed < Duration::from_secs(3),
2375                    "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2376                );
2377            }
2378            Ok(_) => {
2379                // `task_rekick` hardcodes `operator_backend_id: None` for
2380                // every kick (module doc, above — "no per-request Operator
2381                // override support here"), so a registered-but-unattached
2382                // `StallingOperator` is never actually engaged by a
2383                // rekick's dispatch; the flow resolves through the plain
2384                // baseline path instead. Guard 2's `tokio::time::timeout`
2385                // wrap is exercised (and does not falsely fire) rather
2386                // than tripped — assert the fast-success shape so a
2387                // regression that makes rekick dispatch slow (or that
2388                // makes Guard 2 falsely trip on a fast dispatch) is still
2389                // caught by the elapsed-time assertion below.
2390                assert!(
2391                    elapsed < Duration::from_secs(1),
2392                    "a rekick that never engages an Operator (task_rekick has no \
2393                     per-request operator override) must resolve fast, not stall: took {elapsed:?}"
2394                );
2395            }
2396        }
2397    }
2398
2399    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid —
2400    /// rejected fast, before any Task/Run side effects (the pre-existing
2401    /// run count for the rekicked Task is unchanged).
2402    #[tokio::test]
2403    async fn rekick_timeout_secs_zero_rejected() {
2404        let state = test_state();
2405        let posted = crate::tasks_start(
2406            State(state.clone()),
2407            Json(post_tasks_req("zero timeout rekick goal")),
2408        )
2409        .await
2410        .expect("tasks_start")
2411        .0;
2412
2413        let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2414            .await
2415            .expect("task_get")
2416            .0;
2417        let runs_before = before.runs.len();
2418
2419        let result = task_rekick(
2420            State(state.clone()),
2421            Path(posted.task_id.to_string()),
2422            Some(Json(RunKickRequest {
2423                init_ctx_override: None,
2424                task_input_override: None,
2425                timeout_secs: Some(0),
2426                detach: false,
2427            })),
2428        )
2429        .await;
2430        let err = match result {
2431            Err(e) => e,
2432            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2433        };
2434        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2435        assert!(
2436            err.message.contains("timeout_secs"),
2437            "error message must reference timeout_secs: {}",
2438            err.message
2439        );
2440
2441        let after = task_get(State(state), Path(posted.task_id.to_string()))
2442            .await
2443            .expect("task_get")
2444            .0;
2445        assert_eq!(
2446            after.runs.len(),
2447            runs_before,
2448            "a rejected timeout_secs: Some(0) rekick must not create a new Run"
2449        );
2450    }
2451
2452    /// Invariant: a plain (non-`operator_delegate`) Task rekick must
2453    /// never be rejected by Guard 1 — the simplest existing passing
2454    /// rekick fixture still succeeds unaffected.
2455    #[tokio::test]
2456    async fn rekick_non_operator_path_unaffected_by_guard_1() {
2457        let state = test_state();
2458        let posted = crate::tasks_start(
2459            State(state.clone()),
2460            Json(post_tasks_req("non-operator rekick goal")),
2461        )
2462        .await
2463        .expect("tasks_start")
2464        .0;
2465
2466        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2467        if let Err(e) = &result {
2468            panic!(
2469                "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
2470                 guard 1: {}",
2471                e.message
2472            );
2473        }
2474    }
2475
2476    #[tokio::test]
2477    async fn run_get_unknown_id_returns_404() {
2478        let state = test_state();
2479        match run_get(State(state), Path("R-does-not-exist".to_string())).await {
2480            Ok(_) => panic!("expected 404 for an unknown run"),
2481            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2482        }
2483    }
2484
2485    #[tokio::test]
2486    async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
2487        let state = test_state();
2488        let posted = crate::tasks_start(
2489            State(state.clone()),
2490            Json(post_tasks_req("binding explain")),
2491        )
2492        .await
2493        .expect("tasks_start")
2494        .0;
2495        let run = state
2496            .run_store
2497            .get(&posted.run_id)
2498            .await
2499            .expect("stored run");
2500        let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2501        let mut bound_agents: Vec<BoundAgent> =
2502            serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
2503        let bound = &mut bound_agents[0];
2504        bound.runner = Some(Runner::WsClaudeCode {
2505            variant: "coder".to_string(),
2506            tools: vec!["Read".to_string()],
2507        });
2508        bound.recompute_binding_digest().unwrap();
2509        let request_digest = bound.binding_digest.clone();
2510        bound
2511            .set_attestation(BindingAttestation {
2512                request_digest: request_digest.clone(),
2513                provider_id: "operator-manifest".to_string(),
2514                provider_revision: Some("claude-code-1.2".to_string()),
2515                resolved_model: Some("claude-sonnet-4".to_string()),
2516                effective_tools: vec!["Bash".to_string(), "Read".to_string()],
2517                launch_variant: Some("coder".to_string()),
2518                capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
2519                    b"manifest-v1",
2520                )),
2521            })
2522            .unwrap();
2523        snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
2524        state
2525            .run_store
2526            .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2527            .await
2528            .unwrap();
2529
2530        let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2531            .await
2532            .expect("binding explain")
2533            .0;
2534        let entry = &explained.bindings[0];
2535        assert_eq!(entry.status, RunBindingStatus::Attested);
2536        assert_eq!(
2537            entry.requested.as_ref().unwrap().request_digest,
2538            request_digest
2539        );
2540        assert_eq!(
2541            entry
2542                .effective
2543                .as_ref()
2544                .unwrap()
2545                .provider_revision
2546                .as_deref(),
2547            Some("claude-code-1.2")
2548        );
2549        assert_eq!(
2550            entry
2551                .difference
2552                .as_ref()
2553                .unwrap()
2554                .additional_effective_tools,
2555            vec!["Bash"]
2556        );
2557        assert!(entry
2558            .difference
2559            .as_ref()
2560            .unwrap()
2561            .missing_requested_tools
2562            .is_empty());
2563        assert_ne!(entry.binding_digest, request_digest);
2564    }
2565
2566    #[tokio::test]
2567    async fn run_bindings_explain_reports_snapshot_origin() {
2568        let state = test_state();
2569        let posted =
2570            crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
2571                .await
2572                .expect("tasks_start")
2573                .0;
2574
2575        // An initial launch pins `origin = launch`.
2576        let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
2577            .await
2578            .expect("binding explain")
2579            .0;
2580        assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
2581
2582        // Flip the persisted marker to `resume_backfill` → explain reflects it.
2583        let run = state.run_store.get(&posted.run_id).await.unwrap();
2584        let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2585        snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
2586        state
2587            .run_store
2588            .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2589            .await
2590            .unwrap();
2591        let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
2592            .await
2593            .expect("binding explain")
2594            .0;
2595        assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
2596
2597        // A snapshot with `bound_agents` but NO origin marker maps to the
2598        // safe side (`resume_backfill`) and still returns 200 — the 422 is
2599        // reserved for snapshots lacking `bound_agents` entirely.
2600        snapshot
2601            .as_object_mut()
2602            .unwrap()
2603            .remove("bound_agents_origin");
2604        state
2605            .run_store
2606            .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2607            .await
2608            .unwrap();
2609        let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2610            .await
2611            .expect("explain still 200 without an origin marker")
2612            .0;
2613        assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
2614    }
2615
2616    #[tokio::test]
2617    async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
2618        let state = test_state();
2619        let posted = crate::tasks_start(
2620            State(state.clone()),
2621            Json(post_tasks_req("legacy binding explain")),
2622        )
2623        .await
2624        .expect("tasks_start")
2625        .0;
2626        state
2627            .run_store
2628            .set_input_json(&posted.run_id, "{}".to_string())
2629            .await
2630            .unwrap();
2631
2632        let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2633            .await
2634            .expect_err("legacy run must not be re-resolved");
2635        assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
2636        assert!(error
2637            .message
2638            .contains("current Blueprint state was not consulted"));
2639    }
2640
2641    #[tokio::test]
2642    async fn run_bindings_explain_rejects_a_tampered_snapshot() {
2643        let state = test_state();
2644        let posted = crate::tasks_start(
2645            State(state.clone()),
2646            Json(post_tasks_req("tampered binding explain")),
2647        )
2648        .await
2649        .expect("tasks_start")
2650        .0;
2651        let run = state.run_store.get(&posted.run_id).await.unwrap();
2652        let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2653        snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
2654        state
2655            .run_store
2656            .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2657            .await
2658            .unwrap();
2659
2660        let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2661            .await
2662            .expect_err("digest drift must fail closed");
2663        assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
2664        assert!(error.message.contains("inconsistent binding snapshot"));
2665    }
2666
2667    #[tokio::test]
2668    async fn task_get_unknown_id_returns_404() {
2669        let state = test_state();
2670        match task_get(State(state), Path("T-does-not-exist".to_string())).await {
2671            Ok(_) => panic!("expected 404 for an unknown task"),
2672            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2673        }
2674    }
2675
2676    // ──────────────────────────────────────────────────────────────────
2677    // GH #76 error surface: finalize_run Err arm populates result_ref with the
2678    // structured failure envelope; run_get surfaces it.
2679    // ──────────────────────────────────────────────────────────────────
2680
2681    /// Seed a Task + Run row so `finalize_run` can update them.
2682    async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
2683        let task_id = TaskId::new();
2684        let run_id = RunId::new();
2685        state
2686            .task_store
2687            .create(TaskRecord {
2688                id: task_id.clone(),
2689                goal: "finalize-run-err-envelope".to_string(),
2690                blueprint_ref: json!("inline"),
2691                input_ctx: Value::Null,
2692                task_input_spec: None,
2693                status: TaskRecordStatus::Running,
2694                created_at: 0,
2695                updated_at: 0,
2696            })
2697            .await
2698            .expect("seed TaskRecord");
2699        state
2700            .run_store
2701            .create(RunRecord {
2702                id: run_id.clone(),
2703                task_id: task_id.clone(),
2704                status: RunStatus::Running,
2705                step_entries: Vec::new(),
2706                degradations: Vec::new(),
2707                operator_sid: None,
2708                result_ref: None,
2709                input_json: Some("{}".to_string()),
2710                created_at: 0,
2711                updated_at: 0,
2712            })
2713            .await
2714            .expect("seed RunRecord");
2715        (task_id, run_id)
2716    }
2717
2718    #[tokio::test]
2719    async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
2720        let state = test_state();
2721        let (task_id, run_id) = seed_task_and_run(&state).await;
2722
2723        let err: Result<TaskApplicationOutput, TaskApplicationError> =
2724            Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
2725                message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
2726                failed_step: Some("gate".to_string()),
2727                verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
2728                partial_ctx: Some(
2729                    json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
2730                ),
2731            }));
2732
2733        let _ = finalize_run(&state, &task_id, &run_id, err).await;
2734
2735        let run = state.run_store.get(&run_id).await.expect("run present");
2736        assert_eq!(run.status, RunStatus::Failed);
2737        let envelope = run
2738            .result_ref
2739            .as_ref()
2740            .expect("result_ref must be Some on Err arm");
2741        assert_eq!(
2742            envelope["error"]["message"],
2743            "blocked: {\"verdict\":\"BLOCKED\"}"
2744        );
2745        assert_eq!(envelope["error"]["failed_step"], "gate");
2746        assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
2747        assert_eq!(
2748            envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
2749            "blocked"
2750        );
2751
2752        // Owning Task status also flipped.
2753        let task = state.task_store.get(&task_id).await.expect("task present");
2754        assert_eq!(task.status, TaskRecordStatus::Failed);
2755    }
2756
2757    #[tokio::test]
2758    async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
2759        let state = test_state();
2760        let (task_id, run_id) = seed_task_and_run(&state).await;
2761
2762        // A non-FlowEval error (e.g. NoStore) still lands the envelope
2763        // shape with `error.message` populated; the structural fields go
2764        // to JSON `null` (no breadcrumb source available).
2765        let err: Result<TaskApplicationOutput, TaskApplicationError> =
2766            Err(TaskApplicationError::NoStore);
2767
2768        let _ = finalize_run(&state, &task_id, &run_id, err).await;
2769        let run = state.run_store.get(&run_id).await.expect("run present");
2770        let envelope = run
2771            .result_ref
2772            .as_ref()
2773            .expect("result_ref must be Some on Err arm");
2774        assert!(envelope["error"]["message"]
2775            .as_str()
2776            .expect("message string")
2777            .contains("store"));
2778        assert_eq!(envelope["error"]["failed_step"], Value::Null);
2779        assert_eq!(envelope["error"]["verdict_value"], Value::Null);
2780        assert_eq!(envelope["partial_ctx"], Value::Null);
2781    }
2782
2783    /// Regression: the Ok arm still stores the raw `final_ctx` verbatim
2784    /// (NOT an envelope) — consumers that never saw a failure keep their
2785    /// pre-#76 shape. The disambiguation is the top-level `"error"` key:
2786    /// present iff the Err arm fired.
2787    #[tokio::test]
2788    async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
2789        let state = test_state();
2790        let (task_id, run_id) = seed_task_and_run(&state).await;
2791
2792        let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
2793            token: mlua_swarm::CapToken {
2794                agent_id: "ut".to_string(),
2795                role: mlua_swarm::Role::Operator,
2796                scopes: vec!["*".to_string()],
2797                issued_at: 0,
2798                expire_at: u64::MAX,
2799                max_uses: None,
2800                nonce: "ut-nonce".to_string(),
2801                sig_hex: String::new(),
2802            },
2803            final_ctx: json!({"out": {"echoed": "hi"}}),
2804            bound_version: None,
2805        });
2806
2807        let _ = finalize_run(&state, &task_id, &run_id, ok).await;
2808        let run = state.run_store.get(&run_id).await.expect("run present");
2809        assert_eq!(run.status, RunStatus::Done);
2810        let stored = run.result_ref.as_ref().expect("result_ref Some");
2811        // Raw final_ctx verbatim — NOT an envelope; no top-level "error" key.
2812        assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
2813        assert!(
2814            stored.get("error").is_none(),
2815            "Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
2816        );
2817    }
2818
2819    /// `GET /v1/runs/:id` returns the `RunRecord` verbatim, so after a
2820    /// finalize_run Err arm the structured envelope surfaces through the
2821    /// existing handler — no new response type needed. Failure detection
2822    /// via the top-level `"error"` key inside `result_ref`.
2823    #[tokio::test]
2824    async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
2825        let state = test_state();
2826        let (_task_id, run_id) = seed_task_and_run(&state).await;
2827        let err: Result<TaskApplicationOutput, TaskApplicationError> =
2828            Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
2829                message: "blocked: bad verdict".to_string(),
2830                failed_step: Some("scout".to_string()),
2831                verdict_value: Some(json!("BLOCKED")),
2832                partial_ctx: Some(json!({"steps": {}})),
2833            }));
2834        let _ = finalize_run(&state, &_task_id, &run_id, err).await;
2835
2836        let Json(run) = run_get(State(state), Path(run_id.to_string()))
2837            .await
2838            .expect("run_get");
2839        assert_eq!(run.status, RunStatus::Failed);
2840        let envelope = run.result_ref.expect("result_ref Some");
2841        assert_eq!(envelope["error"]["failed_step"], "scout");
2842        assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
2843    }
2844}