1use 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
45pub(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
55pub(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#[derive(Debug, Deserialize, Default)]
113pub struct TasksListQuery {
114 #[serde(default)]
117 pub limit: Option<usize>,
118}
119
120pub 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#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
134pub struct TaskDetailResponse {
135 pub task: TaskRecord,
137 pub runs: Vec<RunRecord>,
139}
140
141pub 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#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
168pub struct RunKickRequest {
169 #[serde(default)]
178 #[schemars(with = "Option<Value>")]
179 pub init_ctx_override: Option<Value>,
180 #[serde(default)]
187 pub task_input_override: Option<TaskInputSpec>,
188 #[serde(default)]
194 pub timeout_secs: Option<u64>,
195 #[serde(default)]
202 pub detach: bool,
203}
204
205#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
207pub struct RunKickResponse {
208 #[schemars(with = "String")]
210 pub task_id: TaskId,
211 #[schemars(with = "String")]
213 pub run_id: RunId,
214 pub status: RunStatus,
219}
220
221pub 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 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 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 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 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 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 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 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
508pub 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
524pub(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#[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 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 }],
585 operators: vec![],
586 metas: vec![],
587 hints: CompilerHints::default(),
588 strategy: CompilerStrategy::default(),
589 metadata: BlueprintMetadata::default(),
590 spawner_hints: Default::default(),
591 default_agent_kind: AgentKind::Operator,
592 default_operator_kind: None,
593 default_init_ctx: None,
594 default_agent_ctx: None,
595 default_context_policy: None,
596 projection_placement: None,
597 audits: vec![],
598 degradation_policy: None,
599 }
600 }
601
602 fn test_state() -> AppState {
607 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
608 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
609 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
610 AppState {
611 engine,
612 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
613 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
614 ws_operator_factory: None,
615 data_store: Arc::new(InMemoryOutputStore::new()),
616 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
617 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
618 task_store: Arc::new(InMemoryTaskStore::new()),
619 run_store: Arc::new(InMemoryRunStore::new()),
620 base_url: None,
621 sync_timeout_secs: 300,
622 }
623 }
624
625 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
626 crate::TaskLaunchRequest {
627 blueprint: BlueprintRef::Inline {
628 value: Box::new(identity_blueprint()),
629 },
630 init_ctx: serde_json::json!({"in": "hello"}),
631 project_root: None,
632 work_dir: None,
633 task_metadata: None,
634 ttl_secs: None,
635 operator: None,
636 operator_sid: None,
637 timeout_secs: None,
638 goal: Some(goal.to_string()),
639 detach: false,
640 }
641 }
642
643 #[test]
644 fn task_id_serializes_as_bare_string() {
645 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
649 assert_eq!(v, serde_json::json!("T-abc"));
650 }
651
652 #[tokio::test]
653 async fn post_then_get_drill_down() {
654 let state = test_state();
655
656 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
657 .await
658 .expect("tasks_start")
659 .0;
660 let task_id = posted.task_id.clone();
661 let run_id = posted.run_id.clone();
662
663 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
665 .await
666 .expect("tasks_list")
667 .0;
668 assert!(
669 list.iter().any(|t| t.id == task_id),
670 "task {task_id} missing from list of {} tasks",
671 list.len()
672 );
673
674 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
676 .await
677 .expect("task_get")
678 .0;
679 assert_eq!(detail.task.id, task_id);
680 assert_eq!(detail.task.goal, "smoke goal");
681 assert_eq!(detail.task.status, TaskRecordStatus::Done);
682 assert_eq!(detail.runs.len(), 1);
683 assert_eq!(detail.runs[0].id, run_id);
684 assert_eq!(detail.runs[0].status, RunStatus::Done);
685
686 let run = run_get(State(state.clone()), Path(run_id.to_string()))
688 .await
689 .expect("run_get")
690 .0;
691 assert_eq!(run.id, run_id);
692 assert_eq!(run.task_id, task_id);
693 assert_eq!(run.result_ref, Some(posted.final_ctx));
694
695 assert_eq!(
699 run.step_entries.len(),
700 1,
701 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
702 run.step_entries
703 );
704 assert_eq!(
705 run.step_entries[0].step_ref,
706 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
707 );
708 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
709 }
710
711 fn identity_blueprint_with_operator_delegate() -> Blueprint {
723 Blueprint {
724 spawner_hints: mlua_swarm::SpawnerHints {
725 layers: vec!["operator_delegate".to_string()],
726 },
727 ..identity_blueprint()
728 }
729 }
730
731 struct StallingOperator;
734
735 #[async_trait::async_trait]
736 impl mlua_swarm::Operator for StallingOperator {
737 async fn execute(
738 &self,
739 _ctx: &mlua_swarm::Ctx,
740 _system: Option<String>,
741 _prompt: Value,
742 _worker: Option<mlua_swarm::WorkerBinding>,
743 _worker_token: mlua_swarm::CapToken,
744 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
745 std::future::pending::<()>().await;
746 unreachable!("StallingOperator.execute must never resolve")
747 }
748 }
749
750 fn operator_launch_req(
754 backend_id: &str,
755 timeout_secs: Option<u64>,
756 ) -> crate::TaskLaunchRequest {
757 crate::TaskLaunchRequest {
758 blueprint: BlueprintRef::Inline {
759 value: Box::new(identity_blueprint_with_operator_delegate()),
760 },
761 init_ctx: serde_json::json!({"in": "hello"}),
762 project_root: None,
763 work_dir: None,
764 task_metadata: None,
765 ttl_secs: None,
766 operator: Some(crate::OperatorReq {
767 operator_backend_id: Some(backend_id.to_string()),
768 ..Default::default()
769 }),
770 operator_sid: None,
771 timeout_secs,
772 goal: Some("operator delegate test goal".to_string()),
773 detach: false,
774 }
775 }
776
777 #[tokio::test]
781 async fn sync_launch_zero_operators_fails_fast() {
782 let state = test_state();
783 let req = operator_launch_req("nonexistent-op", None);
786
787 let started = std::time::Instant::now();
788 let result = crate::tasks_start(State(state), Json(req)).await;
789 let elapsed = started.elapsed();
790
791 let err = match result {
792 Err(e) => e,
793 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
794 };
795 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
796 assert!(
797 err.message.contains("no operator attached"),
798 "error message must mention the missing operator: {}",
799 err.message
800 );
801 assert!(
802 elapsed < Duration::from_secs(1),
803 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
804 );
805 }
806
807 #[tokio::test]
811 async fn sync_launch_stalled_times_out() {
812 let state = test_state();
813 state
814 .engine
815 .register_operator("stall-op", Arc::new(StallingOperator))
816 .await;
817 let req = operator_launch_req("stall-op", Some(1));
818
819 let started = std::time::Instant::now();
820 let result = tokio::time::timeout(
824 Duration::from_secs(5),
825 crate::tasks_start(State(state), Json(req)),
826 )
827 .await
828 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
829 let elapsed = started.elapsed();
830
831 let err = match result {
832 Err(e) => e,
833 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
834 };
835 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
836 assert!(
837 err.message.contains('1'),
838 "error message must mention the configured 1s ceiling: {}",
839 err.message
840 );
841 assert!(
842 elapsed < Duration::from_secs(3),
843 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
844 );
845 }
846
847 #[tokio::test]
851 async fn sync_launch_without_operator_path_unaffected() {
852 let state = test_state();
853 let result = crate::tasks_start(
854 State(state),
855 Json(post_tasks_req("non-operator launch goal")),
856 )
857 .await;
858 if let Err(e) = &result {
859 panic!(
860 "non-operator launch must succeed unaffected by guard 1: {}",
861 e.message
862 );
863 }
864 }
865
866 #[tokio::test]
870 async fn sync_launch_zero_timeout_secs_rejected() {
871 let state = test_state();
872 let mut req = post_tasks_req("zero timeout goal");
873 req.timeout_secs = Some(0);
874
875 let result = crate::tasks_start(State(state), Json(req)).await;
876 let err = match result {
877 Err(e) => e,
878 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
879 };
880 assert_eq!(err.status, StatusCode::BAD_REQUEST);
881 assert!(
882 err.message.contains("timeout_secs"),
883 "error message must reference timeout_secs: {}",
884 err.message
885 );
886 }
887
888 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
897 for _ in 0..50 {
898 let rec = state.run_store.get(run_id).await.expect("run get");
899 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
900 return rec;
901 }
902 tokio::time::sleep(Duration::from_millis(100)).await;
903 }
904 panic!("run {run_id} did not reach a terminal status within ~5s");
905 }
906
907 #[tokio::test]
913 async fn detached_launch_returns_202_and_completes_in_background() {
914 let state = test_state();
915 let mut req = post_tasks_req("detached goal");
916 req.detach = true;
917
918 let reply = crate::tasks_start(State(state.clone()), Json(req))
919 .await
920 .expect("tasks_start (detached)");
921 assert_eq!(reply.1, StatusCode::ACCEPTED);
922 let posted = reply.0;
923 assert_eq!(posted.status, RunStatus::Running);
924 assert_eq!(
925 posted.final_ctx,
926 serde_json::Value::Null,
927 "a detached launch has no final_ctx at response time"
928 );
929
930 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
931 assert_eq!(rec.status, RunStatus::Done);
932 assert!(
933 rec.result_ref.is_some(),
934 "finalize_run must persist the background eval's final_ctx"
935 );
936 assert_eq!(
937 rec.step_entries.len(),
938 1,
939 "the background eval must trace its step_entries like the sync path: {:?}",
940 rec.step_entries
941 );
942 let task = state
943 .task_store
944 .get(&posted.task_id)
945 .await
946 .expect("task get");
947 assert_eq!(task.status, TaskRecordStatus::Done);
948 }
949
950 #[tokio::test]
954 async fn detached_launch_with_timeout_secs_rejected() {
955 let state = test_state();
956 let mut req = post_tasks_req("detached + ceiling goal");
957 req.detach = true;
958 req.timeout_secs = Some(60);
959
960 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
961 Err(e) => e,
962 Ok(_) => panic!("detach + timeout_secs must be rejected"),
963 };
964 assert_eq!(err.status, StatusCode::BAD_REQUEST);
965 assert!(
966 err.message.contains("detach"),
967 "error message must explain the detach/timeout_secs conflict: {}",
968 err.message
969 );
970 let tasks = state.task_store.list().await.expect("task list");
971 assert!(
972 tasks.is_empty(),
973 "the 400 must fire before any TaskRecord is minted"
974 );
975 }
976
977 #[tokio::test]
981 async fn rekick_detached_returns_202_and_completes_in_background() {
982 let state = test_state();
983 let posted = crate::tasks_start(
984 State(state.clone()),
985 Json(post_tasks_req("detached rekick goal")),
986 )
987 .await
988 .expect("tasks_start")
989 .0;
990
991 let (status, rekicked) = task_rekick(
992 State(state.clone()),
993 Path(posted.task_id.to_string()),
994 Some(Json(RunKickRequest {
995 init_ctx_override: None,
996 task_input_override: None,
997 timeout_secs: None,
998 detach: true,
999 })),
1000 )
1001 .await
1002 .expect("task_rekick (detached)");
1003 assert_eq!(status, StatusCode::ACCEPTED);
1004 assert_eq!(rekicked.0.status, RunStatus::Running);
1005 assert_ne!(rekicked.0.run_id, posted.run_id);
1006
1007 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1008 assert_eq!(rec.status, RunStatus::Done);
1009 assert!(
1010 rec.result_ref.is_some(),
1011 "finalize_run must persist the background rekick's final_ctx"
1012 );
1013 }
1014
1015 #[tokio::test]
1019 async fn rekick_detached_with_timeout_secs_rejected() {
1020 let state = test_state();
1021 let posted = crate::tasks_start(
1022 State(state.clone()),
1023 Json(post_tasks_req("detached rekick ceiling goal")),
1024 )
1025 .await
1026 .expect("tasks_start")
1027 .0;
1028
1029 let err = match task_rekick(
1030 State(state.clone()),
1031 Path(posted.task_id.to_string()),
1032 Some(Json(RunKickRequest {
1033 init_ctx_override: None,
1034 task_input_override: None,
1035 timeout_secs: Some(60),
1036 detach: true,
1037 })),
1038 )
1039 .await
1040 {
1041 Err(e) => e,
1042 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1043 };
1044 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1045 assert!(
1046 err.message.contains("detach"),
1047 "error message must explain the detach/timeout_secs conflict: {}",
1048 err.message
1049 );
1050 let runs = state
1051 .run_store
1052 .list_by_task(&posted.task_id)
1053 .await
1054 .expect("runs list");
1055 assert_eq!(
1056 runs.len(),
1057 1,
1058 "the 400 must fire before a second Run is minted"
1059 );
1060 }
1061
1062 #[tokio::test]
1063 async fn rekick_adds_a_second_run_to_the_same_task() {
1064 let state = test_state();
1065 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1066 .await
1067 .expect("tasks_start")
1068 .0;
1069 let task_id = posted.task_id.clone();
1070 let first_run_id = posted.run_id.clone();
1071
1072 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1073 .await
1074 .expect("task_rekick");
1075 assert_eq!(status, StatusCode::CREATED);
1076 let second_run_id = rekicked.0.run_id.clone();
1077 assert_ne!(first_run_id, second_run_id);
1078
1079 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1080 .await
1081 .expect("task_get")
1082 .0;
1083 assert_eq!(
1084 detail.runs.len(),
1085 2,
1086 "expected 2 runs, got {:?}",
1087 detail.runs
1088 );
1089 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1090 assert!(ids.contains(&&first_run_id));
1091 assert!(ids.contains(&&second_run_id));
1092
1093 let first_run = detail
1098 .runs
1099 .iter()
1100 .find(|r| r.id == first_run_id)
1101 .expect("first run present in detail.runs");
1102 let second_run = detail
1103 .runs
1104 .iter()
1105 .find(|r| r.id == second_run_id)
1106 .expect("second run present in detail.runs");
1107 assert_eq!(
1108 first_run.step_entries.len(),
1109 1,
1110 "first run step_entries: {:?}",
1111 first_run.step_entries
1112 );
1113 assert_eq!(
1114 second_run.step_entries.len(),
1115 1,
1116 "second run step_entries: {:?}",
1117 second_run.step_entries
1118 );
1119 assert_eq!(
1120 first_run.step_entries[0].step_ref,
1121 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1122 );
1123 assert_eq!(
1124 second_run.step_entries[0].step_ref,
1125 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1126 );
1127 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1128 assert_eq!(
1129 second_run.step_entries[0].status,
1130 Some("passed".to_string())
1131 );
1132 assert_ne!(
1133 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1134 "each kick dispatches its own StepId — runs must not share step_entries"
1135 );
1136 }
1137
1138 #[tokio::test]
1139 async fn rekick_unknown_task_returns_404() {
1140 let state = test_state();
1141 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1145 Ok(_) => panic!("expected 404 for an unknown task"),
1146 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1147 }
1148 }
1149
1150 fn greeting_blueprint() -> Blueprint {
1159 Blueprint {
1160 schema_version: current_schema_version(),
1161 id: "tasks-test-greeting-bp".into(),
1162 flow: serde_json::from_value(serde_json::json!({
1163 "kind": "step",
1164 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1165 "in": {"op": "path", "at": "$.greeting"},
1166 "out": {"op": "path", "at": "$.out"},
1167 }))
1168 .expect("flow parse"),
1169 agents: vec![AgentDef {
1170 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1171 kind: AgentKind::RustFn,
1172 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1173 profile: None,
1174 meta: None,
1175 }],
1176 operators: vec![],
1177 metas: vec![],
1178 hints: CompilerHints::default(),
1179 strategy: CompilerStrategy::default(),
1180 metadata: BlueprintMetadata::default(),
1181 spawner_hints: Default::default(),
1182 default_agent_kind: AgentKind::Operator,
1183 default_operator_kind: None,
1184 default_init_ctx: None,
1185 default_agent_ctx: None,
1186 default_context_policy: None,
1187 projection_placement: None,
1188 audits: vec![],
1189 degradation_policy: None,
1190 }
1191 }
1192
1193 fn post_greeting_task_req(
1194 greeting: &str,
1195 project_root: Option<&str>,
1196 ) -> crate::TaskLaunchRequest {
1197 crate::TaskLaunchRequest {
1198 blueprint: BlueprintRef::Inline {
1199 value: Box::new(greeting_blueprint()),
1200 },
1201 init_ctx: serde_json::json!({ "greeting": greeting }),
1202 project_root: project_root.map(str::to_string),
1203 work_dir: None,
1204 task_metadata: None,
1205 ttl_secs: None,
1206 operator: None,
1207 operator_sid: None,
1208 timeout_secs: None,
1209 goal: Some("st4 rekick goal".to_string()),
1210 detach: false,
1211 }
1212 }
1213
1214 #[tokio::test]
1215 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1216 let state = test_state();
1219 let posted = crate::tasks_start(
1220 State(state.clone()),
1221 Json(post_greeting_task_req("from-task", None)),
1222 )
1223 .await
1224 .expect("tasks_start")
1225 .0;
1226 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1227
1228 let (status, rekicked) =
1229 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1230 .await
1231 .expect("task_rekick");
1232 assert_eq!(status, StatusCode::CREATED);
1233
1234 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1235 .await
1236 .expect("run_get")
1237 .0;
1238 assert_eq!(
1239 run.result_ref.expect("result_ref present")["out"]["echoed"],
1240 "from-task"
1241 );
1242 }
1243
1244 #[tokio::test]
1245 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1246 let state = test_state();
1247 let posted = crate::tasks_start(
1248 State(state.clone()),
1249 Json(post_greeting_task_req("from-task", None)),
1250 )
1251 .await
1252 .expect("tasks_start")
1253 .0;
1254 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1255
1256 let (status, rekicked) = task_rekick(
1257 State(state.clone()),
1258 Path(posted.task_id.to_string()),
1259 Some(Json(RunKickRequest {
1260 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1261 task_input_override: None,
1262 timeout_secs: None,
1263 detach: false,
1264 })),
1265 )
1266 .await
1267 .expect("task_rekick");
1268 assert_eq!(status, StatusCode::CREATED);
1269
1270 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1271 .await
1272 .expect("run_get")
1273 .0;
1274 assert_eq!(
1275 run.result_ref.expect("result_ref present")["out"]["echoed"],
1276 "from-run",
1277 "Run's init_ctx_override must win over the stored Task input_ctx"
1278 );
1279 }
1280
1281 #[tokio::test]
1282 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1283 let state = test_state();
1291 let posted = crate::tasks_start(
1292 State(state.clone()),
1293 Json(post_greeting_task_req("from-task", Some("/repo"))),
1294 )
1295 .await
1296 .expect("tasks_start")
1297 .0;
1298
1299 let before = state
1300 .task_store
1301 .get(&posted.task_id)
1302 .await
1303 .expect("task fetch");
1304 let before_spec: Option<TaskInputSpec> = before
1305 .task_input_spec
1306 .as_ref()
1307 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1308 assert_eq!(
1309 before_spec,
1310 Some(TaskInputSpec {
1311 project_root: Some("/repo".to_string()),
1312 work_dir: None,
1313 task_metadata: None,
1314 })
1315 );
1316
1317 let (status, _rekicked) =
1318 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1319 .await
1320 .expect("task_rekick");
1321 assert_eq!(status, StatusCode::CREATED);
1322
1323 let after = state
1324 .task_store
1325 .get(&posted.task_id)
1326 .await
1327 .expect("task fetch");
1328 assert_eq!(
1329 after.task_input_spec, before.task_input_spec,
1330 "rekick must not mutate the stored Task-level task_input_spec snapshot"
1331 );
1332 }
1333
1334 #[tokio::test]
1335 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1336 let state = test_state();
1339 let posted = crate::tasks_start(
1340 State(state.clone()),
1341 Json(post_greeting_task_req("from-task", Some("/repo"))),
1342 )
1343 .await
1344 .expect("tasks_start")
1345 .0;
1346
1347 let (status, _rekicked) = task_rekick(
1348 State(state.clone()),
1349 Path(posted.task_id.to_string()),
1350 Some(Json(RunKickRequest {
1351 init_ctx_override: None,
1352 task_input_override: Some(TaskInputSpec {
1353 project_root: Some("/override".to_string()),
1354 work_dir: None,
1355 task_metadata: None,
1356 }),
1357 timeout_secs: None,
1358 detach: false,
1359 })),
1360 )
1361 .await
1362 .expect("task_rekick");
1363 assert_eq!(status, StatusCode::CREATED);
1364
1365 let after = state
1366 .task_store
1367 .get(&posted.task_id)
1368 .await
1369 .expect("task fetch");
1370 let after_spec: Option<TaskInputSpec> = after
1371 .task_input_spec
1372 .as_ref()
1373 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1374 assert_eq!(
1375 after_spec,
1376 Some(TaskInputSpec {
1377 project_root: Some("/repo".to_string()),
1378 work_dir: None,
1379 task_metadata: None,
1380 }),
1381 "a per-Run task_input_override must not leak into the stored TaskRecord"
1382 );
1383 }
1384
1385 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
1399 crate::TaskLaunchRequest {
1400 blueprint: BlueprintRef::Inline {
1401 value: Box::new(identity_blueprint_with_operator_delegate()),
1402 },
1403 init_ctx: serde_json::json!({"in": "hello"}),
1404 project_root: None,
1405 work_dir: None,
1406 task_metadata: None,
1407 ttl_secs: None,
1408 operator: None,
1409 operator_sid: None,
1410 timeout_secs: None,
1411 goal: Some(goal.to_string()),
1412 detach: false,
1413 }
1414 }
1415
1416 #[tokio::test]
1421 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
1422 let state = test_state();
1423 let posted = crate::tasks_start(
1424 State(state.clone()),
1425 Json(delegate_launch_req("operator delegate rekick goal")),
1426 )
1427 .await
1428 .expect("tasks_start (no operator referenced, dispatches through baseline)")
1429 .0;
1430 let started = std::time::Instant::now();
1434 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1435 let elapsed = started.elapsed();
1436
1437 let err = match result {
1438 Err(e) => e,
1439 Ok(_) => panic!(
1440 "rekicking a Task whose Blueprint declares operator_delegate with zero \
1441 attached operators must fail, not dispatch"
1442 ),
1443 };
1444 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1445 assert!(
1446 err.message.contains("no operator attached"),
1447 "error message must mention the missing operator: {}",
1448 err.message
1449 );
1450 assert!(
1451 elapsed < Duration::from_secs(1),
1452 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1453 );
1454 }
1455
1456 #[tokio::test]
1460 async fn rekick_stalled_operator_times_out() {
1461 let state = test_state();
1462 state
1463 .engine
1464 .register_operator("stall-op", Arc::new(StallingOperator))
1465 .await;
1466 let posted = crate::tasks_start(
1467 State(state.clone()),
1468 Json(delegate_launch_req("stalled rekick goal")),
1469 )
1470 .await
1471 .expect("tasks_start")
1472 .0;
1473
1474 let started = std::time::Instant::now();
1475 let result = tokio::time::timeout(
1479 Duration::from_secs(5),
1480 task_rekick(
1481 State(state),
1482 Path(posted.task_id.to_string()),
1483 Some(Json(RunKickRequest {
1484 init_ctx_override: None,
1485 task_input_override: None,
1486 timeout_secs: Some(1),
1487 detach: false,
1488 })),
1489 ),
1490 )
1491 .await
1492 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
1493 let elapsed = started.elapsed();
1494
1495 match &result {
1496 Err(e) => {
1497 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
1498 assert!(
1499 e.message.contains('1'),
1500 "error message must mention the configured 1s ceiling: {}",
1501 e.message
1502 );
1503 assert!(
1504 elapsed < Duration::from_secs(3),
1505 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1506 );
1507 }
1508 Ok(_) => {
1509 assert!(
1521 elapsed < Duration::from_secs(1),
1522 "a rekick that never engages an Operator (task_rekick has no \
1523 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
1524 );
1525 }
1526 }
1527 }
1528
1529 #[tokio::test]
1533 async fn rekick_timeout_secs_zero_rejected() {
1534 let state = test_state();
1535 let posted = crate::tasks_start(
1536 State(state.clone()),
1537 Json(post_tasks_req("zero timeout rekick goal")),
1538 )
1539 .await
1540 .expect("tasks_start")
1541 .0;
1542
1543 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
1544 .await
1545 .expect("task_get")
1546 .0;
1547 let runs_before = before.runs.len();
1548
1549 let result = task_rekick(
1550 State(state.clone()),
1551 Path(posted.task_id.to_string()),
1552 Some(Json(RunKickRequest {
1553 init_ctx_override: None,
1554 task_input_override: None,
1555 timeout_secs: Some(0),
1556 detach: false,
1557 })),
1558 )
1559 .await;
1560 let err = match result {
1561 Err(e) => e,
1562 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1563 };
1564 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1565 assert!(
1566 err.message.contains("timeout_secs"),
1567 "error message must reference timeout_secs: {}",
1568 err.message
1569 );
1570
1571 let after = task_get(State(state), Path(posted.task_id.to_string()))
1572 .await
1573 .expect("task_get")
1574 .0;
1575 assert_eq!(
1576 after.runs.len(),
1577 runs_before,
1578 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
1579 );
1580 }
1581
1582 #[tokio::test]
1586 async fn rekick_non_operator_path_unaffected_by_guard_1() {
1587 let state = test_state();
1588 let posted = crate::tasks_start(
1589 State(state.clone()),
1590 Json(post_tasks_req("non-operator rekick goal")),
1591 )
1592 .await
1593 .expect("tasks_start")
1594 .0;
1595
1596 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1597 if let Err(e) = &result {
1598 panic!(
1599 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
1600 guard 1: {}",
1601 e.message
1602 );
1603 }
1604 }
1605
1606 #[tokio::test]
1607 async fn run_get_unknown_id_returns_404() {
1608 let state = test_state();
1609 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
1610 Ok(_) => panic!("expected 404 for an unknown run"),
1611 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1612 }
1613 }
1614
1615 #[tokio::test]
1616 async fn task_get_unknown_id_returns_404() {
1617 let state = test_state();
1618 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
1619 Ok(_) => panic!("expected 404 for an unknown task"),
1620 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1621 }
1622 }
1623}