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        }
879    }
880
881    /// Minimal `AppState` for handler-level tests — mirrors the construction
882    /// `build_router_full` does internally, but skips the `Router` wrapper so
883    /// tests can call handler functions directly (this crate's established
884    /// unit-test convention; see e.g. `operator_ws::login`'s tests).
885    fn test_state() -> AppState {
886        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
887        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
888        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
889        AppState {
890            engine,
891            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
892            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
893            ws_operator_factory: None,
894            data_store: Arc::new(InMemoryOutputStore::new()),
895            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
896            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
897            task_store: Arc::new(InMemoryTaskStore::new()),
898            run_store: Arc::new(InMemoryRunStore::new()),
899            replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
900            base_url: None,
901            sync_timeout_secs: 300,
902        }
903    }
904
905    fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
906        crate::TaskLaunchRequest {
907            blueprint: BlueprintRef::Inline {
908                value: Box::new(identity_blueprint()),
909            },
910            init_ctx: serde_json::json!({"in": "hello"}),
911            project_root: None,
912            work_dir: None,
913            task_metadata: None,
914            ttl_secs: None,
915            operator: None,
916            operator_sid: None,
917            timeout_secs: None,
918            goal: Some(goal.to_string()),
919            detach: false,
920            check_policy: None,
921        }
922    }
923
924    #[test]
925    fn task_id_serializes_as_bare_string() {
926        // Sanity check for the newtype-struct transparency relied on
927        // throughout this module's response shapes (`TaskId` / `RunId`
928        // serialize as plain JSON strings, not `{"0": "..."}`).
929        let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
930        assert_eq!(v, serde_json::json!("T-abc"));
931    }
932
933    #[tokio::test]
934    async fn post_then_get_drill_down() {
935        let state = test_state();
936
937        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
938            .await
939            .expect("tasks_start")
940            .0;
941        let task_id = posted.task_id.clone();
942        let run_id = posted.run_id.clone();
943
944        // GET /v1/tasks lists it.
945        let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
946            .await
947            .expect("tasks_list")
948            .0;
949        assert!(
950            list.iter().any(|t| t.id == task_id),
951            "task {task_id} missing from list of {} tasks",
952            list.len()
953        );
954
955        // GET /v1/tasks/:id drills down to the Task + its Run.
956        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
957            .await
958            .expect("task_get")
959            .0;
960        assert_eq!(detail.task.id, task_id);
961        assert_eq!(detail.task.goal, "smoke goal");
962        assert_eq!(detail.task.status, TaskRecordStatus::Done);
963        assert_eq!(detail.runs.len(), 1);
964        assert_eq!(detail.runs[0].id, run_id);
965        assert_eq!(detail.runs[0].status, RunStatus::Done);
966
967        // GET /v1/runs/:id returns the same Run directly.
968        let run = run_get(State(state.clone()), Path(run_id.to_string()))
969            .await
970            .expect("run_get")
971            .0;
972        assert_eq!(run.id, run_id);
973        assert_eq!(run.task_id, task_id);
974        assert_eq!(run.result_ref, Some(posted.final_ctx));
975
976        // issue #13 run_id propagation: `POST /v1/tasks` (`run_flow_form`)
977        // wires a `RunContext` into `TaskApplication::handle_with_run`, so
978        // the single dispatched step must be traced into `step_entries`.
979        assert_eq!(
980            run.step_entries.len(),
981            1,
982            "expected one step_entry for the 1-step identity Blueprint, got {:?}",
983            run.step_entries
984        );
985        assert_eq!(
986            run.step_entries[0].step_ref,
987            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
988        );
989        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
990    }
991
992    // ──────────────────────────────────────────────────────────────────
993    // GH #33 — sync-hang guards (readiness precheck / timeout ceiling)
994    // ──────────────────────────────────────────────────────────────────
995
996    /// Same 1-step identity flow as [`identity_blueprint`], but opts into
997    /// the Blueprint-global Operator delegate axis
998    /// (`spawner_hints.layers = ["operator_delegate"]`) so a registered
999    /// `Operator` backend can be exercised end-to-end through the real
1000    /// `tasks_start` dispatch path (`OperatorDelegateMiddleware` bypasses
1001    /// `inner.spawn` and calls `operator.execute` instead — see
1002    /// `mlua_swarm::middleware::OperatorDelegateMiddleware` doc).
1003    fn identity_blueprint_with_operator_delegate() -> Blueprint {
1004        Blueprint {
1005            spawner_hints: mlua_swarm::SpawnerHints {
1006                layers: vec!["operator_delegate".to_string()],
1007            },
1008            ..identity_blueprint()
1009        }
1010    }
1011
1012    /// `Operator` stub whose `execute` never resolves — the GH #33 Guard 2
1013    /// fixture ("a registered-but-never-acking operator").
1014    struct StallingOperator;
1015
1016    #[async_trait::async_trait]
1017    impl mlua_swarm::Operator for StallingOperator {
1018        async fn execute(
1019            &self,
1020            _ctx: &mlua_swarm::Ctx,
1021            _system: Option<String>,
1022            _prompt: Value,
1023            _worker: Option<mlua_swarm::WorkerBinding>,
1024            _worker_token: mlua_swarm::CapToken,
1025        ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1026            std::future::pending::<()>().await;
1027            unreachable!("StallingOperator.execute must never resolve")
1028        }
1029    }
1030
1031    /// A launch request that references an operator backend by id (via
1032    /// `operator.operator_backend_id`, the coarse Guard 1 signal) against
1033    /// [`identity_blueprint_with_operator_delegate`].
1034    fn operator_launch_req(
1035        backend_id: &str,
1036        timeout_secs: Option<u64>,
1037    ) -> crate::TaskLaunchRequest {
1038        crate::TaskLaunchRequest {
1039            blueprint: BlueprintRef::Inline {
1040                value: Box::new(identity_blueprint_with_operator_delegate()),
1041            },
1042            init_ctx: serde_json::json!({"in": "hello"}),
1043            project_root: None,
1044            work_dir: None,
1045            task_metadata: None,
1046            ttl_secs: None,
1047            operator: Some(crate::OperatorReq {
1048                operator_backend_id: Some(backend_id.to_string()),
1049                ..Default::default()
1050            }),
1051            operator_sid: None,
1052            timeout_secs,
1053            goal: Some("operator delegate test goal".to_string()),
1054            detach: false,
1055            check_policy: None,
1056        }
1057    }
1058
1059    /// Guard 1: an operator-requiring launch with zero attached operators
1060    /// must fail immediately with a structured `503`, not hang waiting on
1061    /// a session nothing can serve.
1062    #[tokio::test]
1063    async fn sync_launch_zero_operators_fails_fast() {
1064        let state = test_state();
1065        // No `state.engine.register_operator(...)` call — zero operators
1066        // attached, matching `list_operator_ids()` being empty.
1067        let req = operator_launch_req("nonexistent-op", None);
1068
1069        let started = std::time::Instant::now();
1070        let result = crate::tasks_start(State(state), Json(req)).await;
1071        let elapsed = started.elapsed();
1072
1073        let err = match result {
1074            Err(e) => e,
1075            Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1076        };
1077        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1078        assert!(
1079            err.message.contains("no operator attached"),
1080            "error message must mention the missing operator: {}",
1081            err.message
1082        );
1083        assert!(
1084            elapsed < Duration::from_secs(1),
1085            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1086        );
1087    }
1088
1089    /// Guard 2: a launch that resolves to a registered-but-stalled
1090    /// operator session must return a structured `504` within the
1091    /// requested `timeout_secs` ceiling, not hang the request forever.
1092    #[tokio::test]
1093    async fn sync_launch_stalled_times_out() {
1094        let state = test_state();
1095        state
1096            .engine
1097            .register_operator("stall-op", Arc::new(StallingOperator))
1098            .await;
1099        let req = operator_launch_req("stall-op", Some(1));
1100
1101        let started = std::time::Instant::now();
1102        // Outer safety-net timeout: if guard 2 itself regressed into an
1103        // infinite hang, fail this test loudly instead of stalling `cargo
1104        // test` indefinitely.
1105        let result = tokio::time::timeout(
1106            Duration::from_secs(5),
1107            crate::tasks_start(State(state), Json(req)),
1108        )
1109        .await
1110        .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1111        let elapsed = started.elapsed();
1112
1113        let err = match result {
1114            Err(e) => e,
1115            Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1116        };
1117        assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1118        assert!(
1119            err.message.contains('1'),
1120            "error message must mention the configured 1s ceiling: {}",
1121            err.message
1122        );
1123        assert!(
1124            elapsed < Duration::from_secs(3),
1125            "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1126        );
1127    }
1128
1129    /// Invariant 2: a launch that never references an operator backend
1130    /// must never be rejected by guard 1 — the simplest existing passing
1131    /// fixture (`post_tasks_req`) still succeeds unaffected.
1132    #[tokio::test]
1133    async fn sync_launch_without_operator_path_unaffected() {
1134        let state = test_state();
1135        let result = crate::tasks_start(
1136            State(state),
1137            Json(post_tasks_req("non-operator launch goal")),
1138        )
1139        .await;
1140        if let Err(e) = &result {
1141            panic!(
1142                "non-operator launch must succeed unaffected by guard 1: {}",
1143                e.message
1144            );
1145        }
1146    }
1147
1148    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid
1149    /// (design doc: "0 = reject with 400 or treat as invalid — pick one
1150    /// and test it") — rejected fast, before any Task/Run side effects.
1151    #[tokio::test]
1152    async fn sync_launch_zero_timeout_secs_rejected() {
1153        let state = test_state();
1154        let mut req = post_tasks_req("zero timeout goal");
1155        req.timeout_secs = Some(0);
1156
1157        let result = crate::tasks_start(State(state), Json(req)).await;
1158        let err = match result {
1159            Err(e) => e,
1160            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1161        };
1162        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1163        assert!(
1164            err.message.contains("timeout_secs"),
1165            "error message must reference timeout_secs: {}",
1166            err.message
1167        );
1168    }
1169
1170    // ──────────────────────────────────────────────────────────────────
1171    // GH #37 — detached launch / rekick (driver decoupled from request)
1172    // ──────────────────────────────────────────────────────────────────
1173
1174    /// Polls the run store until the given Run reaches a terminal status,
1175    /// panicking after ~5s — the detached paths complete in the
1176    /// background, so tests must wait on the store rather than the
1177    /// response.
1178    async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1179        for _ in 0..50 {
1180            let rec = state.run_store.get(run_id).await.expect("run get");
1181            if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1182                return rec;
1183            }
1184            tokio::time::sleep(Duration::from_millis(100)).await;
1185        }
1186        panic!("run {run_id} did not reach a terminal status within ~5s");
1187    }
1188
1189    /// GH #37: `detach: true` returns `202 Accepted` immediately with
1190    /// `status: "running"` and a null `final_ctx`; the eval completes in
1191    /// the background and the Run/Task reach `Done` with the result and
1192    /// step trace persisted — the same terminal state the sync path
1193    /// produces.
1194    #[tokio::test]
1195    async fn detached_launch_returns_202_and_completes_in_background() {
1196        let state = test_state();
1197        let mut req = post_tasks_req("detached goal");
1198        req.detach = true;
1199
1200        let reply = crate::tasks_start(State(state.clone()), Json(req))
1201            .await
1202            .expect("tasks_start (detached)");
1203        assert_eq!(reply.1, StatusCode::ACCEPTED);
1204        let posted = reply.0;
1205        assert_eq!(posted.status, RunStatus::Running);
1206        assert_eq!(
1207            posted.final_ctx,
1208            serde_json::Value::Null,
1209            "a detached launch has no final_ctx at response time"
1210        );
1211
1212        let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1213        assert_eq!(rec.status, RunStatus::Done);
1214        assert!(
1215            rec.result_ref.is_some(),
1216            "finalize_run must persist the background eval's final_ctx"
1217        );
1218        assert_eq!(
1219            rec.step_entries.len(),
1220            1,
1221            "the background eval must trace its step_entries like the sync path: {:?}",
1222            rec.step_entries
1223        );
1224        let task = state
1225            .task_store
1226            .get(&posted.task_id)
1227            .await
1228            .expect("task get");
1229        assert_eq!(task.status, TaskRecordStatus::Done);
1230    }
1231
1232    /// GH #37: `detach: true` + `timeout_secs` is contradictory (the sync
1233    /// ceiling has no meaning for a detached run) — rejected with `400`
1234    /// before any Task/Run side effects.
1235    #[tokio::test]
1236    async fn detached_launch_with_timeout_secs_rejected() {
1237        let state = test_state();
1238        let mut req = post_tasks_req("detached + ceiling goal");
1239        req.detach = true;
1240        req.timeout_secs = Some(60);
1241
1242        let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1243            Err(e) => e,
1244            Ok(_) => panic!("detach + timeout_secs must be rejected"),
1245        };
1246        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1247        assert!(
1248            err.message.contains("detach"),
1249            "error message must explain the detach/timeout_secs conflict: {}",
1250            err.message
1251        );
1252        let tasks = state.task_store.list().await.expect("task list");
1253        assert!(
1254            tasks.is_empty(),
1255            "the 400 must fire before any TaskRecord is minted"
1256        );
1257    }
1258
1259    /// GH #37: a detached rekick returns `202 Accepted` with `status:
1260    /// "running"` immediately and completes in the background, adding a
1261    /// second `Done` Run to the same Task.
1262    #[tokio::test]
1263    async fn rekick_detached_returns_202_and_completes_in_background() {
1264        let state = test_state();
1265        let posted = crate::tasks_start(
1266            State(state.clone()),
1267            Json(post_tasks_req("detached rekick goal")),
1268        )
1269        .await
1270        .expect("tasks_start")
1271        .0;
1272
1273        let (status, rekicked) = task_rekick(
1274            State(state.clone()),
1275            Path(posted.task_id.to_string()),
1276            Some(Json(RunKickRequest {
1277                init_ctx_override: None,
1278                task_input_override: None,
1279                timeout_secs: None,
1280                detach: true,
1281            })),
1282        )
1283        .await
1284        .expect("task_rekick (detached)");
1285        assert_eq!(status, StatusCode::ACCEPTED);
1286        assert_eq!(rekicked.0.status, RunStatus::Running);
1287        assert_ne!(rekicked.0.run_id, posted.run_id);
1288
1289        let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1290        assert_eq!(rec.status, RunStatus::Done);
1291        assert!(
1292            rec.result_ref.is_some(),
1293            "finalize_run must persist the background rekick's final_ctx"
1294        );
1295    }
1296
1297    /// GH #37: `detach: true` + `timeout_secs` on the rekick path is the
1298    /// same contradiction as on the launch path — `400`, no new Run
1299    /// minted.
1300    #[tokio::test]
1301    async fn rekick_detached_with_timeout_secs_rejected() {
1302        let state = test_state();
1303        let posted = crate::tasks_start(
1304            State(state.clone()),
1305            Json(post_tasks_req("detached rekick ceiling goal")),
1306        )
1307        .await
1308        .expect("tasks_start")
1309        .0;
1310
1311        let err = match task_rekick(
1312            State(state.clone()),
1313            Path(posted.task_id.to_string()),
1314            Some(Json(RunKickRequest {
1315                init_ctx_override: None,
1316                task_input_override: None,
1317                timeout_secs: Some(60),
1318                detach: true,
1319            })),
1320        )
1321        .await
1322        {
1323            Err(e) => e,
1324            Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1325        };
1326        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1327        assert!(
1328            err.message.contains("detach"),
1329            "error message must explain the detach/timeout_secs conflict: {}",
1330            err.message
1331        );
1332        let runs = state
1333            .run_store
1334            .list_by_task(&posted.task_id)
1335            .await
1336            .expect("runs list");
1337        assert_eq!(
1338            runs.len(),
1339            1,
1340            "the 400 must fire before a second Run is minted"
1341        );
1342    }
1343
1344    #[tokio::test]
1345    async fn rekick_adds_a_second_run_to_the_same_task() {
1346        let state = test_state();
1347        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1348            .await
1349            .expect("tasks_start")
1350            .0;
1351        let task_id = posted.task_id.clone();
1352        let first_run_id = posted.run_id.clone();
1353
1354        let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1355            .await
1356            .expect("task_rekick");
1357        assert_eq!(status, StatusCode::CREATED);
1358        let second_run_id = rekicked.0.run_id.clone();
1359        assert_ne!(first_run_id, second_run_id);
1360
1361        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1362            .await
1363            .expect("task_get")
1364            .0;
1365        assert_eq!(
1366            detail.runs.len(),
1367            2,
1368            "expected 2 runs, got {:?}",
1369            detail.runs
1370        );
1371        let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1372        assert!(ids.contains(&&first_run_id));
1373        assert!(ids.contains(&&second_run_id));
1374
1375        // issue #13 run_id propagation: each kick's own `EngineDispatcher`
1376        // (built fresh per `TaskApplication::handle_with_run` call) must
1377        // trace its own dispatched step into its own `RunRecord` —
1378        // independent `step_entries`, not shared/accumulated across kicks.
1379        let first_run = detail
1380            .runs
1381            .iter()
1382            .find(|r| r.id == first_run_id)
1383            .expect("first run present in detail.runs");
1384        let second_run = detail
1385            .runs
1386            .iter()
1387            .find(|r| r.id == second_run_id)
1388            .expect("second run present in detail.runs");
1389        assert_eq!(
1390            first_run.step_entries.len(),
1391            1,
1392            "first run step_entries: {:?}",
1393            first_run.step_entries
1394        );
1395        assert_eq!(
1396            second_run.step_entries.len(),
1397            1,
1398            "second run step_entries: {:?}",
1399            second_run.step_entries
1400        );
1401        assert_eq!(
1402            first_run.step_entries[0].step_ref,
1403            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1404        );
1405        assert_eq!(
1406            second_run.step_entries[0].step_ref,
1407            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1408        );
1409        assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1410        assert_eq!(
1411            second_run.step_entries[0].status,
1412            Some("passed".to_string())
1413        );
1414        assert_ne!(
1415            first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1416            "each kick dispatches its own StepId — runs must not share step_entries"
1417        );
1418    }
1419
1420    #[tokio::test]
1421    async fn rekick_unknown_task_returns_404() {
1422        let state = test_state();
1423        // `.expect_err()` needs the Ok variant to be `Debug`; `Json<T>`'s
1424        // `Debug` impl is not guaranteed for every `T` across axum versions,
1425        // so a plain match sidesteps that bound entirely.
1426        match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1427            Ok(_) => panic!("expected 404 for an unknown task"),
1428            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1429        }
1430    }
1431
1432    // ──────────────────────────────────────────────────────────────────
1433    // issue #19 ST4: `RunKickRequest` (optional body / 3-layer merge)
1434    // ──────────────────────────────────────────────────────────────────
1435
1436    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
1437    /// `$.out` — unlike [`identity_blueprint`] (a fixed `lit("hello")`
1438    /// input), this one reads its `Step.in` from `ctx`, so it observes
1439    /// whichever `init_ctx` layer actually won the merge.
1440    fn greeting_blueprint() -> Blueprint {
1441        Blueprint {
1442            schema_version: current_schema_version(),
1443            id: "tasks-test-greeting-bp".into(),
1444            flow: serde_json::from_value(serde_json::json!({
1445                "kind": "step",
1446                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1447                "in": {"op": "path", "at": "$.greeting"},
1448                "out": {"op": "path", "at": "$.out"},
1449            }))
1450            .expect("flow parse"),
1451            agents: vec![AgentDef {
1452                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1453                kind: AgentKind::RustFn,
1454                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1455                profile: None,
1456                meta: None,
1457                runner: None,
1458                runner_ref: None,
1459                verdict: None,
1460            }],
1461            operators: vec![],
1462            metas: vec![],
1463            hints: CompilerHints::default(),
1464            strategy: CompilerStrategy::default(),
1465            metadata: BlueprintMetadata::default(),
1466            spawner_hints: Default::default(),
1467            default_agent_kind: AgentKind::Operator,
1468            default_operator_kind: None,
1469            default_init_ctx: None,
1470            default_agent_ctx: None,
1471            default_context_policy: None,
1472            projection_placement: None,
1473            audits: vec![],
1474            degradation_policy: None,
1475            runners: vec![],
1476            default_runner: None,
1477            check_policy: None,
1478        }
1479    }
1480
1481    fn post_greeting_task_req(
1482        greeting: &str,
1483        project_root: Option<&str>,
1484    ) -> crate::TaskLaunchRequest {
1485        crate::TaskLaunchRequest {
1486            blueprint: BlueprintRef::Inline {
1487                value: Box::new(greeting_blueprint()),
1488            },
1489            init_ctx: serde_json::json!({ "greeting": greeting }),
1490            project_root: project_root.map(str::to_string),
1491            work_dir: None,
1492            task_metadata: None,
1493            ttl_secs: None,
1494            operator: None,
1495            operator_sid: None,
1496            timeout_secs: None,
1497            goal: Some("st4 rekick goal".to_string()),
1498            detach: false,
1499            check_policy: None,
1500        }
1501    }
1502
1503    #[tokio::test]
1504    async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1505        // must_not_simplify #3: a body-less rekick must behave exactly
1506        // like pre-#19 — the Task's own `input_ctx` alone seeds the kick.
1507        let state = test_state();
1508        let posted = crate::tasks_start(
1509            State(state.clone()),
1510            Json(post_greeting_task_req("from-task", None)),
1511        )
1512        .await
1513        .expect("tasks_start")
1514        .0;
1515        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1516
1517        let (status, rekicked) =
1518            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1519                .await
1520                .expect("task_rekick");
1521        assert_eq!(status, StatusCode::CREATED);
1522
1523        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1524            .await
1525            .expect("run_get")
1526            .0;
1527        assert_eq!(
1528            run.result_ref.expect("result_ref present")["out"]["echoed"],
1529            "from-task"
1530        );
1531    }
1532
1533    #[tokio::test]
1534    async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1535        let state = test_state();
1536        let posted = crate::tasks_start(
1537            State(state.clone()),
1538            Json(post_greeting_task_req("from-task", None)),
1539        )
1540        .await
1541        .expect("tasks_start")
1542        .0;
1543        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1544
1545        let (status, rekicked) = task_rekick(
1546            State(state.clone()),
1547            Path(posted.task_id.to_string()),
1548            Some(Json(RunKickRequest {
1549                init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1550                task_input_override: None,
1551                timeout_secs: None,
1552                detach: false,
1553            })),
1554        )
1555        .await
1556        .expect("task_rekick");
1557        assert_eq!(status, StatusCode::CREATED);
1558
1559        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1560            .await
1561            .expect("run_get")
1562            .0;
1563        assert_eq!(
1564            run.result_ref.expect("result_ref present")["out"]["echoed"],
1565            "from-run",
1566            "Run's init_ctx_override must win over the stored Task input_ctx"
1567        );
1568    }
1569
1570    #[tokio::test]
1571    async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1572        // Done Criteria: "Task record が task-level canonical fields を
1573        // 保持している時の rekick test". A Task created with
1574        // `project_root` set gets a `task_input_spec` snapshot; a
1575        // body-less rekick must both dispatch successfully (the stored
1576        // spec decodes and resolves without erroring) and leave
1577        // `TaskRecord.task_input_spec` untouched (must_not_simplify #4 —
1578        // a rekick never mutates the stored Task-level snapshot).
1579        let state = test_state();
1580        let posted = crate::tasks_start(
1581            State(state.clone()),
1582            Json(post_greeting_task_req("from-task", Some("/repo"))),
1583        )
1584        .await
1585        .expect("tasks_start")
1586        .0;
1587
1588        let before = state
1589            .task_store
1590            .get(&posted.task_id)
1591            .await
1592            .expect("task fetch");
1593        let before_spec: Option<TaskInputSpec> = before
1594            .task_input_spec
1595            .as_ref()
1596            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1597        assert_eq!(
1598            before_spec,
1599            Some(TaskInputSpec {
1600                project_root: Some("/repo".to_string()),
1601                work_dir: None,
1602                task_metadata: None,
1603            })
1604        );
1605
1606        let (status, _rekicked) =
1607            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1608                .await
1609                .expect("task_rekick");
1610        assert_eq!(status, StatusCode::CREATED);
1611
1612        let after = state
1613            .task_store
1614            .get(&posted.task_id)
1615            .await
1616            .expect("task fetch");
1617        assert_eq!(
1618            after.task_input_spec, before.task_input_spec,
1619            "rekick must not mutate the stored Task-level task_input_spec snapshot"
1620        );
1621    }
1622
1623    #[tokio::test]
1624    async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1625        // must_not_simplify #4: `task_input_override` wins for this kick
1626        // only — the stored `TaskRecord.task_input_spec` is untouched.
1627        let state = test_state();
1628        let posted = crate::tasks_start(
1629            State(state.clone()),
1630            Json(post_greeting_task_req("from-task", Some("/repo"))),
1631        )
1632        .await
1633        .expect("tasks_start")
1634        .0;
1635
1636        let (status, _rekicked) = task_rekick(
1637            State(state.clone()),
1638            Path(posted.task_id.to_string()),
1639            Some(Json(RunKickRequest {
1640                init_ctx_override: None,
1641                task_input_override: Some(TaskInputSpec {
1642                    project_root: Some("/override".to_string()),
1643                    work_dir: None,
1644                    task_metadata: None,
1645                }),
1646                timeout_secs: None,
1647                detach: false,
1648            })),
1649        )
1650        .await
1651        .expect("task_rekick");
1652        assert_eq!(status, StatusCode::CREATED);
1653
1654        let after = state
1655            .task_store
1656            .get(&posted.task_id)
1657            .await
1658            .expect("task fetch");
1659        let after_spec: Option<TaskInputSpec> = after
1660            .task_input_spec
1661            .as_ref()
1662            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1663        assert_eq!(
1664            after_spec,
1665            Some(TaskInputSpec {
1666                project_root: Some("/repo".to_string()),
1667                work_dir: None,
1668                task_metadata: None,
1669            }),
1670            "a per-Run task_input_override must not leak into the stored TaskRecord"
1671        );
1672    }
1673
1674    // ──────────────────────────────────────────────────────────────────
1675    // GH #33 → task_rekick — sync-hang guards (issue #35 ST3 parity)
1676    // ──────────────────────────────────────────────────────────────────
1677
1678    /// A launch request for [`identity_blueprint_with_operator_delegate`]
1679    /// that does **not** reference an operator backend (`operator: None`)
1680    /// — used to create a rekick-able Task without tripping
1681    /// `run_flow_form`'s own Guard 1 at initial-launch time (the launch
1682    /// itself dispatches through the plain baseline path since
1683    /// `ctx.operator.operator` stays unset either way; the BP's
1684    /// `operator_delegate` layer only matters to `task_rekick`'s Guard 1,
1685    /// which reads `resolved_bp.spawner_hints.layers` directly rather than
1686    /// a per-request field).
1687    fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
1688        crate::TaskLaunchRequest {
1689            blueprint: BlueprintRef::Inline {
1690                value: Box::new(identity_blueprint_with_operator_delegate()),
1691            },
1692            init_ctx: serde_json::json!({"in": "hello"}),
1693            project_root: None,
1694            work_dir: None,
1695            task_metadata: None,
1696            ttl_secs: None,
1697            operator: None,
1698            operator_sid: None,
1699            timeout_secs: None,
1700            goal: Some(goal.to_string()),
1701            detach: false,
1702            check_policy: None,
1703        }
1704    }
1705
1706    /// Guard 1 (adapted signal): a Task whose stored Blueprint declares
1707    /// the `operator_delegate` layer, rekicked with zero attached
1708    /// operators, must fail immediately with a structured `503` — not
1709    /// dispatch and not hang waiting on a session nothing can serve.
1710    #[tokio::test]
1711    async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
1712        let state = test_state();
1713        let posted = crate::tasks_start(
1714            State(state.clone()),
1715            Json(delegate_launch_req("operator delegate rekick goal")),
1716        )
1717        .await
1718        .expect("tasks_start (no operator referenced, dispatches through baseline)")
1719        .0;
1720        // No `state.engine.register_operator(...)` call — zero operators
1721        // attached, matching `list_operator_ids()` being empty.
1722
1723        let started = std::time::Instant::now();
1724        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1725        let elapsed = started.elapsed();
1726
1727        let err = match result {
1728            Err(e) => e,
1729            Ok(_) => panic!(
1730                "rekicking a Task whose Blueprint declares operator_delegate with zero \
1731                 attached operators must fail, not dispatch"
1732            ),
1733        };
1734        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1735        assert!(
1736            err.message.contains("no operator attached"),
1737            "error message must mention the missing operator: {}",
1738            err.message
1739        );
1740        assert!(
1741            elapsed < Duration::from_secs(1),
1742            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1743        );
1744    }
1745
1746    /// Guard 2: a rekick with a `timeout_secs` ceiling shorter than the
1747    /// dispatch takes must return a structured `504` within the outer
1748    /// safety-net timeout, not hang the request forever.
1749    #[tokio::test]
1750    async fn rekick_stalled_operator_times_out() {
1751        let state = test_state();
1752        state
1753            .engine
1754            .register_operator("stall-op", Arc::new(StallingOperator))
1755            .await;
1756        let posted = crate::tasks_start(
1757            State(state.clone()),
1758            Json(delegate_launch_req("stalled rekick goal")),
1759        )
1760        .await
1761        .expect("tasks_start")
1762        .0;
1763
1764        let started = std::time::Instant::now();
1765        // Outer safety-net timeout: if guard 2 itself regressed into an
1766        // infinite hang, fail this test loudly instead of stalling `cargo
1767        // test` indefinitely.
1768        let result = tokio::time::timeout(
1769            Duration::from_secs(5),
1770            task_rekick(
1771                State(state),
1772                Path(posted.task_id.to_string()),
1773                Some(Json(RunKickRequest {
1774                    init_ctx_override: None,
1775                    task_input_override: None,
1776                    timeout_secs: Some(1),
1777                    detach: false,
1778                })),
1779            ),
1780        )
1781        .await
1782        .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
1783        let elapsed = started.elapsed();
1784
1785        match &result {
1786            Err(e) => {
1787                assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
1788                assert!(
1789                    e.message.contains('1'),
1790                    "error message must mention the configured 1s ceiling: {}",
1791                    e.message
1792                );
1793                assert!(
1794                    elapsed < Duration::from_secs(3),
1795                    "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1796                );
1797            }
1798            Ok(_) => {
1799                // `task_rekick` hardcodes `operator_backend_id: None` for
1800                // every kick (module doc, above — "no per-request Operator
1801                // override support here"), so a registered-but-unattached
1802                // `StallingOperator` is never actually engaged by a
1803                // rekick's dispatch; the flow resolves through the plain
1804                // baseline path instead. Guard 2's `tokio::time::timeout`
1805                // wrap is exercised (and does not falsely fire) rather
1806                // than tripped — assert the fast-success shape so a
1807                // regression that makes rekick dispatch slow (or that
1808                // makes Guard 2 falsely trip on a fast dispatch) is still
1809                // caught by the elapsed-time assertion below.
1810                assert!(
1811                    elapsed < Duration::from_secs(1),
1812                    "a rekick that never engages an Operator (task_rekick has no \
1813                     per-request operator override) must resolve fast, not stall: took {elapsed:?}"
1814                );
1815            }
1816        }
1817    }
1818
1819    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid —
1820    /// rejected fast, before any Task/Run side effects (the pre-existing
1821    /// run count for the rekicked Task is unchanged).
1822    #[tokio::test]
1823    async fn rekick_timeout_secs_zero_rejected() {
1824        let state = test_state();
1825        let posted = crate::tasks_start(
1826            State(state.clone()),
1827            Json(post_tasks_req("zero timeout rekick goal")),
1828        )
1829        .await
1830        .expect("tasks_start")
1831        .0;
1832
1833        let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
1834            .await
1835            .expect("task_get")
1836            .0;
1837        let runs_before = before.runs.len();
1838
1839        let result = task_rekick(
1840            State(state.clone()),
1841            Path(posted.task_id.to_string()),
1842            Some(Json(RunKickRequest {
1843                init_ctx_override: None,
1844                task_input_override: None,
1845                timeout_secs: Some(0),
1846                detach: false,
1847            })),
1848        )
1849        .await;
1850        let err = match result {
1851            Err(e) => e,
1852            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1853        };
1854        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1855        assert!(
1856            err.message.contains("timeout_secs"),
1857            "error message must reference timeout_secs: {}",
1858            err.message
1859        );
1860
1861        let after = task_get(State(state), Path(posted.task_id.to_string()))
1862            .await
1863            .expect("task_get")
1864            .0;
1865        assert_eq!(
1866            after.runs.len(),
1867            runs_before,
1868            "a rejected timeout_secs: Some(0) rekick must not create a new Run"
1869        );
1870    }
1871
1872    /// Invariant: a plain (non-`operator_delegate`) Task rekick must
1873    /// never be rejected by Guard 1 — the simplest existing passing
1874    /// rekick fixture still succeeds unaffected.
1875    #[tokio::test]
1876    async fn rekick_non_operator_path_unaffected_by_guard_1() {
1877        let state = test_state();
1878        let posted = crate::tasks_start(
1879            State(state.clone()),
1880            Json(post_tasks_req("non-operator rekick goal")),
1881        )
1882        .await
1883        .expect("tasks_start")
1884        .0;
1885
1886        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1887        if let Err(e) = &result {
1888            panic!(
1889                "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
1890                 guard 1: {}",
1891                e.message
1892            );
1893        }
1894    }
1895
1896    #[tokio::test]
1897    async fn run_get_unknown_id_returns_404() {
1898        let state = test_state();
1899        match run_get(State(state), Path("R-does-not-exist".to_string())).await {
1900            Ok(_) => panic!("expected 404 for an unknown run"),
1901            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1902        }
1903    }
1904
1905    #[tokio::test]
1906    async fn task_get_unknown_id_returns_404() {
1907        let state = test_state();
1908        match task_get(State(state), Path("T-does-not-exist".to_string())).await {
1909            Ok(_) => panic!("expected 404 for an unknown task"),
1910            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1911        }
1912    }
1913}