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    };
395    let run_ctx = RunContext {
396        run_id: run_id.clone(),
397        run_store: state.run_store.clone(),
398    };
399
400    // GH #37 detached rekick: same driver-detach semantics as
401    // `run_flow_form` — the eval runs in its own spawned task bounded by
402    // the run TTL alone, `finalize_run` (or the ttl-expiry `Failed`
403    // marking) is owned by that task, and this handler returns `202
404    // Accepted` immediately.
405    if detach {
406        let ttl_secs = crate::default_run_ttl();
407        let bg_state = state.clone();
408        let bg_task_id = task_id.clone();
409        let bg_run_id = run_id.clone();
410        tokio::spawn(async move {
411            let outcome = match tokio::time::timeout(
412                Duration::from_secs(ttl_secs),
413                bg_state.task_app.handle_with_run(input, Some(run_ctx)),
414            )
415            .await
416            {
417                Ok(outcome) => outcome,
418                Err(_elapsed) => {
419                    let reason = serde_json::json!({
420                        "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
421                    });
422                    if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
423                        tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
424                    }
425                    if let Err(e) = bg_state
426                        .run_store
427                        .update_status(&bg_run_id, RunStatus::Failed)
428                        .await
429                    {
430                        tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
431                    }
432                    if let Err(e) = bg_state
433                        .task_store
434                        .update_status(&bg_task_id, TaskRecordStatus::Failed)
435                        .await
436                    {
437                        tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
438                    }
439                    return;
440                }
441            };
442            // `finalize_run` persists both the Ok and Err outcomes itself;
443            // the passthrough return value has no consumer here.
444            let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
445        });
446        return Ok((
447            StatusCode::ACCEPTED,
448            Json(RunKickResponse {
449                task_id,
450                run_id,
451                status: RunStatus::Running,
452            }),
453        ));
454    }
455
456    // GH #33 Guard 2 (issue #35 ST3 — mirrors `run_flow_form`'s
457    // `lib.rs:935-990` exactly): the single await point this handler
458    // blocks on. On expiry the timed-out future is dropped, cancelling the
459    // in-process flow eval — the flow is abandoned, not resumed. Best
460    // effort: mark the Run/Task so they do not stay `Running` forever.
461    let outcome = match tokio::time::timeout(
462        Duration::from_secs(sync_timeout_secs),
463        state.task_app.handle_with_run(input, Some(run_ctx)),
464    )
465    .await
466    {
467        Ok(outcome) => outcome,
468        Err(_elapsed) => {
469            let reason = serde_json::json!({
470                "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
471            });
472            if let Err(e) = state.run_store.set_result(&run_id, reason).await {
473                tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
474            }
475            if let Err(e) = state
476                .run_store
477                .update_status(&run_id, RunStatus::Failed)
478                .await
479            {
480                tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
481            }
482            if let Err(e) = state
483                .task_store
484                .update_status(&task_id, TaskRecordStatus::Failed)
485                .await
486            {
487                tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
488            }
489            return Err(ApiError::timeout(format!(
490                "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
491            )));
492        }
493    };
494    finalize_run(&state, &task_id, &run_id, outcome)
495        .await
496        .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
497
498    Ok((
499        StatusCode::CREATED,
500        Json(RunKickResponse {
501            task_id,
502            run_id,
503            status: RunStatus::Done,
504        }),
505    ))
506}
507
508/// `GET /v1/runs/:id`. Returns a single `RunRecord` (its `step_entries`
509/// trace included).
510pub async fn run_get(
511    State(state): State<AppState>,
512    Path(id): Path<String>,
513) -> Result<Json<RunRecord>, ApiError> {
514    let run_id =
515        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
516    let run = state
517        .run_store
518        .get(&run_id)
519        .await
520        .map_err(map_run_store_err)?;
521    Ok(Json(run))
522}
523
524/// `pub(crate)` so `crate::projection`'s `GET /v1/tasks/:id/ctx` handler can
525/// reuse this module's existing-Task-existence-check error mapping (same
526/// 404-vs-500 split `task_get` already applies).
527pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
528    match e {
529        TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
530        other => ApiError::engine(other),
531    }
532}
533
534fn map_run_store_err(e: RunStoreError) -> ApiError {
535    match e {
536        RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
537        other => ApiError::engine(other),
538    }
539}
540
541// ──────────────────────────────────────────────────────────────────────────
542// UT
543// ──────────────────────────────────────────────────────────────────────────
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use mlua_swarm::application::BlueprintRef;
549    use mlua_swarm::blueprint::{
550        current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
551        CompilerStrategy,
552    };
553    use mlua_swarm::core::config::EngineCfg;
554    use mlua_swarm::core::engine::Engine;
555    use mlua_swarm::store::output::InMemoryOutputStore;
556    use mlua_swarm::store::run::InMemoryRunStore;
557    use mlua_swarm::store::task::InMemoryTaskStore;
558    use std::collections::HashMap;
559    use std::sync::Arc;
560    use tokio::sync::Mutex;
561
562    /// A single-step flow.ir Blueprint that always succeeds: `Step { ref:
563    /// "identity", in: lit("hello"), out: $.out }` against the baseline
564    /// `RustFn` identity worker (same shape as `seed_blueprint` in
565    /// `mlua-swarm-cli`'s `serve.rs`, self-contained here rather than
566    /// importing a binary crate).
567    fn identity_blueprint() -> Blueprint {
568        Blueprint {
569            schema_version: current_schema_version(),
570            id: "tasks-test-bp".into(),
571            flow: serde_json::from_value(serde_json::json!({
572                "kind": "step",
573                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
574                "in": {"op": "lit", "value": "hello"},
575                "out": {"op": "path", "at": "$.out"},
576            }))
577            .expect("flow parse"),
578            agents: vec![AgentDef {
579                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
580                kind: AgentKind::RustFn,
581                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
582                profile: None,
583                meta: None,
584                runner: None,
585                runner_ref: None,
586                verdict: None,
587            }],
588            operators: vec![],
589            metas: vec![],
590            hints: CompilerHints::default(),
591            strategy: CompilerStrategy::default(),
592            metadata: BlueprintMetadata::default(),
593            spawner_hints: Default::default(),
594            default_agent_kind: AgentKind::Operator,
595            default_operator_kind: None,
596            default_init_ctx: None,
597            default_agent_ctx: None,
598            default_context_policy: None,
599            projection_placement: None,
600            audits: vec![],
601            degradation_policy: None,
602            runners: vec![],
603            default_runner: None,
604        }
605    }
606
607    /// Minimal `AppState` for handler-level tests — mirrors the construction
608    /// `build_router_full` does internally, but skips the `Router` wrapper so
609    /// tests can call handler functions directly (this crate's established
610    /// unit-test convention; see e.g. `operator_ws::login`'s tests).
611    fn test_state() -> AppState {
612        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
613        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
614        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
615        AppState {
616            engine,
617            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
618            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
619            ws_operator_factory: None,
620            data_store: Arc::new(InMemoryOutputStore::new()),
621            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
622            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
623            task_store: Arc::new(InMemoryTaskStore::new()),
624            run_store: Arc::new(InMemoryRunStore::new()),
625            base_url: None,
626            sync_timeout_secs: 300,
627        }
628    }
629
630    fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
631        crate::TaskLaunchRequest {
632            blueprint: BlueprintRef::Inline {
633                value: Box::new(identity_blueprint()),
634            },
635            init_ctx: serde_json::json!({"in": "hello"}),
636            project_root: None,
637            work_dir: None,
638            task_metadata: None,
639            ttl_secs: None,
640            operator: None,
641            operator_sid: None,
642            timeout_secs: None,
643            goal: Some(goal.to_string()),
644            detach: false,
645        }
646    }
647
648    #[test]
649    fn task_id_serializes_as_bare_string() {
650        // Sanity check for the newtype-struct transparency relied on
651        // throughout this module's response shapes (`TaskId` / `RunId`
652        // serialize as plain JSON strings, not `{"0": "..."}`).
653        let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
654        assert_eq!(v, serde_json::json!("T-abc"));
655    }
656
657    #[tokio::test]
658    async fn post_then_get_drill_down() {
659        let state = test_state();
660
661        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
662            .await
663            .expect("tasks_start")
664            .0;
665        let task_id = posted.task_id.clone();
666        let run_id = posted.run_id.clone();
667
668        // GET /v1/tasks lists it.
669        let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
670            .await
671            .expect("tasks_list")
672            .0;
673        assert!(
674            list.iter().any(|t| t.id == task_id),
675            "task {task_id} missing from list of {} tasks",
676            list.len()
677        );
678
679        // GET /v1/tasks/:id drills down to the Task + its Run.
680        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
681            .await
682            .expect("task_get")
683            .0;
684        assert_eq!(detail.task.id, task_id);
685        assert_eq!(detail.task.goal, "smoke goal");
686        assert_eq!(detail.task.status, TaskRecordStatus::Done);
687        assert_eq!(detail.runs.len(), 1);
688        assert_eq!(detail.runs[0].id, run_id);
689        assert_eq!(detail.runs[0].status, RunStatus::Done);
690
691        // GET /v1/runs/:id returns the same Run directly.
692        let run = run_get(State(state.clone()), Path(run_id.to_string()))
693            .await
694            .expect("run_get")
695            .0;
696        assert_eq!(run.id, run_id);
697        assert_eq!(run.task_id, task_id);
698        assert_eq!(run.result_ref, Some(posted.final_ctx));
699
700        // issue #13 run_id propagation: `POST /v1/tasks` (`run_flow_form`)
701        // wires a `RunContext` into `TaskApplication::handle_with_run`, so
702        // the single dispatched step must be traced into `step_entries`.
703        assert_eq!(
704            run.step_entries.len(),
705            1,
706            "expected one step_entry for the 1-step identity Blueprint, got {:?}",
707            run.step_entries
708        );
709        assert_eq!(
710            run.step_entries[0].step_ref,
711            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
712        );
713        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
714    }
715
716    // ──────────────────────────────────────────────────────────────────
717    // GH #33 — sync-hang guards (readiness precheck / timeout ceiling)
718    // ──────────────────────────────────────────────────────────────────
719
720    /// Same 1-step identity flow as [`identity_blueprint`], but opts into
721    /// the Blueprint-global Operator delegate axis
722    /// (`spawner_hints.layers = ["operator_delegate"]`) so a registered
723    /// `Operator` backend can be exercised end-to-end through the real
724    /// `tasks_start` dispatch path (`OperatorDelegateMiddleware` bypasses
725    /// `inner.spawn` and calls `operator.execute` instead — see
726    /// `mlua_swarm::middleware::OperatorDelegateMiddleware` doc).
727    fn identity_blueprint_with_operator_delegate() -> Blueprint {
728        Blueprint {
729            spawner_hints: mlua_swarm::SpawnerHints {
730                layers: vec!["operator_delegate".to_string()],
731            },
732            ..identity_blueprint()
733        }
734    }
735
736    /// `Operator` stub whose `execute` never resolves — the GH #33 Guard 2
737    /// fixture ("a registered-but-never-acking operator").
738    struct StallingOperator;
739
740    #[async_trait::async_trait]
741    impl mlua_swarm::Operator for StallingOperator {
742        async fn execute(
743            &self,
744            _ctx: &mlua_swarm::Ctx,
745            _system: Option<String>,
746            _prompt: Value,
747            _worker: Option<mlua_swarm::WorkerBinding>,
748            _worker_token: mlua_swarm::CapToken,
749        ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
750            std::future::pending::<()>().await;
751            unreachable!("StallingOperator.execute must never resolve")
752        }
753    }
754
755    /// A launch request that references an operator backend by id (via
756    /// `operator.operator_backend_id`, the coarse Guard 1 signal) against
757    /// [`identity_blueprint_with_operator_delegate`].
758    fn operator_launch_req(
759        backend_id: &str,
760        timeout_secs: Option<u64>,
761    ) -> crate::TaskLaunchRequest {
762        crate::TaskLaunchRequest {
763            blueprint: BlueprintRef::Inline {
764                value: Box::new(identity_blueprint_with_operator_delegate()),
765            },
766            init_ctx: serde_json::json!({"in": "hello"}),
767            project_root: None,
768            work_dir: None,
769            task_metadata: None,
770            ttl_secs: None,
771            operator: Some(crate::OperatorReq {
772                operator_backend_id: Some(backend_id.to_string()),
773                ..Default::default()
774            }),
775            operator_sid: None,
776            timeout_secs,
777            goal: Some("operator delegate test goal".to_string()),
778            detach: false,
779        }
780    }
781
782    /// Guard 1: an operator-requiring launch with zero attached operators
783    /// must fail immediately with a structured `503`, not hang waiting on
784    /// a session nothing can serve.
785    #[tokio::test]
786    async fn sync_launch_zero_operators_fails_fast() {
787        let state = test_state();
788        // No `state.engine.register_operator(...)` call — zero operators
789        // attached, matching `list_operator_ids()` being empty.
790        let req = operator_launch_req("nonexistent-op", None);
791
792        let started = std::time::Instant::now();
793        let result = crate::tasks_start(State(state), Json(req)).await;
794        let elapsed = started.elapsed();
795
796        let err = match result {
797            Err(e) => e,
798            Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
799        };
800        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
801        assert!(
802            err.message.contains("no operator attached"),
803            "error message must mention the missing operator: {}",
804            err.message
805        );
806        assert!(
807            elapsed < Duration::from_secs(1),
808            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
809        );
810    }
811
812    /// Guard 2: a launch that resolves to a registered-but-stalled
813    /// operator session must return a structured `504` within the
814    /// requested `timeout_secs` ceiling, not hang the request forever.
815    #[tokio::test]
816    async fn sync_launch_stalled_times_out() {
817        let state = test_state();
818        state
819            .engine
820            .register_operator("stall-op", Arc::new(StallingOperator))
821            .await;
822        let req = operator_launch_req("stall-op", Some(1));
823
824        let started = std::time::Instant::now();
825        // Outer safety-net timeout: if guard 2 itself regressed into an
826        // infinite hang, fail this test loudly instead of stalling `cargo
827        // test` indefinitely.
828        let result = tokio::time::timeout(
829            Duration::from_secs(5),
830            crate::tasks_start(State(state), Json(req)),
831        )
832        .await
833        .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
834        let elapsed = started.elapsed();
835
836        let err = match result {
837            Err(e) => e,
838            Ok(_) => panic!("a stalled operator session must time out, not succeed"),
839        };
840        assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
841        assert!(
842            err.message.contains('1'),
843            "error message must mention the configured 1s ceiling: {}",
844            err.message
845        );
846        assert!(
847            elapsed < Duration::from_secs(3),
848            "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
849        );
850    }
851
852    /// Invariant 2: a launch that never references an operator backend
853    /// must never be rejected by guard 1 — the simplest existing passing
854    /// fixture (`post_tasks_req`) still succeeds unaffected.
855    #[tokio::test]
856    async fn sync_launch_without_operator_path_unaffected() {
857        let state = test_state();
858        let result = crate::tasks_start(
859            State(state),
860            Json(post_tasks_req("non-operator launch goal")),
861        )
862        .await;
863        if let Err(e) = &result {
864            panic!(
865                "non-operator launch must succeed unaffected by guard 1: {}",
866                e.message
867            );
868        }
869    }
870
871    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid
872    /// (design doc: "0 = reject with 400 or treat as invalid — pick one
873    /// and test it") — rejected fast, before any Task/Run side effects.
874    #[tokio::test]
875    async fn sync_launch_zero_timeout_secs_rejected() {
876        let state = test_state();
877        let mut req = post_tasks_req("zero timeout goal");
878        req.timeout_secs = Some(0);
879
880        let result = crate::tasks_start(State(state), Json(req)).await;
881        let err = match result {
882            Err(e) => e,
883            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
884        };
885        assert_eq!(err.status, StatusCode::BAD_REQUEST);
886        assert!(
887            err.message.contains("timeout_secs"),
888            "error message must reference timeout_secs: {}",
889            err.message
890        );
891    }
892
893    // ──────────────────────────────────────────────────────────────────
894    // GH #37 — detached launch / rekick (driver decoupled from request)
895    // ──────────────────────────────────────────────────────────────────
896
897    /// Polls the run store until the given Run reaches a terminal status,
898    /// panicking after ~5s — the detached paths complete in the
899    /// background, so tests must wait on the store rather than the
900    /// response.
901    async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
902        for _ in 0..50 {
903            let rec = state.run_store.get(run_id).await.expect("run get");
904            if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
905                return rec;
906            }
907            tokio::time::sleep(Duration::from_millis(100)).await;
908        }
909        panic!("run {run_id} did not reach a terminal status within ~5s");
910    }
911
912    /// GH #37: `detach: true` returns `202 Accepted` immediately with
913    /// `status: "running"` and a null `final_ctx`; the eval completes in
914    /// the background and the Run/Task reach `Done` with the result and
915    /// step trace persisted — the same terminal state the sync path
916    /// produces.
917    #[tokio::test]
918    async fn detached_launch_returns_202_and_completes_in_background() {
919        let state = test_state();
920        let mut req = post_tasks_req("detached goal");
921        req.detach = true;
922
923        let reply = crate::tasks_start(State(state.clone()), Json(req))
924            .await
925            .expect("tasks_start (detached)");
926        assert_eq!(reply.1, StatusCode::ACCEPTED);
927        let posted = reply.0;
928        assert_eq!(posted.status, RunStatus::Running);
929        assert_eq!(
930            posted.final_ctx,
931            serde_json::Value::Null,
932            "a detached launch has no final_ctx at response time"
933        );
934
935        let rec = wait_for_terminal_run(&state, &posted.run_id).await;
936        assert_eq!(rec.status, RunStatus::Done);
937        assert!(
938            rec.result_ref.is_some(),
939            "finalize_run must persist the background eval's final_ctx"
940        );
941        assert_eq!(
942            rec.step_entries.len(),
943            1,
944            "the background eval must trace its step_entries like the sync path: {:?}",
945            rec.step_entries
946        );
947        let task = state
948            .task_store
949            .get(&posted.task_id)
950            .await
951            .expect("task get");
952        assert_eq!(task.status, TaskRecordStatus::Done);
953    }
954
955    /// GH #37: `detach: true` + `timeout_secs` is contradictory (the sync
956    /// ceiling has no meaning for a detached run) — rejected with `400`
957    /// before any Task/Run side effects.
958    #[tokio::test]
959    async fn detached_launch_with_timeout_secs_rejected() {
960        let state = test_state();
961        let mut req = post_tasks_req("detached + ceiling goal");
962        req.detach = true;
963        req.timeout_secs = Some(60);
964
965        let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
966            Err(e) => e,
967            Ok(_) => panic!("detach + timeout_secs must be rejected"),
968        };
969        assert_eq!(err.status, StatusCode::BAD_REQUEST);
970        assert!(
971            err.message.contains("detach"),
972            "error message must explain the detach/timeout_secs conflict: {}",
973            err.message
974        );
975        let tasks = state.task_store.list().await.expect("task list");
976        assert!(
977            tasks.is_empty(),
978            "the 400 must fire before any TaskRecord is minted"
979        );
980    }
981
982    /// GH #37: a detached rekick returns `202 Accepted` with `status:
983    /// "running"` immediately and completes in the background, adding a
984    /// second `Done` Run to the same Task.
985    #[tokio::test]
986    async fn rekick_detached_returns_202_and_completes_in_background() {
987        let state = test_state();
988        let posted = crate::tasks_start(
989            State(state.clone()),
990            Json(post_tasks_req("detached rekick goal")),
991        )
992        .await
993        .expect("tasks_start")
994        .0;
995
996        let (status, rekicked) = task_rekick(
997            State(state.clone()),
998            Path(posted.task_id.to_string()),
999            Some(Json(RunKickRequest {
1000                init_ctx_override: None,
1001                task_input_override: None,
1002                timeout_secs: None,
1003                detach: true,
1004            })),
1005        )
1006        .await
1007        .expect("task_rekick (detached)");
1008        assert_eq!(status, StatusCode::ACCEPTED);
1009        assert_eq!(rekicked.0.status, RunStatus::Running);
1010        assert_ne!(rekicked.0.run_id, posted.run_id);
1011
1012        let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1013        assert_eq!(rec.status, RunStatus::Done);
1014        assert!(
1015            rec.result_ref.is_some(),
1016            "finalize_run must persist the background rekick's final_ctx"
1017        );
1018    }
1019
1020    /// GH #37: `detach: true` + `timeout_secs` on the rekick path is the
1021    /// same contradiction as on the launch path — `400`, no new Run
1022    /// minted.
1023    #[tokio::test]
1024    async fn rekick_detached_with_timeout_secs_rejected() {
1025        let state = test_state();
1026        let posted = crate::tasks_start(
1027            State(state.clone()),
1028            Json(post_tasks_req("detached rekick ceiling goal")),
1029        )
1030        .await
1031        .expect("tasks_start")
1032        .0;
1033
1034        let err = match task_rekick(
1035            State(state.clone()),
1036            Path(posted.task_id.to_string()),
1037            Some(Json(RunKickRequest {
1038                init_ctx_override: None,
1039                task_input_override: None,
1040                timeout_secs: Some(60),
1041                detach: true,
1042            })),
1043        )
1044        .await
1045        {
1046            Err(e) => e,
1047            Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1048        };
1049        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1050        assert!(
1051            err.message.contains("detach"),
1052            "error message must explain the detach/timeout_secs conflict: {}",
1053            err.message
1054        );
1055        let runs = state
1056            .run_store
1057            .list_by_task(&posted.task_id)
1058            .await
1059            .expect("runs list");
1060        assert_eq!(
1061            runs.len(),
1062            1,
1063            "the 400 must fire before a second Run is minted"
1064        );
1065    }
1066
1067    #[tokio::test]
1068    async fn rekick_adds_a_second_run_to_the_same_task() {
1069        let state = test_state();
1070        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1071            .await
1072            .expect("tasks_start")
1073            .0;
1074        let task_id = posted.task_id.clone();
1075        let first_run_id = posted.run_id.clone();
1076
1077        let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1078            .await
1079            .expect("task_rekick");
1080        assert_eq!(status, StatusCode::CREATED);
1081        let second_run_id = rekicked.0.run_id.clone();
1082        assert_ne!(first_run_id, second_run_id);
1083
1084        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1085            .await
1086            .expect("task_get")
1087            .0;
1088        assert_eq!(
1089            detail.runs.len(),
1090            2,
1091            "expected 2 runs, got {:?}",
1092            detail.runs
1093        );
1094        let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1095        assert!(ids.contains(&&first_run_id));
1096        assert!(ids.contains(&&second_run_id));
1097
1098        // issue #13 run_id propagation: each kick's own `EngineDispatcher`
1099        // (built fresh per `TaskApplication::handle_with_run` call) must
1100        // trace its own dispatched step into its own `RunRecord` —
1101        // independent `step_entries`, not shared/accumulated across kicks.
1102        let first_run = detail
1103            .runs
1104            .iter()
1105            .find(|r| r.id == first_run_id)
1106            .expect("first run present in detail.runs");
1107        let second_run = detail
1108            .runs
1109            .iter()
1110            .find(|r| r.id == second_run_id)
1111            .expect("second run present in detail.runs");
1112        assert_eq!(
1113            first_run.step_entries.len(),
1114            1,
1115            "first run step_entries: {:?}",
1116            first_run.step_entries
1117        );
1118        assert_eq!(
1119            second_run.step_entries.len(),
1120            1,
1121            "second run step_entries: {:?}",
1122            second_run.step_entries
1123        );
1124        assert_eq!(
1125            first_run.step_entries[0].step_ref,
1126            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1127        );
1128        assert_eq!(
1129            second_run.step_entries[0].step_ref,
1130            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1131        );
1132        assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1133        assert_eq!(
1134            second_run.step_entries[0].status,
1135            Some("passed".to_string())
1136        );
1137        assert_ne!(
1138            first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1139            "each kick dispatches its own StepId — runs must not share step_entries"
1140        );
1141    }
1142
1143    #[tokio::test]
1144    async fn rekick_unknown_task_returns_404() {
1145        let state = test_state();
1146        // `.expect_err()` needs the Ok variant to be `Debug`; `Json<T>`'s
1147        // `Debug` impl is not guaranteed for every `T` across axum versions,
1148        // so a plain match sidesteps that bound entirely.
1149        match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1150            Ok(_) => panic!("expected 404 for an unknown task"),
1151            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1152        }
1153    }
1154
1155    // ──────────────────────────────────────────────────────────────────
1156    // issue #19 ST4: `RunKickRequest` (optional body / 3-layer merge)
1157    // ──────────────────────────────────────────────────────────────────
1158
1159    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
1160    /// `$.out` — unlike [`identity_blueprint`] (a fixed `lit("hello")`
1161    /// input), this one reads its `Step.in` from `ctx`, so it observes
1162    /// whichever `init_ctx` layer actually won the merge.
1163    fn greeting_blueprint() -> Blueprint {
1164        Blueprint {
1165            schema_version: current_schema_version(),
1166            id: "tasks-test-greeting-bp".into(),
1167            flow: serde_json::from_value(serde_json::json!({
1168                "kind": "step",
1169                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1170                "in": {"op": "path", "at": "$.greeting"},
1171                "out": {"op": "path", "at": "$.out"},
1172            }))
1173            .expect("flow parse"),
1174            agents: vec![AgentDef {
1175                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1176                kind: AgentKind::RustFn,
1177                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1178                profile: None,
1179                meta: None,
1180                runner: None,
1181                runner_ref: None,
1182                verdict: None,
1183            }],
1184            operators: vec![],
1185            metas: vec![],
1186            hints: CompilerHints::default(),
1187            strategy: CompilerStrategy::default(),
1188            metadata: BlueprintMetadata::default(),
1189            spawner_hints: Default::default(),
1190            default_agent_kind: AgentKind::Operator,
1191            default_operator_kind: None,
1192            default_init_ctx: None,
1193            default_agent_ctx: None,
1194            default_context_policy: None,
1195            projection_placement: None,
1196            audits: vec![],
1197            degradation_policy: None,
1198            runners: vec![],
1199            default_runner: None,
1200        }
1201    }
1202
1203    fn post_greeting_task_req(
1204        greeting: &str,
1205        project_root: Option<&str>,
1206    ) -> crate::TaskLaunchRequest {
1207        crate::TaskLaunchRequest {
1208            blueprint: BlueprintRef::Inline {
1209                value: Box::new(greeting_blueprint()),
1210            },
1211            init_ctx: serde_json::json!({ "greeting": greeting }),
1212            project_root: project_root.map(str::to_string),
1213            work_dir: None,
1214            task_metadata: None,
1215            ttl_secs: None,
1216            operator: None,
1217            operator_sid: None,
1218            timeout_secs: None,
1219            goal: Some("st4 rekick goal".to_string()),
1220            detach: false,
1221        }
1222    }
1223
1224    #[tokio::test]
1225    async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1226        // must_not_simplify #3: a body-less rekick must behave exactly
1227        // like pre-#19 — the Task's own `input_ctx` alone seeds the kick.
1228        let state = test_state();
1229        let posted = crate::tasks_start(
1230            State(state.clone()),
1231            Json(post_greeting_task_req("from-task", None)),
1232        )
1233        .await
1234        .expect("tasks_start")
1235        .0;
1236        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1237
1238        let (status, rekicked) =
1239            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1240                .await
1241                .expect("task_rekick");
1242        assert_eq!(status, StatusCode::CREATED);
1243
1244        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1245            .await
1246            .expect("run_get")
1247            .0;
1248        assert_eq!(
1249            run.result_ref.expect("result_ref present")["out"]["echoed"],
1250            "from-task"
1251        );
1252    }
1253
1254    #[tokio::test]
1255    async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1256        let state = test_state();
1257        let posted = crate::tasks_start(
1258            State(state.clone()),
1259            Json(post_greeting_task_req("from-task", None)),
1260        )
1261        .await
1262        .expect("tasks_start")
1263        .0;
1264        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1265
1266        let (status, rekicked) = task_rekick(
1267            State(state.clone()),
1268            Path(posted.task_id.to_string()),
1269            Some(Json(RunKickRequest {
1270                init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1271                task_input_override: None,
1272                timeout_secs: None,
1273                detach: false,
1274            })),
1275        )
1276        .await
1277        .expect("task_rekick");
1278        assert_eq!(status, StatusCode::CREATED);
1279
1280        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1281            .await
1282            .expect("run_get")
1283            .0;
1284        assert_eq!(
1285            run.result_ref.expect("result_ref present")["out"]["echoed"],
1286            "from-run",
1287            "Run's init_ctx_override must win over the stored Task input_ctx"
1288        );
1289    }
1290
1291    #[tokio::test]
1292    async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1293        // Done Criteria: "Task record が task-level canonical fields を
1294        // 保持している時の rekick test". A Task created with
1295        // `project_root` set gets a `task_input_spec` snapshot; a
1296        // body-less rekick must both dispatch successfully (the stored
1297        // spec decodes and resolves without erroring) and leave
1298        // `TaskRecord.task_input_spec` untouched (must_not_simplify #4 —
1299        // a rekick never mutates the stored Task-level snapshot).
1300        let state = test_state();
1301        let posted = crate::tasks_start(
1302            State(state.clone()),
1303            Json(post_greeting_task_req("from-task", Some("/repo"))),
1304        )
1305        .await
1306        .expect("tasks_start")
1307        .0;
1308
1309        let before = state
1310            .task_store
1311            .get(&posted.task_id)
1312            .await
1313            .expect("task fetch");
1314        let before_spec: Option<TaskInputSpec> = before
1315            .task_input_spec
1316            .as_ref()
1317            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1318        assert_eq!(
1319            before_spec,
1320            Some(TaskInputSpec {
1321                project_root: Some("/repo".to_string()),
1322                work_dir: None,
1323                task_metadata: None,
1324            })
1325        );
1326
1327        let (status, _rekicked) =
1328            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1329                .await
1330                .expect("task_rekick");
1331        assert_eq!(status, StatusCode::CREATED);
1332
1333        let after = state
1334            .task_store
1335            .get(&posted.task_id)
1336            .await
1337            .expect("task fetch");
1338        assert_eq!(
1339            after.task_input_spec, before.task_input_spec,
1340            "rekick must not mutate the stored Task-level task_input_spec snapshot"
1341        );
1342    }
1343
1344    #[tokio::test]
1345    async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1346        // must_not_simplify #4: `task_input_override` wins for this kick
1347        // only — the stored `TaskRecord.task_input_spec` is untouched.
1348        let state = test_state();
1349        let posted = crate::tasks_start(
1350            State(state.clone()),
1351            Json(post_greeting_task_req("from-task", Some("/repo"))),
1352        )
1353        .await
1354        .expect("tasks_start")
1355        .0;
1356
1357        let (status, _rekicked) = task_rekick(
1358            State(state.clone()),
1359            Path(posted.task_id.to_string()),
1360            Some(Json(RunKickRequest {
1361                init_ctx_override: None,
1362                task_input_override: Some(TaskInputSpec {
1363                    project_root: Some("/override".to_string()),
1364                    work_dir: None,
1365                    task_metadata: None,
1366                }),
1367                timeout_secs: None,
1368                detach: false,
1369            })),
1370        )
1371        .await
1372        .expect("task_rekick");
1373        assert_eq!(status, StatusCode::CREATED);
1374
1375        let after = state
1376            .task_store
1377            .get(&posted.task_id)
1378            .await
1379            .expect("task fetch");
1380        let after_spec: Option<TaskInputSpec> = after
1381            .task_input_spec
1382            .as_ref()
1383            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1384        assert_eq!(
1385            after_spec,
1386            Some(TaskInputSpec {
1387                project_root: Some("/repo".to_string()),
1388                work_dir: None,
1389                task_metadata: None,
1390            }),
1391            "a per-Run task_input_override must not leak into the stored TaskRecord"
1392        );
1393    }
1394
1395    // ──────────────────────────────────────────────────────────────────
1396    // GH #33 → task_rekick — sync-hang guards (issue #35 ST3 parity)
1397    // ──────────────────────────────────────────────────────────────────
1398
1399    /// A launch request for [`identity_blueprint_with_operator_delegate`]
1400    /// that does **not** reference an operator backend (`operator: None`)
1401    /// — used to create a rekick-able Task without tripping
1402    /// `run_flow_form`'s own Guard 1 at initial-launch time (the launch
1403    /// itself dispatches through the plain baseline path since
1404    /// `ctx.operator.operator` stays unset either way; the BP's
1405    /// `operator_delegate` layer only matters to `task_rekick`'s Guard 1,
1406    /// which reads `resolved_bp.spawner_hints.layers` directly rather than
1407    /// a per-request field).
1408    fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
1409        crate::TaskLaunchRequest {
1410            blueprint: BlueprintRef::Inline {
1411                value: Box::new(identity_blueprint_with_operator_delegate()),
1412            },
1413            init_ctx: serde_json::json!({"in": "hello"}),
1414            project_root: None,
1415            work_dir: None,
1416            task_metadata: None,
1417            ttl_secs: None,
1418            operator: None,
1419            operator_sid: None,
1420            timeout_secs: None,
1421            goal: Some(goal.to_string()),
1422            detach: false,
1423        }
1424    }
1425
1426    /// Guard 1 (adapted signal): a Task whose stored Blueprint declares
1427    /// the `operator_delegate` layer, rekicked with zero attached
1428    /// operators, must fail immediately with a structured `503` — not
1429    /// dispatch and not hang waiting on a session nothing can serve.
1430    #[tokio::test]
1431    async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
1432        let state = test_state();
1433        let posted = crate::tasks_start(
1434            State(state.clone()),
1435            Json(delegate_launch_req("operator delegate rekick goal")),
1436        )
1437        .await
1438        .expect("tasks_start (no operator referenced, dispatches through baseline)")
1439        .0;
1440        // No `state.engine.register_operator(...)` call — zero operators
1441        // attached, matching `list_operator_ids()` being empty.
1442
1443        let started = std::time::Instant::now();
1444        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1445        let elapsed = started.elapsed();
1446
1447        let err = match result {
1448            Err(e) => e,
1449            Ok(_) => panic!(
1450                "rekicking a Task whose Blueprint declares operator_delegate with zero \
1451                 attached operators must fail, not dispatch"
1452            ),
1453        };
1454        assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1455        assert!(
1456            err.message.contains("no operator attached"),
1457            "error message must mention the missing operator: {}",
1458            err.message
1459        );
1460        assert!(
1461            elapsed < Duration::from_secs(1),
1462            "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1463        );
1464    }
1465
1466    /// Guard 2: a rekick with a `timeout_secs` ceiling shorter than the
1467    /// dispatch takes must return a structured `504` within the outer
1468    /// safety-net timeout, not hang the request forever.
1469    #[tokio::test]
1470    async fn rekick_stalled_operator_times_out() {
1471        let state = test_state();
1472        state
1473            .engine
1474            .register_operator("stall-op", Arc::new(StallingOperator))
1475            .await;
1476        let posted = crate::tasks_start(
1477            State(state.clone()),
1478            Json(delegate_launch_req("stalled rekick goal")),
1479        )
1480        .await
1481        .expect("tasks_start")
1482        .0;
1483
1484        let started = std::time::Instant::now();
1485        // Outer safety-net timeout: if guard 2 itself regressed into an
1486        // infinite hang, fail this test loudly instead of stalling `cargo
1487        // test` indefinitely.
1488        let result = tokio::time::timeout(
1489            Duration::from_secs(5),
1490            task_rekick(
1491                State(state),
1492                Path(posted.task_id.to_string()),
1493                Some(Json(RunKickRequest {
1494                    init_ctx_override: None,
1495                    task_input_override: None,
1496                    timeout_secs: Some(1),
1497                    detach: false,
1498                })),
1499            ),
1500        )
1501        .await
1502        .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
1503        let elapsed = started.elapsed();
1504
1505        match &result {
1506            Err(e) => {
1507                assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
1508                assert!(
1509                    e.message.contains('1'),
1510                    "error message must mention the configured 1s ceiling: {}",
1511                    e.message
1512                );
1513                assert!(
1514                    elapsed < Duration::from_secs(3),
1515                    "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1516                );
1517            }
1518            Ok(_) => {
1519                // `task_rekick` hardcodes `operator_backend_id: None` for
1520                // every kick (module doc, above — "no per-request Operator
1521                // override support here"), so a registered-but-unattached
1522                // `StallingOperator` is never actually engaged by a
1523                // rekick's dispatch; the flow resolves through the plain
1524                // baseline path instead. Guard 2's `tokio::time::timeout`
1525                // wrap is exercised (and does not falsely fire) rather
1526                // than tripped — assert the fast-success shape so a
1527                // regression that makes rekick dispatch slow (or that
1528                // makes Guard 2 falsely trip on a fast dispatch) is still
1529                // caught by the elapsed-time assertion below.
1530                assert!(
1531                    elapsed < Duration::from_secs(1),
1532                    "a rekick that never engages an Operator (task_rekick has no \
1533                     per-request operator override) must resolve fast, not stall: took {elapsed:?}"
1534                );
1535            }
1536        }
1537    }
1538
1539    /// Guard 2 ceiling resolution: `timeout_secs: Some(0)` is invalid —
1540    /// rejected fast, before any Task/Run side effects (the pre-existing
1541    /// run count for the rekicked Task is unchanged).
1542    #[tokio::test]
1543    async fn rekick_timeout_secs_zero_rejected() {
1544        let state = test_state();
1545        let posted = crate::tasks_start(
1546            State(state.clone()),
1547            Json(post_tasks_req("zero timeout rekick goal")),
1548        )
1549        .await
1550        .expect("tasks_start")
1551        .0;
1552
1553        let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
1554            .await
1555            .expect("task_get")
1556            .0;
1557        let runs_before = before.runs.len();
1558
1559        let result = task_rekick(
1560            State(state.clone()),
1561            Path(posted.task_id.to_string()),
1562            Some(Json(RunKickRequest {
1563                init_ctx_override: None,
1564                task_input_override: None,
1565                timeout_secs: Some(0),
1566                detach: false,
1567            })),
1568        )
1569        .await;
1570        let err = match result {
1571            Err(e) => e,
1572            Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1573        };
1574        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1575        assert!(
1576            err.message.contains("timeout_secs"),
1577            "error message must reference timeout_secs: {}",
1578            err.message
1579        );
1580
1581        let after = task_get(State(state), Path(posted.task_id.to_string()))
1582            .await
1583            .expect("task_get")
1584            .0;
1585        assert_eq!(
1586            after.runs.len(),
1587            runs_before,
1588            "a rejected timeout_secs: Some(0) rekick must not create a new Run"
1589        );
1590    }
1591
1592    /// Invariant: a plain (non-`operator_delegate`) Task rekick must
1593    /// never be rejected by Guard 1 — the simplest existing passing
1594    /// rekick fixture still succeeds unaffected.
1595    #[tokio::test]
1596    async fn rekick_non_operator_path_unaffected_by_guard_1() {
1597        let state = test_state();
1598        let posted = crate::tasks_start(
1599            State(state.clone()),
1600            Json(post_tasks_req("non-operator rekick goal")),
1601        )
1602        .await
1603        .expect("tasks_start")
1604        .0;
1605
1606        let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1607        if let Err(e) = &result {
1608            panic!(
1609                "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
1610                 guard 1: {}",
1611                e.message
1612            );
1613        }
1614    }
1615
1616    #[tokio::test]
1617    async fn run_get_unknown_id_returns_404() {
1618        let state = test_state();
1619        match run_get(State(state), Path("R-does-not-exist".to_string())).await {
1620            Ok(_) => panic!("expected 404 for an unknown run"),
1621            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1622        }
1623    }
1624
1625    #[tokio::test]
1626    async fn task_get_unknown_id_returns_404() {
1627        let state = test_state();
1628        match task_get(State(state), Path("T-does-not-exist".to_string())).await {
1629            Ok(_) => panic!("expected 404 for an unknown task"),
1630            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1631        }
1632    }
1633}