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 lints: None,
1994 }],
1995 operators: vec![],
1996 metas: vec![],
1997 hints: CompilerHints::default(),
1998 strategy: CompilerStrategy::default(),
1999 metadata: BlueprintMetadata::default(),
2000 spawner_hints: Default::default(),
2001 default_agent_kind: AgentKind::Operator,
2002 default_operator_kind: None,
2003 default_init_ctx: None,
2004 default_agent_ctx: None,
2005 default_context_policy: None,
2006 projection_placement: None,
2007 audits: vec![],
2008 degradation_policy: None,
2009 runners: vec![],
2010 default_runner: None,
2011 subprocesses: vec![],
2012 check_policy: None,
2013 blueprint_ref_includes: Vec::new(),
2014 }
2015 }
2016
2017 fn test_state() -> AppState {
2022 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
2023 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
2024 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
2025 AppState {
2026 engine,
2027 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
2028 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
2029 ws_operator_factory: None,
2030 data_store: Arc::new(InMemoryOutputStore::new()),
2031 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
2032 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
2033 task_store: Arc::new(InMemoryTaskStore::new()),
2034 run_store: Arc::new(InMemoryRunStore::new()),
2035 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
2036 run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
2037 base_url: None,
2038 sync_timeout_secs: 300,
2039 }
2040 }
2041
2042 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
2043 crate::TaskLaunchRequest {
2044 blueprint: BlueprintRef::Inline {
2045 value: Box::new(identity_blueprint()),
2046 },
2047 init_ctx: serde_json::json!({"in": "hello"}),
2048 project_root: None,
2049 work_dir: None,
2050 task_metadata: None,
2051 ttl_secs: None,
2052 operator: None,
2053 operator_sid: None,
2054 timeout_secs: None,
2055 goal: Some(goal.to_string()),
2056 detach: false,
2057 check_policy: None,
2058 }
2059 }
2060
2061 #[test]
2062 fn task_id_serializes_as_bare_string() {
2063 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
2067 assert_eq!(v, serde_json::json!("T-abc"));
2068 }
2069
2070 #[tokio::test]
2071 async fn post_then_get_drill_down() {
2072 let state = test_state();
2073
2074 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
2075 .await
2076 .expect("tasks_start")
2077 .0;
2078 let task_id = posted.task_id.clone();
2079 let run_id = posted.run_id.clone();
2080
2081 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
2083 .await
2084 .expect("tasks_list")
2085 .0;
2086 assert!(
2087 list.iter().any(|t| t.id == task_id),
2088 "task {task_id} missing from list of {} tasks",
2089 list.len()
2090 );
2091
2092 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2094 .await
2095 .expect("task_get")
2096 .0;
2097 assert_eq!(detail.task.id, task_id);
2098 assert_eq!(detail.task.goal, "smoke goal");
2099 assert_eq!(detail.task.status, TaskRecordStatus::Done);
2100 assert_eq!(detail.runs.len(), 1);
2101 assert_eq!(detail.runs[0].id, run_id);
2102 assert_eq!(detail.runs[0].status, RunStatus::Done);
2103
2104 let run = run_get(State(state.clone()), Path(run_id.to_string()))
2106 .await
2107 .expect("run_get")
2108 .0;
2109 assert_eq!(run.id, run_id);
2110 assert_eq!(run.task_id, task_id);
2111 assert_eq!(run.result_ref, Some(posted.final_ctx));
2112
2113 assert_eq!(
2117 run.step_entries.len(),
2118 1,
2119 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
2120 run.step_entries
2121 );
2122 assert_eq!(
2123 run.step_entries[0].step_ref,
2124 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2125 );
2126 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2127 }
2128
2129 fn identity_blueprint_with_operator_delegate() -> Blueprint {
2141 Blueprint {
2142 spawner_hints: mlua_swarm::SpawnerHints {
2143 layers: vec!["operator_delegate".to_string()],
2144 },
2145 ..identity_blueprint()
2146 }
2147 }
2148
2149 struct StallingOperator;
2152
2153 #[async_trait::async_trait]
2154 impl mlua_swarm::Operator for StallingOperator {
2155 async fn execute(
2156 &self,
2157 _ctx: &mlua_swarm::Ctx,
2158 _system: Option<String>,
2159 _prompt: Value,
2160 _worker: Option<mlua_swarm::WorkerBinding>,
2161 _worker_token: mlua_swarm::CapToken,
2162 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
2163 std::future::pending::<()>().await;
2164 unreachable!("StallingOperator.execute must never resolve")
2165 }
2166 }
2167
2168 fn operator_launch_req(
2172 backend_id: &str,
2173 timeout_secs: Option<u64>,
2174 ) -> crate::TaskLaunchRequest {
2175 crate::TaskLaunchRequest {
2176 blueprint: BlueprintRef::Inline {
2177 value: Box::new(identity_blueprint_with_operator_delegate()),
2178 },
2179 init_ctx: serde_json::json!({"in": "hello"}),
2180 project_root: None,
2181 work_dir: None,
2182 task_metadata: None,
2183 ttl_secs: None,
2184 operator: Some(crate::OperatorReq {
2185 operator_backend_id: Some(backend_id.to_string()),
2186 ..Default::default()
2187 }),
2188 operator_sid: None,
2189 timeout_secs,
2190 goal: Some("operator delegate test goal".to_string()),
2191 detach: false,
2192 check_policy: None,
2193 }
2194 }
2195
2196 #[tokio::test]
2200 async fn sync_launch_zero_operators_fails_fast() {
2201 let state = test_state();
2202 let req = operator_launch_req("nonexistent-op", None);
2205
2206 let started = std::time::Instant::now();
2207 let result = crate::tasks_start(State(state), Json(req)).await;
2208 let elapsed = started.elapsed();
2209
2210 let err = match result {
2211 Err(e) => e,
2212 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
2213 };
2214 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2215 assert!(
2216 err.message.contains("no operator attached"),
2217 "error message must mention the missing operator: {}",
2218 err.message
2219 );
2220 assert!(
2221 elapsed < Duration::from_secs(1),
2222 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2223 );
2224 }
2225
2226 #[tokio::test]
2230 async fn sync_launch_stalled_times_out() {
2231 let state = test_state();
2232 state
2233 .engine
2234 .register_operator("stall-op", Arc::new(StallingOperator))
2235 .await;
2236 let req = operator_launch_req("stall-op", Some(1));
2237
2238 let started = std::time::Instant::now();
2239 let result = tokio::time::timeout(
2243 Duration::from_secs(5),
2244 crate::tasks_start(State(state), Json(req)),
2245 )
2246 .await
2247 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
2248 let elapsed = started.elapsed();
2249
2250 let err = match result {
2251 Err(e) => e,
2252 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
2253 };
2254 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
2255 assert!(
2256 err.message.contains('1'),
2257 "error message must mention the configured 1s ceiling: {}",
2258 err.message
2259 );
2260 assert!(
2261 elapsed < Duration::from_secs(3),
2262 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2263 );
2264 }
2265
2266 #[tokio::test]
2270 async fn sync_launch_without_operator_path_unaffected() {
2271 let state = test_state();
2272 let result = crate::tasks_start(
2273 State(state),
2274 Json(post_tasks_req("non-operator launch goal")),
2275 )
2276 .await;
2277 if let Err(e) = &result {
2278 panic!(
2279 "non-operator launch must succeed unaffected by guard 1: {}",
2280 e.message
2281 );
2282 }
2283 }
2284
2285 #[tokio::test]
2289 async fn sync_launch_zero_timeout_secs_rejected() {
2290 let state = test_state();
2291 let mut req = post_tasks_req("zero timeout goal");
2292 req.timeout_secs = Some(0);
2293
2294 let result = crate::tasks_start(State(state), Json(req)).await;
2295 let err = match result {
2296 Err(e) => e,
2297 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2298 };
2299 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2300 assert!(
2301 err.message.contains("timeout_secs"),
2302 "error message must reference timeout_secs: {}",
2303 err.message
2304 );
2305 }
2306
2307 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
2316 for _ in 0..50 {
2317 let rec = state.run_store.get(run_id).await.expect("run get");
2318 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
2319 return rec;
2320 }
2321 tokio::time::sleep(Duration::from_millis(100)).await;
2322 }
2323 panic!("run {run_id} did not reach a terminal status within ~5s");
2324 }
2325
2326 #[tokio::test]
2332 async fn detached_launch_returns_202_and_completes_in_background() {
2333 let state = test_state();
2334 let mut req = post_tasks_req("detached goal");
2335 req.detach = true;
2336
2337 let reply = crate::tasks_start(State(state.clone()), Json(req))
2338 .await
2339 .expect("tasks_start (detached)");
2340 assert_eq!(reply.1, StatusCode::ACCEPTED);
2341 let posted = reply.0;
2342 assert_eq!(posted.status, RunStatus::Running);
2343 assert_eq!(
2344 posted.final_ctx,
2345 serde_json::Value::Null,
2346 "a detached launch has no final_ctx at response time"
2347 );
2348
2349 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
2350 assert_eq!(rec.status, RunStatus::Done);
2351 assert!(
2352 rec.result_ref.is_some(),
2353 "finalize_run must persist the background eval's final_ctx"
2354 );
2355 assert_eq!(
2356 rec.step_entries.len(),
2357 1,
2358 "the background eval must trace its step_entries like the sync path: {:?}",
2359 rec.step_entries
2360 );
2361 let task = state
2362 .task_store
2363 .get(&posted.task_id)
2364 .await
2365 .expect("task get");
2366 assert_eq!(task.status, TaskRecordStatus::Done);
2367 }
2368
2369 #[tokio::test]
2373 async fn detached_launch_with_timeout_secs_rejected() {
2374 let state = test_state();
2375 let mut req = post_tasks_req("detached + ceiling goal");
2376 req.detach = true;
2377 req.timeout_secs = Some(60);
2378
2379 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
2380 Err(e) => e,
2381 Ok(_) => panic!("detach + timeout_secs must be rejected"),
2382 };
2383 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2384 assert!(
2385 err.message.contains("detach"),
2386 "error message must explain the detach/timeout_secs conflict: {}",
2387 err.message
2388 );
2389 let tasks = state.task_store.list().await.expect("task list");
2390 assert!(
2391 tasks.is_empty(),
2392 "the 400 must fire before any TaskRecord is minted"
2393 );
2394 }
2395
2396 #[tokio::test]
2400 async fn rekick_detached_returns_202_and_completes_in_background() {
2401 let state = test_state();
2402 let posted = crate::tasks_start(
2403 State(state.clone()),
2404 Json(post_tasks_req("detached rekick goal")),
2405 )
2406 .await
2407 .expect("tasks_start")
2408 .0;
2409
2410 let (status, rekicked) = task_rekick(
2411 State(state.clone()),
2412 Path(posted.task_id.to_string()),
2413 Some(Json(RunKickRequest {
2414 init_ctx_override: None,
2415 task_input_override: None,
2416 timeout_secs: None,
2417 detach: true,
2418 operator_sid: None,
2419 })),
2420 )
2421 .await
2422 .expect("task_rekick (detached)");
2423 assert_eq!(status, StatusCode::ACCEPTED);
2424 assert_eq!(rekicked.0.status, RunStatus::Running);
2425 assert_ne!(rekicked.0.run_id, posted.run_id);
2426
2427 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
2428 assert_eq!(rec.status, RunStatus::Done);
2429 assert!(
2430 rec.result_ref.is_some(),
2431 "finalize_run must persist the background rekick's final_ctx"
2432 );
2433 }
2434
2435 #[tokio::test]
2439 async fn rekick_detached_with_timeout_secs_rejected() {
2440 let state = test_state();
2441 let posted = crate::tasks_start(
2442 State(state.clone()),
2443 Json(post_tasks_req("detached rekick ceiling goal")),
2444 )
2445 .await
2446 .expect("tasks_start")
2447 .0;
2448
2449 let err = match task_rekick(
2450 State(state.clone()),
2451 Path(posted.task_id.to_string()),
2452 Some(Json(RunKickRequest {
2453 init_ctx_override: None,
2454 task_input_override: None,
2455 timeout_secs: Some(60),
2456 detach: true,
2457 operator_sid: None,
2458 })),
2459 )
2460 .await
2461 {
2462 Err(e) => e,
2463 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
2464 };
2465 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2466 assert!(
2467 err.message.contains("detach"),
2468 "error message must explain the detach/timeout_secs conflict: {}",
2469 err.message
2470 );
2471 let runs = state
2472 .run_store
2473 .list_by_task(&posted.task_id)
2474 .await
2475 .expect("runs list");
2476 assert_eq!(
2477 runs.len(),
2478 1,
2479 "the 400 must fire before a second Run is minted"
2480 );
2481 }
2482
2483 async fn seed_running_run(state: &AppState) -> (TaskId, RunId) {
2491 let now = now_secs();
2492 let task_id = TaskId::new();
2493 let run_id = RunId::new();
2494 state
2495 .task_store
2496 .create(TaskRecord {
2497 id: task_id.clone(),
2498 goal: "panic guard goal".into(),
2499 blueprint_ref: json!({}),
2500 input_ctx: json!({}),
2501 task_input_spec: None,
2502 status: TaskRecordStatus::Running,
2503 created_at: now,
2504 updated_at: now,
2505 })
2506 .await
2507 .expect("task create");
2508 state
2509 .run_store
2510 .create(RunRecord {
2511 id: run_id.clone(),
2512 task_id: task_id.clone(),
2513 status: RunStatus::Running,
2514 step_entries: Vec::new(),
2515 degradations: Vec::new(),
2516 operator_sid: None,
2517 result_ref: None,
2518 input_json: None,
2519 created_at: now,
2520 updated_at: now,
2521 })
2522 .await
2523 .expect("run create");
2524 (task_id, run_id)
2525 }
2526
2527 async fn run_finished_events(state: &AppState, run_id: &RunId) -> Vec<TraceEvent> {
2528 state
2529 .run_trace_store
2530 .list(run_id, &TraceQuery::default())
2531 .await
2532 .expect("trace list")
2533 .into_iter()
2534 .filter(|e| e.kind == trace_kind::RUN_FINISHED)
2535 .collect()
2536 }
2537
2538 #[tokio::test]
2543 async fn panicking_driver_marks_run_interrupted() {
2544 let state = test_state();
2545 let (task_id, run_id) = seed_running_run(&state).await;
2546
2547 let outcome: Result<(), String> =
2548 catch_run_panic(&state, &task_id, &run_id, "test.detach", async {
2549 panic!("boom");
2550 })
2551 .await;
2552 let message = outcome.expect_err("a panicking driver must report the panic to its caller");
2553 assert!(
2554 message.contains("boom"),
2555 "the panic payload must survive as the caller-visible message: {message}"
2556 );
2557
2558 let rec = state.run_store.get(&run_id).await.expect("run get");
2559 assert_eq!(
2560 rec.status,
2561 RunStatus::Interrupted,
2562 "a panicked Run must be resumable, not left Running or marked Failed"
2563 );
2564 let reason = rec
2565 .result_ref
2566 .as_ref()
2567 .and_then(|v| v.get("error"))
2568 .and_then(Value::as_str)
2569 .expect("a structured {\"error\": ...} envelope");
2570 assert!(
2571 reason.contains("boom") && reason.contains("test.detach"),
2572 "the reason must name both the panic payload and the site: {reason}"
2573 );
2574
2575 let task = state.task_store.get(&task_id).await.expect("task get");
2576 assert_eq!(task.status, TaskRecordStatus::Interrupted);
2577
2578 let finished = run_finished_events(&state, &run_id).await;
2579 assert_eq!(finished.len(), 1, "expected one terminal trace marker");
2580 assert_eq!(
2581 finished[0].payload.get("status").and_then(Value::as_str),
2582 Some("interrupted")
2583 );
2584 assert_eq!(
2585 finished[0].payload.get("reason").and_then(Value::as_str),
2586 Some("driver panic")
2587 );
2588 }
2589
2590 #[tokio::test]
2594 async fn panic_guard_does_not_clobber_a_finalized_run() {
2595 let state = test_state();
2596 let (task_id, run_id) = seed_running_run(&state).await;
2597 state
2598 .run_store
2599 .set_result(&run_id, json!({"kept": true}))
2600 .await
2601 .expect("set_result");
2602 state
2603 .run_store
2604 .update_status(&run_id, RunStatus::Done)
2605 .await
2606 .expect("update_status");
2607
2608 let outcome: Result<(), String> =
2609 catch_run_panic(&state, &task_id, &run_id, "test.detach", async {
2610 panic!("late boom");
2611 })
2612 .await;
2613 assert!(
2614 outcome.is_err(),
2615 "the panic is still reported to the caller"
2616 );
2617
2618 let rec = state.run_store.get(&run_id).await.expect("run get");
2619 assert_eq!(rec.status, RunStatus::Done, "the CAS must have refused");
2620 assert_eq!(rec.result_ref, Some(json!({"kept": true})));
2621 let finished = run_finished_events(&state, &run_id).await;
2622 assert!(
2623 finished.is_empty(),
2624 "a refused CAS must not append a second terminal marker: {finished:?}"
2625 );
2626 }
2627
2628 #[tokio::test]
2634 async fn sync_panic_returns_err_and_interrupts_run() {
2635 let state = test_state();
2636 let (task_id, run_id) = seed_running_run(&state).await;
2637
2638 let timed = catch_run_panic(
2639 &state,
2640 &task_id,
2641 &run_id,
2642 "launch.sync",
2643 tokio::time::timeout(Duration::from_secs(30), async {
2644 panic!("sync boom");
2645 }),
2646 )
2647 .await;
2648 let message = timed.expect_err("the sync path must observe the panic as an Err");
2649 assert!(message.contains("sync boom"), "payload lost: {message}");
2650
2651 let err = ApiError::engine(format!("run driver panicked: {message}"));
2652 assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
2653
2654 let rec = state.run_store.get(&run_id).await.expect("run get");
2655 assert_eq!(rec.status, RunStatus::Interrupted);
2656 }
2657
2658 #[tokio::test]
2659 async fn rekick_adds_a_second_run_to_the_same_task() {
2660 let state = test_state();
2661 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
2662 .await
2663 .expect("tasks_start")
2664 .0;
2665 let task_id = posted.task_id.clone();
2666 let first_run_id = posted.run_id.clone();
2667
2668 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
2669 .await
2670 .expect("task_rekick");
2671 assert_eq!(status, StatusCode::CREATED);
2672 let second_run_id = rekicked.0.run_id.clone();
2673 assert_ne!(first_run_id, second_run_id);
2674
2675 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
2676 .await
2677 .expect("task_get")
2678 .0;
2679 assert_eq!(
2680 detail.runs.len(),
2681 2,
2682 "expected 2 runs, got {:?}",
2683 detail.runs
2684 );
2685 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
2686 assert!(ids.contains(&&first_run_id));
2687 assert!(ids.contains(&&second_run_id));
2688
2689 let first_run = detail
2694 .runs
2695 .iter()
2696 .find(|r| r.id == first_run_id)
2697 .expect("first run present in detail.runs");
2698 let second_run = detail
2699 .runs
2700 .iter()
2701 .find(|r| r.id == second_run_id)
2702 .expect("second run present in detail.runs");
2703 assert_eq!(
2704 first_run.step_entries.len(),
2705 1,
2706 "first run step_entries: {:?}",
2707 first_run.step_entries
2708 );
2709 assert_eq!(
2710 second_run.step_entries.len(),
2711 1,
2712 "second run step_entries: {:?}",
2713 second_run.step_entries
2714 );
2715 assert_eq!(
2716 first_run.step_entries[0].step_ref,
2717 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2718 );
2719 assert_eq!(
2720 second_run.step_entries[0].step_ref,
2721 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
2722 );
2723 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
2724 assert_eq!(
2725 second_run.step_entries[0].status,
2726 Some("passed".to_string())
2727 );
2728 assert_ne!(
2729 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
2730 "each kick dispatches its own StepId — runs must not share step_entries"
2731 );
2732 }
2733
2734 #[tokio::test]
2735 async fn rekick_unknown_task_returns_404() {
2736 let state = test_state();
2737 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
2741 Ok(_) => panic!("expected 404 for an unknown task"),
2742 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2743 }
2744 }
2745
2746 fn greeting_blueprint() -> Blueprint {
2755 Blueprint {
2756 schema_version: current_schema_version(),
2757 id: "tasks-test-greeting-bp".into(),
2758 flow: serde_json::from_value(serde_json::json!({
2759 "kind": "step",
2760 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
2761 "in": {"op": "path", "at": "$.greeting"},
2762 "out": {"op": "path", "at": "$.out"},
2763 }))
2764 .expect("flow parse"),
2765 agents: vec![AgentDef {
2766 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
2767 kind: AgentKind::RustFn,
2768 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
2769 profile: None,
2770 meta: None,
2771 runner: None,
2772 runner_ref: None,
2773 verdict: None,
2774 lints: None,
2775 }],
2776 operators: vec![],
2777 metas: vec![],
2778 hints: CompilerHints::default(),
2779 strategy: CompilerStrategy::default(),
2780 metadata: BlueprintMetadata::default(),
2781 spawner_hints: Default::default(),
2782 default_agent_kind: AgentKind::Operator,
2783 default_operator_kind: None,
2784 default_init_ctx: None,
2785 default_agent_ctx: None,
2786 default_context_policy: None,
2787 projection_placement: None,
2788 audits: vec![],
2789 degradation_policy: None,
2790 runners: vec![],
2791 default_runner: None,
2792 subprocesses: vec![],
2793 check_policy: None,
2794 blueprint_ref_includes: Vec::new(),
2795 }
2796 }
2797
2798 fn post_greeting_task_req(
2799 greeting: &str,
2800 project_root: Option<&str>,
2801 ) -> crate::TaskLaunchRequest {
2802 crate::TaskLaunchRequest {
2803 blueprint: BlueprintRef::Inline {
2804 value: Box::new(greeting_blueprint()),
2805 },
2806 init_ctx: serde_json::json!({ "greeting": greeting }),
2807 project_root: project_root.map(str::to_string),
2808 work_dir: None,
2809 task_metadata: None,
2810 ttl_secs: None,
2811 operator: None,
2812 operator_sid: None,
2813 timeout_secs: None,
2814 goal: Some("st4 rekick goal".to_string()),
2815 detach: false,
2816 check_policy: None,
2817 }
2818 }
2819
2820 #[tokio::test]
2821 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2822 let state = test_state();
2825 let posted = crate::tasks_start(
2826 State(state.clone()),
2827 Json(post_greeting_task_req("from-task", None)),
2828 )
2829 .await
2830 .expect("tasks_start")
2831 .0;
2832 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2833
2834 let (status, rekicked) =
2835 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2836 .await
2837 .expect("task_rekick");
2838 assert_eq!(status, StatusCode::CREATED);
2839
2840 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2841 .await
2842 .expect("run_get")
2843 .0;
2844 assert_eq!(
2845 run.result_ref.expect("result_ref present")["out"]["echoed"],
2846 "from-task"
2847 );
2848 }
2849
2850 #[tokio::test]
2851 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2852 let state = test_state();
2853 let posted = crate::tasks_start(
2854 State(state.clone()),
2855 Json(post_greeting_task_req("from-task", None)),
2856 )
2857 .await
2858 .expect("tasks_start")
2859 .0;
2860 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2861
2862 let (status, rekicked) = task_rekick(
2863 State(state.clone()),
2864 Path(posted.task_id.to_string()),
2865 Some(Json(RunKickRequest {
2866 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2867 task_input_override: None,
2868 timeout_secs: None,
2869 detach: false,
2870 operator_sid: None,
2871 })),
2872 )
2873 .await
2874 .expect("task_rekick");
2875 assert_eq!(status, StatusCode::CREATED);
2876
2877 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2878 .await
2879 .expect("run_get")
2880 .0;
2881 assert_eq!(
2882 run.result_ref.expect("result_ref present")["out"]["echoed"],
2883 "from-run",
2884 "Run's init_ctx_override must win over the stored Task input_ctx"
2885 );
2886 }
2887
2888 #[tokio::test]
2889 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2890 let state = test_state();
2898 let posted = crate::tasks_start(
2899 State(state.clone()),
2900 Json(post_greeting_task_req("from-task", Some("/repo"))),
2901 )
2902 .await
2903 .expect("tasks_start")
2904 .0;
2905
2906 let before = state
2907 .task_store
2908 .get(&posted.task_id)
2909 .await
2910 .expect("task fetch");
2911 let before_spec: Option<TaskInputSpec> = before
2912 .task_input_spec
2913 .as_ref()
2914 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2915 assert_eq!(
2916 before_spec,
2917 Some(TaskInputSpec {
2918 project_root: Some("/repo".to_string()),
2919 work_dir: None,
2920 task_metadata: None,
2921 })
2922 );
2923
2924 let (status, _rekicked) =
2925 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2926 .await
2927 .expect("task_rekick");
2928 assert_eq!(status, StatusCode::CREATED);
2929
2930 let after = state
2931 .task_store
2932 .get(&posted.task_id)
2933 .await
2934 .expect("task fetch");
2935 assert_eq!(
2936 after.task_input_spec, before.task_input_spec,
2937 "rekick must not mutate the stored Task-level task_input_spec snapshot"
2938 );
2939 }
2940
2941 #[tokio::test]
2942 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2943 let state = test_state();
2946 let posted = crate::tasks_start(
2947 State(state.clone()),
2948 Json(post_greeting_task_req("from-task", Some("/repo"))),
2949 )
2950 .await
2951 .expect("tasks_start")
2952 .0;
2953
2954 let (status, _rekicked) = task_rekick(
2955 State(state.clone()),
2956 Path(posted.task_id.to_string()),
2957 Some(Json(RunKickRequest {
2958 init_ctx_override: None,
2959 task_input_override: Some(TaskInputSpec {
2960 project_root: Some("/override".to_string()),
2961 work_dir: None,
2962 task_metadata: None,
2963 }),
2964 timeout_secs: None,
2965 detach: false,
2966 operator_sid: None,
2967 })),
2968 )
2969 .await
2970 .expect("task_rekick");
2971 assert_eq!(status, StatusCode::CREATED);
2972
2973 let after = state
2974 .task_store
2975 .get(&posted.task_id)
2976 .await
2977 .expect("task fetch");
2978 let after_spec: Option<TaskInputSpec> = after
2979 .task_input_spec
2980 .as_ref()
2981 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2982 assert_eq!(
2983 after_spec,
2984 Some(TaskInputSpec {
2985 project_root: Some("/repo".to_string()),
2986 work_dir: None,
2987 task_metadata: None,
2988 }),
2989 "a per-Run task_input_override must not leak into the stored TaskRecord"
2990 );
2991 }
2992
2993 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
3007 crate::TaskLaunchRequest {
3008 blueprint: BlueprintRef::Inline {
3009 value: Box::new(identity_blueprint_with_operator_delegate()),
3010 },
3011 init_ctx: serde_json::json!({"in": "hello"}),
3012 project_root: None,
3013 work_dir: None,
3014 task_metadata: None,
3015 ttl_secs: None,
3016 operator: None,
3017 operator_sid: None,
3018 timeout_secs: None,
3019 goal: Some(goal.to_string()),
3020 detach: false,
3021 check_policy: None,
3022 }
3023 }
3024
3025 #[tokio::test]
3030 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
3031 let state = test_state();
3032 let posted = crate::tasks_start(
3033 State(state.clone()),
3034 Json(delegate_launch_req("operator delegate rekick goal")),
3035 )
3036 .await
3037 .expect("tasks_start (no operator referenced, dispatches through baseline)")
3038 .0;
3039 let started = std::time::Instant::now();
3043 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
3044 let elapsed = started.elapsed();
3045
3046 let err = match result {
3047 Err(e) => e,
3048 Ok(_) => panic!(
3049 "rekicking a Task whose Blueprint declares operator_delegate with zero \
3050 attached operators must fail, not dispatch"
3051 ),
3052 };
3053 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
3054 assert!(
3055 err.message.contains("no operator attached"),
3056 "error message must mention the missing operator: {}",
3057 err.message
3058 );
3059 assert!(
3060 elapsed < Duration::from_secs(1),
3061 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
3062 );
3063 }
3064
3065 #[tokio::test]
3069 async fn rekick_stalled_operator_times_out() {
3070 let state = test_state();
3071 state
3072 .engine
3073 .register_operator("stall-op", Arc::new(StallingOperator))
3074 .await;
3075 let posted = crate::tasks_start(
3076 State(state.clone()),
3077 Json(delegate_launch_req("stalled rekick goal")),
3078 )
3079 .await
3080 .expect("tasks_start")
3081 .0;
3082
3083 let started = std::time::Instant::now();
3084 let result = tokio::time::timeout(
3088 Duration::from_secs(5),
3089 task_rekick(
3090 State(state),
3091 Path(posted.task_id.to_string()),
3092 Some(Json(RunKickRequest {
3093 init_ctx_override: None,
3094 task_input_override: None,
3095 timeout_secs: Some(1),
3096 detach: false,
3097 operator_sid: None,
3098 })),
3099 ),
3100 )
3101 .await
3102 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
3103 let elapsed = started.elapsed();
3104
3105 match &result {
3106 Err(e) => {
3107 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
3108 assert!(
3109 e.message.contains('1'),
3110 "error message must mention the configured 1s ceiling: {}",
3111 e.message
3112 );
3113 assert!(
3114 elapsed < Duration::from_secs(3),
3115 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
3116 );
3117 }
3118 Ok(_) => {
3119 assert!(
3131 elapsed < Duration::from_secs(1),
3132 "a rekick that never engages an Operator (task_rekick has no \
3133 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
3134 );
3135 }
3136 }
3137 }
3138
3139 #[tokio::test]
3143 async fn rekick_timeout_secs_zero_rejected() {
3144 let state = test_state();
3145 let posted = crate::tasks_start(
3146 State(state.clone()),
3147 Json(post_tasks_req("zero timeout rekick goal")),
3148 )
3149 .await
3150 .expect("tasks_start")
3151 .0;
3152
3153 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
3154 .await
3155 .expect("task_get")
3156 .0;
3157 let runs_before = before.runs.len();
3158
3159 let result = task_rekick(
3160 State(state.clone()),
3161 Path(posted.task_id.to_string()),
3162 Some(Json(RunKickRequest {
3163 init_ctx_override: None,
3164 task_input_override: None,
3165 timeout_secs: Some(0),
3166 detach: false,
3167 operator_sid: None,
3168 })),
3169 )
3170 .await;
3171 let err = match result {
3172 Err(e) => e,
3173 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
3174 };
3175 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3176 assert!(
3177 err.message.contains("timeout_secs"),
3178 "error message must reference timeout_secs: {}",
3179 err.message
3180 );
3181
3182 let after = task_get(State(state), Path(posted.task_id.to_string()))
3183 .await
3184 .expect("task_get")
3185 .0;
3186 assert_eq!(
3187 after.runs.len(),
3188 runs_before,
3189 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
3190 );
3191 }
3192
3193 #[tokio::test]
3197 async fn rekick_non_operator_path_unaffected_by_guard_1() {
3198 let state = test_state();
3199 let posted = crate::tasks_start(
3200 State(state.clone()),
3201 Json(post_tasks_req("non-operator rekick goal")),
3202 )
3203 .await
3204 .expect("tasks_start")
3205 .0;
3206
3207 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
3208 if let Err(e) = &result {
3209 panic!(
3210 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
3211 guard 1: {}",
3212 e.message
3213 );
3214 }
3215 }
3216
3217 #[tokio::test]
3225 async fn rekick_unknown_operator_sid_rejected_before_side_effects() {
3226 let state = test_state();
3227 let posted = crate::tasks_start(
3228 State(state.clone()),
3229 Json(post_tasks_req("unknown operator_sid rekick goal")),
3230 )
3231 .await
3232 .expect("tasks_start")
3233 .0;
3234
3235 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
3236 .await
3237 .expect("task_get")
3238 .0;
3239 let runs_before = before.runs.len();
3240
3241 let result = task_rekick(
3242 State(state.clone()),
3243 Path(posted.task_id.to_string()),
3244 Some(Json(RunKickRequest {
3245 init_ctx_override: None,
3246 task_input_override: None,
3247 timeout_secs: None,
3248 detach: false,
3249 operator_sid: Some("S-not-registered".to_string()),
3250 })),
3251 )
3252 .await;
3253 let err = match result {
3254 Err(e) => e,
3255 Ok(_) => panic!("an unknown operator_sid must be rejected, not dispatched"),
3256 };
3257 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3258 assert!(
3259 err.message.contains("operator_sid"),
3260 "error message must reference operator_sid: {}",
3261 err.message
3262 );
3263
3264 let after = task_get(State(state), Path(posted.task_id.to_string()))
3265 .await
3266 .expect("task_get")
3267 .0;
3268 assert_eq!(
3269 after.runs.len(),
3270 runs_before,
3271 "a rejected unknown-operator_sid rekick must not create a new Run"
3272 );
3273 }
3274
3275 #[tokio::test]
3283 async fn rekick_with_registered_operator_sid_persists_it_on_the_run() {
3284 let state = test_state();
3285 state
3289 .engine
3290 .register_operator("S-live-op", Arc::new(StallingOperator))
3291 .await;
3292 let posted = crate::tasks_start(
3293 State(state.clone()),
3294 Json(post_tasks_req("registered operator_sid rekick goal")),
3295 )
3296 .await
3297 .expect("tasks_start")
3298 .0;
3299
3300 let (status, rekicked) = task_rekick(
3301 State(state.clone()),
3302 Path(posted.task_id.to_string()),
3303 Some(Json(RunKickRequest {
3304 init_ctx_override: None,
3305 task_input_override: None,
3306 timeout_secs: None,
3307 detach: false,
3308 operator_sid: Some("S-live-op".to_string()),
3309 })),
3310 )
3311 .await
3312 .expect("task_rekick with a registered operator_sid");
3313 assert_eq!(status, StatusCode::CREATED);
3314
3315 let run = state
3316 .run_store
3317 .get(&rekicked.0.run_id)
3318 .await
3319 .expect("run get");
3320 assert_eq!(
3321 run.operator_sid,
3322 Some("S-live-op".to_string()),
3323 "the pinned operator_sid must be persisted verbatim on the RunRecord"
3324 );
3325 }
3326
3327 #[tokio::test]
3333 async fn rekick_pin_reaches_both_axes_and_survives_in_the_launch_snapshot() {
3334 let state = test_state();
3335 state
3336 .engine
3337 .register_operator("S-live-op", Arc::new(StallingOperator))
3338 .await;
3339 let posted = crate::tasks_start(
3340 State(state.clone()),
3341 Json(post_tasks_req("pinned rekick snapshot goal")),
3342 )
3343 .await
3344 .expect("tasks_start")
3345 .0;
3346
3347 let (_status, rekicked) = task_rekick(
3348 State(state.clone()),
3349 Path(posted.task_id.to_string()),
3350 Some(Json(RunKickRequest {
3351 init_ctx_override: None,
3352 task_input_override: None,
3353 timeout_secs: None,
3354 detach: false,
3355 operator_sid: Some("S-live-op".to_string()),
3356 })),
3357 )
3358 .await
3359 .expect("task_rekick with a registered operator_sid");
3360
3361 let run = state
3362 .run_store
3363 .get(&rekicked.0.run_id)
3364 .await
3365 .expect("run get");
3366 let snapshot: Value = serde_json::from_str(
3367 run.input_json
3368 .as_deref()
3369 .expect("a rekicked Run persists its launch snapshot"),
3370 )
3371 .expect("snapshot json");
3372 assert_eq!(
3373 snapshot["operator_backend_id"],
3374 serde_json::json!("S-live-op"),
3375 "the delegate axis keeps receiving the sid exactly as before: {snapshot}"
3376 );
3377 assert_eq!(
3378 snapshot["operator_pin"],
3379 serde_json::json!("S-live-op"),
3380 "the same sid must also pin the AgentSpec axis: {snapshot}"
3381 );
3382 }
3383
3384 #[tokio::test]
3387 async fn unpinned_launch_snapshot_carries_neither_axis() {
3388 let state = test_state();
3389 let posted = crate::tasks_start(
3390 State(state.clone()),
3391 Json(post_tasks_req("unpinned snapshot goal")),
3392 )
3393 .await
3394 .expect("tasks_start")
3395 .0;
3396 let run = state.run_store.get(&posted.run_id).await.expect("run get");
3397 let snapshot: Value =
3398 serde_json::from_str(run.input_json.as_deref().expect("launch snapshot"))
3399 .expect("snapshot json");
3400 assert_eq!(snapshot["operator_backend_id"], Value::Null);
3401 assert_eq!(snapshot["operator_pin"], Value::Null);
3402 assert_eq!(
3403 run.operator_sid, None,
3404 "an unpinned launch records no session on the Run"
3405 );
3406 }
3407
3408 #[test]
3411 fn pre_pin_launch_snapshot_still_decodes() {
3412 let snapshot = serde_json::json!({
3413 "blueprint": { "kind": "inline", "value": identity_blueprint() },
3414 "operator_id": "http-run",
3415 "role": "operator",
3416 "ttl": { "secs": 60, "nanos": 0 },
3417 "init_ctx": {},
3418 "operator_kind": null,
3419 "bridge_id": null,
3420 "hook_id": null,
3421 "operator_backend_id": null,
3422 "task_input": null,
3423 "check_policy": null,
3424 });
3425 let decoded: RunLaunchSnapshot =
3426 serde_json::from_value(snapshot).expect("a pre-pin snapshot must still decode");
3427 assert!(decoded.into_input().operator_pin.is_none());
3428 }
3429
3430 #[tokio::test]
3431 async fn run_get_unknown_id_returns_404() {
3432 let state = test_state();
3433 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
3434 Ok(_) => panic!("expected 404 for an unknown run"),
3435 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3436 }
3437 }
3438
3439 #[tokio::test]
3440 async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
3441 let state = test_state();
3442 let posted = crate::tasks_start(
3443 State(state.clone()),
3444 Json(post_tasks_req("binding explain")),
3445 )
3446 .await
3447 .expect("tasks_start")
3448 .0;
3449 let run = state
3450 .run_store
3451 .get(&posted.run_id)
3452 .await
3453 .expect("stored run");
3454 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3455 let mut bound_agents: Vec<BoundAgent> =
3456 serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
3457 let bound = &mut bound_agents[0];
3458 bound.runner = Some(Runner::WsClaudeCode {
3459 variant: "coder".to_string(),
3460 tools: vec!["Read".to_string()],
3461 });
3462 bound.recompute_binding_digest().unwrap();
3463 let request_digest = bound.binding_digest.clone();
3464 bound
3465 .set_attestation(BindingAttestation {
3466 request_digest: request_digest.clone(),
3467 provider_id: "operator-manifest".to_string(),
3468 provider_revision: Some("claude-code-1.2".to_string()),
3469 resolved_model: Some("claude-sonnet-4".to_string()),
3470 effective_tools: vec!["Bash".to_string(), "Read".to_string()],
3471 launch_variant: Some("coder".to_string()),
3472 capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
3473 b"manifest-v1",
3474 )),
3475 })
3476 .unwrap();
3477 snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
3478 state
3479 .run_store
3480 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3481 .await
3482 .unwrap();
3483
3484 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3485 .await
3486 .expect("binding explain")
3487 .0;
3488 let entry = &explained.bindings[0];
3489 assert_eq!(entry.status, RunBindingStatus::Attested);
3490 assert_eq!(
3491 entry.requested.as_ref().unwrap().request_digest,
3492 request_digest
3493 );
3494 assert_eq!(
3495 entry
3496 .effective
3497 .as_ref()
3498 .unwrap()
3499 .provider_revision
3500 .as_deref(),
3501 Some("claude-code-1.2")
3502 );
3503 assert_eq!(
3504 entry
3505 .difference
3506 .as_ref()
3507 .unwrap()
3508 .additional_effective_tools,
3509 vec!["Bash"]
3510 );
3511 assert!(entry
3512 .difference
3513 .as_ref()
3514 .unwrap()
3515 .missing_requested_tools
3516 .is_empty());
3517 assert_ne!(entry.binding_digest, request_digest);
3518 }
3519
3520 #[tokio::test]
3521 async fn run_bindings_explain_reports_snapshot_origin() {
3522 let state = test_state();
3523 let posted =
3524 crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
3525 .await
3526 .expect("tasks_start")
3527 .0;
3528
3529 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3531 .await
3532 .expect("binding explain")
3533 .0;
3534 assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
3535
3536 let run = state.run_store.get(&posted.run_id).await.unwrap();
3538 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3539 snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
3540 state
3541 .run_store
3542 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3543 .await
3544 .unwrap();
3545 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
3546 .await
3547 .expect("binding explain")
3548 .0;
3549 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3550
3551 snapshot
3555 .as_object_mut()
3556 .unwrap()
3557 .remove("bound_agents_origin");
3558 state
3559 .run_store
3560 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3561 .await
3562 .unwrap();
3563 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3564 .await
3565 .expect("explain still 200 without an origin marker")
3566 .0;
3567 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
3568 }
3569
3570 #[tokio::test]
3571 async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
3572 let state = test_state();
3573 let posted = crate::tasks_start(
3574 State(state.clone()),
3575 Json(post_tasks_req("legacy binding explain")),
3576 )
3577 .await
3578 .expect("tasks_start")
3579 .0;
3580 state
3581 .run_store
3582 .set_input_json(&posted.run_id, "{}".to_string())
3583 .await
3584 .unwrap();
3585
3586 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3587 .await
3588 .expect_err("legacy run must not be re-resolved");
3589 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3590 assert!(error
3591 .message
3592 .contains("current Blueprint state was not consulted"));
3593 }
3594
3595 #[tokio::test]
3596 async fn run_bindings_explain_rejects_a_tampered_snapshot() {
3597 let state = test_state();
3598 let posted = crate::tasks_start(
3599 State(state.clone()),
3600 Json(post_tasks_req("tampered binding explain")),
3601 )
3602 .await
3603 .expect("tasks_start")
3604 .0;
3605 let run = state.run_store.get(&posted.run_id).await.unwrap();
3606 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3607 snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
3608 state
3609 .run_store
3610 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
3611 .await
3612 .unwrap();
3613
3614 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
3615 .await
3616 .expect_err("digest drift must fail closed");
3617 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
3618 assert!(error.message.contains("inconsistent binding snapshot"));
3619 }
3620
3621 #[tokio::test]
3622 async fn task_get_unknown_id_returns_404() {
3623 let state = test_state();
3624 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
3625 Ok(_) => panic!("expected 404 for an unknown task"),
3626 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
3627 }
3628 }
3629
3630 async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
3637 let task_id = TaskId::new();
3638 let run_id = RunId::new();
3639 state
3640 .task_store
3641 .create(TaskRecord {
3642 id: task_id.clone(),
3643 goal: "finalize-run-err-envelope".to_string(),
3644 blueprint_ref: json!("inline"),
3645 input_ctx: Value::Null,
3646 task_input_spec: None,
3647 status: TaskRecordStatus::Running,
3648 created_at: 0,
3649 updated_at: 0,
3650 })
3651 .await
3652 .expect("seed TaskRecord");
3653 state
3654 .run_store
3655 .create(RunRecord {
3656 id: run_id.clone(),
3657 task_id: task_id.clone(),
3658 status: RunStatus::Running,
3659 step_entries: Vec::new(),
3660 degradations: Vec::new(),
3661 operator_sid: None,
3662 result_ref: None,
3663 input_json: Some("{}".to_string()),
3664 created_at: 0,
3665 updated_at: 0,
3666 })
3667 .await
3668 .expect("seed RunRecord");
3669 (task_id, run_id)
3670 }
3671
3672 #[tokio::test]
3673 async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
3674 let state = test_state();
3675 let (task_id, run_id) = seed_task_and_run(&state).await;
3676
3677 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3678 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3679 message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
3680 failed_step: Some("gate".to_string()),
3681 verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
3682 partial_ctx: Some(
3683 json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
3684 ),
3685 }));
3686
3687 let _ = finalize_run(&state, &task_id, &run_id, err).await;
3688
3689 let run = state.run_store.get(&run_id).await.expect("run present");
3690 assert_eq!(run.status, RunStatus::Failed);
3691 let envelope = run
3692 .result_ref
3693 .as_ref()
3694 .expect("result_ref must be Some on Err arm");
3695 assert_eq!(
3696 envelope["error"]["message"],
3697 "blocked: {\"verdict\":\"BLOCKED\"}"
3698 );
3699 assert_eq!(envelope["error"]["failed_step"], "gate");
3700 assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
3701 assert_eq!(
3702 envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
3703 "blocked"
3704 );
3705
3706 let task = state.task_store.get(&task_id).await.expect("task present");
3708 assert_eq!(task.status, TaskRecordStatus::Failed);
3709 }
3710
3711 #[tokio::test]
3712 async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
3713 let state = test_state();
3714 let (task_id, run_id) = seed_task_and_run(&state).await;
3715
3716 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3720 Err(TaskApplicationError::NoStore);
3721
3722 let _ = finalize_run(&state, &task_id, &run_id, err).await;
3723 let run = state.run_store.get(&run_id).await.expect("run present");
3724 let envelope = run
3725 .result_ref
3726 .as_ref()
3727 .expect("result_ref must be Some on Err arm");
3728 assert!(envelope["error"]["message"]
3729 .as_str()
3730 .expect("message string")
3731 .contains("store"));
3732 assert_eq!(envelope["error"]["failed_step"], Value::Null);
3733 assert_eq!(envelope["error"]["verdict_value"], Value::Null);
3734 assert_eq!(envelope["partial_ctx"], Value::Null);
3735 }
3736
3737 #[tokio::test]
3742 async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
3743 let state = test_state();
3744 let (task_id, run_id) = seed_task_and_run(&state).await;
3745
3746 let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
3747 token: mlua_swarm::CapToken {
3748 agent_id: "ut".to_string(),
3749 role: mlua_swarm::Role::Operator,
3750 scopes: vec!["*".to_string()],
3751 issued_at: 0,
3752 expire_at: u64::MAX,
3753 max_uses: None,
3754 nonce: "ut-nonce".to_string(),
3755 sig_hex: String::new(),
3756 },
3757 final_ctx: json!({"out": {"echoed": "hi"}}),
3758 bound_version: None,
3759 });
3760
3761 let _ = finalize_run(&state, &task_id, &run_id, ok).await;
3762 let run = state.run_store.get(&run_id).await.expect("run present");
3763 assert_eq!(run.status, RunStatus::Done);
3764 let stored = run.result_ref.as_ref().expect("result_ref Some");
3765 assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
3767 assert!(
3768 stored.get("error").is_none(),
3769 "Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
3770 );
3771 }
3772
3773 #[tokio::test]
3778 async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
3779 let state = test_state();
3780 let (_task_id, run_id) = seed_task_and_run(&state).await;
3781 let err: Result<TaskApplicationOutput, TaskApplicationError> =
3782 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
3783 message: "blocked: bad verdict".to_string(),
3784 failed_step: Some("scout".to_string()),
3785 verdict_value: Some(json!("BLOCKED")),
3786 partial_ctx: Some(json!({"steps": {}})),
3787 }));
3788 let _ = finalize_run(&state, &_task_id, &run_id, err).await;
3789
3790 let Json(run) = run_get(State(state), Path(run_id.to_string()))
3791 .await
3792 .expect("run_get");
3793 assert_eq!(run.status, RunStatus::Failed);
3794 let envelope = run.result_ref.expect("result_ref Some");
3795 assert_eq!(envelope["error"]["failed_step"], "scout");
3796 assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
3797 }
3798}