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