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 }
879 }
880
881 fn test_state() -> AppState {
886 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
887 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
888 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
889 AppState {
890 engine,
891 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
892 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
893 ws_operator_factory: None,
894 data_store: Arc::new(InMemoryOutputStore::new()),
895 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
896 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
897 task_store: Arc::new(InMemoryTaskStore::new()),
898 run_store: Arc::new(InMemoryRunStore::new()),
899 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
900 base_url: None,
901 sync_timeout_secs: 300,
902 }
903 }
904
905 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
906 crate::TaskLaunchRequest {
907 blueprint: BlueprintRef::Inline {
908 value: Box::new(identity_blueprint()),
909 },
910 init_ctx: serde_json::json!({"in": "hello"}),
911 project_root: None,
912 work_dir: None,
913 task_metadata: None,
914 ttl_secs: None,
915 operator: None,
916 operator_sid: None,
917 timeout_secs: None,
918 goal: Some(goal.to_string()),
919 detach: false,
920 check_policy: None,
921 }
922 }
923
924 #[test]
925 fn task_id_serializes_as_bare_string() {
926 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
930 assert_eq!(v, serde_json::json!("T-abc"));
931 }
932
933 #[tokio::test]
934 async fn post_then_get_drill_down() {
935 let state = test_state();
936
937 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
938 .await
939 .expect("tasks_start")
940 .0;
941 let task_id = posted.task_id.clone();
942 let run_id = posted.run_id.clone();
943
944 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
946 .await
947 .expect("tasks_list")
948 .0;
949 assert!(
950 list.iter().any(|t| t.id == task_id),
951 "task {task_id} missing from list of {} tasks",
952 list.len()
953 );
954
955 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
957 .await
958 .expect("task_get")
959 .0;
960 assert_eq!(detail.task.id, task_id);
961 assert_eq!(detail.task.goal, "smoke goal");
962 assert_eq!(detail.task.status, TaskRecordStatus::Done);
963 assert_eq!(detail.runs.len(), 1);
964 assert_eq!(detail.runs[0].id, run_id);
965 assert_eq!(detail.runs[0].status, RunStatus::Done);
966
967 let run = run_get(State(state.clone()), Path(run_id.to_string()))
969 .await
970 .expect("run_get")
971 .0;
972 assert_eq!(run.id, run_id);
973 assert_eq!(run.task_id, task_id);
974 assert_eq!(run.result_ref, Some(posted.final_ctx));
975
976 assert_eq!(
980 run.step_entries.len(),
981 1,
982 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
983 run.step_entries
984 );
985 assert_eq!(
986 run.step_entries[0].step_ref,
987 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
988 );
989 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
990 }
991
992 fn identity_blueprint_with_operator_delegate() -> Blueprint {
1004 Blueprint {
1005 spawner_hints: mlua_swarm::SpawnerHints {
1006 layers: vec!["operator_delegate".to_string()],
1007 },
1008 ..identity_blueprint()
1009 }
1010 }
1011
1012 struct StallingOperator;
1015
1016 #[async_trait::async_trait]
1017 impl mlua_swarm::Operator for StallingOperator {
1018 async fn execute(
1019 &self,
1020 _ctx: &mlua_swarm::Ctx,
1021 _system: Option<String>,
1022 _prompt: Value,
1023 _worker: Option<mlua_swarm::WorkerBinding>,
1024 _worker_token: mlua_swarm::CapToken,
1025 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1026 std::future::pending::<()>().await;
1027 unreachable!("StallingOperator.execute must never resolve")
1028 }
1029 }
1030
1031 fn operator_launch_req(
1035 backend_id: &str,
1036 timeout_secs: Option<u64>,
1037 ) -> crate::TaskLaunchRequest {
1038 crate::TaskLaunchRequest {
1039 blueprint: BlueprintRef::Inline {
1040 value: Box::new(identity_blueprint_with_operator_delegate()),
1041 },
1042 init_ctx: serde_json::json!({"in": "hello"}),
1043 project_root: None,
1044 work_dir: None,
1045 task_metadata: None,
1046 ttl_secs: None,
1047 operator: Some(crate::OperatorReq {
1048 operator_backend_id: Some(backend_id.to_string()),
1049 ..Default::default()
1050 }),
1051 operator_sid: None,
1052 timeout_secs,
1053 goal: Some("operator delegate test goal".to_string()),
1054 detach: false,
1055 check_policy: None,
1056 }
1057 }
1058
1059 #[tokio::test]
1063 async fn sync_launch_zero_operators_fails_fast() {
1064 let state = test_state();
1065 let req = operator_launch_req("nonexistent-op", None);
1068
1069 let started = std::time::Instant::now();
1070 let result = crate::tasks_start(State(state), Json(req)).await;
1071 let elapsed = started.elapsed();
1072
1073 let err = match result {
1074 Err(e) => e,
1075 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1076 };
1077 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1078 assert!(
1079 err.message.contains("no operator attached"),
1080 "error message must mention the missing operator: {}",
1081 err.message
1082 );
1083 assert!(
1084 elapsed < Duration::from_secs(1),
1085 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1086 );
1087 }
1088
1089 #[tokio::test]
1093 async fn sync_launch_stalled_times_out() {
1094 let state = test_state();
1095 state
1096 .engine
1097 .register_operator("stall-op", Arc::new(StallingOperator))
1098 .await;
1099 let req = operator_launch_req("stall-op", Some(1));
1100
1101 let started = std::time::Instant::now();
1102 let result = tokio::time::timeout(
1106 Duration::from_secs(5),
1107 crate::tasks_start(State(state), Json(req)),
1108 )
1109 .await
1110 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1111 let elapsed = started.elapsed();
1112
1113 let err = match result {
1114 Err(e) => e,
1115 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1116 };
1117 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1118 assert!(
1119 err.message.contains('1'),
1120 "error message must mention the configured 1s ceiling: {}",
1121 err.message
1122 );
1123 assert!(
1124 elapsed < Duration::from_secs(3),
1125 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1126 );
1127 }
1128
1129 #[tokio::test]
1133 async fn sync_launch_without_operator_path_unaffected() {
1134 let state = test_state();
1135 let result = crate::tasks_start(
1136 State(state),
1137 Json(post_tasks_req("non-operator launch goal")),
1138 )
1139 .await;
1140 if let Err(e) = &result {
1141 panic!(
1142 "non-operator launch must succeed unaffected by guard 1: {}",
1143 e.message
1144 );
1145 }
1146 }
1147
1148 #[tokio::test]
1152 async fn sync_launch_zero_timeout_secs_rejected() {
1153 let state = test_state();
1154 let mut req = post_tasks_req("zero timeout goal");
1155 req.timeout_secs = Some(0);
1156
1157 let result = crate::tasks_start(State(state), Json(req)).await;
1158 let err = match result {
1159 Err(e) => e,
1160 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1161 };
1162 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1163 assert!(
1164 err.message.contains("timeout_secs"),
1165 "error message must reference timeout_secs: {}",
1166 err.message
1167 );
1168 }
1169
1170 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1179 for _ in 0..50 {
1180 let rec = state.run_store.get(run_id).await.expect("run get");
1181 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1182 return rec;
1183 }
1184 tokio::time::sleep(Duration::from_millis(100)).await;
1185 }
1186 panic!("run {run_id} did not reach a terminal status within ~5s");
1187 }
1188
1189 #[tokio::test]
1195 async fn detached_launch_returns_202_and_completes_in_background() {
1196 let state = test_state();
1197 let mut req = post_tasks_req("detached goal");
1198 req.detach = true;
1199
1200 let reply = crate::tasks_start(State(state.clone()), Json(req))
1201 .await
1202 .expect("tasks_start (detached)");
1203 assert_eq!(reply.1, StatusCode::ACCEPTED);
1204 let posted = reply.0;
1205 assert_eq!(posted.status, RunStatus::Running);
1206 assert_eq!(
1207 posted.final_ctx,
1208 serde_json::Value::Null,
1209 "a detached launch has no final_ctx at response time"
1210 );
1211
1212 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1213 assert_eq!(rec.status, RunStatus::Done);
1214 assert!(
1215 rec.result_ref.is_some(),
1216 "finalize_run must persist the background eval's final_ctx"
1217 );
1218 assert_eq!(
1219 rec.step_entries.len(),
1220 1,
1221 "the background eval must trace its step_entries like the sync path: {:?}",
1222 rec.step_entries
1223 );
1224 let task = state
1225 .task_store
1226 .get(&posted.task_id)
1227 .await
1228 .expect("task get");
1229 assert_eq!(task.status, TaskRecordStatus::Done);
1230 }
1231
1232 #[tokio::test]
1236 async fn detached_launch_with_timeout_secs_rejected() {
1237 let state = test_state();
1238 let mut req = post_tasks_req("detached + ceiling goal");
1239 req.detach = true;
1240 req.timeout_secs = Some(60);
1241
1242 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1243 Err(e) => e,
1244 Ok(_) => panic!("detach + timeout_secs must be rejected"),
1245 };
1246 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1247 assert!(
1248 err.message.contains("detach"),
1249 "error message must explain the detach/timeout_secs conflict: {}",
1250 err.message
1251 );
1252 let tasks = state.task_store.list().await.expect("task list");
1253 assert!(
1254 tasks.is_empty(),
1255 "the 400 must fire before any TaskRecord is minted"
1256 );
1257 }
1258
1259 #[tokio::test]
1263 async fn rekick_detached_returns_202_and_completes_in_background() {
1264 let state = test_state();
1265 let posted = crate::tasks_start(
1266 State(state.clone()),
1267 Json(post_tasks_req("detached rekick goal")),
1268 )
1269 .await
1270 .expect("tasks_start")
1271 .0;
1272
1273 let (status, rekicked) = task_rekick(
1274 State(state.clone()),
1275 Path(posted.task_id.to_string()),
1276 Some(Json(RunKickRequest {
1277 init_ctx_override: None,
1278 task_input_override: None,
1279 timeout_secs: None,
1280 detach: true,
1281 })),
1282 )
1283 .await
1284 .expect("task_rekick (detached)");
1285 assert_eq!(status, StatusCode::ACCEPTED);
1286 assert_eq!(rekicked.0.status, RunStatus::Running);
1287 assert_ne!(rekicked.0.run_id, posted.run_id);
1288
1289 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1290 assert_eq!(rec.status, RunStatus::Done);
1291 assert!(
1292 rec.result_ref.is_some(),
1293 "finalize_run must persist the background rekick's final_ctx"
1294 );
1295 }
1296
1297 #[tokio::test]
1301 async fn rekick_detached_with_timeout_secs_rejected() {
1302 let state = test_state();
1303 let posted = crate::tasks_start(
1304 State(state.clone()),
1305 Json(post_tasks_req("detached rekick ceiling goal")),
1306 )
1307 .await
1308 .expect("tasks_start")
1309 .0;
1310
1311 let err = match task_rekick(
1312 State(state.clone()),
1313 Path(posted.task_id.to_string()),
1314 Some(Json(RunKickRequest {
1315 init_ctx_override: None,
1316 task_input_override: None,
1317 timeout_secs: Some(60),
1318 detach: true,
1319 })),
1320 )
1321 .await
1322 {
1323 Err(e) => e,
1324 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1325 };
1326 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1327 assert!(
1328 err.message.contains("detach"),
1329 "error message must explain the detach/timeout_secs conflict: {}",
1330 err.message
1331 );
1332 let runs = state
1333 .run_store
1334 .list_by_task(&posted.task_id)
1335 .await
1336 .expect("runs list");
1337 assert_eq!(
1338 runs.len(),
1339 1,
1340 "the 400 must fire before a second Run is minted"
1341 );
1342 }
1343
1344 #[tokio::test]
1345 async fn rekick_adds_a_second_run_to_the_same_task() {
1346 let state = test_state();
1347 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1348 .await
1349 .expect("tasks_start")
1350 .0;
1351 let task_id = posted.task_id.clone();
1352 let first_run_id = posted.run_id.clone();
1353
1354 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1355 .await
1356 .expect("task_rekick");
1357 assert_eq!(status, StatusCode::CREATED);
1358 let second_run_id = rekicked.0.run_id.clone();
1359 assert_ne!(first_run_id, second_run_id);
1360
1361 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1362 .await
1363 .expect("task_get")
1364 .0;
1365 assert_eq!(
1366 detail.runs.len(),
1367 2,
1368 "expected 2 runs, got {:?}",
1369 detail.runs
1370 );
1371 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1372 assert!(ids.contains(&&first_run_id));
1373 assert!(ids.contains(&&second_run_id));
1374
1375 let first_run = detail
1380 .runs
1381 .iter()
1382 .find(|r| r.id == first_run_id)
1383 .expect("first run present in detail.runs");
1384 let second_run = detail
1385 .runs
1386 .iter()
1387 .find(|r| r.id == second_run_id)
1388 .expect("second run present in detail.runs");
1389 assert_eq!(
1390 first_run.step_entries.len(),
1391 1,
1392 "first run step_entries: {:?}",
1393 first_run.step_entries
1394 );
1395 assert_eq!(
1396 second_run.step_entries.len(),
1397 1,
1398 "second run step_entries: {:?}",
1399 second_run.step_entries
1400 );
1401 assert_eq!(
1402 first_run.step_entries[0].step_ref,
1403 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1404 );
1405 assert_eq!(
1406 second_run.step_entries[0].step_ref,
1407 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1408 );
1409 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1410 assert_eq!(
1411 second_run.step_entries[0].status,
1412 Some("passed".to_string())
1413 );
1414 assert_ne!(
1415 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1416 "each kick dispatches its own StepId — runs must not share step_entries"
1417 );
1418 }
1419
1420 #[tokio::test]
1421 async fn rekick_unknown_task_returns_404() {
1422 let state = test_state();
1423 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1427 Ok(_) => panic!("expected 404 for an unknown task"),
1428 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1429 }
1430 }
1431
1432 fn greeting_blueprint() -> Blueprint {
1441 Blueprint {
1442 schema_version: current_schema_version(),
1443 id: "tasks-test-greeting-bp".into(),
1444 flow: serde_json::from_value(serde_json::json!({
1445 "kind": "step",
1446 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1447 "in": {"op": "path", "at": "$.greeting"},
1448 "out": {"op": "path", "at": "$.out"},
1449 }))
1450 .expect("flow parse"),
1451 agents: vec![AgentDef {
1452 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1453 kind: AgentKind::RustFn,
1454 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1455 profile: None,
1456 meta: None,
1457 runner: None,
1458 runner_ref: None,
1459 verdict: None,
1460 }],
1461 operators: vec![],
1462 metas: vec![],
1463 hints: CompilerHints::default(),
1464 strategy: CompilerStrategy::default(),
1465 metadata: BlueprintMetadata::default(),
1466 spawner_hints: Default::default(),
1467 default_agent_kind: AgentKind::Operator,
1468 default_operator_kind: None,
1469 default_init_ctx: None,
1470 default_agent_ctx: None,
1471 default_context_policy: None,
1472 projection_placement: None,
1473 audits: vec![],
1474 degradation_policy: None,
1475 runners: vec![],
1476 default_runner: None,
1477 check_policy: None,
1478 }
1479 }
1480
1481 fn post_greeting_task_req(
1482 greeting: &str,
1483 project_root: Option<&str>,
1484 ) -> crate::TaskLaunchRequest {
1485 crate::TaskLaunchRequest {
1486 blueprint: BlueprintRef::Inline {
1487 value: Box::new(greeting_blueprint()),
1488 },
1489 init_ctx: serde_json::json!({ "greeting": greeting }),
1490 project_root: project_root.map(str::to_string),
1491 work_dir: None,
1492 task_metadata: None,
1493 ttl_secs: None,
1494 operator: None,
1495 operator_sid: None,
1496 timeout_secs: None,
1497 goal: Some("st4 rekick goal".to_string()),
1498 detach: false,
1499 check_policy: None,
1500 }
1501 }
1502
1503 #[tokio::test]
1504 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1505 let state = test_state();
1508 let posted = crate::tasks_start(
1509 State(state.clone()),
1510 Json(post_greeting_task_req("from-task", None)),
1511 )
1512 .await
1513 .expect("tasks_start")
1514 .0;
1515 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1516
1517 let (status, rekicked) =
1518 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1519 .await
1520 .expect("task_rekick");
1521 assert_eq!(status, StatusCode::CREATED);
1522
1523 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1524 .await
1525 .expect("run_get")
1526 .0;
1527 assert_eq!(
1528 run.result_ref.expect("result_ref present")["out"]["echoed"],
1529 "from-task"
1530 );
1531 }
1532
1533 #[tokio::test]
1534 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1535 let state = test_state();
1536 let posted = crate::tasks_start(
1537 State(state.clone()),
1538 Json(post_greeting_task_req("from-task", None)),
1539 )
1540 .await
1541 .expect("tasks_start")
1542 .0;
1543 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1544
1545 let (status, rekicked) = task_rekick(
1546 State(state.clone()),
1547 Path(posted.task_id.to_string()),
1548 Some(Json(RunKickRequest {
1549 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1550 task_input_override: None,
1551 timeout_secs: None,
1552 detach: false,
1553 })),
1554 )
1555 .await
1556 .expect("task_rekick");
1557 assert_eq!(status, StatusCode::CREATED);
1558
1559 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1560 .await
1561 .expect("run_get")
1562 .0;
1563 assert_eq!(
1564 run.result_ref.expect("result_ref present")["out"]["echoed"],
1565 "from-run",
1566 "Run's init_ctx_override must win over the stored Task input_ctx"
1567 );
1568 }
1569
1570 #[tokio::test]
1571 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1572 let state = test_state();
1580 let posted = crate::tasks_start(
1581 State(state.clone()),
1582 Json(post_greeting_task_req("from-task", Some("/repo"))),
1583 )
1584 .await
1585 .expect("tasks_start")
1586 .0;
1587
1588 let before = state
1589 .task_store
1590 .get(&posted.task_id)
1591 .await
1592 .expect("task fetch");
1593 let before_spec: Option<TaskInputSpec> = before
1594 .task_input_spec
1595 .as_ref()
1596 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1597 assert_eq!(
1598 before_spec,
1599 Some(TaskInputSpec {
1600 project_root: Some("/repo".to_string()),
1601 work_dir: None,
1602 task_metadata: None,
1603 })
1604 );
1605
1606 let (status, _rekicked) =
1607 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1608 .await
1609 .expect("task_rekick");
1610 assert_eq!(status, StatusCode::CREATED);
1611
1612 let after = state
1613 .task_store
1614 .get(&posted.task_id)
1615 .await
1616 .expect("task fetch");
1617 assert_eq!(
1618 after.task_input_spec, before.task_input_spec,
1619 "rekick must not mutate the stored Task-level task_input_spec snapshot"
1620 );
1621 }
1622
1623 #[tokio::test]
1624 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1625 let state = test_state();
1628 let posted = crate::tasks_start(
1629 State(state.clone()),
1630 Json(post_greeting_task_req("from-task", Some("/repo"))),
1631 )
1632 .await
1633 .expect("tasks_start")
1634 .0;
1635
1636 let (status, _rekicked) = task_rekick(
1637 State(state.clone()),
1638 Path(posted.task_id.to_string()),
1639 Some(Json(RunKickRequest {
1640 init_ctx_override: None,
1641 task_input_override: Some(TaskInputSpec {
1642 project_root: Some("/override".to_string()),
1643 work_dir: None,
1644 task_metadata: None,
1645 }),
1646 timeout_secs: None,
1647 detach: false,
1648 })),
1649 )
1650 .await
1651 .expect("task_rekick");
1652 assert_eq!(status, StatusCode::CREATED);
1653
1654 let after = state
1655 .task_store
1656 .get(&posted.task_id)
1657 .await
1658 .expect("task fetch");
1659 let after_spec: Option<TaskInputSpec> = after
1660 .task_input_spec
1661 .as_ref()
1662 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1663 assert_eq!(
1664 after_spec,
1665 Some(TaskInputSpec {
1666 project_root: Some("/repo".to_string()),
1667 work_dir: None,
1668 task_metadata: None,
1669 }),
1670 "a per-Run task_input_override must not leak into the stored TaskRecord"
1671 );
1672 }
1673
1674 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
1688 crate::TaskLaunchRequest {
1689 blueprint: BlueprintRef::Inline {
1690 value: Box::new(identity_blueprint_with_operator_delegate()),
1691 },
1692 init_ctx: serde_json::json!({"in": "hello"}),
1693 project_root: None,
1694 work_dir: None,
1695 task_metadata: None,
1696 ttl_secs: None,
1697 operator: None,
1698 operator_sid: None,
1699 timeout_secs: None,
1700 goal: Some(goal.to_string()),
1701 detach: false,
1702 check_policy: None,
1703 }
1704 }
1705
1706 #[tokio::test]
1711 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
1712 let state = test_state();
1713 let posted = crate::tasks_start(
1714 State(state.clone()),
1715 Json(delegate_launch_req("operator delegate rekick goal")),
1716 )
1717 .await
1718 .expect("tasks_start (no operator referenced, dispatches through baseline)")
1719 .0;
1720 let started = std::time::Instant::now();
1724 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1725 let elapsed = started.elapsed();
1726
1727 let err = match result {
1728 Err(e) => e,
1729 Ok(_) => panic!(
1730 "rekicking a Task whose Blueprint declares operator_delegate with zero \
1731 attached operators must fail, not dispatch"
1732 ),
1733 };
1734 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1735 assert!(
1736 err.message.contains("no operator attached"),
1737 "error message must mention the missing operator: {}",
1738 err.message
1739 );
1740 assert!(
1741 elapsed < Duration::from_secs(1),
1742 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1743 );
1744 }
1745
1746 #[tokio::test]
1750 async fn rekick_stalled_operator_times_out() {
1751 let state = test_state();
1752 state
1753 .engine
1754 .register_operator("stall-op", Arc::new(StallingOperator))
1755 .await;
1756 let posted = crate::tasks_start(
1757 State(state.clone()),
1758 Json(delegate_launch_req("stalled rekick goal")),
1759 )
1760 .await
1761 .expect("tasks_start")
1762 .0;
1763
1764 let started = std::time::Instant::now();
1765 let result = tokio::time::timeout(
1769 Duration::from_secs(5),
1770 task_rekick(
1771 State(state),
1772 Path(posted.task_id.to_string()),
1773 Some(Json(RunKickRequest {
1774 init_ctx_override: None,
1775 task_input_override: None,
1776 timeout_secs: Some(1),
1777 detach: false,
1778 })),
1779 ),
1780 )
1781 .await
1782 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
1783 let elapsed = started.elapsed();
1784
1785 match &result {
1786 Err(e) => {
1787 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
1788 assert!(
1789 e.message.contains('1'),
1790 "error message must mention the configured 1s ceiling: {}",
1791 e.message
1792 );
1793 assert!(
1794 elapsed < Duration::from_secs(3),
1795 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1796 );
1797 }
1798 Ok(_) => {
1799 assert!(
1811 elapsed < Duration::from_secs(1),
1812 "a rekick that never engages an Operator (task_rekick has no \
1813 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
1814 );
1815 }
1816 }
1817 }
1818
1819 #[tokio::test]
1823 async fn rekick_timeout_secs_zero_rejected() {
1824 let state = test_state();
1825 let posted = crate::tasks_start(
1826 State(state.clone()),
1827 Json(post_tasks_req("zero timeout rekick goal")),
1828 )
1829 .await
1830 .expect("tasks_start")
1831 .0;
1832
1833 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
1834 .await
1835 .expect("task_get")
1836 .0;
1837 let runs_before = before.runs.len();
1838
1839 let result = task_rekick(
1840 State(state.clone()),
1841 Path(posted.task_id.to_string()),
1842 Some(Json(RunKickRequest {
1843 init_ctx_override: None,
1844 task_input_override: None,
1845 timeout_secs: Some(0),
1846 detach: false,
1847 })),
1848 )
1849 .await;
1850 let err = match result {
1851 Err(e) => e,
1852 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1853 };
1854 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1855 assert!(
1856 err.message.contains("timeout_secs"),
1857 "error message must reference timeout_secs: {}",
1858 err.message
1859 );
1860
1861 let after = task_get(State(state), Path(posted.task_id.to_string()))
1862 .await
1863 .expect("task_get")
1864 .0;
1865 assert_eq!(
1866 after.runs.len(),
1867 runs_before,
1868 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
1869 );
1870 }
1871
1872 #[tokio::test]
1876 async fn rekick_non_operator_path_unaffected_by_guard_1() {
1877 let state = test_state();
1878 let posted = crate::tasks_start(
1879 State(state.clone()),
1880 Json(post_tasks_req("non-operator rekick goal")),
1881 )
1882 .await
1883 .expect("tasks_start")
1884 .0;
1885
1886 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1887 if let Err(e) = &result {
1888 panic!(
1889 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
1890 guard 1: {}",
1891 e.message
1892 );
1893 }
1894 }
1895
1896 #[tokio::test]
1897 async fn run_get_unknown_id_returns_404() {
1898 let state = test_state();
1899 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
1900 Ok(_) => panic!("expected 404 for an unknown run"),
1901 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1902 }
1903 }
1904
1905 #[tokio::test]
1906 async fn task_get_unknown_id_returns_404() {
1907 let state = test_state();
1908 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
1909 Ok(_) => panic!("expected 404 for an unknown task"),
1910 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1911 }
1912 }
1913}