|
1 | 1 | use homeboy::cli_surface::{Cli, Commands}; |
| 2 | +use homeboy::core::agent_tasks::lifecycle as agent_task_lifecycle; |
2 | 3 | use homeboy::core::command_execution_plan::CommandSourceMaterialization; |
3 | 4 | use homeboy::core::component::{self, TargetSpec}; |
4 | 5 | use homeboy::core::git; |
@@ -78,8 +79,20 @@ pub fn route_after_parse( |
78 | 79 | None |
79 | 80 | }; |
80 | 81 |
|
| 82 | + let retry_handoff = if lab_command.is_some() && inferred_runner_id.is_some() { |
| 83 | + materialize_agent_task_retry_handoff(cli, normalized_args)? |
| 84 | + } else { |
| 85 | + None |
| 86 | + }; |
| 87 | + let normalized_args = retry_handoff |
| 88 | + .as_ref() |
| 89 | + .map(|handoff| handoff.args.as_slice()) |
| 90 | + .unwrap_or(normalized_args); |
81 | 91 | let observer = lab_dispatch_observer(cli, normalized_args, inferred_runner_id.as_deref()); |
82 | | - let active_run_id = observer.run_id().map(str::to_string); |
| 92 | + let active_run_id = observer |
| 93 | + .run_id() |
| 94 | + .map(str::to_string) |
| 95 | + .or_else(|| retry_handoff.as_ref().map(|handoff| handoff.run_id.clone())); |
83 | 96 |
|
84 | 97 | let capture_mutation_patch = cli.command.lab_offload_captures_mutation_patch(); |
85 | 98 | let mutation_flag = cli.command.lab_offload_mutation_flag(); |
@@ -128,7 +141,11 @@ pub fn route_after_parse( |
128 | 141 | }, |
129 | 142 | inferred_runner_id.as_deref(), |
130 | 143 | observer, |
131 | | - )?; |
| 144 | + ) |
| 145 | + .map_err(|error| match retry_handoff.as_ref() { |
| 146 | + Some(handoff) => persist_retry_handoff_preacceptance_failure(handoff, error), |
| 147 | + None => error, |
| 148 | + })?; |
132 | 149 |
|
133 | 150 | match outcome { |
134 | 151 | LabRouteOutcome::RunLocal => { |
@@ -166,12 +183,88 @@ fn lab_route_dispatch_timeout( |
166 | 183 | if matches!(command, Commands::Trace(_)) { |
167 | 184 | return Some(lab_routing::lab_trace_dispatch_timeout()); |
168 | 185 | } |
169 | | - if detach_after_handoff && is_agent_task_fanout_cook_batch_run_plan(command) { |
| 186 | + if detach_after_handoff && is_detached_agent_task_handoff(command) { |
170 | 187 | return Some(lab_routing::lab_trace_dispatch_timeout()); |
171 | 188 | } |
172 | 189 | None |
173 | 190 | } |
174 | 191 |
|
| 192 | +struct AgentTaskRetryHandoff { |
| 193 | + args: Vec<String>, |
| 194 | + run_id: String, |
| 195 | + plan: homeboy::core::agent_tasks::scheduler::AgentTaskPlan, |
| 196 | +} |
| 197 | + |
| 198 | +/// Retries are controller-owned because the source plan lives in the local |
| 199 | +/// durable lifecycle store. Materialize it before Lab dispatch, then run the |
| 200 | +/// replacement plan remotely under the new durable run id. |
| 201 | +fn materialize_agent_task_retry_handoff( |
| 202 | + cli: &Cli, |
| 203 | + normalized_args: &[String], |
| 204 | +) -> homeboy::core::Result<Option<AgentTaskRetryHandoff>> { |
| 205 | + let Commands::AgentTask(crate::commands::agent_task::AgentTaskArgs { |
| 206 | + command: crate::commands::agent_task::AgentTaskCommand::Retry(retry), |
| 207 | + }) = &cli.command |
| 208 | + else { |
| 209 | + return Ok(None); |
| 210 | + }; |
| 211 | + if !retry.run { |
| 212 | + return Ok(None); |
| 213 | + } |
| 214 | + |
| 215 | + let record = agent_task_lifecycle::retry(&retry.run_id, retry.new_run_id.as_deref())?; |
| 216 | + let plan = agent_task_lifecycle::load_plan(&record.run_id)?; |
| 217 | + let serialized_plan = serde_json::to_string(&plan).map_err(|error| { |
| 218 | + Error::internal_json( |
| 219 | + error.to_string(), |
| 220 | + Some("serialize agent-task retry plan for Lab handoff".to_string()), |
| 221 | + ) |
| 222 | + })?; |
| 223 | + let agent_task_index = normalized_args |
| 224 | + .iter() |
| 225 | + .position(|arg| arg == "agent-task") |
| 226 | + .ok_or_else(|| { |
| 227 | + Error::internal_unexpected("agent-task retry argv was missing agent-task") |
| 228 | + })?; |
| 229 | + let mut args = normalized_args[..agent_task_index].to_vec(); |
| 230 | + args.extend([ |
| 231 | + "agent-task".to_string(), |
| 232 | + "run-plan".to_string(), |
| 233 | + "--plan".to_string(), |
| 234 | + serialized_plan, |
| 235 | + "--record-run-id".to_string(), |
| 236 | + record.run_id.clone(), |
| 237 | + ]); |
| 238 | + |
| 239 | + Ok(Some(AgentTaskRetryHandoff { |
| 240 | + args, |
| 241 | + run_id: record.run_id, |
| 242 | + plan, |
| 243 | + })) |
| 244 | +} |
| 245 | + |
| 246 | +fn persist_retry_handoff_preacceptance_failure( |
| 247 | + handoff: &AgentTaskRetryHandoff, |
| 248 | + error: Error, |
| 249 | +) -> Error { |
| 250 | + let recovery = format!( |
| 251 | + "Fix the Lab preflight failure, then retry with `homeboy agent-task retry {} --run --runner <runner-id> --detach-after-handoff`.", |
| 252 | + handoff.run_id |
| 253 | + ); |
| 254 | + if let Err(record_error) = agent_task_lifecycle::record_pre_execution_failure( |
| 255 | + &handoff.run_id, |
| 256 | + &handoff.plan, |
| 257 | + "detached_lab_handoff_preacceptance", |
| 258 | + &error, |
| 259 | + ) { |
| 260 | + return error.with_hint(format!( |
| 261 | + "{recovery} Homeboy also could not persist the replacement-run failure: {}", |
| 262 | + record_error.message |
| 263 | + )); |
| 264 | + } |
| 265 | + error.with_hint(recovery) |
| 266 | +} |
| 267 | + |
175 | 268 | /// Insert one env pair into the overrides, recording the key as secret when |
176 | 269 | /// the redaction policy considers the key sensitive or the value redacted. |
177 | 270 | fn insert_lab_env_override( |
@@ -503,6 +596,18 @@ fn is_agent_task_fanout_cook_batch_run_plan(command: &Commands) -> bool { |
503 | 596 | ) |
504 | 597 | } |
505 | 598 |
|
| 599 | +fn is_detached_agent_task_handoff(command: &Commands) -> bool { |
| 600 | + is_agent_task_fanout_cook_batch_run_plan(command) |
| 601 | + || matches!( |
| 602 | + command, |
| 603 | + Commands::AgentTask(crate::commands::agent_task::AgentTaskArgs { |
| 604 | + command: crate::commands::agent_task::AgentTaskCommand::Retry( |
| 605 | + crate::commands::agent_task::RetryArgs { run: true, .. }, |
| 606 | + ), |
| 607 | + }) |
| 608 | + ) |
| 609 | +} |
| 610 | + |
506 | 611 | fn run_rig_source_management_on_runner( |
507 | 612 | runner_id: &str, |
508 | 613 | normalized_args: &[String], |
@@ -1539,6 +1644,91 @@ mod tests { |
1539 | 1644 | assert_eq!(lab_route_dispatch_timeout(&no_detach.command, false), None); |
1540 | 1645 | } |
1541 | 1646 |
|
| 1647 | + #[test] |
| 1648 | + fn detached_agent_task_retry_uses_bounded_handoff_timeout() { |
| 1649 | + let cli = Cli::parse_from([ |
| 1650 | + "homeboy", |
| 1651 | + "--detach-after-handoff", |
| 1652 | + "agent-task", |
| 1653 | + "retry", |
| 1654 | + "failed-run", |
| 1655 | + "--run", |
| 1656 | + "--runner", |
| 1657 | + "homeboy-lab", |
| 1658 | + ]); |
| 1659 | + |
| 1660 | + assert_eq!( |
| 1661 | + lab_route_dispatch_timeout(&cli.command, cli.detach_after_handoff), |
| 1662 | + Some(lab_routing::lab_trace_dispatch_timeout()) |
| 1663 | + ); |
| 1664 | + } |
| 1665 | + |
| 1666 | + #[test] |
| 1667 | + fn detached_retry_materializes_failed_plan_and_persists_bounded_preacceptance_failure() { |
| 1668 | + crate::test_support::with_isolated_home(|_| { |
| 1669 | + let source_plan = homeboy::core::agent_tasks::scheduler::AgentTaskPlan::new( |
| 1670 | + "failed-retry-source", |
| 1671 | + Vec::new(), |
| 1672 | + ); |
| 1673 | + agent_task_lifecycle::submit_plan(&source_plan, Some("failed-run")) |
| 1674 | + .expect("source run submitted"); |
| 1675 | + let source_plan = agent_task_lifecycle::load_plan("failed-run").expect("source plan"); |
| 1676 | + let failure = Error::internal_unexpected("provider exited before completion"); |
| 1677 | + agent_task_lifecycle::record_pre_execution_failure( |
| 1678 | + "failed-run", |
| 1679 | + &source_plan, |
| 1680 | + "provider_execution", |
| 1681 | + &failure, |
| 1682 | + ) |
| 1683 | + .expect("source failure persisted"); |
| 1684 | + |
| 1685 | + let normalized = [ |
| 1686 | + "homeboy", |
| 1687 | + "--detach-after-handoff", |
| 1688 | + "agent-task", |
| 1689 | + "retry", |
| 1690 | + "failed-run", |
| 1691 | + "--run", |
| 1692 | + "--runner", |
| 1693 | + "homeboy-lab", |
| 1694 | + ] |
| 1695 | + .into_iter() |
| 1696 | + .map(str::to_string) |
| 1697 | + .collect::<Vec<_>>(); |
| 1698 | + let cli = Cli::parse_from(&normalized); |
| 1699 | + let handoff = materialize_agent_task_retry_handoff(&cli, &normalized) |
| 1700 | + .expect("retry handoff materialized") |
| 1701 | + .expect("retry handoff"); |
| 1702 | + |
| 1703 | + assert_eq!(handoff.args[2], "agent-task"); |
| 1704 | + assert_eq!(handoff.args[3], "run-plan"); |
| 1705 | + assert_eq!(handoff.args[4], "--plan"); |
| 1706 | + assert_eq!(handoff.args[6], "--record-run-id"); |
| 1707 | + assert_eq!(handoff.args[7], handoff.run_id); |
| 1708 | + let replacement = agent_task_lifecycle::status(&handoff.run_id).expect("replacement"); |
| 1709 | + assert_eq!(replacement.metadata["retry_of"], "failed-run"); |
| 1710 | + |
| 1711 | + let error = persist_retry_handoff_preacceptance_failure( |
| 1712 | + &handoff, |
| 1713 | + Error::internal_unexpected("runner preflight rejected the handoff"), |
| 1714 | + ); |
| 1715 | + assert!(error |
| 1716 | + .hints |
| 1717 | + .iter() |
| 1718 | + .any(|hint| hint.message.contains("agent-task retry") |
| 1719 | + && hint.message.contains(&handoff.run_id))); |
| 1720 | + let replacement = agent_task_lifecycle::status(&handoff.run_id).expect("failed retry"); |
| 1721 | + assert_eq!( |
| 1722 | + replacement.state, |
| 1723 | + homeboy::core::agent_tasks::lifecycle::AgentTaskRunState::Failed |
| 1724 | + ); |
| 1725 | + assert_eq!( |
| 1726 | + replacement.metadata["pre_execution_failure"]["phase"], |
| 1727 | + "detached_lab_handoff_preacceptance" |
| 1728 | + ); |
| 1729 | + }); |
| 1730 | + } |
| 1731 | + |
1542 | 1732 | #[test] |
1543 | 1733 | fn agent_task_fanout_dispatch_id_uses_explicit_or_stable_default() { |
1544 | 1734 | let cli = Cli::parse_from([ |
|
0 commit comments