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.
839/// - `202 Accepted` — accepted; the flow re-runs in a detached background
840///   task (same `tokio::spawn` + run-TTL ceiling shape as [`run_resume`]).
841///   Poll `GET /v1/runs/:id` for the terminal status.
842///
843/// # Order of operations
844///
845/// The compare-and-set runs BEFORE the `delete_from` on purpose: a losing
846/// cas returns `409` without ever touching the store, so a lost race can
847/// never leave the store truncated while the status stayed at its old
848/// terminal value.
849///
850/// 1. 404 check.
851/// 2. Status gate (fast 409 for `Running` / `Pending`).
852/// 3. Decode launch snapshot (fast 400 / 422).
853/// 4. Compute cut index via `list_by_run` + `.position(step_ref == from_step)`
854///    (fast 422 when the step is not present).
855/// 5. Atomic transition `<current terminal> -> Running` (409 on loss).
856/// 6. Physical `delete_from(cut)` on the replay store — safe now because we
857///    won the cas and own the Run.
858/// 7. Build `ReplayCursor` from the truncated entries.
859/// 8. Detached dispatch, same `tokio::spawn` + `default_run_ttl` shape as
860///    [`run_resume`].
861///
862/// # Known limitations (Layer A)
863///
864/// 1. **`from_step` is a raw `step_ref` (agent name)** — projection alias
865///    resolution via `StepNaming` is Layer B territory. For undeclared
866///    steps `step_ref == canonical` so this is only visible when
867///    `AgentMeta.projection_name` is in use.
868/// 2. **`BlueprintRef::Inline` freezes the BP in the launch snapshot** —
869///    the rerun re-decodes the same inline BP, so agent-definition edits
870///    landed on disk between the original dispatch and the rerun are NOT
871///    honored for inline runs. Use `BlueprintRef::Id` for the
872///    iterate-and-rerun workflow.
873/// 3. **Loop bodies match the first occurrence** — `step_ref` is the agent
874///    name, so `.position(|e| e.step_ref == from_step)` finds the FIRST
875///    occurrence and truncates from there. Rerunning a specific loop
876///    iteration needs Layer B semantics.
877/// 4. **Structural BP change is out of scope** — if steps were added /
878///    removed / reordered between the original dispatch and the rerun,
879///    the flow-ir re-eval will naturally miss the step or dispatch a
880///    different downstream. Start a fresh run in that case.
881pub async fn run_rerun_from(
882    State(state): State<AppState>,
883    Path(id): Path<String>,
884    Json(req): Json<RunRerunFromRequest>,
885) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
886    let run_id =
887        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
888
889    if req.from_step.trim().is_empty() {
890        return Err(ApiError::bad_request(
891            "from_step must be a non-empty step ref".to_string(),
892        ));
893    }
894
895    // 404 when the Run does not exist.
896    let run = state
897        .run_store
898        .get(&run_id)
899        .await
900        .map_err(map_run_store_err)?;
901
902    // Status gate — reject in-flight statuses that would race the driver
903    // already dispatching against this run_id.
904    let current = run.status;
905    match current {
906        RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => { /* ok */ }
907        RunStatus::Running | RunStatus::Pending => {
908            return Err(ApiError::conflict(format!(
909                "run {run_id} is {current:?}; rerun-from requires a terminal run \
910                 (Done / Failed / Interrupted)"
911            )));
912        }
913    }
914
915    // Decode the launch-input snapshot BEFORE the compare-and-set: a Run
916    // with no recorded input can never be rerun-from, and returning `422`
917    // here — before flipping the status — avoids stranding it in `Running`
918    // with no driver behind it.
919    let Some(input_json) = run.input_json.clone() else {
920        return Err(ApiError::unprocessable(format!(
921            "run {run_id} cannot be rerun: no launch input was recorded for it (it \
922             predates resume/rerun support, or was created by a path that does not \
923             persist one)"
924        )));
925    };
926    let snapshot: RunLaunchSnapshot = serde_json::from_str(&input_json).map_err(|e| {
927        ApiError::bad_request(format!(
928            "run {run_id}: stored launch input failed to decode: {e}"
929        ))
930    })?;
931
932    // Load the replay log and locate the cut point via first-match on
933    // `step_ref`. See §Known limitations #3 (loop bodies).
934    let entries = state
935        .replay_store
936        .list_by_run(&run_id)
937        .await
938        .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
939    let cut = entries
940        .iter()
941        .position(|e| e.step_ref == req.from_step)
942        .ok_or_else(|| {
943            ApiError::unprocessable(format!(
944                "run {run_id}: from_step {:?} not present in this run's replay log \
945                 (nothing to rerun-from)",
946                req.from_step
947            ))
948        })?;
949
950    // Atomically flip the current terminal status -> Running. A racing
951    // rerun (or a boot-time recovery sweep, or a concurrent resume) loses
952    // the compare-and-set and gets `409` rather than dispatching a second
953    // driver over the same Run.
954    let won = state
955        .run_store
956        .try_transition(&run_id, current, RunStatus::Running)
957        .await
958        .map_err(ApiError::engine)?;
959    if !won {
960        return Err(ApiError::conflict(format!(
961            "run {run_id} was concurrently transitioned (or left the {current:?} state); \
962             it is no longer rerunnable"
963        )));
964    }
965
966    // We own the run now — physically truncate the replay log at the cut
967    // so the rerun dispatch's `append` cannot collide with the pre-rerun
968    // row and `list_by_run` reflects the rerun's real history rather than
969    // the pre-rerun ghost.
970    let dropped_steps = state
971        .replay_store
972        .delete_from(&run_id, cut)
973        .await
974        .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
975
976    // Cursor is built from the pre-cut prefix; every retained entry hits
977    // verbatim in the engine's replay path.
978    let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
979    let replayed_steps = kept.len();
980    let cursor = ReplayCursor::from_entries(kept);
981
982    let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
983        .with_replay_store(state.replay_store.clone())
984        .with_replay_cursor(Arc::new(Mutex::new(cursor)));
985
986    let input = snapshot.into_input();
987    let task_id = run.task_id.clone();
988
989    // A rerun-from is a running Run again; finalize_run resets it to
990    // Done/Failed at the end, same as the rekick / resume paths.
991    state
992        .task_store
993        .update_status(&task_id, TaskRecordStatus::Running)
994        .await
995        .map_err(ApiError::engine)?;
996
997    let ttl_secs = crate::default_run_ttl();
998    let bg_state = state.clone();
999    let bg_task_id = task_id.clone();
1000    let bg_run_id = run_id.clone();
1001    tokio::spawn(async move {
1002        let outcome = match tokio::time::timeout(
1003            Duration::from_secs(ttl_secs),
1004            bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1005        )
1006        .await
1007        {
1008            Ok(outcome) => outcome,
1009            Err(_elapsed) => {
1010                let reason = serde_json::json!({
1011                    "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1012                });
1013                if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1014                    tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1015                }
1016                if let Err(e) = bg_state
1017                    .run_store
1018                    .update_status(&bg_run_id, RunStatus::Failed)
1019                    .await
1020                {
1021                    tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1022                }
1023                if let Err(e) = bg_state
1024                    .task_store
1025                    .update_status(&bg_task_id, TaskRecordStatus::Failed)
1026                    .await
1027                {
1028                    tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1029                }
1030                return;
1031            }
1032        };
1033        let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1034    });
1035
1036    Ok((
1037        StatusCode::ACCEPTED,
1038        Json(RunRerunFromResponse {
1039            run_id,
1040            task_id,
1041            replayed_steps,
1042            dropped_steps,
1043        }),
1044    ))
1045}
1046
1047/// `GET /v1/runs/:id`. Returns a single `RunRecord` (its `step_entries`
1048/// trace included).
1049pub async fn run_get(
1050    State(state): State<AppState>,
1051    Path(id): Path<String>,
1052) -> Result<Json<RunRecord>, ApiError> {
1053    let run_id =
1054        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1055    let run = state
1056        .run_store
1057        .get(&run_id)
1058        .await
1059        .map_err(map_run_store_err)?;
1060    Ok(Json(run))
1061}
1062
1063/// `pub(crate)` so `crate::projection`'s `GET /v1/tasks/:id/ctx` handler can
1064/// reuse this module's existing-Task-existence-check error mapping (same
1065/// 404-vs-500 split `task_get` already applies).
1066pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1067    match e {
1068        TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1069        other => ApiError::engine(other),
1070    }
1071}
1072
1073fn map_run_store_err(e: RunStoreError) -> ApiError {
1074    match e {
1075        RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1076        other => ApiError::engine(other),
1077    }
1078}
1079
1080// ──────────────────────────────────────────────────────────────────────────
1081// UT
1082// ──────────────────────────────────────────────────────────────────────────
1083
1084#[cfg(test)]
1085mod tests {
1086    use super::*;
1087    use mlua_swarm::application::BlueprintRef;
1088    use mlua_swarm::blueprint::{
1089        current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1090        CompilerStrategy,
1091    };
1092    use mlua_swarm::core::config::EngineCfg;
1093    use mlua_swarm::core::engine::Engine;
1094    use mlua_swarm::store::output::InMemoryOutputStore;
1095    use mlua_swarm::store::run::InMemoryRunStore;
1096    use mlua_swarm::store::task::InMemoryTaskStore;
1097    use std::collections::HashMap;
1098    use std::sync::Arc;
1099    use tokio::sync::Mutex;
1100
1101    /// A single-step flow.ir Blueprint that always succeeds: `Step { ref:
1102    /// "identity", in: lit("hello"), out: $.out }` against the baseline
1103    /// `RustFn` identity worker (same shape as `seed_blueprint` in
1104    /// `mlua-swarm-cli`'s `serve.rs`, self-contained here rather than
1105    /// importing a binary crate).
1106    fn identity_blueprint() -> Blueprint {
1107        Blueprint {
1108            schema_version: current_schema_version(),
1109            id: "tasks-test-bp".into(),
1110            flow: serde_json::from_value(serde_json::json!({
1111                "kind": "step",
1112                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1113                "in": {"op": "lit", "value": "hello"},
1114                "out": {"op": "path", "at": "$.out"},
1115            }))
1116            .expect("flow parse"),
1117            agents: vec![AgentDef {
1118                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1119                kind: AgentKind::RustFn,
1120                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1121                profile: None,
1122                meta: None,
1123                runner: None,
1124                runner_ref: None,
1125                verdict: None,
1126            }],
1127            operators: vec![],
1128            metas: vec![],
1129            hints: CompilerHints::default(),
1130            strategy: CompilerStrategy::default(),
1131            metadata: BlueprintMetadata::default(),
1132            spawner_hints: Default::default(),
1133            default_agent_kind: AgentKind::Operator,
1134            default_operator_kind: None,
1135            default_init_ctx: None,
1136            default_agent_ctx: None,
1137            default_context_policy: None,
1138            projection_placement: None,
1139            audits: vec![],
1140            degradation_policy: None,
1141            runners: vec![],
1142            default_runner: None,
1143            check_policy: None,
1144            blueprint_ref_includes: Vec::new(),
1145        }
1146    }
1147
1148    /// Minimal `AppState` for handler-level tests — mirrors the construction
1149    /// `build_router_full` does internally, but skips the `Router` wrapper so
1150    /// tests can call handler functions directly (this crate's established
1151    /// unit-test convention; see e.g. `operator_ws::login`'s tests).
1152    fn test_state() -> AppState {
1153        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1154        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1155        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1156        AppState {
1157            engine,
1158            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1159            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1160            ws_operator_factory: None,
1161            data_store: Arc::new(InMemoryOutputStore::new()),
1162            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1163            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1164            task_store: Arc::new(InMemoryTaskStore::new()),
1165            run_store: Arc::new(InMemoryRunStore::new()),
1166            replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1167            base_url: None,
1168            sync_timeout_secs: 300,
1169        }
1170    }
1171
1172    fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
1173        crate::TaskLaunchRequest {
1174            blueprint: BlueprintRef::Inline {
1175                value: Box::new(identity_blueprint()),
1176            },
1177            init_ctx: serde_json::json!({"in": "hello"}),
1178            project_root: None,
1179            work_dir: None,
1180            task_metadata: None,
1181            ttl_secs: None,
1182            operator: None,
1183            operator_sid: None,
1184            timeout_secs: None,
1185            goal: Some(goal.to_string()),
1186            detach: false,
1187            check_policy: None,
1188        }
1189    }
1190
1191    #[test]
1192    fn task_id_serializes_as_bare_string() {
1193        // Sanity check for the newtype-struct transparency relied on
1194        // throughout this module's response shapes (`TaskId` / `RunId`
1195        // serialize as plain JSON strings, not `{"0": "..."}`).
1196        let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
1197        assert_eq!(v, serde_json::json!("T-abc"));
1198    }
1199
1200    #[tokio::test]
1201    async fn post_then_get_drill_down() {
1202        let state = test_state();
1203
1204        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
1205            .await
1206            .expect("tasks_start")
1207            .0;
1208        let task_id = posted.task_id.clone();
1209        let run_id = posted.run_id.clone();
1210
1211        // GET /v1/tasks lists it.
1212        let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
1213            .await
1214            .expect("tasks_list")
1215            .0;
1216        assert!(
1217            list.iter().any(|t| t.id == task_id),
1218            "task {task_id} missing from list of {} tasks",
1219            list.len()
1220        );
1221
1222        // GET /v1/tasks/:id drills down to the Task + its Run.
1223        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1224            .await
1225            .expect("task_get")
1226            .0;
1227        assert_eq!(detail.task.id, task_id);
1228        assert_eq!(detail.task.goal, "smoke goal");
1229        assert_eq!(detail.task.status, TaskRecordStatus::Done);
1230        assert_eq!(detail.runs.len(), 1);
1231        assert_eq!(detail.runs[0].id, run_id);
1232        assert_eq!(detail.runs[0].status, RunStatus::Done);
1233
1234        // GET /v1/runs/:id returns the same Run directly.
1235        let run = run_get(State(state.clone()), Path(run_id.to_string()))
1236            .await
1237            .expect("run_get")
1238            .0;
1239        assert_eq!(run.id, run_id);
1240        assert_eq!(run.task_id, task_id);
1241        assert_eq!(run.result_ref, Some(posted.final_ctx));
1242
1243        // issue #13 run_id propagation: `POST /v1/tasks` (`run_flow_form`)
1244        // wires a `RunContext` into `TaskApplication::handle_with_run`, so
1245        // the single dispatched step must be traced into `step_entries`.
1246        assert_eq!(
1247            run.step_entries.len(),
1248            1,
1249            "expected one step_entry for the 1-step identity Blueprint, got {:?}",
1250            run.step_entries
1251        );
1252        assert_eq!(
1253            run.step_entries[0].step_ref,
1254            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1255        );
1256        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1257    }
1258
1259    // ──────────────────────────────────────────────────────────────────
1260    // GH #33 — sync-hang guards (readiness precheck / timeout ceiling)
1261    // ──────────────────────────────────────────────────────────────────
1262
1263    /// Same 1-step identity flow as [`identity_blueprint`], but opts into
1264    /// the Blueprint-global Operator delegate axis
1265    /// (`spawner_hints.layers = ["operator_delegate"]`) so a registered
1266    /// `Operator` backend can be exercised end-to-end through the real
1267    /// `tasks_start` dispatch path (`OperatorDelegateMiddleware` bypasses
1268    /// `inner.spawn` and calls `operator.execute` instead — see
1269    /// `mlua_swarm::middleware::OperatorDelegateMiddleware` doc).
1270    fn identity_blueprint_with_operator_delegate() -> Blueprint {
1271        Blueprint {
1272            spawner_hints: mlua_swarm::SpawnerHints {
1273                layers: vec!["operator_delegate".to_string()],
1274            },
1275            ..identity_blueprint()
1276        }
1277    }
1278
1279    /// `Operator` stub whose `execute` never resolves — the GH #33 Guard 2
1280    /// fixture ("a registered-but-never-acking operator").
1281    struct StallingOperator;
1282
1283    #[async_trait::async_trait]
1284    impl mlua_swarm::Operator for StallingOperator {
1285        async fn execute(
1286            &self,
1287            _ctx: &mlua_swarm::Ctx,
1288            _system: Option<String>,
1289            _prompt: Value,
1290            _worker: Option<mlua_swarm::WorkerBinding>,
1291            _worker_token: mlua_swarm::CapToken,
1292        ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1293            std::future::pending::<()>().await;
1294            unreachable!("StallingOperator.execute must never resolve")
1295        }
1296    }
1297
1298    /// A launch request that references an operator backend by id (via
1299    /// `operator.operator_backend_id`, the coarse Guard 1 signal) against
1300    /// [`identity_blueprint_with_operator_delegate`].
1301    fn operator_launch_req(
1302        backend_id: &str,
1303        timeout_secs: Option<u64>,
1304    ) -> crate::TaskLaunchRequest {
1305        crate::TaskLaunchRequest {
1306            blueprint: BlueprintRef::Inline {
1307                value: Box::new(identity_blueprint_with_operator_delegate()),
1308            },
1309            init_ctx: serde_json::json!({"in": "hello"}),
1310            project_root: None,
1311            work_dir: None,
1312            task_metadata: None,
1313            ttl_secs: None,
1314            operator: Some(crate::OperatorReq {
1315                operator_backend_id: Some(backend_id.to_string()),
1316                ..Default::default()
1317            }),
1318            operator_sid: None,
1319            timeout_secs,
1320            goal: Some("operator delegate test goal".to_string()),
1321            detach: false,
1322            check_policy: None,
1323        }
1324    }
1325
1326    /// Guard 1: an operator-requiring launch with zero attached operators
1327    /// must fail immediately with a structured `503`, not hang waiting on
1328    /// a session nothing can serve.
1329    #[tokio::test]
1330    async fn sync_launch_zero_operators_fails_fast() {
1331        let state = test_state();
1332        // No `state.engine.register_operator(...)` call — zero operators
1333        // attached, matching `list_operator_ids()` being empty.
1334        let req = operator_launch_req("nonexistent-op", None);
1335
1336        let started = std::time::Instant::now();
1337        let result = crate::tasks_start(State(state), Json(req)).await;
1338        let elapsed = started.elapsed();
1339
1340        let err = match result {
1341            Err(e) => e,
1342            Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1343        };
1344        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1345        assert!(
1346            err.message.contains("no operator attached"),
1347            "error message must mention the missing operator: {}",
1348            err.message
1349        );
1350        assert!(
1351            elapsed < Duration::from_secs(1),
1352            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1353        );
1354    }
1355
1356    /// Guard 2: a launch that resolves to a registered-but-stalled
1357    /// operator session must return a structured `504` within the
1358    /// requested `timeout_secs` ceiling, not hang the request forever.
1359    #[tokio::test]
1360    async fn sync_launch_stalled_times_out() {
1361        let state = test_state();
1362        state
1363            .engine
1364            .register_operator("stall-op", Arc::new(StallingOperator))
1365            .await;
1366        let req = operator_launch_req("stall-op", Some(1));
1367
1368        let started = std::time::Instant::now();
1369        // Outer safety-net timeout: if guard 2 itself regressed into an
1370        // infinite hang, fail this test loudly instead of stalling `cargo
1371        // test` indefinitely.
1372        let result = tokio::time::timeout(
1373            Duration::from_secs(5),
1374            crate::tasks_start(State(state), Json(req)),
1375        )
1376        .await
1377        .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1378        let elapsed = started.elapsed();
1379
1380        let err = match result {
1381            Err(e) => e,
1382            Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1383        };
1384        assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1385        assert!(
1386            err.message.contains('1'),
1387            "error message must mention the configured 1s ceiling: {}",
1388            err.message
1389        );
1390        assert!(
1391            elapsed < Duration::from_secs(3),
1392            "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1393        );
1394    }
1395
1396    /// Invariant 2: a launch that never references an operator backend
1397    /// must never be rejected by guard 1 — the simplest existing passing
1398    /// fixture (`post_tasks_req`) still succeeds unaffected.
1399    #[tokio::test]
1400    async fn sync_launch_without_operator_path_unaffected() {
1401        let state = test_state();
1402        let result = crate::tasks_start(
1403            State(state),
1404            Json(post_tasks_req("non-operator launch goal")),
1405        )
1406        .await;
1407        if let Err(e) = &result {
1408            panic!(
1409                "non-operator launch must succeed unaffected by guard 1: {}",
1410                e.message
1411            );
1412        }
1413    }
1414
1415    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid
1416    /// (design doc: "0 = reject with 400 or treat as invalid — pick one
1417    /// and test it") — rejected fast, before any Task/Run side effects.
1418    #[tokio::test]
1419    async fn sync_launch_zero_timeout_secs_rejected() {
1420        let state = test_state();
1421        let mut req = post_tasks_req("zero timeout goal");
1422        req.timeout_secs = Some(0);
1423
1424        let result = crate::tasks_start(State(state), Json(req)).await;
1425        let err = match result {
1426            Err(e) => e,
1427            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1428        };
1429        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1430        assert!(
1431            err.message.contains("timeout_secs"),
1432            "error message must reference timeout_secs: {}",
1433            err.message
1434        );
1435    }
1436
1437    // ──────────────────────────────────────────────────────────────────
1438    // GH #37 — detached launch / rekick (driver decoupled from request)
1439    // ──────────────────────────────────────────────────────────────────
1440
1441    /// Polls the run store until the given Run reaches a terminal status,
1442    /// panicking after ~5s — the detached paths complete in the
1443    /// background, so tests must wait on the store rather than the
1444    /// response.
1445    async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1446        for _ in 0..50 {
1447            let rec = state.run_store.get(run_id).await.expect("run get");
1448            if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1449                return rec;
1450            }
1451            tokio::time::sleep(Duration::from_millis(100)).await;
1452        }
1453        panic!("run {run_id} did not reach a terminal status within ~5s");
1454    }
1455
1456    /// GH #37: `detach: true` returns `202 Accepted` immediately with
1457    /// `status: "running"` and a null `final_ctx`; the eval completes in
1458    /// the background and the Run/Task reach `Done` with the result and
1459    /// step trace persisted — the same terminal state the sync path
1460    /// produces.
1461    #[tokio::test]
1462    async fn detached_launch_returns_202_and_completes_in_background() {
1463        let state = test_state();
1464        let mut req = post_tasks_req("detached goal");
1465        req.detach = true;
1466
1467        let reply = crate::tasks_start(State(state.clone()), Json(req))
1468            .await
1469            .expect("tasks_start (detached)");
1470        assert_eq!(reply.1, StatusCode::ACCEPTED);
1471        let posted = reply.0;
1472        assert_eq!(posted.status, RunStatus::Running);
1473        assert_eq!(
1474            posted.final_ctx,
1475            serde_json::Value::Null,
1476            "a detached launch has no final_ctx at response time"
1477        );
1478
1479        let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1480        assert_eq!(rec.status, RunStatus::Done);
1481        assert!(
1482            rec.result_ref.is_some(),
1483            "finalize_run must persist the background eval's final_ctx"
1484        );
1485        assert_eq!(
1486            rec.step_entries.len(),
1487            1,
1488            "the background eval must trace its step_entries like the sync path: {:?}",
1489            rec.step_entries
1490        );
1491        let task = state
1492            .task_store
1493            .get(&posted.task_id)
1494            .await
1495            .expect("task get");
1496        assert_eq!(task.status, TaskRecordStatus::Done);
1497    }
1498
1499    /// GH #37: `detach: true` + `timeout_secs` is contradictory (the sync
1500    /// ceiling has no meaning for a detached run) — rejected with `400`
1501    /// before any Task/Run side effects.
1502    #[tokio::test]
1503    async fn detached_launch_with_timeout_secs_rejected() {
1504        let state = test_state();
1505        let mut req = post_tasks_req("detached + ceiling goal");
1506        req.detach = true;
1507        req.timeout_secs = Some(60);
1508
1509        let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1510            Err(e) => e,
1511            Ok(_) => panic!("detach + timeout_secs must be rejected"),
1512        };
1513        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1514        assert!(
1515            err.message.contains("detach"),
1516            "error message must explain the detach/timeout_secs conflict: {}",
1517            err.message
1518        );
1519        let tasks = state.task_store.list().await.expect("task list");
1520        assert!(
1521            tasks.is_empty(),
1522            "the 400 must fire before any TaskRecord is minted"
1523        );
1524    }
1525
1526    /// GH #37: a detached rekick returns `202 Accepted` with `status:
1527    /// "running"` immediately and completes in the background, adding a
1528    /// second `Done` Run to the same Task.
1529    #[tokio::test]
1530    async fn rekick_detached_returns_202_and_completes_in_background() {
1531        let state = test_state();
1532        let posted = crate::tasks_start(
1533            State(state.clone()),
1534            Json(post_tasks_req("detached rekick goal")),
1535        )
1536        .await
1537        .expect("tasks_start")
1538        .0;
1539
1540        let (status, rekicked) = task_rekick(
1541            State(state.clone()),
1542            Path(posted.task_id.to_string()),
1543            Some(Json(RunKickRequest {
1544                init_ctx_override: None,
1545                task_input_override: None,
1546                timeout_secs: None,
1547                detach: true,
1548            })),
1549        )
1550        .await
1551        .expect("task_rekick (detached)");
1552        assert_eq!(status, StatusCode::ACCEPTED);
1553        assert_eq!(rekicked.0.status, RunStatus::Running);
1554        assert_ne!(rekicked.0.run_id, posted.run_id);
1555
1556        let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1557        assert_eq!(rec.status, RunStatus::Done);
1558        assert!(
1559            rec.result_ref.is_some(),
1560            "finalize_run must persist the background rekick's final_ctx"
1561        );
1562    }
1563
1564    /// GH #37: `detach: true` + `timeout_secs` on the rekick path is the
1565    /// same contradiction as on the launch path — `400`, no new Run
1566    /// minted.
1567    #[tokio::test]
1568    async fn rekick_detached_with_timeout_secs_rejected() {
1569        let state = test_state();
1570        let posted = crate::tasks_start(
1571            State(state.clone()),
1572            Json(post_tasks_req("detached rekick ceiling goal")),
1573        )
1574        .await
1575        .expect("tasks_start")
1576        .0;
1577
1578        let err = match task_rekick(
1579            State(state.clone()),
1580            Path(posted.task_id.to_string()),
1581            Some(Json(RunKickRequest {
1582                init_ctx_override: None,
1583                task_input_override: None,
1584                timeout_secs: Some(60),
1585                detach: true,
1586            })),
1587        )
1588        .await
1589        {
1590            Err(e) => e,
1591            Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1592        };
1593        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1594        assert!(
1595            err.message.contains("detach"),
1596            "error message must explain the detach/timeout_secs conflict: {}",
1597            err.message
1598        );
1599        let runs = state
1600            .run_store
1601            .list_by_task(&posted.task_id)
1602            .await
1603            .expect("runs list");
1604        assert_eq!(
1605            runs.len(),
1606            1,
1607            "the 400 must fire before a second Run is minted"
1608        );
1609    }
1610
1611    #[tokio::test]
1612    async fn rekick_adds_a_second_run_to_the_same_task() {
1613        let state = test_state();
1614        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1615            .await
1616            .expect("tasks_start")
1617            .0;
1618        let task_id = posted.task_id.clone();
1619        let first_run_id = posted.run_id.clone();
1620
1621        let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1622            .await
1623            .expect("task_rekick");
1624        assert_eq!(status, StatusCode::CREATED);
1625        let second_run_id = rekicked.0.run_id.clone();
1626        assert_ne!(first_run_id, second_run_id);
1627
1628        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1629            .await
1630            .expect("task_get")
1631            .0;
1632        assert_eq!(
1633            detail.runs.len(),
1634            2,
1635            "expected 2 runs, got {:?}",
1636            detail.runs
1637        );
1638        let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1639        assert!(ids.contains(&&first_run_id));
1640        assert!(ids.contains(&&second_run_id));
1641
1642        // issue #13 run_id propagation: each kick's own `EngineDispatcher`
1643        // (built fresh per `TaskApplication::handle_with_run` call) must
1644        // trace its own dispatched step into its own `RunRecord` —
1645        // independent `step_entries`, not shared/accumulated across kicks.
1646        let first_run = detail
1647            .runs
1648            .iter()
1649            .find(|r| r.id == first_run_id)
1650            .expect("first run present in detail.runs");
1651        let second_run = detail
1652            .runs
1653            .iter()
1654            .find(|r| r.id == second_run_id)
1655            .expect("second run present in detail.runs");
1656        assert_eq!(
1657            first_run.step_entries.len(),
1658            1,
1659            "first run step_entries: {:?}",
1660            first_run.step_entries
1661        );
1662        assert_eq!(
1663            second_run.step_entries.len(),
1664            1,
1665            "second run step_entries: {:?}",
1666            second_run.step_entries
1667        );
1668        assert_eq!(
1669            first_run.step_entries[0].step_ref,
1670            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1671        );
1672        assert_eq!(
1673            second_run.step_entries[0].step_ref,
1674            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1675        );
1676        assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1677        assert_eq!(
1678            second_run.step_entries[0].status,
1679            Some("passed".to_string())
1680        );
1681        assert_ne!(
1682            first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1683            "each kick dispatches its own StepId — runs must not share step_entries"
1684        );
1685    }
1686
1687    #[tokio::test]
1688    async fn rekick_unknown_task_returns_404() {
1689        let state = test_state();
1690        // `.expect_err()` needs the Ok variant to be `Debug`; `Json<T>`'s
1691        // `Debug` impl is not guaranteed for every `T` across axum versions,
1692        // so a plain match sidesteps that bound entirely.
1693        match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1694            Ok(_) => panic!("expected 404 for an unknown task"),
1695            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1696        }
1697    }
1698
1699    // ──────────────────────────────────────────────────────────────────
1700    // issue #19 ST4: `RunKickRequest` (optional body / 3-layer merge)
1701    // ──────────────────────────────────────────────────────────────────
1702
1703    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
1704    /// `$.out` — unlike [`identity_blueprint`] (a fixed `lit("hello")`
1705    /// input), this one reads its `Step.in` from `ctx`, so it observes
1706    /// whichever `init_ctx` layer actually won the merge.
1707    fn greeting_blueprint() -> Blueprint {
1708        Blueprint {
1709            schema_version: current_schema_version(),
1710            id: "tasks-test-greeting-bp".into(),
1711            flow: serde_json::from_value(serde_json::json!({
1712                "kind": "step",
1713                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1714                "in": {"op": "path", "at": "$.greeting"},
1715                "out": {"op": "path", "at": "$.out"},
1716            }))
1717            .expect("flow parse"),
1718            agents: vec![AgentDef {
1719                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1720                kind: AgentKind::RustFn,
1721                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1722                profile: None,
1723                meta: None,
1724                runner: None,
1725                runner_ref: None,
1726                verdict: None,
1727            }],
1728            operators: vec![],
1729            metas: vec![],
1730            hints: CompilerHints::default(),
1731            strategy: CompilerStrategy::default(),
1732            metadata: BlueprintMetadata::default(),
1733            spawner_hints: Default::default(),
1734            default_agent_kind: AgentKind::Operator,
1735            default_operator_kind: None,
1736            default_init_ctx: None,
1737            default_agent_ctx: None,
1738            default_context_policy: None,
1739            projection_placement: None,
1740            audits: vec![],
1741            degradation_policy: None,
1742            runners: vec![],
1743            default_runner: None,
1744            check_policy: None,
1745            blueprint_ref_includes: Vec::new(),
1746        }
1747    }
1748
1749    fn post_greeting_task_req(
1750        greeting: &str,
1751        project_root: Option<&str>,
1752    ) -> crate::TaskLaunchRequest {
1753        crate::TaskLaunchRequest {
1754            blueprint: BlueprintRef::Inline {
1755                value: Box::new(greeting_blueprint()),
1756            },
1757            init_ctx: serde_json::json!({ "greeting": greeting }),
1758            project_root: project_root.map(str::to_string),
1759            work_dir: None,
1760            task_metadata: None,
1761            ttl_secs: None,
1762            operator: None,
1763            operator_sid: None,
1764            timeout_secs: None,
1765            goal: Some("st4 rekick goal".to_string()),
1766            detach: false,
1767            check_policy: None,
1768        }
1769    }
1770
1771    #[tokio::test]
1772    async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1773        // must_not_simplify #3: a body-less rekick must behave exactly
1774        // like pre-#19 — the Task's own `input_ctx` alone seeds the kick.
1775        let state = test_state();
1776        let posted = crate::tasks_start(
1777            State(state.clone()),
1778            Json(post_greeting_task_req("from-task", None)),
1779        )
1780        .await
1781        .expect("tasks_start")
1782        .0;
1783        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1784
1785        let (status, rekicked) =
1786            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1787                .await
1788                .expect("task_rekick");
1789        assert_eq!(status, StatusCode::CREATED);
1790
1791        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1792            .await
1793            .expect("run_get")
1794            .0;
1795        assert_eq!(
1796            run.result_ref.expect("result_ref present")["out"]["echoed"],
1797            "from-task"
1798        );
1799    }
1800
1801    #[tokio::test]
1802    async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1803        let state = test_state();
1804        let posted = crate::tasks_start(
1805            State(state.clone()),
1806            Json(post_greeting_task_req("from-task", None)),
1807        )
1808        .await
1809        .expect("tasks_start")
1810        .0;
1811        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1812
1813        let (status, rekicked) = task_rekick(
1814            State(state.clone()),
1815            Path(posted.task_id.to_string()),
1816            Some(Json(RunKickRequest {
1817                init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1818                task_input_override: None,
1819                timeout_secs: None,
1820                detach: false,
1821            })),
1822        )
1823        .await
1824        .expect("task_rekick");
1825        assert_eq!(status, StatusCode::CREATED);
1826
1827        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1828            .await
1829            .expect("run_get")
1830            .0;
1831        assert_eq!(
1832            run.result_ref.expect("result_ref present")["out"]["echoed"],
1833            "from-run",
1834            "Run's init_ctx_override must win over the stored Task input_ctx"
1835        );
1836    }
1837
1838    #[tokio::test]
1839    async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1840        // Done Criteria: "Task record が task-level canonical fields を
1841        // 保持している時の rekick test". A Task created with
1842        // `project_root` set gets a `task_input_spec` snapshot; a
1843        // body-less rekick must both dispatch successfully (the stored
1844        // spec decodes and resolves without erroring) and leave
1845        // `TaskRecord.task_input_spec` untouched (must_not_simplify #4 —
1846        // a rekick never mutates the stored Task-level snapshot).
1847        let state = test_state();
1848        let posted = crate::tasks_start(
1849            State(state.clone()),
1850            Json(post_greeting_task_req("from-task", Some("/repo"))),
1851        )
1852        .await
1853        .expect("tasks_start")
1854        .0;
1855
1856        let before = state
1857            .task_store
1858            .get(&posted.task_id)
1859            .await
1860            .expect("task fetch");
1861        let before_spec: Option<TaskInputSpec> = before
1862            .task_input_spec
1863            .as_ref()
1864            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1865        assert_eq!(
1866            before_spec,
1867            Some(TaskInputSpec {
1868                project_root: Some("/repo".to_string()),
1869                work_dir: None,
1870                task_metadata: None,
1871            })
1872        );
1873
1874        let (status, _rekicked) =
1875            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1876                .await
1877                .expect("task_rekick");
1878        assert_eq!(status, StatusCode::CREATED);
1879
1880        let after = state
1881            .task_store
1882            .get(&posted.task_id)
1883            .await
1884            .expect("task fetch");
1885        assert_eq!(
1886            after.task_input_spec, before.task_input_spec,
1887            "rekick must not mutate the stored Task-level task_input_spec snapshot"
1888        );
1889    }
1890
1891    #[tokio::test]
1892    async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1893        // must_not_simplify #4: `task_input_override` wins for this kick
1894        // only — the stored `TaskRecord.task_input_spec` is untouched.
1895        let state = test_state();
1896        let posted = crate::tasks_start(
1897            State(state.clone()),
1898            Json(post_greeting_task_req("from-task", Some("/repo"))),
1899        )
1900        .await
1901        .expect("tasks_start")
1902        .0;
1903
1904        let (status, _rekicked) = task_rekick(
1905            State(state.clone()),
1906            Path(posted.task_id.to_string()),
1907            Some(Json(RunKickRequest {
1908                init_ctx_override: None,
1909                task_input_override: Some(TaskInputSpec {
1910                    project_root: Some("/override".to_string()),
1911                    work_dir: None,
1912                    task_metadata: None,
1913                }),
1914                timeout_secs: None,
1915                detach: false,
1916            })),
1917        )
1918        .await
1919        .expect("task_rekick");
1920        assert_eq!(status, StatusCode::CREATED);
1921
1922        let after = state
1923            .task_store
1924            .get(&posted.task_id)
1925            .await
1926            .expect("task fetch");
1927        let after_spec: Option<TaskInputSpec> = after
1928            .task_input_spec
1929            .as_ref()
1930            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1931        assert_eq!(
1932            after_spec,
1933            Some(TaskInputSpec {
1934                project_root: Some("/repo".to_string()),
1935                work_dir: None,
1936                task_metadata: None,
1937            }),
1938            "a per-Run task_input_override must not leak into the stored TaskRecord"
1939        );
1940    }
1941
1942    // ──────────────────────────────────────────────────────────────────
1943    // GH #33 → task_rekick — sync-hang guards (issue #35 ST3 parity)
1944    // ──────────────────────────────────────────────────────────────────
1945
1946    /// A launch request for [`identity_blueprint_with_operator_delegate`]
1947    /// that does **not** reference an operator backend (`operator: None`)
1948    /// — used to create a rekick-able Task without tripping
1949    /// `run_flow_form`'s own Guard 1 at initial-launch time (the launch
1950    /// itself dispatches through the plain baseline path since
1951    /// `ctx.operator.operator` stays unset either way; the BP's
1952    /// `operator_delegate` layer only matters to `task_rekick`'s Guard 1,
1953    /// which reads `resolved_bp.spawner_hints.layers` directly rather than
1954    /// a per-request field).
1955    fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
1956        crate::TaskLaunchRequest {
1957            blueprint: BlueprintRef::Inline {
1958                value: Box::new(identity_blueprint_with_operator_delegate()),
1959            },
1960            init_ctx: serde_json::json!({"in": "hello"}),
1961            project_root: None,
1962            work_dir: None,
1963            task_metadata: None,
1964            ttl_secs: None,
1965            operator: None,
1966            operator_sid: None,
1967            timeout_secs: None,
1968            goal: Some(goal.to_string()),
1969            detach: false,
1970            check_policy: None,
1971        }
1972    }
1973
1974    /// Guard 1 (adapted signal): a Task whose stored Blueprint declares
1975    /// the `operator_delegate` layer, rekicked with zero attached
1976    /// operators, must fail immediately with a structured `503` — not
1977    /// dispatch and not hang waiting on a session nothing can serve.
1978    #[tokio::test]
1979    async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
1980        let state = test_state();
1981        let posted = crate::tasks_start(
1982            State(state.clone()),
1983            Json(delegate_launch_req("operator delegate rekick goal")),
1984        )
1985        .await
1986        .expect("tasks_start (no operator referenced, dispatches through baseline)")
1987        .0;
1988        // No `state.engine.register_operator(...)` call — zero operators
1989        // attached, matching `list_operator_ids()` being empty.
1990
1991        let started = std::time::Instant::now();
1992        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1993        let elapsed = started.elapsed();
1994
1995        let err = match result {
1996            Err(e) => e,
1997            Ok(_) => panic!(
1998                "rekicking a Task whose Blueprint declares operator_delegate with zero \
1999                 attached operators must fail, not dispatch"
2000            ),
2001        };
2002        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2003        assert!(
2004            err.message.contains("no operator attached"),
2005            "error message must mention the missing operator: {}",
2006            err.message
2007        );
2008        assert!(
2009            elapsed < Duration::from_secs(1),
2010            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2011        );
2012    }
2013
2014    /// Guard 2: a rekick with a `timeout_secs` ceiling shorter than the
2015    /// dispatch takes must return a structured `504` within the outer
2016    /// safety-net timeout, not hang the request forever.
2017    #[tokio::test]
2018    async fn rekick_stalled_operator_times_out() {
2019        let state = test_state();
2020        state
2021            .engine
2022            .register_operator("stall-op", Arc::new(StallingOperator))
2023            .await;
2024        let posted = crate::tasks_start(
2025            State(state.clone()),
2026            Json(delegate_launch_req("stalled rekick goal")),
2027        )
2028        .await
2029        .expect("tasks_start")
2030        .0;
2031
2032        let started = std::time::Instant::now();
2033        // Outer safety-net timeout: if guard 2 itself regressed into an
2034        // infinite hang, fail this test loudly instead of stalling `cargo
2035        // test` indefinitely.
2036        let result = tokio::time::timeout(
2037            Duration::from_secs(5),
2038            task_rekick(
2039                State(state),
2040                Path(posted.task_id.to_string()),
2041                Some(Json(RunKickRequest {
2042                    init_ctx_override: None,
2043                    task_input_override: None,
2044                    timeout_secs: Some(1),
2045                    detach: false,
2046                })),
2047            ),
2048        )
2049        .await
2050        .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
2051        let elapsed = started.elapsed();
2052
2053        match &result {
2054            Err(e) => {
2055                assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
2056                assert!(
2057                    e.message.contains('1'),
2058                    "error message must mention the configured 1s ceiling: {}",
2059                    e.message
2060                );
2061                assert!(
2062                    elapsed < Duration::from_secs(3),
2063                    "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2064                );
2065            }
2066            Ok(_) => {
2067                // `task_rekick` hardcodes `operator_backend_id: None` for
2068                // every kick (module doc, above — "no per-request Operator
2069                // override support here"), so a registered-but-unattached
2070                // `StallingOperator` is never actually engaged by a
2071                // rekick's dispatch; the flow resolves through the plain
2072                // baseline path instead. Guard 2's `tokio::time::timeout`
2073                // wrap is exercised (and does not falsely fire) rather
2074                // than tripped — assert the fast-success shape so a
2075                // regression that makes rekick dispatch slow (or that
2076                // makes Guard 2 falsely trip on a fast dispatch) is still
2077                // caught by the elapsed-time assertion below.
2078                assert!(
2079                    elapsed < Duration::from_secs(1),
2080                    "a rekick that never engages an Operator (task_rekick has no \
2081                     per-request operator override) must resolve fast, not stall: took {elapsed:?}"
2082                );
2083            }
2084        }
2085    }
2086
2087    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid —
2088    /// rejected fast, before any Task/Run side effects (the pre-existing
2089    /// run count for the rekicked Task is unchanged).
2090    #[tokio::test]
2091    async fn rekick_timeout_secs_zero_rejected() {
2092        let state = test_state();
2093        let posted = crate::tasks_start(
2094            State(state.clone()),
2095            Json(post_tasks_req("zero timeout rekick goal")),
2096        )
2097        .await
2098        .expect("tasks_start")
2099        .0;
2100
2101        let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2102            .await
2103            .expect("task_get")
2104            .0;
2105        let runs_before = before.runs.len();
2106
2107        let result = task_rekick(
2108            State(state.clone()),
2109            Path(posted.task_id.to_string()),
2110            Some(Json(RunKickRequest {
2111                init_ctx_override: None,
2112                task_input_override: None,
2113                timeout_secs: Some(0),
2114                detach: false,
2115            })),
2116        )
2117        .await;
2118        let err = match result {
2119            Err(e) => e,
2120            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2121        };
2122        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2123        assert!(
2124            err.message.contains("timeout_secs"),
2125            "error message must reference timeout_secs: {}",
2126            err.message
2127        );
2128
2129        let after = task_get(State(state), Path(posted.task_id.to_string()))
2130            .await
2131            .expect("task_get")
2132            .0;
2133        assert_eq!(
2134            after.runs.len(),
2135            runs_before,
2136            "a rejected timeout_secs: Some(0) rekick must not create a new Run"
2137        );
2138    }
2139
2140    /// Invariant: a plain (non-`operator_delegate`) Task rekick must
2141    /// never be rejected by Guard 1 — the simplest existing passing
2142    /// rekick fixture still succeeds unaffected.
2143    #[tokio::test]
2144    async fn rekick_non_operator_path_unaffected_by_guard_1() {
2145        let state = test_state();
2146        let posted = crate::tasks_start(
2147            State(state.clone()),
2148            Json(post_tasks_req("non-operator rekick goal")),
2149        )
2150        .await
2151        .expect("tasks_start")
2152        .0;
2153
2154        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2155        if let Err(e) = &result {
2156            panic!(
2157                "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
2158                 guard 1: {}",
2159                e.message
2160            );
2161        }
2162    }
2163
2164    #[tokio::test]
2165    async fn run_get_unknown_id_returns_404() {
2166        let state = test_state();
2167        match run_get(State(state), Path("R-does-not-exist".to_string())).await {
2168            Ok(_) => panic!("expected 404 for an unknown run"),
2169            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2170        }
2171    }
2172
2173    #[tokio::test]
2174    async fn task_get_unknown_id_returns_404() {
2175        let state = test_state();
2176        match task_get(State(state), Path("T-does-not-exist".to_string())).await {
2177            Ok(_) => panic!("expected 404 for an unknown task"),
2178            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2179        }
2180    }
2181}