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 (tx, rx) = tokio::sync::oneshot::channel::<Result<(), ApiError>>();
849 let bg_state = state.clone();
850 let bg_task_id = task_id.clone();
851 let bg_run_id = run_id.clone();
852 let guard_state = state.clone();
853 let guard_task_id = task_id.clone();
854 let guard_run_id = run_id.clone();
855 tokio::spawn(async move {
856 let driver = async move {
857 let outcome = match tokio::time::timeout(
858 Duration::from_secs(sync_timeout_secs),
859 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
860 )
861 .await
862 {
863 Ok(outcome) => outcome,
864 Err(_elapsed) => {
865 let reason = serde_json::json!({
866 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
867 });
868 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
869 tracing::warn!(%bg_run_id, error = %e, "task_rekick: timeout set_result failed");
870 }
871 if let Err(e) = bg_state
872 .run_store
873 .update_status(&bg_run_id, RunStatus::Failed)
874 .await
875 {
876 tracing::warn!(%bg_run_id, error = %e, "task_rekick: timeout run update_status failed");
877 }
878 if let Err(e) = bg_state
879 .task_store
880 .update_status(&bg_task_id, TaskRecordStatus::Failed)
881 .await
882 {
883 tracing::warn!(%bg_task_id, error = %e, "task_rekick: timeout task update_status failed");
884 }
885 return Err(ApiError::timeout(format!(
886 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {bg_task_id}, run {bg_run_id}"
887 )));
888 }
889 };
890 finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome)
891 .await
892 .map(|_| ())
893 .map_err(|e| ApiError::bad_request(format!("run: {e}")))
894 };
895 let reply = match catch_run_panic(
896 &guard_state,
897 &guard_task_id,
898 &guard_run_id,
899 "rekick.sync",
900 driver,
901 )
902 .await
903 {
904 Ok(reply) => reply,
905 Err(msg) => Err(ApiError::engine(format!(
906 "run driver panicked: {msg}; the run was marked Interrupted and can be resumed \
907 via POST /v1/runs/{guard_run_id}/resume"
908 ))),
909 };
910 let _ = tx.send(reply);
913 });
914
915 rx.await.map_err(|_| {
918 ApiError::engine(format!(
919 "run driver task ended without reporting an outcome; see GET /v1/runs/{run_id} \
920 for the run's persisted status"
921 ))
922 })??;
923
924 Ok((
925 StatusCode::CREATED,
926 Json(RunKickResponse {
927 task_id,
928 run_id,
929 status: RunStatus::Done,
930 }),
931 ))
932}
933
934#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
936pub struct RunResumeResponse {
937 #[schemars(with = "String")]
942 pub run_id: RunId,
943 #[schemars(with = "String")]
945 pub task_id: TaskId,
946 pub replayed_steps: usize,
951}
952
953pub async fn run_resume(
979 State(state): State<AppState>,
980 Path(id): Path<String>,
981) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
982 let run_id =
983 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
984
985 let run = state
987 .run_store
988 .get(&run_id)
989 .await
990 .map_err(map_run_store_err)?;
991
992 if run.status != RunStatus::Interrupted {
994 return Err(ApiError::conflict(format!(
995 "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
996 run.status
997 )));
998 }
999
1000 let Some(input_json) = run.input_json.clone() else {
1005 return Err(ApiError::unprocessable(format!(
1006 "run {run_id} cannot be resumed: no launch input was recorded for it (it \
1007 predates resume support, or was created by a path that does not persist one)"
1008 )));
1009 };
1010 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
1011 ApiError::unprocessable(format!(
1012 "run {run_id}: stored launch input failed to decode: {e}"
1013 ))
1014 })?;
1015 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
1016 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
1017 ApiError::unprocessable(format!(
1018 "run {run_id}: stored launch input failed to decode: {e}"
1019 ))
1020 })?;
1021
1022 let won = state
1026 .run_store
1027 .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
1028 .await
1029 .map_err(ApiError::engine)?;
1030 if !won {
1031 return Err(ApiError::conflict(format!(
1032 "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
1033 no longer resumable"
1034 )));
1035 }
1036
1037 let entries = state
1041 .replay_store
1042 .list_by_run(&run_id)
1043 .await
1044 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
1045 let replayed_steps = entries.len();
1046 let cursor = ReplayCursor::from_entries(entries);
1047
1048 let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
1053 trace
1054 .append(
1055 trace_kind::RUN_STARTED,
1056 None,
1057 None,
1058 json!({"mode": "resume"}),
1059 )
1060 .await;
1061 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1062 .with_replay_store(state.replay_store.clone())
1063 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1064 .with_resume()
1065 .with_trace(trace);
1066
1067 let input = snapshot.into_input();
1068 let task_id = run.task_id.clone();
1069
1070 state
1073 .task_store
1074 .update_status(&task_id, TaskRecordStatus::Running)
1075 .await
1076 .map_err(ApiError::engine)?;
1077
1078 let ttl_secs = crate::default_run_ttl();
1082 let bg_state = state.clone();
1083 let bg_task_id = task_id.clone();
1084 let bg_run_id = run_id.clone();
1085 let guard_state = state.clone();
1087 let guard_task_id = task_id.clone();
1088 let guard_run_id = run_id.clone();
1089 tokio::spawn(async move {
1090 let driver = async move {
1091 let outcome = match tokio::time::timeout(
1092 Duration::from_secs(ttl_secs),
1093 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1094 )
1095 .await
1096 {
1097 Ok(outcome) => outcome,
1098 Err(_elapsed) => {
1099 let reason = serde_json::json!({
1100 "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
1101 });
1102 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1103 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
1104 }
1105 if let Err(e) = bg_state
1106 .run_store
1107 .update_status(&bg_run_id, RunStatus::Failed)
1108 .await
1109 {
1110 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
1111 }
1112 if let Err(e) = bg_state
1113 .task_store
1114 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1115 .await
1116 {
1117 tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
1118 }
1119 return;
1120 }
1121 };
1122 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1124 };
1125 let _ = catch_run_panic(
1126 &guard_state,
1127 &guard_task_id,
1128 &guard_run_id,
1129 "resume.detach",
1130 driver,
1131 )
1132 .await;
1133 });
1134
1135 Ok((
1136 StatusCode::ACCEPTED,
1137 Json(RunResumeResponse {
1138 run_id,
1139 task_id,
1140 replayed_steps,
1141 }),
1142 ))
1143}
1144
1145#[derive(Debug, Deserialize, schemars::JsonSchema)]
1147pub struct RunRerunFromRequest {
1148 pub from_step: String,
1155}
1156
1157#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
1159pub struct RunRerunFromResponse {
1160 #[schemars(with = "String")]
1165 pub run_id: RunId,
1166 #[schemars(with = "String")]
1168 pub task_id: TaskId,
1169 pub replayed_steps: usize,
1173 pub dropped_steps: usize,
1176}
1177
1178pub async fn run_rerun_from(
1253 State(state): State<AppState>,
1254 Path(id): Path<String>,
1255 Json(req): Json<RunRerunFromRequest>,
1256) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
1257 let run_id =
1258 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1259
1260 if req.from_step.trim().is_empty() {
1261 return Err(ApiError::bad_request(
1262 "from_step must be a non-empty step ref".to_string(),
1263 ));
1264 }
1265
1266 let run = state
1268 .run_store
1269 .get(&run_id)
1270 .await
1271 .map_err(map_run_store_err)?;
1272
1273 let current = run.status;
1276 match current {
1277 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted | RunStatus::Cancelled => { }
1279 RunStatus::Running | RunStatus::Pending => {
1280 return Err(ApiError::conflict(format!(
1281 "run {run_id} is {current:?}; rerun-from requires a terminal run \
1282 (Done / Failed / Interrupted / Cancelled)"
1283 )));
1284 }
1285 }
1286
1287 let Some(input_json) = run.input_json.clone() else {
1292 return Err(ApiError::unprocessable(format!(
1293 "run {run_id} cannot be rerun: no launch input was recorded for it (it \
1294 predates resume/rerun support, or was created by a path that does not \
1295 persist one)"
1296 )));
1297 };
1298 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
1299 ApiError::unprocessable(format!(
1300 "run {run_id}: stored launch input failed to decode: {e}"
1301 ))
1302 })?;
1303 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
1304 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
1305 ApiError::unprocessable(format!(
1306 "run {run_id}: stored launch input failed to decode: {e}"
1307 ))
1308 })?;
1309
1310 let entries = state
1313 .replay_store
1314 .list_by_run(&run_id)
1315 .await
1316 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
1317 let cut = entries
1318 .iter()
1319 .position(|e| e.step_ref == req.from_step)
1320 .ok_or_else(|| {
1321 if entries.is_empty() && !run.step_entries.is_empty() {
1331 ApiError::unprocessable(format!(
1332 "run {run_id}: replay log is empty but {} step entries are traced \
1333 on the RunRecord — the log was consumed by a prior rerun-from \
1334 that reached the truncate stage. This run can no longer be \
1335 rerun-from; start a fresh run via POST /v1/tasks.",
1336 run.step_entries.len()
1337 ))
1338 } else {
1339 ApiError::unprocessable(format!(
1340 "run {run_id}: from_step {:?} not present in this run's replay log \
1341 (nothing to rerun-from)",
1342 req.from_step
1343 ))
1344 }
1345 })?;
1346
1347 if let Err(e) = state.task_app.precompile(&snapshot.blueprint).await {
1361 return Err(ApiError::unprocessable(format!(
1362 "run {run_id} cannot be rerun: current-head Blueprint fails to compile — {e}"
1363 )));
1364 }
1365
1366 let won = state
1371 .run_store
1372 .try_transition(&run_id, current, RunStatus::Running)
1373 .await
1374 .map_err(ApiError::engine)?;
1375 if !won {
1376 return Err(ApiError::conflict(format!(
1377 "run {run_id} was concurrently transitioned (or left the {current:?} state); \
1378 it is no longer rerunnable"
1379 )));
1380 }
1381
1382 let dropped_steps = state
1387 .replay_store
1388 .delete_from(&run_id, cut)
1389 .await
1390 .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
1391
1392 let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
1395 let replayed_steps = kept.len();
1396 let cursor = ReplayCursor::from_entries(kept);
1397
1398 let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
1402 trace
1403 .append(
1404 trace_kind::RUN_STARTED,
1405 None,
1406 None,
1407 json!({"mode": "rerun_from"}),
1408 )
1409 .await;
1410 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1411 .with_replay_store(state.replay_store.clone())
1412 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1413 .with_resume()
1414 .with_trace(trace);
1415
1416 let input = snapshot.into_input();
1417 let task_id = run.task_id.clone();
1418
1419 state
1422 .task_store
1423 .update_status(&task_id, TaskRecordStatus::Running)
1424 .await
1425 .map_err(ApiError::engine)?;
1426
1427 let ttl_secs = crate::default_run_ttl();
1428 let bg_state = state.clone();
1429 let bg_task_id = task_id.clone();
1430 let bg_run_id = run_id.clone();
1431 let guard_state = state.clone();
1433 let guard_task_id = task_id.clone();
1434 let guard_run_id = run_id.clone();
1435 tokio::spawn(async move {
1436 let driver = async move {
1437 let outcome = match tokio::time::timeout(
1438 Duration::from_secs(ttl_secs),
1439 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1440 )
1441 .await
1442 {
1443 Ok(outcome) => outcome,
1444 Err(_elapsed) => {
1445 let reason = serde_json::json!({
1446 "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1447 });
1448 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1449 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1450 }
1451 if let Err(e) = bg_state
1452 .run_store
1453 .update_status(&bg_run_id, RunStatus::Failed)
1454 .await
1455 {
1456 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1457 }
1458 if let Err(e) = bg_state
1459 .task_store
1460 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1461 .await
1462 {
1463 tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1464 }
1465 return;
1466 }
1467 };
1468 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1469 };
1470 let _ = catch_run_panic(
1471 &guard_state,
1472 &guard_task_id,
1473 &guard_run_id,
1474 "rerun_from.detach",
1475 driver,
1476 )
1477 .await;
1478 });
1479
1480 Ok((
1481 StatusCode::ACCEPTED,
1482 Json(RunRerunFromResponse {
1483 run_id,
1484 task_id,
1485 replayed_steps,
1486 dropped_steps,
1487 }),
1488 ))
1489}
1490
1491pub async fn run_get(
1494 State(state): State<AppState>,
1495 Path(id): Path<String>,
1496) -> Result<Json<RunRecord>, ApiError> {
1497 let run_id =
1498 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1499 let run = state
1500 .run_store
1501 .get(&run_id)
1502 .await
1503 .map_err(map_run_store_err)?;
1504 Ok(Json(run))
1505}
1506
1507#[derive(Debug, Deserialize, Default)]
1509pub struct RunsListQuery {
1510 #[serde(default)]
1512 pub task_id: Option<String>,
1513 #[serde(default)]
1516 pub status: Option<String>,
1517 #[serde(default)]
1519 pub limit: Option<usize>,
1520 #[serde(default)]
1522 pub offset: Option<usize>,
1523}
1524
1525#[derive(Debug, Serialize)]
1527pub struct RunsListResponse {
1528 pub runs: Vec<RunRecord>,
1530}
1531
1532pub async fn runs_list(
1537 State(state): State<AppState>,
1538 Query(q): Query<RunsListQuery>,
1539) -> Result<Json<RunsListResponse>, ApiError> {
1540 let task_id = q
1541 .task_id
1542 .map(TaskId::parse)
1543 .transpose()
1544 .map_err(|e| ApiError::bad_request(format!("invalid task_id: {e}")))?;
1545 let status = q
1546 .status
1547 .as_deref()
1548 .map(|s| {
1549 serde_json::from_value::<RunStatus>(Value::String(s.to_string())).map_err(|_| {
1550 ApiError::bad_request(format!(
1551 "invalid status {s:?} (expected pending/running/done/failed/interrupted)"
1552 ))
1553 })
1554 })
1555 .transpose()?;
1556 let runs = state
1557 .run_store
1558 .list(&RunListFilter {
1559 task_id,
1560 status,
1561 limit: q.limit,
1562 offset: q.offset,
1563 })
1564 .await
1565 .map_err(map_run_store_err)?;
1566 Ok(Json(RunsListResponse { runs }))
1567}
1568
1569#[derive(Debug, Serialize, schemars::JsonSchema)]
1574pub struct RunStepsResponse {
1575 pub run_id: String,
1577 pub steps: Vec<StepEntry>,
1579}
1580
1581pub async fn run_steps(
1586 State(state): State<AppState>,
1587 Path(id): Path<String>,
1588) -> Result<Json<RunStepsResponse>, ApiError> {
1589 let run_id =
1590 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1591 let run = state
1592 .run_store
1593 .get(&run_id)
1594 .await
1595 .map_err(map_run_store_err)?;
1596 Ok(Json(RunStepsResponse {
1597 run_id: run.id.to_string(),
1598 steps: run.step_entries,
1599 }))
1600}
1601
1602#[derive(Debug, Deserialize, Default)]
1606pub struct RunTraceQuery {
1607 #[serde(default)]
1609 pub after: Option<u64>,
1610 #[serde(default)]
1612 pub limit: Option<usize>,
1613 #[serde(default)]
1615 pub latest: Option<usize>,
1616 #[serde(default)]
1619 pub kind: Option<String>,
1620 #[serde(default)]
1622 pub step: Option<String>,
1623 #[serde(default)]
1625 pub attempt: Option<u32>,
1626}
1627
1628#[derive(Debug, Serialize)]
1630pub struct RunTraceResponse {
1631 pub run_id: String,
1633 pub events: Vec<TraceEvent>,
1635}
1636
1637pub async fn run_trace(
1643 State(state): State<AppState>,
1644 Path(id): Path<String>,
1645 Query(q): Query<RunTraceQuery>,
1646) -> Result<Json<RunTraceResponse>, ApiError> {
1647 let run_id =
1648 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1649 let query = TraceQuery {
1650 after: q.after,
1651 limit: q.limit,
1652 latest: q.latest,
1653 kinds: q
1654 .kind
1655 .as_deref()
1656 .map(|s| {
1657 s.split(',')
1658 .map(str::trim)
1659 .filter(|k| !k.is_empty())
1660 .map(str::to_string)
1661 .collect()
1662 })
1663 .unwrap_or_default(),
1664 step_ref: q.step,
1665 attempt: q.attempt,
1666 };
1667 let events = state
1668 .run_trace_store
1669 .list(&run_id, &query)
1670 .await
1671 .map_err(|e| ApiError::engine(format!("trace list: {e}")))?;
1672 Ok(Json(RunTraceResponse {
1673 run_id: run_id.to_string(),
1674 events,
1675 }))
1676}
1677
1678pub async fn run_cancel(
1688 State(state): State<AppState>,
1689 Path(id): Path<String>,
1690) -> Result<axum::http::StatusCode, ApiError> {
1691 let run_id =
1692 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1693 let record = state
1696 .run_store
1697 .get(&run_id)
1698 .await
1699 .map_err(map_run_store_err)?;
1700 TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
1703 .append(trace_kind::CANCEL_REQUESTED, None, None, json!({}))
1704 .await;
1705 if matches!(record.status, RunStatus::Pending | RunStatus::Running) {
1710 if let Err(e) = state
1711 .run_store
1712 .update_status(&run_id, RunStatus::Cancelled)
1713 .await
1714 {
1715 tracing::warn!(%run_id, error = %e, "run_cancel: update_status(Cancelled) failed");
1716 }
1717 }
1718 Ok(axum::http::StatusCode::NO_CONTENT)
1719}
1720
1721pub async fn run_delete(
1734 State(state): State<AppState>,
1735 Path(id): Path<String>,
1736) -> Result<axum::http::StatusCode, ApiError> {
1737 let run_id =
1738 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1739 state
1740 .run_store
1741 .delete(&run_id)
1742 .await
1743 .map_err(map_run_store_err)?;
1744 if let Err(e) = state.run_trace_store.delete_run(&run_id).await {
1745 tracing::warn!(%run_id, error = %e, "run_delete: trace delete_run failed (run row already deleted)");
1746 }
1747 Ok(axum::http::StatusCode::NO_CONTENT)
1748}
1749
1750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1753#[serde(rename_all = "snake_case")]
1754pub enum RunBindingStatus {
1755 DeclarationOnly,
1758 Attested,
1760}
1761
1762#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1764pub struct RunBindingDifference {
1765 pub model_changed: bool,
1767 pub missing_requested_tools: Vec<String>,
1770 pub additional_effective_tools: Vec<String>,
1772 pub launch_variant_changed: bool,
1774}
1775
1776#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1779pub struct RunBindingExplainEntry {
1780 pub agent: String,
1782 pub runner_source: mlua_swarm::blueprint::RunnerResolutionSource,
1784 pub status: RunBindingStatus,
1786 pub requested: Option<BindRequest>,
1788 pub effective: Option<BindingAttestation>,
1790 pub difference: Option<RunBindingDifference>,
1793 pub binding_digest: mlua_swarm::blueprint::BindingDigest,
1795}
1796
1797#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1799pub struct RunBindingsExplainResponse {
1800 #[schemars(with = "String")]
1802 pub run_id: RunId,
1803 #[schemars(with = "String")]
1805 pub task_id: TaskId,
1806 pub snapshot_origin: SnapshotOrigin,
1814 pub bindings: Vec<RunBindingExplainEntry>,
1816}
1817
1818fn requested_binding(bound: &BoundAgent) -> Option<BindRequest> {
1819 mlua_swarm::binding_request_for_snapshot(bound)
1820}
1821
1822fn binding_difference(
1823 requested: &BindRequest,
1824 effective: &BindingAttestation,
1825) -> RunBindingDifference {
1826 let missing_requested_tools = requested
1827 .requested_tools
1828 .iter()
1829 .filter(|tool| !effective.effective_tools.contains(tool))
1830 .cloned()
1831 .collect();
1832 let additional_effective_tools = effective
1833 .effective_tools
1834 .iter()
1835 .filter(|tool| !requested.requested_tools.contains(tool))
1836 .cloned()
1837 .collect();
1838 RunBindingDifference {
1839 model_changed: requested.requested_model != effective.resolved_model,
1840 missing_requested_tools,
1841 additional_effective_tools,
1842 launch_variant_changed: requested.launch_variant != effective.launch_variant,
1843 }
1844}
1845
1846fn validated_bound_agents_from_snapshot(
1847 run_id: &RunId,
1848 snapshot: &Value,
1849) -> Result<Option<Vec<BoundAgent>>, ApiError> {
1850 let Some(bound_value) = snapshot.get("bound_agents") else {
1851 return Ok(None);
1852 };
1853 let bound_agents: Vec<BoundAgent> =
1854 serde_json::from_value(bound_value.clone()).map_err(|e| {
1855 ApiError::unprocessable(format!(
1856 "run {run_id} contains an invalid binding snapshot: {e}"
1857 ))
1858 })?;
1859 validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
1860 ApiError::unprocessable(format!(
1861 "run {run_id} contains an inconsistent binding snapshot: {error}"
1862 ))
1863 })?;
1864 Ok(Some(bound_agents))
1865}
1866
1867pub async fn run_bindings_explain(
1871 State(state): State<AppState>,
1872 Path(id): Path<String>,
1873) -> Result<Json<RunBindingsExplainResponse>, ApiError> {
1874 let run_id =
1875 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1876 let run = state
1877 .run_store
1878 .get(&run_id)
1879 .await
1880 .map_err(map_run_store_err)?;
1881 let input_json = run.input_json.as_deref().ok_or_else(|| {
1882 ApiError::unprocessable(format!(
1883 "run {run_id} has no launch snapshot; binding explain is unavailable"
1884 ))
1885 })?;
1886 let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
1887 ApiError::unprocessable(format!(
1888 "run {run_id} launch snapshot is invalid JSON; binding explain is unavailable: {e}"
1889 ))
1890 })?;
1891 let bound_agents = validated_bound_agents_from_snapshot(&run_id, &snapshot)?.ok_or_else(|| {
1892 ApiError::unprocessable(format!(
1893 "run {run_id} predates immutable binding snapshots; current Blueprint state was not consulted"
1894 ))
1895 })?;
1896
1897 let bindings = bound_agents
1898 .into_iter()
1899 .map(|bound| {
1900 let requested = requested_binding(&bound);
1901 let effective = bound.attestation.clone();
1902 let difference = requested
1903 .as_ref()
1904 .zip(effective.as_ref())
1905 .map(|(request, attestation)| binding_difference(request, attestation));
1906 RunBindingExplainEntry {
1907 agent: bound.agent.name,
1908 runner_source: bound.runner_source,
1909 status: if effective.is_some() {
1910 RunBindingStatus::Attested
1911 } else {
1912 RunBindingStatus::DeclarationOnly
1913 },
1914 requested,
1915 effective,
1916 difference,
1917 binding_digest: bound.binding_digest,
1918 }
1919 })
1920 .collect();
1921
1922 Ok(Json(RunBindingsExplainResponse {
1923 run_id: run.id,
1924 task_id: run.task_id,
1925 snapshot_origin: SnapshotOrigin::from_snapshot(&snapshot),
1926 bindings,
1927 }))
1928}
1929
1930pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1934 match e {
1935 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1936 other => ApiError::engine(other),
1937 }
1938}
1939
1940fn map_run_store_err(e: RunStoreError) -> ApiError {
1941 match e {
1942 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1943 other => ApiError::engine(other),
1944 }
1945}
1946
1947#[cfg(test)]
1952mod tests {
1953 use super::*;
1954 use mlua_swarm::application::BlueprintRef;
1955 use mlua_swarm::blueprint::{
1956 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1957 CompilerStrategy, Runner,
1958 };
1959 use mlua_swarm::core::config::EngineCfg;
1960 use mlua_swarm::core::engine::Engine;
1961 use mlua_swarm::store::output::InMemoryOutputStore;
1962 use mlua_swarm::store::run::InMemoryRunStore;
1963 use mlua_swarm::store::task::InMemoryTaskStore;
1964 use std::collections::HashMap;
1965 use std::sync::Arc;
1966 use tokio::sync::Mutex;
1967
1968 fn identity_blueprint() -> Blueprint {
1974 Blueprint {
1975 schema_version: current_schema_version(),
1976 id: "tasks-test-bp".into(),
1977 flow: serde_json::from_value(serde_json::json!({
1978 "kind": "step",
1979 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1980 "in": {"op": "lit", "value": "hello"},
1981 "out": {"op": "path", "at": "$.out"},
1982 }))
1983 .expect("flow parse"),
1984 agents: vec![AgentDef {
1985 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1986 kind: AgentKind::RustFn,
1987 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1988 profile: None,
1989 meta: None,
1990 runner: None,
1991 runner_ref: None,
1992 verdict: None,
1993 }],
1994 operators: vec![],
1995 metas: vec![],
1996 hints: CompilerHints::default(),
1997 strategy: CompilerStrategy::default(),
1998 metadata: BlueprintMetadata::default(),
1999 spawner_hints: Default::default(),
2000 default_agent_kind: AgentKind::Operator,
2001 default_operator_kind: None,
2002 default_init_ctx: None,
2003 default_agent_ctx: None,
2004 default_context_policy: None,
2005 projection_placement: None,
2006 audits: vec![],
2007 degradation_policy: None,
2008 runners: vec![],
2009 default_runner: None,
2010 subprocesses: vec![],
2011 check_policy: None,
2012 blueprint_ref_includes: Vec::new(),
2013 }
2014 }
2015
2016 fn test_state() -> AppState {
2021 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
2022 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
2023 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
2024 AppState {
2025 engine,
2026 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
2027 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
2028 ws_operator_factory: None,
2029 data_store: Arc::new(InMemoryOutputStore::new()),
2030 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
2031 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
2032 task_store: Arc::new(InMemoryTaskStore::new()),
2033 run_store: Arc::new(InMemoryRunStore::new()),
2034 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
2035 run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
2036 base_url: None,
2037 sync_timeout_secs: 300,
2038 }
2039 }
2040
2041 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
2042 crate::TaskLaunchRequest {
2043 blueprint: BlueprintRef::Inline {
2044 value: Box::new(identity_blueprint()),
2045 },
2046 init_ctx: serde_json::json!({"in": "hello"}),
2047 project_root: None,
2048 work_dir: None,
2049 task_metadata: None,
2050 ttl_secs: None,
2051 operator: None,
2052 operator_sid: None,
2053 timeout_secs: None,
2054 goal: Some(goal.to_string()),
2055 detach: false,
2056 check_policy: None,
2057 }
2058 }
2059
2060 #[test]
2061 fn task_id_serializes_as_bare_string() {
2062 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
2066 assert_eq!(v, serde_json::json!("T-abc"));
2067 }
2068
2069 #[tokio::test]
2070 async fn post_then_get_drill_down() {
2071 let state = test_state();
2072
2073 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
2074 .await
2075 .expect("tasks_start")
2076 .0;
2077 let task_id = posted.task_id.clone();
2078 let run_id = posted.run_id.clone();
2079
2080 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
2082 .await
2083 .expect("tasks_list")
2084 .0;
2085 assert!(
2086 list.iter().any(|t| t.id == task_id),
2087 "task {task_id} missing from list of {} tasks",
2088 list.len()
2089 );
2090
2091 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2093 .await
2094 .expect("task_get")
2095 .0;
2096 assert_eq!(detail.task.id, task_id);
2097 assert_eq!(detail.task.goal, "smoke goal");
2098 assert_eq!(detail.task.status, TaskRecordStatus::Done);
2099 assert_eq!(detail.runs.len(), 1);
2100 assert_eq!(detail.runs[0].id, run_id);
2101 assert_eq!(detail.runs[0].status, RunStatus::Done);
2102
2103 let run = run_get(State(state.clone()), Path(run_id.to_string()))
2105 .await
2106 .expect("run_get")
2107 .0;
2108 assert_eq!(run.id, run_id);
2109 assert_eq!(run.task_id, task_id);
2110 assert_eq!(run.result_ref, Some(posted.final_ctx));
2111
2112 assert_eq!(
2116 run.step_entries.len(),
2117 1,
2118 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
2119 run.step_entries
2120 );
2121 assert_eq!(
2122 run.step_entries[0].step_ref,
2123 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2124 );
2125 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2126 }
2127
2128 fn identity_blueprint_with_operator_delegate() -> Blueprint {
2140 Blueprint {
2141 spawner_hints: mlua_swarm::SpawnerHints {
2142 layers: vec!["operator_delegate".to_string()],
2143 },
2144 ..identity_blueprint()
2145 }
2146 }
2147
2148 struct StallingOperator;
2151
2152 #[async_trait::async_trait]
2153 impl mlua_swarm::Operator for StallingOperator {
2154 async fn execute(
2155 &self,
2156 _ctx: &mlua_swarm::Ctx,
2157 _system: Option<String>,
2158 _prompt: Value,
2159 _worker: Option<mlua_swarm::WorkerBinding>,
2160 _worker_token: mlua_swarm::CapToken,
2161 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
2162 std::future::pending::<()>().await;
2163 unreachable!("StallingOperator.execute must never resolve")
2164 }
2165 }
2166
2167 fn operator_launch_req(
2171 backend_id: &str,
2172 timeout_secs: Option<u64>,
2173 ) -> crate::TaskLaunchRequest {
2174 crate::TaskLaunchRequest {
2175 blueprint: BlueprintRef::Inline {
2176 value: Box::new(identity_blueprint_with_operator_delegate()),
2177 },
2178 init_ctx: serde_json::json!({"in": "hello"}),
2179 project_root: None,
2180 work_dir: None,
2181 task_metadata: None,
2182 ttl_secs: None,
2183 operator: Some(crate::OperatorReq {
2184 operator_backend_id: Some(backend_id.to_string()),
2185 ..Default::default()
2186 }),
2187 operator_sid: None,
2188 timeout_secs,
2189 goal: Some("operator delegate test goal".to_string()),
2190 detach: false,
2191 check_policy: None,
2192 }
2193 }
2194
2195 #[tokio::test]
2199 async fn sync_launch_zero_operators_fails_fast() {
2200 let state = test_state();
2201 let req = operator_launch_req("nonexistent-op", None);
2204
2205 let started = std::time::Instant::now();
2206 let result = crate::tasks_start(State(state), Json(req)).await;
2207 let elapsed = started.elapsed();
2208
2209 let err = match result {
2210 Err(e) => e,
2211 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
2212 };
2213 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2214 assert!(
2215 err.message.contains("no operator attached"),
2216 "error message must mention the missing operator: {}",
2217 err.message
2218 );
2219 assert!(
2220 elapsed < Duration::from_secs(1),
2221 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2222 );
2223 }
2224
2225 #[tokio::test]
2229 async fn sync_launch_stalled_times_out() {
2230 let state = test_state();
2231 state
2232 .engine
2233 .register_operator("stall-op", Arc::new(StallingOperator))
2234 .await;
2235 let req = operator_launch_req("stall-op", Some(1));
2236
2237 let started = std::time::Instant::now();
2238 let result = tokio::time::timeout(
2242 Duration::from_secs(5),
2243 crate::tasks_start(State(state), Json(req)),
2244 )
2245 .await
2246 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
2247 let elapsed = started.elapsed();
2248
2249 let err = match result {
2250 Err(e) => e,
2251 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
2252 };
2253 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
2254 assert!(
2255 err.message.contains('1'),
2256 "error message must mention the configured 1s ceiling: {}",
2257 err.message
2258 );
2259 assert!(
2260 elapsed < Duration::from_secs(3),
2261 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2262 );
2263 }
2264
2265 #[tokio::test]
2269 async fn sync_launch_without_operator_path_unaffected() {
2270 let state = test_state();
2271 let result = crate::tasks_start(
2272 State(state),
2273 Json(post_tasks_req("non-operator launch goal")),
2274 )
2275 .await;
2276 if let Err(e) = &result {
2277 panic!(
2278 "non-operator launch must succeed unaffected by guard 1: {}",
2279 e.message
2280 );
2281 }
2282 }
2283
2284 #[tokio::test]
2288 async fn sync_launch_zero_timeout_secs_rejected() {
2289 let state = test_state();
2290 let mut req = post_tasks_req("zero timeout goal");
2291 req.timeout_secs = Some(0);
2292
2293 let result = crate::tasks_start(State(state), Json(req)).await;
2294 let err = match result {
2295 Err(e) => e,
2296 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2297 };
2298 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2299 assert!(
2300 err.message.contains("timeout_secs"),
2301 "error message must reference timeout_secs: {}",
2302 err.message
2303 );
2304 }
2305
2306 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
2315 for _ in 0..50 {
2316 let rec = state.run_store.get(run_id).await.expect("run get");
2317 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
2318 return rec;
2319 }
2320 tokio::time::sleep(Duration::from_millis(100)).await;
2321 }
2322 panic!("run {run_id} did not reach a terminal status within ~5s");
2323 }
2324
2325 #[tokio::test]
2331 async fn detached_launch_returns_202_and_completes_in_background() {
2332 let state = test_state();
2333 let mut req = post_tasks_req("detached goal");
2334 req.detach = true;
2335
2336 let reply = crate::tasks_start(State(state.clone()), Json(req))
2337 .await
2338 .expect("tasks_start (detached)");
2339 assert_eq!(reply.1, StatusCode::ACCEPTED);
2340 let posted = reply.0;
2341 assert_eq!(posted.status, RunStatus::Running);
2342 assert_eq!(
2343 posted.final_ctx,
2344 serde_json::Value::Null,
2345 "a detached launch has no final_ctx at response time"
2346 );
2347
2348 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
2349 assert_eq!(rec.status, RunStatus::Done);
2350 assert!(
2351 rec.result_ref.is_some(),
2352 "finalize_run must persist the background eval's final_ctx"
2353 );
2354 assert_eq!(
2355 rec.step_entries.len(),
2356 1,
2357 "the background eval must trace its step_entries like the sync path: {:?}",
2358 rec.step_entries
2359 );
2360 let task = state
2361 .task_store
2362 .get(&posted.task_id)
2363 .await
2364 .expect("task get");
2365 assert_eq!(task.status, TaskRecordStatus::Done);
2366 }
2367
2368 #[tokio::test]
2372 async fn detached_launch_with_timeout_secs_rejected() {
2373 let state = test_state();
2374 let mut req = post_tasks_req("detached + ceiling goal");
2375 req.detach = true;
2376 req.timeout_secs = Some(60);
2377
2378 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
2379 Err(e) => e,
2380 Ok(_) => panic!("detach + timeout_secs must be rejected"),
2381 };
2382 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2383 assert!(
2384 err.message.contains("detach"),
2385 "error message must explain the detach/timeout_secs conflict: {}",
2386 err.message
2387 );
2388 let tasks = state.task_store.list().await.expect("task list");
2389 assert!(
2390 tasks.is_empty(),
2391 "the 400 must fire before any TaskRecord is minted"
2392 );
2393 }
2394
2395 #[tokio::test]
2399 async fn rekick_detached_returns_202_and_completes_in_background() {
2400 let state = test_state();
2401 let posted = crate::tasks_start(
2402 State(state.clone()),
2403 Json(post_tasks_req("detached rekick goal")),
2404 )
2405 .await
2406 .expect("tasks_start")
2407 .0;
2408
2409 let (status, rekicked) = task_rekick(
2410 State(state.clone()),
2411 Path(posted.task_id.to_string()),
2412 Some(Json(RunKickRequest {
2413 init_ctx_override: None,
2414 task_input_override: None,
2415 timeout_secs: None,
2416 detach: true,
2417 operator_sid: None,
2418 })),
2419 )
2420 .await
2421 .expect("task_rekick (detached)");
2422 assert_eq!(status, StatusCode::ACCEPTED);
2423 assert_eq!(rekicked.0.status, RunStatus::Running);
2424 assert_ne!(rekicked.0.run_id, posted.run_id);
2425
2426 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
2427 assert_eq!(rec.status, RunStatus::Done);
2428 assert!(
2429 rec.result_ref.is_some(),
2430 "finalize_run must persist the background rekick's final_ctx"
2431 );
2432 }
2433
2434 #[tokio::test]
2438 async fn rekick_detached_with_timeout_secs_rejected() {
2439 let state = test_state();
2440 let posted = crate::tasks_start(
2441 State(state.clone()),
2442 Json(post_tasks_req("detached rekick ceiling goal")),
2443 )
2444 .await
2445 .expect("tasks_start")
2446 .0;
2447
2448 let err = match task_rekick(
2449 State(state.clone()),
2450 Path(posted.task_id.to_string()),
2451 Some(Json(RunKickRequest {
2452 init_ctx_override: None,
2453 task_input_override: None,
2454 timeout_secs: Some(60),
2455 detach: true,
2456 operator_sid: None,
2457 })),
2458 )
2459 .await
2460 {
2461 Err(e) => e,
2462 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
2463 };
2464 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2465 assert!(
2466 err.message.contains("detach"),
2467 "error message must explain the detach/timeout_secs conflict: {}",
2468 err.message
2469 );
2470 let runs = state
2471 .run_store
2472 .list_by_task(&posted.task_id)
2473 .await
2474 .expect("runs list");
2475 assert_eq!(
2476 runs.len(),
2477 1,
2478 "the 400 must fire before a second Run is minted"
2479 );
2480 }
2481
2482 async fn seed_running_run(state: &AppState) -> (TaskId, RunId) {
2490 let now = now_secs();
2491 let task_id = TaskId::new();
2492 let run_id = RunId::new();
2493 state
2494 .task_store
2495 .create(TaskRecord {
2496 id: task_id.clone(),
2497 goal: "panic guard goal".into(),
2498 blueprint_ref: json!({}),
2499 input_ctx: json!({}),
2500 task_input_spec: None,
2501 status: TaskRecordStatus::Running,
2502 created_at: now,
2503 updated_at: now,
2504 })
2505 .await
2506 .expect("task create");
2507 state
2508 .run_store
2509 .create(RunRecord {
2510 id: run_id.clone(),
2511 task_id: task_id.clone(),
2512 status: RunStatus::Running,
2513 step_entries: Vec::new(),
2514 degradations: Vec::new(),
2515 operator_sid: None,
2516 result_ref: None,
2517 input_json: None,
2518 created_at: now,
2519 updated_at: now,
2520 })
2521 .await
2522 .expect("run create");
2523 (task_id, run_id)
2524 }
2525
2526 async fn run_finished_events(state: &AppState, run_id: &RunId) -> Vec<TraceEvent> {
2527 state
2528 .run_trace_store
2529 .list(run_id, &TraceQuery::default())
2530 .await
2531 .expect("trace list")
2532 .into_iter()
2533 .filter(|e| e.kind == trace_kind::RUN_FINISHED)
2534 .collect()
2535 }
2536
2537 #[tokio::test]
2542 async fn panicking_driver_marks_run_interrupted() {
2543 let state = test_state();
2544 let (task_id, run_id) = seed_running_run(&state).await;
2545
2546 let outcome: Result<(), String> =
2547 catch_run_panic(&state, &task_id, &run_id, "test.detach", async {
2548 panic!("boom");
2549 })
2550 .await;
2551 let message = outcome.expect_err("a panicking driver must report the panic to its caller");
2552 assert!(
2553 message.contains("boom"),
2554 "the panic payload must survive as the caller-visible message: {message}"
2555 );
2556
2557 let rec = state.run_store.get(&run_id).await.expect("run get");
2558 assert_eq!(
2559 rec.status,
2560 RunStatus::Interrupted,
2561 "a panicked Run must be resumable, not left Running or marked Failed"
2562 );
2563 let reason = rec
2564 .result_ref
2565 .as_ref()
2566 .and_then(|v| v.get("error"))
2567 .and_then(Value::as_str)
2568 .expect("a structured {\"error\": ...} envelope");
2569 assert!(
2570 reason.contains("boom") && reason.contains("test.detach"),
2571 "the reason must name both the panic payload and the site: {reason}"
2572 );
2573
2574 let task = state.task_store.get(&task_id).await.expect("task get");
2575 assert_eq!(task.status, TaskRecordStatus::Interrupted);
2576
2577 let finished = run_finished_events(&state, &run_id).await;
2578 assert_eq!(finished.len(), 1, "expected one terminal trace marker");
2579 assert_eq!(
2580 finished[0].payload.get("status").and_then(Value::as_str),
2581 Some("interrupted")
2582 );
2583 assert_eq!(
2584 finished[0].payload.get("reason").and_then(Value::as_str),
2585 Some("driver panic")
2586 );
2587 }
2588
2589 #[tokio::test]
2593 async fn panic_guard_does_not_clobber_a_finalized_run() {
2594 let state = test_state();
2595 let (task_id, run_id) = seed_running_run(&state).await;
2596 state
2597 .run_store
2598 .set_result(&run_id, json!({"kept": true}))
2599 .await
2600 .expect("set_result");
2601 state
2602 .run_store
2603 .update_status(&run_id, RunStatus::Done)
2604 .await
2605 .expect("update_status");
2606
2607 let outcome: Result<(), String> =
2608 catch_run_panic(&state, &task_id, &run_id, "test.detach", async {
2609 panic!("late boom");
2610 })
2611 .await;
2612 assert!(
2613 outcome.is_err(),
2614 "the panic is still reported to the caller"
2615 );
2616
2617 let rec = state.run_store.get(&run_id).await.expect("run get");
2618 assert_eq!(rec.status, RunStatus::Done, "the CAS must have refused");
2619 assert_eq!(rec.result_ref, Some(json!({"kept": true})));
2620 let finished = run_finished_events(&state, &run_id).await;
2621 assert!(
2622 finished.is_empty(),
2623 "a refused CAS must not append a second terminal marker: {finished:?}"
2624 );
2625 }
2626
2627 #[tokio::test]
2633 async fn sync_panic_returns_err_and_interrupts_run() {
2634 let state = test_state();
2635 let (task_id, run_id) = seed_running_run(&state).await;
2636
2637 let timed = catch_run_panic(
2638 &state,
2639 &task_id,
2640 &run_id,
2641 "launch.sync",
2642 tokio::time::timeout(Duration::from_secs(30), async {
2643 panic!("sync boom");
2644 }),
2645 )
2646 .await;
2647 let message = timed.expect_err("the sync path must observe the panic as an Err");
2648 assert!(message.contains("sync boom"), "payload lost: {message}");
2649
2650 let err = ApiError::engine(format!("run driver panicked: {message}"));
2651 assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
2652
2653 let rec = state.run_store.get(&run_id).await.expect("run get");
2654 assert_eq!(rec.status, RunStatus::Interrupted);
2655 }
2656
2657 #[tokio::test]
2658 async fn rekick_adds_a_second_run_to_the_same_task() {
2659 let state = test_state();
2660 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
2661 .await
2662 .expect("tasks_start")
2663 .0;
2664 let task_id = posted.task_id.clone();
2665 let first_run_id = posted.run_id.clone();
2666
2667 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
2668 .await
2669 .expect("task_rekick");
2670 assert_eq!(status, StatusCode::CREATED);
2671 let second_run_id = rekicked.0.run_id.clone();
2672 assert_ne!(first_run_id, second_run_id);
2673
2674 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2675 .await
2676 .expect("task_get")
2677 .0;
2678 assert_eq!(
2679 detail.runs.len(),
2680 2,
2681 "expected 2 runs, got {:?}",
2682 detail.runs
2683 );
2684 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
2685 assert!(ids.contains(&&first_run_id));
2686 assert!(ids.contains(&&second_run_id));
2687
2688 let first_run = detail
2693 .runs
2694 .iter()
2695 .find(|r| r.id == first_run_id)
2696 .expect("first run present in detail.runs");
2697 let second_run = detail
2698 .runs
2699 .iter()
2700 .find(|r| r.id == second_run_id)
2701 .expect("second run present in detail.runs");
2702 assert_eq!(
2703 first_run.step_entries.len(),
2704 1,
2705 "first run step_entries: {:?}",
2706 first_run.step_entries
2707 );
2708 assert_eq!(
2709 second_run.step_entries.len(),
2710 1,
2711 "second run step_entries: {:?}",
2712 second_run.step_entries
2713 );
2714 assert_eq!(
2715 first_run.step_entries[0].step_ref,
2716 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2717 );
2718 assert_eq!(
2719 second_run.step_entries[0].step_ref,
2720 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2721 );
2722 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
2723 assert_eq!(
2724 second_run.step_entries[0].status,
2725 Some("passed".to_string())
2726 );
2727 assert_ne!(
2728 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
2729 "each kick dispatches its own StepId — runs must not share step_entries"
2730 );
2731 }
2732
2733 #[tokio::test]
2734 async fn rekick_unknown_task_returns_404() {
2735 let state = test_state();
2736 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
2740 Ok(_) => panic!("expected 404 for an unknown task"),
2741 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2742 }
2743 }
2744
2745 fn greeting_blueprint() -> Blueprint {
2754 Blueprint {
2755 schema_version: current_schema_version(),
2756 id: "tasks-test-greeting-bp".into(),
2757 flow: serde_json::from_value(serde_json::json!({
2758 "kind": "step",
2759 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
2760 "in": {"op": "path", "at": "$.greeting"},
2761 "out": {"op": "path", "at": "$.out"},
2762 }))
2763 .expect("flow parse"),
2764 agents: vec![AgentDef {
2765 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
2766 kind: AgentKind::RustFn,
2767 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
2768 profile: None,
2769 meta: None,
2770 runner: None,
2771 runner_ref: None,
2772 verdict: None,
2773 }],
2774 operators: vec![],
2775 metas: vec![],
2776 hints: CompilerHints::default(),
2777 strategy: CompilerStrategy::default(),
2778 metadata: BlueprintMetadata::default(),
2779 spawner_hints: Default::default(),
2780 default_agent_kind: AgentKind::Operator,
2781 default_operator_kind: None,
2782 default_init_ctx: None,
2783 default_agent_ctx: None,
2784 default_context_policy: None,
2785 projection_placement: None,
2786 audits: vec![],
2787 degradation_policy: None,
2788 runners: vec![],
2789 default_runner: None,
2790 subprocesses: vec![],
2791 check_policy: None,
2792 blueprint_ref_includes: Vec::new(),
2793 }
2794 }
2795
2796 fn post_greeting_task_req(
2797 greeting: &str,
2798 project_root: Option<&str>,
2799 ) -> crate::TaskLaunchRequest {
2800 crate::TaskLaunchRequest {
2801 blueprint: BlueprintRef::Inline {
2802 value: Box::new(greeting_blueprint()),
2803 },
2804 init_ctx: serde_json::json!({ "greeting": greeting }),
2805 project_root: project_root.map(str::to_string),
2806 work_dir: None,
2807 task_metadata: None,
2808 ttl_secs: None,
2809 operator: None,
2810 operator_sid: None,
2811 timeout_secs: None,
2812 goal: Some("st4 rekick goal".to_string()),
2813 detach: false,
2814 check_policy: None,
2815 }
2816 }
2817
2818 #[tokio::test]
2819 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2820 let state = test_state();
2823 let posted = crate::tasks_start(
2824 State(state.clone()),
2825 Json(post_greeting_task_req("from-task", None)),
2826 )
2827 .await
2828 .expect("tasks_start")
2829 .0;
2830 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2831
2832 let (status, rekicked) =
2833 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2834 .await
2835 .expect("task_rekick");
2836 assert_eq!(status, StatusCode::CREATED);
2837
2838 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2839 .await
2840 .expect("run_get")
2841 .0;
2842 assert_eq!(
2843 run.result_ref.expect("result_ref present")["out"]["echoed"],
2844 "from-task"
2845 );
2846 }
2847
2848 #[tokio::test]
2849 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2850 let state = test_state();
2851 let posted = crate::tasks_start(
2852 State(state.clone()),
2853 Json(post_greeting_task_req("from-task", None)),
2854 )
2855 .await
2856 .expect("tasks_start")
2857 .0;
2858 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2859
2860 let (status, rekicked) = task_rekick(
2861 State(state.clone()),
2862 Path(posted.task_id.to_string()),
2863 Some(Json(RunKickRequest {
2864 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2865 task_input_override: None,
2866 timeout_secs: None,
2867 detach: false,
2868 operator_sid: None,
2869 })),
2870 )
2871 .await
2872 .expect("task_rekick");
2873 assert_eq!(status, StatusCode::CREATED);
2874
2875 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2876 .await
2877 .expect("run_get")
2878 .0;
2879 assert_eq!(
2880 run.result_ref.expect("result_ref present")["out"]["echoed"],
2881 "from-run",
2882 "Run's init_ctx_override must win over the stored Task input_ctx"
2883 );
2884 }
2885
2886 #[tokio::test]
2887 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2888 let state = test_state();
2896 let posted = crate::tasks_start(
2897 State(state.clone()),
2898 Json(post_greeting_task_req("from-task", Some("/repo"))),
2899 )
2900 .await
2901 .expect("tasks_start")
2902 .0;
2903
2904 let before = state
2905 .task_store
2906 .get(&posted.task_id)
2907 .await
2908 .expect("task fetch");
2909 let before_spec: Option<TaskInputSpec> = before
2910 .task_input_spec
2911 .as_ref()
2912 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2913 assert_eq!(
2914 before_spec,
2915 Some(TaskInputSpec {
2916 project_root: Some("/repo".to_string()),
2917 work_dir: None,
2918 task_metadata: None,
2919 })
2920 );
2921
2922 let (status, _rekicked) =
2923 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2924 .await
2925 .expect("task_rekick");
2926 assert_eq!(status, StatusCode::CREATED);
2927
2928 let after = state
2929 .task_store
2930 .get(&posted.task_id)
2931 .await
2932 .expect("task fetch");
2933 assert_eq!(
2934 after.task_input_spec, before.task_input_spec,
2935 "rekick must not mutate the stored Task-level task_input_spec snapshot"
2936 );
2937 }
2938
2939 #[tokio::test]
2940 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2941 let state = test_state();
2944 let posted = crate::tasks_start(
2945 State(state.clone()),
2946 Json(post_greeting_task_req("from-task", Some("/repo"))),
2947 )
2948 .await
2949 .expect("tasks_start")
2950 .0;
2951
2952 let (status, _rekicked) = task_rekick(
2953 State(state.clone()),
2954 Path(posted.task_id.to_string()),
2955 Some(Json(RunKickRequest {
2956 init_ctx_override: None,
2957 task_input_override: Some(TaskInputSpec {
2958 project_root: Some("/override".to_string()),
2959 work_dir: None,
2960 task_metadata: None,
2961 }),
2962 timeout_secs: None,
2963 detach: false,
2964 operator_sid: None,
2965 })),
2966 )
2967 .await
2968 .expect("task_rekick");
2969 assert_eq!(status, StatusCode::CREATED);
2970
2971 let after = state
2972 .task_store
2973 .get(&posted.task_id)
2974 .await
2975 .expect("task fetch");
2976 let after_spec: Option<TaskInputSpec> = after
2977 .task_input_spec
2978 .as_ref()
2979 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2980 assert_eq!(
2981 after_spec,
2982 Some(TaskInputSpec {
2983 project_root: Some("/repo".to_string()),
2984 work_dir: None,
2985 task_metadata: None,
2986 }),
2987 "a per-Run task_input_override must not leak into the stored TaskRecord"
2988 );
2989 }
2990
2991 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
3005 crate::TaskLaunchRequest {
3006 blueprint: BlueprintRef::Inline {
3007 value: Box::new(identity_blueprint_with_operator_delegate()),
3008 },
3009 init_ctx: serde_json::json!({"in": "hello"}),
3010 project_root: None,
3011 work_dir: None,
3012 task_metadata: None,
3013 ttl_secs: None,
3014 operator: None,
3015 operator_sid: None,
3016 timeout_secs: None,
3017 goal: Some(goal.to_string()),
3018 detach: false,
3019 check_policy: None,
3020 }
3021 }
3022
3023 #[tokio::test]
3028 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
3029 let state = test_state();
3030 let posted = crate::tasks_start(
3031 State(state.clone()),
3032 Json(delegate_launch_req("operator delegate rekick goal")),
3033 )
3034 .await
3035 .expect("tasks_start (no operator referenced, dispatches through baseline)")
3036 .0;
3037 let started = std::time::Instant::now();
3041 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
3042 let elapsed = started.elapsed();
3043
3044 let err = match result {
3045 Err(e) => e,
3046 Ok(_) => panic!(
3047 "rekicking a Task whose Blueprint declares operator_delegate with zero \
3048 attached operators must fail, not dispatch"
3049 ),
3050 };
3051 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
3052 assert!(
3053 err.message.contains("no operator attached"),
3054 "error message must mention the missing operator: {}",
3055 err.message
3056 );
3057 assert!(
3058 elapsed < Duration::from_secs(1),
3059 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
3060 );
3061 }
3062
3063 #[tokio::test]
3067 async fn rekick_stalled_operator_times_out() {
3068 let state = test_state();
3069 state
3070 .engine
3071 .register_operator("stall-op", Arc::new(StallingOperator))
3072 .await;
3073 let posted = crate::tasks_start(
3074 State(state.clone()),
3075 Json(delegate_launch_req("stalled rekick goal")),
3076 )
3077 .await
3078 .expect("tasks_start")
3079 .0;
3080
3081 let started = std::time::Instant::now();
3082 let result = tokio::time::timeout(
3086 Duration::from_secs(5),
3087 task_rekick(
3088 State(state),
3089 Path(posted.task_id.to_string()),
3090 Some(Json(RunKickRequest {
3091 init_ctx_override: None,
3092 task_input_override: None,
3093 timeout_secs: Some(1),
3094 detach: false,
3095 operator_sid: None,
3096 })),
3097 ),
3098 )
3099 .await
3100 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
3101 let elapsed = started.elapsed();
3102
3103 match &result {
3104 Err(e) => {
3105 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
3106 assert!(
3107 e.message.contains('1'),
3108 "error message must mention the configured 1s ceiling: {}",
3109 e.message
3110 );
3111 assert!(
3112 elapsed < Duration::from_secs(3),
3113 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
3114 );
3115 }
3116 Ok(_) => {
3117 assert!(
3129 elapsed < Duration::from_secs(1),
3130 "a rekick that never engages an Operator (task_rekick has no \
3131 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
3132 );
3133 }
3134 }
3135 }
3136
3137 #[tokio::test]
3141 async fn rekick_timeout_secs_zero_rejected() {
3142 let state = test_state();
3143 let posted = crate::tasks_start(
3144 State(state.clone()),
3145 Json(post_tasks_req("zero timeout rekick goal")),
3146 )
3147 .await
3148 .expect("tasks_start")
3149 .0;
3150
3151 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
3152 .await
3153 .expect("task_get")
3154 .0;
3155 let runs_before = before.runs.len();
3156
3157 let result = task_rekick(
3158 State(state.clone()),
3159 Path(posted.task_id.to_string()),
3160 Some(Json(RunKickRequest {
3161 init_ctx_override: None,
3162 task_input_override: None,
3163 timeout_secs: Some(0),
3164 detach: false,
3165 operator_sid: None,
3166 })),
3167 )
3168 .await;
3169 let err = match result {
3170 Err(e) => e,
3171 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
3172 };
3173 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3174 assert!(
3175 err.message.contains("timeout_secs"),
3176 "error message must reference timeout_secs: {}",
3177 err.message
3178 );
3179
3180 let after = task_get(State(state), Path(posted.task_id.to_string()))
3181 .await
3182 .expect("task_get")
3183 .0;
3184 assert_eq!(
3185 after.runs.len(),
3186 runs_before,
3187 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
3188 );
3189 }
3190
3191 #[tokio::test]
3195 async fn rekick_non_operator_path_unaffected_by_guard_1() {
3196 let state = test_state();
3197 let posted = crate::tasks_start(
3198 State(state.clone()),
3199 Json(post_tasks_req("non-operator rekick goal")),
3200 )
3201 .await
3202 .expect("tasks_start")
3203 .0;
3204
3205 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
3206 if let Err(e) = &result {
3207 panic!(
3208 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
3209 guard 1: {}",
3210 e.message
3211 );
3212 }
3213 }
3214
3215 #[tokio::test]
3223 async fn rekick_unknown_operator_sid_rejected_before_side_effects() {
3224 let state = test_state();
3225 let posted = crate::tasks_start(
3226 State(state.clone()),
3227 Json(post_tasks_req("unknown operator_sid rekick goal")),
3228 )
3229 .await
3230 .expect("tasks_start")
3231 .0;
3232
3233 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
3234 .await
3235 .expect("task_get")
3236 .0;
3237 let runs_before = before.runs.len();
3238
3239 let result = task_rekick(
3240 State(state.clone()),
3241 Path(posted.task_id.to_string()),
3242 Some(Json(RunKickRequest {
3243 init_ctx_override: None,
3244 task_input_override: None,
3245 timeout_secs: None,
3246 detach: false,
3247 operator_sid: Some("S-not-registered".to_string()),
3248 })),
3249 )
3250 .await;
3251 let err = match result {
3252 Err(e) => e,
3253 Ok(_) => panic!("an unknown operator_sid must be rejected, not dispatched"),
3254 };
3255 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3256 assert!(
3257 err.message.contains("operator_sid"),
3258 "error message must reference operator_sid: {}",
3259 err.message
3260 );
3261
3262 let after = task_get(State(state), Path(posted.task_id.to_string()))
3263 .await
3264 .expect("task_get")
3265 .0;
3266 assert_eq!(
3267 after.runs.len(),
3268 runs_before,
3269 "a rejected unknown-operator_sid rekick must not create a new Run"
3270 );
3271 }
3272
3273 #[tokio::test]
3281 async fn rekick_with_registered_operator_sid_persists_it_on_the_run() {
3282 let state = test_state();
3283 state
3287 .engine
3288 .register_operator("S-live-op", Arc::new(StallingOperator))
3289 .await;
3290 let posted = crate::tasks_start(
3291 State(state.clone()),
3292 Json(post_tasks_req("registered operator_sid rekick goal")),
3293 )
3294 .await
3295 .expect("tasks_start")
3296 .0;
3297
3298 let (status, rekicked) = task_rekick(
3299 State(state.clone()),
3300 Path(posted.task_id.to_string()),
3301 Some(Json(RunKickRequest {
3302 init_ctx_override: None,
3303 task_input_override: None,
3304 timeout_secs: None,
3305 detach: false,
3306 operator_sid: Some("S-live-op".to_string()),
3307 })),
3308 )
3309 .await
3310 .expect("task_rekick with a registered operator_sid");
3311 assert_eq!(status, StatusCode::CREATED);
3312
3313 let run = state
3314 .run_store
3315 .get(&rekicked.0.run_id)
3316 .await
3317 .expect("run get");
3318 assert_eq!(
3319 run.operator_sid,
3320 Some("S-live-op".to_string()),
3321 "the pinned operator_sid must be persisted verbatim on the RunRecord"
3322 );
3323 }
3324
3325 #[tokio::test]
3331 async fn rekick_pin_reaches_both_axes_and_survives_in_the_launch_snapshot() {
3332 let state = test_state();
3333 state
3334 .engine
3335 .register_operator("S-live-op", Arc::new(StallingOperator))
3336 .await;
3337 let posted = crate::tasks_start(
3338 State(state.clone()),
3339 Json(post_tasks_req("pinned rekick snapshot goal")),
3340 )
3341 .await
3342 .expect("tasks_start")
3343 .0;
3344
3345 let (_status, rekicked) = task_rekick(
3346 State(state.clone()),
3347 Path(posted.task_id.to_string()),
3348 Some(Json(RunKickRequest {
3349 init_ctx_override: None,
3350 task_input_override: None,
3351 timeout_secs: None,
3352 detach: false,
3353 operator_sid: Some("S-live-op".to_string()),
3354 })),
3355 )
3356 .await
3357 .expect("task_rekick with a registered operator_sid");
3358
3359 let run = state
3360 .run_store
3361 .get(&rekicked.0.run_id)
3362 .await
3363 .expect("run get");
3364 let snapshot: Value = serde_json::from_str(
3365 run.input_json
3366 .as_deref()
3367 .expect("a rekicked Run persists its launch snapshot"),
3368 )
3369 .expect("snapshot json");
3370 assert_eq!(
3371 snapshot["operator_backend_id"],
3372 serde_json::json!("S-live-op"),
3373 "the delegate axis keeps receiving the sid exactly as before: {snapshot}"
3374 );
3375 assert_eq!(
3376 snapshot["operator_pin"],
3377 serde_json::json!("S-live-op"),
3378 "the same sid must also pin the AgentSpec axis: {snapshot}"
3379 );
3380 }
3381
3382 #[tokio::test]
3385 async fn unpinned_launch_snapshot_carries_neither_axis() {
3386 let state = test_state();
3387 let posted = crate::tasks_start(
3388 State(state.clone()),
3389 Json(post_tasks_req("unpinned snapshot goal")),
3390 )
3391 .await
3392 .expect("tasks_start")
3393 .0;
3394 let run = state.run_store.get(&posted.run_id).await.expect("run get");
3395 let snapshot: Value =
3396 serde_json::from_str(run.input_json.as_deref().expect("launch snapshot"))
3397 .expect("snapshot json");
3398 assert_eq!(snapshot["operator_backend_id"], Value::Null);
3399 assert_eq!(snapshot["operator_pin"], Value::Null);
3400 assert_eq!(
3401 run.operator_sid, None,
3402 "an unpinned launch records no session on the Run"
3403 );
3404 }
3405
3406 #[test]
3409 fn pre_pin_launch_snapshot_still_decodes() {
3410 let snapshot = serde_json::json!({
3411 "blueprint": { "kind": "inline", "value": identity_blueprint() },
3412 "operator_id": "http-run",
3413 "role": "operator",
3414 "ttl": { "secs": 60, "nanos": 0 },
3415 "init_ctx": {},
3416 "operator_kind": null,
3417 "bridge_id": null,
3418 "hook_id": null,
3419 "operator_backend_id": null,
3420 "task_input": null,
3421 "check_policy": null,
3422 });
3423 let decoded: RunLaunchSnapshot =
3424 serde_json::from_value(snapshot).expect("a pre-pin snapshot must still decode");
3425 assert!(decoded.into_input().operator_pin.is_none());
3426 }
3427
3428 #[tokio::test]
3429 async fn run_get_unknown_id_returns_404() {
3430 let state = test_state();
3431 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
3432 Ok(_) => panic!("expected 404 for an unknown run"),
3433 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3434 }
3435 }
3436
3437 #[tokio::test]
3438 async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
3439 let state = test_state();
3440 let posted = crate::tasks_start(
3441 State(state.clone()),
3442 Json(post_tasks_req("binding explain")),
3443 )
3444 .await
3445 .expect("tasks_start")
3446 .0;
3447 let run = state
3448 .run_store
3449 .get(&posted.run_id)
3450 .await
3451 .expect("stored run");
3452 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3453 let mut bound_agents: Vec<BoundAgent> =
3454 serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
3455 let bound = &mut bound_agents[0];
3456 bound.runner = Some(Runner::WsClaudeCode {
3457 variant: "coder".to_string(),
3458 tools: vec!["Read".to_string()],
3459 });
3460 bound.recompute_binding_digest().unwrap();
3461 let request_digest = bound.binding_digest.clone();
3462 bound
3463 .set_attestation(BindingAttestation {
3464 request_digest: request_digest.clone(),
3465 provider_id: "operator-manifest".to_string(),
3466 provider_revision: Some("claude-code-1.2".to_string()),
3467 resolved_model: Some("claude-sonnet-4".to_string()),
3468 effective_tools: vec!["Bash".to_string(), "Read".to_string()],
3469 launch_variant: Some("coder".to_string()),
3470 capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
3471 b"manifest-v1",
3472 )),
3473 })
3474 .unwrap();
3475 snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
3476 state
3477 .run_store
3478 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3479 .await
3480 .unwrap();
3481
3482 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3483 .await
3484 .expect("binding explain")
3485 .0;
3486 let entry = &explained.bindings[0];
3487 assert_eq!(entry.status, RunBindingStatus::Attested);
3488 assert_eq!(
3489 entry.requested.as_ref().unwrap().request_digest,
3490 request_digest
3491 );
3492 assert_eq!(
3493 entry
3494 .effective
3495 .as_ref()
3496 .unwrap()
3497 .provider_revision
3498 .as_deref(),
3499 Some("claude-code-1.2")
3500 );
3501 assert_eq!(
3502 entry
3503 .difference
3504 .as_ref()
3505 .unwrap()
3506 .additional_effective_tools,
3507 vec!["Bash"]
3508 );
3509 assert!(entry
3510 .difference
3511 .as_ref()
3512 .unwrap()
3513 .missing_requested_tools
3514 .is_empty());
3515 assert_ne!(entry.binding_digest, request_digest);
3516 }
3517
3518 #[tokio::test]
3519 async fn run_bindings_explain_reports_snapshot_origin() {
3520 let state = test_state();
3521 let posted =
3522 crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
3523 .await
3524 .expect("tasks_start")
3525 .0;
3526
3527 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3529 .await
3530 .expect("binding explain")
3531 .0;
3532 assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
3533
3534 let run = state.run_store.get(&posted.run_id).await.unwrap();
3536 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3537 snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
3538 state
3539 .run_store
3540 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3541 .await
3542 .unwrap();
3543 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3544 .await
3545 .expect("binding explain")
3546 .0;
3547 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3548
3549 snapshot
3553 .as_object_mut()
3554 .unwrap()
3555 .remove("bound_agents_origin");
3556 state
3557 .run_store
3558 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3559 .await
3560 .unwrap();
3561 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3562 .await
3563 .expect("explain still 200 without an origin marker")
3564 .0;
3565 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3566 }
3567
3568 #[tokio::test]
3569 async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
3570 let state = test_state();
3571 let posted = crate::tasks_start(
3572 State(state.clone()),
3573 Json(post_tasks_req("legacy binding explain")),
3574 )
3575 .await
3576 .expect("tasks_start")
3577 .0;
3578 state
3579 .run_store
3580 .set_input_json(&posted.run_id, "{}".to_string())
3581 .await
3582 .unwrap();
3583
3584 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3585 .await
3586 .expect_err("legacy run must not be re-resolved");
3587 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3588 assert!(error
3589 .message
3590 .contains("current Blueprint state was not consulted"));
3591 }
3592
3593 #[tokio::test]
3594 async fn run_bindings_explain_rejects_a_tampered_snapshot() {
3595 let state = test_state();
3596 let posted = crate::tasks_start(
3597 State(state.clone()),
3598 Json(post_tasks_req("tampered binding explain")),
3599 )
3600 .await
3601 .expect("tasks_start")
3602 .0;
3603 let run = state.run_store.get(&posted.run_id).await.unwrap();
3604 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3605 snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
3606 state
3607 .run_store
3608 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3609 .await
3610 .unwrap();
3611
3612 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3613 .await
3614 .expect_err("digest drift must fail closed");
3615 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3616 assert!(error.message.contains("inconsistent binding snapshot"));
3617 }
3618
3619 #[tokio::test]
3620 async fn task_get_unknown_id_returns_404() {
3621 let state = test_state();
3622 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
3623 Ok(_) => panic!("expected 404 for an unknown task"),
3624 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3625 }
3626 }
3627
3628 async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
3635 let task_id = TaskId::new();
3636 let run_id = RunId::new();
3637 state
3638 .task_store
3639 .create(TaskRecord {
3640 id: task_id.clone(),
3641 goal: "finalize-run-err-envelope".to_string(),
3642 blueprint_ref: json!("inline"),
3643 input_ctx: Value::Null,
3644 task_input_spec: None,
3645 status: TaskRecordStatus::Running,
3646 created_at: 0,
3647 updated_at: 0,
3648 })
3649 .await
3650 .expect("seed TaskRecord");
3651 state
3652 .run_store
3653 .create(RunRecord {
3654 id: run_id.clone(),
3655 task_id: task_id.clone(),
3656 status: RunStatus::Running,
3657 step_entries: Vec::new(),
3658 degradations: Vec::new(),
3659 operator_sid: None,
3660 result_ref: None,
3661 input_json: Some("{}".to_string()),
3662 created_at: 0,
3663 updated_at: 0,
3664 })
3665 .await
3666 .expect("seed RunRecord");
3667 (task_id, run_id)
3668 }
3669
3670 #[tokio::test]
3671 async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
3672 let state = test_state();
3673 let (task_id, run_id) = seed_task_and_run(&state).await;
3674
3675 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3676 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3677 message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
3678 failed_step: Some("gate".to_string()),
3679 verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
3680 partial_ctx: Some(
3681 json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
3682 ),
3683 }));
3684
3685 let _ = finalize_run(&state, &task_id, &run_id, err).await;
3686
3687 let run = state.run_store.get(&run_id).await.expect("run present");
3688 assert_eq!(run.status, RunStatus::Failed);
3689 let envelope = run
3690 .result_ref
3691 .as_ref()
3692 .expect("result_ref must be Some on Err arm");
3693 assert_eq!(
3694 envelope["error"]["message"],
3695 "blocked: {\"verdict\":\"BLOCKED\"}"
3696 );
3697 assert_eq!(envelope["error"]["failed_step"], "gate");
3698 assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
3699 assert_eq!(
3700 envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
3701 "blocked"
3702 );
3703
3704 let task = state.task_store.get(&task_id).await.expect("task present");
3706 assert_eq!(task.status, TaskRecordStatus::Failed);
3707 }
3708
3709 #[tokio::test]
3710 async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
3711 let state = test_state();
3712 let (task_id, run_id) = seed_task_and_run(&state).await;
3713
3714 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3718 Err(TaskApplicationError::NoStore);
3719
3720 let _ = finalize_run(&state, &task_id, &run_id, err).await;
3721 let run = state.run_store.get(&run_id).await.expect("run present");
3722 let envelope = run
3723 .result_ref
3724 .as_ref()
3725 .expect("result_ref must be Some on Err arm");
3726 assert!(envelope["error"]["message"]
3727 .as_str()
3728 .expect("message string")
3729 .contains("store"));
3730 assert_eq!(envelope["error"]["failed_step"], Value::Null);
3731 assert_eq!(envelope["error"]["verdict_value"], Value::Null);
3732 assert_eq!(envelope["partial_ctx"], Value::Null);
3733 }
3734
3735 #[tokio::test]
3740 async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
3741 let state = test_state();
3742 let (task_id, run_id) = seed_task_and_run(&state).await;
3743
3744 let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
3745 token: mlua_swarm::CapToken {
3746 agent_id: "ut".to_string(),
3747 role: mlua_swarm::Role::Operator,
3748 scopes: vec!["*".to_string()],
3749 issued_at: 0,
3750 expire_at: u64::MAX,
3751 max_uses: None,
3752 nonce: "ut-nonce".to_string(),
3753 sig_hex: String::new(),
3754 },
3755 final_ctx: json!({"out": {"echoed": "hi"}}),
3756 bound_version: None,
3757 });
3758
3759 let _ = finalize_run(&state, &task_id, &run_id, ok).await;
3760 let run = state.run_store.get(&run_id).await.expect("run present");
3761 assert_eq!(run.status, RunStatus::Done);
3762 let stored = run.result_ref.as_ref().expect("result_ref Some");
3763 assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
3765 assert!(
3766 stored.get("error").is_none(),
3767 "Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
3768 );
3769 }
3770
3771 #[tokio::test]
3776 async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
3777 let state = test_state();
3778 let (_task_id, run_id) = seed_task_and_run(&state).await;
3779 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3780 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3781 message: "blocked: bad verdict".to_string(),
3782 failed_step: Some("scout".to_string()),
3783 verdict_value: Some(json!("BLOCKED")),
3784 partial_ctx: Some(json!({"steps": {}})),
3785 }));
3786 let _ = finalize_run(&state, &_task_id, &run_id, err).await;
3787
3788 let Json(run) = run_get(State(state), Path(run_id.to_string()))
3789 .await
3790 .expect("run_get");
3791 assert_eq!(run.status, RunStatus::Failed);
3792 let envelope = run.result_ref.expect("result_ref Some");
3793 assert_eq!(envelope["error"]["failed_step"], "scout");
3794 assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
3795 }
3796}