1use axum::{
38 extract::{Path, Query, State},
39 http::StatusCode,
40 Json,
41};
42use mlua_swarm::application::{
43 BlueprintRef, TaskApplicationError, TaskApplicationInput, TaskApplicationOutput,
44};
45use mlua_swarm::blueprint::{BindRequest, BindingAttestation, BoundAgent};
46use mlua_swarm::core::config::CheckPolicy;
47use mlua_swarm::service::merge_init_ctx_3layer;
48use mlua_swarm::service::TaskLaunchError;
49use mlua_swarm::store::replay::ReplayCursor;
50use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError, SnapshotOrigin};
51use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
52use mlua_swarm::{
53 validate_bound_agent_snapshots, OperatorKind, Role, RunId, TaskId, TaskInputSpec,
54};
55use serde::{Deserialize, Serialize};
56use serde_json::{json, Value};
57use std::collections::HashMap;
58use std::sync::{Arc, Mutex};
59use std::time::Duration;
60
61use crate::{ApiError, AppState};
62
63pub(crate) fn now_secs() -> u64 {
67 std::time::SystemTime::now()
68 .duration_since(std::time::UNIX_EPOCH)
69 .map(|d| d.as_secs())
70 .unwrap_or(0)
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
87pub(crate) struct RunLaunchSnapshot {
88 blueprint: BlueprintRef,
89 operator_id: String,
90 role: Role,
91 ttl: Duration,
92 init_ctx: Value,
93 operator_kind: Option<OperatorKind>,
94 bridge_id: Option<String>,
95 hook_id: Option<String>,
96 operator_backend_id: Option<String>,
97 #[serde(default)]
98 operator_kind_overrides: HashMap<String, OperatorKind>,
99 task_input: Option<TaskInputSpec>,
100 check_policy: Option<CheckPolicy>,
101}
102
103impl RunLaunchSnapshot {
104 fn from_input(input: &TaskApplicationInput) -> Self {
107 Self {
108 blueprint: input.blueprint.clone(),
109 operator_id: input.operator_id.clone(),
110 role: input.role,
111 ttl: input.ttl,
112 init_ctx: input.init_ctx.clone(),
113 operator_kind: input.operator_kind,
114 bridge_id: input.bridge_id.clone(),
115 hook_id: input.hook_id.clone(),
116 operator_backend_id: input.operator_backend_id.clone(),
117 operator_kind_overrides: input.operator_kind_overrides.clone(),
118 task_input: input.task_input.clone(),
119 check_policy: input.check_policy,
120 }
121 }
122
123 fn into_input(self) -> TaskApplicationInput {
125 TaskApplicationInput {
126 blueprint: self.blueprint,
127 operator_id: self.operator_id,
128 role: self.role,
129 ttl: self.ttl,
130 init_ctx: self.init_ctx,
131 operator_kind: self.operator_kind,
132 bridge_id: self.bridge_id,
133 hook_id: self.hook_id,
134 operator_backend_id: self.operator_backend_id,
135 operator_kind_overrides: self.operator_kind_overrides,
136 task_input: self.task_input,
137 check_policy: self.check_policy,
138 }
139 }
140}
141
142pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
149 serde_json::to_string(&RunLaunchSnapshot::from_input(input))
150 .map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
151}
152
153pub(crate) async fn finalize_run(
163 state: &AppState,
164 task_id: &TaskId,
165 run_id: &RunId,
166 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
167) -> Result<TaskApplicationOutput, TaskApplicationError> {
168 match &outcome {
169 Ok(out) => {
170 if let Err(e) = state
171 .run_store
172 .set_result(run_id, out.final_ctx.clone())
173 .await
174 {
175 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
176 }
177 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
178 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
179 }
180 if let Err(e) = state
181 .task_store
182 .update_status(task_id, TaskRecordStatus::Done)
183 .await
184 {
185 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
186 }
187 }
188 Err(e) => {
189 let envelope = match e {
217 TaskApplicationError::Launch(TaskLaunchError::FlowEval {
218 message,
219 failed_step,
220 verdict_value,
221 partial_ctx,
222 }) => json!({
223 "error": {
224 "message": message,
225 "failed_step": failed_step,
226 "verdict_value": verdict_value,
227 },
228 "partial_ctx": partial_ctx,
229 }),
230 other => json!({
231 "error": {
232 "message": other.to_string(),
233 "failed_step": Value::Null,
234 "verdict_value": Value::Null,
235 },
236 "partial_ctx": Value::Null,
237 }),
238 };
239 if let Err(store_err) = state.run_store.set_result(run_id, envelope).await {
240 tracing::warn!(%run_id, error = %store_err, "finalize_run: set_result (failure envelope) failed");
241 }
242 if let Err(store_err) = state
243 .run_store
244 .update_status(run_id, RunStatus::Failed)
245 .await
246 {
247 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
248 }
249 if let Err(store_err) = state
250 .task_store
251 .update_status(task_id, TaskRecordStatus::Failed)
252 .await
253 {
254 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
255 }
256 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
257 }
258 }
259 outcome
260}
261
262#[derive(Debug, Deserialize, Default)]
264pub struct TasksListQuery {
265 #[serde(default)]
268 pub limit: Option<usize>,
269}
270
271pub async fn tasks_list(
273 State(state): State<AppState>,
274 Query(q): Query<TasksListQuery>,
275) -> Result<Json<Vec<TaskRecord>>, ApiError> {
276 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
277 if let Some(limit) = q.limit {
278 records.truncate(limit);
279 }
280 Ok(Json(records))
281}
282
283#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
285pub struct TaskDetailResponse {
286 pub task: TaskRecord,
288 pub runs: Vec<RunRecord>,
290}
291
292pub async fn task_get(
295 State(state): State<AppState>,
296 Path(id): Path<String>,
297) -> Result<Json<TaskDetailResponse>, ApiError> {
298 let task_id =
299 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
300 let task = state
301 .task_store
302 .get(&task_id)
303 .await
304 .map_err(map_task_store_err)?;
305 let runs = state
306 .run_store
307 .list_by_task(&task_id)
308 .await
309 .map_err(ApiError::engine)?;
310 Ok(Json(TaskDetailResponse { task, runs }))
311}
312
313#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
319pub struct RunKickRequest {
320 #[serde(default)]
329 #[schemars(with = "Option<Value>")]
330 pub init_ctx_override: Option<Value>,
331 #[serde(default)]
338 pub task_input_override: Option<TaskInputSpec>,
339 #[serde(default)]
345 pub timeout_secs: Option<u64>,
346 #[serde(default)]
353 pub detach: bool,
354}
355
356#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
358pub struct RunKickResponse {
359 #[schemars(with = "String")]
361 pub task_id: TaskId,
362 #[schemars(with = "String")]
364 pub run_id: RunId,
365 pub status: RunStatus,
370}
371
372pub async fn task_rekick(
399 State(state): State<AppState>,
400 Path(id): Path<String>,
401 body: Option<Json<RunKickRequest>>,
402) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
403 let task_id =
404 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
405 let task = state
406 .task_store
407 .get(&task_id)
408 .await
409 .map_err(map_task_store_err)?;
410
411 let blueprint_ref: mlua_swarm::application::BlueprintRef =
412 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
413 ApiError::bad_request(format!(
414 "task {task_id}: stored blueprint_ref failed to decode: {e}"
415 ))
416 })?;
417
418 let (resolved_bp, _bound_version) = state
424 .task_app
425 .resolve(&blueprint_ref)
426 .await
427 .map_err(|e| ApiError::from_task_resolve(&e, &format!("task {task_id}: bp resolve")))?;
428
429 let req = body.map(|Json(r)| r).unwrap_or_default();
430
431 let detach = req.detach;
441 let sync_timeout_secs = match (detach, req.timeout_secs) {
442 (true, Some(_)) => {
443 return Err(ApiError::bad_request(
444 "timeout_secs is the synchronous rekick ceiling and does not apply to a \
445 detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
446 timeout_secs"
447 .into(),
448 ));
449 }
450 (false, Some(0)) => {
451 return Err(ApiError::bad_request(
452 "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
453 ));
454 }
455 (false, Some(v)) => v,
456 (_, None) => state.sync_timeout_secs,
457 };
458
459 if resolved_bp
471 .spawner_hints
472 .layers
473 .iter()
474 .any(|l| l == "operator_delegate")
475 {
476 let attached = state.engine.list_operator_ids().await;
477 if attached.is_empty() {
478 return Err(ApiError::unavailable(format!(
479 "no operator attached to serve this rekick (task {task_id}'s \
480 Blueprint declares the operator_delegate layer): attach an \
481 operator via POST /v1/operators + WS, or use the poll-style \
482 flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
483 )));
484 }
485 }
486
487 let merged_init_ctx = merge_init_ctx_3layer(
488 resolved_bp.default_init_ctx.as_ref(),
489 &task.input_ctx,
490 req.init_ctx_override.as_ref(),
491 );
492
493 let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
497 Some(over) => Some(over),
498 None => task
499 .task_input_spec
500 .as_ref()
501 .map(|v| serde_json::from_value(v.clone()))
502 .transpose()
503 .map_err(|e| {
504 ApiError::bad_request(format!(
505 "task {task_id}: stored task_input_spec failed to decode: {e}"
506 ))
507 })?,
508 };
509
510 let run_id = RunId::new();
511 let now = now_secs();
512
513 let input = TaskApplicationInput {
514 blueprint: blueprint_ref,
515 operator_id: "http-run".to_string(),
516 role: Role::Operator,
517 ttl: Duration::from_secs(crate::default_run_ttl()),
518 init_ctx: merged_init_ctx,
519 operator_kind: None,
520 bridge_id: None,
521 hook_id: None,
522 operator_backend_id: None,
523 operator_kind_overrides: HashMap::new(),
524 task_input: task_input_spec,
525 check_policy: None,
529 };
530 let input_json = Some(snapshot_launch_input(&input)?);
535
536 state
537 .task_store
538 .update_status(&task_id, TaskRecordStatus::Running)
539 .await
540 .map_err(ApiError::engine)?;
541 state
542 .run_store
543 .create(RunRecord {
544 id: run_id.clone(),
545 task_id: task_id.clone(),
546 status: RunStatus::Running,
547 step_entries: Vec::new(),
548 degradations: Vec::new(),
549 operator_sid: None,
550 result_ref: None,
551 input_json,
552 created_at: now,
553 updated_at: now,
554 })
555 .await
556 .map_err(ApiError::engine)?;
557
558 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
559 .with_replay_store(state.replay_store.clone());
560
561 if detach {
567 let ttl_secs = crate::default_run_ttl();
568 let bg_state = state.clone();
569 let bg_task_id = task_id.clone();
570 let bg_run_id = run_id.clone();
571 tokio::spawn(async move {
572 let outcome = match tokio::time::timeout(
573 Duration::from_secs(ttl_secs),
574 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
575 )
576 .await
577 {
578 Ok(outcome) => outcome,
579 Err(_elapsed) => {
580 let reason = serde_json::json!({
581 "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
582 });
583 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
584 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
585 }
586 if let Err(e) = bg_state
587 .run_store
588 .update_status(&bg_run_id, RunStatus::Failed)
589 .await
590 {
591 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
592 }
593 if let Err(e) = bg_state
594 .task_store
595 .update_status(&bg_task_id, TaskRecordStatus::Failed)
596 .await
597 {
598 tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
599 }
600 return;
601 }
602 };
603 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
606 });
607 return Ok((
608 StatusCode::ACCEPTED,
609 Json(RunKickResponse {
610 task_id,
611 run_id,
612 status: RunStatus::Running,
613 }),
614 ));
615 }
616
617 let outcome = match tokio::time::timeout(
623 Duration::from_secs(sync_timeout_secs),
624 state.task_app.handle_with_run(input, Some(run_ctx)),
625 )
626 .await
627 {
628 Ok(outcome) => outcome,
629 Err(_elapsed) => {
630 let reason = serde_json::json!({
631 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
632 });
633 if let Err(e) = state.run_store.set_result(&run_id, reason).await {
634 tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
635 }
636 if let Err(e) = state
637 .run_store
638 .update_status(&run_id, RunStatus::Failed)
639 .await
640 {
641 tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
642 }
643 if let Err(e) = state
644 .task_store
645 .update_status(&task_id, TaskRecordStatus::Failed)
646 .await
647 {
648 tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
649 }
650 return Err(ApiError::timeout(format!(
651 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
652 )));
653 }
654 };
655 finalize_run(&state, &task_id, &run_id, outcome)
656 .await
657 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
658
659 Ok((
660 StatusCode::CREATED,
661 Json(RunKickResponse {
662 task_id,
663 run_id,
664 status: RunStatus::Done,
665 }),
666 ))
667}
668
669#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
671pub struct RunResumeResponse {
672 #[schemars(with = "String")]
677 pub run_id: RunId,
678 #[schemars(with = "String")]
680 pub task_id: TaskId,
681 pub replayed_steps: usize,
686}
687
688pub async fn run_resume(
714 State(state): State<AppState>,
715 Path(id): Path<String>,
716) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
717 let run_id =
718 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
719
720 let run = state
722 .run_store
723 .get(&run_id)
724 .await
725 .map_err(map_run_store_err)?;
726
727 if run.status != RunStatus::Interrupted {
729 return Err(ApiError::conflict(format!(
730 "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
731 run.status
732 )));
733 }
734
735 let Some(input_json) = run.input_json.clone() else {
740 return Err(ApiError::unprocessable(format!(
741 "run {run_id} cannot be resumed: no launch input was recorded for it (it \
742 predates resume support, or was created by a path that does not persist one)"
743 )));
744 };
745 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
746 ApiError::unprocessable(format!(
747 "run {run_id}: stored launch input failed to decode: {e}"
748 ))
749 })?;
750 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
751 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
752 ApiError::unprocessable(format!(
753 "run {run_id}: stored launch input failed to decode: {e}"
754 ))
755 })?;
756
757 let won = state
761 .run_store
762 .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
763 .await
764 .map_err(ApiError::engine)?;
765 if !won {
766 return Err(ApiError::conflict(format!(
767 "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
768 no longer resumable"
769 )));
770 }
771
772 let entries = state
776 .replay_store
777 .list_by_run(&run_id)
778 .await
779 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
780 let replayed_steps = entries.len();
781 let cursor = ReplayCursor::from_entries(entries);
782
783 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
788 .with_replay_store(state.replay_store.clone())
789 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
790 .with_resume();
791
792 let input = snapshot.into_input();
793 let task_id = run.task_id.clone();
794
795 state
798 .task_store
799 .update_status(&task_id, TaskRecordStatus::Running)
800 .await
801 .map_err(ApiError::engine)?;
802
803 let ttl_secs = crate::default_run_ttl();
807 let bg_state = state.clone();
808 let bg_task_id = task_id.clone();
809 let bg_run_id = run_id.clone();
810 tokio::spawn(async move {
811 let outcome = match tokio::time::timeout(
812 Duration::from_secs(ttl_secs),
813 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
814 )
815 .await
816 {
817 Ok(outcome) => outcome,
818 Err(_elapsed) => {
819 let reason = serde_json::json!({
820 "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
821 });
822 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
823 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
824 }
825 if let Err(e) = bg_state
826 .run_store
827 .update_status(&bg_run_id, RunStatus::Failed)
828 .await
829 {
830 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
831 }
832 if let Err(e) = bg_state
833 .task_store
834 .update_status(&bg_task_id, TaskRecordStatus::Failed)
835 .await
836 {
837 tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
838 }
839 return;
840 }
841 };
842 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
844 });
845
846 Ok((
847 StatusCode::ACCEPTED,
848 Json(RunResumeResponse {
849 run_id,
850 task_id,
851 replayed_steps,
852 }),
853 ))
854}
855
856#[derive(Debug, Deserialize, schemars::JsonSchema)]
858pub struct RunRerunFromRequest {
859 pub from_step: String,
866}
867
868#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
870pub struct RunRerunFromResponse {
871 #[schemars(with = "String")]
876 pub run_id: RunId,
877 #[schemars(with = "String")]
879 pub task_id: TaskId,
880 pub replayed_steps: usize,
884 pub dropped_steps: usize,
887}
888
889pub async fn run_rerun_from(
964 State(state): State<AppState>,
965 Path(id): Path<String>,
966 Json(req): Json<RunRerunFromRequest>,
967) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
968 let run_id =
969 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
970
971 if req.from_step.trim().is_empty() {
972 return Err(ApiError::bad_request(
973 "from_step must be a non-empty step ref".to_string(),
974 ));
975 }
976
977 let run = state
979 .run_store
980 .get(&run_id)
981 .await
982 .map_err(map_run_store_err)?;
983
984 let current = run.status;
987 match current {
988 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => { }
989 RunStatus::Running | RunStatus::Pending => {
990 return Err(ApiError::conflict(format!(
991 "run {run_id} is {current:?}; rerun-from requires a terminal run \
992 (Done / Failed / Interrupted)"
993 )));
994 }
995 }
996
997 let Some(input_json) = run.input_json.clone() else {
1002 return Err(ApiError::unprocessable(format!(
1003 "run {run_id} cannot be rerun: no launch input was recorded for it (it \
1004 predates resume/rerun support, or was created by a path that does not \
1005 persist one)"
1006 )));
1007 };
1008 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
1009 ApiError::unprocessable(format!(
1010 "run {run_id}: stored launch input failed to decode: {e}"
1011 ))
1012 })?;
1013 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
1014 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
1015 ApiError::unprocessable(format!(
1016 "run {run_id}: stored launch input failed to decode: {e}"
1017 ))
1018 })?;
1019
1020 let entries = state
1023 .replay_store
1024 .list_by_run(&run_id)
1025 .await
1026 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
1027 let cut = entries
1028 .iter()
1029 .position(|e| e.step_ref == req.from_step)
1030 .ok_or_else(|| {
1031 if entries.is_empty() && !run.step_entries.is_empty() {
1041 ApiError::unprocessable(format!(
1042 "run {run_id}: replay log is empty but {} step entries are traced \
1043 on the RunRecord — the log was consumed by a prior rerun-from \
1044 that reached the truncate stage. This run can no longer be \
1045 rerun-from; start a fresh run via POST /v1/tasks.",
1046 run.step_entries.len()
1047 ))
1048 } else {
1049 ApiError::unprocessable(format!(
1050 "run {run_id}: from_step {:?} not present in this run's replay log \
1051 (nothing to rerun-from)",
1052 req.from_step
1053 ))
1054 }
1055 })?;
1056
1057 if let Err(e) = state.task_app.precompile(&snapshot.blueprint).await {
1071 return Err(ApiError::unprocessable(format!(
1072 "run {run_id} cannot be rerun: current-head Blueprint fails to compile — {e}"
1073 )));
1074 }
1075
1076 let won = state
1081 .run_store
1082 .try_transition(&run_id, current, RunStatus::Running)
1083 .await
1084 .map_err(ApiError::engine)?;
1085 if !won {
1086 return Err(ApiError::conflict(format!(
1087 "run {run_id} was concurrently transitioned (or left the {current:?} state); \
1088 it is no longer rerunnable"
1089 )));
1090 }
1091
1092 let dropped_steps = state
1097 .replay_store
1098 .delete_from(&run_id, cut)
1099 .await
1100 .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
1101
1102 let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
1105 let replayed_steps = kept.len();
1106 let cursor = ReplayCursor::from_entries(kept);
1107
1108 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1112 .with_replay_store(state.replay_store.clone())
1113 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1114 .with_resume();
1115
1116 let input = snapshot.into_input();
1117 let task_id = run.task_id.clone();
1118
1119 state
1122 .task_store
1123 .update_status(&task_id, TaskRecordStatus::Running)
1124 .await
1125 .map_err(ApiError::engine)?;
1126
1127 let ttl_secs = crate::default_run_ttl();
1128 let bg_state = state.clone();
1129 let bg_task_id = task_id.clone();
1130 let bg_run_id = run_id.clone();
1131 tokio::spawn(async move {
1132 let outcome = match tokio::time::timeout(
1133 Duration::from_secs(ttl_secs),
1134 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1135 )
1136 .await
1137 {
1138 Ok(outcome) => outcome,
1139 Err(_elapsed) => {
1140 let reason = serde_json::json!({
1141 "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1142 });
1143 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1144 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1145 }
1146 if let Err(e) = bg_state
1147 .run_store
1148 .update_status(&bg_run_id, RunStatus::Failed)
1149 .await
1150 {
1151 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1152 }
1153 if let Err(e) = bg_state
1154 .task_store
1155 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1156 .await
1157 {
1158 tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1159 }
1160 return;
1161 }
1162 };
1163 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1164 });
1165
1166 Ok((
1167 StatusCode::ACCEPTED,
1168 Json(RunRerunFromResponse {
1169 run_id,
1170 task_id,
1171 replayed_steps,
1172 dropped_steps,
1173 }),
1174 ))
1175}
1176
1177pub async fn run_get(
1180 State(state): State<AppState>,
1181 Path(id): Path<String>,
1182) -> Result<Json<RunRecord>, ApiError> {
1183 let run_id =
1184 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1185 let run = state
1186 .run_store
1187 .get(&run_id)
1188 .await
1189 .map_err(map_run_store_err)?;
1190 Ok(Json(run))
1191}
1192
1193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1196#[serde(rename_all = "snake_case")]
1197pub enum RunBindingStatus {
1198 DeclarationOnly,
1201 Attested,
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1207pub struct RunBindingDifference {
1208 pub model_changed: bool,
1210 pub missing_requested_tools: Vec<String>,
1213 pub additional_effective_tools: Vec<String>,
1215 pub launch_variant_changed: bool,
1217}
1218
1219#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1222pub struct RunBindingExplainEntry {
1223 pub agent: String,
1225 pub runner_source: mlua_swarm::blueprint::RunnerResolutionSource,
1227 pub status: RunBindingStatus,
1229 pub requested: Option<BindRequest>,
1231 pub effective: Option<BindingAttestation>,
1233 pub difference: Option<RunBindingDifference>,
1236 pub binding_digest: mlua_swarm::blueprint::BindingDigest,
1238}
1239
1240#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1242pub struct RunBindingsExplainResponse {
1243 #[schemars(with = "String")]
1245 pub run_id: RunId,
1246 #[schemars(with = "String")]
1248 pub task_id: TaskId,
1249 pub snapshot_origin: SnapshotOrigin,
1257 pub bindings: Vec<RunBindingExplainEntry>,
1259}
1260
1261fn requested_binding(bound: &BoundAgent) -> Option<BindRequest> {
1262 mlua_swarm::binding_request_for_snapshot(bound)
1263}
1264
1265fn binding_difference(
1266 requested: &BindRequest,
1267 effective: &BindingAttestation,
1268) -> RunBindingDifference {
1269 let missing_requested_tools = requested
1270 .requested_tools
1271 .iter()
1272 .filter(|tool| !effective.effective_tools.contains(tool))
1273 .cloned()
1274 .collect();
1275 let additional_effective_tools = effective
1276 .effective_tools
1277 .iter()
1278 .filter(|tool| !requested.requested_tools.contains(tool))
1279 .cloned()
1280 .collect();
1281 RunBindingDifference {
1282 model_changed: requested.requested_model != effective.resolved_model,
1283 missing_requested_tools,
1284 additional_effective_tools,
1285 launch_variant_changed: requested.launch_variant != effective.launch_variant,
1286 }
1287}
1288
1289fn validated_bound_agents_from_snapshot(
1290 run_id: &RunId,
1291 snapshot: &Value,
1292) -> Result<Option<Vec<BoundAgent>>, ApiError> {
1293 let Some(bound_value) = snapshot.get("bound_agents") else {
1294 return Ok(None);
1295 };
1296 let bound_agents: Vec<BoundAgent> =
1297 serde_json::from_value(bound_value.clone()).map_err(|e| {
1298 ApiError::unprocessable(format!(
1299 "run {run_id} contains an invalid binding snapshot: {e}"
1300 ))
1301 })?;
1302 validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
1303 ApiError::unprocessable(format!(
1304 "run {run_id} contains an inconsistent binding snapshot: {error}"
1305 ))
1306 })?;
1307 Ok(Some(bound_agents))
1308}
1309
1310pub async fn run_bindings_explain(
1314 State(state): State<AppState>,
1315 Path(id): Path<String>,
1316) -> Result<Json<RunBindingsExplainResponse>, ApiError> {
1317 let run_id =
1318 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1319 let run = state
1320 .run_store
1321 .get(&run_id)
1322 .await
1323 .map_err(map_run_store_err)?;
1324 let input_json = run.input_json.as_deref().ok_or_else(|| {
1325 ApiError::unprocessable(format!(
1326 "run {run_id} has no launch snapshot; binding explain is unavailable"
1327 ))
1328 })?;
1329 let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
1330 ApiError::unprocessable(format!(
1331 "run {run_id} launch snapshot is invalid JSON; binding explain is unavailable: {e}"
1332 ))
1333 })?;
1334 let bound_agents = validated_bound_agents_from_snapshot(&run_id, &snapshot)?.ok_or_else(|| {
1335 ApiError::unprocessable(format!(
1336 "run {run_id} predates immutable binding snapshots; current Blueprint state was not consulted"
1337 ))
1338 })?;
1339
1340 let bindings = bound_agents
1341 .into_iter()
1342 .map(|bound| {
1343 let requested = requested_binding(&bound);
1344 let effective = bound.attestation.clone();
1345 let difference = requested
1346 .as_ref()
1347 .zip(effective.as_ref())
1348 .map(|(request, attestation)| binding_difference(request, attestation));
1349 RunBindingExplainEntry {
1350 agent: bound.agent.name,
1351 runner_source: bound.runner_source,
1352 status: if effective.is_some() {
1353 RunBindingStatus::Attested
1354 } else {
1355 RunBindingStatus::DeclarationOnly
1356 },
1357 requested,
1358 effective,
1359 difference,
1360 binding_digest: bound.binding_digest,
1361 }
1362 })
1363 .collect();
1364
1365 Ok(Json(RunBindingsExplainResponse {
1366 run_id: run.id,
1367 task_id: run.task_id,
1368 snapshot_origin: SnapshotOrigin::from_snapshot(&snapshot),
1369 bindings,
1370 }))
1371}
1372
1373pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1377 match e {
1378 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1379 other => ApiError::engine(other),
1380 }
1381}
1382
1383fn map_run_store_err(e: RunStoreError) -> ApiError {
1384 match e {
1385 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1386 other => ApiError::engine(other),
1387 }
1388}
1389
1390#[cfg(test)]
1395mod tests {
1396 use super::*;
1397 use mlua_swarm::application::BlueprintRef;
1398 use mlua_swarm::blueprint::{
1399 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1400 CompilerStrategy, Runner,
1401 };
1402 use mlua_swarm::core::config::EngineCfg;
1403 use mlua_swarm::core::engine::Engine;
1404 use mlua_swarm::store::output::InMemoryOutputStore;
1405 use mlua_swarm::store::run::InMemoryRunStore;
1406 use mlua_swarm::store::task::InMemoryTaskStore;
1407 use std::collections::HashMap;
1408 use std::sync::Arc;
1409 use tokio::sync::Mutex;
1410
1411 fn identity_blueprint() -> Blueprint {
1417 Blueprint {
1418 schema_version: current_schema_version(),
1419 id: "tasks-test-bp".into(),
1420 flow: serde_json::from_value(serde_json::json!({
1421 "kind": "step",
1422 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1423 "in": {"op": "lit", "value": "hello"},
1424 "out": {"op": "path", "at": "$.out"},
1425 }))
1426 .expect("flow parse"),
1427 agents: vec![AgentDef {
1428 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1429 kind: AgentKind::RustFn,
1430 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1431 profile: None,
1432 meta: None,
1433 runner: None,
1434 runner_ref: None,
1435 verdict: None,
1436 }],
1437 operators: vec![],
1438 metas: vec![],
1439 hints: CompilerHints::default(),
1440 strategy: CompilerStrategy::default(),
1441 metadata: BlueprintMetadata::default(),
1442 spawner_hints: Default::default(),
1443 default_agent_kind: AgentKind::Operator,
1444 default_operator_kind: None,
1445 default_init_ctx: None,
1446 default_agent_ctx: None,
1447 default_context_policy: None,
1448 projection_placement: None,
1449 audits: vec![],
1450 degradation_policy: None,
1451 runners: vec![],
1452 default_runner: None,
1453 subprocesses: vec![],
1454 check_policy: None,
1455 blueprint_ref_includes: Vec::new(),
1456 }
1457 }
1458
1459 fn test_state() -> AppState {
1464 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1465 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1466 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1467 AppState {
1468 engine,
1469 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1470 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1471 ws_operator_factory: None,
1472 data_store: Arc::new(InMemoryOutputStore::new()),
1473 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1474 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1475 task_store: Arc::new(InMemoryTaskStore::new()),
1476 run_store: Arc::new(InMemoryRunStore::new()),
1477 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1478 base_url: None,
1479 sync_timeout_secs: 300,
1480 }
1481 }
1482
1483 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
1484 crate::TaskLaunchRequest {
1485 blueprint: BlueprintRef::Inline {
1486 value: Box::new(identity_blueprint()),
1487 },
1488 init_ctx: serde_json::json!({"in": "hello"}),
1489 project_root: None,
1490 work_dir: None,
1491 task_metadata: None,
1492 ttl_secs: None,
1493 operator: None,
1494 operator_sid: None,
1495 timeout_secs: None,
1496 goal: Some(goal.to_string()),
1497 detach: false,
1498 check_policy: None,
1499 }
1500 }
1501
1502 #[test]
1503 fn task_id_serializes_as_bare_string() {
1504 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
1508 assert_eq!(v, serde_json::json!("T-abc"));
1509 }
1510
1511 #[tokio::test]
1512 async fn post_then_get_drill_down() {
1513 let state = test_state();
1514
1515 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
1516 .await
1517 .expect("tasks_start")
1518 .0;
1519 let task_id = posted.task_id.clone();
1520 let run_id = posted.run_id.clone();
1521
1522 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
1524 .await
1525 .expect("tasks_list")
1526 .0;
1527 assert!(
1528 list.iter().any(|t| t.id == task_id),
1529 "task {task_id} missing from list of {} tasks",
1530 list.len()
1531 );
1532
1533 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1535 .await
1536 .expect("task_get")
1537 .0;
1538 assert_eq!(detail.task.id, task_id);
1539 assert_eq!(detail.task.goal, "smoke goal");
1540 assert_eq!(detail.task.status, TaskRecordStatus::Done);
1541 assert_eq!(detail.runs.len(), 1);
1542 assert_eq!(detail.runs[0].id, run_id);
1543 assert_eq!(detail.runs[0].status, RunStatus::Done);
1544
1545 let run = run_get(State(state.clone()), Path(run_id.to_string()))
1547 .await
1548 .expect("run_get")
1549 .0;
1550 assert_eq!(run.id, run_id);
1551 assert_eq!(run.task_id, task_id);
1552 assert_eq!(run.result_ref, Some(posted.final_ctx));
1553
1554 assert_eq!(
1558 run.step_entries.len(),
1559 1,
1560 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
1561 run.step_entries
1562 );
1563 assert_eq!(
1564 run.step_entries[0].step_ref,
1565 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1566 );
1567 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1568 }
1569
1570 fn identity_blueprint_with_operator_delegate() -> Blueprint {
1582 Blueprint {
1583 spawner_hints: mlua_swarm::SpawnerHints {
1584 layers: vec!["operator_delegate".to_string()],
1585 },
1586 ..identity_blueprint()
1587 }
1588 }
1589
1590 struct StallingOperator;
1593
1594 #[async_trait::async_trait]
1595 impl mlua_swarm::Operator for StallingOperator {
1596 async fn execute(
1597 &self,
1598 _ctx: &mlua_swarm::Ctx,
1599 _system: Option<String>,
1600 _prompt: Value,
1601 _worker: Option<mlua_swarm::WorkerBinding>,
1602 _worker_token: mlua_swarm::CapToken,
1603 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1604 std::future::pending::<()>().await;
1605 unreachable!("StallingOperator.execute must never resolve")
1606 }
1607 }
1608
1609 fn operator_launch_req(
1613 backend_id: &str,
1614 timeout_secs: Option<u64>,
1615 ) -> crate::TaskLaunchRequest {
1616 crate::TaskLaunchRequest {
1617 blueprint: BlueprintRef::Inline {
1618 value: Box::new(identity_blueprint_with_operator_delegate()),
1619 },
1620 init_ctx: serde_json::json!({"in": "hello"}),
1621 project_root: None,
1622 work_dir: None,
1623 task_metadata: None,
1624 ttl_secs: None,
1625 operator: Some(crate::OperatorReq {
1626 operator_backend_id: Some(backend_id.to_string()),
1627 ..Default::default()
1628 }),
1629 operator_sid: None,
1630 timeout_secs,
1631 goal: Some("operator delegate test goal".to_string()),
1632 detach: false,
1633 check_policy: None,
1634 }
1635 }
1636
1637 #[tokio::test]
1641 async fn sync_launch_zero_operators_fails_fast() {
1642 let state = test_state();
1643 let req = operator_launch_req("nonexistent-op", None);
1646
1647 let started = std::time::Instant::now();
1648 let result = crate::tasks_start(State(state), Json(req)).await;
1649 let elapsed = started.elapsed();
1650
1651 let err = match result {
1652 Err(e) => e,
1653 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1654 };
1655 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1656 assert!(
1657 err.message.contains("no operator attached"),
1658 "error message must mention the missing operator: {}",
1659 err.message
1660 );
1661 assert!(
1662 elapsed < Duration::from_secs(1),
1663 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1664 );
1665 }
1666
1667 #[tokio::test]
1671 async fn sync_launch_stalled_times_out() {
1672 let state = test_state();
1673 state
1674 .engine
1675 .register_operator("stall-op", Arc::new(StallingOperator))
1676 .await;
1677 let req = operator_launch_req("stall-op", Some(1));
1678
1679 let started = std::time::Instant::now();
1680 let result = tokio::time::timeout(
1684 Duration::from_secs(5),
1685 crate::tasks_start(State(state), Json(req)),
1686 )
1687 .await
1688 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1689 let elapsed = started.elapsed();
1690
1691 let err = match result {
1692 Err(e) => e,
1693 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1694 };
1695 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1696 assert!(
1697 err.message.contains('1'),
1698 "error message must mention the configured 1s ceiling: {}",
1699 err.message
1700 );
1701 assert!(
1702 elapsed < Duration::from_secs(3),
1703 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1704 );
1705 }
1706
1707 #[tokio::test]
1711 async fn sync_launch_without_operator_path_unaffected() {
1712 let state = test_state();
1713 let result = crate::tasks_start(
1714 State(state),
1715 Json(post_tasks_req("non-operator launch goal")),
1716 )
1717 .await;
1718 if let Err(e) = &result {
1719 panic!(
1720 "non-operator launch must succeed unaffected by guard 1: {}",
1721 e.message
1722 );
1723 }
1724 }
1725
1726 #[tokio::test]
1730 async fn sync_launch_zero_timeout_secs_rejected() {
1731 let state = test_state();
1732 let mut req = post_tasks_req("zero timeout goal");
1733 req.timeout_secs = Some(0);
1734
1735 let result = crate::tasks_start(State(state), Json(req)).await;
1736 let err = match result {
1737 Err(e) => e,
1738 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1739 };
1740 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1741 assert!(
1742 err.message.contains("timeout_secs"),
1743 "error message must reference timeout_secs: {}",
1744 err.message
1745 );
1746 }
1747
1748 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1757 for _ in 0..50 {
1758 let rec = state.run_store.get(run_id).await.expect("run get");
1759 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1760 return rec;
1761 }
1762 tokio::time::sleep(Duration::from_millis(100)).await;
1763 }
1764 panic!("run {run_id} did not reach a terminal status within ~5s");
1765 }
1766
1767 #[tokio::test]
1773 async fn detached_launch_returns_202_and_completes_in_background() {
1774 let state = test_state();
1775 let mut req = post_tasks_req("detached goal");
1776 req.detach = true;
1777
1778 let reply = crate::tasks_start(State(state.clone()), Json(req))
1779 .await
1780 .expect("tasks_start (detached)");
1781 assert_eq!(reply.1, StatusCode::ACCEPTED);
1782 let posted = reply.0;
1783 assert_eq!(posted.status, RunStatus::Running);
1784 assert_eq!(
1785 posted.final_ctx,
1786 serde_json::Value::Null,
1787 "a detached launch has no final_ctx at response time"
1788 );
1789
1790 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1791 assert_eq!(rec.status, RunStatus::Done);
1792 assert!(
1793 rec.result_ref.is_some(),
1794 "finalize_run must persist the background eval's final_ctx"
1795 );
1796 assert_eq!(
1797 rec.step_entries.len(),
1798 1,
1799 "the background eval must trace its step_entries like the sync path: {:?}",
1800 rec.step_entries
1801 );
1802 let task = state
1803 .task_store
1804 .get(&posted.task_id)
1805 .await
1806 .expect("task get");
1807 assert_eq!(task.status, TaskRecordStatus::Done);
1808 }
1809
1810 #[tokio::test]
1814 async fn detached_launch_with_timeout_secs_rejected() {
1815 let state = test_state();
1816 let mut req = post_tasks_req("detached + ceiling goal");
1817 req.detach = true;
1818 req.timeout_secs = Some(60);
1819
1820 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1821 Err(e) => e,
1822 Ok(_) => panic!("detach + timeout_secs must be rejected"),
1823 };
1824 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1825 assert!(
1826 err.message.contains("detach"),
1827 "error message must explain the detach/timeout_secs conflict: {}",
1828 err.message
1829 );
1830 let tasks = state.task_store.list().await.expect("task list");
1831 assert!(
1832 tasks.is_empty(),
1833 "the 400 must fire before any TaskRecord is minted"
1834 );
1835 }
1836
1837 #[tokio::test]
1841 async fn rekick_detached_returns_202_and_completes_in_background() {
1842 let state = test_state();
1843 let posted = crate::tasks_start(
1844 State(state.clone()),
1845 Json(post_tasks_req("detached rekick goal")),
1846 )
1847 .await
1848 .expect("tasks_start")
1849 .0;
1850
1851 let (status, rekicked) = task_rekick(
1852 State(state.clone()),
1853 Path(posted.task_id.to_string()),
1854 Some(Json(RunKickRequest {
1855 init_ctx_override: None,
1856 task_input_override: None,
1857 timeout_secs: None,
1858 detach: true,
1859 })),
1860 )
1861 .await
1862 .expect("task_rekick (detached)");
1863 assert_eq!(status, StatusCode::ACCEPTED);
1864 assert_eq!(rekicked.0.status, RunStatus::Running);
1865 assert_ne!(rekicked.0.run_id, posted.run_id);
1866
1867 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1868 assert_eq!(rec.status, RunStatus::Done);
1869 assert!(
1870 rec.result_ref.is_some(),
1871 "finalize_run must persist the background rekick's final_ctx"
1872 );
1873 }
1874
1875 #[tokio::test]
1879 async fn rekick_detached_with_timeout_secs_rejected() {
1880 let state = test_state();
1881 let posted = crate::tasks_start(
1882 State(state.clone()),
1883 Json(post_tasks_req("detached rekick ceiling goal")),
1884 )
1885 .await
1886 .expect("tasks_start")
1887 .0;
1888
1889 let err = match task_rekick(
1890 State(state.clone()),
1891 Path(posted.task_id.to_string()),
1892 Some(Json(RunKickRequest {
1893 init_ctx_override: None,
1894 task_input_override: None,
1895 timeout_secs: Some(60),
1896 detach: true,
1897 })),
1898 )
1899 .await
1900 {
1901 Err(e) => e,
1902 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1903 };
1904 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1905 assert!(
1906 err.message.contains("detach"),
1907 "error message must explain the detach/timeout_secs conflict: {}",
1908 err.message
1909 );
1910 let runs = state
1911 .run_store
1912 .list_by_task(&posted.task_id)
1913 .await
1914 .expect("runs list");
1915 assert_eq!(
1916 runs.len(),
1917 1,
1918 "the 400 must fire before a second Run is minted"
1919 );
1920 }
1921
1922 #[tokio::test]
1923 async fn rekick_adds_a_second_run_to_the_same_task() {
1924 let state = test_state();
1925 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1926 .await
1927 .expect("tasks_start")
1928 .0;
1929 let task_id = posted.task_id.clone();
1930 let first_run_id = posted.run_id.clone();
1931
1932 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1933 .await
1934 .expect("task_rekick");
1935 assert_eq!(status, StatusCode::CREATED);
1936 let second_run_id = rekicked.0.run_id.clone();
1937 assert_ne!(first_run_id, second_run_id);
1938
1939 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1940 .await
1941 .expect("task_get")
1942 .0;
1943 assert_eq!(
1944 detail.runs.len(),
1945 2,
1946 "expected 2 runs, got {:?}",
1947 detail.runs
1948 );
1949 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1950 assert!(ids.contains(&&first_run_id));
1951 assert!(ids.contains(&&second_run_id));
1952
1953 let first_run = detail
1958 .runs
1959 .iter()
1960 .find(|r| r.id == first_run_id)
1961 .expect("first run present in detail.runs");
1962 let second_run = detail
1963 .runs
1964 .iter()
1965 .find(|r| r.id == second_run_id)
1966 .expect("second run present in detail.runs");
1967 assert_eq!(
1968 first_run.step_entries.len(),
1969 1,
1970 "first run step_entries: {:?}",
1971 first_run.step_entries
1972 );
1973 assert_eq!(
1974 second_run.step_entries.len(),
1975 1,
1976 "second run step_entries: {:?}",
1977 second_run.step_entries
1978 );
1979 assert_eq!(
1980 first_run.step_entries[0].step_ref,
1981 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1982 );
1983 assert_eq!(
1984 second_run.step_entries[0].step_ref,
1985 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1986 );
1987 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1988 assert_eq!(
1989 second_run.step_entries[0].status,
1990 Some("passed".to_string())
1991 );
1992 assert_ne!(
1993 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1994 "each kick dispatches its own StepId — runs must not share step_entries"
1995 );
1996 }
1997
1998 #[tokio::test]
1999 async fn rekick_unknown_task_returns_404() {
2000 let state = test_state();
2001 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
2005 Ok(_) => panic!("expected 404 for an unknown task"),
2006 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2007 }
2008 }
2009
2010 fn greeting_blueprint() -> Blueprint {
2019 Blueprint {
2020 schema_version: current_schema_version(),
2021 id: "tasks-test-greeting-bp".into(),
2022 flow: serde_json::from_value(serde_json::json!({
2023 "kind": "step",
2024 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
2025 "in": {"op": "path", "at": "$.greeting"},
2026 "out": {"op": "path", "at": "$.out"},
2027 }))
2028 .expect("flow parse"),
2029 agents: vec![AgentDef {
2030 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
2031 kind: AgentKind::RustFn,
2032 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
2033 profile: None,
2034 meta: None,
2035 runner: None,
2036 runner_ref: None,
2037 verdict: None,
2038 }],
2039 operators: vec![],
2040 metas: vec![],
2041 hints: CompilerHints::default(),
2042 strategy: CompilerStrategy::default(),
2043 metadata: BlueprintMetadata::default(),
2044 spawner_hints: Default::default(),
2045 default_agent_kind: AgentKind::Operator,
2046 default_operator_kind: None,
2047 default_init_ctx: None,
2048 default_agent_ctx: None,
2049 default_context_policy: None,
2050 projection_placement: None,
2051 audits: vec![],
2052 degradation_policy: None,
2053 runners: vec![],
2054 default_runner: None,
2055 subprocesses: vec![],
2056 check_policy: None,
2057 blueprint_ref_includes: Vec::new(),
2058 }
2059 }
2060
2061 fn post_greeting_task_req(
2062 greeting: &str,
2063 project_root: Option<&str>,
2064 ) -> crate::TaskLaunchRequest {
2065 crate::TaskLaunchRequest {
2066 blueprint: BlueprintRef::Inline {
2067 value: Box::new(greeting_blueprint()),
2068 },
2069 init_ctx: serde_json::json!({ "greeting": greeting }),
2070 project_root: project_root.map(str::to_string),
2071 work_dir: None,
2072 task_metadata: None,
2073 ttl_secs: None,
2074 operator: None,
2075 operator_sid: None,
2076 timeout_secs: None,
2077 goal: Some("st4 rekick goal".to_string()),
2078 detach: false,
2079 check_policy: None,
2080 }
2081 }
2082
2083 #[tokio::test]
2084 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2085 let state = test_state();
2088 let posted = crate::tasks_start(
2089 State(state.clone()),
2090 Json(post_greeting_task_req("from-task", None)),
2091 )
2092 .await
2093 .expect("tasks_start")
2094 .0;
2095 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2096
2097 let (status, rekicked) =
2098 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2099 .await
2100 .expect("task_rekick");
2101 assert_eq!(status, StatusCode::CREATED);
2102
2103 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2104 .await
2105 .expect("run_get")
2106 .0;
2107 assert_eq!(
2108 run.result_ref.expect("result_ref present")["out"]["echoed"],
2109 "from-task"
2110 );
2111 }
2112
2113 #[tokio::test]
2114 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2115 let state = test_state();
2116 let posted = crate::tasks_start(
2117 State(state.clone()),
2118 Json(post_greeting_task_req("from-task", None)),
2119 )
2120 .await
2121 .expect("tasks_start")
2122 .0;
2123 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2124
2125 let (status, rekicked) = task_rekick(
2126 State(state.clone()),
2127 Path(posted.task_id.to_string()),
2128 Some(Json(RunKickRequest {
2129 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2130 task_input_override: None,
2131 timeout_secs: None,
2132 detach: false,
2133 })),
2134 )
2135 .await
2136 .expect("task_rekick");
2137 assert_eq!(status, StatusCode::CREATED);
2138
2139 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2140 .await
2141 .expect("run_get")
2142 .0;
2143 assert_eq!(
2144 run.result_ref.expect("result_ref present")["out"]["echoed"],
2145 "from-run",
2146 "Run's init_ctx_override must win over the stored Task input_ctx"
2147 );
2148 }
2149
2150 #[tokio::test]
2151 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2152 let state = test_state();
2160 let posted = crate::tasks_start(
2161 State(state.clone()),
2162 Json(post_greeting_task_req("from-task", Some("/repo"))),
2163 )
2164 .await
2165 .expect("tasks_start")
2166 .0;
2167
2168 let before = state
2169 .task_store
2170 .get(&posted.task_id)
2171 .await
2172 .expect("task fetch");
2173 let before_spec: Option<TaskInputSpec> = before
2174 .task_input_spec
2175 .as_ref()
2176 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2177 assert_eq!(
2178 before_spec,
2179 Some(TaskInputSpec {
2180 project_root: Some("/repo".to_string()),
2181 work_dir: None,
2182 task_metadata: None,
2183 })
2184 );
2185
2186 let (status, _rekicked) =
2187 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2188 .await
2189 .expect("task_rekick");
2190 assert_eq!(status, StatusCode::CREATED);
2191
2192 let after = state
2193 .task_store
2194 .get(&posted.task_id)
2195 .await
2196 .expect("task fetch");
2197 assert_eq!(
2198 after.task_input_spec, before.task_input_spec,
2199 "rekick must not mutate the stored Task-level task_input_spec snapshot"
2200 );
2201 }
2202
2203 #[tokio::test]
2204 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2205 let state = test_state();
2208 let posted = crate::tasks_start(
2209 State(state.clone()),
2210 Json(post_greeting_task_req("from-task", Some("/repo"))),
2211 )
2212 .await
2213 .expect("tasks_start")
2214 .0;
2215
2216 let (status, _rekicked) = task_rekick(
2217 State(state.clone()),
2218 Path(posted.task_id.to_string()),
2219 Some(Json(RunKickRequest {
2220 init_ctx_override: None,
2221 task_input_override: Some(TaskInputSpec {
2222 project_root: Some("/override".to_string()),
2223 work_dir: None,
2224 task_metadata: None,
2225 }),
2226 timeout_secs: None,
2227 detach: false,
2228 })),
2229 )
2230 .await
2231 .expect("task_rekick");
2232 assert_eq!(status, StatusCode::CREATED);
2233
2234 let after = state
2235 .task_store
2236 .get(&posted.task_id)
2237 .await
2238 .expect("task fetch");
2239 let after_spec: Option<TaskInputSpec> = after
2240 .task_input_spec
2241 .as_ref()
2242 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2243 assert_eq!(
2244 after_spec,
2245 Some(TaskInputSpec {
2246 project_root: Some("/repo".to_string()),
2247 work_dir: None,
2248 task_metadata: None,
2249 }),
2250 "a per-Run task_input_override must not leak into the stored TaskRecord"
2251 );
2252 }
2253
2254 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
2268 crate::TaskLaunchRequest {
2269 blueprint: BlueprintRef::Inline {
2270 value: Box::new(identity_blueprint_with_operator_delegate()),
2271 },
2272 init_ctx: serde_json::json!({"in": "hello"}),
2273 project_root: None,
2274 work_dir: None,
2275 task_metadata: None,
2276 ttl_secs: None,
2277 operator: None,
2278 operator_sid: None,
2279 timeout_secs: None,
2280 goal: Some(goal.to_string()),
2281 detach: false,
2282 check_policy: None,
2283 }
2284 }
2285
2286 #[tokio::test]
2291 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
2292 let state = test_state();
2293 let posted = crate::tasks_start(
2294 State(state.clone()),
2295 Json(delegate_launch_req("operator delegate rekick goal")),
2296 )
2297 .await
2298 .expect("tasks_start (no operator referenced, dispatches through baseline)")
2299 .0;
2300 let started = std::time::Instant::now();
2304 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2305 let elapsed = started.elapsed();
2306
2307 let err = match result {
2308 Err(e) => e,
2309 Ok(_) => panic!(
2310 "rekicking a Task whose Blueprint declares operator_delegate with zero \
2311 attached operators must fail, not dispatch"
2312 ),
2313 };
2314 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2315 assert!(
2316 err.message.contains("no operator attached"),
2317 "error message must mention the missing operator: {}",
2318 err.message
2319 );
2320 assert!(
2321 elapsed < Duration::from_secs(1),
2322 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2323 );
2324 }
2325
2326 #[tokio::test]
2330 async fn rekick_stalled_operator_times_out() {
2331 let state = test_state();
2332 state
2333 .engine
2334 .register_operator("stall-op", Arc::new(StallingOperator))
2335 .await;
2336 let posted = crate::tasks_start(
2337 State(state.clone()),
2338 Json(delegate_launch_req("stalled rekick goal")),
2339 )
2340 .await
2341 .expect("tasks_start")
2342 .0;
2343
2344 let started = std::time::Instant::now();
2345 let result = tokio::time::timeout(
2349 Duration::from_secs(5),
2350 task_rekick(
2351 State(state),
2352 Path(posted.task_id.to_string()),
2353 Some(Json(RunKickRequest {
2354 init_ctx_override: None,
2355 task_input_override: None,
2356 timeout_secs: Some(1),
2357 detach: false,
2358 })),
2359 ),
2360 )
2361 .await
2362 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
2363 let elapsed = started.elapsed();
2364
2365 match &result {
2366 Err(e) => {
2367 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
2368 assert!(
2369 e.message.contains('1'),
2370 "error message must mention the configured 1s ceiling: {}",
2371 e.message
2372 );
2373 assert!(
2374 elapsed < Duration::from_secs(3),
2375 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2376 );
2377 }
2378 Ok(_) => {
2379 assert!(
2391 elapsed < Duration::from_secs(1),
2392 "a rekick that never engages an Operator (task_rekick has no \
2393 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
2394 );
2395 }
2396 }
2397 }
2398
2399 #[tokio::test]
2403 async fn rekick_timeout_secs_zero_rejected() {
2404 let state = test_state();
2405 let posted = crate::tasks_start(
2406 State(state.clone()),
2407 Json(post_tasks_req("zero timeout rekick goal")),
2408 )
2409 .await
2410 .expect("tasks_start")
2411 .0;
2412
2413 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2414 .await
2415 .expect("task_get")
2416 .0;
2417 let runs_before = before.runs.len();
2418
2419 let result = task_rekick(
2420 State(state.clone()),
2421 Path(posted.task_id.to_string()),
2422 Some(Json(RunKickRequest {
2423 init_ctx_override: None,
2424 task_input_override: None,
2425 timeout_secs: Some(0),
2426 detach: false,
2427 })),
2428 )
2429 .await;
2430 let err = match result {
2431 Err(e) => e,
2432 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2433 };
2434 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2435 assert!(
2436 err.message.contains("timeout_secs"),
2437 "error message must reference timeout_secs: {}",
2438 err.message
2439 );
2440
2441 let after = task_get(State(state), Path(posted.task_id.to_string()))
2442 .await
2443 .expect("task_get")
2444 .0;
2445 assert_eq!(
2446 after.runs.len(),
2447 runs_before,
2448 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
2449 );
2450 }
2451
2452 #[tokio::test]
2456 async fn rekick_non_operator_path_unaffected_by_guard_1() {
2457 let state = test_state();
2458 let posted = crate::tasks_start(
2459 State(state.clone()),
2460 Json(post_tasks_req("non-operator rekick goal")),
2461 )
2462 .await
2463 .expect("tasks_start")
2464 .0;
2465
2466 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2467 if let Err(e) = &result {
2468 panic!(
2469 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
2470 guard 1: {}",
2471 e.message
2472 );
2473 }
2474 }
2475
2476 #[tokio::test]
2477 async fn run_get_unknown_id_returns_404() {
2478 let state = test_state();
2479 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
2480 Ok(_) => panic!("expected 404 for an unknown run"),
2481 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2482 }
2483 }
2484
2485 #[tokio::test]
2486 async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
2487 let state = test_state();
2488 let posted = crate::tasks_start(
2489 State(state.clone()),
2490 Json(post_tasks_req("binding explain")),
2491 )
2492 .await
2493 .expect("tasks_start")
2494 .0;
2495 let run = state
2496 .run_store
2497 .get(&posted.run_id)
2498 .await
2499 .expect("stored run");
2500 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2501 let mut bound_agents: Vec<BoundAgent> =
2502 serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
2503 let bound = &mut bound_agents[0];
2504 bound.runner = Some(Runner::WsClaudeCode {
2505 variant: "coder".to_string(),
2506 tools: vec!["Read".to_string()],
2507 });
2508 bound.recompute_binding_digest().unwrap();
2509 let request_digest = bound.binding_digest.clone();
2510 bound
2511 .set_attestation(BindingAttestation {
2512 request_digest: request_digest.clone(),
2513 provider_id: "operator-manifest".to_string(),
2514 provider_revision: Some("claude-code-1.2".to_string()),
2515 resolved_model: Some("claude-sonnet-4".to_string()),
2516 effective_tools: vec!["Bash".to_string(), "Read".to_string()],
2517 launch_variant: Some("coder".to_string()),
2518 capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
2519 b"manifest-v1",
2520 )),
2521 })
2522 .unwrap();
2523 snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
2524 state
2525 .run_store
2526 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2527 .await
2528 .unwrap();
2529
2530 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2531 .await
2532 .expect("binding explain")
2533 .0;
2534 let entry = &explained.bindings[0];
2535 assert_eq!(entry.status, RunBindingStatus::Attested);
2536 assert_eq!(
2537 entry.requested.as_ref().unwrap().request_digest,
2538 request_digest
2539 );
2540 assert_eq!(
2541 entry
2542 .effective
2543 .as_ref()
2544 .unwrap()
2545 .provider_revision
2546 .as_deref(),
2547 Some("claude-code-1.2")
2548 );
2549 assert_eq!(
2550 entry
2551 .difference
2552 .as_ref()
2553 .unwrap()
2554 .additional_effective_tools,
2555 vec!["Bash"]
2556 );
2557 assert!(entry
2558 .difference
2559 .as_ref()
2560 .unwrap()
2561 .missing_requested_tools
2562 .is_empty());
2563 assert_ne!(entry.binding_digest, request_digest);
2564 }
2565
2566 #[tokio::test]
2567 async fn run_bindings_explain_reports_snapshot_origin() {
2568 let state = test_state();
2569 let posted =
2570 crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
2571 .await
2572 .expect("tasks_start")
2573 .0;
2574
2575 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
2577 .await
2578 .expect("binding explain")
2579 .0;
2580 assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
2581
2582 let run = state.run_store.get(&posted.run_id).await.unwrap();
2584 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2585 snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
2586 state
2587 .run_store
2588 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2589 .await
2590 .unwrap();
2591 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
2592 .await
2593 .expect("binding explain")
2594 .0;
2595 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
2596
2597 snapshot
2601 .as_object_mut()
2602 .unwrap()
2603 .remove("bound_agents_origin");
2604 state
2605 .run_store
2606 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2607 .await
2608 .unwrap();
2609 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2610 .await
2611 .expect("explain still 200 without an origin marker")
2612 .0;
2613 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
2614 }
2615
2616 #[tokio::test]
2617 async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
2618 let state = test_state();
2619 let posted = crate::tasks_start(
2620 State(state.clone()),
2621 Json(post_tasks_req("legacy binding explain")),
2622 )
2623 .await
2624 .expect("tasks_start")
2625 .0;
2626 state
2627 .run_store
2628 .set_input_json(&posted.run_id, "{}".to_string())
2629 .await
2630 .unwrap();
2631
2632 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2633 .await
2634 .expect_err("legacy run must not be re-resolved");
2635 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
2636 assert!(error
2637 .message
2638 .contains("current Blueprint state was not consulted"));
2639 }
2640
2641 #[tokio::test]
2642 async fn run_bindings_explain_rejects_a_tampered_snapshot() {
2643 let state = test_state();
2644 let posted = crate::tasks_start(
2645 State(state.clone()),
2646 Json(post_tasks_req("tampered binding explain")),
2647 )
2648 .await
2649 .expect("tasks_start")
2650 .0;
2651 let run = state.run_store.get(&posted.run_id).await.unwrap();
2652 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2653 snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
2654 state
2655 .run_store
2656 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2657 .await
2658 .unwrap();
2659
2660 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2661 .await
2662 .expect_err("digest drift must fail closed");
2663 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
2664 assert!(error.message.contains("inconsistent binding snapshot"));
2665 }
2666
2667 #[tokio::test]
2668 async fn task_get_unknown_id_returns_404() {
2669 let state = test_state();
2670 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
2671 Ok(_) => panic!("expected 404 for an unknown task"),
2672 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2673 }
2674 }
2675
2676 async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
2683 let task_id = TaskId::new();
2684 let run_id = RunId::new();
2685 state
2686 .task_store
2687 .create(TaskRecord {
2688 id: task_id.clone(),
2689 goal: "finalize-run-err-envelope".to_string(),
2690 blueprint_ref: json!("inline"),
2691 input_ctx: Value::Null,
2692 task_input_spec: None,
2693 status: TaskRecordStatus::Running,
2694 created_at: 0,
2695 updated_at: 0,
2696 })
2697 .await
2698 .expect("seed TaskRecord");
2699 state
2700 .run_store
2701 .create(RunRecord {
2702 id: run_id.clone(),
2703 task_id: task_id.clone(),
2704 status: RunStatus::Running,
2705 step_entries: Vec::new(),
2706 degradations: Vec::new(),
2707 operator_sid: None,
2708 result_ref: None,
2709 input_json: Some("{}".to_string()),
2710 created_at: 0,
2711 updated_at: 0,
2712 })
2713 .await
2714 .expect("seed RunRecord");
2715 (task_id, run_id)
2716 }
2717
2718 #[tokio::test]
2719 async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
2720 let state = test_state();
2721 let (task_id, run_id) = seed_task_and_run(&state).await;
2722
2723 let err: Result<TaskApplicationOutput, TaskApplicationError> =
2724 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
2725 message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
2726 failed_step: Some("gate".to_string()),
2727 verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
2728 partial_ctx: Some(
2729 json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
2730 ),
2731 }));
2732
2733 let _ = finalize_run(&state, &task_id, &run_id, err).await;
2734
2735 let run = state.run_store.get(&run_id).await.expect("run present");
2736 assert_eq!(run.status, RunStatus::Failed);
2737 let envelope = run
2738 .result_ref
2739 .as_ref()
2740 .expect("result_ref must be Some on Err arm");
2741 assert_eq!(
2742 envelope["error"]["message"],
2743 "blocked: {\"verdict\":\"BLOCKED\"}"
2744 );
2745 assert_eq!(envelope["error"]["failed_step"], "gate");
2746 assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
2747 assert_eq!(
2748 envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
2749 "blocked"
2750 );
2751
2752 let task = state.task_store.get(&task_id).await.expect("task present");
2754 assert_eq!(task.status, TaskRecordStatus::Failed);
2755 }
2756
2757 #[tokio::test]
2758 async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
2759 let state = test_state();
2760 let (task_id, run_id) = seed_task_and_run(&state).await;
2761
2762 let err: Result<TaskApplicationOutput, TaskApplicationError> =
2766 Err(TaskApplicationError::NoStore);
2767
2768 let _ = finalize_run(&state, &task_id, &run_id, err).await;
2769 let run = state.run_store.get(&run_id).await.expect("run present");
2770 let envelope = run
2771 .result_ref
2772 .as_ref()
2773 .expect("result_ref must be Some on Err arm");
2774 assert!(envelope["error"]["message"]
2775 .as_str()
2776 .expect("message string")
2777 .contains("store"));
2778 assert_eq!(envelope["error"]["failed_step"], Value::Null);
2779 assert_eq!(envelope["error"]["verdict_value"], Value::Null);
2780 assert_eq!(envelope["partial_ctx"], Value::Null);
2781 }
2782
2783 #[tokio::test]
2788 async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
2789 let state = test_state();
2790 let (task_id, run_id) = seed_task_and_run(&state).await;
2791
2792 let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
2793 token: mlua_swarm::CapToken {
2794 agent_id: "ut".to_string(),
2795 role: mlua_swarm::Role::Operator,
2796 scopes: vec!["*".to_string()],
2797 issued_at: 0,
2798 expire_at: u64::MAX,
2799 max_uses: None,
2800 nonce: "ut-nonce".to_string(),
2801 sig_hex: String::new(),
2802 },
2803 final_ctx: json!({"out": {"echoed": "hi"}}),
2804 bound_version: None,
2805 });
2806
2807 let _ = finalize_run(&state, &task_id, &run_id, ok).await;
2808 let run = state.run_store.get(&run_id).await.expect("run present");
2809 assert_eq!(run.status, RunStatus::Done);
2810 let stored = run.result_ref.as_ref().expect("result_ref Some");
2811 assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
2813 assert!(
2814 stored.get("error").is_none(),
2815 "Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
2816 );
2817 }
2818
2819 #[tokio::test]
2824 async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
2825 let state = test_state();
2826 let (_task_id, run_id) = seed_task_and_run(&state).await;
2827 let err: Result<TaskApplicationOutput, TaskApplicationError> =
2828 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
2829 message: "blocked: bad verdict".to_string(),
2830 failed_step: Some("scout".to_string()),
2831 verdict_value: Some(json!("BLOCKED")),
2832 partial_ctx: Some(json!({"steps": {}})),
2833 }));
2834 let _ = finalize_run(&state, &_task_id, &run_id, err).await;
2835
2836 let Json(run) = run_get(State(state), Path(run_id.to_string()))
2837 .await
2838 .expect("run_get");
2839 assert_eq!(run.status, RunStatus::Failed);
2840 let envelope = run.result_ref.expect("result_ref Some");
2841 assert_eq!(envelope["error"]["failed_step"], "scout");
2842 assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
2843 }
2844}