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::store::replay::ReplayCursor;
49use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError, SnapshotOrigin};
50use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
51use mlua_swarm::{
52 validate_bound_agent_snapshots, OperatorKind, Role, RunId, TaskId, TaskInputSpec,
53};
54use serde::{Deserialize, Serialize};
55use serde_json::Value;
56use std::collections::HashMap;
57use std::sync::{Arc, Mutex};
58use std::time::Duration;
59
60use crate::{ApiError, AppState};
61
62pub(crate) fn now_secs() -> u64 {
66 std::time::SystemTime::now()
67 .duration_since(std::time::UNIX_EPOCH)
68 .map(|d| d.as_secs())
69 .unwrap_or(0)
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
86pub(crate) struct RunLaunchSnapshot {
87 blueprint: BlueprintRef,
88 operator_id: String,
89 role: Role,
90 ttl: Duration,
91 init_ctx: Value,
92 operator_kind: Option<OperatorKind>,
93 bridge_id: Option<String>,
94 hook_id: Option<String>,
95 operator_backend_id: Option<String>,
96 #[serde(default)]
97 operator_kind_overrides: HashMap<String, OperatorKind>,
98 task_input: Option<TaskInputSpec>,
99 check_policy: Option<CheckPolicy>,
100}
101
102impl RunLaunchSnapshot {
103 fn from_input(input: &TaskApplicationInput) -> Self {
106 Self {
107 blueprint: input.blueprint.clone(),
108 operator_id: input.operator_id.clone(),
109 role: input.role,
110 ttl: input.ttl,
111 init_ctx: input.init_ctx.clone(),
112 operator_kind: input.operator_kind,
113 bridge_id: input.bridge_id.clone(),
114 hook_id: input.hook_id.clone(),
115 operator_backend_id: input.operator_backend_id.clone(),
116 operator_kind_overrides: input.operator_kind_overrides.clone(),
117 task_input: input.task_input.clone(),
118 check_policy: input.check_policy,
119 }
120 }
121
122 fn into_input(self) -> TaskApplicationInput {
124 TaskApplicationInput {
125 blueprint: self.blueprint,
126 operator_id: self.operator_id,
127 role: self.role,
128 ttl: self.ttl,
129 init_ctx: self.init_ctx,
130 operator_kind: self.operator_kind,
131 bridge_id: self.bridge_id,
132 hook_id: self.hook_id,
133 operator_backend_id: self.operator_backend_id,
134 operator_kind_overrides: self.operator_kind_overrides,
135 task_input: self.task_input,
136 check_policy: self.check_policy,
137 }
138 }
139}
140
141pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
148 serde_json::to_string(&RunLaunchSnapshot::from_input(input))
149 .map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
150}
151
152pub(crate) async fn finalize_run(
162 state: &AppState,
163 task_id: &TaskId,
164 run_id: &RunId,
165 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
166) -> Result<TaskApplicationOutput, TaskApplicationError> {
167 match &outcome {
168 Ok(out) => {
169 if let Err(e) = state
170 .run_store
171 .set_result(run_id, out.final_ctx.clone())
172 .await
173 {
174 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
175 }
176 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
177 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
178 }
179 if let Err(e) = state
180 .task_store
181 .update_status(task_id, TaskRecordStatus::Done)
182 .await
183 {
184 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
185 }
186 }
187 Err(e) => {
188 if let Err(store_err) = state
189 .run_store
190 .update_status(run_id, RunStatus::Failed)
191 .await
192 {
193 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
194 }
195 if let Err(store_err) = state
196 .task_store
197 .update_status(task_id, TaskRecordStatus::Failed)
198 .await
199 {
200 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
201 }
202 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
203 }
204 }
205 outcome
206}
207
208#[derive(Debug, Deserialize, Default)]
210pub struct TasksListQuery {
211 #[serde(default)]
214 pub limit: Option<usize>,
215}
216
217pub async fn tasks_list(
219 State(state): State<AppState>,
220 Query(q): Query<TasksListQuery>,
221) -> Result<Json<Vec<TaskRecord>>, ApiError> {
222 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
223 if let Some(limit) = q.limit {
224 records.truncate(limit);
225 }
226 Ok(Json(records))
227}
228
229#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
231pub struct TaskDetailResponse {
232 pub task: TaskRecord,
234 pub runs: Vec<RunRecord>,
236}
237
238pub async fn task_get(
241 State(state): State<AppState>,
242 Path(id): Path<String>,
243) -> Result<Json<TaskDetailResponse>, ApiError> {
244 let task_id =
245 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
246 let task = state
247 .task_store
248 .get(&task_id)
249 .await
250 .map_err(map_task_store_err)?;
251 let runs = state
252 .run_store
253 .list_by_task(&task_id)
254 .await
255 .map_err(ApiError::engine)?;
256 Ok(Json(TaskDetailResponse { task, runs }))
257}
258
259#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
265pub struct RunKickRequest {
266 #[serde(default)]
275 #[schemars(with = "Option<Value>")]
276 pub init_ctx_override: Option<Value>,
277 #[serde(default)]
284 pub task_input_override: Option<TaskInputSpec>,
285 #[serde(default)]
291 pub timeout_secs: Option<u64>,
292 #[serde(default)]
299 pub detach: bool,
300}
301
302#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
304pub struct RunKickResponse {
305 #[schemars(with = "String")]
307 pub task_id: TaskId,
308 #[schemars(with = "String")]
310 pub run_id: RunId,
311 pub status: RunStatus,
316}
317
318pub async fn task_rekick(
345 State(state): State<AppState>,
346 Path(id): Path<String>,
347 body: Option<Json<RunKickRequest>>,
348) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
349 let task_id =
350 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
351 let task = state
352 .task_store
353 .get(&task_id)
354 .await
355 .map_err(map_task_store_err)?;
356
357 let blueprint_ref: mlua_swarm::application::BlueprintRef =
358 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
359 ApiError::bad_request(format!(
360 "task {task_id}: stored blueprint_ref failed to decode: {e}"
361 ))
362 })?;
363
364 let (resolved_bp, _bound_version) = state
370 .task_app
371 .resolve(&blueprint_ref)
372 .await
373 .map_err(|e| ApiError::bad_request(format!("task {task_id}: bp resolve: {e}")))?;
374
375 let req = body.map(|Json(r)| r).unwrap_or_default();
376
377 let detach = req.detach;
387 let sync_timeout_secs = match (detach, req.timeout_secs) {
388 (true, Some(_)) => {
389 return Err(ApiError::bad_request(
390 "timeout_secs is the synchronous rekick ceiling and does not apply to a \
391 detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
392 timeout_secs"
393 .into(),
394 ));
395 }
396 (false, Some(0)) => {
397 return Err(ApiError::bad_request(
398 "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
399 ));
400 }
401 (false, Some(v)) => v,
402 (_, None) => state.sync_timeout_secs,
403 };
404
405 if resolved_bp
417 .spawner_hints
418 .layers
419 .iter()
420 .any(|l| l == "operator_delegate")
421 {
422 let attached = state.engine.list_operator_ids().await;
423 if attached.is_empty() {
424 return Err(ApiError::unavailable(format!(
425 "no operator attached to serve this rekick (task {task_id}'s \
426 Blueprint declares the operator_delegate layer): attach an \
427 operator via POST /v1/operators + WS, or use the poll-style \
428 flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
429 )));
430 }
431 }
432
433 let merged_init_ctx = merge_init_ctx_3layer(
434 resolved_bp.default_init_ctx.as_ref(),
435 &task.input_ctx,
436 req.init_ctx_override.as_ref(),
437 );
438
439 let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
443 Some(over) => Some(over),
444 None => task
445 .task_input_spec
446 .as_ref()
447 .map(|v| serde_json::from_value(v.clone()))
448 .transpose()
449 .map_err(|e| {
450 ApiError::bad_request(format!(
451 "task {task_id}: stored task_input_spec failed to decode: {e}"
452 ))
453 })?,
454 };
455
456 let run_id = RunId::new();
457 let now = now_secs();
458
459 let input = TaskApplicationInput {
460 blueprint: blueprint_ref,
461 operator_id: "http-run".to_string(),
462 role: Role::Operator,
463 ttl: Duration::from_secs(crate::default_run_ttl()),
464 init_ctx: merged_init_ctx,
465 operator_kind: None,
466 bridge_id: None,
467 hook_id: None,
468 operator_backend_id: None,
469 operator_kind_overrides: HashMap::new(),
470 task_input: task_input_spec,
471 check_policy: None,
475 };
476 let input_json = Some(snapshot_launch_input(&input)?);
481
482 state
483 .task_store
484 .update_status(&task_id, TaskRecordStatus::Running)
485 .await
486 .map_err(ApiError::engine)?;
487 state
488 .run_store
489 .create(RunRecord {
490 id: run_id.clone(),
491 task_id: task_id.clone(),
492 status: RunStatus::Running,
493 step_entries: Vec::new(),
494 degradations: Vec::new(),
495 operator_sid: None,
496 result_ref: None,
497 input_json,
498 created_at: now,
499 updated_at: now,
500 })
501 .await
502 .map_err(ApiError::engine)?;
503
504 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
505 .with_replay_store(state.replay_store.clone());
506
507 if detach {
513 let ttl_secs = crate::default_run_ttl();
514 let bg_state = state.clone();
515 let bg_task_id = task_id.clone();
516 let bg_run_id = run_id.clone();
517 tokio::spawn(async move {
518 let outcome = match tokio::time::timeout(
519 Duration::from_secs(ttl_secs),
520 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
521 )
522 .await
523 {
524 Ok(outcome) => outcome,
525 Err(_elapsed) => {
526 let reason = serde_json::json!({
527 "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
528 });
529 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
530 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
531 }
532 if let Err(e) = bg_state
533 .run_store
534 .update_status(&bg_run_id, RunStatus::Failed)
535 .await
536 {
537 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
538 }
539 if let Err(e) = bg_state
540 .task_store
541 .update_status(&bg_task_id, TaskRecordStatus::Failed)
542 .await
543 {
544 tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
545 }
546 return;
547 }
548 };
549 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
552 });
553 return Ok((
554 StatusCode::ACCEPTED,
555 Json(RunKickResponse {
556 task_id,
557 run_id,
558 status: RunStatus::Running,
559 }),
560 ));
561 }
562
563 let outcome = match tokio::time::timeout(
569 Duration::from_secs(sync_timeout_secs),
570 state.task_app.handle_with_run(input, Some(run_ctx)),
571 )
572 .await
573 {
574 Ok(outcome) => outcome,
575 Err(_elapsed) => {
576 let reason = serde_json::json!({
577 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
578 });
579 if let Err(e) = state.run_store.set_result(&run_id, reason).await {
580 tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
581 }
582 if let Err(e) = state
583 .run_store
584 .update_status(&run_id, RunStatus::Failed)
585 .await
586 {
587 tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
588 }
589 if let Err(e) = state
590 .task_store
591 .update_status(&task_id, TaskRecordStatus::Failed)
592 .await
593 {
594 tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
595 }
596 return Err(ApiError::timeout(format!(
597 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
598 )));
599 }
600 };
601 finalize_run(&state, &task_id, &run_id, outcome)
602 .await
603 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
604
605 Ok((
606 StatusCode::CREATED,
607 Json(RunKickResponse {
608 task_id,
609 run_id,
610 status: RunStatus::Done,
611 }),
612 ))
613}
614
615#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
617pub struct RunResumeResponse {
618 #[schemars(with = "String")]
623 pub run_id: RunId,
624 #[schemars(with = "String")]
626 pub task_id: TaskId,
627 pub replayed_steps: usize,
632}
633
634pub async fn run_resume(
660 State(state): State<AppState>,
661 Path(id): Path<String>,
662) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
663 let run_id =
664 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
665
666 let run = state
668 .run_store
669 .get(&run_id)
670 .await
671 .map_err(map_run_store_err)?;
672
673 if run.status != RunStatus::Interrupted {
675 return Err(ApiError::conflict(format!(
676 "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
677 run.status
678 )));
679 }
680
681 let Some(input_json) = run.input_json.clone() else {
686 return Err(ApiError::unprocessable(format!(
687 "run {run_id} cannot be resumed: no launch input was recorded for it (it \
688 predates resume support, or was created by a path that does not persist one)"
689 )));
690 };
691 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
692 ApiError::unprocessable(format!(
693 "run {run_id}: stored launch input failed to decode: {e}"
694 ))
695 })?;
696 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
697 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
698 ApiError::unprocessable(format!(
699 "run {run_id}: stored launch input failed to decode: {e}"
700 ))
701 })?;
702
703 let won = state
707 .run_store
708 .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
709 .await
710 .map_err(ApiError::engine)?;
711 if !won {
712 return Err(ApiError::conflict(format!(
713 "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
714 no longer resumable"
715 )));
716 }
717
718 let entries = state
722 .replay_store
723 .list_by_run(&run_id)
724 .await
725 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
726 let replayed_steps = entries.len();
727 let cursor = ReplayCursor::from_entries(entries);
728
729 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
734 .with_replay_store(state.replay_store.clone())
735 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
736 .with_resume();
737
738 let input = snapshot.into_input();
739 let task_id = run.task_id.clone();
740
741 state
744 .task_store
745 .update_status(&task_id, TaskRecordStatus::Running)
746 .await
747 .map_err(ApiError::engine)?;
748
749 let ttl_secs = crate::default_run_ttl();
753 let bg_state = state.clone();
754 let bg_task_id = task_id.clone();
755 let bg_run_id = run_id.clone();
756 tokio::spawn(async move {
757 let outcome = match tokio::time::timeout(
758 Duration::from_secs(ttl_secs),
759 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
760 )
761 .await
762 {
763 Ok(outcome) => outcome,
764 Err(_elapsed) => {
765 let reason = serde_json::json!({
766 "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
767 });
768 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
769 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
770 }
771 if let Err(e) = bg_state
772 .run_store
773 .update_status(&bg_run_id, RunStatus::Failed)
774 .await
775 {
776 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
777 }
778 if let Err(e) = bg_state
779 .task_store
780 .update_status(&bg_task_id, TaskRecordStatus::Failed)
781 .await
782 {
783 tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
784 }
785 return;
786 }
787 };
788 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
790 });
791
792 Ok((
793 StatusCode::ACCEPTED,
794 Json(RunResumeResponse {
795 run_id,
796 task_id,
797 replayed_steps,
798 }),
799 ))
800}
801
802#[derive(Debug, Deserialize, schemars::JsonSchema)]
804pub struct RunRerunFromRequest {
805 pub from_step: String,
812}
813
814#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
816pub struct RunRerunFromResponse {
817 #[schemars(with = "String")]
822 pub run_id: RunId,
823 #[schemars(with = "String")]
825 pub task_id: TaskId,
826 pub replayed_steps: usize,
830 pub dropped_steps: usize,
833}
834
835pub async fn run_rerun_from(
910 State(state): State<AppState>,
911 Path(id): Path<String>,
912 Json(req): Json<RunRerunFromRequest>,
913) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
914 let run_id =
915 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
916
917 if req.from_step.trim().is_empty() {
918 return Err(ApiError::bad_request(
919 "from_step must be a non-empty step ref".to_string(),
920 ));
921 }
922
923 let run = state
925 .run_store
926 .get(&run_id)
927 .await
928 .map_err(map_run_store_err)?;
929
930 let current = run.status;
933 match current {
934 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => { }
935 RunStatus::Running | RunStatus::Pending => {
936 return Err(ApiError::conflict(format!(
937 "run {run_id} is {current:?}; rerun-from requires a terminal run \
938 (Done / Failed / Interrupted)"
939 )));
940 }
941 }
942
943 let Some(input_json) = run.input_json.clone() else {
948 return Err(ApiError::unprocessable(format!(
949 "run {run_id} cannot be rerun: no launch input was recorded for it (it \
950 predates resume/rerun support, or was created by a path that does not \
951 persist one)"
952 )));
953 };
954 let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
955 ApiError::unprocessable(format!(
956 "run {run_id}: stored launch input failed to decode: {e}"
957 ))
958 })?;
959 validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
960 let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
961 ApiError::unprocessable(format!(
962 "run {run_id}: stored launch input failed to decode: {e}"
963 ))
964 })?;
965
966 let entries = state
969 .replay_store
970 .list_by_run(&run_id)
971 .await
972 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
973 let cut = entries
974 .iter()
975 .position(|e| e.step_ref == req.from_step)
976 .ok_or_else(|| {
977 if entries.is_empty() && !run.step_entries.is_empty() {
987 ApiError::unprocessable(format!(
988 "run {run_id}: replay log is empty but {} step entries are traced \
989 on the RunRecord — the log was consumed by a prior rerun-from \
990 that reached the truncate stage. This run can no longer be \
991 rerun-from; start a fresh run via POST /v1/tasks.",
992 run.step_entries.len()
993 ))
994 } else {
995 ApiError::unprocessable(format!(
996 "run {run_id}: from_step {:?} not present in this run's replay log \
997 (nothing to rerun-from)",
998 req.from_step
999 ))
1000 }
1001 })?;
1002
1003 if let Err(e) = state.task_app.precompile(&snapshot.blueprint).await {
1017 return Err(ApiError::unprocessable(format!(
1018 "run {run_id} cannot be rerun: current-head Blueprint fails to compile — {e}"
1019 )));
1020 }
1021
1022 let won = state
1027 .run_store
1028 .try_transition(&run_id, current, RunStatus::Running)
1029 .await
1030 .map_err(ApiError::engine)?;
1031 if !won {
1032 return Err(ApiError::conflict(format!(
1033 "run {run_id} was concurrently transitioned (or left the {current:?} state); \
1034 it is no longer rerunnable"
1035 )));
1036 }
1037
1038 let dropped_steps = state
1043 .replay_store
1044 .delete_from(&run_id, cut)
1045 .await
1046 .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
1047
1048 let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
1051 let replayed_steps = kept.len();
1052 let cursor = ReplayCursor::from_entries(kept);
1053
1054 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1058 .with_replay_store(state.replay_store.clone())
1059 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
1060 .with_resume();
1061
1062 let input = snapshot.into_input();
1063 let task_id = run.task_id.clone();
1064
1065 state
1068 .task_store
1069 .update_status(&task_id, TaskRecordStatus::Running)
1070 .await
1071 .map_err(ApiError::engine)?;
1072
1073 let ttl_secs = crate::default_run_ttl();
1074 let bg_state = state.clone();
1075 let bg_task_id = task_id.clone();
1076 let bg_run_id = run_id.clone();
1077 tokio::spawn(async move {
1078 let outcome = match tokio::time::timeout(
1079 Duration::from_secs(ttl_secs),
1080 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1081 )
1082 .await
1083 {
1084 Ok(outcome) => outcome,
1085 Err(_elapsed) => {
1086 let reason = serde_json::json!({
1087 "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1088 });
1089 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1090 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1091 }
1092 if let Err(e) = bg_state
1093 .run_store
1094 .update_status(&bg_run_id, RunStatus::Failed)
1095 .await
1096 {
1097 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1098 }
1099 if let Err(e) = bg_state
1100 .task_store
1101 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1102 .await
1103 {
1104 tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1105 }
1106 return;
1107 }
1108 };
1109 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1110 });
1111
1112 Ok((
1113 StatusCode::ACCEPTED,
1114 Json(RunRerunFromResponse {
1115 run_id,
1116 task_id,
1117 replayed_steps,
1118 dropped_steps,
1119 }),
1120 ))
1121}
1122
1123pub async fn run_get(
1126 State(state): State<AppState>,
1127 Path(id): Path<String>,
1128) -> Result<Json<RunRecord>, ApiError> {
1129 let run_id =
1130 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1131 let run = state
1132 .run_store
1133 .get(&run_id)
1134 .await
1135 .map_err(map_run_store_err)?;
1136 Ok(Json(run))
1137}
1138
1139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1142#[serde(rename_all = "snake_case")]
1143pub enum RunBindingStatus {
1144 DeclarationOnly,
1147 Attested,
1149}
1150
1151#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1153pub struct RunBindingDifference {
1154 pub model_changed: bool,
1156 pub missing_requested_tools: Vec<String>,
1159 pub additional_effective_tools: Vec<String>,
1161 pub launch_variant_changed: bool,
1163}
1164
1165#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1168pub struct RunBindingExplainEntry {
1169 pub agent: String,
1171 pub runner_source: mlua_swarm::blueprint::RunnerResolutionSource,
1173 pub status: RunBindingStatus,
1175 pub requested: Option<BindRequest>,
1177 pub effective: Option<BindingAttestation>,
1179 pub difference: Option<RunBindingDifference>,
1182 pub binding_digest: mlua_swarm::blueprint::BindingDigest,
1184}
1185
1186#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
1188pub struct RunBindingsExplainResponse {
1189 #[schemars(with = "String")]
1191 pub run_id: RunId,
1192 #[schemars(with = "String")]
1194 pub task_id: TaskId,
1195 pub snapshot_origin: SnapshotOrigin,
1203 pub bindings: Vec<RunBindingExplainEntry>,
1205}
1206
1207fn requested_binding(bound: &BoundAgent) -> Option<BindRequest> {
1208 mlua_swarm::binding_request_for_snapshot(bound)
1209}
1210
1211fn binding_difference(
1212 requested: &BindRequest,
1213 effective: &BindingAttestation,
1214) -> RunBindingDifference {
1215 let missing_requested_tools = requested
1216 .requested_tools
1217 .iter()
1218 .filter(|tool| !effective.effective_tools.contains(tool))
1219 .cloned()
1220 .collect();
1221 let additional_effective_tools = effective
1222 .effective_tools
1223 .iter()
1224 .filter(|tool| !requested.requested_tools.contains(tool))
1225 .cloned()
1226 .collect();
1227 RunBindingDifference {
1228 model_changed: requested.requested_model != effective.resolved_model,
1229 missing_requested_tools,
1230 additional_effective_tools,
1231 launch_variant_changed: requested.launch_variant != effective.launch_variant,
1232 }
1233}
1234
1235fn validated_bound_agents_from_snapshot(
1236 run_id: &RunId,
1237 snapshot: &Value,
1238) -> Result<Option<Vec<BoundAgent>>, ApiError> {
1239 let Some(bound_value) = snapshot.get("bound_agents") else {
1240 return Ok(None);
1241 };
1242 let bound_agents: Vec<BoundAgent> =
1243 serde_json::from_value(bound_value.clone()).map_err(|e| {
1244 ApiError::unprocessable(format!(
1245 "run {run_id} contains an invalid binding snapshot: {e}"
1246 ))
1247 })?;
1248 validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
1249 ApiError::unprocessable(format!(
1250 "run {run_id} contains an inconsistent binding snapshot: {error}"
1251 ))
1252 })?;
1253 Ok(Some(bound_agents))
1254}
1255
1256pub async fn run_bindings_explain(
1260 State(state): State<AppState>,
1261 Path(id): Path<String>,
1262) -> Result<Json<RunBindingsExplainResponse>, ApiError> {
1263 let run_id =
1264 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1265 let run = state
1266 .run_store
1267 .get(&run_id)
1268 .await
1269 .map_err(map_run_store_err)?;
1270 let input_json = run.input_json.as_deref().ok_or_else(|| {
1271 ApiError::unprocessable(format!(
1272 "run {run_id} has no launch snapshot; binding explain is unavailable"
1273 ))
1274 })?;
1275 let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
1276 ApiError::unprocessable(format!(
1277 "run {run_id} launch snapshot is invalid JSON; binding explain is unavailable: {e}"
1278 ))
1279 })?;
1280 let bound_agents = validated_bound_agents_from_snapshot(&run_id, &snapshot)?.ok_or_else(|| {
1281 ApiError::unprocessable(format!(
1282 "run {run_id} predates immutable binding snapshots; current Blueprint state was not consulted"
1283 ))
1284 })?;
1285
1286 let bindings = bound_agents
1287 .into_iter()
1288 .map(|bound| {
1289 let requested = requested_binding(&bound);
1290 let effective = bound.attestation.clone();
1291 let difference = requested
1292 .as_ref()
1293 .zip(effective.as_ref())
1294 .map(|(request, attestation)| binding_difference(request, attestation));
1295 RunBindingExplainEntry {
1296 agent: bound.agent.name,
1297 runner_source: bound.runner_source,
1298 status: if effective.is_some() {
1299 RunBindingStatus::Attested
1300 } else {
1301 RunBindingStatus::DeclarationOnly
1302 },
1303 requested,
1304 effective,
1305 difference,
1306 binding_digest: bound.binding_digest,
1307 }
1308 })
1309 .collect();
1310
1311 Ok(Json(RunBindingsExplainResponse {
1312 run_id: run.id,
1313 task_id: run.task_id,
1314 snapshot_origin: SnapshotOrigin::from_snapshot(&snapshot),
1315 bindings,
1316 }))
1317}
1318
1319pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1323 match e {
1324 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1325 other => ApiError::engine(other),
1326 }
1327}
1328
1329fn map_run_store_err(e: RunStoreError) -> ApiError {
1330 match e {
1331 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1332 other => ApiError::engine(other),
1333 }
1334}
1335
1336#[cfg(test)]
1341mod tests {
1342 use super::*;
1343 use mlua_swarm::application::BlueprintRef;
1344 use mlua_swarm::blueprint::{
1345 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1346 CompilerStrategy, Runner,
1347 };
1348 use mlua_swarm::core::config::EngineCfg;
1349 use mlua_swarm::core::engine::Engine;
1350 use mlua_swarm::store::output::InMemoryOutputStore;
1351 use mlua_swarm::store::run::InMemoryRunStore;
1352 use mlua_swarm::store::task::InMemoryTaskStore;
1353 use std::collections::HashMap;
1354 use std::sync::Arc;
1355 use tokio::sync::Mutex;
1356
1357 fn identity_blueprint() -> Blueprint {
1363 Blueprint {
1364 schema_version: current_schema_version(),
1365 id: "tasks-test-bp".into(),
1366 flow: serde_json::from_value(serde_json::json!({
1367 "kind": "step",
1368 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1369 "in": {"op": "lit", "value": "hello"},
1370 "out": {"op": "path", "at": "$.out"},
1371 }))
1372 .expect("flow parse"),
1373 agents: vec![AgentDef {
1374 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1375 kind: AgentKind::RustFn,
1376 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1377 profile: None,
1378 meta: None,
1379 runner: None,
1380 runner_ref: None,
1381 verdict: None,
1382 }],
1383 operators: vec![],
1384 metas: vec![],
1385 hints: CompilerHints::default(),
1386 strategy: CompilerStrategy::default(),
1387 metadata: BlueprintMetadata::default(),
1388 spawner_hints: Default::default(),
1389 default_agent_kind: AgentKind::Operator,
1390 default_operator_kind: None,
1391 default_init_ctx: None,
1392 default_agent_ctx: None,
1393 default_context_policy: None,
1394 projection_placement: None,
1395 audits: vec![],
1396 degradation_policy: None,
1397 runners: vec![],
1398 default_runner: None,
1399 check_policy: None,
1400 blueprint_ref_includes: Vec::new(),
1401 }
1402 }
1403
1404 fn test_state() -> AppState {
1409 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1410 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1411 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1412 AppState {
1413 engine,
1414 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1415 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1416 ws_operator_factory: None,
1417 data_store: Arc::new(InMemoryOutputStore::new()),
1418 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1419 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1420 task_store: Arc::new(InMemoryTaskStore::new()),
1421 run_store: Arc::new(InMemoryRunStore::new()),
1422 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1423 base_url: None,
1424 sync_timeout_secs: 300,
1425 }
1426 }
1427
1428 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
1429 crate::TaskLaunchRequest {
1430 blueprint: BlueprintRef::Inline {
1431 value: Box::new(identity_blueprint()),
1432 },
1433 init_ctx: serde_json::json!({"in": "hello"}),
1434 project_root: None,
1435 work_dir: None,
1436 task_metadata: None,
1437 ttl_secs: None,
1438 operator: None,
1439 operator_sid: None,
1440 timeout_secs: None,
1441 goal: Some(goal.to_string()),
1442 detach: false,
1443 check_policy: None,
1444 }
1445 }
1446
1447 #[test]
1448 fn task_id_serializes_as_bare_string() {
1449 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
1453 assert_eq!(v, serde_json::json!("T-abc"));
1454 }
1455
1456 #[tokio::test]
1457 async fn post_then_get_drill_down() {
1458 let state = test_state();
1459
1460 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
1461 .await
1462 .expect("tasks_start")
1463 .0;
1464 let task_id = posted.task_id.clone();
1465 let run_id = posted.run_id.clone();
1466
1467 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
1469 .await
1470 .expect("tasks_list")
1471 .0;
1472 assert!(
1473 list.iter().any(|t| t.id == task_id),
1474 "task {task_id} missing from list of {} tasks",
1475 list.len()
1476 );
1477
1478 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1480 .await
1481 .expect("task_get")
1482 .0;
1483 assert_eq!(detail.task.id, task_id);
1484 assert_eq!(detail.task.goal, "smoke goal");
1485 assert_eq!(detail.task.status, TaskRecordStatus::Done);
1486 assert_eq!(detail.runs.len(), 1);
1487 assert_eq!(detail.runs[0].id, run_id);
1488 assert_eq!(detail.runs[0].status, RunStatus::Done);
1489
1490 let run = run_get(State(state.clone()), Path(run_id.to_string()))
1492 .await
1493 .expect("run_get")
1494 .0;
1495 assert_eq!(run.id, run_id);
1496 assert_eq!(run.task_id, task_id);
1497 assert_eq!(run.result_ref, Some(posted.final_ctx));
1498
1499 assert_eq!(
1503 run.step_entries.len(),
1504 1,
1505 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
1506 run.step_entries
1507 );
1508 assert_eq!(
1509 run.step_entries[0].step_ref,
1510 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1511 );
1512 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1513 }
1514
1515 fn identity_blueprint_with_operator_delegate() -> Blueprint {
1527 Blueprint {
1528 spawner_hints: mlua_swarm::SpawnerHints {
1529 layers: vec!["operator_delegate".to_string()],
1530 },
1531 ..identity_blueprint()
1532 }
1533 }
1534
1535 struct StallingOperator;
1538
1539 #[async_trait::async_trait]
1540 impl mlua_swarm::Operator for StallingOperator {
1541 async fn execute(
1542 &self,
1543 _ctx: &mlua_swarm::Ctx,
1544 _system: Option<String>,
1545 _prompt: Value,
1546 _worker: Option<mlua_swarm::WorkerBinding>,
1547 _worker_token: mlua_swarm::CapToken,
1548 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1549 std::future::pending::<()>().await;
1550 unreachable!("StallingOperator.execute must never resolve")
1551 }
1552 }
1553
1554 fn operator_launch_req(
1558 backend_id: &str,
1559 timeout_secs: Option<u64>,
1560 ) -> crate::TaskLaunchRequest {
1561 crate::TaskLaunchRequest {
1562 blueprint: BlueprintRef::Inline {
1563 value: Box::new(identity_blueprint_with_operator_delegate()),
1564 },
1565 init_ctx: serde_json::json!({"in": "hello"}),
1566 project_root: None,
1567 work_dir: None,
1568 task_metadata: None,
1569 ttl_secs: None,
1570 operator: Some(crate::OperatorReq {
1571 operator_backend_id: Some(backend_id.to_string()),
1572 ..Default::default()
1573 }),
1574 operator_sid: None,
1575 timeout_secs,
1576 goal: Some("operator delegate test goal".to_string()),
1577 detach: false,
1578 check_policy: None,
1579 }
1580 }
1581
1582 #[tokio::test]
1586 async fn sync_launch_zero_operators_fails_fast() {
1587 let state = test_state();
1588 let req = operator_launch_req("nonexistent-op", None);
1591
1592 let started = std::time::Instant::now();
1593 let result = crate::tasks_start(State(state), Json(req)).await;
1594 let elapsed = started.elapsed();
1595
1596 let err = match result {
1597 Err(e) => e,
1598 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1599 };
1600 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1601 assert!(
1602 err.message.contains("no operator attached"),
1603 "error message must mention the missing operator: {}",
1604 err.message
1605 );
1606 assert!(
1607 elapsed < Duration::from_secs(1),
1608 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1609 );
1610 }
1611
1612 #[tokio::test]
1616 async fn sync_launch_stalled_times_out() {
1617 let state = test_state();
1618 state
1619 .engine
1620 .register_operator("stall-op", Arc::new(StallingOperator))
1621 .await;
1622 let req = operator_launch_req("stall-op", Some(1));
1623
1624 let started = std::time::Instant::now();
1625 let result = tokio::time::timeout(
1629 Duration::from_secs(5),
1630 crate::tasks_start(State(state), Json(req)),
1631 )
1632 .await
1633 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1634 let elapsed = started.elapsed();
1635
1636 let err = match result {
1637 Err(e) => e,
1638 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1639 };
1640 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1641 assert!(
1642 err.message.contains('1'),
1643 "error message must mention the configured 1s ceiling: {}",
1644 err.message
1645 );
1646 assert!(
1647 elapsed < Duration::from_secs(3),
1648 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1649 );
1650 }
1651
1652 #[tokio::test]
1656 async fn sync_launch_without_operator_path_unaffected() {
1657 let state = test_state();
1658 let result = crate::tasks_start(
1659 State(state),
1660 Json(post_tasks_req("non-operator launch goal")),
1661 )
1662 .await;
1663 if let Err(e) = &result {
1664 panic!(
1665 "non-operator launch must succeed unaffected by guard 1: {}",
1666 e.message
1667 );
1668 }
1669 }
1670
1671 #[tokio::test]
1675 async fn sync_launch_zero_timeout_secs_rejected() {
1676 let state = test_state();
1677 let mut req = post_tasks_req("zero timeout goal");
1678 req.timeout_secs = Some(0);
1679
1680 let result = crate::tasks_start(State(state), Json(req)).await;
1681 let err = match result {
1682 Err(e) => e,
1683 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1684 };
1685 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1686 assert!(
1687 err.message.contains("timeout_secs"),
1688 "error message must reference timeout_secs: {}",
1689 err.message
1690 );
1691 }
1692
1693 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1702 for _ in 0..50 {
1703 let rec = state.run_store.get(run_id).await.expect("run get");
1704 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1705 return rec;
1706 }
1707 tokio::time::sleep(Duration::from_millis(100)).await;
1708 }
1709 panic!("run {run_id} did not reach a terminal status within ~5s");
1710 }
1711
1712 #[tokio::test]
1718 async fn detached_launch_returns_202_and_completes_in_background() {
1719 let state = test_state();
1720 let mut req = post_tasks_req("detached goal");
1721 req.detach = true;
1722
1723 let reply = crate::tasks_start(State(state.clone()), Json(req))
1724 .await
1725 .expect("tasks_start (detached)");
1726 assert_eq!(reply.1, StatusCode::ACCEPTED);
1727 let posted = reply.0;
1728 assert_eq!(posted.status, RunStatus::Running);
1729 assert_eq!(
1730 posted.final_ctx,
1731 serde_json::Value::Null,
1732 "a detached launch has no final_ctx at response time"
1733 );
1734
1735 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1736 assert_eq!(rec.status, RunStatus::Done);
1737 assert!(
1738 rec.result_ref.is_some(),
1739 "finalize_run must persist the background eval's final_ctx"
1740 );
1741 assert_eq!(
1742 rec.step_entries.len(),
1743 1,
1744 "the background eval must trace its step_entries like the sync path: {:?}",
1745 rec.step_entries
1746 );
1747 let task = state
1748 .task_store
1749 .get(&posted.task_id)
1750 .await
1751 .expect("task get");
1752 assert_eq!(task.status, TaskRecordStatus::Done);
1753 }
1754
1755 #[tokio::test]
1759 async fn detached_launch_with_timeout_secs_rejected() {
1760 let state = test_state();
1761 let mut req = post_tasks_req("detached + ceiling goal");
1762 req.detach = true;
1763 req.timeout_secs = Some(60);
1764
1765 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1766 Err(e) => e,
1767 Ok(_) => panic!("detach + timeout_secs must be rejected"),
1768 };
1769 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1770 assert!(
1771 err.message.contains("detach"),
1772 "error message must explain the detach/timeout_secs conflict: {}",
1773 err.message
1774 );
1775 let tasks = state.task_store.list().await.expect("task list");
1776 assert!(
1777 tasks.is_empty(),
1778 "the 400 must fire before any TaskRecord is minted"
1779 );
1780 }
1781
1782 #[tokio::test]
1786 async fn rekick_detached_returns_202_and_completes_in_background() {
1787 let state = test_state();
1788 let posted = crate::tasks_start(
1789 State(state.clone()),
1790 Json(post_tasks_req("detached rekick goal")),
1791 )
1792 .await
1793 .expect("tasks_start")
1794 .0;
1795
1796 let (status, rekicked) = task_rekick(
1797 State(state.clone()),
1798 Path(posted.task_id.to_string()),
1799 Some(Json(RunKickRequest {
1800 init_ctx_override: None,
1801 task_input_override: None,
1802 timeout_secs: None,
1803 detach: true,
1804 })),
1805 )
1806 .await
1807 .expect("task_rekick (detached)");
1808 assert_eq!(status, StatusCode::ACCEPTED);
1809 assert_eq!(rekicked.0.status, RunStatus::Running);
1810 assert_ne!(rekicked.0.run_id, posted.run_id);
1811
1812 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1813 assert_eq!(rec.status, RunStatus::Done);
1814 assert!(
1815 rec.result_ref.is_some(),
1816 "finalize_run must persist the background rekick's final_ctx"
1817 );
1818 }
1819
1820 #[tokio::test]
1824 async fn rekick_detached_with_timeout_secs_rejected() {
1825 let state = test_state();
1826 let posted = crate::tasks_start(
1827 State(state.clone()),
1828 Json(post_tasks_req("detached rekick ceiling goal")),
1829 )
1830 .await
1831 .expect("tasks_start")
1832 .0;
1833
1834 let err = match task_rekick(
1835 State(state.clone()),
1836 Path(posted.task_id.to_string()),
1837 Some(Json(RunKickRequest {
1838 init_ctx_override: None,
1839 task_input_override: None,
1840 timeout_secs: Some(60),
1841 detach: true,
1842 })),
1843 )
1844 .await
1845 {
1846 Err(e) => e,
1847 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1848 };
1849 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1850 assert!(
1851 err.message.contains("detach"),
1852 "error message must explain the detach/timeout_secs conflict: {}",
1853 err.message
1854 );
1855 let runs = state
1856 .run_store
1857 .list_by_task(&posted.task_id)
1858 .await
1859 .expect("runs list");
1860 assert_eq!(
1861 runs.len(),
1862 1,
1863 "the 400 must fire before a second Run is minted"
1864 );
1865 }
1866
1867 #[tokio::test]
1868 async fn rekick_adds_a_second_run_to_the_same_task() {
1869 let state = test_state();
1870 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1871 .await
1872 .expect("tasks_start")
1873 .0;
1874 let task_id = posted.task_id.clone();
1875 let first_run_id = posted.run_id.clone();
1876
1877 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1878 .await
1879 .expect("task_rekick");
1880 assert_eq!(status, StatusCode::CREATED);
1881 let second_run_id = rekicked.0.run_id.clone();
1882 assert_ne!(first_run_id, second_run_id);
1883
1884 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1885 .await
1886 .expect("task_get")
1887 .0;
1888 assert_eq!(
1889 detail.runs.len(),
1890 2,
1891 "expected 2 runs, got {:?}",
1892 detail.runs
1893 );
1894 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1895 assert!(ids.contains(&&first_run_id));
1896 assert!(ids.contains(&&second_run_id));
1897
1898 let first_run = detail
1903 .runs
1904 .iter()
1905 .find(|r| r.id == first_run_id)
1906 .expect("first run present in detail.runs");
1907 let second_run = detail
1908 .runs
1909 .iter()
1910 .find(|r| r.id == second_run_id)
1911 .expect("second run present in detail.runs");
1912 assert_eq!(
1913 first_run.step_entries.len(),
1914 1,
1915 "first run step_entries: {:?}",
1916 first_run.step_entries
1917 );
1918 assert_eq!(
1919 second_run.step_entries.len(),
1920 1,
1921 "second run step_entries: {:?}",
1922 second_run.step_entries
1923 );
1924 assert_eq!(
1925 first_run.step_entries[0].step_ref,
1926 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1927 );
1928 assert_eq!(
1929 second_run.step_entries[0].step_ref,
1930 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1931 );
1932 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1933 assert_eq!(
1934 second_run.step_entries[0].status,
1935 Some("passed".to_string())
1936 );
1937 assert_ne!(
1938 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1939 "each kick dispatches its own StepId — runs must not share step_entries"
1940 );
1941 }
1942
1943 #[tokio::test]
1944 async fn rekick_unknown_task_returns_404() {
1945 let state = test_state();
1946 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1950 Ok(_) => panic!("expected 404 for an unknown task"),
1951 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1952 }
1953 }
1954
1955 fn greeting_blueprint() -> Blueprint {
1964 Blueprint {
1965 schema_version: current_schema_version(),
1966 id: "tasks-test-greeting-bp".into(),
1967 flow: serde_json::from_value(serde_json::json!({
1968 "kind": "step",
1969 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1970 "in": {"op": "path", "at": "$.greeting"},
1971 "out": {"op": "path", "at": "$.out"},
1972 }))
1973 .expect("flow parse"),
1974 agents: vec![AgentDef {
1975 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1976 kind: AgentKind::RustFn,
1977 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1978 profile: None,
1979 meta: None,
1980 runner: None,
1981 runner_ref: None,
1982 verdict: None,
1983 }],
1984 operators: vec![],
1985 metas: vec![],
1986 hints: CompilerHints::default(),
1987 strategy: CompilerStrategy::default(),
1988 metadata: BlueprintMetadata::default(),
1989 spawner_hints: Default::default(),
1990 default_agent_kind: AgentKind::Operator,
1991 default_operator_kind: None,
1992 default_init_ctx: None,
1993 default_agent_ctx: None,
1994 default_context_policy: None,
1995 projection_placement: None,
1996 audits: vec![],
1997 degradation_policy: None,
1998 runners: vec![],
1999 default_runner: None,
2000 check_policy: None,
2001 blueprint_ref_includes: Vec::new(),
2002 }
2003 }
2004
2005 fn post_greeting_task_req(
2006 greeting: &str,
2007 project_root: Option<&str>,
2008 ) -> crate::TaskLaunchRequest {
2009 crate::TaskLaunchRequest {
2010 blueprint: BlueprintRef::Inline {
2011 value: Box::new(greeting_blueprint()),
2012 },
2013 init_ctx: serde_json::json!({ "greeting": greeting }),
2014 project_root: project_root.map(str::to_string),
2015 work_dir: None,
2016 task_metadata: None,
2017 ttl_secs: None,
2018 operator: None,
2019 operator_sid: None,
2020 timeout_secs: None,
2021 goal: Some("st4 rekick goal".to_string()),
2022 detach: false,
2023 check_policy: None,
2024 }
2025 }
2026
2027 #[tokio::test]
2028 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
2029 let state = test_state();
2032 let posted = crate::tasks_start(
2033 State(state.clone()),
2034 Json(post_greeting_task_req("from-task", None)),
2035 )
2036 .await
2037 .expect("tasks_start")
2038 .0;
2039 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2040
2041 let (status, rekicked) =
2042 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2043 .await
2044 .expect("task_rekick");
2045 assert_eq!(status, StatusCode::CREATED);
2046
2047 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2048 .await
2049 .expect("run_get")
2050 .0;
2051 assert_eq!(
2052 run.result_ref.expect("result_ref present")["out"]["echoed"],
2053 "from-task"
2054 );
2055 }
2056
2057 #[tokio::test]
2058 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
2059 let state = test_state();
2060 let posted = crate::tasks_start(
2061 State(state.clone()),
2062 Json(post_greeting_task_req("from-task", None)),
2063 )
2064 .await
2065 .expect("tasks_start")
2066 .0;
2067 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
2068
2069 let (status, rekicked) = task_rekick(
2070 State(state.clone()),
2071 Path(posted.task_id.to_string()),
2072 Some(Json(RunKickRequest {
2073 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
2074 task_input_override: None,
2075 timeout_secs: None,
2076 detach: false,
2077 })),
2078 )
2079 .await
2080 .expect("task_rekick");
2081 assert_eq!(status, StatusCode::CREATED);
2082
2083 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
2084 .await
2085 .expect("run_get")
2086 .0;
2087 assert_eq!(
2088 run.result_ref.expect("result_ref present")["out"]["echoed"],
2089 "from-run",
2090 "Run's init_ctx_override must win over the stored Task input_ctx"
2091 );
2092 }
2093
2094 #[tokio::test]
2095 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
2096 let state = test_state();
2104 let posted = crate::tasks_start(
2105 State(state.clone()),
2106 Json(post_greeting_task_req("from-task", Some("/repo"))),
2107 )
2108 .await
2109 .expect("tasks_start")
2110 .0;
2111
2112 let before = state
2113 .task_store
2114 .get(&posted.task_id)
2115 .await
2116 .expect("task fetch");
2117 let before_spec: Option<TaskInputSpec> = before
2118 .task_input_spec
2119 .as_ref()
2120 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2121 assert_eq!(
2122 before_spec,
2123 Some(TaskInputSpec {
2124 project_root: Some("/repo".to_string()),
2125 work_dir: None,
2126 task_metadata: None,
2127 })
2128 );
2129
2130 let (status, _rekicked) =
2131 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
2132 .await
2133 .expect("task_rekick");
2134 assert_eq!(status, StatusCode::CREATED);
2135
2136 let after = state
2137 .task_store
2138 .get(&posted.task_id)
2139 .await
2140 .expect("task fetch");
2141 assert_eq!(
2142 after.task_input_spec, before.task_input_spec,
2143 "rekick must not mutate the stored Task-level task_input_spec snapshot"
2144 );
2145 }
2146
2147 #[tokio::test]
2148 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
2149 let state = test_state();
2152 let posted = crate::tasks_start(
2153 State(state.clone()),
2154 Json(post_greeting_task_req("from-task", Some("/repo"))),
2155 )
2156 .await
2157 .expect("tasks_start")
2158 .0;
2159
2160 let (status, _rekicked) = task_rekick(
2161 State(state.clone()),
2162 Path(posted.task_id.to_string()),
2163 Some(Json(RunKickRequest {
2164 init_ctx_override: None,
2165 task_input_override: Some(TaskInputSpec {
2166 project_root: Some("/override".to_string()),
2167 work_dir: None,
2168 task_metadata: None,
2169 }),
2170 timeout_secs: None,
2171 detach: false,
2172 })),
2173 )
2174 .await
2175 .expect("task_rekick");
2176 assert_eq!(status, StatusCode::CREATED);
2177
2178 let after = state
2179 .task_store
2180 .get(&posted.task_id)
2181 .await
2182 .expect("task fetch");
2183 let after_spec: Option<TaskInputSpec> = after
2184 .task_input_spec
2185 .as_ref()
2186 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
2187 assert_eq!(
2188 after_spec,
2189 Some(TaskInputSpec {
2190 project_root: Some("/repo".to_string()),
2191 work_dir: None,
2192 task_metadata: None,
2193 }),
2194 "a per-Run task_input_override must not leak into the stored TaskRecord"
2195 );
2196 }
2197
2198 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
2212 crate::TaskLaunchRequest {
2213 blueprint: BlueprintRef::Inline {
2214 value: Box::new(identity_blueprint_with_operator_delegate()),
2215 },
2216 init_ctx: serde_json::json!({"in": "hello"}),
2217 project_root: None,
2218 work_dir: None,
2219 task_metadata: None,
2220 ttl_secs: None,
2221 operator: None,
2222 operator_sid: None,
2223 timeout_secs: None,
2224 goal: Some(goal.to_string()),
2225 detach: false,
2226 check_policy: None,
2227 }
2228 }
2229
2230 #[tokio::test]
2235 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
2236 let state = test_state();
2237 let posted = crate::tasks_start(
2238 State(state.clone()),
2239 Json(delegate_launch_req("operator delegate rekick goal")),
2240 )
2241 .await
2242 .expect("tasks_start (no operator referenced, dispatches through baseline)")
2243 .0;
2244 let started = std::time::Instant::now();
2248 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2249 let elapsed = started.elapsed();
2250
2251 let err = match result {
2252 Err(e) => e,
2253 Ok(_) => panic!(
2254 "rekicking a Task whose Blueprint declares operator_delegate with zero \
2255 attached operators must fail, not dispatch"
2256 ),
2257 };
2258 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2259 assert!(
2260 err.message.contains("no operator attached"),
2261 "error message must mention the missing operator: {}",
2262 err.message
2263 );
2264 assert!(
2265 elapsed < Duration::from_secs(1),
2266 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2267 );
2268 }
2269
2270 #[tokio::test]
2274 async fn rekick_stalled_operator_times_out() {
2275 let state = test_state();
2276 state
2277 .engine
2278 .register_operator("stall-op", Arc::new(StallingOperator))
2279 .await;
2280 let posted = crate::tasks_start(
2281 State(state.clone()),
2282 Json(delegate_launch_req("stalled rekick goal")),
2283 )
2284 .await
2285 .expect("tasks_start")
2286 .0;
2287
2288 let started = std::time::Instant::now();
2289 let result = tokio::time::timeout(
2293 Duration::from_secs(5),
2294 task_rekick(
2295 State(state),
2296 Path(posted.task_id.to_string()),
2297 Some(Json(RunKickRequest {
2298 init_ctx_override: None,
2299 task_input_override: None,
2300 timeout_secs: Some(1),
2301 detach: false,
2302 })),
2303 ),
2304 )
2305 .await
2306 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
2307 let elapsed = started.elapsed();
2308
2309 match &result {
2310 Err(e) => {
2311 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
2312 assert!(
2313 e.message.contains('1'),
2314 "error message must mention the configured 1s ceiling: {}",
2315 e.message
2316 );
2317 assert!(
2318 elapsed < Duration::from_secs(3),
2319 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2320 );
2321 }
2322 Ok(_) => {
2323 assert!(
2335 elapsed < Duration::from_secs(1),
2336 "a rekick that never engages an Operator (task_rekick has no \
2337 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
2338 );
2339 }
2340 }
2341 }
2342
2343 #[tokio::test]
2347 async fn rekick_timeout_secs_zero_rejected() {
2348 let state = test_state();
2349 let posted = crate::tasks_start(
2350 State(state.clone()),
2351 Json(post_tasks_req("zero timeout rekick goal")),
2352 )
2353 .await
2354 .expect("tasks_start")
2355 .0;
2356
2357 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2358 .await
2359 .expect("task_get")
2360 .0;
2361 let runs_before = before.runs.len();
2362
2363 let result = task_rekick(
2364 State(state.clone()),
2365 Path(posted.task_id.to_string()),
2366 Some(Json(RunKickRequest {
2367 init_ctx_override: None,
2368 task_input_override: None,
2369 timeout_secs: Some(0),
2370 detach: false,
2371 })),
2372 )
2373 .await;
2374 let err = match result {
2375 Err(e) => e,
2376 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2377 };
2378 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2379 assert!(
2380 err.message.contains("timeout_secs"),
2381 "error message must reference timeout_secs: {}",
2382 err.message
2383 );
2384
2385 let after = task_get(State(state), Path(posted.task_id.to_string()))
2386 .await
2387 .expect("task_get")
2388 .0;
2389 assert_eq!(
2390 after.runs.len(),
2391 runs_before,
2392 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
2393 );
2394 }
2395
2396 #[tokio::test]
2400 async fn rekick_non_operator_path_unaffected_by_guard_1() {
2401 let state = test_state();
2402 let posted = crate::tasks_start(
2403 State(state.clone()),
2404 Json(post_tasks_req("non-operator rekick goal")),
2405 )
2406 .await
2407 .expect("tasks_start")
2408 .0;
2409
2410 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2411 if let Err(e) = &result {
2412 panic!(
2413 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
2414 guard 1: {}",
2415 e.message
2416 );
2417 }
2418 }
2419
2420 #[tokio::test]
2421 async fn run_get_unknown_id_returns_404() {
2422 let state = test_state();
2423 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
2424 Ok(_) => panic!("expected 404 for an unknown run"),
2425 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2426 }
2427 }
2428
2429 #[tokio::test]
2430 async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
2431 let state = test_state();
2432 let posted = crate::tasks_start(
2433 State(state.clone()),
2434 Json(post_tasks_req("binding explain")),
2435 )
2436 .await
2437 .expect("tasks_start")
2438 .0;
2439 let run = state
2440 .run_store
2441 .get(&posted.run_id)
2442 .await
2443 .expect("stored run");
2444 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2445 let mut bound_agents: Vec<BoundAgent> =
2446 serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
2447 let bound = &mut bound_agents[0];
2448 bound.runner = Some(Runner::WsClaudeCode {
2449 variant: "coder".to_string(),
2450 tools: vec!["Read".to_string()],
2451 });
2452 bound.recompute_binding_digest().unwrap();
2453 let request_digest = bound.binding_digest.clone();
2454 bound
2455 .set_attestation(BindingAttestation {
2456 request_digest: request_digest.clone(),
2457 provider_id: "operator-manifest".to_string(),
2458 provider_revision: Some("claude-code-1.2".to_string()),
2459 resolved_model: Some("claude-sonnet-4".to_string()),
2460 effective_tools: vec!["Bash".to_string(), "Read".to_string()],
2461 launch_variant: Some("coder".to_string()),
2462 capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
2463 b"manifest-v1",
2464 )),
2465 })
2466 .unwrap();
2467 snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
2468 state
2469 .run_store
2470 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2471 .await
2472 .unwrap();
2473
2474 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2475 .await
2476 .expect("binding explain")
2477 .0;
2478 let entry = &explained.bindings[0];
2479 assert_eq!(entry.status, RunBindingStatus::Attested);
2480 assert_eq!(
2481 entry.requested.as_ref().unwrap().request_digest,
2482 request_digest
2483 );
2484 assert_eq!(
2485 entry
2486 .effective
2487 .as_ref()
2488 .unwrap()
2489 .provider_revision
2490 .as_deref(),
2491 Some("claude-code-1.2")
2492 );
2493 assert_eq!(
2494 entry
2495 .difference
2496 .as_ref()
2497 .unwrap()
2498 .additional_effective_tools,
2499 vec!["Bash"]
2500 );
2501 assert!(entry
2502 .difference
2503 .as_ref()
2504 .unwrap()
2505 .missing_requested_tools
2506 .is_empty());
2507 assert_ne!(entry.binding_digest, request_digest);
2508 }
2509
2510 #[tokio::test]
2511 async fn run_bindings_explain_reports_snapshot_origin() {
2512 let state = test_state();
2513 let posted =
2514 crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
2515 .await
2516 .expect("tasks_start")
2517 .0;
2518
2519 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
2521 .await
2522 .expect("binding explain")
2523 .0;
2524 assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
2525
2526 let run = state.run_store.get(&posted.run_id).await.unwrap();
2528 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2529 snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
2530 state
2531 .run_store
2532 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2533 .await
2534 .unwrap();
2535 let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
2536 .await
2537 .expect("binding explain")
2538 .0;
2539 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
2540
2541 snapshot
2545 .as_object_mut()
2546 .unwrap()
2547 .remove("bound_agents_origin");
2548 state
2549 .run_store
2550 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2551 .await
2552 .unwrap();
2553 let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2554 .await
2555 .expect("explain still 200 without an origin marker")
2556 .0;
2557 assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
2558 }
2559
2560 #[tokio::test]
2561 async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
2562 let state = test_state();
2563 let posted = crate::tasks_start(
2564 State(state.clone()),
2565 Json(post_tasks_req("legacy binding explain")),
2566 )
2567 .await
2568 .expect("tasks_start")
2569 .0;
2570 state
2571 .run_store
2572 .set_input_json(&posted.run_id, "{}".to_string())
2573 .await
2574 .unwrap();
2575
2576 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2577 .await
2578 .expect_err("legacy run must not be re-resolved");
2579 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
2580 assert!(error
2581 .message
2582 .contains("current Blueprint state was not consulted"));
2583 }
2584
2585 #[tokio::test]
2586 async fn run_bindings_explain_rejects_a_tampered_snapshot() {
2587 let state = test_state();
2588 let posted = crate::tasks_start(
2589 State(state.clone()),
2590 Json(post_tasks_req("tampered binding explain")),
2591 )
2592 .await
2593 .expect("tasks_start")
2594 .0;
2595 let run = state.run_store.get(&posted.run_id).await.unwrap();
2596 let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2597 snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
2598 state
2599 .run_store
2600 .set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
2601 .await
2602 .unwrap();
2603
2604 let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
2605 .await
2606 .expect_err("digest drift must fail closed");
2607 assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
2608 assert!(error.message.contains("inconsistent binding snapshot"));
2609 }
2610
2611 #[tokio::test]
2612 async fn task_get_unknown_id_returns_404() {
2613 let state = test_state();
2614 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
2615 Ok(_) => panic!("expected 404 for an unknown task"),
2616 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2617 }
2618 }
2619}