1use axum::{
29 extract::{Path, Query, State},
30 http::StatusCode,
31 Json,
32};
33use mlua_swarm::application::{
34 BlueprintRef, TaskApplicationError, TaskApplicationInput, TaskApplicationOutput,
35};
36use mlua_swarm::core::config::CheckPolicy;
37use mlua_swarm::service::merge_init_ctx_3layer;
38use mlua_swarm::store::replay::ReplayCursor;
39use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError};
40use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
41use mlua_swarm::{OperatorKind, Role, RunId, TaskId, TaskInputSpec};
42use serde::{Deserialize, Serialize};
43use serde_json::Value;
44use std::collections::HashMap;
45use std::sync::{Arc, Mutex};
46use std::time::Duration;
47
48use crate::{ApiError, AppState};
49
50pub(crate) fn now_secs() -> u64 {
54 std::time::SystemTime::now()
55 .duration_since(std::time::UNIX_EPOCH)
56 .map(|d| d.as_secs())
57 .unwrap_or(0)
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
74pub(crate) struct RunLaunchSnapshot {
75 blueprint: BlueprintRef,
76 operator_id: String,
77 role: Role,
78 ttl: Duration,
79 init_ctx: Value,
80 operator_kind: Option<OperatorKind>,
81 bridge_id: Option<String>,
82 hook_id: Option<String>,
83 operator_backend_id: Option<String>,
84 #[serde(default)]
85 operator_kind_overrides: HashMap<String, OperatorKind>,
86 task_input: Option<TaskInputSpec>,
87 check_policy: Option<CheckPolicy>,
88}
89
90impl RunLaunchSnapshot {
91 fn from_input(input: &TaskApplicationInput) -> Self {
94 Self {
95 blueprint: input.blueprint.clone(),
96 operator_id: input.operator_id.clone(),
97 role: input.role,
98 ttl: input.ttl,
99 init_ctx: input.init_ctx.clone(),
100 operator_kind: input.operator_kind,
101 bridge_id: input.bridge_id.clone(),
102 hook_id: input.hook_id.clone(),
103 operator_backend_id: input.operator_backend_id.clone(),
104 operator_kind_overrides: input.operator_kind_overrides.clone(),
105 task_input: input.task_input.clone(),
106 check_policy: input.check_policy,
107 }
108 }
109
110 fn into_input(self) -> TaskApplicationInput {
112 TaskApplicationInput {
113 blueprint: self.blueprint,
114 operator_id: self.operator_id,
115 role: self.role,
116 ttl: self.ttl,
117 init_ctx: self.init_ctx,
118 operator_kind: self.operator_kind,
119 bridge_id: self.bridge_id,
120 hook_id: self.hook_id,
121 operator_backend_id: self.operator_backend_id,
122 operator_kind_overrides: self.operator_kind_overrides,
123 task_input: self.task_input,
124 check_policy: self.check_policy,
125 }
126 }
127}
128
129pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
136 serde_json::to_string(&RunLaunchSnapshot::from_input(input))
137 .map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
138}
139
140pub(crate) async fn finalize_run(
150 state: &AppState,
151 task_id: &TaskId,
152 run_id: &RunId,
153 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
154) -> Result<TaskApplicationOutput, TaskApplicationError> {
155 match &outcome {
156 Ok(out) => {
157 if let Err(e) = state
158 .run_store
159 .set_result(run_id, out.final_ctx.clone())
160 .await
161 {
162 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
163 }
164 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
165 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
166 }
167 if let Err(e) = state
168 .task_store
169 .update_status(task_id, TaskRecordStatus::Done)
170 .await
171 {
172 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
173 }
174 }
175 Err(e) => {
176 if let Err(store_err) = state
177 .run_store
178 .update_status(run_id, RunStatus::Failed)
179 .await
180 {
181 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
182 }
183 if let Err(store_err) = state
184 .task_store
185 .update_status(task_id, TaskRecordStatus::Failed)
186 .await
187 {
188 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
189 }
190 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
191 }
192 }
193 outcome
194}
195
196#[derive(Debug, Deserialize, Default)]
198pub struct TasksListQuery {
199 #[serde(default)]
202 pub limit: Option<usize>,
203}
204
205pub async fn tasks_list(
207 State(state): State<AppState>,
208 Query(q): Query<TasksListQuery>,
209) -> Result<Json<Vec<TaskRecord>>, ApiError> {
210 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
211 if let Some(limit) = q.limit {
212 records.truncate(limit);
213 }
214 Ok(Json(records))
215}
216
217#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
219pub struct TaskDetailResponse {
220 pub task: TaskRecord,
222 pub runs: Vec<RunRecord>,
224}
225
226pub async fn task_get(
229 State(state): State<AppState>,
230 Path(id): Path<String>,
231) -> Result<Json<TaskDetailResponse>, ApiError> {
232 let task_id =
233 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
234 let task = state
235 .task_store
236 .get(&task_id)
237 .await
238 .map_err(map_task_store_err)?;
239 let runs = state
240 .run_store
241 .list_by_task(&task_id)
242 .await
243 .map_err(ApiError::engine)?;
244 Ok(Json(TaskDetailResponse { task, runs }))
245}
246
247#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
253pub struct RunKickRequest {
254 #[serde(default)]
263 #[schemars(with = "Option<Value>")]
264 pub init_ctx_override: Option<Value>,
265 #[serde(default)]
272 pub task_input_override: Option<TaskInputSpec>,
273 #[serde(default)]
279 pub timeout_secs: Option<u64>,
280 #[serde(default)]
287 pub detach: bool,
288}
289
290#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
292pub struct RunKickResponse {
293 #[schemars(with = "String")]
295 pub task_id: TaskId,
296 #[schemars(with = "String")]
298 pub run_id: RunId,
299 pub status: RunStatus,
304}
305
306pub async fn task_rekick(
333 State(state): State<AppState>,
334 Path(id): Path<String>,
335 body: Option<Json<RunKickRequest>>,
336) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
337 let task_id =
338 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
339 let task = state
340 .task_store
341 .get(&task_id)
342 .await
343 .map_err(map_task_store_err)?;
344
345 let blueprint_ref: mlua_swarm::application::BlueprintRef =
346 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
347 ApiError::bad_request(format!(
348 "task {task_id}: stored blueprint_ref failed to decode: {e}"
349 ))
350 })?;
351
352 let (resolved_bp, _bound_version) = state
358 .task_app
359 .resolve(&blueprint_ref)
360 .await
361 .map_err(|e| ApiError::bad_request(format!("task {task_id}: bp resolve: {e}")))?;
362
363 let req = body.map(|Json(r)| r).unwrap_or_default();
364
365 let detach = req.detach;
375 let sync_timeout_secs = match (detach, req.timeout_secs) {
376 (true, Some(_)) => {
377 return Err(ApiError::bad_request(
378 "timeout_secs is the synchronous rekick ceiling and does not apply to a \
379 detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
380 timeout_secs"
381 .into(),
382 ));
383 }
384 (false, Some(0)) => {
385 return Err(ApiError::bad_request(
386 "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
387 ));
388 }
389 (false, Some(v)) => v,
390 (_, None) => state.sync_timeout_secs,
391 };
392
393 if resolved_bp
405 .spawner_hints
406 .layers
407 .iter()
408 .any(|l| l == "operator_delegate")
409 {
410 let attached = state.engine.list_operator_ids().await;
411 if attached.is_empty() {
412 return Err(ApiError::unavailable(format!(
413 "no operator attached to serve this rekick (task {task_id}'s \
414 Blueprint declares the operator_delegate layer): attach an \
415 operator via POST /v1/operators + WS, or use the poll-style \
416 flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
417 )));
418 }
419 }
420
421 let merged_init_ctx = merge_init_ctx_3layer(
422 resolved_bp.default_init_ctx.as_ref(),
423 &task.input_ctx,
424 req.init_ctx_override.as_ref(),
425 );
426
427 let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
431 Some(over) => Some(over),
432 None => task
433 .task_input_spec
434 .as_ref()
435 .map(|v| serde_json::from_value(v.clone()))
436 .transpose()
437 .map_err(|e| {
438 ApiError::bad_request(format!(
439 "task {task_id}: stored task_input_spec failed to decode: {e}"
440 ))
441 })?,
442 };
443
444 let run_id = RunId::new();
445 let now = now_secs();
446
447 let input = TaskApplicationInput {
448 blueprint: blueprint_ref,
449 operator_id: "http-run".to_string(),
450 role: Role::Operator,
451 ttl: Duration::from_secs(crate::default_run_ttl()),
452 init_ctx: merged_init_ctx,
453 operator_kind: None,
454 bridge_id: None,
455 hook_id: None,
456 operator_backend_id: None,
457 operator_kind_overrides: HashMap::new(),
458 task_input: task_input_spec,
459 check_policy: None,
463 };
464 let input_json = Some(snapshot_launch_input(&input)?);
469
470 state
471 .task_store
472 .update_status(&task_id, TaskRecordStatus::Running)
473 .await
474 .map_err(ApiError::engine)?;
475 state
476 .run_store
477 .create(RunRecord {
478 id: run_id.clone(),
479 task_id: task_id.clone(),
480 status: RunStatus::Running,
481 step_entries: Vec::new(),
482 degradations: Vec::new(),
483 operator_sid: None,
484 result_ref: None,
485 input_json,
486 created_at: now,
487 updated_at: now,
488 })
489 .await
490 .map_err(ApiError::engine)?;
491
492 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
493 .with_replay_store(state.replay_store.clone());
494
495 if detach {
501 let ttl_secs = crate::default_run_ttl();
502 let bg_state = state.clone();
503 let bg_task_id = task_id.clone();
504 let bg_run_id = run_id.clone();
505 tokio::spawn(async move {
506 let outcome = match tokio::time::timeout(
507 Duration::from_secs(ttl_secs),
508 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
509 )
510 .await
511 {
512 Ok(outcome) => outcome,
513 Err(_elapsed) => {
514 let reason = serde_json::json!({
515 "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
516 });
517 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
518 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
519 }
520 if let Err(e) = bg_state
521 .run_store
522 .update_status(&bg_run_id, RunStatus::Failed)
523 .await
524 {
525 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
526 }
527 if let Err(e) = bg_state
528 .task_store
529 .update_status(&bg_task_id, TaskRecordStatus::Failed)
530 .await
531 {
532 tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
533 }
534 return;
535 }
536 };
537 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
540 });
541 return Ok((
542 StatusCode::ACCEPTED,
543 Json(RunKickResponse {
544 task_id,
545 run_id,
546 status: RunStatus::Running,
547 }),
548 ));
549 }
550
551 let outcome = match tokio::time::timeout(
557 Duration::from_secs(sync_timeout_secs),
558 state.task_app.handle_with_run(input, Some(run_ctx)),
559 )
560 .await
561 {
562 Ok(outcome) => outcome,
563 Err(_elapsed) => {
564 let reason = serde_json::json!({
565 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
566 });
567 if let Err(e) = state.run_store.set_result(&run_id, reason).await {
568 tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
569 }
570 if let Err(e) = state
571 .run_store
572 .update_status(&run_id, RunStatus::Failed)
573 .await
574 {
575 tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
576 }
577 if let Err(e) = state
578 .task_store
579 .update_status(&task_id, TaskRecordStatus::Failed)
580 .await
581 {
582 tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
583 }
584 return Err(ApiError::timeout(format!(
585 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
586 )));
587 }
588 };
589 finalize_run(&state, &task_id, &run_id, outcome)
590 .await
591 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
592
593 Ok((
594 StatusCode::CREATED,
595 Json(RunKickResponse {
596 task_id,
597 run_id,
598 status: RunStatus::Done,
599 }),
600 ))
601}
602
603#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
605pub struct RunResumeResponse {
606 #[schemars(with = "String")]
611 pub run_id: RunId,
612 #[schemars(with = "String")]
614 pub task_id: TaskId,
615 pub replayed_steps: usize,
620}
621
622pub async fn run_resume(
648 State(state): State<AppState>,
649 Path(id): Path<String>,
650) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
651 let run_id =
652 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
653
654 let run = state
656 .run_store
657 .get(&run_id)
658 .await
659 .map_err(map_run_store_err)?;
660
661 if run.status != RunStatus::Interrupted {
663 return Err(ApiError::conflict(format!(
664 "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
665 run.status
666 )));
667 }
668
669 let Some(input_json) = run.input_json.clone() else {
674 return Err(ApiError::unprocessable(format!(
675 "run {run_id} cannot be resumed: no launch input was recorded for it (it \
676 predates resume support, or was created by a path that does not persist one)"
677 )));
678 };
679 let snapshot: RunLaunchSnapshot = serde_json::from_str(&input_json).map_err(|e| {
680 ApiError::bad_request(format!(
681 "run {run_id}: stored launch input failed to decode: {e}"
682 ))
683 })?;
684
685 let won = state
689 .run_store
690 .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
691 .await
692 .map_err(ApiError::engine)?;
693 if !won {
694 return Err(ApiError::conflict(format!(
695 "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
696 no longer resumable"
697 )));
698 }
699
700 let entries = state
704 .replay_store
705 .list_by_run(&run_id)
706 .await
707 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
708 let replayed_steps = entries.len();
709 let cursor = ReplayCursor::from_entries(entries);
710
711 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
714 .with_replay_store(state.replay_store.clone())
715 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
716
717 let input = snapshot.into_input();
718 let task_id = run.task_id.clone();
719
720 state
723 .task_store
724 .update_status(&task_id, TaskRecordStatus::Running)
725 .await
726 .map_err(ApiError::engine)?;
727
728 let ttl_secs = crate::default_run_ttl();
732 let bg_state = state.clone();
733 let bg_task_id = task_id.clone();
734 let bg_run_id = run_id.clone();
735 tokio::spawn(async move {
736 let outcome = match tokio::time::timeout(
737 Duration::from_secs(ttl_secs),
738 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
739 )
740 .await
741 {
742 Ok(outcome) => outcome,
743 Err(_elapsed) => {
744 let reason = serde_json::json!({
745 "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
746 });
747 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
748 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
749 }
750 if let Err(e) = bg_state
751 .run_store
752 .update_status(&bg_run_id, RunStatus::Failed)
753 .await
754 {
755 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
756 }
757 if let Err(e) = bg_state
758 .task_store
759 .update_status(&bg_task_id, TaskRecordStatus::Failed)
760 .await
761 {
762 tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
763 }
764 return;
765 }
766 };
767 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
769 });
770
771 Ok((
772 StatusCode::ACCEPTED,
773 Json(RunResumeResponse {
774 run_id,
775 task_id,
776 replayed_steps,
777 }),
778 ))
779}
780
781pub async fn run_get(
784 State(state): State<AppState>,
785 Path(id): Path<String>,
786) -> Result<Json<RunRecord>, ApiError> {
787 let run_id =
788 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
789 let run = state
790 .run_store
791 .get(&run_id)
792 .await
793 .map_err(map_run_store_err)?;
794 Ok(Json(run))
795}
796
797pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
801 match e {
802 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
803 other => ApiError::engine(other),
804 }
805}
806
807fn map_run_store_err(e: RunStoreError) -> ApiError {
808 match e {
809 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
810 other => ApiError::engine(other),
811 }
812}
813
814#[cfg(test)]
819mod tests {
820 use super::*;
821 use mlua_swarm::application::BlueprintRef;
822 use mlua_swarm::blueprint::{
823 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
824 CompilerStrategy,
825 };
826 use mlua_swarm::core::config::EngineCfg;
827 use mlua_swarm::core::engine::Engine;
828 use mlua_swarm::store::output::InMemoryOutputStore;
829 use mlua_swarm::store::run::InMemoryRunStore;
830 use mlua_swarm::store::task::InMemoryTaskStore;
831 use std::collections::HashMap;
832 use std::sync::Arc;
833 use tokio::sync::Mutex;
834
835 fn identity_blueprint() -> Blueprint {
841 Blueprint {
842 schema_version: current_schema_version(),
843 id: "tasks-test-bp".into(),
844 flow: serde_json::from_value(serde_json::json!({
845 "kind": "step",
846 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
847 "in": {"op": "lit", "value": "hello"},
848 "out": {"op": "path", "at": "$.out"},
849 }))
850 .expect("flow parse"),
851 agents: vec![AgentDef {
852 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
853 kind: AgentKind::RustFn,
854 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
855 profile: None,
856 meta: None,
857 runner: None,
858 runner_ref: None,
859 verdict: None,
860 }],
861 operators: vec![],
862 metas: vec![],
863 hints: CompilerHints::default(),
864 strategy: CompilerStrategy::default(),
865 metadata: BlueprintMetadata::default(),
866 spawner_hints: Default::default(),
867 default_agent_kind: AgentKind::Operator,
868 default_operator_kind: None,
869 default_init_ctx: None,
870 default_agent_ctx: None,
871 default_context_policy: None,
872 projection_placement: None,
873 audits: vec![],
874 degradation_policy: None,
875 runners: vec![],
876 default_runner: None,
877 check_policy: None,
878 blueprint_ref_includes: Vec::new(),
879 }
880 }
881
882 fn test_state() -> AppState {
887 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
888 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
889 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
890 AppState {
891 engine,
892 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
893 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
894 ws_operator_factory: None,
895 data_store: Arc::new(InMemoryOutputStore::new()),
896 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
897 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
898 task_store: Arc::new(InMemoryTaskStore::new()),
899 run_store: Arc::new(InMemoryRunStore::new()),
900 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
901 base_url: None,
902 sync_timeout_secs: 300,
903 }
904 }
905
906 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
907 crate::TaskLaunchRequest {
908 blueprint: BlueprintRef::Inline {
909 value: Box::new(identity_blueprint()),
910 },
911 init_ctx: serde_json::json!({"in": "hello"}),
912 project_root: None,
913 work_dir: None,
914 task_metadata: None,
915 ttl_secs: None,
916 operator: None,
917 operator_sid: None,
918 timeout_secs: None,
919 goal: Some(goal.to_string()),
920 detach: false,
921 check_policy: None,
922 }
923 }
924
925 #[test]
926 fn task_id_serializes_as_bare_string() {
927 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
931 assert_eq!(v, serde_json::json!("T-abc"));
932 }
933
934 #[tokio::test]
935 async fn post_then_get_drill_down() {
936 let state = test_state();
937
938 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
939 .await
940 .expect("tasks_start")
941 .0;
942 let task_id = posted.task_id.clone();
943 let run_id = posted.run_id.clone();
944
945 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
947 .await
948 .expect("tasks_list")
949 .0;
950 assert!(
951 list.iter().any(|t| t.id == task_id),
952 "task {task_id} missing from list of {} tasks",
953 list.len()
954 );
955
956 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
958 .await
959 .expect("task_get")
960 .0;
961 assert_eq!(detail.task.id, task_id);
962 assert_eq!(detail.task.goal, "smoke goal");
963 assert_eq!(detail.task.status, TaskRecordStatus::Done);
964 assert_eq!(detail.runs.len(), 1);
965 assert_eq!(detail.runs[0].id, run_id);
966 assert_eq!(detail.runs[0].status, RunStatus::Done);
967
968 let run = run_get(State(state.clone()), Path(run_id.to_string()))
970 .await
971 .expect("run_get")
972 .0;
973 assert_eq!(run.id, run_id);
974 assert_eq!(run.task_id, task_id);
975 assert_eq!(run.result_ref, Some(posted.final_ctx));
976
977 assert_eq!(
981 run.step_entries.len(),
982 1,
983 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
984 run.step_entries
985 );
986 assert_eq!(
987 run.step_entries[0].step_ref,
988 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
989 );
990 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
991 }
992
993 fn identity_blueprint_with_operator_delegate() -> Blueprint {
1005 Blueprint {
1006 spawner_hints: mlua_swarm::SpawnerHints {
1007 layers: vec!["operator_delegate".to_string()],
1008 },
1009 ..identity_blueprint()
1010 }
1011 }
1012
1013 struct StallingOperator;
1016
1017 #[async_trait::async_trait]
1018 impl mlua_swarm::Operator for StallingOperator {
1019 async fn execute(
1020 &self,
1021 _ctx: &mlua_swarm::Ctx,
1022 _system: Option<String>,
1023 _prompt: Value,
1024 _worker: Option<mlua_swarm::WorkerBinding>,
1025 _worker_token: mlua_swarm::CapToken,
1026 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1027 std::future::pending::<()>().await;
1028 unreachable!("StallingOperator.execute must never resolve")
1029 }
1030 }
1031
1032 fn operator_launch_req(
1036 backend_id: &str,
1037 timeout_secs: Option<u64>,
1038 ) -> crate::TaskLaunchRequest {
1039 crate::TaskLaunchRequest {
1040 blueprint: BlueprintRef::Inline {
1041 value: Box::new(identity_blueprint_with_operator_delegate()),
1042 },
1043 init_ctx: serde_json::json!({"in": "hello"}),
1044 project_root: None,
1045 work_dir: None,
1046 task_metadata: None,
1047 ttl_secs: None,
1048 operator: Some(crate::OperatorReq {
1049 operator_backend_id: Some(backend_id.to_string()),
1050 ..Default::default()
1051 }),
1052 operator_sid: None,
1053 timeout_secs,
1054 goal: Some("operator delegate test goal".to_string()),
1055 detach: false,
1056 check_policy: None,
1057 }
1058 }
1059
1060 #[tokio::test]
1064 async fn sync_launch_zero_operators_fails_fast() {
1065 let state = test_state();
1066 let req = operator_launch_req("nonexistent-op", None);
1069
1070 let started = std::time::Instant::now();
1071 let result = crate::tasks_start(State(state), Json(req)).await;
1072 let elapsed = started.elapsed();
1073
1074 let err = match result {
1075 Err(e) => e,
1076 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1077 };
1078 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1079 assert!(
1080 err.message.contains("no operator attached"),
1081 "error message must mention the missing operator: {}",
1082 err.message
1083 );
1084 assert!(
1085 elapsed < Duration::from_secs(1),
1086 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1087 );
1088 }
1089
1090 #[tokio::test]
1094 async fn sync_launch_stalled_times_out() {
1095 let state = test_state();
1096 state
1097 .engine
1098 .register_operator("stall-op", Arc::new(StallingOperator))
1099 .await;
1100 let req = operator_launch_req("stall-op", Some(1));
1101
1102 let started = std::time::Instant::now();
1103 let result = tokio::time::timeout(
1107 Duration::from_secs(5),
1108 crate::tasks_start(State(state), Json(req)),
1109 )
1110 .await
1111 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1112 let elapsed = started.elapsed();
1113
1114 let err = match result {
1115 Err(e) => e,
1116 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1117 };
1118 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1119 assert!(
1120 err.message.contains('1'),
1121 "error message must mention the configured 1s ceiling: {}",
1122 err.message
1123 );
1124 assert!(
1125 elapsed < Duration::from_secs(3),
1126 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1127 );
1128 }
1129
1130 #[tokio::test]
1134 async fn sync_launch_without_operator_path_unaffected() {
1135 let state = test_state();
1136 let result = crate::tasks_start(
1137 State(state),
1138 Json(post_tasks_req("non-operator launch goal")),
1139 )
1140 .await;
1141 if let Err(e) = &result {
1142 panic!(
1143 "non-operator launch must succeed unaffected by guard 1: {}",
1144 e.message
1145 );
1146 }
1147 }
1148
1149 #[tokio::test]
1153 async fn sync_launch_zero_timeout_secs_rejected() {
1154 let state = test_state();
1155 let mut req = post_tasks_req("zero timeout goal");
1156 req.timeout_secs = Some(0);
1157
1158 let result = crate::tasks_start(State(state), Json(req)).await;
1159 let err = match result {
1160 Err(e) => e,
1161 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1162 };
1163 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1164 assert!(
1165 err.message.contains("timeout_secs"),
1166 "error message must reference timeout_secs: {}",
1167 err.message
1168 );
1169 }
1170
1171 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1180 for _ in 0..50 {
1181 let rec = state.run_store.get(run_id).await.expect("run get");
1182 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1183 return rec;
1184 }
1185 tokio::time::sleep(Duration::from_millis(100)).await;
1186 }
1187 panic!("run {run_id} did not reach a terminal status within ~5s");
1188 }
1189
1190 #[tokio::test]
1196 async fn detached_launch_returns_202_and_completes_in_background() {
1197 let state = test_state();
1198 let mut req = post_tasks_req("detached goal");
1199 req.detach = true;
1200
1201 let reply = crate::tasks_start(State(state.clone()), Json(req))
1202 .await
1203 .expect("tasks_start (detached)");
1204 assert_eq!(reply.1, StatusCode::ACCEPTED);
1205 let posted = reply.0;
1206 assert_eq!(posted.status, RunStatus::Running);
1207 assert_eq!(
1208 posted.final_ctx,
1209 serde_json::Value::Null,
1210 "a detached launch has no final_ctx at response time"
1211 );
1212
1213 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1214 assert_eq!(rec.status, RunStatus::Done);
1215 assert!(
1216 rec.result_ref.is_some(),
1217 "finalize_run must persist the background eval's final_ctx"
1218 );
1219 assert_eq!(
1220 rec.step_entries.len(),
1221 1,
1222 "the background eval must trace its step_entries like the sync path: {:?}",
1223 rec.step_entries
1224 );
1225 let task = state
1226 .task_store
1227 .get(&posted.task_id)
1228 .await
1229 .expect("task get");
1230 assert_eq!(task.status, TaskRecordStatus::Done);
1231 }
1232
1233 #[tokio::test]
1237 async fn detached_launch_with_timeout_secs_rejected() {
1238 let state = test_state();
1239 let mut req = post_tasks_req("detached + ceiling goal");
1240 req.detach = true;
1241 req.timeout_secs = Some(60);
1242
1243 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1244 Err(e) => e,
1245 Ok(_) => panic!("detach + timeout_secs must be rejected"),
1246 };
1247 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1248 assert!(
1249 err.message.contains("detach"),
1250 "error message must explain the detach/timeout_secs conflict: {}",
1251 err.message
1252 );
1253 let tasks = state.task_store.list().await.expect("task list");
1254 assert!(
1255 tasks.is_empty(),
1256 "the 400 must fire before any TaskRecord is minted"
1257 );
1258 }
1259
1260 #[tokio::test]
1264 async fn rekick_detached_returns_202_and_completes_in_background() {
1265 let state = test_state();
1266 let posted = crate::tasks_start(
1267 State(state.clone()),
1268 Json(post_tasks_req("detached rekick goal")),
1269 )
1270 .await
1271 .expect("tasks_start")
1272 .0;
1273
1274 let (status, rekicked) = task_rekick(
1275 State(state.clone()),
1276 Path(posted.task_id.to_string()),
1277 Some(Json(RunKickRequest {
1278 init_ctx_override: None,
1279 task_input_override: None,
1280 timeout_secs: None,
1281 detach: true,
1282 })),
1283 )
1284 .await
1285 .expect("task_rekick (detached)");
1286 assert_eq!(status, StatusCode::ACCEPTED);
1287 assert_eq!(rekicked.0.status, RunStatus::Running);
1288 assert_ne!(rekicked.0.run_id, posted.run_id);
1289
1290 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1291 assert_eq!(rec.status, RunStatus::Done);
1292 assert!(
1293 rec.result_ref.is_some(),
1294 "finalize_run must persist the background rekick's final_ctx"
1295 );
1296 }
1297
1298 #[tokio::test]
1302 async fn rekick_detached_with_timeout_secs_rejected() {
1303 let state = test_state();
1304 let posted = crate::tasks_start(
1305 State(state.clone()),
1306 Json(post_tasks_req("detached rekick ceiling goal")),
1307 )
1308 .await
1309 .expect("tasks_start")
1310 .0;
1311
1312 let err = match task_rekick(
1313 State(state.clone()),
1314 Path(posted.task_id.to_string()),
1315 Some(Json(RunKickRequest {
1316 init_ctx_override: None,
1317 task_input_override: None,
1318 timeout_secs: Some(60),
1319 detach: true,
1320 })),
1321 )
1322 .await
1323 {
1324 Err(e) => e,
1325 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1326 };
1327 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1328 assert!(
1329 err.message.contains("detach"),
1330 "error message must explain the detach/timeout_secs conflict: {}",
1331 err.message
1332 );
1333 let runs = state
1334 .run_store
1335 .list_by_task(&posted.task_id)
1336 .await
1337 .expect("runs list");
1338 assert_eq!(
1339 runs.len(),
1340 1,
1341 "the 400 must fire before a second Run is minted"
1342 );
1343 }
1344
1345 #[tokio::test]
1346 async fn rekick_adds_a_second_run_to_the_same_task() {
1347 let state = test_state();
1348 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1349 .await
1350 .expect("tasks_start")
1351 .0;
1352 let task_id = posted.task_id.clone();
1353 let first_run_id = posted.run_id.clone();
1354
1355 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1356 .await
1357 .expect("task_rekick");
1358 assert_eq!(status, StatusCode::CREATED);
1359 let second_run_id = rekicked.0.run_id.clone();
1360 assert_ne!(first_run_id, second_run_id);
1361
1362 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1363 .await
1364 .expect("task_get")
1365 .0;
1366 assert_eq!(
1367 detail.runs.len(),
1368 2,
1369 "expected 2 runs, got {:?}",
1370 detail.runs
1371 );
1372 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1373 assert!(ids.contains(&&first_run_id));
1374 assert!(ids.contains(&&second_run_id));
1375
1376 let first_run = detail
1381 .runs
1382 .iter()
1383 .find(|r| r.id == first_run_id)
1384 .expect("first run present in detail.runs");
1385 let second_run = detail
1386 .runs
1387 .iter()
1388 .find(|r| r.id == second_run_id)
1389 .expect("second run present in detail.runs");
1390 assert_eq!(
1391 first_run.step_entries.len(),
1392 1,
1393 "first run step_entries: {:?}",
1394 first_run.step_entries
1395 );
1396 assert_eq!(
1397 second_run.step_entries.len(),
1398 1,
1399 "second run step_entries: {:?}",
1400 second_run.step_entries
1401 );
1402 assert_eq!(
1403 first_run.step_entries[0].step_ref,
1404 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1405 );
1406 assert_eq!(
1407 second_run.step_entries[0].step_ref,
1408 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1409 );
1410 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1411 assert_eq!(
1412 second_run.step_entries[0].status,
1413 Some("passed".to_string())
1414 );
1415 assert_ne!(
1416 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1417 "each kick dispatches its own StepId — runs must not share step_entries"
1418 );
1419 }
1420
1421 #[tokio::test]
1422 async fn rekick_unknown_task_returns_404() {
1423 let state = test_state();
1424 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1428 Ok(_) => panic!("expected 404 for an unknown task"),
1429 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1430 }
1431 }
1432
1433 fn greeting_blueprint() -> Blueprint {
1442 Blueprint {
1443 schema_version: current_schema_version(),
1444 id: "tasks-test-greeting-bp".into(),
1445 flow: serde_json::from_value(serde_json::json!({
1446 "kind": "step",
1447 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1448 "in": {"op": "path", "at": "$.greeting"},
1449 "out": {"op": "path", "at": "$.out"},
1450 }))
1451 .expect("flow parse"),
1452 agents: vec![AgentDef {
1453 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1454 kind: AgentKind::RustFn,
1455 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1456 profile: None,
1457 meta: None,
1458 runner: None,
1459 runner_ref: None,
1460 verdict: None,
1461 }],
1462 operators: vec![],
1463 metas: vec![],
1464 hints: CompilerHints::default(),
1465 strategy: CompilerStrategy::default(),
1466 metadata: BlueprintMetadata::default(),
1467 spawner_hints: Default::default(),
1468 default_agent_kind: AgentKind::Operator,
1469 default_operator_kind: None,
1470 default_init_ctx: None,
1471 default_agent_ctx: None,
1472 default_context_policy: None,
1473 projection_placement: None,
1474 audits: vec![],
1475 degradation_policy: None,
1476 runners: vec![],
1477 default_runner: None,
1478 check_policy: None,
1479 blueprint_ref_includes: Vec::new(),
1480 }
1481 }
1482
1483 fn post_greeting_task_req(
1484 greeting: &str,
1485 project_root: Option<&str>,
1486 ) -> crate::TaskLaunchRequest {
1487 crate::TaskLaunchRequest {
1488 blueprint: BlueprintRef::Inline {
1489 value: Box::new(greeting_blueprint()),
1490 },
1491 init_ctx: serde_json::json!({ "greeting": greeting }),
1492 project_root: project_root.map(str::to_string),
1493 work_dir: None,
1494 task_metadata: None,
1495 ttl_secs: None,
1496 operator: None,
1497 operator_sid: None,
1498 timeout_secs: None,
1499 goal: Some("st4 rekick goal".to_string()),
1500 detach: false,
1501 check_policy: None,
1502 }
1503 }
1504
1505 #[tokio::test]
1506 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1507 let state = test_state();
1510 let posted = crate::tasks_start(
1511 State(state.clone()),
1512 Json(post_greeting_task_req("from-task", None)),
1513 )
1514 .await
1515 .expect("tasks_start")
1516 .0;
1517 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1518
1519 let (status, rekicked) =
1520 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1521 .await
1522 .expect("task_rekick");
1523 assert_eq!(status, StatusCode::CREATED);
1524
1525 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1526 .await
1527 .expect("run_get")
1528 .0;
1529 assert_eq!(
1530 run.result_ref.expect("result_ref present")["out"]["echoed"],
1531 "from-task"
1532 );
1533 }
1534
1535 #[tokio::test]
1536 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1537 let state = test_state();
1538 let posted = crate::tasks_start(
1539 State(state.clone()),
1540 Json(post_greeting_task_req("from-task", None)),
1541 )
1542 .await
1543 .expect("tasks_start")
1544 .0;
1545 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1546
1547 let (status, rekicked) = task_rekick(
1548 State(state.clone()),
1549 Path(posted.task_id.to_string()),
1550 Some(Json(RunKickRequest {
1551 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1552 task_input_override: None,
1553 timeout_secs: None,
1554 detach: false,
1555 })),
1556 )
1557 .await
1558 .expect("task_rekick");
1559 assert_eq!(status, StatusCode::CREATED);
1560
1561 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1562 .await
1563 .expect("run_get")
1564 .0;
1565 assert_eq!(
1566 run.result_ref.expect("result_ref present")["out"]["echoed"],
1567 "from-run",
1568 "Run's init_ctx_override must win over the stored Task input_ctx"
1569 );
1570 }
1571
1572 #[tokio::test]
1573 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1574 let state = test_state();
1582 let posted = crate::tasks_start(
1583 State(state.clone()),
1584 Json(post_greeting_task_req("from-task", Some("/repo"))),
1585 )
1586 .await
1587 .expect("tasks_start")
1588 .0;
1589
1590 let before = state
1591 .task_store
1592 .get(&posted.task_id)
1593 .await
1594 .expect("task fetch");
1595 let before_spec: Option<TaskInputSpec> = before
1596 .task_input_spec
1597 .as_ref()
1598 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1599 assert_eq!(
1600 before_spec,
1601 Some(TaskInputSpec {
1602 project_root: Some("/repo".to_string()),
1603 work_dir: None,
1604 task_metadata: None,
1605 })
1606 );
1607
1608 let (status, _rekicked) =
1609 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1610 .await
1611 .expect("task_rekick");
1612 assert_eq!(status, StatusCode::CREATED);
1613
1614 let after = state
1615 .task_store
1616 .get(&posted.task_id)
1617 .await
1618 .expect("task fetch");
1619 assert_eq!(
1620 after.task_input_spec, before.task_input_spec,
1621 "rekick must not mutate the stored Task-level task_input_spec snapshot"
1622 );
1623 }
1624
1625 #[tokio::test]
1626 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1627 let state = test_state();
1630 let posted = crate::tasks_start(
1631 State(state.clone()),
1632 Json(post_greeting_task_req("from-task", Some("/repo"))),
1633 )
1634 .await
1635 .expect("tasks_start")
1636 .0;
1637
1638 let (status, _rekicked) = task_rekick(
1639 State(state.clone()),
1640 Path(posted.task_id.to_string()),
1641 Some(Json(RunKickRequest {
1642 init_ctx_override: None,
1643 task_input_override: Some(TaskInputSpec {
1644 project_root: Some("/override".to_string()),
1645 work_dir: None,
1646 task_metadata: None,
1647 }),
1648 timeout_secs: None,
1649 detach: false,
1650 })),
1651 )
1652 .await
1653 .expect("task_rekick");
1654 assert_eq!(status, StatusCode::CREATED);
1655
1656 let after = state
1657 .task_store
1658 .get(&posted.task_id)
1659 .await
1660 .expect("task fetch");
1661 let after_spec: Option<TaskInputSpec> = after
1662 .task_input_spec
1663 .as_ref()
1664 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1665 assert_eq!(
1666 after_spec,
1667 Some(TaskInputSpec {
1668 project_root: Some("/repo".to_string()),
1669 work_dir: None,
1670 task_metadata: None,
1671 }),
1672 "a per-Run task_input_override must not leak into the stored TaskRecord"
1673 );
1674 }
1675
1676 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
1690 crate::TaskLaunchRequest {
1691 blueprint: BlueprintRef::Inline {
1692 value: Box::new(identity_blueprint_with_operator_delegate()),
1693 },
1694 init_ctx: serde_json::json!({"in": "hello"}),
1695 project_root: None,
1696 work_dir: None,
1697 task_metadata: None,
1698 ttl_secs: None,
1699 operator: None,
1700 operator_sid: None,
1701 timeout_secs: None,
1702 goal: Some(goal.to_string()),
1703 detach: false,
1704 check_policy: None,
1705 }
1706 }
1707
1708 #[tokio::test]
1713 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
1714 let state = test_state();
1715 let posted = crate::tasks_start(
1716 State(state.clone()),
1717 Json(delegate_launch_req("operator delegate rekick goal")),
1718 )
1719 .await
1720 .expect("tasks_start (no operator referenced, dispatches through baseline)")
1721 .0;
1722 let started = std::time::Instant::now();
1726 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1727 let elapsed = started.elapsed();
1728
1729 let err = match result {
1730 Err(e) => e,
1731 Ok(_) => panic!(
1732 "rekicking a Task whose Blueprint declares operator_delegate with zero \
1733 attached operators must fail, not dispatch"
1734 ),
1735 };
1736 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1737 assert!(
1738 err.message.contains("no operator attached"),
1739 "error message must mention the missing operator: {}",
1740 err.message
1741 );
1742 assert!(
1743 elapsed < Duration::from_secs(1),
1744 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1745 );
1746 }
1747
1748 #[tokio::test]
1752 async fn rekick_stalled_operator_times_out() {
1753 let state = test_state();
1754 state
1755 .engine
1756 .register_operator("stall-op", Arc::new(StallingOperator))
1757 .await;
1758 let posted = crate::tasks_start(
1759 State(state.clone()),
1760 Json(delegate_launch_req("stalled rekick goal")),
1761 )
1762 .await
1763 .expect("tasks_start")
1764 .0;
1765
1766 let started = std::time::Instant::now();
1767 let result = tokio::time::timeout(
1771 Duration::from_secs(5),
1772 task_rekick(
1773 State(state),
1774 Path(posted.task_id.to_string()),
1775 Some(Json(RunKickRequest {
1776 init_ctx_override: None,
1777 task_input_override: None,
1778 timeout_secs: Some(1),
1779 detach: false,
1780 })),
1781 ),
1782 )
1783 .await
1784 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
1785 let elapsed = started.elapsed();
1786
1787 match &result {
1788 Err(e) => {
1789 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
1790 assert!(
1791 e.message.contains('1'),
1792 "error message must mention the configured 1s ceiling: {}",
1793 e.message
1794 );
1795 assert!(
1796 elapsed < Duration::from_secs(3),
1797 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1798 );
1799 }
1800 Ok(_) => {
1801 assert!(
1813 elapsed < Duration::from_secs(1),
1814 "a rekick that never engages an Operator (task_rekick has no \
1815 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
1816 );
1817 }
1818 }
1819 }
1820
1821 #[tokio::test]
1825 async fn rekick_timeout_secs_zero_rejected() {
1826 let state = test_state();
1827 let posted = crate::tasks_start(
1828 State(state.clone()),
1829 Json(post_tasks_req("zero timeout rekick goal")),
1830 )
1831 .await
1832 .expect("tasks_start")
1833 .0;
1834
1835 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
1836 .await
1837 .expect("task_get")
1838 .0;
1839 let runs_before = before.runs.len();
1840
1841 let result = task_rekick(
1842 State(state.clone()),
1843 Path(posted.task_id.to_string()),
1844 Some(Json(RunKickRequest {
1845 init_ctx_override: None,
1846 task_input_override: None,
1847 timeout_secs: Some(0),
1848 detach: false,
1849 })),
1850 )
1851 .await;
1852 let err = match result {
1853 Err(e) => e,
1854 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1855 };
1856 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1857 assert!(
1858 err.message.contains("timeout_secs"),
1859 "error message must reference timeout_secs: {}",
1860 err.message
1861 );
1862
1863 let after = task_get(State(state), Path(posted.task_id.to_string()))
1864 .await
1865 .expect("task_get")
1866 .0;
1867 assert_eq!(
1868 after.runs.len(),
1869 runs_before,
1870 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
1871 );
1872 }
1873
1874 #[tokio::test]
1878 async fn rekick_non_operator_path_unaffected_by_guard_1() {
1879 let state = test_state();
1880 let posted = crate::tasks_start(
1881 State(state.clone()),
1882 Json(post_tasks_req("non-operator rekick goal")),
1883 )
1884 .await
1885 .expect("tasks_start")
1886 .0;
1887
1888 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1889 if let Err(e) = &result {
1890 panic!(
1891 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
1892 guard 1: {}",
1893 e.message
1894 );
1895 }
1896 }
1897
1898 #[tokio::test]
1899 async fn run_get_unknown_id_returns_404() {
1900 let state = test_state();
1901 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
1902 Ok(_) => panic!("expected 404 for an unknown run"),
1903 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1904 }
1905 }
1906
1907 #[tokio::test]
1908 async fn task_get_unknown_id_returns_404() {
1909 let state = test_state();
1910 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
1911 Ok(_) => panic!("expected 404 for an unknown task"),
1912 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1913 }
1914 }
1915}