1use axum::{
36 extract::{Path, Query, State},
37 http::StatusCode,
38 Json,
39};
40use mlua_swarm::application::{
41 BlueprintRef, TaskApplicationError, TaskApplicationInput, TaskApplicationOutput,
42};
43use mlua_swarm::core::config::CheckPolicy;
44use mlua_swarm::service::merge_init_ctx_3layer;
45use mlua_swarm::store::replay::ReplayCursor;
46use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError};
47use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
48use mlua_swarm::{OperatorKind, Role, RunId, TaskId, TaskInputSpec};
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51use std::collections::HashMap;
52use std::sync::{Arc, Mutex};
53use std::time::Duration;
54
55use crate::{ApiError, AppState};
56
57pub(crate) fn now_secs() -> u64 {
61 std::time::SystemTime::now()
62 .duration_since(std::time::UNIX_EPOCH)
63 .map(|d| d.as_secs())
64 .unwrap_or(0)
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
81pub(crate) struct RunLaunchSnapshot {
82 blueprint: BlueprintRef,
83 operator_id: String,
84 role: Role,
85 ttl: Duration,
86 init_ctx: Value,
87 operator_kind: Option<OperatorKind>,
88 bridge_id: Option<String>,
89 hook_id: Option<String>,
90 operator_backend_id: Option<String>,
91 #[serde(default)]
92 operator_kind_overrides: HashMap<String, OperatorKind>,
93 task_input: Option<TaskInputSpec>,
94 check_policy: Option<CheckPolicy>,
95}
96
97impl RunLaunchSnapshot {
98 fn from_input(input: &TaskApplicationInput) -> Self {
101 Self {
102 blueprint: input.blueprint.clone(),
103 operator_id: input.operator_id.clone(),
104 role: input.role,
105 ttl: input.ttl,
106 init_ctx: input.init_ctx.clone(),
107 operator_kind: input.operator_kind,
108 bridge_id: input.bridge_id.clone(),
109 hook_id: input.hook_id.clone(),
110 operator_backend_id: input.operator_backend_id.clone(),
111 operator_kind_overrides: input.operator_kind_overrides.clone(),
112 task_input: input.task_input.clone(),
113 check_policy: input.check_policy,
114 }
115 }
116
117 fn into_input(self) -> TaskApplicationInput {
119 TaskApplicationInput {
120 blueprint: self.blueprint,
121 operator_id: self.operator_id,
122 role: self.role,
123 ttl: self.ttl,
124 init_ctx: self.init_ctx,
125 operator_kind: self.operator_kind,
126 bridge_id: self.bridge_id,
127 hook_id: self.hook_id,
128 operator_backend_id: self.operator_backend_id,
129 operator_kind_overrides: self.operator_kind_overrides,
130 task_input: self.task_input,
131 check_policy: self.check_policy,
132 }
133 }
134}
135
136pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
143 serde_json::to_string(&RunLaunchSnapshot::from_input(input))
144 .map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
145}
146
147pub(crate) async fn finalize_run(
157 state: &AppState,
158 task_id: &TaskId,
159 run_id: &RunId,
160 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
161) -> Result<TaskApplicationOutput, TaskApplicationError> {
162 match &outcome {
163 Ok(out) => {
164 if let Err(e) = state
165 .run_store
166 .set_result(run_id, out.final_ctx.clone())
167 .await
168 {
169 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
170 }
171 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
172 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
173 }
174 if let Err(e) = state
175 .task_store
176 .update_status(task_id, TaskRecordStatus::Done)
177 .await
178 {
179 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
180 }
181 }
182 Err(e) => {
183 if let Err(store_err) = state
184 .run_store
185 .update_status(run_id, RunStatus::Failed)
186 .await
187 {
188 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
189 }
190 if let Err(store_err) = state
191 .task_store
192 .update_status(task_id, TaskRecordStatus::Failed)
193 .await
194 {
195 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
196 }
197 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
198 }
199 }
200 outcome
201}
202
203#[derive(Debug, Deserialize, Default)]
205pub struct TasksListQuery {
206 #[serde(default)]
209 pub limit: Option<usize>,
210}
211
212pub async fn tasks_list(
214 State(state): State<AppState>,
215 Query(q): Query<TasksListQuery>,
216) -> Result<Json<Vec<TaskRecord>>, ApiError> {
217 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
218 if let Some(limit) = q.limit {
219 records.truncate(limit);
220 }
221 Ok(Json(records))
222}
223
224#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
226pub struct TaskDetailResponse {
227 pub task: TaskRecord,
229 pub runs: Vec<RunRecord>,
231}
232
233pub async fn task_get(
236 State(state): State<AppState>,
237 Path(id): Path<String>,
238) -> Result<Json<TaskDetailResponse>, ApiError> {
239 let task_id =
240 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
241 let task = state
242 .task_store
243 .get(&task_id)
244 .await
245 .map_err(map_task_store_err)?;
246 let runs = state
247 .run_store
248 .list_by_task(&task_id)
249 .await
250 .map_err(ApiError::engine)?;
251 Ok(Json(TaskDetailResponse { task, runs }))
252}
253
254#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
260pub struct RunKickRequest {
261 #[serde(default)]
270 #[schemars(with = "Option<Value>")]
271 pub init_ctx_override: Option<Value>,
272 #[serde(default)]
279 pub task_input_override: Option<TaskInputSpec>,
280 #[serde(default)]
286 pub timeout_secs: Option<u64>,
287 #[serde(default)]
294 pub detach: bool,
295}
296
297#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
299pub struct RunKickResponse {
300 #[schemars(with = "String")]
302 pub task_id: TaskId,
303 #[schemars(with = "String")]
305 pub run_id: RunId,
306 pub status: RunStatus,
311}
312
313pub async fn task_rekick(
340 State(state): State<AppState>,
341 Path(id): Path<String>,
342 body: Option<Json<RunKickRequest>>,
343) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
344 let task_id =
345 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
346 let task = state
347 .task_store
348 .get(&task_id)
349 .await
350 .map_err(map_task_store_err)?;
351
352 let blueprint_ref: mlua_swarm::application::BlueprintRef =
353 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
354 ApiError::bad_request(format!(
355 "task {task_id}: stored blueprint_ref failed to decode: {e}"
356 ))
357 })?;
358
359 let (resolved_bp, _bound_version) = state
365 .task_app
366 .resolve(&blueprint_ref)
367 .await
368 .map_err(|e| ApiError::bad_request(format!("task {task_id}: bp resolve: {e}")))?;
369
370 let req = body.map(|Json(r)| r).unwrap_or_default();
371
372 let detach = req.detach;
382 let sync_timeout_secs = match (detach, req.timeout_secs) {
383 (true, Some(_)) => {
384 return Err(ApiError::bad_request(
385 "timeout_secs is the synchronous rekick ceiling and does not apply to a \
386 detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
387 timeout_secs"
388 .into(),
389 ));
390 }
391 (false, Some(0)) => {
392 return Err(ApiError::bad_request(
393 "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
394 ));
395 }
396 (false, Some(v)) => v,
397 (_, None) => state.sync_timeout_secs,
398 };
399
400 if resolved_bp
412 .spawner_hints
413 .layers
414 .iter()
415 .any(|l| l == "operator_delegate")
416 {
417 let attached = state.engine.list_operator_ids().await;
418 if attached.is_empty() {
419 return Err(ApiError::unavailable(format!(
420 "no operator attached to serve this rekick (task {task_id}'s \
421 Blueprint declares the operator_delegate layer): attach an \
422 operator via POST /v1/operators + WS, or use the poll-style \
423 flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
424 )));
425 }
426 }
427
428 let merged_init_ctx = merge_init_ctx_3layer(
429 resolved_bp.default_init_ctx.as_ref(),
430 &task.input_ctx,
431 req.init_ctx_override.as_ref(),
432 );
433
434 let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
438 Some(over) => Some(over),
439 None => task
440 .task_input_spec
441 .as_ref()
442 .map(|v| serde_json::from_value(v.clone()))
443 .transpose()
444 .map_err(|e| {
445 ApiError::bad_request(format!(
446 "task {task_id}: stored task_input_spec failed to decode: {e}"
447 ))
448 })?,
449 };
450
451 let run_id = RunId::new();
452 let now = now_secs();
453
454 let input = TaskApplicationInput {
455 blueprint: blueprint_ref,
456 operator_id: "http-run".to_string(),
457 role: Role::Operator,
458 ttl: Duration::from_secs(crate::default_run_ttl()),
459 init_ctx: merged_init_ctx,
460 operator_kind: None,
461 bridge_id: None,
462 hook_id: None,
463 operator_backend_id: None,
464 operator_kind_overrides: HashMap::new(),
465 task_input: task_input_spec,
466 check_policy: None,
470 };
471 let input_json = Some(snapshot_launch_input(&input)?);
476
477 state
478 .task_store
479 .update_status(&task_id, TaskRecordStatus::Running)
480 .await
481 .map_err(ApiError::engine)?;
482 state
483 .run_store
484 .create(RunRecord {
485 id: run_id.clone(),
486 task_id: task_id.clone(),
487 status: RunStatus::Running,
488 step_entries: Vec::new(),
489 degradations: Vec::new(),
490 operator_sid: None,
491 result_ref: None,
492 input_json,
493 created_at: now,
494 updated_at: now,
495 })
496 .await
497 .map_err(ApiError::engine)?;
498
499 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
500 .with_replay_store(state.replay_store.clone());
501
502 if detach {
508 let ttl_secs = crate::default_run_ttl();
509 let bg_state = state.clone();
510 let bg_task_id = task_id.clone();
511 let bg_run_id = run_id.clone();
512 tokio::spawn(async move {
513 let outcome = match tokio::time::timeout(
514 Duration::from_secs(ttl_secs),
515 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
516 )
517 .await
518 {
519 Ok(outcome) => outcome,
520 Err(_elapsed) => {
521 let reason = serde_json::json!({
522 "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
523 });
524 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
525 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
526 }
527 if let Err(e) = bg_state
528 .run_store
529 .update_status(&bg_run_id, RunStatus::Failed)
530 .await
531 {
532 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
533 }
534 if let Err(e) = bg_state
535 .task_store
536 .update_status(&bg_task_id, TaskRecordStatus::Failed)
537 .await
538 {
539 tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
540 }
541 return;
542 }
543 };
544 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
547 });
548 return Ok((
549 StatusCode::ACCEPTED,
550 Json(RunKickResponse {
551 task_id,
552 run_id,
553 status: RunStatus::Running,
554 }),
555 ));
556 }
557
558 let outcome = match tokio::time::timeout(
564 Duration::from_secs(sync_timeout_secs),
565 state.task_app.handle_with_run(input, Some(run_ctx)),
566 )
567 .await
568 {
569 Ok(outcome) => outcome,
570 Err(_elapsed) => {
571 let reason = serde_json::json!({
572 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
573 });
574 if let Err(e) = state.run_store.set_result(&run_id, reason).await {
575 tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
576 }
577 if let Err(e) = state
578 .run_store
579 .update_status(&run_id, RunStatus::Failed)
580 .await
581 {
582 tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
583 }
584 if let Err(e) = state
585 .task_store
586 .update_status(&task_id, TaskRecordStatus::Failed)
587 .await
588 {
589 tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
590 }
591 return Err(ApiError::timeout(format!(
592 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
593 )));
594 }
595 };
596 finalize_run(&state, &task_id, &run_id, outcome)
597 .await
598 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
599
600 Ok((
601 StatusCode::CREATED,
602 Json(RunKickResponse {
603 task_id,
604 run_id,
605 status: RunStatus::Done,
606 }),
607 ))
608}
609
610#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
612pub struct RunResumeResponse {
613 #[schemars(with = "String")]
618 pub run_id: RunId,
619 #[schemars(with = "String")]
621 pub task_id: TaskId,
622 pub replayed_steps: usize,
627}
628
629pub async fn run_resume(
655 State(state): State<AppState>,
656 Path(id): Path<String>,
657) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
658 let run_id =
659 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
660
661 let run = state
663 .run_store
664 .get(&run_id)
665 .await
666 .map_err(map_run_store_err)?;
667
668 if run.status != RunStatus::Interrupted {
670 return Err(ApiError::conflict(format!(
671 "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
672 run.status
673 )));
674 }
675
676 let Some(input_json) = run.input_json.clone() else {
681 return Err(ApiError::unprocessable(format!(
682 "run {run_id} cannot be resumed: no launch input was recorded for it (it \
683 predates resume support, or was created by a path that does not persist one)"
684 )));
685 };
686 let snapshot: RunLaunchSnapshot = serde_json::from_str(&input_json).map_err(|e| {
687 ApiError::bad_request(format!(
688 "run {run_id}: stored launch input failed to decode: {e}"
689 ))
690 })?;
691
692 let won = state
696 .run_store
697 .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
698 .await
699 .map_err(ApiError::engine)?;
700 if !won {
701 return Err(ApiError::conflict(format!(
702 "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
703 no longer resumable"
704 )));
705 }
706
707 let entries = state
711 .replay_store
712 .list_by_run(&run_id)
713 .await
714 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
715 let replayed_steps = entries.len();
716 let cursor = ReplayCursor::from_entries(entries);
717
718 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
721 .with_replay_store(state.replay_store.clone())
722 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
723
724 let input = snapshot.into_input();
725 let task_id = run.task_id.clone();
726
727 state
730 .task_store
731 .update_status(&task_id, TaskRecordStatus::Running)
732 .await
733 .map_err(ApiError::engine)?;
734
735 let ttl_secs = crate::default_run_ttl();
739 let bg_state = state.clone();
740 let bg_task_id = task_id.clone();
741 let bg_run_id = run_id.clone();
742 tokio::spawn(async move {
743 let outcome = match tokio::time::timeout(
744 Duration::from_secs(ttl_secs),
745 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
746 )
747 .await
748 {
749 Ok(outcome) => outcome,
750 Err(_elapsed) => {
751 let reason = serde_json::json!({
752 "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
753 });
754 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
755 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
756 }
757 if let Err(e) = bg_state
758 .run_store
759 .update_status(&bg_run_id, RunStatus::Failed)
760 .await
761 {
762 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
763 }
764 if let Err(e) = bg_state
765 .task_store
766 .update_status(&bg_task_id, TaskRecordStatus::Failed)
767 .await
768 {
769 tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
770 }
771 return;
772 }
773 };
774 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
776 });
777
778 Ok((
779 StatusCode::ACCEPTED,
780 Json(RunResumeResponse {
781 run_id,
782 task_id,
783 replayed_steps,
784 }),
785 ))
786}
787
788#[derive(Debug, Deserialize, schemars::JsonSchema)]
790pub struct RunRerunFromRequest {
791 pub from_step: String,
798}
799
800#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
802pub struct RunRerunFromResponse {
803 #[schemars(with = "String")]
808 pub run_id: RunId,
809 #[schemars(with = "String")]
811 pub task_id: TaskId,
812 pub replayed_steps: usize,
816 pub dropped_steps: usize,
819}
820
821pub async fn run_rerun_from(
896 State(state): State<AppState>,
897 Path(id): Path<String>,
898 Json(req): Json<RunRerunFromRequest>,
899) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
900 let run_id =
901 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
902
903 if req.from_step.trim().is_empty() {
904 return Err(ApiError::bad_request(
905 "from_step must be a non-empty step ref".to_string(),
906 ));
907 }
908
909 let run = state
911 .run_store
912 .get(&run_id)
913 .await
914 .map_err(map_run_store_err)?;
915
916 let current = run.status;
919 match current {
920 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => { }
921 RunStatus::Running | RunStatus::Pending => {
922 return Err(ApiError::conflict(format!(
923 "run {run_id} is {current:?}; rerun-from requires a terminal run \
924 (Done / Failed / Interrupted)"
925 )));
926 }
927 }
928
929 let Some(input_json) = run.input_json.clone() else {
934 return Err(ApiError::unprocessable(format!(
935 "run {run_id} cannot be rerun: no launch input was recorded for it (it \
936 predates resume/rerun support, or was created by a path that does not \
937 persist one)"
938 )));
939 };
940 let snapshot: RunLaunchSnapshot = serde_json::from_str(&input_json).map_err(|e| {
941 ApiError::bad_request(format!(
942 "run {run_id}: stored launch input failed to decode: {e}"
943 ))
944 })?;
945
946 let entries = state
949 .replay_store
950 .list_by_run(&run_id)
951 .await
952 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
953 let cut = entries
954 .iter()
955 .position(|e| e.step_ref == req.from_step)
956 .ok_or_else(|| {
957 if entries.is_empty() && !run.step_entries.is_empty() {
967 ApiError::unprocessable(format!(
968 "run {run_id}: replay log is empty but {} step entries are traced \
969 on the RunRecord — the log was consumed by a prior rerun-from \
970 that reached the truncate stage. This run can no longer be \
971 rerun-from; start a fresh run via POST /v1/tasks.",
972 run.step_entries.len()
973 ))
974 } else {
975 ApiError::unprocessable(format!(
976 "run {run_id}: from_step {:?} not present in this run's replay log \
977 (nothing to rerun-from)",
978 req.from_step
979 ))
980 }
981 })?;
982
983 if let Err(e) = state.task_app.precompile(&snapshot.blueprint).await {
997 return Err(ApiError::unprocessable(format!(
998 "run {run_id} cannot be rerun: current-head Blueprint fails to compile — {e}"
999 )));
1000 }
1001
1002 let won = state
1007 .run_store
1008 .try_transition(&run_id, current, RunStatus::Running)
1009 .await
1010 .map_err(ApiError::engine)?;
1011 if !won {
1012 return Err(ApiError::conflict(format!(
1013 "run {run_id} was concurrently transitioned (or left the {current:?} state); \
1014 it is no longer rerunnable"
1015 )));
1016 }
1017
1018 let dropped_steps = state
1023 .replay_store
1024 .delete_from(&run_id, cut)
1025 .await
1026 .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
1027
1028 let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
1031 let replayed_steps = kept.len();
1032 let cursor = ReplayCursor::from_entries(kept);
1033
1034 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1035 .with_replay_store(state.replay_store.clone())
1036 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
1037
1038 let input = snapshot.into_input();
1039 let task_id = run.task_id.clone();
1040
1041 state
1044 .task_store
1045 .update_status(&task_id, TaskRecordStatus::Running)
1046 .await
1047 .map_err(ApiError::engine)?;
1048
1049 let ttl_secs = crate::default_run_ttl();
1050 let bg_state = state.clone();
1051 let bg_task_id = task_id.clone();
1052 let bg_run_id = run_id.clone();
1053 tokio::spawn(async move {
1054 let outcome = match tokio::time::timeout(
1055 Duration::from_secs(ttl_secs),
1056 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1057 )
1058 .await
1059 {
1060 Ok(outcome) => outcome,
1061 Err(_elapsed) => {
1062 let reason = serde_json::json!({
1063 "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1064 });
1065 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1066 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1067 }
1068 if let Err(e) = bg_state
1069 .run_store
1070 .update_status(&bg_run_id, RunStatus::Failed)
1071 .await
1072 {
1073 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1074 }
1075 if let Err(e) = bg_state
1076 .task_store
1077 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1078 .await
1079 {
1080 tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1081 }
1082 return;
1083 }
1084 };
1085 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1086 });
1087
1088 Ok((
1089 StatusCode::ACCEPTED,
1090 Json(RunRerunFromResponse {
1091 run_id,
1092 task_id,
1093 replayed_steps,
1094 dropped_steps,
1095 }),
1096 ))
1097}
1098
1099pub async fn run_get(
1102 State(state): State<AppState>,
1103 Path(id): Path<String>,
1104) -> Result<Json<RunRecord>, ApiError> {
1105 let run_id =
1106 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1107 let run = state
1108 .run_store
1109 .get(&run_id)
1110 .await
1111 .map_err(map_run_store_err)?;
1112 Ok(Json(run))
1113}
1114
1115pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1119 match e {
1120 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1121 other => ApiError::engine(other),
1122 }
1123}
1124
1125fn map_run_store_err(e: RunStoreError) -> ApiError {
1126 match e {
1127 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1128 other => ApiError::engine(other),
1129 }
1130}
1131
1132#[cfg(test)]
1137mod tests {
1138 use super::*;
1139 use mlua_swarm::application::BlueprintRef;
1140 use mlua_swarm::blueprint::{
1141 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1142 CompilerStrategy,
1143 };
1144 use mlua_swarm::core::config::EngineCfg;
1145 use mlua_swarm::core::engine::Engine;
1146 use mlua_swarm::store::output::InMemoryOutputStore;
1147 use mlua_swarm::store::run::InMemoryRunStore;
1148 use mlua_swarm::store::task::InMemoryTaskStore;
1149 use std::collections::HashMap;
1150 use std::sync::Arc;
1151 use tokio::sync::Mutex;
1152
1153 fn identity_blueprint() -> Blueprint {
1159 Blueprint {
1160 schema_version: current_schema_version(),
1161 id: "tasks-test-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": "lit", "value": "hello"},
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 runner: None,
1176 runner_ref: None,
1177 verdict: None,
1178 }],
1179 operators: vec![],
1180 metas: vec![],
1181 hints: CompilerHints::default(),
1182 strategy: CompilerStrategy::default(),
1183 metadata: BlueprintMetadata::default(),
1184 spawner_hints: Default::default(),
1185 default_agent_kind: AgentKind::Operator,
1186 default_operator_kind: None,
1187 default_init_ctx: None,
1188 default_agent_ctx: None,
1189 default_context_policy: None,
1190 projection_placement: None,
1191 audits: vec![],
1192 degradation_policy: None,
1193 runners: vec![],
1194 default_runner: None,
1195 check_policy: None,
1196 blueprint_ref_includes: Vec::new(),
1197 }
1198 }
1199
1200 fn test_state() -> AppState {
1205 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1206 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1207 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1208 AppState {
1209 engine,
1210 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1211 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1212 ws_operator_factory: None,
1213 data_store: Arc::new(InMemoryOutputStore::new()),
1214 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1215 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1216 task_store: Arc::new(InMemoryTaskStore::new()),
1217 run_store: Arc::new(InMemoryRunStore::new()),
1218 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1219 base_url: None,
1220 sync_timeout_secs: 300,
1221 }
1222 }
1223
1224 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
1225 crate::TaskLaunchRequest {
1226 blueprint: BlueprintRef::Inline {
1227 value: Box::new(identity_blueprint()),
1228 },
1229 init_ctx: serde_json::json!({"in": "hello"}),
1230 project_root: None,
1231 work_dir: None,
1232 task_metadata: None,
1233 ttl_secs: None,
1234 operator: None,
1235 operator_sid: None,
1236 timeout_secs: None,
1237 goal: Some(goal.to_string()),
1238 detach: false,
1239 check_policy: None,
1240 }
1241 }
1242
1243 #[test]
1244 fn task_id_serializes_as_bare_string() {
1245 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
1249 assert_eq!(v, serde_json::json!("T-abc"));
1250 }
1251
1252 #[tokio::test]
1253 async fn post_then_get_drill_down() {
1254 let state = test_state();
1255
1256 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
1257 .await
1258 .expect("tasks_start")
1259 .0;
1260 let task_id = posted.task_id.clone();
1261 let run_id = posted.run_id.clone();
1262
1263 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
1265 .await
1266 .expect("tasks_list")
1267 .0;
1268 assert!(
1269 list.iter().any(|t| t.id == task_id),
1270 "task {task_id} missing from list of {} tasks",
1271 list.len()
1272 );
1273
1274 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1276 .await
1277 .expect("task_get")
1278 .0;
1279 assert_eq!(detail.task.id, task_id);
1280 assert_eq!(detail.task.goal, "smoke goal");
1281 assert_eq!(detail.task.status, TaskRecordStatus::Done);
1282 assert_eq!(detail.runs.len(), 1);
1283 assert_eq!(detail.runs[0].id, run_id);
1284 assert_eq!(detail.runs[0].status, RunStatus::Done);
1285
1286 let run = run_get(State(state.clone()), Path(run_id.to_string()))
1288 .await
1289 .expect("run_get")
1290 .0;
1291 assert_eq!(run.id, run_id);
1292 assert_eq!(run.task_id, task_id);
1293 assert_eq!(run.result_ref, Some(posted.final_ctx));
1294
1295 assert_eq!(
1299 run.step_entries.len(),
1300 1,
1301 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
1302 run.step_entries
1303 );
1304 assert_eq!(
1305 run.step_entries[0].step_ref,
1306 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1307 );
1308 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1309 }
1310
1311 fn identity_blueprint_with_operator_delegate() -> Blueprint {
1323 Blueprint {
1324 spawner_hints: mlua_swarm::SpawnerHints {
1325 layers: vec!["operator_delegate".to_string()],
1326 },
1327 ..identity_blueprint()
1328 }
1329 }
1330
1331 struct StallingOperator;
1334
1335 #[async_trait::async_trait]
1336 impl mlua_swarm::Operator for StallingOperator {
1337 async fn execute(
1338 &self,
1339 _ctx: &mlua_swarm::Ctx,
1340 _system: Option<String>,
1341 _prompt: Value,
1342 _worker: Option<mlua_swarm::WorkerBinding>,
1343 _worker_token: mlua_swarm::CapToken,
1344 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1345 std::future::pending::<()>().await;
1346 unreachable!("StallingOperator.execute must never resolve")
1347 }
1348 }
1349
1350 fn operator_launch_req(
1354 backend_id: &str,
1355 timeout_secs: Option<u64>,
1356 ) -> crate::TaskLaunchRequest {
1357 crate::TaskLaunchRequest {
1358 blueprint: BlueprintRef::Inline {
1359 value: Box::new(identity_blueprint_with_operator_delegate()),
1360 },
1361 init_ctx: serde_json::json!({"in": "hello"}),
1362 project_root: None,
1363 work_dir: None,
1364 task_metadata: None,
1365 ttl_secs: None,
1366 operator: Some(crate::OperatorReq {
1367 operator_backend_id: Some(backend_id.to_string()),
1368 ..Default::default()
1369 }),
1370 operator_sid: None,
1371 timeout_secs,
1372 goal: Some("operator delegate test goal".to_string()),
1373 detach: false,
1374 check_policy: None,
1375 }
1376 }
1377
1378 #[tokio::test]
1382 async fn sync_launch_zero_operators_fails_fast() {
1383 let state = test_state();
1384 let req = operator_launch_req("nonexistent-op", None);
1387
1388 let started = std::time::Instant::now();
1389 let result = crate::tasks_start(State(state), Json(req)).await;
1390 let elapsed = started.elapsed();
1391
1392 let err = match result {
1393 Err(e) => e,
1394 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1395 };
1396 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1397 assert!(
1398 err.message.contains("no operator attached"),
1399 "error message must mention the missing operator: {}",
1400 err.message
1401 );
1402 assert!(
1403 elapsed < Duration::from_secs(1),
1404 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1405 );
1406 }
1407
1408 #[tokio::test]
1412 async fn sync_launch_stalled_times_out() {
1413 let state = test_state();
1414 state
1415 .engine
1416 .register_operator("stall-op", Arc::new(StallingOperator))
1417 .await;
1418 let req = operator_launch_req("stall-op", Some(1));
1419
1420 let started = std::time::Instant::now();
1421 let result = tokio::time::timeout(
1425 Duration::from_secs(5),
1426 crate::tasks_start(State(state), Json(req)),
1427 )
1428 .await
1429 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1430 let elapsed = started.elapsed();
1431
1432 let err = match result {
1433 Err(e) => e,
1434 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1435 };
1436 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1437 assert!(
1438 err.message.contains('1'),
1439 "error message must mention the configured 1s ceiling: {}",
1440 err.message
1441 );
1442 assert!(
1443 elapsed < Duration::from_secs(3),
1444 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1445 );
1446 }
1447
1448 #[tokio::test]
1452 async fn sync_launch_without_operator_path_unaffected() {
1453 let state = test_state();
1454 let result = crate::tasks_start(
1455 State(state),
1456 Json(post_tasks_req("non-operator launch goal")),
1457 )
1458 .await;
1459 if let Err(e) = &result {
1460 panic!(
1461 "non-operator launch must succeed unaffected by guard 1: {}",
1462 e.message
1463 );
1464 }
1465 }
1466
1467 #[tokio::test]
1471 async fn sync_launch_zero_timeout_secs_rejected() {
1472 let state = test_state();
1473 let mut req = post_tasks_req("zero timeout goal");
1474 req.timeout_secs = Some(0);
1475
1476 let result = crate::tasks_start(State(state), Json(req)).await;
1477 let err = match result {
1478 Err(e) => e,
1479 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1480 };
1481 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1482 assert!(
1483 err.message.contains("timeout_secs"),
1484 "error message must reference timeout_secs: {}",
1485 err.message
1486 );
1487 }
1488
1489 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1498 for _ in 0..50 {
1499 let rec = state.run_store.get(run_id).await.expect("run get");
1500 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1501 return rec;
1502 }
1503 tokio::time::sleep(Duration::from_millis(100)).await;
1504 }
1505 panic!("run {run_id} did not reach a terminal status within ~5s");
1506 }
1507
1508 #[tokio::test]
1514 async fn detached_launch_returns_202_and_completes_in_background() {
1515 let state = test_state();
1516 let mut req = post_tasks_req("detached goal");
1517 req.detach = true;
1518
1519 let reply = crate::tasks_start(State(state.clone()), Json(req))
1520 .await
1521 .expect("tasks_start (detached)");
1522 assert_eq!(reply.1, StatusCode::ACCEPTED);
1523 let posted = reply.0;
1524 assert_eq!(posted.status, RunStatus::Running);
1525 assert_eq!(
1526 posted.final_ctx,
1527 serde_json::Value::Null,
1528 "a detached launch has no final_ctx at response time"
1529 );
1530
1531 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1532 assert_eq!(rec.status, RunStatus::Done);
1533 assert!(
1534 rec.result_ref.is_some(),
1535 "finalize_run must persist the background eval's final_ctx"
1536 );
1537 assert_eq!(
1538 rec.step_entries.len(),
1539 1,
1540 "the background eval must trace its step_entries like the sync path: {:?}",
1541 rec.step_entries
1542 );
1543 let task = state
1544 .task_store
1545 .get(&posted.task_id)
1546 .await
1547 .expect("task get");
1548 assert_eq!(task.status, TaskRecordStatus::Done);
1549 }
1550
1551 #[tokio::test]
1555 async fn detached_launch_with_timeout_secs_rejected() {
1556 let state = test_state();
1557 let mut req = post_tasks_req("detached + ceiling goal");
1558 req.detach = true;
1559 req.timeout_secs = Some(60);
1560
1561 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1562 Err(e) => e,
1563 Ok(_) => panic!("detach + timeout_secs must be rejected"),
1564 };
1565 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1566 assert!(
1567 err.message.contains("detach"),
1568 "error message must explain the detach/timeout_secs conflict: {}",
1569 err.message
1570 );
1571 let tasks = state.task_store.list().await.expect("task list");
1572 assert!(
1573 tasks.is_empty(),
1574 "the 400 must fire before any TaskRecord is minted"
1575 );
1576 }
1577
1578 #[tokio::test]
1582 async fn rekick_detached_returns_202_and_completes_in_background() {
1583 let state = test_state();
1584 let posted = crate::tasks_start(
1585 State(state.clone()),
1586 Json(post_tasks_req("detached rekick goal")),
1587 )
1588 .await
1589 .expect("tasks_start")
1590 .0;
1591
1592 let (status, rekicked) = task_rekick(
1593 State(state.clone()),
1594 Path(posted.task_id.to_string()),
1595 Some(Json(RunKickRequest {
1596 init_ctx_override: None,
1597 task_input_override: None,
1598 timeout_secs: None,
1599 detach: true,
1600 })),
1601 )
1602 .await
1603 .expect("task_rekick (detached)");
1604 assert_eq!(status, StatusCode::ACCEPTED);
1605 assert_eq!(rekicked.0.status, RunStatus::Running);
1606 assert_ne!(rekicked.0.run_id, posted.run_id);
1607
1608 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1609 assert_eq!(rec.status, RunStatus::Done);
1610 assert!(
1611 rec.result_ref.is_some(),
1612 "finalize_run must persist the background rekick's final_ctx"
1613 );
1614 }
1615
1616 #[tokio::test]
1620 async fn rekick_detached_with_timeout_secs_rejected() {
1621 let state = test_state();
1622 let posted = crate::tasks_start(
1623 State(state.clone()),
1624 Json(post_tasks_req("detached rekick ceiling goal")),
1625 )
1626 .await
1627 .expect("tasks_start")
1628 .0;
1629
1630 let err = match task_rekick(
1631 State(state.clone()),
1632 Path(posted.task_id.to_string()),
1633 Some(Json(RunKickRequest {
1634 init_ctx_override: None,
1635 task_input_override: None,
1636 timeout_secs: Some(60),
1637 detach: true,
1638 })),
1639 )
1640 .await
1641 {
1642 Err(e) => e,
1643 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1644 };
1645 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1646 assert!(
1647 err.message.contains("detach"),
1648 "error message must explain the detach/timeout_secs conflict: {}",
1649 err.message
1650 );
1651 let runs = state
1652 .run_store
1653 .list_by_task(&posted.task_id)
1654 .await
1655 .expect("runs list");
1656 assert_eq!(
1657 runs.len(),
1658 1,
1659 "the 400 must fire before a second Run is minted"
1660 );
1661 }
1662
1663 #[tokio::test]
1664 async fn rekick_adds_a_second_run_to_the_same_task() {
1665 let state = test_state();
1666 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1667 .await
1668 .expect("tasks_start")
1669 .0;
1670 let task_id = posted.task_id.clone();
1671 let first_run_id = posted.run_id.clone();
1672
1673 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1674 .await
1675 .expect("task_rekick");
1676 assert_eq!(status, StatusCode::CREATED);
1677 let second_run_id = rekicked.0.run_id.clone();
1678 assert_ne!(first_run_id, second_run_id);
1679
1680 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1681 .await
1682 .expect("task_get")
1683 .0;
1684 assert_eq!(
1685 detail.runs.len(),
1686 2,
1687 "expected 2 runs, got {:?}",
1688 detail.runs
1689 );
1690 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1691 assert!(ids.contains(&&first_run_id));
1692 assert!(ids.contains(&&second_run_id));
1693
1694 let first_run = detail
1699 .runs
1700 .iter()
1701 .find(|r| r.id == first_run_id)
1702 .expect("first run present in detail.runs");
1703 let second_run = detail
1704 .runs
1705 .iter()
1706 .find(|r| r.id == second_run_id)
1707 .expect("second run present in detail.runs");
1708 assert_eq!(
1709 first_run.step_entries.len(),
1710 1,
1711 "first run step_entries: {:?}",
1712 first_run.step_entries
1713 );
1714 assert_eq!(
1715 second_run.step_entries.len(),
1716 1,
1717 "second run step_entries: {:?}",
1718 second_run.step_entries
1719 );
1720 assert_eq!(
1721 first_run.step_entries[0].step_ref,
1722 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1723 );
1724 assert_eq!(
1725 second_run.step_entries[0].step_ref,
1726 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1727 );
1728 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1729 assert_eq!(
1730 second_run.step_entries[0].status,
1731 Some("passed".to_string())
1732 );
1733 assert_ne!(
1734 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1735 "each kick dispatches its own StepId — runs must not share step_entries"
1736 );
1737 }
1738
1739 #[tokio::test]
1740 async fn rekick_unknown_task_returns_404() {
1741 let state = test_state();
1742 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1746 Ok(_) => panic!("expected 404 for an unknown task"),
1747 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1748 }
1749 }
1750
1751 fn greeting_blueprint() -> Blueprint {
1760 Blueprint {
1761 schema_version: current_schema_version(),
1762 id: "tasks-test-greeting-bp".into(),
1763 flow: serde_json::from_value(serde_json::json!({
1764 "kind": "step",
1765 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1766 "in": {"op": "path", "at": "$.greeting"},
1767 "out": {"op": "path", "at": "$.out"},
1768 }))
1769 .expect("flow parse"),
1770 agents: vec![AgentDef {
1771 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1772 kind: AgentKind::RustFn,
1773 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1774 profile: None,
1775 meta: None,
1776 runner: None,
1777 runner_ref: None,
1778 verdict: None,
1779 }],
1780 operators: vec![],
1781 metas: vec![],
1782 hints: CompilerHints::default(),
1783 strategy: CompilerStrategy::default(),
1784 metadata: BlueprintMetadata::default(),
1785 spawner_hints: Default::default(),
1786 default_agent_kind: AgentKind::Operator,
1787 default_operator_kind: None,
1788 default_init_ctx: None,
1789 default_agent_ctx: None,
1790 default_context_policy: None,
1791 projection_placement: None,
1792 audits: vec![],
1793 degradation_policy: None,
1794 runners: vec![],
1795 default_runner: None,
1796 check_policy: None,
1797 blueprint_ref_includes: Vec::new(),
1798 }
1799 }
1800
1801 fn post_greeting_task_req(
1802 greeting: &str,
1803 project_root: Option<&str>,
1804 ) -> crate::TaskLaunchRequest {
1805 crate::TaskLaunchRequest {
1806 blueprint: BlueprintRef::Inline {
1807 value: Box::new(greeting_blueprint()),
1808 },
1809 init_ctx: serde_json::json!({ "greeting": greeting }),
1810 project_root: project_root.map(str::to_string),
1811 work_dir: None,
1812 task_metadata: None,
1813 ttl_secs: None,
1814 operator: None,
1815 operator_sid: None,
1816 timeout_secs: None,
1817 goal: Some("st4 rekick goal".to_string()),
1818 detach: false,
1819 check_policy: None,
1820 }
1821 }
1822
1823 #[tokio::test]
1824 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1825 let state = test_state();
1828 let posted = crate::tasks_start(
1829 State(state.clone()),
1830 Json(post_greeting_task_req("from-task", None)),
1831 )
1832 .await
1833 .expect("tasks_start")
1834 .0;
1835 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1836
1837 let (status, rekicked) =
1838 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1839 .await
1840 .expect("task_rekick");
1841 assert_eq!(status, StatusCode::CREATED);
1842
1843 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1844 .await
1845 .expect("run_get")
1846 .0;
1847 assert_eq!(
1848 run.result_ref.expect("result_ref present")["out"]["echoed"],
1849 "from-task"
1850 );
1851 }
1852
1853 #[tokio::test]
1854 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1855 let state = test_state();
1856 let posted = crate::tasks_start(
1857 State(state.clone()),
1858 Json(post_greeting_task_req("from-task", None)),
1859 )
1860 .await
1861 .expect("tasks_start")
1862 .0;
1863 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1864
1865 let (status, rekicked) = task_rekick(
1866 State(state.clone()),
1867 Path(posted.task_id.to_string()),
1868 Some(Json(RunKickRequest {
1869 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1870 task_input_override: None,
1871 timeout_secs: None,
1872 detach: false,
1873 })),
1874 )
1875 .await
1876 .expect("task_rekick");
1877 assert_eq!(status, StatusCode::CREATED);
1878
1879 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1880 .await
1881 .expect("run_get")
1882 .0;
1883 assert_eq!(
1884 run.result_ref.expect("result_ref present")["out"]["echoed"],
1885 "from-run",
1886 "Run's init_ctx_override must win over the stored Task input_ctx"
1887 );
1888 }
1889
1890 #[tokio::test]
1891 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1892 let state = test_state();
1900 let posted = crate::tasks_start(
1901 State(state.clone()),
1902 Json(post_greeting_task_req("from-task", Some("/repo"))),
1903 )
1904 .await
1905 .expect("tasks_start")
1906 .0;
1907
1908 let before = state
1909 .task_store
1910 .get(&posted.task_id)
1911 .await
1912 .expect("task fetch");
1913 let before_spec: Option<TaskInputSpec> = before
1914 .task_input_spec
1915 .as_ref()
1916 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1917 assert_eq!(
1918 before_spec,
1919 Some(TaskInputSpec {
1920 project_root: Some("/repo".to_string()),
1921 work_dir: None,
1922 task_metadata: None,
1923 })
1924 );
1925
1926 let (status, _rekicked) =
1927 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1928 .await
1929 .expect("task_rekick");
1930 assert_eq!(status, StatusCode::CREATED);
1931
1932 let after = state
1933 .task_store
1934 .get(&posted.task_id)
1935 .await
1936 .expect("task fetch");
1937 assert_eq!(
1938 after.task_input_spec, before.task_input_spec,
1939 "rekick must not mutate the stored Task-level task_input_spec snapshot"
1940 );
1941 }
1942
1943 #[tokio::test]
1944 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1945 let state = test_state();
1948 let posted = crate::tasks_start(
1949 State(state.clone()),
1950 Json(post_greeting_task_req("from-task", Some("/repo"))),
1951 )
1952 .await
1953 .expect("tasks_start")
1954 .0;
1955
1956 let (status, _rekicked) = task_rekick(
1957 State(state.clone()),
1958 Path(posted.task_id.to_string()),
1959 Some(Json(RunKickRequest {
1960 init_ctx_override: None,
1961 task_input_override: Some(TaskInputSpec {
1962 project_root: Some("/override".to_string()),
1963 work_dir: None,
1964 task_metadata: None,
1965 }),
1966 timeout_secs: None,
1967 detach: false,
1968 })),
1969 )
1970 .await
1971 .expect("task_rekick");
1972 assert_eq!(status, StatusCode::CREATED);
1973
1974 let after = state
1975 .task_store
1976 .get(&posted.task_id)
1977 .await
1978 .expect("task fetch");
1979 let after_spec: Option<TaskInputSpec> = after
1980 .task_input_spec
1981 .as_ref()
1982 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1983 assert_eq!(
1984 after_spec,
1985 Some(TaskInputSpec {
1986 project_root: Some("/repo".to_string()),
1987 work_dir: None,
1988 task_metadata: None,
1989 }),
1990 "a per-Run task_input_override must not leak into the stored TaskRecord"
1991 );
1992 }
1993
1994 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
2008 crate::TaskLaunchRequest {
2009 blueprint: BlueprintRef::Inline {
2010 value: Box::new(identity_blueprint_with_operator_delegate()),
2011 },
2012 init_ctx: serde_json::json!({"in": "hello"}),
2013 project_root: None,
2014 work_dir: None,
2015 task_metadata: None,
2016 ttl_secs: None,
2017 operator: None,
2018 operator_sid: None,
2019 timeout_secs: None,
2020 goal: Some(goal.to_string()),
2021 detach: false,
2022 check_policy: None,
2023 }
2024 }
2025
2026 #[tokio::test]
2031 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
2032 let state = test_state();
2033 let posted = crate::tasks_start(
2034 State(state.clone()),
2035 Json(delegate_launch_req("operator delegate rekick goal")),
2036 )
2037 .await
2038 .expect("tasks_start (no operator referenced, dispatches through baseline)")
2039 .0;
2040 let started = std::time::Instant::now();
2044 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2045 let elapsed = started.elapsed();
2046
2047 let err = match result {
2048 Err(e) => e,
2049 Ok(_) => panic!(
2050 "rekicking a Task whose Blueprint declares operator_delegate with zero \
2051 attached operators must fail, not dispatch"
2052 ),
2053 };
2054 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2055 assert!(
2056 err.message.contains("no operator attached"),
2057 "error message must mention the missing operator: {}",
2058 err.message
2059 );
2060 assert!(
2061 elapsed < Duration::from_secs(1),
2062 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2063 );
2064 }
2065
2066 #[tokio::test]
2070 async fn rekick_stalled_operator_times_out() {
2071 let state = test_state();
2072 state
2073 .engine
2074 .register_operator("stall-op", Arc::new(StallingOperator))
2075 .await;
2076 let posted = crate::tasks_start(
2077 State(state.clone()),
2078 Json(delegate_launch_req("stalled rekick goal")),
2079 )
2080 .await
2081 .expect("tasks_start")
2082 .0;
2083
2084 let started = std::time::Instant::now();
2085 let result = tokio::time::timeout(
2089 Duration::from_secs(5),
2090 task_rekick(
2091 State(state),
2092 Path(posted.task_id.to_string()),
2093 Some(Json(RunKickRequest {
2094 init_ctx_override: None,
2095 task_input_override: None,
2096 timeout_secs: Some(1),
2097 detach: false,
2098 })),
2099 ),
2100 )
2101 .await
2102 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
2103 let elapsed = started.elapsed();
2104
2105 match &result {
2106 Err(e) => {
2107 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
2108 assert!(
2109 e.message.contains('1'),
2110 "error message must mention the configured 1s ceiling: {}",
2111 e.message
2112 );
2113 assert!(
2114 elapsed < Duration::from_secs(3),
2115 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2116 );
2117 }
2118 Ok(_) => {
2119 assert!(
2131 elapsed < Duration::from_secs(1),
2132 "a rekick that never engages an Operator (task_rekick has no \
2133 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
2134 );
2135 }
2136 }
2137 }
2138
2139 #[tokio::test]
2143 async fn rekick_timeout_secs_zero_rejected() {
2144 let state = test_state();
2145 let posted = crate::tasks_start(
2146 State(state.clone()),
2147 Json(post_tasks_req("zero timeout rekick goal")),
2148 )
2149 .await
2150 .expect("tasks_start")
2151 .0;
2152
2153 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2154 .await
2155 .expect("task_get")
2156 .0;
2157 let runs_before = before.runs.len();
2158
2159 let result = task_rekick(
2160 State(state.clone()),
2161 Path(posted.task_id.to_string()),
2162 Some(Json(RunKickRequest {
2163 init_ctx_override: None,
2164 task_input_override: None,
2165 timeout_secs: Some(0),
2166 detach: false,
2167 })),
2168 )
2169 .await;
2170 let err = match result {
2171 Err(e) => e,
2172 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2173 };
2174 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2175 assert!(
2176 err.message.contains("timeout_secs"),
2177 "error message must reference timeout_secs: {}",
2178 err.message
2179 );
2180
2181 let after = task_get(State(state), Path(posted.task_id.to_string()))
2182 .await
2183 .expect("task_get")
2184 .0;
2185 assert_eq!(
2186 after.runs.len(),
2187 runs_before,
2188 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
2189 );
2190 }
2191
2192 #[tokio::test]
2196 async fn rekick_non_operator_path_unaffected_by_guard_1() {
2197 let state = test_state();
2198 let posted = crate::tasks_start(
2199 State(state.clone()),
2200 Json(post_tasks_req("non-operator rekick goal")),
2201 )
2202 .await
2203 .expect("tasks_start")
2204 .0;
2205
2206 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2207 if let Err(e) = &result {
2208 panic!(
2209 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
2210 guard 1: {}",
2211 e.message
2212 );
2213 }
2214 }
2215
2216 #[tokio::test]
2217 async fn run_get_unknown_id_returns_404() {
2218 let state = test_state();
2219 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
2220 Ok(_) => panic!("expected 404 for an unknown run"),
2221 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2222 }
2223 }
2224
2225 #[tokio::test]
2226 async fn task_get_unknown_id_returns_404() {
2227 let state = test_state();
2228 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
2229 Ok(_) => panic!("expected 404 for an unknown task"),
2230 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2231 }
2232 }
2233}