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