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 check_policy: None,
398 };
399 let run_ctx = RunContext {
400 run_id: run_id.clone(),
401 run_store: state.run_store.clone(),
402 };
403
404 if detach {
410 let ttl_secs = crate::default_run_ttl();
411 let bg_state = state.clone();
412 let bg_task_id = task_id.clone();
413 let bg_run_id = run_id.clone();
414 tokio::spawn(async move {
415 let outcome = match tokio::time::timeout(
416 Duration::from_secs(ttl_secs),
417 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
418 )
419 .await
420 {
421 Ok(outcome) => outcome,
422 Err(_elapsed) => {
423 let reason = serde_json::json!({
424 "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
425 });
426 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
427 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
428 }
429 if let Err(e) = bg_state
430 .run_store
431 .update_status(&bg_run_id, RunStatus::Failed)
432 .await
433 {
434 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
435 }
436 if let Err(e) = bg_state
437 .task_store
438 .update_status(&bg_task_id, TaskRecordStatus::Failed)
439 .await
440 {
441 tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
442 }
443 return;
444 }
445 };
446 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
449 });
450 return Ok((
451 StatusCode::ACCEPTED,
452 Json(RunKickResponse {
453 task_id,
454 run_id,
455 status: RunStatus::Running,
456 }),
457 ));
458 }
459
460 let outcome = match tokio::time::timeout(
466 Duration::from_secs(sync_timeout_secs),
467 state.task_app.handle_with_run(input, Some(run_ctx)),
468 )
469 .await
470 {
471 Ok(outcome) => outcome,
472 Err(_elapsed) => {
473 let reason = serde_json::json!({
474 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
475 });
476 if let Err(e) = state.run_store.set_result(&run_id, reason).await {
477 tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
478 }
479 if let Err(e) = state
480 .run_store
481 .update_status(&run_id, RunStatus::Failed)
482 .await
483 {
484 tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
485 }
486 if let Err(e) = state
487 .task_store
488 .update_status(&task_id, TaskRecordStatus::Failed)
489 .await
490 {
491 tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
492 }
493 return Err(ApiError::timeout(format!(
494 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
495 )));
496 }
497 };
498 finalize_run(&state, &task_id, &run_id, outcome)
499 .await
500 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
501
502 Ok((
503 StatusCode::CREATED,
504 Json(RunKickResponse {
505 task_id,
506 run_id,
507 status: RunStatus::Done,
508 }),
509 ))
510}
511
512pub async fn run_get(
515 State(state): State<AppState>,
516 Path(id): Path<String>,
517) -> Result<Json<RunRecord>, ApiError> {
518 let run_id =
519 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
520 let run = state
521 .run_store
522 .get(&run_id)
523 .await
524 .map_err(map_run_store_err)?;
525 Ok(Json(run))
526}
527
528pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
532 match e {
533 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
534 other => ApiError::engine(other),
535 }
536}
537
538fn map_run_store_err(e: RunStoreError) -> ApiError {
539 match e {
540 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
541 other => ApiError::engine(other),
542 }
543}
544
545#[cfg(test)]
550mod tests {
551 use super::*;
552 use mlua_swarm::application::BlueprintRef;
553 use mlua_swarm::blueprint::{
554 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
555 CompilerStrategy,
556 };
557 use mlua_swarm::core::config::EngineCfg;
558 use mlua_swarm::core::engine::Engine;
559 use mlua_swarm::store::output::InMemoryOutputStore;
560 use mlua_swarm::store::run::InMemoryRunStore;
561 use mlua_swarm::store::task::InMemoryTaskStore;
562 use std::collections::HashMap;
563 use std::sync::Arc;
564 use tokio::sync::Mutex;
565
566 fn identity_blueprint() -> Blueprint {
572 Blueprint {
573 schema_version: current_schema_version(),
574 id: "tasks-test-bp".into(),
575 flow: serde_json::from_value(serde_json::json!({
576 "kind": "step",
577 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
578 "in": {"op": "lit", "value": "hello"},
579 "out": {"op": "path", "at": "$.out"},
580 }))
581 .expect("flow parse"),
582 agents: vec![AgentDef {
583 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
584 kind: AgentKind::RustFn,
585 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
586 profile: None,
587 meta: None,
588 runner: None,
589 runner_ref: None,
590 verdict: None,
591 }],
592 operators: vec![],
593 metas: vec![],
594 hints: CompilerHints::default(),
595 strategy: CompilerStrategy::default(),
596 metadata: BlueprintMetadata::default(),
597 spawner_hints: Default::default(),
598 default_agent_kind: AgentKind::Operator,
599 default_operator_kind: None,
600 default_init_ctx: None,
601 default_agent_ctx: None,
602 default_context_policy: None,
603 projection_placement: None,
604 audits: vec![],
605 degradation_policy: None,
606 runners: vec![],
607 default_runner: None,
608 check_policy: None,
609 }
610 }
611
612 fn test_state() -> AppState {
617 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
618 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
619 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
620 AppState {
621 engine,
622 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
623 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
624 ws_operator_factory: None,
625 data_store: Arc::new(InMemoryOutputStore::new()),
626 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
627 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
628 task_store: Arc::new(InMemoryTaskStore::new()),
629 run_store: Arc::new(InMemoryRunStore::new()),
630 base_url: None,
631 sync_timeout_secs: 300,
632 }
633 }
634
635 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
636 crate::TaskLaunchRequest {
637 blueprint: BlueprintRef::Inline {
638 value: Box::new(identity_blueprint()),
639 },
640 init_ctx: serde_json::json!({"in": "hello"}),
641 project_root: None,
642 work_dir: None,
643 task_metadata: None,
644 ttl_secs: None,
645 operator: None,
646 operator_sid: None,
647 timeout_secs: None,
648 goal: Some(goal.to_string()),
649 detach: false,
650 check_policy: None,
651 }
652 }
653
654 #[test]
655 fn task_id_serializes_as_bare_string() {
656 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
660 assert_eq!(v, serde_json::json!("T-abc"));
661 }
662
663 #[tokio::test]
664 async fn post_then_get_drill_down() {
665 let state = test_state();
666
667 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
668 .await
669 .expect("tasks_start")
670 .0;
671 let task_id = posted.task_id.clone();
672 let run_id = posted.run_id.clone();
673
674 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
676 .await
677 .expect("tasks_list")
678 .0;
679 assert!(
680 list.iter().any(|t| t.id == task_id),
681 "task {task_id} missing from list of {} tasks",
682 list.len()
683 );
684
685 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
687 .await
688 .expect("task_get")
689 .0;
690 assert_eq!(detail.task.id, task_id);
691 assert_eq!(detail.task.goal, "smoke goal");
692 assert_eq!(detail.task.status, TaskRecordStatus::Done);
693 assert_eq!(detail.runs.len(), 1);
694 assert_eq!(detail.runs[0].id, run_id);
695 assert_eq!(detail.runs[0].status, RunStatus::Done);
696
697 let run = run_get(State(state.clone()), Path(run_id.to_string()))
699 .await
700 .expect("run_get")
701 .0;
702 assert_eq!(run.id, run_id);
703 assert_eq!(run.task_id, task_id);
704 assert_eq!(run.result_ref, Some(posted.final_ctx));
705
706 assert_eq!(
710 run.step_entries.len(),
711 1,
712 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
713 run.step_entries
714 );
715 assert_eq!(
716 run.step_entries[0].step_ref,
717 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
718 );
719 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
720 }
721
722 fn identity_blueprint_with_operator_delegate() -> Blueprint {
734 Blueprint {
735 spawner_hints: mlua_swarm::SpawnerHints {
736 layers: vec!["operator_delegate".to_string()],
737 },
738 ..identity_blueprint()
739 }
740 }
741
742 struct StallingOperator;
745
746 #[async_trait::async_trait]
747 impl mlua_swarm::Operator for StallingOperator {
748 async fn execute(
749 &self,
750 _ctx: &mlua_swarm::Ctx,
751 _system: Option<String>,
752 _prompt: Value,
753 _worker: Option<mlua_swarm::WorkerBinding>,
754 _worker_token: mlua_swarm::CapToken,
755 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
756 std::future::pending::<()>().await;
757 unreachable!("StallingOperator.execute must never resolve")
758 }
759 }
760
761 fn operator_launch_req(
765 backend_id: &str,
766 timeout_secs: Option<u64>,
767 ) -> crate::TaskLaunchRequest {
768 crate::TaskLaunchRequest {
769 blueprint: BlueprintRef::Inline {
770 value: Box::new(identity_blueprint_with_operator_delegate()),
771 },
772 init_ctx: serde_json::json!({"in": "hello"}),
773 project_root: None,
774 work_dir: None,
775 task_metadata: None,
776 ttl_secs: None,
777 operator: Some(crate::OperatorReq {
778 operator_backend_id: Some(backend_id.to_string()),
779 ..Default::default()
780 }),
781 operator_sid: None,
782 timeout_secs,
783 goal: Some("operator delegate test goal".to_string()),
784 detach: false,
785 check_policy: None,
786 }
787 }
788
789 #[tokio::test]
793 async fn sync_launch_zero_operators_fails_fast() {
794 let state = test_state();
795 let req = operator_launch_req("nonexistent-op", None);
798
799 let started = std::time::Instant::now();
800 let result = crate::tasks_start(State(state), Json(req)).await;
801 let elapsed = started.elapsed();
802
803 let err = match result {
804 Err(e) => e,
805 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
806 };
807 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
808 assert!(
809 err.message.contains("no operator attached"),
810 "error message must mention the missing operator: {}",
811 err.message
812 );
813 assert!(
814 elapsed < Duration::from_secs(1),
815 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
816 );
817 }
818
819 #[tokio::test]
823 async fn sync_launch_stalled_times_out() {
824 let state = test_state();
825 state
826 .engine
827 .register_operator("stall-op", Arc::new(StallingOperator))
828 .await;
829 let req = operator_launch_req("stall-op", Some(1));
830
831 let started = std::time::Instant::now();
832 let result = tokio::time::timeout(
836 Duration::from_secs(5),
837 crate::tasks_start(State(state), Json(req)),
838 )
839 .await
840 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
841 let elapsed = started.elapsed();
842
843 let err = match result {
844 Err(e) => e,
845 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
846 };
847 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
848 assert!(
849 err.message.contains('1'),
850 "error message must mention the configured 1s ceiling: {}",
851 err.message
852 );
853 assert!(
854 elapsed < Duration::from_secs(3),
855 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
856 );
857 }
858
859 #[tokio::test]
863 async fn sync_launch_without_operator_path_unaffected() {
864 let state = test_state();
865 let result = crate::tasks_start(
866 State(state),
867 Json(post_tasks_req("non-operator launch goal")),
868 )
869 .await;
870 if let Err(e) = &result {
871 panic!(
872 "non-operator launch must succeed unaffected by guard 1: {}",
873 e.message
874 );
875 }
876 }
877
878 #[tokio::test]
882 async fn sync_launch_zero_timeout_secs_rejected() {
883 let state = test_state();
884 let mut req = post_tasks_req("zero timeout goal");
885 req.timeout_secs = Some(0);
886
887 let result = crate::tasks_start(State(state), Json(req)).await;
888 let err = match result {
889 Err(e) => e,
890 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
891 };
892 assert_eq!(err.status, StatusCode::BAD_REQUEST);
893 assert!(
894 err.message.contains("timeout_secs"),
895 "error message must reference timeout_secs: {}",
896 err.message
897 );
898 }
899
900 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
909 for _ in 0..50 {
910 let rec = state.run_store.get(run_id).await.expect("run get");
911 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
912 return rec;
913 }
914 tokio::time::sleep(Duration::from_millis(100)).await;
915 }
916 panic!("run {run_id} did not reach a terminal status within ~5s");
917 }
918
919 #[tokio::test]
925 async fn detached_launch_returns_202_and_completes_in_background() {
926 let state = test_state();
927 let mut req = post_tasks_req("detached goal");
928 req.detach = true;
929
930 let reply = crate::tasks_start(State(state.clone()), Json(req))
931 .await
932 .expect("tasks_start (detached)");
933 assert_eq!(reply.1, StatusCode::ACCEPTED);
934 let posted = reply.0;
935 assert_eq!(posted.status, RunStatus::Running);
936 assert_eq!(
937 posted.final_ctx,
938 serde_json::Value::Null,
939 "a detached launch has no final_ctx at response time"
940 );
941
942 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
943 assert_eq!(rec.status, RunStatus::Done);
944 assert!(
945 rec.result_ref.is_some(),
946 "finalize_run must persist the background eval's final_ctx"
947 );
948 assert_eq!(
949 rec.step_entries.len(),
950 1,
951 "the background eval must trace its step_entries like the sync path: {:?}",
952 rec.step_entries
953 );
954 let task = state
955 .task_store
956 .get(&posted.task_id)
957 .await
958 .expect("task get");
959 assert_eq!(task.status, TaskRecordStatus::Done);
960 }
961
962 #[tokio::test]
966 async fn detached_launch_with_timeout_secs_rejected() {
967 let state = test_state();
968 let mut req = post_tasks_req("detached + ceiling goal");
969 req.detach = true;
970 req.timeout_secs = Some(60);
971
972 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
973 Err(e) => e,
974 Ok(_) => panic!("detach + timeout_secs must be rejected"),
975 };
976 assert_eq!(err.status, StatusCode::BAD_REQUEST);
977 assert!(
978 err.message.contains("detach"),
979 "error message must explain the detach/timeout_secs conflict: {}",
980 err.message
981 );
982 let tasks = state.task_store.list().await.expect("task list");
983 assert!(
984 tasks.is_empty(),
985 "the 400 must fire before any TaskRecord is minted"
986 );
987 }
988
989 #[tokio::test]
993 async fn rekick_detached_returns_202_and_completes_in_background() {
994 let state = test_state();
995 let posted = crate::tasks_start(
996 State(state.clone()),
997 Json(post_tasks_req("detached rekick goal")),
998 )
999 .await
1000 .expect("tasks_start")
1001 .0;
1002
1003 let (status, rekicked) = task_rekick(
1004 State(state.clone()),
1005 Path(posted.task_id.to_string()),
1006 Some(Json(RunKickRequest {
1007 init_ctx_override: None,
1008 task_input_override: None,
1009 timeout_secs: None,
1010 detach: true,
1011 })),
1012 )
1013 .await
1014 .expect("task_rekick (detached)");
1015 assert_eq!(status, StatusCode::ACCEPTED);
1016 assert_eq!(rekicked.0.status, RunStatus::Running);
1017 assert_ne!(rekicked.0.run_id, posted.run_id);
1018
1019 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1020 assert_eq!(rec.status, RunStatus::Done);
1021 assert!(
1022 rec.result_ref.is_some(),
1023 "finalize_run must persist the background rekick's final_ctx"
1024 );
1025 }
1026
1027 #[tokio::test]
1031 async fn rekick_detached_with_timeout_secs_rejected() {
1032 let state = test_state();
1033 let posted = crate::tasks_start(
1034 State(state.clone()),
1035 Json(post_tasks_req("detached rekick ceiling goal")),
1036 )
1037 .await
1038 .expect("tasks_start")
1039 .0;
1040
1041 let err = match task_rekick(
1042 State(state.clone()),
1043 Path(posted.task_id.to_string()),
1044 Some(Json(RunKickRequest {
1045 init_ctx_override: None,
1046 task_input_override: None,
1047 timeout_secs: Some(60),
1048 detach: true,
1049 })),
1050 )
1051 .await
1052 {
1053 Err(e) => e,
1054 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1055 };
1056 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1057 assert!(
1058 err.message.contains("detach"),
1059 "error message must explain the detach/timeout_secs conflict: {}",
1060 err.message
1061 );
1062 let runs = state
1063 .run_store
1064 .list_by_task(&posted.task_id)
1065 .await
1066 .expect("runs list");
1067 assert_eq!(
1068 runs.len(),
1069 1,
1070 "the 400 must fire before a second Run is minted"
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn rekick_adds_a_second_run_to_the_same_task() {
1076 let state = test_state();
1077 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1078 .await
1079 .expect("tasks_start")
1080 .0;
1081 let task_id = posted.task_id.clone();
1082 let first_run_id = posted.run_id.clone();
1083
1084 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1085 .await
1086 .expect("task_rekick");
1087 assert_eq!(status, StatusCode::CREATED);
1088 let second_run_id = rekicked.0.run_id.clone();
1089 assert_ne!(first_run_id, second_run_id);
1090
1091 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1092 .await
1093 .expect("task_get")
1094 .0;
1095 assert_eq!(
1096 detail.runs.len(),
1097 2,
1098 "expected 2 runs, got {:?}",
1099 detail.runs
1100 );
1101 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1102 assert!(ids.contains(&&first_run_id));
1103 assert!(ids.contains(&&second_run_id));
1104
1105 let first_run = detail
1110 .runs
1111 .iter()
1112 .find(|r| r.id == first_run_id)
1113 .expect("first run present in detail.runs");
1114 let second_run = detail
1115 .runs
1116 .iter()
1117 .find(|r| r.id == second_run_id)
1118 .expect("second run present in detail.runs");
1119 assert_eq!(
1120 first_run.step_entries.len(),
1121 1,
1122 "first run step_entries: {:?}",
1123 first_run.step_entries
1124 );
1125 assert_eq!(
1126 second_run.step_entries.len(),
1127 1,
1128 "second run step_entries: {:?}",
1129 second_run.step_entries
1130 );
1131 assert_eq!(
1132 first_run.step_entries[0].step_ref,
1133 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1134 );
1135 assert_eq!(
1136 second_run.step_entries[0].step_ref,
1137 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1138 );
1139 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1140 assert_eq!(
1141 second_run.step_entries[0].status,
1142 Some("passed".to_string())
1143 );
1144 assert_ne!(
1145 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1146 "each kick dispatches its own StepId — runs must not share step_entries"
1147 );
1148 }
1149
1150 #[tokio::test]
1151 async fn rekick_unknown_task_returns_404() {
1152 let state = test_state();
1153 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1157 Ok(_) => panic!("expected 404 for an unknown task"),
1158 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1159 }
1160 }
1161
1162 fn greeting_blueprint() -> Blueprint {
1171 Blueprint {
1172 schema_version: current_schema_version(),
1173 id: "tasks-test-greeting-bp".into(),
1174 flow: serde_json::from_value(serde_json::json!({
1175 "kind": "step",
1176 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1177 "in": {"op": "path", "at": "$.greeting"},
1178 "out": {"op": "path", "at": "$.out"},
1179 }))
1180 .expect("flow parse"),
1181 agents: vec![AgentDef {
1182 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1183 kind: AgentKind::RustFn,
1184 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1185 profile: None,
1186 meta: None,
1187 runner: None,
1188 runner_ref: None,
1189 verdict: None,
1190 }],
1191 operators: vec![],
1192 metas: vec![],
1193 hints: CompilerHints::default(),
1194 strategy: CompilerStrategy::default(),
1195 metadata: BlueprintMetadata::default(),
1196 spawner_hints: Default::default(),
1197 default_agent_kind: AgentKind::Operator,
1198 default_operator_kind: None,
1199 default_init_ctx: None,
1200 default_agent_ctx: None,
1201 default_context_policy: None,
1202 projection_placement: None,
1203 audits: vec![],
1204 degradation_policy: None,
1205 runners: vec![],
1206 default_runner: None,
1207 check_policy: None,
1208 }
1209 }
1210
1211 fn post_greeting_task_req(
1212 greeting: &str,
1213 project_root: Option<&str>,
1214 ) -> crate::TaskLaunchRequest {
1215 crate::TaskLaunchRequest {
1216 blueprint: BlueprintRef::Inline {
1217 value: Box::new(greeting_blueprint()),
1218 },
1219 init_ctx: serde_json::json!({ "greeting": greeting }),
1220 project_root: project_root.map(str::to_string),
1221 work_dir: None,
1222 task_metadata: None,
1223 ttl_secs: None,
1224 operator: None,
1225 operator_sid: None,
1226 timeout_secs: None,
1227 goal: Some("st4 rekick goal".to_string()),
1228 detach: false,
1229 check_policy: None,
1230 }
1231 }
1232
1233 #[tokio::test]
1234 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1235 let state = test_state();
1238 let posted = crate::tasks_start(
1239 State(state.clone()),
1240 Json(post_greeting_task_req("from-task", None)),
1241 )
1242 .await
1243 .expect("tasks_start")
1244 .0;
1245 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1246
1247 let (status, rekicked) =
1248 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1249 .await
1250 .expect("task_rekick");
1251 assert_eq!(status, StatusCode::CREATED);
1252
1253 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1254 .await
1255 .expect("run_get")
1256 .0;
1257 assert_eq!(
1258 run.result_ref.expect("result_ref present")["out"]["echoed"],
1259 "from-task"
1260 );
1261 }
1262
1263 #[tokio::test]
1264 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1265 let state = test_state();
1266 let posted = crate::tasks_start(
1267 State(state.clone()),
1268 Json(post_greeting_task_req("from-task", None)),
1269 )
1270 .await
1271 .expect("tasks_start")
1272 .0;
1273 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1274
1275 let (status, rekicked) = task_rekick(
1276 State(state.clone()),
1277 Path(posted.task_id.to_string()),
1278 Some(Json(RunKickRequest {
1279 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1280 task_input_override: None,
1281 timeout_secs: None,
1282 detach: false,
1283 })),
1284 )
1285 .await
1286 .expect("task_rekick");
1287 assert_eq!(status, StatusCode::CREATED);
1288
1289 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1290 .await
1291 .expect("run_get")
1292 .0;
1293 assert_eq!(
1294 run.result_ref.expect("result_ref present")["out"]["echoed"],
1295 "from-run",
1296 "Run's init_ctx_override must win over the stored Task input_ctx"
1297 );
1298 }
1299
1300 #[tokio::test]
1301 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1302 let state = test_state();
1310 let posted = crate::tasks_start(
1311 State(state.clone()),
1312 Json(post_greeting_task_req("from-task", Some("/repo"))),
1313 )
1314 .await
1315 .expect("tasks_start")
1316 .0;
1317
1318 let before = state
1319 .task_store
1320 .get(&posted.task_id)
1321 .await
1322 .expect("task fetch");
1323 let before_spec: Option<TaskInputSpec> = before
1324 .task_input_spec
1325 .as_ref()
1326 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1327 assert_eq!(
1328 before_spec,
1329 Some(TaskInputSpec {
1330 project_root: Some("/repo".to_string()),
1331 work_dir: None,
1332 task_metadata: None,
1333 })
1334 );
1335
1336 let (status, _rekicked) =
1337 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1338 .await
1339 .expect("task_rekick");
1340 assert_eq!(status, StatusCode::CREATED);
1341
1342 let after = state
1343 .task_store
1344 .get(&posted.task_id)
1345 .await
1346 .expect("task fetch");
1347 assert_eq!(
1348 after.task_input_spec, before.task_input_spec,
1349 "rekick must not mutate the stored Task-level task_input_spec snapshot"
1350 );
1351 }
1352
1353 #[tokio::test]
1354 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1355 let state = test_state();
1358 let posted = crate::tasks_start(
1359 State(state.clone()),
1360 Json(post_greeting_task_req("from-task", Some("/repo"))),
1361 )
1362 .await
1363 .expect("tasks_start")
1364 .0;
1365
1366 let (status, _rekicked) = task_rekick(
1367 State(state.clone()),
1368 Path(posted.task_id.to_string()),
1369 Some(Json(RunKickRequest {
1370 init_ctx_override: None,
1371 task_input_override: Some(TaskInputSpec {
1372 project_root: Some("/override".to_string()),
1373 work_dir: None,
1374 task_metadata: None,
1375 }),
1376 timeout_secs: None,
1377 detach: false,
1378 })),
1379 )
1380 .await
1381 .expect("task_rekick");
1382 assert_eq!(status, StatusCode::CREATED);
1383
1384 let after = state
1385 .task_store
1386 .get(&posted.task_id)
1387 .await
1388 .expect("task fetch");
1389 let after_spec: Option<TaskInputSpec> = after
1390 .task_input_spec
1391 .as_ref()
1392 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1393 assert_eq!(
1394 after_spec,
1395 Some(TaskInputSpec {
1396 project_root: Some("/repo".to_string()),
1397 work_dir: None,
1398 task_metadata: None,
1399 }),
1400 "a per-Run task_input_override must not leak into the stored TaskRecord"
1401 );
1402 }
1403
1404 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
1418 crate::TaskLaunchRequest {
1419 blueprint: BlueprintRef::Inline {
1420 value: Box::new(identity_blueprint_with_operator_delegate()),
1421 },
1422 init_ctx: serde_json::json!({"in": "hello"}),
1423 project_root: None,
1424 work_dir: None,
1425 task_metadata: None,
1426 ttl_secs: None,
1427 operator: None,
1428 operator_sid: None,
1429 timeout_secs: None,
1430 goal: Some(goal.to_string()),
1431 detach: false,
1432 check_policy: None,
1433 }
1434 }
1435
1436 #[tokio::test]
1441 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
1442 let state = test_state();
1443 let posted = crate::tasks_start(
1444 State(state.clone()),
1445 Json(delegate_launch_req("operator delegate rekick goal")),
1446 )
1447 .await
1448 .expect("tasks_start (no operator referenced, dispatches through baseline)")
1449 .0;
1450 let started = std::time::Instant::now();
1454 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1455 let elapsed = started.elapsed();
1456
1457 let err = match result {
1458 Err(e) => e,
1459 Ok(_) => panic!(
1460 "rekicking a Task whose Blueprint declares operator_delegate with zero \
1461 attached operators must fail, not dispatch"
1462 ),
1463 };
1464 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1465 assert!(
1466 err.message.contains("no operator attached"),
1467 "error message must mention the missing operator: {}",
1468 err.message
1469 );
1470 assert!(
1471 elapsed < Duration::from_secs(1),
1472 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1473 );
1474 }
1475
1476 #[tokio::test]
1480 async fn rekick_stalled_operator_times_out() {
1481 let state = test_state();
1482 state
1483 .engine
1484 .register_operator("stall-op", Arc::new(StallingOperator))
1485 .await;
1486 let posted = crate::tasks_start(
1487 State(state.clone()),
1488 Json(delegate_launch_req("stalled rekick goal")),
1489 )
1490 .await
1491 .expect("tasks_start")
1492 .0;
1493
1494 let started = std::time::Instant::now();
1495 let result = tokio::time::timeout(
1499 Duration::from_secs(5),
1500 task_rekick(
1501 State(state),
1502 Path(posted.task_id.to_string()),
1503 Some(Json(RunKickRequest {
1504 init_ctx_override: None,
1505 task_input_override: None,
1506 timeout_secs: Some(1),
1507 detach: false,
1508 })),
1509 ),
1510 )
1511 .await
1512 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
1513 let elapsed = started.elapsed();
1514
1515 match &result {
1516 Err(e) => {
1517 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
1518 assert!(
1519 e.message.contains('1'),
1520 "error message must mention the configured 1s ceiling: {}",
1521 e.message
1522 );
1523 assert!(
1524 elapsed < Duration::from_secs(3),
1525 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1526 );
1527 }
1528 Ok(_) => {
1529 assert!(
1541 elapsed < Duration::from_secs(1),
1542 "a rekick that never engages an Operator (task_rekick has no \
1543 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
1544 );
1545 }
1546 }
1547 }
1548
1549 #[tokio::test]
1553 async fn rekick_timeout_secs_zero_rejected() {
1554 let state = test_state();
1555 let posted = crate::tasks_start(
1556 State(state.clone()),
1557 Json(post_tasks_req("zero timeout rekick goal")),
1558 )
1559 .await
1560 .expect("tasks_start")
1561 .0;
1562
1563 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
1564 .await
1565 .expect("task_get")
1566 .0;
1567 let runs_before = before.runs.len();
1568
1569 let result = task_rekick(
1570 State(state.clone()),
1571 Path(posted.task_id.to_string()),
1572 Some(Json(RunKickRequest {
1573 init_ctx_override: None,
1574 task_input_override: None,
1575 timeout_secs: Some(0),
1576 detach: false,
1577 })),
1578 )
1579 .await;
1580 let err = match result {
1581 Err(e) => e,
1582 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1583 };
1584 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1585 assert!(
1586 err.message.contains("timeout_secs"),
1587 "error message must reference timeout_secs: {}",
1588 err.message
1589 );
1590
1591 let after = task_get(State(state), Path(posted.task_id.to_string()))
1592 .await
1593 .expect("task_get")
1594 .0;
1595 assert_eq!(
1596 after.runs.len(),
1597 runs_before,
1598 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
1599 );
1600 }
1601
1602 #[tokio::test]
1606 async fn rekick_non_operator_path_unaffected_by_guard_1() {
1607 let state = test_state();
1608 let posted = crate::tasks_start(
1609 State(state.clone()),
1610 Json(post_tasks_req("non-operator rekick goal")),
1611 )
1612 .await
1613 .expect("tasks_start")
1614 .0;
1615
1616 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1617 if let Err(e) = &result {
1618 panic!(
1619 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
1620 guard 1: {}",
1621 e.message
1622 );
1623 }
1624 }
1625
1626 #[tokio::test]
1627 async fn run_get_unknown_id_returns_404() {
1628 let state = test_state();
1629 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
1630 Ok(_) => panic!("expected 404 for an unknown run"),
1631 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1632 }
1633 }
1634
1635 #[tokio::test]
1636 async fn task_get_unknown_id_returns_404() {
1637 let state = test_state();
1638 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
1639 Ok(_) => panic!("expected 404 for an unknown task"),
1640 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1641 }
1642 }
1643}