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