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