1use axum::{
38 extract::{Path, Query, State},
39 http::StatusCode,
40 Json,
41};
42use futures_util::FutureExt;
43use mlua_swarm::application::{
44 BlueprintRef, TaskApplicationError, TaskApplicationInput, TaskApplicationOutput,
45};
46use mlua_swarm::blueprint::{BindRequest, BindingAttestation, BoundAgent};
47use mlua_swarm::core::config::CheckPolicy;
48use mlua_swarm::service::merge_init_ctx_3layer;
49use mlua_swarm::service::TaskLaunchError;
50use mlua_swarm::store::replay::ReplayCursor;
51use mlua_swarm::store::run::{
52 RunContext, RunListFilter, RunRecord, RunStatus, RunStoreError, SnapshotOrigin, StepEntry,
53};
54use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
55use mlua_swarm::store::trace::{kind as trace_kind, TraceEvent, TraceHandle, TraceQuery};
56use mlua_swarm::{
57 validate_bound_agent_snapshots, OperatorKind, Role, RunId, TaskId, TaskInputSpec,
58};
59use serde::{Deserialize, Serialize};
60use serde_json::{json, Value};
61use std::collections::HashMap;
62use std::panic::AssertUnwindSafe;
63use std::sync::{Arc, Mutex};
64use std::time::Duration;
65
66use crate::{ApiError, AppState};
67
68pub(crate) fn now_secs() -> u64 {
72 std::time::SystemTime::now()
73 .duration_since(std::time::UNIX_EPOCH)
74 .map(|d| d.as_secs())
75 .unwrap_or(0)
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
92pub(crate) struct RunLaunchSnapshot {
93 blueprint: BlueprintRef,
94 operator_id: String,
95 role: Role,
96 ttl: Duration,
97 init_ctx: Value,
98 operator_kind: Option<OperatorKind>,
99 bridge_id: Option<String>,
100 hook_id: Option<String>,
101 operator_backend_id: Option<String>,
102 #[serde(default)]
106 operator_pin: Option<String>,
107 #[serde(default)]
108 operator_kind_overrides: HashMap<String, OperatorKind>,
109 task_input: Option<TaskInputSpec>,
110 check_policy: Option<CheckPolicy>,
111}
112
113impl RunLaunchSnapshot {
114 fn from_input(input: &TaskApplicationInput) -> Self {
117 Self {
118 blueprint: input.blueprint.clone(),
119 operator_id: input.operator_id.clone(),
120 role: input.role,
121 ttl: input.ttl,
122 init_ctx: input.init_ctx.clone(),
123 operator_kind: input.operator_kind,
124 bridge_id: input.bridge_id.clone(),
125 hook_id: input.hook_id.clone(),
126 operator_backend_id: input.operator_backend_id.clone(),
127 operator_pin: input.operator_pin.clone(),
128 operator_kind_overrides: input.operator_kind_overrides.clone(),
129 task_input: input.task_input.clone(),
130 check_policy: input.check_policy,
131 }
132 }
133
134 fn into_input(self) -> TaskApplicationInput {
136 TaskApplicationInput {
137 blueprint: self.blueprint,
138 operator_id: self.operator_id,
139 role: self.role,
140 ttl: self.ttl,
141 init_ctx: self.init_ctx,
142 operator_kind: self.operator_kind,
143 bridge_id: self.bridge_id,
144 hook_id: self.hook_id,
145 operator_backend_id: self.operator_backend_id,
146 operator_pin: self.operator_pin,
147 operator_kind_overrides: self.operator_kind_overrides,
148 task_input: self.task_input,
149 check_policy: self.check_policy,
150 }
151 }
152}
153
154pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
161 serde_json::to_string(&RunLaunchSnapshot::from_input(input))
162 .map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
163}
164
165pub(crate) async fn finalize_run(
175 state: &AppState,
176 task_id: &TaskId,
177 run_id: &RunId,
178 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
179) -> Result<TaskApplicationOutput, TaskApplicationError> {
180 match &outcome {
181 Ok(out) => {
182 if let Err(e) = state
183 .run_store
184 .set_result(run_id, out.final_ctx.clone())
185 .await
186 {
187 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
188 }
189 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
190 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
191 }
192 if let Err(e) = state
193 .task_store
194 .update_status(task_id, TaskRecordStatus::Done)
195 .await
196 {
197 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
198 }
199 }
200 Err(e) => {
201 let envelope = match e {
229 TaskApplicationError::Launch(TaskLaunchError::FlowEval {
230 message,
231 failed_step,
232 verdict_value,
233 partial_ctx,
234 }) => json!({
235 "error": {
236 "message": message,
237 "failed_step": failed_step,
238 "verdict_value": verdict_value,
239 },
240 "partial_ctx": partial_ctx,
241 }),
242 other => json!({
243 "error": {
244 "message": other.to_string(),
245 "failed_step": Value::Null,
246 "verdict_value": Value::Null,
247 },
248 "partial_ctx": Value::Null,
249 }),
250 };
251 if let Err(store_err) = state.run_store.set_result(run_id, envelope).await {
252 tracing::warn!(%run_id, error = %store_err, "finalize_run: set_result (failure envelope) failed");
253 }
254 if let Err(store_err) = state
255 .run_store
256 .update_status(run_id, RunStatus::Failed)
257 .await
258 {
259 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
260 }
261 if let Err(store_err) = state
262 .task_store
263 .update_status(task_id, TaskRecordStatus::Failed)
264 .await
265 {
266 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
267 }
268 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
269 }
270 }
271 let status = if outcome.is_ok() { "done" } else { "failed" };
275 TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
276 .append(
277 trace_kind::RUN_FINISHED,
278 None,
279 None,
280 json!({ "status": status }),
281 )
282 .await;
283 outcome
284}
285
286fn panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
291 if let Some(s) = payload.downcast_ref::<&'static str>() {
292 (*s).to_string()
293 } else if let Some(s) = payload.downcast_ref::<String>() {
294 s.clone()
295 } else {
296 "non-string panic payload".to_string()
297 }
298}
299
300pub(crate) async fn mark_run_interrupted_by_panic(
315 state: &AppState,
316 task_id: &TaskId,
317 run_id: &RunId,
318 site: &str,
319 payload: &str,
320) {
321 match state
322 .run_store
323 .try_transition(run_id, RunStatus::Running, RunStatus::Interrupted)
324 .await
325 {
326 Ok(true) => {}
327 Ok(false) => {
328 tracing::warn!(
329 %run_id,
330 site,
331 "run driver panicked, but the Run is no longer `Running` — leaving its terminal status untouched"
332 );
333 return;
334 }
335 Err(e) => {
336 tracing::warn!(%run_id, error = %e, "panic guard: run try_transition(Running -> Interrupted) failed");
337 return;
338 }
339 }
340
341 let envelope = json!({ "error": format!("run driver panicked at {site}: {payload}") });
342 if let Err(e) = state.run_store.set_result(run_id, envelope).await {
343 tracing::warn!(%run_id, error = %e, "panic guard: set_result failed");
344 }
345 if let Err(e) = state
346 .task_store
347 .update_status(task_id, TaskRecordStatus::Interrupted)
348 .await
349 {
350 tracing::warn!(%task_id, error = %e, "panic guard: task update_status(Interrupted) failed");
351 }
352 TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
355 .append(
356 trace_kind::RUN_FINISHED,
357 None,
358 None,
359 json!({ "status": "interrupted", "reason": "driver panic" }),
360 )
361 .await;
362}
363
364pub(crate) async fn catch_run_panic<T, F>(
379 state: &AppState,
380 task_id: &TaskId,
381 run_id: &RunId,
382 site: &str,
383 fut: F,
384) -> Result<T, String>
385where
386 F: std::future::Future<Output = T>,
387{
388 match AssertUnwindSafe(fut).catch_unwind().await {
389 Ok(value) => Ok(value),
390 Err(payload) => {
391 let message = panic_payload_to_string(payload);
392 tracing::error!(
393 %task_id,
394 %run_id,
395 site,
396 payload = %message,
397 "run driver panicked — marking the Run Interrupted"
398 );
399 mark_run_interrupted_by_panic(state, task_id, run_id, site, &message).await;
400 Err(message)
401 }
402 }
403}
404
405#[derive(Debug, Deserialize, Default)]
407pub struct TasksListQuery {
408 #[serde(default)]
411 pub limit: Option<usize>,
412}
413
414pub async fn tasks_list(
416 State(state): State<AppState>,
417 Query(q): Query<TasksListQuery>,
418) -> Result<Json<Vec<TaskRecord>>, ApiError> {
419 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
420 if let Some(limit) = q.limit {
421 records.truncate(limit);
422 }
423 Ok(Json(records))
424}
425
426#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
428pub struct TaskDetailResponse {
429 pub task: TaskRecord,
431 pub runs: Vec<RunRecord>,
433}
434
435pub async fn task_get(
438 State(state): State<AppState>,
439 Path(id): Path<String>,
440) -> Result<Json<TaskDetailResponse>, ApiError> {
441 let task_id =
442 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
443 let task = state
444 .task_store
445 .get(&task_id)
446 .await
447 .map_err(map_task_store_err)?;
448 let runs = state
449 .run_store
450 .list_by_task(&task_id)
451 .await
452 .map_err(ApiError::engine)?;
453 Ok(Json(TaskDetailResponse { task, runs }))
454}
455
456#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
462pub struct RunKickRequest {
463 #[serde(default)]
472 #[schemars(with = "Option<Value>")]
473 pub init_ctx_override: Option<Value>,
474 #[serde(default)]
481 pub task_input_override: Option<TaskInputSpec>,
482 #[serde(default)]
488 pub timeout_secs: Option<u64>,
489 #[serde(default)]
496 pub detach: bool,
497 #[serde(default)]
509 pub operator_sid: Option<String>,
510}
511
512#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
514pub struct RunKickResponse {
515 #[schemars(with = "String")]
517 pub task_id: TaskId,
518 #[schemars(with = "String")]
520 pub run_id: RunId,
521 pub status: RunStatus,
526}
527
528pub async fn task_rekick(
556 State(state): State<AppState>,
557 Path(id): Path<String>,
558 body: Option<Json<RunKickRequest>>,
559) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
560 let task_id =
561 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
562 let task = state
563 .task_store
564 .get(&task_id)
565 .await
566 .map_err(map_task_store_err)?;
567
568 let blueprint_ref: mlua_swarm::application::BlueprintRef =
569 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
570 ApiError::bad_request(format!(
571 "task {task_id}: stored blueprint_ref failed to decode: {e}"
572 ))
573 })?;
574
575 let (resolved_bp, _bound_version) = state
581 .task_app
582 .resolve(&blueprint_ref)
583 .await
584 .map_err(|e| ApiError::from_task_resolve(&e, &format!("task {task_id}: bp resolve")))?;
585
586 let req = body.map(|Json(r)| r).unwrap_or_default();
587
588 let operator_backend_id = match &req.operator_sid {
599 Some(sid) => {
600 let known_ids = state.engine.list_operator_ids().await;
601 if !known_ids.iter().any(|id| id == sid) {
602 return Err(ApiError::bad_request(format!(
603 "operator_sid: no such registered operator session '{sid}'"
604 )));
605 }
606 Some(sid.clone())
607 }
608 None => None,
609 };
610
611 let detach = req.detach;
621 let sync_timeout_secs = match (detach, req.timeout_secs) {
622 (true, Some(_)) => {
623 return Err(ApiError::bad_request(
624 "timeout_secs is the synchronous rekick ceiling and does not apply to a \
625 detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
626 timeout_secs"
627 .into(),
628 ));
629 }
630 (false, Some(0)) => {
631 return Err(ApiError::bad_request(
632 "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
633 ));
634 }
635 (false, Some(v)) => v,
636 (_, None) => state.sync_timeout_secs,
637 };
638
639 if resolved_bp
652 .spawner_hints
653 .layers
654 .iter()
655 .any(|l| l == "operator_delegate")
656 {
657 let attached = state.engine.list_operator_ids().await;
658 if attached.is_empty() {
659 return Err(ApiError::unavailable(format!(
660 "no operator attached to serve this rekick (task {task_id}'s \
661 Blueprint declares the operator_delegate layer): attach an \
662 operator via POST /v1/operators + WS, or use the poll-style \
663 flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
664 )));
665 }
666 }
667
668 let merged_init_ctx = merge_init_ctx_3layer(
669 resolved_bp.default_init_ctx.as_ref(),
670 &task.input_ctx,
671 req.init_ctx_override.as_ref(),
672 );
673
674 let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
678 Some(over) => Some(over),
679 None => task
680 .task_input_spec
681 .as_ref()
682 .map(|v| serde_json::from_value(v.clone()))
683 .transpose()
684 .map_err(|e| {
685 ApiError::bad_request(format!(
686 "task {task_id}: stored task_input_spec failed to decode: {e}"
687 ))
688 })?,
689 };
690
691 let run_id = RunId::new();
692 let now = now_secs();
693
694 let input = TaskApplicationInput {
695 blueprint: blueprint_ref,
696 operator_id: "http-run".to_string(),
697 role: Role::Operator,
698 ttl: Duration::from_secs(crate::default_run_ttl()),
699 init_ctx: merged_init_ctx,
700 operator_kind: None,
701 bridge_id: None,
702 hook_id: None,
703 operator_backend_id,
704 operator_pin: req.operator_sid.clone(),
709 operator_kind_overrides: HashMap::new(),
710 task_input: task_input_spec,
711 check_policy: None,
715 };
716 let input_json = Some(snapshot_launch_input(&input)?);
721
722 state
723 .task_store
724 .update_status(&task_id, TaskRecordStatus::Running)
725 .await
726 .map_err(ApiError::engine)?;
727 state
728 .run_store
729 .create(RunRecord {
730 id: run_id.clone(),
731 task_id: task_id.clone(),
732 status: RunStatus::Running,
733 step_entries: Vec::new(),
734 degradations: Vec::new(),
735 operator_sid: req.operator_sid.clone(),
736 result_ref: None,
737 input_json,
738 created_at: now,
739 updated_at: now,
740 })
741 .await
742 .map_err(ApiError::engine)?;
743
744 let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
745 trace
746 .append(
747 trace_kind::RUN_STARTED,
748 None,
749 None,
750 json!({"mode": "rekick"}),
751 )
752 .await;
753 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
754 .with_replay_store(state.replay_store.clone())
755 .with_trace(trace);
756
757 if detach {
763 let ttl_secs = crate::default_run_ttl();
764 let bg_state = state.clone();
765 let bg_task_id = task_id.clone();
766 let bg_run_id = run_id.clone();
767 let guard_state = state.clone();
769 let guard_task_id = task_id.clone();
770 let guard_run_id = run_id.clone();
771 tokio::spawn(async move {
772 let driver = async move {
773 let outcome = match tokio::time::timeout(
774 Duration::from_secs(ttl_secs),
775 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
776 )
777 .await
778 {
779 Ok(outcome) => outcome,
780 Err(_elapsed) => {
781 let reason = serde_json::json!({
782 "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
783 });
784 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
785 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
786 }
787 if let Err(e) = bg_state
788 .run_store
789 .update_status(&bg_run_id, RunStatus::Failed)
790 .await
791 {
792 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
793 }
794 if let Err(e) = bg_state
795 .task_store
796 .update_status(&bg_task_id, TaskRecordStatus::Failed)
797 .await
798 {
799 tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
800 }
801 TraceHandle::new(bg_run_id.clone(), bg_state.run_trace_store.clone())
804 .append(
805 trace_kind::RUN_FINISHED,
806 None,
807 None,
808 json!({ "status": "failed", "reason": format!("ttl {ttl_secs}s exceeded") }),
809 )
810 .await;
811 return;
812 }
813 };
814 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
817 };
818 let _ = catch_run_panic(
819 &guard_state,
820 &guard_task_id,
821 &guard_run_id,
822 "rekick.detach",
823 driver,
824 )
825 .await;
826 });
827 return Ok((
828 StatusCode::ACCEPTED,
829 Json(RunKickResponse {
830 task_id,
831 run_id,
832 status: RunStatus::Running,
833 }),
834 ));
835 }
836
837 let timed = catch_run_panic(
845 &state,
846 &task_id,
847 &run_id,
848 "rekick.sync",
849 tokio::time::timeout(
850 Duration::from_secs(sync_timeout_secs),
851 state.task_app.handle_with_run(input, Some(run_ctx)),
852 ),
853 )
854 .await
855 .map_err(|msg| {
856 ApiError::engine(format!(
857 "run driver panicked: {msg}; the run was marked Interrupted and can be resumed \
858 via POST /v1/runs/{run_id}/resume"
859 ))
860 })?;
861 let outcome = match timed {
862 Ok(outcome) => outcome,
863 Err(_elapsed) => {
864 let reason = serde_json::json!({
865 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
866 });
867 if let Err(e) = state.run_store.set_result(&run_id, reason).await {
868 tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
869 }
870 if let Err(e) = state
871 .run_store
872 .update_status(&run_id, RunStatus::Failed)
873 .await
874 {
875 tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
876 }
877 if let Err(e) = state
878 .task_store
879 .update_status(&task_id, TaskRecordStatus::Failed)
880 .await
881 {
882 tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
883 }
884 return Err(ApiError::timeout(format!(
885 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
886 )));
887 }
888 };
889 finalize_run(&state, &task_id, &run_id, outcome)
890 .await
891 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
892
893 Ok((
894 StatusCode::CREATED,
895 Json(RunKickResponse {
896 task_id,
897 run_id,
898 status: RunStatus::Done,
899 }),
900 ))
901}
902
903#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
905pub struct RunResumeResponse {
906 #[schemars(with = "String")]
911 pub run_id: RunId,
912 #[schemars(with = "String")]
914 pub task_id: TaskId,
915 pub replayed_steps: usize,
920}
921
922pub async fn run_resume(
948 State(state): State<AppState>,
949 Path(id): Path<String>,
950) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
951 let run_id =
952 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
953
954 let run = state
956 .run_store
957 .get(&run_id)
958 .await
959 .map_err(map_run_store_err)?;
960
961 if run.status != RunStatus::Interrupted {
963 return Err(ApiError::conflict(format!(
964 "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
965 run.status
966 )));
967 }
968
969 let Some(input_json) = run.input_json.clone() else {
974 return Err(ApiError::unprocessable(format!(
975 "run {run_id} cannot be resumed: no launch input was recorded for it (it \
976 predates resume support, or was created by a path that does not persist one)"
977 )));
978 };
979 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
980 ApiError::unprocessable(format!(
981 "run {run_id}: stored launch input failed to decode: {e}"
982 ))
983 })?;
984 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
985 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
986 ApiError::unprocessable(format!(
987 "run {run_id}: stored launch input failed to decode: {e}"
988 ))
989 })?;
990
991 let won = state
995 .run_store
996 .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
997 .await
998 .map_err(ApiError::engine)?;
999 if !won {
1000 return Err(ApiError::conflict(format!(
1001 "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
1002 no longer resumable"
1003 )));
1004 }
1005
1006 let entries = state
1010 .replay_store
1011 .list_by_run(&run_id)
1012 .await
1013 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
1014 let replayed_steps = entries.len();
1015 let cursor = ReplayCursor::from_entries(entries);
1016
1017 let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
1022 trace
1023 .append(
1024 trace_kind::RUN_STARTED,
1025 None,
1026 None,
1027 json!({"mode": "resume"}),
1028 )
1029 .await;
1030 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1031 .with_replay_store(state.replay_store.clone())
1032 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1033 .with_resume()
1034 .with_trace(trace);
1035
1036 let input = snapshot.into_input();
1037 let task_id = run.task_id.clone();
1038
1039 state
1042 .task_store
1043 .update_status(&task_id, TaskRecordStatus::Running)
1044 .await
1045 .map_err(ApiError::engine)?;
1046
1047 let ttl_secs = crate::default_run_ttl();
1051 let bg_state = state.clone();
1052 let bg_task_id = task_id.clone();
1053 let bg_run_id = run_id.clone();
1054 let guard_state = state.clone();
1056 let guard_task_id = task_id.clone();
1057 let guard_run_id = run_id.clone();
1058 tokio::spawn(async move {
1059 let driver = async move {
1060 let outcome = match tokio::time::timeout(
1061 Duration::from_secs(ttl_secs),
1062 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1063 )
1064 .await
1065 {
1066 Ok(outcome) => outcome,
1067 Err(_elapsed) => {
1068 let reason = serde_json::json!({
1069 "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
1070 });
1071 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1072 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
1073 }
1074 if let Err(e) = bg_state
1075 .run_store
1076 .update_status(&bg_run_id, RunStatus::Failed)
1077 .await
1078 {
1079 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
1080 }
1081 if let Err(e) = bg_state
1082 .task_store
1083 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1084 .await
1085 {
1086 tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
1087 }
1088 return;
1089 }
1090 };
1091 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1093 };
1094 let _ = catch_run_panic(
1095 &guard_state,
1096 &guard_task_id,
1097 &guard_run_id,
1098 "resume.detach",
1099 driver,
1100 )
1101 .await;
1102 });
1103
1104 Ok((
1105 StatusCode::ACCEPTED,
1106 Json(RunResumeResponse {
1107 run_id,
1108 task_id,
1109 replayed_steps,
1110 }),
1111 ))
1112}
1113
1114#[derive(Debug, Deserialize, schemars::JsonSchema)]
1116pub struct RunRerunFromRequest {
1117 pub from_step: String,
1124}
1125
1126#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
1128pub struct RunRerunFromResponse {
1129 #[schemars(with = "String")]
1134 pub run_id: RunId,
1135 #[schemars(with = "String")]
1137 pub task_id: TaskId,
1138 pub replayed_steps: usize,
1142 pub dropped_steps: usize,
1145}
1146
1147pub async fn run_rerun_from(
1222 State(state): State<AppState>,
1223 Path(id): Path<String>,
1224 Json(req): Json<RunRerunFromRequest>,
1225) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
1226 let run_id =
1227 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1228
1229 if req.from_step.trim().is_empty() {
1230 return Err(ApiError::bad_request(
1231 "from_step must be a non-empty step ref".to_string(),
1232 ));
1233 }
1234
1235 let run = state
1237 .run_store
1238 .get(&run_id)
1239 .await
1240 .map_err(map_run_store_err)?;
1241
1242 let current = run.status;
1245 match current {
1246 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted | RunStatus::Cancelled => { }
1248 RunStatus::Running | RunStatus::Pending => {
1249 return Err(ApiError::conflict(format!(
1250 "run {run_id} is {current:?}; rerun-from requires a terminal run \
1251 (Done / Failed / Interrupted / Cancelled)"
1252 )));
1253 }
1254 }
1255
1256 let Some(input_json) = run.input_json.clone() else {
1261 return Err(ApiError::unprocessable(format!(
1262 "run {run_id} cannot be rerun: no launch input was recorded for it (it \
1263 predates resume/rerun support, or was created by a path that does not \
1264 persist one)"
1265 )));
1266 };
1267 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
1268 ApiError::unprocessable(format!(
1269 "run {run_id}: stored launch input failed to decode: {e}"
1270 ))
1271 })?;
1272 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
1273 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
1274 ApiError::unprocessable(format!(
1275 "run {run_id}: stored launch input failed to decode: {e}"
1276 ))
1277 })?;
1278
1279 let entries = state
1282 .replay_store
1283 .list_by_run(&run_id)
1284 .await
1285 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
1286 let cut = entries
1287 .iter()
1288 .position(|e| e.step_ref == req.from_step)
1289 .ok_or_else(|| {
1290 if entries.is_empty() && !run.step_entries.is_empty() {
1300 ApiError::unprocessable(format!(
1301 "run {run_id}: replay log is empty but {} step entries are traced \
1302 on the RunRecord — the log was consumed by a prior rerun-from \
1303 that reached the truncate stage. This run can no longer be \
1304 rerun-from; start a fresh run via POST /v1/tasks.",
1305 run.step_entries.len()
1306 ))
1307 } else {
1308 ApiError::unprocessable(format!(
1309 "run {run_id}: from_step {:?} not present in this run's replay log \
1310 (nothing to rerun-from)",
1311 req.from_step
1312 ))
1313 }
1314 })?;
1315
1316 if let Err(e) = state.task_app.precompile(&snapshot.blueprint).await {
1330 return Err(ApiError::unprocessable(format!(
1331 "run {run_id} cannot be rerun: current-head Blueprint fails to compile — {e}"
1332 )));
1333 }
1334
1335 let won = state
1340 .run_store
1341 .try_transition(&run_id, current, RunStatus::Running)
1342 .await
1343 .map_err(ApiError::engine)?;
1344 if !won {
1345 return Err(ApiError::conflict(format!(
1346 "run {run_id} was concurrently transitioned (or left the {current:?} state); \
1347 it is no longer rerunnable"
1348 )));
1349 }
1350
1351 let dropped_steps = state
1356 .replay_store
1357 .delete_from(&run_id, cut)
1358 .await
1359 .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
1360
1361 let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
1364 let replayed_steps = kept.len();
1365 let cursor = ReplayCursor::from_entries(kept);
1366
1367 let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
1371 trace
1372 .append(
1373 trace_kind::RUN_STARTED,
1374 None,
1375 None,
1376 json!({"mode": "rerun_from"}),
1377 )
1378 .await;
1379 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1380 .with_replay_store(state.replay_store.clone())
1381 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1382 .with_resume()
1383 .with_trace(trace);
1384
1385 let input = snapshot.into_input();
1386 let task_id = run.task_id.clone();
1387
1388 state
1391 .task_store
1392 .update_status(&task_id, TaskRecordStatus::Running)
1393 .await
1394 .map_err(ApiError::engine)?;
1395
1396 let ttl_secs = crate::default_run_ttl();
1397 let bg_state = state.clone();
1398 let bg_task_id = task_id.clone();
1399 let bg_run_id = run_id.clone();
1400 let guard_state = state.clone();
1402 let guard_task_id = task_id.clone();
1403 let guard_run_id = run_id.clone();
1404 tokio::spawn(async move {
1405 let driver = async move {
1406 let outcome = match tokio::time::timeout(
1407 Duration::from_secs(ttl_secs),
1408 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1409 )
1410 .await
1411 {
1412 Ok(outcome) => outcome,
1413 Err(_elapsed) => {
1414 let reason = serde_json::json!({
1415 "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1416 });
1417 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1418 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1419 }
1420 if let Err(e) = bg_state
1421 .run_store
1422 .update_status(&bg_run_id, RunStatus::Failed)
1423 .await
1424 {
1425 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1426 }
1427 if let Err(e) = bg_state
1428 .task_store
1429 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1430 .await
1431 {
1432 tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1433 }
1434 return;
1435 }
1436 };
1437 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1438 };
1439 let _ = catch_run_panic(
1440 &guard_state,
1441 &guard_task_id,
1442 &guard_run_id,
1443 "rerun_from.detach",
1444 driver,
1445 )
1446 .await;
1447 });
1448
1449 Ok((
1450 StatusCode::ACCEPTED,
1451 Json(RunRerunFromResponse {
1452 run_id,
1453 task_id,
1454 replayed_steps,
1455 dropped_steps,
1456 }),
1457 ))
1458}
1459
1460pub async fn run_get(
1463 State(state): State<AppState>,
1464 Path(id): Path<String>,
1465) -> Result<Json<RunRecord>, ApiError> {
1466 let run_id =
1467 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1468 let run = state
1469 .run_store
1470 .get(&run_id)
1471 .await
1472 .map_err(map_run_store_err)?;
1473 Ok(Json(run))
1474}
1475
1476#[derive(Debug, Deserialize, Default)]
1478pub struct RunsListQuery {
1479 #[serde(default)]
1481 pub task_id: Option<String>,
1482 #[serde(default)]
1485 pub status: Option<String>,
1486 #[serde(default)]
1488 pub limit: Option<usize>,
1489 #[serde(default)]
1491 pub offset: Option<usize>,
1492}
1493
1494#[derive(Debug, Serialize)]
1496pub struct RunsListResponse {
1497 pub runs: Vec<RunRecord>,
1499}
1500
1501pub async fn runs_list(
1506 State(state): State<AppState>,
1507 Query(q): Query<RunsListQuery>,
1508) -> Result<Json<RunsListResponse>, ApiError> {
1509 let task_id = q
1510 .task_id
1511 .map(TaskId::parse)
1512 .transpose()
1513 .map_err(|e| ApiError::bad_request(format!("invalid task_id: {e}")))?;
1514 let status = q
1515 .status
1516 .as_deref()
1517 .map(|s| {
1518 serde_json::from_value::<RunStatus>(Value::String(s.to_string())).map_err(|_| {
1519 ApiError::bad_request(format!(
1520 "invalid status {s:?} (expected pending/running/done/failed/interrupted)"
1521 ))
1522 })
1523 })
1524 .transpose()?;
1525 let runs = state
1526 .run_store
1527 .list(&RunListFilter {
1528 task_id,
1529 status,
1530 limit: q.limit,
1531 offset: q.offset,
1532 })
1533 .await
1534 .map_err(map_run_store_err)?;
1535 Ok(Json(RunsListResponse { runs }))
1536}
1537
1538#[derive(Debug, Serialize, schemars::JsonSchema)]
1543pub struct RunStepsResponse {
1544 pub run_id: String,
1546 pub steps: Vec<StepEntry>,
1548}
1549
1550pub async fn run_steps(
1555 State(state): State<AppState>,
1556 Path(id): Path<String>,
1557) -> Result<Json<RunStepsResponse>, ApiError> {
1558 let run_id =
1559 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1560 let run = state
1561 .run_store
1562 .get(&run_id)
1563 .await
1564 .map_err(map_run_store_err)?;
1565 Ok(Json(RunStepsResponse {
1566 run_id: run.id.to_string(),
1567 steps: run.step_entries,
1568 }))
1569}
1570
1571#[derive(Debug, Deserialize, Default)]
1575pub struct RunTraceQuery {
1576 #[serde(default)]
1578 pub after: Option<u64>,
1579 #[serde(default)]
1581 pub limit: Option<usize>,
1582 #[serde(default)]
1584 pub latest: Option<usize>,
1585 #[serde(default)]
1588 pub kind: Option<String>,
1589 #[serde(default)]
1591 pub step: Option<String>,
1592 #[serde(default)]
1594 pub attempt: Option<u32>,
1595}
1596
1597#[derive(Debug, Serialize)]
1599pub struct RunTraceResponse {
1600 pub run_id: String,
1602 pub events: Vec<TraceEvent>,
1604}
1605
1606pub async fn run_trace(
1612 State(state): State<AppState>,
1613 Path(id): Path<String>,
1614 Query(q): Query<RunTraceQuery>,
1615) -> Result<Json<RunTraceResponse>, ApiError> {
1616 let run_id =
1617 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1618 let query = TraceQuery {
1619 after: q.after,
1620 limit: q.limit,
1621 latest: q.latest,
1622 kinds: q
1623 .kind
1624 .as_deref()
1625 .map(|s| {
1626 s.split(',')
1627 .map(str::trim)
1628 .filter(|k| !k.is_empty())
1629 .map(str::to_string)
1630 .collect()
1631 })
1632 .unwrap_or_default(),
1633 step_ref: q.step,
1634 attempt: q.attempt,
1635 };
1636 let events = state
1637 .run_trace_store
1638 .list(&run_id, &query)
1639 .await
1640 .map_err(|e| ApiError::engine(format!("trace list: {e}")))?;
1641 Ok(Json(RunTraceResponse {
1642 run_id: run_id.to_string(),
1643 events,
1644 }))
1645}
1646
1647pub async fn run_cancel(
1657 State(state): State<AppState>,
1658 Path(id): Path<String>,
1659) -> Result<axum::http::StatusCode, ApiError> {
1660 let run_id =
1661 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1662 let record = state
1665 .run_store
1666 .get(&run_id)
1667 .await
1668 .map_err(map_run_store_err)?;
1669 TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
1672 .append(trace_kind::CANCEL_REQUESTED, None, None, json!({}))
1673 .await;
1674 if matches!(record.status, RunStatus::Pending | RunStatus::Running) {
1679 if let Err(e) = state
1680 .run_store
1681 .update_status(&run_id, RunStatus::Cancelled)
1682 .await
1683 {
1684 tracing::warn!(%run_id, error = %e, "run_cancel: update_status(Cancelled) failed");
1685 }
1686 }
1687 Ok(axum::http::StatusCode::NO_CONTENT)
1688}
1689
1690pub async fn run_delete(
1703 State(state): State<AppState>,
1704 Path(id): Path<String>,
1705) -> Result<axum::http::StatusCode, ApiError> {
1706 let run_id =
1707 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1708 state
1709 .run_store
1710 .delete(&run_id)
1711 .await
1712 .map_err(map_run_store_err)?;
1713 if let Err(e) = state.run_trace_store.delete_run(&run_id).await {
1714 tracing::warn!(%run_id, error = %e, "run_delete: trace delete_run failed (run row already deleted)");
1715 }
1716 Ok(axum::http::StatusCode::NO_CONTENT)
1717}
1718
1719#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1722#[serde(rename_all = "snake_case")]
1723pub enum RunBindingStatus {
1724 DeclarationOnly,
1727 Attested,
1729}
1730
1731#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1733pub struct RunBindingDifference {
1734 pub model_changed: bool,
1736 pub missing_requested_tools: Vec<String>,
1739 pub additional_effective_tools: Vec<String>,
1741 pub launch_variant_changed: bool,
1743}
1744
1745#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1748pub struct RunBindingExplainEntry {
1749 pub agent: String,
1751 pub runner_source: mlua_swarm::blueprint::RunnerResolutionSource,
1753 pub status: RunBindingStatus,
1755 pub requested: Option<BindRequest>,
1757 pub effective: Option<BindingAttestation>,
1759 pub difference: Option<RunBindingDifference>,
1762 pub binding_digest: mlua_swarm::blueprint::BindingDigest,
1764}
1765
1766#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1768pub struct RunBindingsExplainResponse {
1769 #[schemars(with = "String")]
1771 pub run_id: RunId,
1772 #[schemars(with = "String")]
1774 pub task_id: TaskId,
1775 pub snapshot_origin: SnapshotOrigin,
1783 pub bindings: Vec<RunBindingExplainEntry>,
1785}
1786
1787fn requested_binding(bound: &BoundAgent) -> Option<BindRequest> {
1788 mlua_swarm::binding_request_for_snapshot(bound)
1789}
1790
1791fn binding_difference(
1792 requested: &BindRequest,
1793 effective: &BindingAttestation,
1794) -> RunBindingDifference {
1795 let missing_requested_tools = requested
1796 .requested_tools
1797 .iter()
1798 .filter(|tool| !effective.effective_tools.contains(tool))
1799 .cloned()
1800 .collect();
1801 let additional_effective_tools = effective
1802 .effective_tools
1803 .iter()
1804 .filter(|tool| !requested.requested_tools.contains(tool))
1805 .cloned()
1806 .collect();
1807 RunBindingDifference {
1808 model_changed: requested.requested_model != effective.resolved_model,
1809 missing_requested_tools,
1810 additional_effective_tools,
1811 launch_variant_changed: requested.launch_variant != effective.launch_variant,
1812 }
1813}
1814
1815fn validated_bound_agents_from_snapshot(
1816 run_id: &RunId,
1817 snapshot: &Value,
1818) -> Result<Option<Vec<BoundAgent>>, ApiError> {
1819 let Some(bound_value) = snapshot.get("bound_agents") else {
1820 return Ok(None);
1821 };
1822 let bound_agents: Vec<BoundAgent> =
1823 serde_json::from_value(bound_value.clone()).map_err(|e| {
1824 ApiError::unprocessable(format!(
1825 "run {run_id} contains an invalid binding snapshot: {e}"
1826 ))
1827 })?;
1828 validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
1829 ApiError::unprocessable(format!(
1830 "run {run_id} contains an inconsistent binding snapshot: {error}"
1831 ))
1832 })?;
1833 Ok(Some(bound_agents))
1834}
1835
1836pub async fn run_bindings_explain(
1840 State(state): State<AppState>,
1841 Path(id): Path<String>,
1842) -> Result<Json<RunBindingsExplainResponse>, ApiError> {
1843 let run_id =
1844 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1845 let run = state
1846 .run_store
1847 .get(&run_id)
1848 .await
1849 .map_err(map_run_store_err)?;
1850 let input_json = run.input_json.as_deref().ok_or_else(|| {
1851 ApiError::unprocessable(format!(
1852 "run {run_id} has no launch snapshot; binding explain is unavailable"
1853 ))
1854 })?;
1855 let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
1856 ApiError::unprocessable(format!(
1857 "run {run_id} launch snapshot is invalid JSON; binding explain is unavailable: {e}"
1858 ))
1859 })?;
1860 let bound_agents = validated_bound_agents_from_snapshot(&run_id, &snapshot)?.ok_or_else(|| {
1861 ApiError::unprocessable(format!(
1862 "run {run_id} predates immutable binding snapshots; current Blueprint state was not consulted"
1863 ))
1864 })?;
1865
1866 let bindings = bound_agents
1867 .into_iter()
1868 .map(|bound| {
1869 let requested = requested_binding(&bound);
1870 let effective = bound.attestation.clone();
1871 let difference = requested
1872 .as_ref()
1873 .zip(effective.as_ref())
1874 .map(|(request, attestation)| binding_difference(request, attestation));
1875 RunBindingExplainEntry {
1876 agent: bound.agent.name,
1877 runner_source: bound.runner_source,
1878 status: if effective.is_some() {
1879 RunBindingStatus::Attested
1880 } else {
1881 RunBindingStatus::DeclarationOnly
1882 },
1883 requested,
1884 effective,
1885 difference,
1886 binding_digest: bound.binding_digest,
1887 }
1888 })
1889 .collect();
1890
1891 Ok(Json(RunBindingsExplainResponse {
1892 run_id: run.id,
1893 task_id: run.task_id,
1894 snapshot_origin: SnapshotOrigin::from_snapshot(&snapshot),
1895 bindings,
1896 }))
1897}
1898
1899pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1903 match e {
1904 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1905 other => ApiError::engine(other),
1906 }
1907}
1908
1909fn map_run_store_err(e: RunStoreError) -> ApiError {
1910 match e {
1911 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1912 other => ApiError::engine(other),
1913 }
1914}
1915
1916#[cfg(test)]
1921mod tests {
1922 use super::*;
1923 use mlua_swarm::application::BlueprintRef;
1924 use mlua_swarm::blueprint::{
1925 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1926 CompilerStrategy, Runner,
1927 };
1928 use mlua_swarm::core::config::EngineCfg;
1929 use mlua_swarm::core::engine::Engine;
1930 use mlua_swarm::store::output::InMemoryOutputStore;
1931 use mlua_swarm::store::run::InMemoryRunStore;
1932 use mlua_swarm::store::task::InMemoryTaskStore;
1933 use std::collections::HashMap;
1934 use std::sync::Arc;
1935 use tokio::sync::Mutex;
1936
1937 fn identity_blueprint() -> Blueprint {
1943 Blueprint {
1944 schema_version: current_schema_version(),
1945 id: "tasks-test-bp".into(),
1946 flow: serde_json::from_value(serde_json::json!({
1947 "kind": "step",
1948 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1949 "in": {"op": "lit", "value": "hello"},
1950 "out": {"op": "path", "at": "$.out"},
1951 }))
1952 .expect("flow parse"),
1953 agents: vec![AgentDef {
1954 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1955 kind: AgentKind::RustFn,
1956 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1957 profile: None,
1958 meta: None,
1959 runner: None,
1960 runner_ref: None,
1961 verdict: None,
1962 }],
1963 operators: vec![],
1964 metas: vec![],
1965 hints: CompilerHints::default(),
1966 strategy: CompilerStrategy::default(),
1967 metadata: BlueprintMetadata::default(),
1968 spawner_hints: Default::default(),
1969 default_agent_kind: AgentKind::Operator,
1970 default_operator_kind: None,
1971 default_init_ctx: None,
1972 default_agent_ctx: None,
1973 default_context_policy: None,
1974 projection_placement: None,
1975 audits: vec![],
1976 degradation_policy: None,
1977 runners: vec![],
1978 default_runner: None,
1979 subprocesses: vec![],
1980 check_policy: None,
1981 blueprint_ref_includes: Vec::new(),
1982 }
1983 }
1984
1985 fn test_state() -> AppState {
1990 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1991 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1992 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1993 AppState {
1994 engine,
1995 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1996 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1997 ws_operator_factory: None,
1998 data_store: Arc::new(InMemoryOutputStore::new()),
1999 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
2000 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
2001 task_store: Arc::new(InMemoryTaskStore::new()),
2002 run_store: Arc::new(InMemoryRunStore::new()),
2003 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
2004 run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
2005 base_url: None,
2006 sync_timeout_secs: 300,
2007 }
2008 }
2009
2010 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
2011 crate::TaskLaunchRequest {
2012 blueprint: BlueprintRef::Inline {
2013 value: Box::new(identity_blueprint()),
2014 },
2015 init_ctx: serde_json::json!({"in": "hello"}),
2016 project_root: None,
2017 work_dir: None,
2018 task_metadata: None,
2019 ttl_secs: None,
2020 operator: None,
2021 operator_sid: None,
2022 timeout_secs: None,
2023 goal: Some(goal.to_string()),
2024 detach: false,
2025 check_policy: None,
2026 }
2027 }
2028
2029 #[test]
2030 fn task_id_serializes_as_bare_string() {
2031 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
2035 assert_eq!(v, serde_json::json!("T-abc"));
2036 }
2037
2038 #[tokio::test]
2039 async fn post_then_get_drill_down() {
2040 let state = test_state();
2041
2042 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
2043 .await
2044 .expect("tasks_start")
2045 .0;
2046 let task_id = posted.task_id.clone();
2047 let run_id = posted.run_id.clone();
2048
2049 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
2051 .await
2052 .expect("tasks_list")
2053 .0;
2054 assert!(
2055 list.iter().any(|t| t.id == task_id),
2056 "task {task_id} missing from list of {} tasks",
2057 list.len()
2058 );
2059
2060 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2062 .await
2063 .expect("task_get")
2064 .0;
2065 assert_eq!(detail.task.id, task_id);
2066 assert_eq!(detail.task.goal, "smoke goal");
2067 assert_eq!(detail.task.status, TaskRecordStatus::Done);
2068 assert_eq!(detail.runs.len(), 1);
2069 assert_eq!(detail.runs[0].id, run_id);
2070 assert_eq!(detail.runs[0].status, RunStatus::Done);
2071
2072 let run = run_get(State(state.clone()), Path(run_id.to_string()))
2074 .await
2075 .expect("run_get")
2076 .0;
2077 assert_eq!(run.id, run_id);
2078 assert_eq!(run.task_id, task_id);
2079 assert_eq!(run.result_ref, Some(posted.final_ctx));
2080
2081 assert_eq!(
2085 run.step_entries.len(),
2086 1,
2087 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
2088 run.step_entries
2089 );
2090 assert_eq!(
2091 run.step_entries[0].step_ref,
2092 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2093 );
2094 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2095 }
2096
2097 fn identity_blueprint_with_operator_delegate() -> Blueprint {
2109 Blueprint {
2110 spawner_hints: mlua_swarm::SpawnerHints {
2111 layers: vec!["operator_delegate".to_string()],
2112 },
2113 ..identity_blueprint()
2114 }
2115 }
2116
2117 struct StallingOperator;
2120
2121 #[async_trait::async_trait]
2122 impl mlua_swarm::Operator for StallingOperator {
2123 async fn execute(
2124 &self,
2125 _ctx: &mlua_swarm::Ctx,
2126 _system: Option<String>,
2127 _prompt: Value,
2128 _worker: Option<mlua_swarm::WorkerBinding>,
2129 _worker_token: mlua_swarm::CapToken,
2130 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
2131 std::future::pending::<()>().await;
2132 unreachable!("StallingOperator.execute must never resolve")
2133 }
2134 }
2135
2136 fn operator_launch_req(
2140 backend_id: &str,
2141 timeout_secs: Option<u64>,
2142 ) -> crate::TaskLaunchRequest {
2143 crate::TaskLaunchRequest {
2144 blueprint: BlueprintRef::Inline {
2145 value: Box::new(identity_blueprint_with_operator_delegate()),
2146 },
2147 init_ctx: serde_json::json!({"in": "hello"}),
2148 project_root: None,
2149 work_dir: None,
2150 task_metadata: None,
2151 ttl_secs: None,
2152 operator: Some(crate::OperatorReq {
2153 operator_backend_id: Some(backend_id.to_string()),
2154 ..Default::default()
2155 }),
2156 operator_sid: None,
2157 timeout_secs,
2158 goal: Some("operator delegate test goal".to_string()),
2159 detach: false,
2160 check_policy: None,
2161 }
2162 }
2163
2164 #[tokio::test]
2168 async fn sync_launch_zero_operators_fails_fast() {
2169 let state = test_state();
2170 let req = operator_launch_req("nonexistent-op", None);
2173
2174 let started = std::time::Instant::now();
2175 let result = crate::tasks_start(State(state), Json(req)).await;
2176 let elapsed = started.elapsed();
2177
2178 let err = match result {
2179 Err(e) => e,
2180 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
2181 };
2182 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2183 assert!(
2184 err.message.contains("no operator attached"),
2185 "error message must mention the missing operator: {}",
2186 err.message
2187 );
2188 assert!(
2189 elapsed < Duration::from_secs(1),
2190 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2191 );
2192 }
2193
2194 #[tokio::test]
2198 async fn sync_launch_stalled_times_out() {
2199 let state = test_state();
2200 state
2201 .engine
2202 .register_operator("stall-op", Arc::new(StallingOperator))
2203 .await;
2204 let req = operator_launch_req("stall-op", Some(1));
2205
2206 let started = std::time::Instant::now();
2207 let result = tokio::time::timeout(
2211 Duration::from_secs(5),
2212 crate::tasks_start(State(state), Json(req)),
2213 )
2214 .await
2215 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
2216 let elapsed = started.elapsed();
2217
2218 let err = match result {
2219 Err(e) => e,
2220 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
2221 };
2222 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
2223 assert!(
2224 err.message.contains('1'),
2225 "error message must mention the configured 1s ceiling: {}",
2226 err.message
2227 );
2228 assert!(
2229 elapsed < Duration::from_secs(3),
2230 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2231 );
2232 }
2233
2234 #[tokio::test]
2238 async fn sync_launch_without_operator_path_unaffected() {
2239 let state = test_state();
2240 let result = crate::tasks_start(
2241 State(state),
2242 Json(post_tasks_req("non-operator launch goal")),
2243 )
2244 .await;
2245 if let Err(e) = &result {
2246 panic!(
2247 "non-operator launch must succeed unaffected by guard 1: {}",
2248 e.message
2249 );
2250 }
2251 }
2252
2253 #[tokio::test]
2257 async fn sync_launch_zero_timeout_secs_rejected() {
2258 let state = test_state();
2259 let mut req = post_tasks_req("zero timeout goal");
2260 req.timeout_secs = Some(0);
2261
2262 let result = crate::tasks_start(State(state), Json(req)).await;
2263 let err = match result {
2264 Err(e) => e,
2265 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2266 };
2267 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2268 assert!(
2269 err.message.contains("timeout_secs"),
2270 "error message must reference timeout_secs: {}",
2271 err.message
2272 );
2273 }
2274
2275 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
2284 for _ in 0..50 {
2285 let rec = state.run_store.get(run_id).await.expect("run get");
2286 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
2287 return rec;
2288 }
2289 tokio::time::sleep(Duration::from_millis(100)).await;
2290 }
2291 panic!("run {run_id} did not reach a terminal status within ~5s");
2292 }
2293
2294 #[tokio::test]
2300 async fn detached_launch_returns_202_and_completes_in_background() {
2301 let state = test_state();
2302 let mut req = post_tasks_req("detached goal");
2303 req.detach = true;
2304
2305 let reply = crate::tasks_start(State(state.clone()), Json(req))
2306 .await
2307 .expect("tasks_start (detached)");
2308 assert_eq!(reply.1, StatusCode::ACCEPTED);
2309 let posted = reply.0;
2310 assert_eq!(posted.status, RunStatus::Running);
2311 assert_eq!(
2312 posted.final_ctx,
2313 serde_json::Value::Null,
2314 "a detached launch has no final_ctx at response time"
2315 );
2316
2317 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
2318 assert_eq!(rec.status, RunStatus::Done);
2319 assert!(
2320 rec.result_ref.is_some(),
2321 "finalize_run must persist the background eval's final_ctx"
2322 );
2323 assert_eq!(
2324 rec.step_entries.len(),
2325 1,
2326 "the background eval must trace its step_entries like the sync path: {:?}",
2327 rec.step_entries
2328 );
2329 let task = state
2330 .task_store
2331 .get(&posted.task_id)
2332 .await
2333 .expect("task get");
2334 assert_eq!(task.status, TaskRecordStatus::Done);
2335 }
2336
2337 #[tokio::test]
2341 async fn detached_launch_with_timeout_secs_rejected() {
2342 let state = test_state();
2343 let mut req = post_tasks_req("detached + ceiling goal");
2344 req.detach = true;
2345 req.timeout_secs = Some(60);
2346
2347 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
2348 Err(e) => e,
2349 Ok(_) => panic!("detach + timeout_secs must be rejected"),
2350 };
2351 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2352 assert!(
2353 err.message.contains("detach"),
2354 "error message must explain the detach/timeout_secs conflict: {}",
2355 err.message
2356 );
2357 let tasks = state.task_store.list().await.expect("task list");
2358 assert!(
2359 tasks.is_empty(),
2360 "the 400 must fire before any TaskRecord is minted"
2361 );
2362 }
2363
2364 #[tokio::test]
2368 async fn rekick_detached_returns_202_and_completes_in_background() {
2369 let state = test_state();
2370 let posted = crate::tasks_start(
2371 State(state.clone()),
2372 Json(post_tasks_req("detached rekick goal")),
2373 )
2374 .await
2375 .expect("tasks_start")
2376 .0;
2377
2378 let (status, rekicked) = task_rekick(
2379 State(state.clone()),
2380 Path(posted.task_id.to_string()),
2381 Some(Json(RunKickRequest {
2382 init_ctx_override: None,
2383 task_input_override: None,
2384 timeout_secs: None,
2385 detach: true,
2386 operator_sid: None,
2387 })),
2388 )
2389 .await
2390 .expect("task_rekick (detached)");
2391 assert_eq!(status, StatusCode::ACCEPTED);
2392 assert_eq!(rekicked.0.status, RunStatus::Running);
2393 assert_ne!(rekicked.0.run_id, posted.run_id);
2394
2395 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
2396 assert_eq!(rec.status, RunStatus::Done);
2397 assert!(
2398 rec.result_ref.is_some(),
2399 "finalize_run must persist the background rekick's final_ctx"
2400 );
2401 }
2402
2403 #[tokio::test]
2407 async fn rekick_detached_with_timeout_secs_rejected() {
2408 let state = test_state();
2409 let posted = crate::tasks_start(
2410 State(state.clone()),
2411 Json(post_tasks_req("detached rekick ceiling goal")),
2412 )
2413 .await
2414 .expect("tasks_start")
2415 .0;
2416
2417 let err = match task_rekick(
2418 State(state.clone()),
2419 Path(posted.task_id.to_string()),
2420 Some(Json(RunKickRequest {
2421 init_ctx_override: None,
2422 task_input_override: None,
2423 timeout_secs: Some(60),
2424 detach: true,
2425 operator_sid: None,
2426 })),
2427 )
2428 .await
2429 {
2430 Err(e) => e,
2431 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
2432 };
2433 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2434 assert!(
2435 err.message.contains("detach"),
2436 "error message must explain the detach/timeout_secs conflict: {}",
2437 err.message
2438 );
2439 let runs = state
2440 .run_store
2441 .list_by_task(&posted.task_id)
2442 .await
2443 .expect("runs list");
2444 assert_eq!(
2445 runs.len(),
2446 1,
2447 "the 400 must fire before a second Run is minted"
2448 );
2449 }
2450
2451 async fn seed_running_run(state: &AppState) -> (TaskId, RunId) {
2459 let now = now_secs();
2460 let task_id = TaskId::new();
2461 let run_id = RunId::new();
2462 state
2463 .task_store
2464 .create(TaskRecord {
2465 id: task_id.clone(),
2466 goal: "panic guard goal".into(),
2467 blueprint_ref: json!({}),
2468 input_ctx: json!({}),
2469 task_input_spec: None,
2470 status: TaskRecordStatus::Running,
2471 created_at: now,
2472 updated_at: now,
2473 })
2474 .await
2475 .expect("task create");
2476 state
2477 .run_store
2478 .create(RunRecord {
2479 id: run_id.clone(),
2480 task_id: task_id.clone(),
2481 status: RunStatus::Running,
2482 step_entries: Vec::new(),
2483 degradations: Vec::new(),
2484 operator_sid: None,
2485 result_ref: None,
2486 input_json: None,
2487 created_at: now,
2488 updated_at: now,
2489 })
2490 .await
2491 .expect("run create");
2492 (task_id, run_id)
2493 }
2494
2495 async fn run_finished_events(state: &AppState, run_id: &RunId) -> Vec<TraceEvent> {
2496 state
2497 .run_trace_store
2498 .list(run_id, &TraceQuery::default())
2499 .await
2500 .expect("trace list")
2501 .into_iter()
2502 .filter(|e| e.kind == trace_kind::RUN_FINISHED)
2503 .collect()
2504 }
2505
2506 #[tokio::test]
2511 async fn panicking_driver_marks_run_interrupted() {
2512 let state = test_state();
2513 let (task_id, run_id) = seed_running_run(&state).await;
2514
2515 let outcome: Result<(), String> =
2516 catch_run_panic(&state, &task_id, &run_id, "test.detach", async {
2517 panic!("boom");
2518 })
2519 .await;
2520 let message = outcome.expect_err("a panicking driver must report the panic to its caller");
2521 assert!(
2522 message.contains("boom"),
2523 "the panic payload must survive as the caller-visible message: {message}"
2524 );
2525
2526 let rec = state.run_store.get(&run_id).await.expect("run get");
2527 assert_eq!(
2528 rec.status,
2529 RunStatus::Interrupted,
2530 "a panicked Run must be resumable, not left Running or marked Failed"
2531 );
2532 let reason = rec
2533 .result_ref
2534 .as_ref()
2535 .and_then(|v| v.get("error"))
2536 .and_then(Value::as_str)
2537 .expect("a structured {\"error\": ...} envelope");
2538 assert!(
2539 reason.contains("boom") && reason.contains("test.detach"),
2540 "the reason must name both the panic payload and the site: {reason}"
2541 );
2542
2543 let task = state.task_store.get(&task_id).await.expect("task get");
2544 assert_eq!(task.status, TaskRecordStatus::Interrupted);
2545
2546 let finished = run_finished_events(&state, &run_id).await;
2547 assert_eq!(finished.len(), 1, "expected one terminal trace marker");
2548 assert_eq!(
2549 finished[0].payload.get("status").and_then(Value::as_str),
2550 Some("interrupted")
2551 );
2552 assert_eq!(
2553 finished[0].payload.get("reason").and_then(Value::as_str),
2554 Some("driver panic")
2555 );
2556 }
2557
2558 #[tokio::test]
2562 async fn panic_guard_does_not_clobber_a_finalized_run() {
2563 let state = test_state();
2564 let (task_id, run_id) = seed_running_run(&state).await;
2565 state
2566 .run_store
2567 .set_result(&run_id, json!({"kept": true}))
2568 .await
2569 .expect("set_result");
2570 state
2571 .run_store
2572 .update_status(&run_id, RunStatus::Done)
2573 .await
2574 .expect("update_status");
2575
2576 let outcome: Result<(), String> =
2577 catch_run_panic(&state, &task_id, &run_id, "test.detach", async {
2578 panic!("late boom");
2579 })
2580 .await;
2581 assert!(
2582 outcome.is_err(),
2583 "the panic is still reported to the caller"
2584 );
2585
2586 let rec = state.run_store.get(&run_id).await.expect("run get");
2587 assert_eq!(rec.status, RunStatus::Done, "the CAS must have refused");
2588 assert_eq!(rec.result_ref, Some(json!({"kept": true})));
2589 let finished = run_finished_events(&state, &run_id).await;
2590 assert!(
2591 finished.is_empty(),
2592 "a refused CAS must not append a second terminal marker: {finished:?}"
2593 );
2594 }
2595
2596 #[tokio::test]
2602 async fn sync_panic_returns_err_and_interrupts_run() {
2603 let state = test_state();
2604 let (task_id, run_id) = seed_running_run(&state).await;
2605
2606 let timed = catch_run_panic(
2607 &state,
2608 &task_id,
2609 &run_id,
2610 "launch.sync",
2611 tokio::time::timeout(Duration::from_secs(30), async {
2612 panic!("sync boom");
2613 }),
2614 )
2615 .await;
2616 let message = timed.expect_err("the sync path must observe the panic as an Err");
2617 assert!(message.contains("sync boom"), "payload lost: {message}");
2618
2619 let err = ApiError::engine(format!("run driver panicked: {message}"));
2620 assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
2621
2622 let rec = state.run_store.get(&run_id).await.expect("run get");
2623 assert_eq!(rec.status, RunStatus::Interrupted);
2624 }
2625
2626 #[tokio::test]
2627 async fn rekick_adds_a_second_run_to_the_same_task() {
2628 let state = test_state();
2629 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
2630 .await
2631 .expect("tasks_start")
2632 .0;
2633 let task_id = posted.task_id.clone();
2634 let first_run_id = posted.run_id.clone();
2635
2636 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
2637 .await
2638 .expect("task_rekick");
2639 assert_eq!(status, StatusCode::CREATED);
2640 let second_run_id = rekicked.0.run_id.clone();
2641 assert_ne!(first_run_id, second_run_id);
2642
2643 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2644 .await
2645 .expect("task_get")
2646 .0;
2647 assert_eq!(
2648 detail.runs.len(),
2649 2,
2650 "expected 2 runs, got {:?}",
2651 detail.runs
2652 );
2653 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
2654 assert!(ids.contains(&&first_run_id));
2655 assert!(ids.contains(&&second_run_id));
2656
2657 let first_run = detail
2662 .runs
2663 .iter()
2664 .find(|r| r.id == first_run_id)
2665 .expect("first run present in detail.runs");
2666 let second_run = detail
2667 .runs
2668 .iter()
2669 .find(|r| r.id == second_run_id)
2670 .expect("second run present in detail.runs");
2671 assert_eq!(
2672 first_run.step_entries.len(),
2673 1,
2674 "first run step_entries: {:?}",
2675 first_run.step_entries
2676 );
2677 assert_eq!(
2678 second_run.step_entries.len(),
2679 1,
2680 "second run step_entries: {:?}",
2681 second_run.step_entries
2682 );
2683 assert_eq!(
2684 first_run.step_entries[0].step_ref,
2685 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2686 );
2687 assert_eq!(
2688 second_run.step_entries[0].step_ref,
2689 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2690 );
2691 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
2692 assert_eq!(
2693 second_run.step_entries[0].status,
2694 Some("passed".to_string())
2695 );
2696 assert_ne!(
2697 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
2698 "each kick dispatches its own StepId — runs must not share step_entries"
2699 );
2700 }
2701
2702 #[tokio::test]
2703 async fn rekick_unknown_task_returns_404() {
2704 let state = test_state();
2705 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
2709 Ok(_) => panic!("expected 404 for an unknown task"),
2710 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2711 }
2712 }
2713
2714 fn greeting_blueprint() -> Blueprint {
2723 Blueprint {
2724 schema_version: current_schema_version(),
2725 id: "tasks-test-greeting-bp".into(),
2726 flow: serde_json::from_value(serde_json::json!({
2727 "kind": "step",
2728 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
2729 "in": {"op": "path", "at": "$.greeting"},
2730 "out": {"op": "path", "at": "$.out"},
2731 }))
2732 .expect("flow parse"),
2733 agents: vec![AgentDef {
2734 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
2735 kind: AgentKind::RustFn,
2736 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
2737 profile: None,
2738 meta: None,
2739 runner: None,
2740 runner_ref: None,
2741 verdict: None,
2742 }],
2743 operators: vec![],
2744 metas: vec![],
2745 hints: CompilerHints::default(),
2746 strategy: CompilerStrategy::default(),
2747 metadata: BlueprintMetadata::default(),
2748 spawner_hints: Default::default(),
2749 default_agent_kind: AgentKind::Operator,
2750 default_operator_kind: None,
2751 default_init_ctx: None,
2752 default_agent_ctx: None,
2753 default_context_policy: None,
2754 projection_placement: None,
2755 audits: vec![],
2756 degradation_policy: None,
2757 runners: vec![],
2758 default_runner: None,
2759 subprocesses: vec![],
2760 check_policy: None,
2761 blueprint_ref_includes: Vec::new(),
2762 }
2763 }
2764
2765 fn post_greeting_task_req(
2766 greeting: &str,
2767 project_root: Option<&str>,
2768 ) -> crate::TaskLaunchRequest {
2769 crate::TaskLaunchRequest {
2770 blueprint: BlueprintRef::Inline {
2771 value: Box::new(greeting_blueprint()),
2772 },
2773 init_ctx: serde_json::json!({ "greeting": greeting }),
2774 project_root: project_root.map(str::to_string),
2775 work_dir: None,
2776 task_metadata: None,
2777 ttl_secs: None,
2778 operator: None,
2779 operator_sid: None,
2780 timeout_secs: None,
2781 goal: Some("st4 rekick goal".to_string()),
2782 detach: false,
2783 check_policy: None,
2784 }
2785 }
2786
2787 #[tokio::test]
2788 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2789 let state = test_state();
2792 let posted = crate::tasks_start(
2793 State(state.clone()),
2794 Json(post_greeting_task_req("from-task", None)),
2795 )
2796 .await
2797 .expect("tasks_start")
2798 .0;
2799 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2800
2801 let (status, rekicked) =
2802 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2803 .await
2804 .expect("task_rekick");
2805 assert_eq!(status, StatusCode::CREATED);
2806
2807 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2808 .await
2809 .expect("run_get")
2810 .0;
2811 assert_eq!(
2812 run.result_ref.expect("result_ref present")["out"]["echoed"],
2813 "from-task"
2814 );
2815 }
2816
2817 #[tokio::test]
2818 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2819 let state = test_state();
2820 let posted = crate::tasks_start(
2821 State(state.clone()),
2822 Json(post_greeting_task_req("from-task", None)),
2823 )
2824 .await
2825 .expect("tasks_start")
2826 .0;
2827 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2828
2829 let (status, rekicked) = task_rekick(
2830 State(state.clone()),
2831 Path(posted.task_id.to_string()),
2832 Some(Json(RunKickRequest {
2833 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2834 task_input_override: None,
2835 timeout_secs: None,
2836 detach: false,
2837 operator_sid: None,
2838 })),
2839 )
2840 .await
2841 .expect("task_rekick");
2842 assert_eq!(status, StatusCode::CREATED);
2843
2844 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2845 .await
2846 .expect("run_get")
2847 .0;
2848 assert_eq!(
2849 run.result_ref.expect("result_ref present")["out"]["echoed"],
2850 "from-run",
2851 "Run's init_ctx_override must win over the stored Task input_ctx"
2852 );
2853 }
2854
2855 #[tokio::test]
2856 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2857 let state = test_state();
2865 let posted = crate::tasks_start(
2866 State(state.clone()),
2867 Json(post_greeting_task_req("from-task", Some("/repo"))),
2868 )
2869 .await
2870 .expect("tasks_start")
2871 .0;
2872
2873 let before = state
2874 .task_store
2875 .get(&posted.task_id)
2876 .await
2877 .expect("task fetch");
2878 let before_spec: Option<TaskInputSpec> = before
2879 .task_input_spec
2880 .as_ref()
2881 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2882 assert_eq!(
2883 before_spec,
2884 Some(TaskInputSpec {
2885 project_root: Some("/repo".to_string()),
2886 work_dir: None,
2887 task_metadata: None,
2888 })
2889 );
2890
2891 let (status, _rekicked) =
2892 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2893 .await
2894 .expect("task_rekick");
2895 assert_eq!(status, StatusCode::CREATED);
2896
2897 let after = state
2898 .task_store
2899 .get(&posted.task_id)
2900 .await
2901 .expect("task fetch");
2902 assert_eq!(
2903 after.task_input_spec, before.task_input_spec,
2904 "rekick must not mutate the stored Task-level task_input_spec snapshot"
2905 );
2906 }
2907
2908 #[tokio::test]
2909 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2910 let state = test_state();
2913 let posted = crate::tasks_start(
2914 State(state.clone()),
2915 Json(post_greeting_task_req("from-task", Some("/repo"))),
2916 )
2917 .await
2918 .expect("tasks_start")
2919 .0;
2920
2921 let (status, _rekicked) = task_rekick(
2922 State(state.clone()),
2923 Path(posted.task_id.to_string()),
2924 Some(Json(RunKickRequest {
2925 init_ctx_override: None,
2926 task_input_override: Some(TaskInputSpec {
2927 project_root: Some("/override".to_string()),
2928 work_dir: None,
2929 task_metadata: None,
2930 }),
2931 timeout_secs: None,
2932 detach: false,
2933 operator_sid: None,
2934 })),
2935 )
2936 .await
2937 .expect("task_rekick");
2938 assert_eq!(status, StatusCode::CREATED);
2939
2940 let after = state
2941 .task_store
2942 .get(&posted.task_id)
2943 .await
2944 .expect("task fetch");
2945 let after_spec: Option<TaskInputSpec> = after
2946 .task_input_spec
2947 .as_ref()
2948 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2949 assert_eq!(
2950 after_spec,
2951 Some(TaskInputSpec {
2952 project_root: Some("/repo".to_string()),
2953 work_dir: None,
2954 task_metadata: None,
2955 }),
2956 "a per-Run task_input_override must not leak into the stored TaskRecord"
2957 );
2958 }
2959
2960 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
2974 crate::TaskLaunchRequest {
2975 blueprint: BlueprintRef::Inline {
2976 value: Box::new(identity_blueprint_with_operator_delegate()),
2977 },
2978 init_ctx: serde_json::json!({"in": "hello"}),
2979 project_root: None,
2980 work_dir: None,
2981 task_metadata: None,
2982 ttl_secs: None,
2983 operator: None,
2984 operator_sid: None,
2985 timeout_secs: None,
2986 goal: Some(goal.to_string()),
2987 detach: false,
2988 check_policy: None,
2989 }
2990 }
2991
2992 #[tokio::test]
2997 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
2998 let state = test_state();
2999 let posted = crate::tasks_start(
3000 State(state.clone()),
3001 Json(delegate_launch_req("operator delegate rekick goal")),
3002 )
3003 .await
3004 .expect("tasks_start (no operator referenced, dispatches through baseline)")
3005 .0;
3006 let started = std::time::Instant::now();
3010 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
3011 let elapsed = started.elapsed();
3012
3013 let err = match result {
3014 Err(e) => e,
3015 Ok(_) => panic!(
3016 "rekicking a Task whose Blueprint declares operator_delegate with zero \
3017 attached operators must fail, not dispatch"
3018 ),
3019 };
3020 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
3021 assert!(
3022 err.message.contains("no operator attached"),
3023 "error message must mention the missing operator: {}",
3024 err.message
3025 );
3026 assert!(
3027 elapsed < Duration::from_secs(1),
3028 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
3029 );
3030 }
3031
3032 #[tokio::test]
3036 async fn rekick_stalled_operator_times_out() {
3037 let state = test_state();
3038 state
3039 .engine
3040 .register_operator("stall-op", Arc::new(StallingOperator))
3041 .await;
3042 let posted = crate::tasks_start(
3043 State(state.clone()),
3044 Json(delegate_launch_req("stalled rekick goal")),
3045 )
3046 .await
3047 .expect("tasks_start")
3048 .0;
3049
3050 let started = std::time::Instant::now();
3051 let result = tokio::time::timeout(
3055 Duration::from_secs(5),
3056 task_rekick(
3057 State(state),
3058 Path(posted.task_id.to_string()),
3059 Some(Json(RunKickRequest {
3060 init_ctx_override: None,
3061 task_input_override: None,
3062 timeout_secs: Some(1),
3063 detach: false,
3064 operator_sid: None,
3065 })),
3066 ),
3067 )
3068 .await
3069 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
3070 let elapsed = started.elapsed();
3071
3072 match &result {
3073 Err(e) => {
3074 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
3075 assert!(
3076 e.message.contains('1'),
3077 "error message must mention the configured 1s ceiling: {}",
3078 e.message
3079 );
3080 assert!(
3081 elapsed < Duration::from_secs(3),
3082 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
3083 );
3084 }
3085 Ok(_) => {
3086 assert!(
3098 elapsed < Duration::from_secs(1),
3099 "a rekick that never engages an Operator (task_rekick has no \
3100 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
3101 );
3102 }
3103 }
3104 }
3105
3106 #[tokio::test]
3110 async fn rekick_timeout_secs_zero_rejected() {
3111 let state = test_state();
3112 let posted = crate::tasks_start(
3113 State(state.clone()),
3114 Json(post_tasks_req("zero timeout rekick goal")),
3115 )
3116 .await
3117 .expect("tasks_start")
3118 .0;
3119
3120 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
3121 .await
3122 .expect("task_get")
3123 .0;
3124 let runs_before = before.runs.len();
3125
3126 let result = task_rekick(
3127 State(state.clone()),
3128 Path(posted.task_id.to_string()),
3129 Some(Json(RunKickRequest {
3130 init_ctx_override: None,
3131 task_input_override: None,
3132 timeout_secs: Some(0),
3133 detach: false,
3134 operator_sid: None,
3135 })),
3136 )
3137 .await;
3138 let err = match result {
3139 Err(e) => e,
3140 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
3141 };
3142 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3143 assert!(
3144 err.message.contains("timeout_secs"),
3145 "error message must reference timeout_secs: {}",
3146 err.message
3147 );
3148
3149 let after = task_get(State(state), Path(posted.task_id.to_string()))
3150 .await
3151 .expect("task_get")
3152 .0;
3153 assert_eq!(
3154 after.runs.len(),
3155 runs_before,
3156 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
3157 );
3158 }
3159
3160 #[tokio::test]
3164 async fn rekick_non_operator_path_unaffected_by_guard_1() {
3165 let state = test_state();
3166 let posted = crate::tasks_start(
3167 State(state.clone()),
3168 Json(post_tasks_req("non-operator rekick goal")),
3169 )
3170 .await
3171 .expect("tasks_start")
3172 .0;
3173
3174 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
3175 if let Err(e) = &result {
3176 panic!(
3177 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
3178 guard 1: {}",
3179 e.message
3180 );
3181 }
3182 }
3183
3184 #[tokio::test]
3192 async fn rekick_unknown_operator_sid_rejected_before_side_effects() {
3193 let state = test_state();
3194 let posted = crate::tasks_start(
3195 State(state.clone()),
3196 Json(post_tasks_req("unknown operator_sid rekick goal")),
3197 )
3198 .await
3199 .expect("tasks_start")
3200 .0;
3201
3202 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
3203 .await
3204 .expect("task_get")
3205 .0;
3206 let runs_before = before.runs.len();
3207
3208 let result = task_rekick(
3209 State(state.clone()),
3210 Path(posted.task_id.to_string()),
3211 Some(Json(RunKickRequest {
3212 init_ctx_override: None,
3213 task_input_override: None,
3214 timeout_secs: None,
3215 detach: false,
3216 operator_sid: Some("S-not-registered".to_string()),
3217 })),
3218 )
3219 .await;
3220 let err = match result {
3221 Err(e) => e,
3222 Ok(_) => panic!("an unknown operator_sid must be rejected, not dispatched"),
3223 };
3224 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3225 assert!(
3226 err.message.contains("operator_sid"),
3227 "error message must reference operator_sid: {}",
3228 err.message
3229 );
3230
3231 let after = task_get(State(state), Path(posted.task_id.to_string()))
3232 .await
3233 .expect("task_get")
3234 .0;
3235 assert_eq!(
3236 after.runs.len(),
3237 runs_before,
3238 "a rejected unknown-operator_sid rekick must not create a new Run"
3239 );
3240 }
3241
3242 #[tokio::test]
3250 async fn rekick_with_registered_operator_sid_persists_it_on_the_run() {
3251 let state = test_state();
3252 state
3256 .engine
3257 .register_operator("S-live-op", Arc::new(StallingOperator))
3258 .await;
3259 let posted = crate::tasks_start(
3260 State(state.clone()),
3261 Json(post_tasks_req("registered operator_sid rekick goal")),
3262 )
3263 .await
3264 .expect("tasks_start")
3265 .0;
3266
3267 let (status, rekicked) = task_rekick(
3268 State(state.clone()),
3269 Path(posted.task_id.to_string()),
3270 Some(Json(RunKickRequest {
3271 init_ctx_override: None,
3272 task_input_override: None,
3273 timeout_secs: None,
3274 detach: false,
3275 operator_sid: Some("S-live-op".to_string()),
3276 })),
3277 )
3278 .await
3279 .expect("task_rekick with a registered operator_sid");
3280 assert_eq!(status, StatusCode::CREATED);
3281
3282 let run = state
3283 .run_store
3284 .get(&rekicked.0.run_id)
3285 .await
3286 .expect("run get");
3287 assert_eq!(
3288 run.operator_sid,
3289 Some("S-live-op".to_string()),
3290 "the pinned operator_sid must be persisted verbatim on the RunRecord"
3291 );
3292 }
3293
3294 #[tokio::test]
3300 async fn rekick_pin_reaches_both_axes_and_survives_in_the_launch_snapshot() {
3301 let state = test_state();
3302 state
3303 .engine
3304 .register_operator("S-live-op", Arc::new(StallingOperator))
3305 .await;
3306 let posted = crate::tasks_start(
3307 State(state.clone()),
3308 Json(post_tasks_req("pinned rekick snapshot goal")),
3309 )
3310 .await
3311 .expect("tasks_start")
3312 .0;
3313
3314 let (_status, rekicked) = task_rekick(
3315 State(state.clone()),
3316 Path(posted.task_id.to_string()),
3317 Some(Json(RunKickRequest {
3318 init_ctx_override: None,
3319 task_input_override: None,
3320 timeout_secs: None,
3321 detach: false,
3322 operator_sid: Some("S-live-op".to_string()),
3323 })),
3324 )
3325 .await
3326 .expect("task_rekick with a registered operator_sid");
3327
3328 let run = state
3329 .run_store
3330 .get(&rekicked.0.run_id)
3331 .await
3332 .expect("run get");
3333 let snapshot: Value = serde_json::from_str(
3334 run.input_json
3335 .as_deref()
3336 .expect("a rekicked Run persists its launch snapshot"),
3337 )
3338 .expect("snapshot json");
3339 assert_eq!(
3340 snapshot["operator_backend_id"],
3341 serde_json::json!("S-live-op"),
3342 "the delegate axis keeps receiving the sid exactly as before: {snapshot}"
3343 );
3344 assert_eq!(
3345 snapshot["operator_pin"],
3346 serde_json::json!("S-live-op"),
3347 "the same sid must also pin the AgentSpec axis: {snapshot}"
3348 );
3349 }
3350
3351 #[tokio::test]
3354 async fn unpinned_launch_snapshot_carries_neither_axis() {
3355 let state = test_state();
3356 let posted = crate::tasks_start(
3357 State(state.clone()),
3358 Json(post_tasks_req("unpinned snapshot goal")),
3359 )
3360 .await
3361 .expect("tasks_start")
3362 .0;
3363 let run = state.run_store.get(&posted.run_id).await.expect("run get");
3364 let snapshot: Value =
3365 serde_json::from_str(run.input_json.as_deref().expect("launch snapshot"))
3366 .expect("snapshot json");
3367 assert_eq!(snapshot["operator_backend_id"], Value::Null);
3368 assert_eq!(snapshot["operator_pin"], Value::Null);
3369 assert_eq!(
3370 run.operator_sid, None,
3371 "an unpinned launch records no session on the Run"
3372 );
3373 }
3374
3375 #[test]
3378 fn pre_pin_launch_snapshot_still_decodes() {
3379 let snapshot = serde_json::json!({
3380 "blueprint": { "kind": "inline", "value": identity_blueprint() },
3381 "operator_id": "http-run",
3382 "role": "operator",
3383 "ttl": { "secs": 60, "nanos": 0 },
3384 "init_ctx": {},
3385 "operator_kind": null,
3386 "bridge_id": null,
3387 "hook_id": null,
3388 "operator_backend_id": null,
3389 "task_input": null,
3390 "check_policy": null,
3391 });
3392 let decoded: RunLaunchSnapshot =
3393 serde_json::from_value(snapshot).expect("a pre-pin snapshot must still decode");
3394 assert!(decoded.into_input().operator_pin.is_none());
3395 }
3396
3397 #[tokio::test]
3398 async fn run_get_unknown_id_returns_404() {
3399 let state = test_state();
3400 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
3401 Ok(_) => panic!("expected 404 for an unknown run"),
3402 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3403 }
3404 }
3405
3406 #[tokio::test]
3407 async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
3408 let state = test_state();
3409 let posted = crate::tasks_start(
3410 State(state.clone()),
3411 Json(post_tasks_req("binding explain")),
3412 )
3413 .await
3414 .expect("tasks_start")
3415 .0;
3416 let run = state
3417 .run_store
3418 .get(&posted.run_id)
3419 .await
3420 .expect("stored run");
3421 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3422 let mut bound_agents: Vec<BoundAgent> =
3423 serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
3424 let bound = &mut bound_agents[0];
3425 bound.runner = Some(Runner::WsClaudeCode {
3426 variant: "coder".to_string(),
3427 tools: vec!["Read".to_string()],
3428 });
3429 bound.recompute_binding_digest().unwrap();
3430 let request_digest = bound.binding_digest.clone();
3431 bound
3432 .set_attestation(BindingAttestation {
3433 request_digest: request_digest.clone(),
3434 provider_id: "operator-manifest".to_string(),
3435 provider_revision: Some("claude-code-1.2".to_string()),
3436 resolved_model: Some("claude-sonnet-4".to_string()),
3437 effective_tools: vec!["Bash".to_string(), "Read".to_string()],
3438 launch_variant: Some("coder".to_string()),
3439 capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
3440 b"manifest-v1",
3441 )),
3442 })
3443 .unwrap();
3444 snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
3445 state
3446 .run_store
3447 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3448 .await
3449 .unwrap();
3450
3451 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3452 .await
3453 .expect("binding explain")
3454 .0;
3455 let entry = &explained.bindings[0];
3456 assert_eq!(entry.status, RunBindingStatus::Attested);
3457 assert_eq!(
3458 entry.requested.as_ref().unwrap().request_digest,
3459 request_digest
3460 );
3461 assert_eq!(
3462 entry
3463 .effective
3464 .as_ref()
3465 .unwrap()
3466 .provider_revision
3467 .as_deref(),
3468 Some("claude-code-1.2")
3469 );
3470 assert_eq!(
3471 entry
3472 .difference
3473 .as_ref()
3474 .unwrap()
3475 .additional_effective_tools,
3476 vec!["Bash"]
3477 );
3478 assert!(entry
3479 .difference
3480 .as_ref()
3481 .unwrap()
3482 .missing_requested_tools
3483 .is_empty());
3484 assert_ne!(entry.binding_digest, request_digest);
3485 }
3486
3487 #[tokio::test]
3488 async fn run_bindings_explain_reports_snapshot_origin() {
3489 let state = test_state();
3490 let posted =
3491 crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
3492 .await
3493 .expect("tasks_start")
3494 .0;
3495
3496 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3498 .await
3499 .expect("binding explain")
3500 .0;
3501 assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
3502
3503 let run = state.run_store.get(&posted.run_id).await.unwrap();
3505 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3506 snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
3507 state
3508 .run_store
3509 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3510 .await
3511 .unwrap();
3512 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3513 .await
3514 .expect("binding explain")
3515 .0;
3516 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3517
3518 snapshot
3522 .as_object_mut()
3523 .unwrap()
3524 .remove("bound_agents_origin");
3525 state
3526 .run_store
3527 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3528 .await
3529 .unwrap();
3530 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3531 .await
3532 .expect("explain still 200 without an origin marker")
3533 .0;
3534 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3535 }
3536
3537 #[tokio::test]
3538 async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
3539 let state = test_state();
3540 let posted = crate::tasks_start(
3541 State(state.clone()),
3542 Json(post_tasks_req("legacy binding explain")),
3543 )
3544 .await
3545 .expect("tasks_start")
3546 .0;
3547 state
3548 .run_store
3549 .set_input_json(&posted.run_id, "{}".to_string())
3550 .await
3551 .unwrap();
3552
3553 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3554 .await
3555 .expect_err("legacy run must not be re-resolved");
3556 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3557 assert!(error
3558 .message
3559 .contains("current Blueprint state was not consulted"));
3560 }
3561
3562 #[tokio::test]
3563 async fn run_bindings_explain_rejects_a_tampered_snapshot() {
3564 let state = test_state();
3565 let posted = crate::tasks_start(
3566 State(state.clone()),
3567 Json(post_tasks_req("tampered binding explain")),
3568 )
3569 .await
3570 .expect("tasks_start")
3571 .0;
3572 let run = state.run_store.get(&posted.run_id).await.unwrap();
3573 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3574 snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
3575 state
3576 .run_store
3577 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3578 .await
3579 .unwrap();
3580
3581 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3582 .await
3583 .expect_err("digest drift must fail closed");
3584 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3585 assert!(error.message.contains("inconsistent binding snapshot"));
3586 }
3587
3588 #[tokio::test]
3589 async fn task_get_unknown_id_returns_404() {
3590 let state = test_state();
3591 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
3592 Ok(_) => panic!("expected 404 for an unknown task"),
3593 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3594 }
3595 }
3596
3597 async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
3604 let task_id = TaskId::new();
3605 let run_id = RunId::new();
3606 state
3607 .task_store
3608 .create(TaskRecord {
3609 id: task_id.clone(),
3610 goal: "finalize-run-err-envelope".to_string(),
3611 blueprint_ref: json!("inline"),
3612 input_ctx: Value::Null,
3613 task_input_spec: None,
3614 status: TaskRecordStatus::Running,
3615 created_at: 0,
3616 updated_at: 0,
3617 })
3618 .await
3619 .expect("seed TaskRecord");
3620 state
3621 .run_store
3622 .create(RunRecord {
3623 id: run_id.clone(),
3624 task_id: task_id.clone(),
3625 status: RunStatus::Running,
3626 step_entries: Vec::new(),
3627 degradations: Vec::new(),
3628 operator_sid: None,
3629 result_ref: None,
3630 input_json: Some("{}".to_string()),
3631 created_at: 0,
3632 updated_at: 0,
3633 })
3634 .await
3635 .expect("seed RunRecord");
3636 (task_id, run_id)
3637 }
3638
3639 #[tokio::test]
3640 async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
3641 let state = test_state();
3642 let (task_id, run_id) = seed_task_and_run(&state).await;
3643
3644 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3645 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3646 message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
3647 failed_step: Some("gate".to_string()),
3648 verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
3649 partial_ctx: Some(
3650 json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
3651 ),
3652 }));
3653
3654 let _ = finalize_run(&state, &task_id, &run_id, err).await;
3655
3656 let run = state.run_store.get(&run_id).await.expect("run present");
3657 assert_eq!(run.status, RunStatus::Failed);
3658 let envelope = run
3659 .result_ref
3660 .as_ref()
3661 .expect("result_ref must be Some on Err arm");
3662 assert_eq!(
3663 envelope["error"]["message"],
3664 "blocked: {\"verdict\":\"BLOCKED\"}"
3665 );
3666 assert_eq!(envelope["error"]["failed_step"], "gate");
3667 assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
3668 assert_eq!(
3669 envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
3670 "blocked"
3671 );
3672
3673 let task = state.task_store.get(&task_id).await.expect("task present");
3675 assert_eq!(task.status, TaskRecordStatus::Failed);
3676 }
3677
3678 #[tokio::test]
3679 async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
3680 let state = test_state();
3681 let (task_id, run_id) = seed_task_and_run(&state).await;
3682
3683 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3687 Err(TaskApplicationError::NoStore);
3688
3689 let _ = finalize_run(&state, &task_id, &run_id, err).await;
3690 let run = state.run_store.get(&run_id).await.expect("run present");
3691 let envelope = run
3692 .result_ref
3693 .as_ref()
3694 .expect("result_ref must be Some on Err arm");
3695 assert!(envelope["error"]["message"]
3696 .as_str()
3697 .expect("message string")
3698 .contains("store"));
3699 assert_eq!(envelope["error"]["failed_step"], Value::Null);
3700 assert_eq!(envelope["error"]["verdict_value"], Value::Null);
3701 assert_eq!(envelope["partial_ctx"], Value::Null);
3702 }
3703
3704 #[tokio::test]
3709 async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
3710 let state = test_state();
3711 let (task_id, run_id) = seed_task_and_run(&state).await;
3712
3713 let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
3714 token: mlua_swarm::CapToken {
3715 agent_id: "ut".to_string(),
3716 role: mlua_swarm::Role::Operator,
3717 scopes: vec!["*".to_string()],
3718 issued_at: 0,
3719 expire_at: u64::MAX,
3720 max_uses: None,
3721 nonce: "ut-nonce".to_string(),
3722 sig_hex: String::new(),
3723 },
3724 final_ctx: json!({"out": {"echoed": "hi"}}),
3725 bound_version: None,
3726 });
3727
3728 let _ = finalize_run(&state, &task_id, &run_id, ok).await;
3729 let run = state.run_store.get(&run_id).await.expect("run present");
3730 assert_eq!(run.status, RunStatus::Done);
3731 let stored = run.result_ref.as_ref().expect("result_ref Some");
3732 assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
3734 assert!(
3735 stored.get("error").is_none(),
3736 "Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
3737 );
3738 }
3739
3740 #[tokio::test]
3745 async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
3746 let state = test_state();
3747 let (_task_id, run_id) = seed_task_and_run(&state).await;
3748 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3749 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3750 message: "blocked: bad verdict".to_string(),
3751 failed_step: Some("scout".to_string()),
3752 verdict_value: Some(json!("BLOCKED")),
3753 partial_ctx: Some(json!({"steps": {}})),
3754 }));
3755 let _ = finalize_run(&state, &_task_id, &run_id, err).await;
3756
3757 let Json(run) = run_get(State(state), Path(run_id.to_string()))
3758 .await
3759 .expect("run_get");
3760 assert_eq!(run.status, RunStatus::Failed);
3761 let envelope = run.result_ref.expect("result_ref Some");
3762 assert_eq!(envelope["error"]["failed_step"], "scout");
3763 assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
3764 }
3765}