Skip to content

Commit 307b77c

Browse files
authored
Merge pull request #7859 from Extra-Chill/fix/7856-detached-retry-handoff
Return detached retries after durable handoff
2 parents e339725 + 03a0aa8 commit 307b77c

3 files changed

Lines changed: 221 additions & 9 deletions

File tree

src/commands/infra/route.rs

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use homeboy::cli_surface::{Cli, Commands};
2+
use homeboy::core::agent_tasks::lifecycle as agent_task_lifecycle;
23
use homeboy::core::command_execution_plan::CommandSourceMaterialization;
34
use homeboy::core::component::{self, TargetSpec};
45
use homeboy::core::git;
@@ -78,8 +79,20 @@ pub fn route_after_parse(
7879
None
7980
};
8081

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);
8191
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()));
8396

8497
let capture_mutation_patch = cli.command.lab_offload_captures_mutation_patch();
8598
let mutation_flag = cli.command.lab_offload_mutation_flag();
@@ -128,7 +141,11 @@ pub fn route_after_parse(
128141
},
129142
inferred_runner_id.as_deref(),
130143
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+
})?;
132149

133150
match outcome {
134151
LabRouteOutcome::RunLocal => {
@@ -166,12 +183,88 @@ fn lab_route_dispatch_timeout(
166183
if matches!(command, Commands::Trace(_)) {
167184
return Some(lab_routing::lab_trace_dispatch_timeout());
168185
}
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) {
170187
return Some(lab_routing::lab_trace_dispatch_timeout());
171188
}
172189
None
173190
}
174191

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+
175268
/// Insert one env pair into the overrides, recording the key as secret when
176269
/// the redaction policy considers the key sensitive or the value redacted.
177270
fn insert_lab_env_override(
@@ -503,6 +596,18 @@ fn is_agent_task_fanout_cook_batch_run_plan(command: &Commands) -> bool {
503596
)
504597
}
505598

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+
506611
fn run_rig_source_management_on_runner(
507612
runner_id: &str,
508613
normalized_args: &[String],
@@ -1539,6 +1644,91 @@ mod tests {
15391644
assert_eq!(lab_route_dispatch_timeout(&no_detach.command, false), None);
15401645
}
15411646

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+
15421732
#[test]
15431733
fn agent_task_fanout_dispatch_id_uses_explicit_or_stable_default() {
15441734
let cli = Cli::parse_from([

src/core/agent_tasks.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,12 @@ pub mod lifecycle {
237237
aggregate_source, artifacts, cancel, cancel_run, claim_next_queued_run,
238238
cook_attempt_run_id, cook_index, list_records, load_plan, logs, mark_resuming,
239239
mark_running, record_completed_run, record_cook_attempt, record_pre_dispatch_failure,
240-
record_promotion, record_remote_dispatch_failure, record_run_aggregate, retry,
241-
run_record_exists, run_status, status, submit_plan, AgentTaskArtifactRef,
242-
AgentTaskCookIndex, AgentTaskCookIndexAttempt, AgentTaskEventEnvelope,
243-
AgentTaskPreDispatchFailure, AgentTaskRemoteDispatchFailure, AgentTaskRunArtifacts,
244-
AgentTaskRunLog, AgentTaskRunProviderHandle, AgentTaskRunRecord, AgentTaskRunState,
245-
AgentTaskRunStatus, AgentTaskRunTask,
240+
record_pre_execution_failure, record_promotion, record_remote_dispatch_failure,
241+
record_run_aggregate, retry, run_record_exists, run_status, status, submit_plan,
242+
AgentTaskArtifactRef, AgentTaskCookIndex, AgentTaskCookIndexAttempt,
243+
AgentTaskEventEnvelope, AgentTaskPreDispatchFailure, AgentTaskRemoteDispatchFailure,
244+
AgentTaskRunArtifacts, AgentTaskRunLog, AgentTaskRunProviderHandle, AgentTaskRunRecord,
245+
AgentTaskRunState, AgentTaskRunStatus, AgentTaskRunTask,
246246
};
247247
}
248248

src/core/runner/lab/agent_task_bridge.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,9 @@ pub(super) fn ensure_agent_task_dispatch_run_id_with(
600600
args: &[String],
601601
preferred: Option<&str>,
602602
) -> Option<(Vec<String>, String)> {
603+
if let Some((_, run_id)) = agent_task_run_plan_recording_args(args) {
604+
return Some((args.to_vec(), run_id));
605+
}
603606
let invocation = CommandInvocation::for_subcommand(args, "agent-task")?;
604607
let action_index = invocation.child_index_matching(&["cook", "dispatch"])?;
605608

@@ -1362,6 +1365,25 @@ mod tests {
13621365
assert_eq!(out, args);
13631366
}
13641367

1368+
#[test]
1369+
fn ensure_agent_task_dispatch_run_id_with_uses_materialized_run_plan_id() {
1370+
let args = vec![
1371+
"homeboy".to_string(),
1372+
"agent-task".to_string(),
1373+
"run-plan".to_string(),
1374+
"--plan".to_string(),
1375+
"@/runner/retry-plan.json".to_string(),
1376+
"--record-run-id".to_string(),
1377+
"retry-run".to_string(),
1378+
];
1379+
1380+
let (out, run_id) = ensure_agent_task_dispatch_run_id_with(&args, None)
1381+
.expect("materialized run-plan has a durable run id");
1382+
1383+
assert_eq!(run_id, "retry-run");
1384+
assert_eq!(out, args);
1385+
}
1386+
13651387
#[test]
13661388
fn dispatch_run_isolation_token_reuses_explicit_run_id() {
13671389
let args = vec![

0 commit comments

Comments
 (0)