1use axum::{
38 extract::{Path, Query, State},
39 http::StatusCode,
40 Json,
41};
42use mlua_swarm::application::{
43 BlueprintRef, TaskApplicationError, TaskApplicationInput, TaskApplicationOutput,
44};
45use mlua_swarm::blueprint::{BindRequest, BindingAttestation, BoundAgent};
46use mlua_swarm::core::config::CheckPolicy;
47use mlua_swarm::service::merge_init_ctx_3layer;
48use mlua_swarm::service::TaskLaunchError;
49use mlua_swarm::store::replay::ReplayCursor;
50use mlua_swarm::store::run::{
51 RunContext, RunListFilter, RunRecord, RunStatus, RunStoreError, SnapshotOrigin, StepEntry,
52};
53use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
54use mlua_swarm::store::trace::{kind as trace_kind, TraceEvent, TraceHandle, TraceQuery};
55use mlua_swarm::{
56 validate_bound_agent_snapshots, OperatorKind, Role, RunId, TaskId, TaskInputSpec,
57};
58use serde::{Deserialize, Serialize};
59use serde_json::{json, Value};
60use std::collections::HashMap;
61use std::sync::{Arc, Mutex};
62use std::time::Duration;
63
64use crate::{ApiError, AppState};
65
66pub(crate) fn now_secs() -> u64 {
70 std::time::SystemTime::now()
71 .duration_since(std::time::UNIX_EPOCH)
72 .map(|d| d.as_secs())
73 .unwrap_or(0)
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
90pub(crate) struct RunLaunchSnapshot {
91 blueprint: BlueprintRef,
92 operator_id: String,
93 role: Role,
94 ttl: Duration,
95 init_ctx: Value,
96 operator_kind: Option<OperatorKind>,
97 bridge_id: Option<String>,
98 hook_id: Option<String>,
99 operator_backend_id: Option<String>,
100 #[serde(default)]
101 operator_kind_overrides: HashMap<String, OperatorKind>,
102 task_input: Option<TaskInputSpec>,
103 check_policy: Option<CheckPolicy>,
104}
105
106impl RunLaunchSnapshot {
107 fn from_input(input: &TaskApplicationInput) -> Self {
110 Self {
111 blueprint: input.blueprint.clone(),
112 operator_id: input.operator_id.clone(),
113 role: input.role,
114 ttl: input.ttl,
115 init_ctx: input.init_ctx.clone(),
116 operator_kind: input.operator_kind,
117 bridge_id: input.bridge_id.clone(),
118 hook_id: input.hook_id.clone(),
119 operator_backend_id: input.operator_backend_id.clone(),
120 operator_kind_overrides: input.operator_kind_overrides.clone(),
121 task_input: input.task_input.clone(),
122 check_policy: input.check_policy,
123 }
124 }
125
126 fn into_input(self) -> TaskApplicationInput {
128 TaskApplicationInput {
129 blueprint: self.blueprint,
130 operator_id: self.operator_id,
131 role: self.role,
132 ttl: self.ttl,
133 init_ctx: self.init_ctx,
134 operator_kind: self.operator_kind,
135 bridge_id: self.bridge_id,
136 hook_id: self.hook_id,
137 operator_backend_id: self.operator_backend_id,
138 operator_kind_overrides: self.operator_kind_overrides,
139 task_input: self.task_input,
140 check_policy: self.check_policy,
141 }
142 }
143}
144
145pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
152 serde_json::to_string(&RunLaunchSnapshot::from_input(input))
153 .map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
154}
155
156pub(crate) async fn finalize_run(
166 state: &AppState,
167 task_id: &TaskId,
168 run_id: &RunId,
169 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
170) -> Result<TaskApplicationOutput, TaskApplicationError> {
171 match &outcome {
172 Ok(out) => {
173 if let Err(e) = state
174 .run_store
175 .set_result(run_id, out.final_ctx.clone())
176 .await
177 {
178 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
179 }
180 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
181 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
182 }
183 if let Err(e) = state
184 .task_store
185 .update_status(task_id, TaskRecordStatus::Done)
186 .await
187 {
188 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
189 }
190 }
191 Err(e) => {
192 let envelope = match e {
220 TaskApplicationError::Launch(TaskLaunchError::FlowEval {
221 message,
222 failed_step,
223 verdict_value,
224 partial_ctx,
225 }) => json!({
226 "error": {
227 "message": message,
228 "failed_step": failed_step,
229 "verdict_value": verdict_value,
230 },
231 "partial_ctx": partial_ctx,
232 }),
233 other => json!({
234 "error": {
235 "message": other.to_string(),
236 "failed_step": Value::Null,
237 "verdict_value": Value::Null,
238 },
239 "partial_ctx": Value::Null,
240 }),
241 };
242 if let Err(store_err) = state.run_store.set_result(run_id, envelope).await {
243 tracing::warn!(%run_id, error = %store_err, "finalize_run: set_result (failure envelope) failed");
244 }
245 if let Err(store_err) = state
246 .run_store
247 .update_status(run_id, RunStatus::Failed)
248 .await
249 {
250 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
251 }
252 if let Err(store_err) = state
253 .task_store
254 .update_status(task_id, TaskRecordStatus::Failed)
255 .await
256 {
257 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
258 }
259 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
260 }
261 }
262 let status = if outcome.is_ok() { "done" } else { "failed" };
266 TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
267 .append(
268 trace_kind::RUN_FINISHED,
269 None,
270 None,
271 json!({ "status": status }),
272 )
273 .await;
274 outcome
275}
276
277#[derive(Debug, Deserialize, Default)]
279pub struct TasksListQuery {
280 #[serde(default)]
283 pub limit: Option<usize>,
284}
285
286pub async fn tasks_list(
288 State(state): State<AppState>,
289 Query(q): Query<TasksListQuery>,
290) -> Result<Json<Vec<TaskRecord>>, ApiError> {
291 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
292 if let Some(limit) = q.limit {
293 records.truncate(limit);
294 }
295 Ok(Json(records))
296}
297
298#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
300pub struct TaskDetailResponse {
301 pub task: TaskRecord,
303 pub runs: Vec<RunRecord>,
305}
306
307pub async fn task_get(
310 State(state): State<AppState>,
311 Path(id): Path<String>,
312) -> Result<Json<TaskDetailResponse>, ApiError> {
313 let task_id =
314 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
315 let task = state
316 .task_store
317 .get(&task_id)
318 .await
319 .map_err(map_task_store_err)?;
320 let runs = state
321 .run_store
322 .list_by_task(&task_id)
323 .await
324 .map_err(ApiError::engine)?;
325 Ok(Json(TaskDetailResponse { task, runs }))
326}
327
328#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
334pub struct RunKickRequest {
335 #[serde(default)]
344 #[schemars(with = "Option<Value>")]
345 pub init_ctx_override: Option<Value>,
346 #[serde(default)]
353 pub task_input_override: Option<TaskInputSpec>,
354 #[serde(default)]
360 pub timeout_secs: Option<u64>,
361 #[serde(default)]
368 pub detach: bool,
369 #[serde(default)]
381 pub operator_sid: Option<String>,
382}
383
384#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
386pub struct RunKickResponse {
387 #[schemars(with = "String")]
389 pub task_id: TaskId,
390 #[schemars(with = "String")]
392 pub run_id: RunId,
393 pub status: RunStatus,
398}
399
400pub async fn task_rekick(
428 State(state): State<AppState>,
429 Path(id): Path<String>,
430 body: Option<Json<RunKickRequest>>,
431) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
432 let task_id =
433 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
434 let task = state
435 .task_store
436 .get(&task_id)
437 .await
438 .map_err(map_task_store_err)?;
439
440 let blueprint_ref: mlua_swarm::application::BlueprintRef =
441 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
442 ApiError::bad_request(format!(
443 "task {task_id}: stored blueprint_ref failed to decode: {e}"
444 ))
445 })?;
446
447 let (resolved_bp, _bound_version) = state
453 .task_app
454 .resolve(&blueprint_ref)
455 .await
456 .map_err(|e| ApiError::from_task_resolve(&e, &format!("task {task_id}: bp resolve")))?;
457
458 let req = body.map(|Json(r)| r).unwrap_or_default();
459
460 let operator_backend_id = match &req.operator_sid {
471 Some(sid) => {
472 let known_ids = state.engine.list_operator_ids().await;
473 if !known_ids.iter().any(|id| id == sid) {
474 return Err(ApiError::bad_request(format!(
475 "operator_sid: no such registered operator session '{sid}'"
476 )));
477 }
478 Some(sid.clone())
479 }
480 None => None,
481 };
482
483 let detach = req.detach;
493 let sync_timeout_secs = match (detach, req.timeout_secs) {
494 (true, Some(_)) => {
495 return Err(ApiError::bad_request(
496 "timeout_secs is the synchronous rekick ceiling and does not apply to a \
497 detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
498 timeout_secs"
499 .into(),
500 ));
501 }
502 (false, Some(0)) => {
503 return Err(ApiError::bad_request(
504 "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
505 ));
506 }
507 (false, Some(v)) => v,
508 (_, None) => state.sync_timeout_secs,
509 };
510
511 if resolved_bp
524 .spawner_hints
525 .layers
526 .iter()
527 .any(|l| l == "operator_delegate")
528 {
529 let attached = state.engine.list_operator_ids().await;
530 if attached.is_empty() {
531 return Err(ApiError::unavailable(format!(
532 "no operator attached to serve this rekick (task {task_id}'s \
533 Blueprint declares the operator_delegate layer): attach an \
534 operator via POST /v1/operators + WS, or use the poll-style \
535 flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
536 )));
537 }
538 }
539
540 let merged_init_ctx = merge_init_ctx_3layer(
541 resolved_bp.default_init_ctx.as_ref(),
542 &task.input_ctx,
543 req.init_ctx_override.as_ref(),
544 );
545
546 let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
550 Some(over) => Some(over),
551 None => task
552 .task_input_spec
553 .as_ref()
554 .map(|v| serde_json::from_value(v.clone()))
555 .transpose()
556 .map_err(|e| {
557 ApiError::bad_request(format!(
558 "task {task_id}: stored task_input_spec failed to decode: {e}"
559 ))
560 })?,
561 };
562
563 let run_id = RunId::new();
564 let now = now_secs();
565
566 let input = TaskApplicationInput {
567 blueprint: blueprint_ref,
568 operator_id: "http-run".to_string(),
569 role: Role::Operator,
570 ttl: Duration::from_secs(crate::default_run_ttl()),
571 init_ctx: merged_init_ctx,
572 operator_kind: None,
573 bridge_id: None,
574 hook_id: None,
575 operator_backend_id,
576 operator_kind_overrides: HashMap::new(),
577 task_input: task_input_spec,
578 check_policy: None,
582 };
583 let input_json = Some(snapshot_launch_input(&input)?);
588
589 state
590 .task_store
591 .update_status(&task_id, TaskRecordStatus::Running)
592 .await
593 .map_err(ApiError::engine)?;
594 state
595 .run_store
596 .create(RunRecord {
597 id: run_id.clone(),
598 task_id: task_id.clone(),
599 status: RunStatus::Running,
600 step_entries: Vec::new(),
601 degradations: Vec::new(),
602 operator_sid: req.operator_sid.clone(),
603 result_ref: None,
604 input_json,
605 created_at: now,
606 updated_at: now,
607 })
608 .await
609 .map_err(ApiError::engine)?;
610
611 let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
612 trace
613 .append(
614 trace_kind::RUN_STARTED,
615 None,
616 None,
617 json!({"mode": "rekick"}),
618 )
619 .await;
620 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
621 .with_replay_store(state.replay_store.clone())
622 .with_trace(trace);
623
624 if detach {
630 let ttl_secs = crate::default_run_ttl();
631 let bg_state = state.clone();
632 let bg_task_id = task_id.clone();
633 let bg_run_id = run_id.clone();
634 tokio::spawn(async move {
635 let outcome = match tokio::time::timeout(
636 Duration::from_secs(ttl_secs),
637 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
638 )
639 .await
640 {
641 Ok(outcome) => outcome,
642 Err(_elapsed) => {
643 let reason = serde_json::json!({
644 "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
645 });
646 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
647 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
648 }
649 if let Err(e) = bg_state
650 .run_store
651 .update_status(&bg_run_id, RunStatus::Failed)
652 .await
653 {
654 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
655 }
656 if let Err(e) = bg_state
657 .task_store
658 .update_status(&bg_task_id, TaskRecordStatus::Failed)
659 .await
660 {
661 tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
662 }
663 TraceHandle::new(bg_run_id.clone(), bg_state.run_trace_store.clone())
666 .append(
667 trace_kind::RUN_FINISHED,
668 None,
669 None,
670 json!({ "status": "failed", "reason": format!("ttl {ttl_secs}s exceeded") }),
671 )
672 .await;
673 return;
674 }
675 };
676 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
679 });
680 return Ok((
681 StatusCode::ACCEPTED,
682 Json(RunKickResponse {
683 task_id,
684 run_id,
685 status: RunStatus::Running,
686 }),
687 ));
688 }
689
690 let outcome = match tokio::time::timeout(
696 Duration::from_secs(sync_timeout_secs),
697 state.task_app.handle_with_run(input, Some(run_ctx)),
698 )
699 .await
700 {
701 Ok(outcome) => outcome,
702 Err(_elapsed) => {
703 let reason = serde_json::json!({
704 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
705 });
706 if let Err(e) = state.run_store.set_result(&run_id, reason).await {
707 tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
708 }
709 if let Err(e) = state
710 .run_store
711 .update_status(&run_id, RunStatus::Failed)
712 .await
713 {
714 tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
715 }
716 if let Err(e) = state
717 .task_store
718 .update_status(&task_id, TaskRecordStatus::Failed)
719 .await
720 {
721 tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
722 }
723 return Err(ApiError::timeout(format!(
724 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
725 )));
726 }
727 };
728 finalize_run(&state, &task_id, &run_id, outcome)
729 .await
730 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
731
732 Ok((
733 StatusCode::CREATED,
734 Json(RunKickResponse {
735 task_id,
736 run_id,
737 status: RunStatus::Done,
738 }),
739 ))
740}
741
742#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
744pub struct RunResumeResponse {
745 #[schemars(with = "String")]
750 pub run_id: RunId,
751 #[schemars(with = "String")]
753 pub task_id: TaskId,
754 pub replayed_steps: usize,
759}
760
761pub async fn run_resume(
787 State(state): State<AppState>,
788 Path(id): Path<String>,
789) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
790 let run_id =
791 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
792
793 let run = state
795 .run_store
796 .get(&run_id)
797 .await
798 .map_err(map_run_store_err)?;
799
800 if run.status != RunStatus::Interrupted {
802 return Err(ApiError::conflict(format!(
803 "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
804 run.status
805 )));
806 }
807
808 let Some(input_json) = run.input_json.clone() else {
813 return Err(ApiError::unprocessable(format!(
814 "run {run_id} cannot be resumed: no launch input was recorded for it (it \
815 predates resume support, or was created by a path that does not persist one)"
816 )));
817 };
818 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
819 ApiError::unprocessable(format!(
820 "run {run_id}: stored launch input failed to decode: {e}"
821 ))
822 })?;
823 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
824 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
825 ApiError::unprocessable(format!(
826 "run {run_id}: stored launch input failed to decode: {e}"
827 ))
828 })?;
829
830 let won = state
834 .run_store
835 .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
836 .await
837 .map_err(ApiError::engine)?;
838 if !won {
839 return Err(ApiError::conflict(format!(
840 "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
841 no longer resumable"
842 )));
843 }
844
845 let entries = state
849 .replay_store
850 .list_by_run(&run_id)
851 .await
852 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
853 let replayed_steps = entries.len();
854 let cursor = ReplayCursor::from_entries(entries);
855
856 let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
861 trace
862 .append(
863 trace_kind::RUN_STARTED,
864 None,
865 None,
866 json!({"mode": "resume"}),
867 )
868 .await;
869 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
870 .with_replay_store(state.replay_store.clone())
871 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
872 .with_resume()
873 .with_trace(trace);
874
875 let input = snapshot.into_input();
876 let task_id = run.task_id.clone();
877
878 state
881 .task_store
882 .update_status(&task_id, TaskRecordStatus::Running)
883 .await
884 .map_err(ApiError::engine)?;
885
886 let ttl_secs = crate::default_run_ttl();
890 let bg_state = state.clone();
891 let bg_task_id = task_id.clone();
892 let bg_run_id = run_id.clone();
893 tokio::spawn(async move {
894 let outcome = match tokio::time::timeout(
895 Duration::from_secs(ttl_secs),
896 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
897 )
898 .await
899 {
900 Ok(outcome) => outcome,
901 Err(_elapsed) => {
902 let reason = serde_json::json!({
903 "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
904 });
905 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
906 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
907 }
908 if let Err(e) = bg_state
909 .run_store
910 .update_status(&bg_run_id, RunStatus::Failed)
911 .await
912 {
913 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
914 }
915 if let Err(e) = bg_state
916 .task_store
917 .update_status(&bg_task_id, TaskRecordStatus::Failed)
918 .await
919 {
920 tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
921 }
922 return;
923 }
924 };
925 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
927 });
928
929 Ok((
930 StatusCode::ACCEPTED,
931 Json(RunResumeResponse {
932 run_id,
933 task_id,
934 replayed_steps,
935 }),
936 ))
937}
938
939#[derive(Debug, Deserialize, schemars::JsonSchema)]
941pub struct RunRerunFromRequest {
942 pub from_step: String,
949}
950
951#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
953pub struct RunRerunFromResponse {
954 #[schemars(with = "String")]
959 pub run_id: RunId,
960 #[schemars(with = "String")]
962 pub task_id: TaskId,
963 pub replayed_steps: usize,
967 pub dropped_steps: usize,
970}
971
972pub async fn run_rerun_from(
1047 State(state): State<AppState>,
1048 Path(id): Path<String>,
1049 Json(req): Json<RunRerunFromRequest>,
1050) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
1051 let run_id =
1052 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1053
1054 if req.from_step.trim().is_empty() {
1055 return Err(ApiError::bad_request(
1056 "from_step must be a non-empty step ref".to_string(),
1057 ));
1058 }
1059
1060 let run = state
1062 .run_store
1063 .get(&run_id)
1064 .await
1065 .map_err(map_run_store_err)?;
1066
1067 let current = run.status;
1070 match current {
1071 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted | RunStatus::Cancelled => { }
1073 RunStatus::Running | RunStatus::Pending => {
1074 return Err(ApiError::conflict(format!(
1075 "run {run_id} is {current:?}; rerun-from requires a terminal run \
1076 (Done / Failed / Interrupted / Cancelled)"
1077 )));
1078 }
1079 }
1080
1081 let Some(input_json) = run.input_json.clone() else {
1086 return Err(ApiError::unprocessable(format!(
1087 "run {run_id} cannot be rerun: no launch input was recorded for it (it \
1088 predates resume/rerun support, or was created by a path that does not \
1089 persist one)"
1090 )));
1091 };
1092 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
1093 ApiError::unprocessable(format!(
1094 "run {run_id}: stored launch input failed to decode: {e}"
1095 ))
1096 })?;
1097 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
1098 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
1099 ApiError::unprocessable(format!(
1100 "run {run_id}: stored launch input failed to decode: {e}"
1101 ))
1102 })?;
1103
1104 let entries = state
1107 .replay_store
1108 .list_by_run(&run_id)
1109 .await
1110 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
1111 let cut = entries
1112 .iter()
1113 .position(|e| e.step_ref == req.from_step)
1114 .ok_or_else(|| {
1115 if entries.is_empty() && !run.step_entries.is_empty() {
1125 ApiError::unprocessable(format!(
1126 "run {run_id}: replay log is empty but {} step entries are traced \
1127 on the RunRecord — the log was consumed by a prior rerun-from \
1128 that reached the truncate stage. This run can no longer be \
1129 rerun-from; start a fresh run via POST /v1/tasks.",
1130 run.step_entries.len()
1131 ))
1132 } else {
1133 ApiError::unprocessable(format!(
1134 "run {run_id}: from_step {:?} not present in this run's replay log \
1135 (nothing to rerun-from)",
1136 req.from_step
1137 ))
1138 }
1139 })?;
1140
1141 if let Err(e) = state.task_app.precompile(&snapshot.blueprint).await {
1155 return Err(ApiError::unprocessable(format!(
1156 "run {run_id} cannot be rerun: current-head Blueprint fails to compile — {e}"
1157 )));
1158 }
1159
1160 let won = state
1165 .run_store
1166 .try_transition(&run_id, current, RunStatus::Running)
1167 .await
1168 .map_err(ApiError::engine)?;
1169 if !won {
1170 return Err(ApiError::conflict(format!(
1171 "run {run_id} was concurrently transitioned (or left the {current:?} state); \
1172 it is no longer rerunnable"
1173 )));
1174 }
1175
1176 let dropped_steps = state
1181 .replay_store
1182 .delete_from(&run_id, cut)
1183 .await
1184 .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
1185
1186 let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
1189 let replayed_steps = kept.len();
1190 let cursor = ReplayCursor::from_entries(kept);
1191
1192 let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
1196 trace
1197 .append(
1198 trace_kind::RUN_STARTED,
1199 None,
1200 None,
1201 json!({"mode": "rerun_from"}),
1202 )
1203 .await;
1204 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1205 .with_replay_store(state.replay_store.clone())
1206 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1207 .with_resume()
1208 .with_trace(trace);
1209
1210 let input = snapshot.into_input();
1211 let task_id = run.task_id.clone();
1212
1213 state
1216 .task_store
1217 .update_status(&task_id, TaskRecordStatus::Running)
1218 .await
1219 .map_err(ApiError::engine)?;
1220
1221 let ttl_secs = crate::default_run_ttl();
1222 let bg_state = state.clone();
1223 let bg_task_id = task_id.clone();
1224 let bg_run_id = run_id.clone();
1225 tokio::spawn(async move {
1226 let outcome = match tokio::time::timeout(
1227 Duration::from_secs(ttl_secs),
1228 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1229 )
1230 .await
1231 {
1232 Ok(outcome) => outcome,
1233 Err(_elapsed) => {
1234 let reason = serde_json::json!({
1235 "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1236 });
1237 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1238 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1239 }
1240 if let Err(e) = bg_state
1241 .run_store
1242 .update_status(&bg_run_id, RunStatus::Failed)
1243 .await
1244 {
1245 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1246 }
1247 if let Err(e) = bg_state
1248 .task_store
1249 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1250 .await
1251 {
1252 tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1253 }
1254 return;
1255 }
1256 };
1257 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1258 });
1259
1260 Ok((
1261 StatusCode::ACCEPTED,
1262 Json(RunRerunFromResponse {
1263 run_id,
1264 task_id,
1265 replayed_steps,
1266 dropped_steps,
1267 }),
1268 ))
1269}
1270
1271pub async fn run_get(
1274 State(state): State<AppState>,
1275 Path(id): Path<String>,
1276) -> Result<Json<RunRecord>, ApiError> {
1277 let run_id =
1278 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1279 let run = state
1280 .run_store
1281 .get(&run_id)
1282 .await
1283 .map_err(map_run_store_err)?;
1284 Ok(Json(run))
1285}
1286
1287#[derive(Debug, Deserialize, Default)]
1289pub struct RunsListQuery {
1290 #[serde(default)]
1292 pub task_id: Option<String>,
1293 #[serde(default)]
1296 pub status: Option<String>,
1297 #[serde(default)]
1299 pub limit: Option<usize>,
1300 #[serde(default)]
1302 pub offset: Option<usize>,
1303}
1304
1305#[derive(Debug, Serialize)]
1307pub struct RunsListResponse {
1308 pub runs: Vec<RunRecord>,
1310}
1311
1312pub async fn runs_list(
1317 State(state): State<AppState>,
1318 Query(q): Query<RunsListQuery>,
1319) -> Result<Json<RunsListResponse>, ApiError> {
1320 let task_id = q
1321 .task_id
1322 .map(TaskId::parse)
1323 .transpose()
1324 .map_err(|e| ApiError::bad_request(format!("invalid task_id: {e}")))?;
1325 let status = q
1326 .status
1327 .as_deref()
1328 .map(|s| {
1329 serde_json::from_value::<RunStatus>(Value::String(s.to_string())).map_err(|_| {
1330 ApiError::bad_request(format!(
1331 "invalid status {s:?} (expected pending/running/done/failed/interrupted)"
1332 ))
1333 })
1334 })
1335 .transpose()?;
1336 let runs = state
1337 .run_store
1338 .list(&RunListFilter {
1339 task_id,
1340 status,
1341 limit: q.limit,
1342 offset: q.offset,
1343 })
1344 .await
1345 .map_err(map_run_store_err)?;
1346 Ok(Json(RunsListResponse { runs }))
1347}
1348
1349#[derive(Debug, Serialize)]
1351pub struct RunStepsResponse {
1352 pub run_id: String,
1354 pub steps: Vec<StepEntry>,
1356}
1357
1358pub async fn run_steps(
1363 State(state): State<AppState>,
1364 Path(id): Path<String>,
1365) -> Result<Json<RunStepsResponse>, ApiError> {
1366 let run_id =
1367 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1368 let run = state
1369 .run_store
1370 .get(&run_id)
1371 .await
1372 .map_err(map_run_store_err)?;
1373 Ok(Json(RunStepsResponse {
1374 run_id: run.id.to_string(),
1375 steps: run.step_entries,
1376 }))
1377}
1378
1379#[derive(Debug, Deserialize, Default)]
1383pub struct RunTraceQuery {
1384 #[serde(default)]
1386 pub after: Option<u64>,
1387 #[serde(default)]
1389 pub limit: Option<usize>,
1390 #[serde(default)]
1392 pub latest: Option<usize>,
1393 #[serde(default)]
1396 pub kind: Option<String>,
1397 #[serde(default)]
1399 pub step: Option<String>,
1400 #[serde(default)]
1402 pub attempt: Option<u32>,
1403}
1404
1405#[derive(Debug, Serialize)]
1407pub struct RunTraceResponse {
1408 pub run_id: String,
1410 pub events: Vec<TraceEvent>,
1412}
1413
1414pub async fn run_trace(
1420 State(state): State<AppState>,
1421 Path(id): Path<String>,
1422 Query(q): Query<RunTraceQuery>,
1423) -> Result<Json<RunTraceResponse>, ApiError> {
1424 let run_id =
1425 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1426 let query = TraceQuery {
1427 after: q.after,
1428 limit: q.limit,
1429 latest: q.latest,
1430 kinds: q
1431 .kind
1432 .as_deref()
1433 .map(|s| {
1434 s.split(',')
1435 .map(str::trim)
1436 .filter(|k| !k.is_empty())
1437 .map(str::to_string)
1438 .collect()
1439 })
1440 .unwrap_or_default(),
1441 step_ref: q.step,
1442 attempt: q.attempt,
1443 };
1444 let events = state
1445 .run_trace_store
1446 .list(&run_id, &query)
1447 .await
1448 .map_err(|e| ApiError::engine(format!("trace list: {e}")))?;
1449 Ok(Json(RunTraceResponse {
1450 run_id: run_id.to_string(),
1451 events,
1452 }))
1453}
1454
1455pub async fn run_cancel(
1465 State(state): State<AppState>,
1466 Path(id): Path<String>,
1467) -> Result<axum::http::StatusCode, ApiError> {
1468 let run_id =
1469 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1470 let record = state
1473 .run_store
1474 .get(&run_id)
1475 .await
1476 .map_err(map_run_store_err)?;
1477 TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
1480 .append(trace_kind::CANCEL_REQUESTED, None, None, json!({}))
1481 .await;
1482 if matches!(record.status, RunStatus::Pending | RunStatus::Running) {
1487 if let Err(e) = state
1488 .run_store
1489 .update_status(&run_id, RunStatus::Cancelled)
1490 .await
1491 {
1492 tracing::warn!(%run_id, error = %e, "run_cancel: update_status(Cancelled) failed");
1493 }
1494 }
1495 Ok(axum::http::StatusCode::NO_CONTENT)
1496}
1497
1498pub async fn run_delete(
1511 State(state): State<AppState>,
1512 Path(id): Path<String>,
1513) -> Result<axum::http::StatusCode, ApiError> {
1514 let run_id =
1515 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1516 state
1517 .run_store
1518 .delete(&run_id)
1519 .await
1520 .map_err(map_run_store_err)?;
1521 if let Err(e) = state.run_trace_store.delete_run(&run_id).await {
1522 tracing::warn!(%run_id, error = %e, "run_delete: trace delete_run failed (run row already deleted)");
1523 }
1524 Ok(axum::http::StatusCode::NO_CONTENT)
1525}
1526
1527#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1530#[serde(rename_all = "snake_case")]
1531pub enum RunBindingStatus {
1532 DeclarationOnly,
1535 Attested,
1537}
1538
1539#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1541pub struct RunBindingDifference {
1542 pub model_changed: bool,
1544 pub missing_requested_tools: Vec<String>,
1547 pub additional_effective_tools: Vec<String>,
1549 pub launch_variant_changed: bool,
1551}
1552
1553#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1556pub struct RunBindingExplainEntry {
1557 pub agent: String,
1559 pub runner_source: mlua_swarm::blueprint::RunnerResolutionSource,
1561 pub status: RunBindingStatus,
1563 pub requested: Option<BindRequest>,
1565 pub effective: Option<BindingAttestation>,
1567 pub difference: Option<RunBindingDifference>,
1570 pub binding_digest: mlua_swarm::blueprint::BindingDigest,
1572}
1573
1574#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1576pub struct RunBindingsExplainResponse {
1577 #[schemars(with = "String")]
1579 pub run_id: RunId,
1580 #[schemars(with = "String")]
1582 pub task_id: TaskId,
1583 pub snapshot_origin: SnapshotOrigin,
1591 pub bindings: Vec<RunBindingExplainEntry>,
1593}
1594
1595fn requested_binding(bound: &BoundAgent) -> Option<BindRequest> {
1596 mlua_swarm::binding_request_for_snapshot(bound)
1597}
1598
1599fn binding_difference(
1600 requested: &BindRequest,
1601 effective: &BindingAttestation,
1602) -> RunBindingDifference {
1603 let missing_requested_tools = requested
1604 .requested_tools
1605 .iter()
1606 .filter(|tool| !effective.effective_tools.contains(tool))
1607 .cloned()
1608 .collect();
1609 let additional_effective_tools = effective
1610 .effective_tools
1611 .iter()
1612 .filter(|tool| !requested.requested_tools.contains(tool))
1613 .cloned()
1614 .collect();
1615 RunBindingDifference {
1616 model_changed: requested.requested_model != effective.resolved_model,
1617 missing_requested_tools,
1618 additional_effective_tools,
1619 launch_variant_changed: requested.launch_variant != effective.launch_variant,
1620 }
1621}
1622
1623fn validated_bound_agents_from_snapshot(
1624 run_id: &RunId,
1625 snapshot: &Value,
1626) -> Result<Option<Vec<BoundAgent>>, ApiError> {
1627 let Some(bound_value) = snapshot.get("bound_agents") else {
1628 return Ok(None);
1629 };
1630 let bound_agents: Vec<BoundAgent> =
1631 serde_json::from_value(bound_value.clone()).map_err(|e| {
1632 ApiError::unprocessable(format!(
1633 "run {run_id} contains an invalid binding snapshot: {e}"
1634 ))
1635 })?;
1636 validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
1637 ApiError::unprocessable(format!(
1638 "run {run_id} contains an inconsistent binding snapshot: {error}"
1639 ))
1640 })?;
1641 Ok(Some(bound_agents))
1642}
1643
1644pub async fn run_bindings_explain(
1648 State(state): State<AppState>,
1649 Path(id): Path<String>,
1650) -> Result<Json<RunBindingsExplainResponse>, ApiError> {
1651 let run_id =
1652 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1653 let run = state
1654 .run_store
1655 .get(&run_id)
1656 .await
1657 .map_err(map_run_store_err)?;
1658 let input_json = run.input_json.as_deref().ok_or_else(|| {
1659 ApiError::unprocessable(format!(
1660 "run {run_id} has no launch snapshot; binding explain is unavailable"
1661 ))
1662 })?;
1663 let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
1664 ApiError::unprocessable(format!(
1665 "run {run_id} launch snapshot is invalid JSON; binding explain is unavailable: {e}"
1666 ))
1667 })?;
1668 let bound_agents = validated_bound_agents_from_snapshot(&run_id, &snapshot)?.ok_or_else(|| {
1669 ApiError::unprocessable(format!(
1670 "run {run_id} predates immutable binding snapshots; current Blueprint state was not consulted"
1671 ))
1672 })?;
1673
1674 let bindings = bound_agents
1675 .into_iter()
1676 .map(|bound| {
1677 let requested = requested_binding(&bound);
1678 let effective = bound.attestation.clone();
1679 let difference = requested
1680 .as_ref()
1681 .zip(effective.as_ref())
1682 .map(|(request, attestation)| binding_difference(request, attestation));
1683 RunBindingExplainEntry {
1684 agent: bound.agent.name,
1685 runner_source: bound.runner_source,
1686 status: if effective.is_some() {
1687 RunBindingStatus::Attested
1688 } else {
1689 RunBindingStatus::DeclarationOnly
1690 },
1691 requested,
1692 effective,
1693 difference,
1694 binding_digest: bound.binding_digest,
1695 }
1696 })
1697 .collect();
1698
1699 Ok(Json(RunBindingsExplainResponse {
1700 run_id: run.id,
1701 task_id: run.task_id,
1702 snapshot_origin: SnapshotOrigin::from_snapshot(&snapshot),
1703 bindings,
1704 }))
1705}
1706
1707pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1711 match e {
1712 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1713 other => ApiError::engine(other),
1714 }
1715}
1716
1717fn map_run_store_err(e: RunStoreError) -> ApiError {
1718 match e {
1719 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1720 other => ApiError::engine(other),
1721 }
1722}
1723
1724#[cfg(test)]
1729mod tests {
1730 use super::*;
1731 use mlua_swarm::application::BlueprintRef;
1732 use mlua_swarm::blueprint::{
1733 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1734 CompilerStrategy, Runner,
1735 };
1736 use mlua_swarm::core::config::EngineCfg;
1737 use mlua_swarm::core::engine::Engine;
1738 use mlua_swarm::store::output::InMemoryOutputStore;
1739 use mlua_swarm::store::run::InMemoryRunStore;
1740 use mlua_swarm::store::task::InMemoryTaskStore;
1741 use std::collections::HashMap;
1742 use std::sync::Arc;
1743 use tokio::sync::Mutex;
1744
1745 fn identity_blueprint() -> Blueprint {
1751 Blueprint {
1752 schema_version: current_schema_version(),
1753 id: "tasks-test-bp".into(),
1754 flow: serde_json::from_value(serde_json::json!({
1755 "kind": "step",
1756 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1757 "in": {"op": "lit", "value": "hello"},
1758 "out": {"op": "path", "at": "$.out"},
1759 }))
1760 .expect("flow parse"),
1761 agents: vec![AgentDef {
1762 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1763 kind: AgentKind::RustFn,
1764 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1765 profile: None,
1766 meta: None,
1767 runner: None,
1768 runner_ref: None,
1769 verdict: None,
1770 }],
1771 operators: vec![],
1772 metas: vec![],
1773 hints: CompilerHints::default(),
1774 strategy: CompilerStrategy::default(),
1775 metadata: BlueprintMetadata::default(),
1776 spawner_hints: Default::default(),
1777 default_agent_kind: AgentKind::Operator,
1778 default_operator_kind: None,
1779 default_init_ctx: None,
1780 default_agent_ctx: None,
1781 default_context_policy: None,
1782 projection_placement: None,
1783 audits: vec![],
1784 degradation_policy: None,
1785 runners: vec![],
1786 default_runner: None,
1787 subprocesses: vec![],
1788 check_policy: None,
1789 blueprint_ref_includes: Vec::new(),
1790 }
1791 }
1792
1793 fn test_state() -> AppState {
1798 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1799 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1800 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1801 AppState {
1802 engine,
1803 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1804 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1805 ws_operator_factory: None,
1806 data_store: Arc::new(InMemoryOutputStore::new()),
1807 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1808 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1809 task_store: Arc::new(InMemoryTaskStore::new()),
1810 run_store: Arc::new(InMemoryRunStore::new()),
1811 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1812 run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
1813 base_url: None,
1814 sync_timeout_secs: 300,
1815 }
1816 }
1817
1818 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
1819 crate::TaskLaunchRequest {
1820 blueprint: BlueprintRef::Inline {
1821 value: Box::new(identity_blueprint()),
1822 },
1823 init_ctx: serde_json::json!({"in": "hello"}),
1824 project_root: None,
1825 work_dir: None,
1826 task_metadata: None,
1827 ttl_secs: None,
1828 operator: None,
1829 operator_sid: None,
1830 timeout_secs: None,
1831 goal: Some(goal.to_string()),
1832 detach: false,
1833 check_policy: None,
1834 }
1835 }
1836
1837 #[test]
1838 fn task_id_serializes_as_bare_string() {
1839 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
1843 assert_eq!(v, serde_json::json!("T-abc"));
1844 }
1845
1846 #[tokio::test]
1847 async fn post_then_get_drill_down() {
1848 let state = test_state();
1849
1850 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
1851 .await
1852 .expect("tasks_start")
1853 .0;
1854 let task_id = posted.task_id.clone();
1855 let run_id = posted.run_id.clone();
1856
1857 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
1859 .await
1860 .expect("tasks_list")
1861 .0;
1862 assert!(
1863 list.iter().any(|t| t.id == task_id),
1864 "task {task_id} missing from list of {} tasks",
1865 list.len()
1866 );
1867
1868 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1870 .await
1871 .expect("task_get")
1872 .0;
1873 assert_eq!(detail.task.id, task_id);
1874 assert_eq!(detail.task.goal, "smoke goal");
1875 assert_eq!(detail.task.status, TaskRecordStatus::Done);
1876 assert_eq!(detail.runs.len(), 1);
1877 assert_eq!(detail.runs[0].id, run_id);
1878 assert_eq!(detail.runs[0].status, RunStatus::Done);
1879
1880 let run = run_get(State(state.clone()), Path(run_id.to_string()))
1882 .await
1883 .expect("run_get")
1884 .0;
1885 assert_eq!(run.id, run_id);
1886 assert_eq!(run.task_id, task_id);
1887 assert_eq!(run.result_ref, Some(posted.final_ctx));
1888
1889 assert_eq!(
1893 run.step_entries.len(),
1894 1,
1895 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
1896 run.step_entries
1897 );
1898 assert_eq!(
1899 run.step_entries[0].step_ref,
1900 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1901 );
1902 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1903 }
1904
1905 fn identity_blueprint_with_operator_delegate() -> Blueprint {
1917 Blueprint {
1918 spawner_hints: mlua_swarm::SpawnerHints {
1919 layers: vec!["operator_delegate".to_string()],
1920 },
1921 ..identity_blueprint()
1922 }
1923 }
1924
1925 struct StallingOperator;
1928
1929 #[async_trait::async_trait]
1930 impl mlua_swarm::Operator for StallingOperator {
1931 async fn execute(
1932 &self,
1933 _ctx: &mlua_swarm::Ctx,
1934 _system: Option<String>,
1935 _prompt: Value,
1936 _worker: Option<mlua_swarm::WorkerBinding>,
1937 _worker_token: mlua_swarm::CapToken,
1938 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1939 std::future::pending::<()>().await;
1940 unreachable!("StallingOperator.execute must never resolve")
1941 }
1942 }
1943
1944 fn operator_launch_req(
1948 backend_id: &str,
1949 timeout_secs: Option<u64>,
1950 ) -> crate::TaskLaunchRequest {
1951 crate::TaskLaunchRequest {
1952 blueprint: BlueprintRef::Inline {
1953 value: Box::new(identity_blueprint_with_operator_delegate()),
1954 },
1955 init_ctx: serde_json::json!({"in": "hello"}),
1956 project_root: None,
1957 work_dir: None,
1958 task_metadata: None,
1959 ttl_secs: None,
1960 operator: Some(crate::OperatorReq {
1961 operator_backend_id: Some(backend_id.to_string()),
1962 ..Default::default()
1963 }),
1964 operator_sid: None,
1965 timeout_secs,
1966 goal: Some("operator delegate test goal".to_string()),
1967 detach: false,
1968 check_policy: None,
1969 }
1970 }
1971
1972 #[tokio::test]
1976 async fn sync_launch_zero_operators_fails_fast() {
1977 let state = test_state();
1978 let req = operator_launch_req("nonexistent-op", None);
1981
1982 let started = std::time::Instant::now();
1983 let result = crate::tasks_start(State(state), Json(req)).await;
1984 let elapsed = started.elapsed();
1985
1986 let err = match result {
1987 Err(e) => e,
1988 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1989 };
1990 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1991 assert!(
1992 err.message.contains("no operator attached"),
1993 "error message must mention the missing operator: {}",
1994 err.message
1995 );
1996 assert!(
1997 elapsed < Duration::from_secs(1),
1998 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1999 );
2000 }
2001
2002 #[tokio::test]
2006 async fn sync_launch_stalled_times_out() {
2007 let state = test_state();
2008 state
2009 .engine
2010 .register_operator("stall-op", Arc::new(StallingOperator))
2011 .await;
2012 let req = operator_launch_req("stall-op", Some(1));
2013
2014 let started = std::time::Instant::now();
2015 let result = tokio::time::timeout(
2019 Duration::from_secs(5),
2020 crate::tasks_start(State(state), Json(req)),
2021 )
2022 .await
2023 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
2024 let elapsed = started.elapsed();
2025
2026 let err = match result {
2027 Err(e) => e,
2028 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
2029 };
2030 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
2031 assert!(
2032 err.message.contains('1'),
2033 "error message must mention the configured 1s ceiling: {}",
2034 err.message
2035 );
2036 assert!(
2037 elapsed < Duration::from_secs(3),
2038 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2039 );
2040 }
2041
2042 #[tokio::test]
2046 async fn sync_launch_without_operator_path_unaffected() {
2047 let state = test_state();
2048 let result = crate::tasks_start(
2049 State(state),
2050 Json(post_tasks_req("non-operator launch goal")),
2051 )
2052 .await;
2053 if let Err(e) = &result {
2054 panic!(
2055 "non-operator launch must succeed unaffected by guard 1: {}",
2056 e.message
2057 );
2058 }
2059 }
2060
2061 #[tokio::test]
2065 async fn sync_launch_zero_timeout_secs_rejected() {
2066 let state = test_state();
2067 let mut req = post_tasks_req("zero timeout goal");
2068 req.timeout_secs = Some(0);
2069
2070 let result = crate::tasks_start(State(state), Json(req)).await;
2071 let err = match result {
2072 Err(e) => e,
2073 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2074 };
2075 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2076 assert!(
2077 err.message.contains("timeout_secs"),
2078 "error message must reference timeout_secs: {}",
2079 err.message
2080 );
2081 }
2082
2083 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
2092 for _ in 0..50 {
2093 let rec = state.run_store.get(run_id).await.expect("run get");
2094 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
2095 return rec;
2096 }
2097 tokio::time::sleep(Duration::from_millis(100)).await;
2098 }
2099 panic!("run {run_id} did not reach a terminal status within ~5s");
2100 }
2101
2102 #[tokio::test]
2108 async fn detached_launch_returns_202_and_completes_in_background() {
2109 let state = test_state();
2110 let mut req = post_tasks_req("detached goal");
2111 req.detach = true;
2112
2113 let reply = crate::tasks_start(State(state.clone()), Json(req))
2114 .await
2115 .expect("tasks_start (detached)");
2116 assert_eq!(reply.1, StatusCode::ACCEPTED);
2117 let posted = reply.0;
2118 assert_eq!(posted.status, RunStatus::Running);
2119 assert_eq!(
2120 posted.final_ctx,
2121 serde_json::Value::Null,
2122 "a detached launch has no final_ctx at response time"
2123 );
2124
2125 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
2126 assert_eq!(rec.status, RunStatus::Done);
2127 assert!(
2128 rec.result_ref.is_some(),
2129 "finalize_run must persist the background eval's final_ctx"
2130 );
2131 assert_eq!(
2132 rec.step_entries.len(),
2133 1,
2134 "the background eval must trace its step_entries like the sync path: {:?}",
2135 rec.step_entries
2136 );
2137 let task = state
2138 .task_store
2139 .get(&posted.task_id)
2140 .await
2141 .expect("task get");
2142 assert_eq!(task.status, TaskRecordStatus::Done);
2143 }
2144
2145 #[tokio::test]
2149 async fn detached_launch_with_timeout_secs_rejected() {
2150 let state = test_state();
2151 let mut req = post_tasks_req("detached + ceiling goal");
2152 req.detach = true;
2153 req.timeout_secs = Some(60);
2154
2155 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
2156 Err(e) => e,
2157 Ok(_) => panic!("detach + timeout_secs must be rejected"),
2158 };
2159 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2160 assert!(
2161 err.message.contains("detach"),
2162 "error message must explain the detach/timeout_secs conflict: {}",
2163 err.message
2164 );
2165 let tasks = state.task_store.list().await.expect("task list");
2166 assert!(
2167 tasks.is_empty(),
2168 "the 400 must fire before any TaskRecord is minted"
2169 );
2170 }
2171
2172 #[tokio::test]
2176 async fn rekick_detached_returns_202_and_completes_in_background() {
2177 let state = test_state();
2178 let posted = crate::tasks_start(
2179 State(state.clone()),
2180 Json(post_tasks_req("detached rekick goal")),
2181 )
2182 .await
2183 .expect("tasks_start")
2184 .0;
2185
2186 let (status, rekicked) = task_rekick(
2187 State(state.clone()),
2188 Path(posted.task_id.to_string()),
2189 Some(Json(RunKickRequest {
2190 init_ctx_override: None,
2191 task_input_override: None,
2192 timeout_secs: None,
2193 detach: true,
2194 operator_sid: None,
2195 })),
2196 )
2197 .await
2198 .expect("task_rekick (detached)");
2199 assert_eq!(status, StatusCode::ACCEPTED);
2200 assert_eq!(rekicked.0.status, RunStatus::Running);
2201 assert_ne!(rekicked.0.run_id, posted.run_id);
2202
2203 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
2204 assert_eq!(rec.status, RunStatus::Done);
2205 assert!(
2206 rec.result_ref.is_some(),
2207 "finalize_run must persist the background rekick's final_ctx"
2208 );
2209 }
2210
2211 #[tokio::test]
2215 async fn rekick_detached_with_timeout_secs_rejected() {
2216 let state = test_state();
2217 let posted = crate::tasks_start(
2218 State(state.clone()),
2219 Json(post_tasks_req("detached rekick ceiling goal")),
2220 )
2221 .await
2222 .expect("tasks_start")
2223 .0;
2224
2225 let err = match task_rekick(
2226 State(state.clone()),
2227 Path(posted.task_id.to_string()),
2228 Some(Json(RunKickRequest {
2229 init_ctx_override: None,
2230 task_input_override: None,
2231 timeout_secs: Some(60),
2232 detach: true,
2233 operator_sid: None,
2234 })),
2235 )
2236 .await
2237 {
2238 Err(e) => e,
2239 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
2240 };
2241 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2242 assert!(
2243 err.message.contains("detach"),
2244 "error message must explain the detach/timeout_secs conflict: {}",
2245 err.message
2246 );
2247 let runs = state
2248 .run_store
2249 .list_by_task(&posted.task_id)
2250 .await
2251 .expect("runs list");
2252 assert_eq!(
2253 runs.len(),
2254 1,
2255 "the 400 must fire before a second Run is minted"
2256 );
2257 }
2258
2259 #[tokio::test]
2260 async fn rekick_adds_a_second_run_to_the_same_task() {
2261 let state = test_state();
2262 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
2263 .await
2264 .expect("tasks_start")
2265 .0;
2266 let task_id = posted.task_id.clone();
2267 let first_run_id = posted.run_id.clone();
2268
2269 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
2270 .await
2271 .expect("task_rekick");
2272 assert_eq!(status, StatusCode::CREATED);
2273 let second_run_id = rekicked.0.run_id.clone();
2274 assert_ne!(first_run_id, second_run_id);
2275
2276 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2277 .await
2278 .expect("task_get")
2279 .0;
2280 assert_eq!(
2281 detail.runs.len(),
2282 2,
2283 "expected 2 runs, got {:?}",
2284 detail.runs
2285 );
2286 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
2287 assert!(ids.contains(&&first_run_id));
2288 assert!(ids.contains(&&second_run_id));
2289
2290 let first_run = detail
2295 .runs
2296 .iter()
2297 .find(|r| r.id == first_run_id)
2298 .expect("first run present in detail.runs");
2299 let second_run = detail
2300 .runs
2301 .iter()
2302 .find(|r| r.id == second_run_id)
2303 .expect("second run present in detail.runs");
2304 assert_eq!(
2305 first_run.step_entries.len(),
2306 1,
2307 "first run step_entries: {:?}",
2308 first_run.step_entries
2309 );
2310 assert_eq!(
2311 second_run.step_entries.len(),
2312 1,
2313 "second run step_entries: {:?}",
2314 second_run.step_entries
2315 );
2316 assert_eq!(
2317 first_run.step_entries[0].step_ref,
2318 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2319 );
2320 assert_eq!(
2321 second_run.step_entries[0].step_ref,
2322 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2323 );
2324 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
2325 assert_eq!(
2326 second_run.step_entries[0].status,
2327 Some("passed".to_string())
2328 );
2329 assert_ne!(
2330 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
2331 "each kick dispatches its own StepId — runs must not share step_entries"
2332 );
2333 }
2334
2335 #[tokio::test]
2336 async fn rekick_unknown_task_returns_404() {
2337 let state = test_state();
2338 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
2342 Ok(_) => panic!("expected 404 for an unknown task"),
2343 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2344 }
2345 }
2346
2347 fn greeting_blueprint() -> Blueprint {
2356 Blueprint {
2357 schema_version: current_schema_version(),
2358 id: "tasks-test-greeting-bp".into(),
2359 flow: serde_json::from_value(serde_json::json!({
2360 "kind": "step",
2361 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
2362 "in": {"op": "path", "at": "$.greeting"},
2363 "out": {"op": "path", "at": "$.out"},
2364 }))
2365 .expect("flow parse"),
2366 agents: vec![AgentDef {
2367 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
2368 kind: AgentKind::RustFn,
2369 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
2370 profile: None,
2371 meta: None,
2372 runner: None,
2373 runner_ref: None,
2374 verdict: None,
2375 }],
2376 operators: vec![],
2377 metas: vec![],
2378 hints: CompilerHints::default(),
2379 strategy: CompilerStrategy::default(),
2380 metadata: BlueprintMetadata::default(),
2381 spawner_hints: Default::default(),
2382 default_agent_kind: AgentKind::Operator,
2383 default_operator_kind: None,
2384 default_init_ctx: None,
2385 default_agent_ctx: None,
2386 default_context_policy: None,
2387 projection_placement: None,
2388 audits: vec![],
2389 degradation_policy: None,
2390 runners: vec![],
2391 default_runner: None,
2392 subprocesses: vec![],
2393 check_policy: None,
2394 blueprint_ref_includes: Vec::new(),
2395 }
2396 }
2397
2398 fn post_greeting_task_req(
2399 greeting: &str,
2400 project_root: Option<&str>,
2401 ) -> crate::TaskLaunchRequest {
2402 crate::TaskLaunchRequest {
2403 blueprint: BlueprintRef::Inline {
2404 value: Box::new(greeting_blueprint()),
2405 },
2406 init_ctx: serde_json::json!({ "greeting": greeting }),
2407 project_root: project_root.map(str::to_string),
2408 work_dir: None,
2409 task_metadata: None,
2410 ttl_secs: None,
2411 operator: None,
2412 operator_sid: None,
2413 timeout_secs: None,
2414 goal: Some("st4 rekick goal".to_string()),
2415 detach: false,
2416 check_policy: None,
2417 }
2418 }
2419
2420 #[tokio::test]
2421 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2422 let state = test_state();
2425 let posted = crate::tasks_start(
2426 State(state.clone()),
2427 Json(post_greeting_task_req("from-task", None)),
2428 )
2429 .await
2430 .expect("tasks_start")
2431 .0;
2432 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2433
2434 let (status, rekicked) =
2435 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2436 .await
2437 .expect("task_rekick");
2438 assert_eq!(status, StatusCode::CREATED);
2439
2440 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2441 .await
2442 .expect("run_get")
2443 .0;
2444 assert_eq!(
2445 run.result_ref.expect("result_ref present")["out"]["echoed"],
2446 "from-task"
2447 );
2448 }
2449
2450 #[tokio::test]
2451 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2452 let state = test_state();
2453 let posted = crate::tasks_start(
2454 State(state.clone()),
2455 Json(post_greeting_task_req("from-task", None)),
2456 )
2457 .await
2458 .expect("tasks_start")
2459 .0;
2460 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2461
2462 let (status, rekicked) = task_rekick(
2463 State(state.clone()),
2464 Path(posted.task_id.to_string()),
2465 Some(Json(RunKickRequest {
2466 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2467 task_input_override: None,
2468 timeout_secs: None,
2469 detach: false,
2470 operator_sid: None,
2471 })),
2472 )
2473 .await
2474 .expect("task_rekick");
2475 assert_eq!(status, StatusCode::CREATED);
2476
2477 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2478 .await
2479 .expect("run_get")
2480 .0;
2481 assert_eq!(
2482 run.result_ref.expect("result_ref present")["out"]["echoed"],
2483 "from-run",
2484 "Run's init_ctx_override must win over the stored Task input_ctx"
2485 );
2486 }
2487
2488 #[tokio::test]
2489 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2490 let state = test_state();
2498 let posted = crate::tasks_start(
2499 State(state.clone()),
2500 Json(post_greeting_task_req("from-task", Some("/repo"))),
2501 )
2502 .await
2503 .expect("tasks_start")
2504 .0;
2505
2506 let before = state
2507 .task_store
2508 .get(&posted.task_id)
2509 .await
2510 .expect("task fetch");
2511 let before_spec: Option<TaskInputSpec> = before
2512 .task_input_spec
2513 .as_ref()
2514 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2515 assert_eq!(
2516 before_spec,
2517 Some(TaskInputSpec {
2518 project_root: Some("/repo".to_string()),
2519 work_dir: None,
2520 task_metadata: None,
2521 })
2522 );
2523
2524 let (status, _rekicked) =
2525 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2526 .await
2527 .expect("task_rekick");
2528 assert_eq!(status, StatusCode::CREATED);
2529
2530 let after = state
2531 .task_store
2532 .get(&posted.task_id)
2533 .await
2534 .expect("task fetch");
2535 assert_eq!(
2536 after.task_input_spec, before.task_input_spec,
2537 "rekick must not mutate the stored Task-level task_input_spec snapshot"
2538 );
2539 }
2540
2541 #[tokio::test]
2542 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2543 let state = test_state();
2546 let posted = crate::tasks_start(
2547 State(state.clone()),
2548 Json(post_greeting_task_req("from-task", Some("/repo"))),
2549 )
2550 .await
2551 .expect("tasks_start")
2552 .0;
2553
2554 let (status, _rekicked) = task_rekick(
2555 State(state.clone()),
2556 Path(posted.task_id.to_string()),
2557 Some(Json(RunKickRequest {
2558 init_ctx_override: None,
2559 task_input_override: Some(TaskInputSpec {
2560 project_root: Some("/override".to_string()),
2561 work_dir: None,
2562 task_metadata: None,
2563 }),
2564 timeout_secs: None,
2565 detach: false,
2566 operator_sid: None,
2567 })),
2568 )
2569 .await
2570 .expect("task_rekick");
2571 assert_eq!(status, StatusCode::CREATED);
2572
2573 let after = state
2574 .task_store
2575 .get(&posted.task_id)
2576 .await
2577 .expect("task fetch");
2578 let after_spec: Option<TaskInputSpec> = after
2579 .task_input_spec
2580 .as_ref()
2581 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2582 assert_eq!(
2583 after_spec,
2584 Some(TaskInputSpec {
2585 project_root: Some("/repo".to_string()),
2586 work_dir: None,
2587 task_metadata: None,
2588 }),
2589 "a per-Run task_input_override must not leak into the stored TaskRecord"
2590 );
2591 }
2592
2593 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
2607 crate::TaskLaunchRequest {
2608 blueprint: BlueprintRef::Inline {
2609 value: Box::new(identity_blueprint_with_operator_delegate()),
2610 },
2611 init_ctx: serde_json::json!({"in": "hello"}),
2612 project_root: None,
2613 work_dir: None,
2614 task_metadata: None,
2615 ttl_secs: None,
2616 operator: None,
2617 operator_sid: None,
2618 timeout_secs: None,
2619 goal: Some(goal.to_string()),
2620 detach: false,
2621 check_policy: None,
2622 }
2623 }
2624
2625 #[tokio::test]
2630 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
2631 let state = test_state();
2632 let posted = crate::tasks_start(
2633 State(state.clone()),
2634 Json(delegate_launch_req("operator delegate rekick goal")),
2635 )
2636 .await
2637 .expect("tasks_start (no operator referenced, dispatches through baseline)")
2638 .0;
2639 let started = std::time::Instant::now();
2643 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2644 let elapsed = started.elapsed();
2645
2646 let err = match result {
2647 Err(e) => e,
2648 Ok(_) => panic!(
2649 "rekicking a Task whose Blueprint declares operator_delegate with zero \
2650 attached operators must fail, not dispatch"
2651 ),
2652 };
2653 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2654 assert!(
2655 err.message.contains("no operator attached"),
2656 "error message must mention the missing operator: {}",
2657 err.message
2658 );
2659 assert!(
2660 elapsed < Duration::from_secs(1),
2661 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2662 );
2663 }
2664
2665 #[tokio::test]
2669 async fn rekick_stalled_operator_times_out() {
2670 let state = test_state();
2671 state
2672 .engine
2673 .register_operator("stall-op", Arc::new(StallingOperator))
2674 .await;
2675 let posted = crate::tasks_start(
2676 State(state.clone()),
2677 Json(delegate_launch_req("stalled rekick goal")),
2678 )
2679 .await
2680 .expect("tasks_start")
2681 .0;
2682
2683 let started = std::time::Instant::now();
2684 let result = tokio::time::timeout(
2688 Duration::from_secs(5),
2689 task_rekick(
2690 State(state),
2691 Path(posted.task_id.to_string()),
2692 Some(Json(RunKickRequest {
2693 init_ctx_override: None,
2694 task_input_override: None,
2695 timeout_secs: Some(1),
2696 detach: false,
2697 operator_sid: None,
2698 })),
2699 ),
2700 )
2701 .await
2702 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
2703 let elapsed = started.elapsed();
2704
2705 match &result {
2706 Err(e) => {
2707 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
2708 assert!(
2709 e.message.contains('1'),
2710 "error message must mention the configured 1s ceiling: {}",
2711 e.message
2712 );
2713 assert!(
2714 elapsed < Duration::from_secs(3),
2715 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2716 );
2717 }
2718 Ok(_) => {
2719 assert!(
2731 elapsed < Duration::from_secs(1),
2732 "a rekick that never engages an Operator (task_rekick has no \
2733 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
2734 );
2735 }
2736 }
2737 }
2738
2739 #[tokio::test]
2743 async fn rekick_timeout_secs_zero_rejected() {
2744 let state = test_state();
2745 let posted = crate::tasks_start(
2746 State(state.clone()),
2747 Json(post_tasks_req("zero timeout rekick goal")),
2748 )
2749 .await
2750 .expect("tasks_start")
2751 .0;
2752
2753 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2754 .await
2755 .expect("task_get")
2756 .0;
2757 let runs_before = before.runs.len();
2758
2759 let result = task_rekick(
2760 State(state.clone()),
2761 Path(posted.task_id.to_string()),
2762 Some(Json(RunKickRequest {
2763 init_ctx_override: None,
2764 task_input_override: None,
2765 timeout_secs: Some(0),
2766 detach: false,
2767 operator_sid: None,
2768 })),
2769 )
2770 .await;
2771 let err = match result {
2772 Err(e) => e,
2773 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2774 };
2775 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2776 assert!(
2777 err.message.contains("timeout_secs"),
2778 "error message must reference timeout_secs: {}",
2779 err.message
2780 );
2781
2782 let after = task_get(State(state), Path(posted.task_id.to_string()))
2783 .await
2784 .expect("task_get")
2785 .0;
2786 assert_eq!(
2787 after.runs.len(),
2788 runs_before,
2789 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
2790 );
2791 }
2792
2793 #[tokio::test]
2797 async fn rekick_non_operator_path_unaffected_by_guard_1() {
2798 let state = test_state();
2799 let posted = crate::tasks_start(
2800 State(state.clone()),
2801 Json(post_tasks_req("non-operator rekick goal")),
2802 )
2803 .await
2804 .expect("tasks_start")
2805 .0;
2806
2807 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2808 if let Err(e) = &result {
2809 panic!(
2810 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
2811 guard 1: {}",
2812 e.message
2813 );
2814 }
2815 }
2816
2817 #[tokio::test]
2825 async fn rekick_unknown_operator_sid_rejected_before_side_effects() {
2826 let state = test_state();
2827 let posted = crate::tasks_start(
2828 State(state.clone()),
2829 Json(post_tasks_req("unknown operator_sid rekick goal")),
2830 )
2831 .await
2832 .expect("tasks_start")
2833 .0;
2834
2835 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2836 .await
2837 .expect("task_get")
2838 .0;
2839 let runs_before = before.runs.len();
2840
2841 let result = task_rekick(
2842 State(state.clone()),
2843 Path(posted.task_id.to_string()),
2844 Some(Json(RunKickRequest {
2845 init_ctx_override: None,
2846 task_input_override: None,
2847 timeout_secs: None,
2848 detach: false,
2849 operator_sid: Some("S-not-registered".to_string()),
2850 })),
2851 )
2852 .await;
2853 let err = match result {
2854 Err(e) => e,
2855 Ok(_) => panic!("an unknown operator_sid must be rejected, not dispatched"),
2856 };
2857 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2858 assert!(
2859 err.message.contains("operator_sid"),
2860 "error message must reference operator_sid: {}",
2861 err.message
2862 );
2863
2864 let after = task_get(State(state), Path(posted.task_id.to_string()))
2865 .await
2866 .expect("task_get")
2867 .0;
2868 assert_eq!(
2869 after.runs.len(),
2870 runs_before,
2871 "a rejected unknown-operator_sid rekick must not create a new Run"
2872 );
2873 }
2874
2875 #[tokio::test]
2883 async fn rekick_with_registered_operator_sid_persists_it_on_the_run() {
2884 let state = test_state();
2885 state
2889 .engine
2890 .register_operator("S-live-op", Arc::new(StallingOperator))
2891 .await;
2892 let posted = crate::tasks_start(
2893 State(state.clone()),
2894 Json(post_tasks_req("registered operator_sid rekick goal")),
2895 )
2896 .await
2897 .expect("tasks_start")
2898 .0;
2899
2900 let (status, rekicked) = task_rekick(
2901 State(state.clone()),
2902 Path(posted.task_id.to_string()),
2903 Some(Json(RunKickRequest {
2904 init_ctx_override: None,
2905 task_input_override: None,
2906 timeout_secs: None,
2907 detach: false,
2908 operator_sid: Some("S-live-op".to_string()),
2909 })),
2910 )
2911 .await
2912 .expect("task_rekick with a registered operator_sid");
2913 assert_eq!(status, StatusCode::CREATED);
2914
2915 let run = state
2916 .run_store
2917 .get(&rekicked.0.run_id)
2918 .await
2919 .expect("run get");
2920 assert_eq!(
2921 run.operator_sid,
2922 Some("S-live-op".to_string()),
2923 "the pinned operator_sid must be persisted verbatim on the RunRecord"
2924 );
2925 }
2926
2927 #[tokio::test]
2928 async fn run_get_unknown_id_returns_404() {
2929 let state = test_state();
2930 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
2931 Ok(_) => panic!("expected 404 for an unknown run"),
2932 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2933 }
2934 }
2935
2936 #[tokio::test]
2937 async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
2938 let state = test_state();
2939 let posted = crate::tasks_start(
2940 State(state.clone()),
2941 Json(post_tasks_req("binding explain")),
2942 )
2943 .await
2944 .expect("tasks_start")
2945 .0;
2946 let run = state
2947 .run_store
2948 .get(&posted.run_id)
2949 .await
2950 .expect("stored run");
2951 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2952 let mut bound_agents: Vec<BoundAgent> =
2953 serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
2954 let bound = &mut bound_agents[0];
2955 bound.runner = Some(Runner::WsClaudeCode {
2956 variant: "coder".to_string(),
2957 tools: vec!["Read".to_string()],
2958 });
2959 bound.recompute_binding_digest().unwrap();
2960 let request_digest = bound.binding_digest.clone();
2961 bound
2962 .set_attestation(BindingAttestation {
2963 request_digest: request_digest.clone(),
2964 provider_id: "operator-manifest".to_string(),
2965 provider_revision: Some("claude-code-1.2".to_string()),
2966 resolved_model: Some("claude-sonnet-4".to_string()),
2967 effective_tools: vec!["Bash".to_string(), "Read".to_string()],
2968 launch_variant: Some("coder".to_string()),
2969 capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
2970 b"manifest-v1",
2971 )),
2972 })
2973 .unwrap();
2974 snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
2975 state
2976 .run_store
2977 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2978 .await
2979 .unwrap();
2980
2981 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2982 .await
2983 .expect("binding explain")
2984 .0;
2985 let entry = &explained.bindings[0];
2986 assert_eq!(entry.status, RunBindingStatus::Attested);
2987 assert_eq!(
2988 entry.requested.as_ref().unwrap().request_digest,
2989 request_digest
2990 );
2991 assert_eq!(
2992 entry
2993 .effective
2994 .as_ref()
2995 .unwrap()
2996 .provider_revision
2997 .as_deref(),
2998 Some("claude-code-1.2")
2999 );
3000 assert_eq!(
3001 entry
3002 .difference
3003 .as_ref()
3004 .unwrap()
3005 .additional_effective_tools,
3006 vec!["Bash"]
3007 );
3008 assert!(entry
3009 .difference
3010 .as_ref()
3011 .unwrap()
3012 .missing_requested_tools
3013 .is_empty());
3014 assert_ne!(entry.binding_digest, request_digest);
3015 }
3016
3017 #[tokio::test]
3018 async fn run_bindings_explain_reports_snapshot_origin() {
3019 let state = test_state();
3020 let posted =
3021 crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
3022 .await
3023 .expect("tasks_start")
3024 .0;
3025
3026 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3028 .await
3029 .expect("binding explain")
3030 .0;
3031 assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
3032
3033 let run = state.run_store.get(&posted.run_id).await.unwrap();
3035 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3036 snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
3037 state
3038 .run_store
3039 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3040 .await
3041 .unwrap();
3042 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3043 .await
3044 .expect("binding explain")
3045 .0;
3046 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3047
3048 snapshot
3052 .as_object_mut()
3053 .unwrap()
3054 .remove("bound_agents_origin");
3055 state
3056 .run_store
3057 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3058 .await
3059 .unwrap();
3060 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3061 .await
3062 .expect("explain still 200 without an origin marker")
3063 .0;
3064 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3065 }
3066
3067 #[tokio::test]
3068 async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
3069 let state = test_state();
3070 let posted = crate::tasks_start(
3071 State(state.clone()),
3072 Json(post_tasks_req("legacy binding explain")),
3073 )
3074 .await
3075 .expect("tasks_start")
3076 .0;
3077 state
3078 .run_store
3079 .set_input_json(&posted.run_id, "{}".to_string())
3080 .await
3081 .unwrap();
3082
3083 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3084 .await
3085 .expect_err("legacy run must not be re-resolved");
3086 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3087 assert!(error
3088 .message
3089 .contains("current Blueprint state was not consulted"));
3090 }
3091
3092 #[tokio::test]
3093 async fn run_bindings_explain_rejects_a_tampered_snapshot() {
3094 let state = test_state();
3095 let posted = crate::tasks_start(
3096 State(state.clone()),
3097 Json(post_tasks_req("tampered binding explain")),
3098 )
3099 .await
3100 .expect("tasks_start")
3101 .0;
3102 let run = state.run_store.get(&posted.run_id).await.unwrap();
3103 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3104 snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
3105 state
3106 .run_store
3107 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3108 .await
3109 .unwrap();
3110
3111 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3112 .await
3113 .expect_err("digest drift must fail closed");
3114 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3115 assert!(error.message.contains("inconsistent binding snapshot"));
3116 }
3117
3118 #[tokio::test]
3119 async fn task_get_unknown_id_returns_404() {
3120 let state = test_state();
3121 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
3122 Ok(_) => panic!("expected 404 for an unknown task"),
3123 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3124 }
3125 }
3126
3127 async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
3134 let task_id = TaskId::new();
3135 let run_id = RunId::new();
3136 state
3137 .task_store
3138 .create(TaskRecord {
3139 id: task_id.clone(),
3140 goal: "finalize-run-err-envelope".to_string(),
3141 blueprint_ref: json!("inline"),
3142 input_ctx: Value::Null,
3143 task_input_spec: None,
3144 status: TaskRecordStatus::Running,
3145 created_at: 0,
3146 updated_at: 0,
3147 })
3148 .await
3149 .expect("seed TaskRecord");
3150 state
3151 .run_store
3152 .create(RunRecord {
3153 id: run_id.clone(),
3154 task_id: task_id.clone(),
3155 status: RunStatus::Running,
3156 step_entries: Vec::new(),
3157 degradations: Vec::new(),
3158 operator_sid: None,
3159 result_ref: None,
3160 input_json: Some("{}".to_string()),
3161 created_at: 0,
3162 updated_at: 0,
3163 })
3164 .await
3165 .expect("seed RunRecord");
3166 (task_id, run_id)
3167 }
3168
3169 #[tokio::test]
3170 async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
3171 let state = test_state();
3172 let (task_id, run_id) = seed_task_and_run(&state).await;
3173
3174 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3175 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3176 message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
3177 failed_step: Some("gate".to_string()),
3178 verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
3179 partial_ctx: Some(
3180 json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
3181 ),
3182 }));
3183
3184 let _ = finalize_run(&state, &task_id, &run_id, err).await;
3185
3186 let run = state.run_store.get(&run_id).await.expect("run present");
3187 assert_eq!(run.status, RunStatus::Failed);
3188 let envelope = run
3189 .result_ref
3190 .as_ref()
3191 .expect("result_ref must be Some on Err arm");
3192 assert_eq!(
3193 envelope["error"]["message"],
3194 "blocked: {\"verdict\":\"BLOCKED\"}"
3195 );
3196 assert_eq!(envelope["error"]["failed_step"], "gate");
3197 assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
3198 assert_eq!(
3199 envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
3200 "blocked"
3201 );
3202
3203 let task = state.task_store.get(&task_id).await.expect("task present");
3205 assert_eq!(task.status, TaskRecordStatus::Failed);
3206 }
3207
3208 #[tokio::test]
3209 async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
3210 let state = test_state();
3211 let (task_id, run_id) = seed_task_and_run(&state).await;
3212
3213 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3217 Err(TaskApplicationError::NoStore);
3218
3219 let _ = finalize_run(&state, &task_id, &run_id, err).await;
3220 let run = state.run_store.get(&run_id).await.expect("run present");
3221 let envelope = run
3222 .result_ref
3223 .as_ref()
3224 .expect("result_ref must be Some on Err arm");
3225 assert!(envelope["error"]["message"]
3226 .as_str()
3227 .expect("message string")
3228 .contains("store"));
3229 assert_eq!(envelope["error"]["failed_step"], Value::Null);
3230 assert_eq!(envelope["error"]["verdict_value"], Value::Null);
3231 assert_eq!(envelope["partial_ctx"], Value::Null);
3232 }
3233
3234 #[tokio::test]
3239 async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
3240 let state = test_state();
3241 let (task_id, run_id) = seed_task_and_run(&state).await;
3242
3243 let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
3244 token: mlua_swarm::CapToken {
3245 agent_id: "ut".to_string(),
3246 role: mlua_swarm::Role::Operator,
3247 scopes: vec!["*".to_string()],
3248 issued_at: 0,
3249 expire_at: u64::MAX,
3250 max_uses: None,
3251 nonce: "ut-nonce".to_string(),
3252 sig_hex: String::new(),
3253 },
3254 final_ctx: json!({"out": {"echoed": "hi"}}),
3255 bound_version: None,
3256 });
3257
3258 let _ = finalize_run(&state, &task_id, &run_id, ok).await;
3259 let run = state.run_store.get(&run_id).await.expect("run present");
3260 assert_eq!(run.status, RunStatus::Done);
3261 let stored = run.result_ref.as_ref().expect("result_ref Some");
3262 assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
3264 assert!(
3265 stored.get("error").is_none(),
3266 "Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
3267 );
3268 }
3269
3270 #[tokio::test]
3275 async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
3276 let state = test_state();
3277 let (_task_id, run_id) = seed_task_and_run(&state).await;
3278 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3279 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3280 message: "blocked: bad verdict".to_string(),
3281 failed_step: Some("scout".to_string()),
3282 verdict_value: Some(json!("BLOCKED")),
3283 partial_ctx: Some(json!({"steps": {}})),
3284 }));
3285 let _ = finalize_run(&state, &_task_id, &run_id, err).await;
3286
3287 let Json(run) = run_get(State(state), Path(run_id.to_string()))
3288 .await
3289 .expect("run_get");
3290 assert_eq!(run.status, RunStatus::Failed);
3291 let envelope = run.result_ref.expect("result_ref Some");
3292 assert_eq!(envelope["error"]["failed_step"], "scout");
3293 assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
3294 }
3295}