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::bad_request(format!("task {task_id}: bp resolve: {e}")))?;
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 check_policy: None,
1454 blueprint_ref_includes: Vec::new(),
1455 }
1456 }
1457
1458 fn test_state() -> AppState {
1463 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1464 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1465 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1466 AppState {
1467 engine,
1468 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1469 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1470 ws_operator_factory: None,
1471 data_store: Arc::new(InMemoryOutputStore::new()),
1472 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1473 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1474 task_store: Arc::new(InMemoryTaskStore::new()),
1475 run_store: Arc::new(InMemoryRunStore::new()),
1476 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1477 base_url: None,
1478 sync_timeout_secs: 300,
1479 }
1480 }
1481
1482 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
1483 crate::TaskLaunchRequest {
1484 blueprint: BlueprintRef::Inline {
1485 value: Box::new(identity_blueprint()),
1486 },
1487 init_ctx: serde_json::json!({"in": "hello"}),
1488 project_root: None,
1489 work_dir: None,
1490 task_metadata: None,
1491 ttl_secs: None,
1492 operator: None,
1493 operator_sid: None,
1494 timeout_secs: None,
1495 goal: Some(goal.to_string()),
1496 detach: false,
1497 check_policy: None,
1498 }
1499 }
1500
1501 #[test]
1502 fn task_id_serializes_as_bare_string() {
1503 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
1507 assert_eq!(v, serde_json::json!("T-abc"));
1508 }
1509
1510 #[tokio::test]
1511 async fn post_then_get_drill_down() {
1512 let state = test_state();
1513
1514 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
1515 .await
1516 .expect("tasks_start")
1517 .0;
1518 let task_id = posted.task_id.clone();
1519 let run_id = posted.run_id.clone();
1520
1521 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
1523 .await
1524 .expect("tasks_list")
1525 .0;
1526 assert!(
1527 list.iter().any(|t| t.id == task_id),
1528 "task {task_id} missing from list of {} tasks",
1529 list.len()
1530 );
1531
1532 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1534 .await
1535 .expect("task_get")
1536 .0;
1537 assert_eq!(detail.task.id, task_id);
1538 assert_eq!(detail.task.goal, "smoke goal");
1539 assert_eq!(detail.task.status, TaskRecordStatus::Done);
1540 assert_eq!(detail.runs.len(), 1);
1541 assert_eq!(detail.runs[0].id, run_id);
1542 assert_eq!(detail.runs[0].status, RunStatus::Done);
1543
1544 let run = run_get(State(state.clone()), Path(run_id.to_string()))
1546 .await
1547 .expect("run_get")
1548 .0;
1549 assert_eq!(run.id, run_id);
1550 assert_eq!(run.task_id, task_id);
1551 assert_eq!(run.result_ref, Some(posted.final_ctx));
1552
1553 assert_eq!(
1557 run.step_entries.len(),
1558 1,
1559 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
1560 run.step_entries
1561 );
1562 assert_eq!(
1563 run.step_entries[0].step_ref,
1564 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1565 );
1566 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1567 }
1568
1569 fn identity_blueprint_with_operator_delegate() -> Blueprint {
1581 Blueprint {
1582 spawner_hints: mlua_swarm::SpawnerHints {
1583 layers: vec!["operator_delegate".to_string()],
1584 },
1585 ..identity_blueprint()
1586 }
1587 }
1588
1589 struct StallingOperator;
1592
1593 #[async_trait::async_trait]
1594 impl mlua_swarm::Operator for StallingOperator {
1595 async fn execute(
1596 &self,
1597 _ctx: &mlua_swarm::Ctx,
1598 _system: Option<String>,
1599 _prompt: Value,
1600 _worker: Option<mlua_swarm::WorkerBinding>,
1601 _worker_token: mlua_swarm::CapToken,
1602 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1603 std::future::pending::<()>().await;
1604 unreachable!("StallingOperator.execute must never resolve")
1605 }
1606 }
1607
1608 fn operator_launch_req(
1612 backend_id: &str,
1613 timeout_secs: Option<u64>,
1614 ) -> crate::TaskLaunchRequest {
1615 crate::TaskLaunchRequest {
1616 blueprint: BlueprintRef::Inline {
1617 value: Box::new(identity_blueprint_with_operator_delegate()),
1618 },
1619 init_ctx: serde_json::json!({"in": "hello"}),
1620 project_root: None,
1621 work_dir: None,
1622 task_metadata: None,
1623 ttl_secs: None,
1624 operator: Some(crate::OperatorReq {
1625 operator_backend_id: Some(backend_id.to_string()),
1626 ..Default::default()
1627 }),
1628 operator_sid: None,
1629 timeout_secs,
1630 goal: Some("operator delegate test goal".to_string()),
1631 detach: false,
1632 check_policy: None,
1633 }
1634 }
1635
1636 #[tokio::test]
1640 async fn sync_launch_zero_operators_fails_fast() {
1641 let state = test_state();
1642 let req = operator_launch_req("nonexistent-op", None);
1645
1646 let started = std::time::Instant::now();
1647 let result = crate::tasks_start(State(state), Json(req)).await;
1648 let elapsed = started.elapsed();
1649
1650 let err = match result {
1651 Err(e) => e,
1652 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1653 };
1654 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1655 assert!(
1656 err.message.contains("no operator attached"),
1657 "error message must mention the missing operator: {}",
1658 err.message
1659 );
1660 assert!(
1661 elapsed < Duration::from_secs(1),
1662 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1663 );
1664 }
1665
1666 #[tokio::test]
1670 async fn sync_launch_stalled_times_out() {
1671 let state = test_state();
1672 state
1673 .engine
1674 .register_operator("stall-op", Arc::new(StallingOperator))
1675 .await;
1676 let req = operator_launch_req("stall-op", Some(1));
1677
1678 let started = std::time::Instant::now();
1679 let result = tokio::time::timeout(
1683 Duration::from_secs(5),
1684 crate::tasks_start(State(state), Json(req)),
1685 )
1686 .await
1687 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1688 let elapsed = started.elapsed();
1689
1690 let err = match result {
1691 Err(e) => e,
1692 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1693 };
1694 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1695 assert!(
1696 err.message.contains('1'),
1697 "error message must mention the configured 1s ceiling: {}",
1698 err.message
1699 );
1700 assert!(
1701 elapsed < Duration::from_secs(3),
1702 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1703 );
1704 }
1705
1706 #[tokio::test]
1710 async fn sync_launch_without_operator_path_unaffected() {
1711 let state = test_state();
1712 let result = crate::tasks_start(
1713 State(state),
1714 Json(post_tasks_req("non-operator launch goal")),
1715 )
1716 .await;
1717 if let Err(e) = &result {
1718 panic!(
1719 "non-operator launch must succeed unaffected by guard 1: {}",
1720 e.message
1721 );
1722 }
1723 }
1724
1725 #[tokio::test]
1729 async fn sync_launch_zero_timeout_secs_rejected() {
1730 let state = test_state();
1731 let mut req = post_tasks_req("zero timeout goal");
1732 req.timeout_secs = Some(0);
1733
1734 let result = crate::tasks_start(State(state), Json(req)).await;
1735 let err = match result {
1736 Err(e) => e,
1737 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1738 };
1739 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1740 assert!(
1741 err.message.contains("timeout_secs"),
1742 "error message must reference timeout_secs: {}",
1743 err.message
1744 );
1745 }
1746
1747 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1756 for _ in 0..50 {
1757 let rec = state.run_store.get(run_id).await.expect("run get");
1758 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1759 return rec;
1760 }
1761 tokio::time::sleep(Duration::from_millis(100)).await;
1762 }
1763 panic!("run {run_id} did not reach a terminal status within ~5s");
1764 }
1765
1766 #[tokio::test]
1772 async fn detached_launch_returns_202_and_completes_in_background() {
1773 let state = test_state();
1774 let mut req = post_tasks_req("detached goal");
1775 req.detach = true;
1776
1777 let reply = crate::tasks_start(State(state.clone()), Json(req))
1778 .await
1779 .expect("tasks_start (detached)");
1780 assert_eq!(reply.1, StatusCode::ACCEPTED);
1781 let posted = reply.0;
1782 assert_eq!(posted.status, RunStatus::Running);
1783 assert_eq!(
1784 posted.final_ctx,
1785 serde_json::Value::Null,
1786 "a detached launch has no final_ctx at response time"
1787 );
1788
1789 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1790 assert_eq!(rec.status, RunStatus::Done);
1791 assert!(
1792 rec.result_ref.is_some(),
1793 "finalize_run must persist the background eval's final_ctx"
1794 );
1795 assert_eq!(
1796 rec.step_entries.len(),
1797 1,
1798 "the background eval must trace its step_entries like the sync path: {:?}",
1799 rec.step_entries
1800 );
1801 let task = state
1802 .task_store
1803 .get(&posted.task_id)
1804 .await
1805 .expect("task get");
1806 assert_eq!(task.status, TaskRecordStatus::Done);
1807 }
1808
1809 #[tokio::test]
1813 async fn detached_launch_with_timeout_secs_rejected() {
1814 let state = test_state();
1815 let mut req = post_tasks_req("detached + ceiling goal");
1816 req.detach = true;
1817 req.timeout_secs = Some(60);
1818
1819 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1820 Err(e) => e,
1821 Ok(_) => panic!("detach + timeout_secs must be rejected"),
1822 };
1823 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1824 assert!(
1825 err.message.contains("detach"),
1826 "error message must explain the detach/timeout_secs conflict: {}",
1827 err.message
1828 );
1829 let tasks = state.task_store.list().await.expect("task list");
1830 assert!(
1831 tasks.is_empty(),
1832 "the 400 must fire before any TaskRecord is minted"
1833 );
1834 }
1835
1836 #[tokio::test]
1840 async fn rekick_detached_returns_202_and_completes_in_background() {
1841 let state = test_state();
1842 let posted = crate::tasks_start(
1843 State(state.clone()),
1844 Json(post_tasks_req("detached rekick goal")),
1845 )
1846 .await
1847 .expect("tasks_start")
1848 .0;
1849
1850 let (status, rekicked) = task_rekick(
1851 State(state.clone()),
1852 Path(posted.task_id.to_string()),
1853 Some(Json(RunKickRequest {
1854 init_ctx_override: None,
1855 task_input_override: None,
1856 timeout_secs: None,
1857 detach: true,
1858 })),
1859 )
1860 .await
1861 .expect("task_rekick (detached)");
1862 assert_eq!(status, StatusCode::ACCEPTED);
1863 assert_eq!(rekicked.0.status, RunStatus::Running);
1864 assert_ne!(rekicked.0.run_id, posted.run_id);
1865
1866 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1867 assert_eq!(rec.status, RunStatus::Done);
1868 assert!(
1869 rec.result_ref.is_some(),
1870 "finalize_run must persist the background rekick's final_ctx"
1871 );
1872 }
1873
1874 #[tokio::test]
1878 async fn rekick_detached_with_timeout_secs_rejected() {
1879 let state = test_state();
1880 let posted = crate::tasks_start(
1881 State(state.clone()),
1882 Json(post_tasks_req("detached rekick ceiling goal")),
1883 )
1884 .await
1885 .expect("tasks_start")
1886 .0;
1887
1888 let err = match task_rekick(
1889 State(state.clone()),
1890 Path(posted.task_id.to_string()),
1891 Some(Json(RunKickRequest {
1892 init_ctx_override: None,
1893 task_input_override: None,
1894 timeout_secs: Some(60),
1895 detach: true,
1896 })),
1897 )
1898 .await
1899 {
1900 Err(e) => e,
1901 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1902 };
1903 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1904 assert!(
1905 err.message.contains("detach"),
1906 "error message must explain the detach/timeout_secs conflict: {}",
1907 err.message
1908 );
1909 let runs = state
1910 .run_store
1911 .list_by_task(&posted.task_id)
1912 .await
1913 .expect("runs list");
1914 assert_eq!(
1915 runs.len(),
1916 1,
1917 "the 400 must fire before a second Run is minted"
1918 );
1919 }
1920
1921 #[tokio::test]
1922 async fn rekick_adds_a_second_run_to_the_same_task() {
1923 let state = test_state();
1924 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1925 .await
1926 .expect("tasks_start")
1927 .0;
1928 let task_id = posted.task_id.clone();
1929 let first_run_id = posted.run_id.clone();
1930
1931 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1932 .await
1933 .expect("task_rekick");
1934 assert_eq!(status, StatusCode::CREATED);
1935 let second_run_id = rekicked.0.run_id.clone();
1936 assert_ne!(first_run_id, second_run_id);
1937
1938 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1939 .await
1940 .expect("task_get")
1941 .0;
1942 assert_eq!(
1943 detail.runs.len(),
1944 2,
1945 "expected 2 runs, got {:?}",
1946 detail.runs
1947 );
1948 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1949 assert!(ids.contains(&&first_run_id));
1950 assert!(ids.contains(&&second_run_id));
1951
1952 let first_run = detail
1957 .runs
1958 .iter()
1959 .find(|r| r.id == first_run_id)
1960 .expect("first run present in detail.runs");
1961 let second_run = detail
1962 .runs
1963 .iter()
1964 .find(|r| r.id == second_run_id)
1965 .expect("second run present in detail.runs");
1966 assert_eq!(
1967 first_run.step_entries.len(),
1968 1,
1969 "first run step_entries: {:?}",
1970 first_run.step_entries
1971 );
1972 assert_eq!(
1973 second_run.step_entries.len(),
1974 1,
1975 "second run step_entries: {:?}",
1976 second_run.step_entries
1977 );
1978 assert_eq!(
1979 first_run.step_entries[0].step_ref,
1980 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1981 );
1982 assert_eq!(
1983 second_run.step_entries[0].step_ref,
1984 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1985 );
1986 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1987 assert_eq!(
1988 second_run.step_entries[0].status,
1989 Some("passed".to_string())
1990 );
1991 assert_ne!(
1992 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1993 "each kick dispatches its own StepId — runs must not share step_entries"
1994 );
1995 }
1996
1997 #[tokio::test]
1998 async fn rekick_unknown_task_returns_404() {
1999 let state = test_state();
2000 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
2004 Ok(_) => panic!("expected 404 for an unknown task"),
2005 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2006 }
2007 }
2008
2009 fn greeting_blueprint() -> Blueprint {
2018 Blueprint {
2019 schema_version: current_schema_version(),
2020 id: "tasks-test-greeting-bp".into(),
2021 flow: serde_json::from_value(serde_json::json!({
2022 "kind": "step",
2023 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
2024 "in": {"op": "path", "at": "$.greeting"},
2025 "out": {"op": "path", "at": "$.out"},
2026 }))
2027 .expect("flow parse"),
2028 agents: vec![AgentDef {
2029 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
2030 kind: AgentKind::RustFn,
2031 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
2032 profile: None,
2033 meta: None,
2034 runner: None,
2035 runner_ref: None,
2036 verdict: None,
2037 }],
2038 operators: vec![],
2039 metas: vec![],
2040 hints: CompilerHints::default(),
2041 strategy: CompilerStrategy::default(),
2042 metadata: BlueprintMetadata::default(),
2043 spawner_hints: Default::default(),
2044 default_agent_kind: AgentKind::Operator,
2045 default_operator_kind: None,
2046 default_init_ctx: None,
2047 default_agent_ctx: None,
2048 default_context_policy: None,
2049 projection_placement: None,
2050 audits: vec![],
2051 degradation_policy: None,
2052 runners: vec![],
2053 default_runner: None,
2054 check_policy: None,
2055 blueprint_ref_includes: Vec::new(),
2056 }
2057 }
2058
2059 fn post_greeting_task_req(
2060 greeting: &str,
2061 project_root: Option<&str>,
2062 ) -> crate::TaskLaunchRequest {
2063 crate::TaskLaunchRequest {
2064 blueprint: BlueprintRef::Inline {
2065 value: Box::new(greeting_blueprint()),
2066 },
2067 init_ctx: serde_json::json!({ "greeting": greeting }),
2068 project_root: project_root.map(str::to_string),
2069 work_dir: None,
2070 task_metadata: None,
2071 ttl_secs: None,
2072 operator: None,
2073 operator_sid: None,
2074 timeout_secs: None,
2075 goal: Some("st4 rekick goal".to_string()),
2076 detach: false,
2077 check_policy: None,
2078 }
2079 }
2080
2081 #[tokio::test]
2082 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2083 let state = test_state();
2086 let posted = crate::tasks_start(
2087 State(state.clone()),
2088 Json(post_greeting_task_req("from-task", None)),
2089 )
2090 .await
2091 .expect("tasks_start")
2092 .0;
2093 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2094
2095 let (status, rekicked) =
2096 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2097 .await
2098 .expect("task_rekick");
2099 assert_eq!(status, StatusCode::CREATED);
2100
2101 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2102 .await
2103 .expect("run_get")
2104 .0;
2105 assert_eq!(
2106 run.result_ref.expect("result_ref present")["out"]["echoed"],
2107 "from-task"
2108 );
2109 }
2110
2111 #[tokio::test]
2112 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2113 let state = test_state();
2114 let posted = crate::tasks_start(
2115 State(state.clone()),
2116 Json(post_greeting_task_req("from-task", None)),
2117 )
2118 .await
2119 .expect("tasks_start")
2120 .0;
2121 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2122
2123 let (status, rekicked) = task_rekick(
2124 State(state.clone()),
2125 Path(posted.task_id.to_string()),
2126 Some(Json(RunKickRequest {
2127 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2128 task_input_override: None,
2129 timeout_secs: None,
2130 detach: false,
2131 })),
2132 )
2133 .await
2134 .expect("task_rekick");
2135 assert_eq!(status, StatusCode::CREATED);
2136
2137 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2138 .await
2139 .expect("run_get")
2140 .0;
2141 assert_eq!(
2142 run.result_ref.expect("result_ref present")["out"]["echoed"],
2143 "from-run",
2144 "Run's init_ctx_override must win over the stored Task input_ctx"
2145 );
2146 }
2147
2148 #[tokio::test]
2149 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2150 let state = test_state();
2158 let posted = crate::tasks_start(
2159 State(state.clone()),
2160 Json(post_greeting_task_req("from-task", Some("/repo"))),
2161 )
2162 .await
2163 .expect("tasks_start")
2164 .0;
2165
2166 let before = state
2167 .task_store
2168 .get(&posted.task_id)
2169 .await
2170 .expect("task fetch");
2171 let before_spec: Option<TaskInputSpec> = before
2172 .task_input_spec
2173 .as_ref()
2174 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2175 assert_eq!(
2176 before_spec,
2177 Some(TaskInputSpec {
2178 project_root: Some("/repo".to_string()),
2179 work_dir: None,
2180 task_metadata: None,
2181 })
2182 );
2183
2184 let (status, _rekicked) =
2185 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2186 .await
2187 .expect("task_rekick");
2188 assert_eq!(status, StatusCode::CREATED);
2189
2190 let after = state
2191 .task_store
2192 .get(&posted.task_id)
2193 .await
2194 .expect("task fetch");
2195 assert_eq!(
2196 after.task_input_spec, before.task_input_spec,
2197 "rekick must not mutate the stored Task-level task_input_spec snapshot"
2198 );
2199 }
2200
2201 #[tokio::test]
2202 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2203 let state = test_state();
2206 let posted = crate::tasks_start(
2207 State(state.clone()),
2208 Json(post_greeting_task_req("from-task", Some("/repo"))),
2209 )
2210 .await
2211 .expect("tasks_start")
2212 .0;
2213
2214 let (status, _rekicked) = task_rekick(
2215 State(state.clone()),
2216 Path(posted.task_id.to_string()),
2217 Some(Json(RunKickRequest {
2218 init_ctx_override: None,
2219 task_input_override: Some(TaskInputSpec {
2220 project_root: Some("/override".to_string()),
2221 work_dir: None,
2222 task_metadata: None,
2223 }),
2224 timeout_secs: None,
2225 detach: false,
2226 })),
2227 )
2228 .await
2229 .expect("task_rekick");
2230 assert_eq!(status, StatusCode::CREATED);
2231
2232 let after = state
2233 .task_store
2234 .get(&posted.task_id)
2235 .await
2236 .expect("task fetch");
2237 let after_spec: Option<TaskInputSpec> = after
2238 .task_input_spec
2239 .as_ref()
2240 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2241 assert_eq!(
2242 after_spec,
2243 Some(TaskInputSpec {
2244 project_root: Some("/repo".to_string()),
2245 work_dir: None,
2246 task_metadata: None,
2247 }),
2248 "a per-Run task_input_override must not leak into the stored TaskRecord"
2249 );
2250 }
2251
2252 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
2266 crate::TaskLaunchRequest {
2267 blueprint: BlueprintRef::Inline {
2268 value: Box::new(identity_blueprint_with_operator_delegate()),
2269 },
2270 init_ctx: serde_json::json!({"in": "hello"}),
2271 project_root: None,
2272 work_dir: None,
2273 task_metadata: None,
2274 ttl_secs: None,
2275 operator: None,
2276 operator_sid: None,
2277 timeout_secs: None,
2278 goal: Some(goal.to_string()),
2279 detach: false,
2280 check_policy: None,
2281 }
2282 }
2283
2284 #[tokio::test]
2289 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
2290 let state = test_state();
2291 let posted = crate::tasks_start(
2292 State(state.clone()),
2293 Json(delegate_launch_req("operator delegate rekick goal")),
2294 )
2295 .await
2296 .expect("tasks_start (no operator referenced, dispatches through baseline)")
2297 .0;
2298 let started = std::time::Instant::now();
2302 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2303 let elapsed = started.elapsed();
2304
2305 let err = match result {
2306 Err(e) => e,
2307 Ok(_) => panic!(
2308 "rekicking a Task whose Blueprint declares operator_delegate with zero \
2309 attached operators must fail, not dispatch"
2310 ),
2311 };
2312 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2313 assert!(
2314 err.message.contains("no operator attached"),
2315 "error message must mention the missing operator: {}",
2316 err.message
2317 );
2318 assert!(
2319 elapsed < Duration::from_secs(1),
2320 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2321 );
2322 }
2323
2324 #[tokio::test]
2328 async fn rekick_stalled_operator_times_out() {
2329 let state = test_state();
2330 state
2331 .engine
2332 .register_operator("stall-op", Arc::new(StallingOperator))
2333 .await;
2334 let posted = crate::tasks_start(
2335 State(state.clone()),
2336 Json(delegate_launch_req("stalled rekick goal")),
2337 )
2338 .await
2339 .expect("tasks_start")
2340 .0;
2341
2342 let started = std::time::Instant::now();
2343 let result = tokio::time::timeout(
2347 Duration::from_secs(5),
2348 task_rekick(
2349 State(state),
2350 Path(posted.task_id.to_string()),
2351 Some(Json(RunKickRequest {
2352 init_ctx_override: None,
2353 task_input_override: None,
2354 timeout_secs: Some(1),
2355 detach: false,
2356 })),
2357 ),
2358 )
2359 .await
2360 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
2361 let elapsed = started.elapsed();
2362
2363 match &result {
2364 Err(e) => {
2365 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
2366 assert!(
2367 e.message.contains('1'),
2368 "error message must mention the configured 1s ceiling: {}",
2369 e.message
2370 );
2371 assert!(
2372 elapsed < Duration::from_secs(3),
2373 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2374 );
2375 }
2376 Ok(_) => {
2377 assert!(
2389 elapsed < Duration::from_secs(1),
2390 "a rekick that never engages an Operator (task_rekick has no \
2391 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
2392 );
2393 }
2394 }
2395 }
2396
2397 #[tokio::test]
2401 async fn rekick_timeout_secs_zero_rejected() {
2402 let state = test_state();
2403 let posted = crate::tasks_start(
2404 State(state.clone()),
2405 Json(post_tasks_req("zero timeout rekick goal")),
2406 )
2407 .await
2408 .expect("tasks_start")
2409 .0;
2410
2411 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2412 .await
2413 .expect("task_get")
2414 .0;
2415 let runs_before = before.runs.len();
2416
2417 let result = task_rekick(
2418 State(state.clone()),
2419 Path(posted.task_id.to_string()),
2420 Some(Json(RunKickRequest {
2421 init_ctx_override: None,
2422 task_input_override: None,
2423 timeout_secs: Some(0),
2424 detach: false,
2425 })),
2426 )
2427 .await;
2428 let err = match result {
2429 Err(e) => e,
2430 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2431 };
2432 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2433 assert!(
2434 err.message.contains("timeout_secs"),
2435 "error message must reference timeout_secs: {}",
2436 err.message
2437 );
2438
2439 let after = task_get(State(state), Path(posted.task_id.to_string()))
2440 .await
2441 .expect("task_get")
2442 .0;
2443 assert_eq!(
2444 after.runs.len(),
2445 runs_before,
2446 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
2447 );
2448 }
2449
2450 #[tokio::test]
2454 async fn rekick_non_operator_path_unaffected_by_guard_1() {
2455 let state = test_state();
2456 let posted = crate::tasks_start(
2457 State(state.clone()),
2458 Json(post_tasks_req("non-operator rekick goal")),
2459 )
2460 .await
2461 .expect("tasks_start")
2462 .0;
2463
2464 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2465 if let Err(e) = &result {
2466 panic!(
2467 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
2468 guard 1: {}",
2469 e.message
2470 );
2471 }
2472 }
2473
2474 #[tokio::test]
2475 async fn run_get_unknown_id_returns_404() {
2476 let state = test_state();
2477 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
2478 Ok(_) => panic!("expected 404 for an unknown run"),
2479 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2480 }
2481 }
2482
2483 #[tokio::test]
2484 async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
2485 let state = test_state();
2486 let posted = crate::tasks_start(
2487 State(state.clone()),
2488 Json(post_tasks_req("binding explain")),
2489 )
2490 .await
2491 .expect("tasks_start")
2492 .0;
2493 let run = state
2494 .run_store
2495 .get(&posted.run_id)
2496 .await
2497 .expect("stored run");
2498 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2499 let mut bound_agents: Vec<BoundAgent> =
2500 serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
2501 let bound = &mut bound_agents[0];
2502 bound.runner = Some(Runner::WsClaudeCode {
2503 variant: "coder".to_string(),
2504 tools: vec!["Read".to_string()],
2505 });
2506 bound.recompute_binding_digest().unwrap();
2507 let request_digest = bound.binding_digest.clone();
2508 bound
2509 .set_attestation(BindingAttestation {
2510 request_digest: request_digest.clone(),
2511 provider_id: "operator-manifest".to_string(),
2512 provider_revision: Some("claude-code-1.2".to_string()),
2513 resolved_model: Some("claude-sonnet-4".to_string()),
2514 effective_tools: vec!["Bash".to_string(), "Read".to_string()],
2515 launch_variant: Some("coder".to_string()),
2516 capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
2517 b"manifest-v1",
2518 )),
2519 })
2520 .unwrap();
2521 snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
2522 state
2523 .run_store
2524 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2525 .await
2526 .unwrap();
2527
2528 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2529 .await
2530 .expect("binding explain")
2531 .0;
2532 let entry = &explained.bindings[0];
2533 assert_eq!(entry.status, RunBindingStatus::Attested);
2534 assert_eq!(
2535 entry.requested.as_ref().unwrap().request_digest,
2536 request_digest
2537 );
2538 assert_eq!(
2539 entry
2540 .effective
2541 .as_ref()
2542 .unwrap()
2543 .provider_revision
2544 .as_deref(),
2545 Some("claude-code-1.2")
2546 );
2547 assert_eq!(
2548 entry
2549 .difference
2550 .as_ref()
2551 .unwrap()
2552 .additional_effective_tools,
2553 vec!["Bash"]
2554 );
2555 assert!(entry
2556 .difference
2557 .as_ref()
2558 .unwrap()
2559 .missing_requested_tools
2560 .is_empty());
2561 assert_ne!(entry.binding_digest, request_digest);
2562 }
2563
2564 #[tokio::test]
2565 async fn run_bindings_explain_reports_snapshot_origin() {
2566 let state = test_state();
2567 let posted =
2568 crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
2569 .await
2570 .expect("tasks_start")
2571 .0;
2572
2573 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
2575 .await
2576 .expect("binding explain")
2577 .0;
2578 assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
2579
2580 let run = state.run_store.get(&posted.run_id).await.unwrap();
2582 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2583 snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
2584 state
2585 .run_store
2586 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2587 .await
2588 .unwrap();
2589 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
2590 .await
2591 .expect("binding explain")
2592 .0;
2593 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
2594
2595 snapshot
2599 .as_object_mut()
2600 .unwrap()
2601 .remove("bound_agents_origin");
2602 state
2603 .run_store
2604 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2605 .await
2606 .unwrap();
2607 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2608 .await
2609 .expect("explain still 200 without an origin marker")
2610 .0;
2611 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
2612 }
2613
2614 #[tokio::test]
2615 async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
2616 let state = test_state();
2617 let posted = crate::tasks_start(
2618 State(state.clone()),
2619 Json(post_tasks_req("legacy binding explain")),
2620 )
2621 .await
2622 .expect("tasks_start")
2623 .0;
2624 state
2625 .run_store
2626 .set_input_json(&posted.run_id, "{}".to_string())
2627 .await
2628 .unwrap();
2629
2630 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2631 .await
2632 .expect_err("legacy run must not be re-resolved");
2633 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
2634 assert!(error
2635 .message
2636 .contains("current Blueprint state was not consulted"));
2637 }
2638
2639 #[tokio::test]
2640 async fn run_bindings_explain_rejects_a_tampered_snapshot() {
2641 let state = test_state();
2642 let posted = crate::tasks_start(
2643 State(state.clone()),
2644 Json(post_tasks_req("tampered binding explain")),
2645 )
2646 .await
2647 .expect("tasks_start")
2648 .0;
2649 let run = state.run_store.get(&posted.run_id).await.unwrap();
2650 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2651 snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
2652 state
2653 .run_store
2654 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2655 .await
2656 .unwrap();
2657
2658 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2659 .await
2660 .expect_err("digest drift must fail closed");
2661 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
2662 assert!(error.message.contains("inconsistent binding snapshot"));
2663 }
2664
2665 #[tokio::test]
2666 async fn task_get_unknown_id_returns_404() {
2667 let state = test_state();
2668 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
2669 Ok(_) => panic!("expected 404 for an unknown task"),
2670 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2671 }
2672 }
2673
2674 async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
2681 let task_id = TaskId::new();
2682 let run_id = RunId::new();
2683 state
2684 .task_store
2685 .create(TaskRecord {
2686 id: task_id.clone(),
2687 goal: "finalize-run-err-envelope".to_string(),
2688 blueprint_ref: json!("inline"),
2689 input_ctx: Value::Null,
2690 task_input_spec: None,
2691 status: TaskRecordStatus::Running,
2692 created_at: 0,
2693 updated_at: 0,
2694 })
2695 .await
2696 .expect("seed TaskRecord");
2697 state
2698 .run_store
2699 .create(RunRecord {
2700 id: run_id.clone(),
2701 task_id: task_id.clone(),
2702 status: RunStatus::Running,
2703 step_entries: Vec::new(),
2704 degradations: Vec::new(),
2705 operator_sid: None,
2706 result_ref: None,
2707 input_json: Some("{}".to_string()),
2708 created_at: 0,
2709 updated_at: 0,
2710 })
2711 .await
2712 .expect("seed RunRecord");
2713 (task_id, run_id)
2714 }
2715
2716 #[tokio::test]
2717 async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
2718 let state = test_state();
2719 let (task_id, run_id) = seed_task_and_run(&state).await;
2720
2721 let err: Result<TaskApplicationOutput, TaskApplicationError> =
2722 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
2723 message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
2724 failed_step: Some("gate".to_string()),
2725 verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
2726 partial_ctx: Some(
2727 json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
2728 ),
2729 }));
2730
2731 let _ = finalize_run(&state, &task_id, &run_id, err).await;
2732
2733 let run = state.run_store.get(&run_id).await.expect("run present");
2734 assert_eq!(run.status, RunStatus::Failed);
2735 let envelope = run
2736 .result_ref
2737 .as_ref()
2738 .expect("result_ref must be Some on Err arm");
2739 assert_eq!(
2740 envelope["error"]["message"],
2741 "blocked: {\"verdict\":\"BLOCKED\"}"
2742 );
2743 assert_eq!(envelope["error"]["failed_step"], "gate");
2744 assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
2745 assert_eq!(
2746 envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
2747 "blocked"
2748 );
2749
2750 let task = state.task_store.get(&task_id).await.expect("task present");
2752 assert_eq!(task.status, TaskRecordStatus::Failed);
2753 }
2754
2755 #[tokio::test]
2756 async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
2757 let state = test_state();
2758 let (task_id, run_id) = seed_task_and_run(&state).await;
2759
2760 let err: Result<TaskApplicationOutput, TaskApplicationError> =
2764 Err(TaskApplicationError::NoStore);
2765
2766 let _ = finalize_run(&state, &task_id, &run_id, err).await;
2767 let run = state.run_store.get(&run_id).await.expect("run present");
2768 let envelope = run
2769 .result_ref
2770 .as_ref()
2771 .expect("result_ref must be Some on Err arm");
2772 assert!(envelope["error"]["message"]
2773 .as_str()
2774 .expect("message string")
2775 .contains("store"));
2776 assert_eq!(envelope["error"]["failed_step"], Value::Null);
2777 assert_eq!(envelope["error"]["verdict_value"], Value::Null);
2778 assert_eq!(envelope["partial_ctx"], Value::Null);
2779 }
2780
2781 #[tokio::test]
2786 async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
2787 let state = test_state();
2788 let (task_id, run_id) = seed_task_and_run(&state).await;
2789
2790 let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
2791 token: mlua_swarm::CapToken {
2792 agent_id: "ut".to_string(),
2793 role: mlua_swarm::Role::Operator,
2794 scopes: vec!["*".to_string()],
2795 issued_at: 0,
2796 expire_at: u64::MAX,
2797 max_uses: None,
2798 nonce: "ut-nonce".to_string(),
2799 sig_hex: String::new(),
2800 },
2801 final_ctx: json!({"out": {"echoed": "hi"}}),
2802 bound_version: None,
2803 });
2804
2805 let _ = finalize_run(&state, &task_id, &run_id, ok).await;
2806 let run = state.run_store.get(&run_id).await.expect("run present");
2807 assert_eq!(run.status, RunStatus::Done);
2808 let stored = run.result_ref.as_ref().expect("result_ref Some");
2809 assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
2811 assert!(
2812 stored.get("error").is_none(),
2813 "Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
2814 );
2815 }
2816
2817 #[tokio::test]
2822 async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
2823 let state = test_state();
2824 let (_task_id, run_id) = seed_task_and_run(&state).await;
2825 let err: Result<TaskApplicationOutput, TaskApplicationError> =
2826 Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
2827 message: "blocked: bad verdict".to_string(),
2828 failed_step: Some("scout".to_string()),
2829 verdict_value: Some(json!("BLOCKED")),
2830 partial_ctx: Some(json!({"steps": {}})),
2831 }));
2832 let _ = finalize_run(&state, &_task_id, &run_id, err).await;
2833
2834 let Json(run) = run_get(State(state), Path(run_id.to_string()))
2835 .await
2836 .expect("run_get");
2837 assert_eq!(run.status, RunStatus::Failed);
2838 let envelope = run.result_ref.expect("result_ref Some");
2839 assert_eq!(envelope["error"]["failed_step"], "scout");
2840 assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
2841 }
2842}