Skip to content

Commit 895b3c0

Browse files
authored
fix: make agent task retries idempotent (#10591)
1 parent 8cae0e4 commit 895b3c0

17 files changed

Lines changed: 1666 additions & 944 deletions

File tree

crates/homeboy-agents/src/agent_task_controller_service/actions.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ pub(super) fn execute_retry_action(
618618
action: &AgentTaskLoopPolicyActionRecord,
619619
target_run_id: &str,
620620
) -> Result<(Value, i32)> {
621-
let retry = agent_task_service::retry(target_run_id, None, false)?;
621+
let retry = agent_task_service::retry(target_run_id, None, false, false)?;
622622
let retry_run_id = retry.record.run_id.clone();
623623
if !record
624624
.task_lineage

crates/homeboy-agents/src/agent_task_lifecycle/lab_offload.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,12 @@ fn record_lab_offload_proxy(
570570
if record.state.is_terminal() {
571571
return Ok(record);
572572
}
573+
let now = chrono::Utc::now();
574+
record.lab_handoff = Some(AgentTaskLabHandoff::pending(
575+
runner_id,
576+
now.to_rfc3339(),
577+
(now + chrono::Duration::seconds(lab_handoff_acceptance_timeout_seconds())).to_rfc3339(),
578+
));
573579
let metadata = record.ensure_metadata_object();
574580
metadata.insert("kind".to_string(), json!("lab_offload_controller_proxy"));
575581
// This record is the controller's durable projection of a runner handoff.

crates/homeboy-agents/src/agent_task_lifecycle/lifecycle_ops.rs

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,14 @@ where
443443
}
444444
// A runner re-submitting a retry must not erase the predecessor identity
445445
// that makes the reservation discoverable through the indexed lookup.
446-
for key in ["retry_of", "retry_requested_at", "retry_origin"] {
446+
for key in [
447+
"retry_of",
448+
"retried_from",
449+
"retry_root",
450+
"retries",
451+
"retry_requested_at",
452+
"retry_origin",
453+
] {
447454
if let Some(value) = existing.metadata.get(key) {
448455
record.metadata[key] = value.clone();
449456
}
@@ -1612,7 +1619,77 @@ pub fn mark_resuming(run_id: &str) -> Result<AgentTaskRunRecord> {
16121619
}
16131620

16141621
pub fn retry(run_id: &str, requested_run_id: Option<&str>) -> Result<AgentTaskRunRecord> {
1622+
retry_with_force_inner(run_id, requested_run_id, false, false)
1623+
}
1624+
1625+
pub(crate) fn record_metadata_value(run_id: &str, key: &str, value: Value) -> Result<()> {
1626+
store::mutate_record(&sanitize_run_id(run_id), |record| {
1627+
record
1628+
.ensure_metadata_object()
1629+
.insert(key.to_string(), value.clone());
1630+
record.updated_at = Some(now_timestamp());
1631+
true
1632+
})
1633+
.map(|_| ())
1634+
}
1635+
1636+
/// Reserve one successor for the complete retry lineage before admitting it.
1637+
/// The advisory lock spans processes, so a lost CLI response can be retried
1638+
/// without creating a second queued controller run.
1639+
pub fn retry_with_force(
1640+
run_id: &str,
1641+
requested_run_id: Option<&str>,
1642+
force: bool,
1643+
) -> Result<AgentTaskRunRecord> {
1644+
retry_with_force_inner(run_id, requested_run_id, force, true)
1645+
}
1646+
1647+
fn retry_with_force_inner(
1648+
run_id: &str,
1649+
requested_run_id: Option<&str>,
1650+
force: bool,
1651+
enforce_lineage_reservation: bool,
1652+
) -> Result<AgentTaskRunRecord> {
16151653
let source = store::read_record(&resolve_run_id(run_id)?)?;
1654+
let root_run_id = retry_root_run_id(&source)?;
1655+
let _reservation = enforce_lineage_reservation
1656+
.then(|| RetryLineageLock::lock(&root_run_id))
1657+
.transpose()?;
1658+
let mut requested_run_id = requested_run_id;
1659+
if enforce_lineage_reservation {
1660+
let records = store::read_records()?;
1661+
let mut successors = records
1662+
.into_iter()
1663+
.filter(|record| record.run_id != root_run_id)
1664+
.filter(|record| retry_root_run_id(record).ok().as_deref() == Some(&root_run_id))
1665+
.collect::<Vec<_>>();
1666+
successors.sort_by(|left, right| left.run_id.cmp(&right.run_id));
1667+
if let Some(active) = successors.iter().find(|record| !record.state.is_terminal()) {
1668+
if !force {
1669+
// A caller can lose the response after the first durable write.
1670+
// Replaying its exact requested successor is an idempotent read,
1671+
// not an attempt to allocate beside the active reservation.
1672+
if requested_run_id == Some(active.run_id.as_str()) {
1673+
return Ok(active.clone());
1674+
}
1675+
return Err(active_retry_successor_error(active));
1676+
}
1677+
if requested_run_id == Some(active.run_id.as_str()) {
1678+
requested_run_id = None;
1679+
}
1680+
}
1681+
if !successors.is_empty() && !force {
1682+
return Err(Error::validation_invalid_argument(
1683+
"force",
1684+
format!(
1685+
"retry lineage rooted at '{}' already has terminal successor(s); use --force to create another retry",
1686+
root_run_id
1687+
),
1688+
Some(root_run_id),
1689+
None,
1690+
));
1691+
}
1692+
}
16161693
let mut plan = load_controller_plan(&source.run_id)?;
16171694
super::cook_workspace_restore::restore_initial_cook_candidate_workspace(&mut plan)?;
16181695
super::cook_workspace_restore::restore_follow_up_cook_candidate_workspace(&mut plan)?;
@@ -1648,8 +1725,12 @@ pub fn retry(run_id: &str, requested_run_id: Option<&str>) -> Result<AgentTaskRu
16481725
metadata.insert("retry_origin".to_string(), Value::Object(retry_origin));
16491726
}
16501727
metadata.insert("retry_of".to_string(), json!(source.run_id));
1728+
if enforce_lineage_reservation {
1729+
metadata.insert("retried_from".to_string(), json!(source.run_id));
1730+
metadata.insert("retry_root".to_string(), json!(root_run_id));
1731+
}
16511732
metadata.insert("retry_requested_at".to_string(), json!(now_timestamp()));
1652-
submit_plan_with_runtime_admission_on_runner_with_metadata(
1733+
let record = submit_plan_with_runtime_admission_on_runner_with_metadata(
16531734
&plan,
16541735
requested_run_id,
16551736
execution_runner_id(),
@@ -1660,9 +1741,107 @@ pub fn retry(run_id: &str, requested_run_id: Option<&str>) -> Result<AgentTaskRu
16601741
|| Ok(store::read_record(run_id)?.state.is_terminal()),
16611742
)
16621743
},
1744+
)?;
1745+
if enforce_lineage_reservation {
1746+
persist_retry_lineage(&source.run_id, &root_run_id, &record.run_id)?;
1747+
}
1748+
Ok(record)
1749+
}
1750+
1751+
const RETRY_LINEAGE_LIMIT: usize = 16;
1752+
1753+
struct RetryLineageLock {
1754+
#[allow(dead_code)]
1755+
file: File,
1756+
}
1757+
1758+
impl RetryLineageLock {
1759+
fn lock(root_run_id: &str) -> Result<Self> {
1760+
let path = paths::homeboy_data()?
1761+
.join("agent-task-runs")
1762+
.join("retry-lineages")
1763+
.join(format!("{}.lock", sanitize_run_id(root_run_id)));
1764+
if let Some(parent) = path.parent() {
1765+
fs::create_dir_all(parent)
1766+
.map_err(|error| Error::internal_io(error.to_string(), None))?;
1767+
}
1768+
let file = OpenOptions::new()
1769+
.create(true)
1770+
.read(true)
1771+
.write(true)
1772+
.open(&path)
1773+
.map_err(|error| {
1774+
Error::internal_io(error.to_string(), Some(path.display().to_string()))
1775+
})?;
1776+
#[cfg(unix)]
1777+
if unsafe { libc::flock(std::os::fd::AsRawFd::as_raw_fd(&file), libc::LOCK_EX) } != 0 {
1778+
return Err(Error::internal_io(
1779+
std::io::Error::last_os_error().to_string(),
1780+
Some(format!("lock retry lineage {root_run_id}")),
1781+
));
1782+
}
1783+
Ok(Self { file })
1784+
}
1785+
}
1786+
1787+
fn retry_root_run_id(record: &AgentTaskRunRecord) -> Result<String> {
1788+
let mut current = record.clone();
1789+
for _ in 0..RETRY_LINEAGE_LIMIT {
1790+
let Some(parent) = current.metadata.get("retry_of").and_then(Value::as_str) else {
1791+
return Ok(current.run_id);
1792+
};
1793+
current = store::read_record(&sanitize_run_id(parent))?;
1794+
}
1795+
Err(Error::validation_invalid_argument(
1796+
"retry_of",
1797+
"retry lineage exceeds the supported depth",
1798+
Some(record.run_id.clone()),
1799+
None,
1800+
))
1801+
}
1802+
1803+
fn active_retry_successor_error(record: &AgentTaskRunRecord) -> Error {
1804+
Error::validation_invalid_argument(
1805+
"run_id",
1806+
format!(
1807+
"active retry successor '{}' is {:?}; inspect it with `homeboy agent-task status {}`",
1808+
record.run_id, record.state, record.run_id
1809+
),
1810+
Some(record.run_id.clone()),
1811+
Some(vec![format!("homeboy agent-task status {}", record.run_id)]),
16631812
)
16641813
}
16651814

1815+
fn persist_retry_lineage(source_run_id: &str, root_run_id: &str, child_run_id: &str) -> Result<()> {
1816+
let mut targets = vec![sanitize_run_id(source_run_id)];
1817+
let root_run_id = sanitize_run_id(root_run_id);
1818+
if !targets.contains(&root_run_id) {
1819+
targets.push(root_run_id.clone());
1820+
}
1821+
for run_id in targets {
1822+
store::mutate_record(&run_id, |record| {
1823+
let metadata = record.ensure_metadata_object();
1824+
let lineage = metadata
1825+
.entry("retries".to_string())
1826+
.or_insert_with(|| json!([]));
1827+
if !lineage.is_array() {
1828+
*lineage = json!([]);
1829+
}
1830+
let retries = lineage.as_array_mut().expect("retry lineage is an array");
1831+
if !retries.iter().any(|entry| entry == child_run_id) {
1832+
retries.push(json!(child_run_id));
1833+
if retries.len() > RETRY_LINEAGE_LIMIT {
1834+
retries.drain(..retries.len() - RETRY_LINEAGE_LIMIT);
1835+
}
1836+
}
1837+
metadata.insert("retry_root".to_string(), json!(root_run_id));
1838+
record.updated_at = Some(now_timestamp());
1839+
true
1840+
})?;
1841+
}
1842+
Ok(())
1843+
}
1844+
16661845
/// Find the one lifecycle-first Cook retry reservation that can be bound to an
16671846
/// unbound recipe attempt. The `retry_of` lookup is backed by the observation
16681847
/// metadata index; the plan and attempt-shaped run id prevent adoption of an

crates/homeboy-agents/src/agent_task_lifecycle/operation_claims.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,46 @@ pub fn complete_cook_operation(run_id: &str, operation_key: &str, result: Value)
194194
Ok(())
195195
}
196196

197+
/// Restore a completed claim only when the caller has independently recovered
198+
/// the durable side effect. This repairs an interrupted source-record rewrite
199+
/// without allowing an arbitrary completion marker to invent work.
200+
pub fn recover_completed_cook_operation(
201+
run_id: &str,
202+
operation_key: &str,
203+
result: Value,
204+
) -> Result<()> {
205+
let run_id = sanitize_run_id(run_id);
206+
let now = now_timestamp();
207+
store::mutate_record(&run_id, |record| {
208+
let metadata = record.ensure_metadata_object();
209+
let claims = metadata
210+
.entry(OPERATION_CLAIMS_KEY.to_string())
211+
.or_insert_with(|| json!([]));
212+
if !claims.is_array() {
213+
*claims = json!([]);
214+
}
215+
let claims = claims.as_array_mut().expect("operation claims array");
216+
if let Some(claim) = claims
217+
.iter()
218+
.find(|claim| claim["operation_key"] == json!(operation_key))
219+
{
220+
return claim["state"] != json!("completed");
221+
}
222+
claims.push(json!({
223+
"operation_key": operation_key,
224+
"state": "completed",
225+
"leased_at": now,
226+
"completed_at": now,
227+
"owner_pid": std::process::id(),
228+
"result": result,
229+
"recovered_from_authoritative_successor": true,
230+
}));
231+
record.updated_at = Some(now.clone());
232+
true
233+
})?;
234+
Ok(())
235+
}
236+
197237
/// Terminalize a claimed operation that did not produce its external result.
198238
/// Failed claims retain their exact bounded diagnostic but are intentionally
199239
/// reclaimable by a later explicit continuation.

crates/homeboy-agents/src/agent_task_lifecycle/tests/submit_and_persist.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,89 @@ fn retry_first_visible_record_always_has_indexed_predecessor_identity() {
4444
});
4545
}
4646

47+
#[test]
48+
fn retry_lineage_refuses_an_active_successor_and_requires_force_after_terminal_successors() {
49+
with_isolated_home(|_| {
50+
let source_id = "retry-lineage-source";
51+
submit_plan(&test_plan(), Some(source_id)).expect("submit source");
52+
53+
let first = retry_with_force(source_id, Some("retry-lineage-first"), false)
54+
.expect("first retry reserves successor");
55+
let replayed = retry_with_force(source_id, Some("retry-lineage-first"), false)
56+
.expect("exact active retry reservation is idempotent");
57+
assert_eq!(replayed.run_id, first.run_id);
58+
let active = retry_with_force(source_id, Some("retry-lineage-second"), false)
59+
.expect_err("active successor prevents duplicate retry");
60+
assert!(active.message.contains("retry-lineage-first"));
61+
assert!(active
62+
.message
63+
.contains("homeboy agent-task status retry-lineage-first"));
64+
65+
let forced_active = retry_with_force(source_id, Some("retry-lineage-first"), true)
66+
.expect("force creates a distinct successor beside the active retry");
67+
assert_ne!(forced_active.run_id, first.run_id);
68+
assert_eq!(forced_active.metadata["retried_from"], source_id);
69+
assert_eq!(forced_active.metadata["retry_root"], source_id);
70+
71+
cancel_run(&first.run_id, Some("test terminal successor")).expect("terminalize successor");
72+
cancel_run(
73+
&forced_active.run_id,
74+
Some("test terminal forced successor"),
75+
)
76+
.expect("terminalize forced successor");
77+
let terminal = retry_with_force(source_id, Some("retry-lineage-second"), false)
78+
.expect_err("terminal successor requires explicit force");
79+
assert_eq!(terminal.details["field"], "force");
80+
81+
let forced = retry_with_force(source_id, Some("retry-lineage-second"), true)
82+
.expect("force creates next retry");
83+
assert_eq!(forced.metadata["retried_from"], source_id);
84+
assert_eq!(forced.metadata["retry_root"], source_id);
85+
let source = exact_record(source_id).expect("source record");
86+
assert_eq!(
87+
source.metadata["retries"],
88+
json!([
89+
"retry-lineage-first",
90+
forced_active.run_id,
91+
"retry-lineage-second"
92+
])
93+
);
94+
});
95+
}
96+
97+
#[test]
98+
fn runner_resubmission_preserves_existing_retry_lineage_metadata() {
99+
with_isolated_home(|_| {
100+
let source_id = "retry-resubmission-source";
101+
let first_retry_id = "retry-resubmission-first";
102+
let second_retry_id = "retry-resubmission-second";
103+
let plan = test_plan();
104+
submit_plan(&plan, Some(source_id)).expect("submit source");
105+
retry_with_force(source_id, Some(first_retry_id), false).expect("reserve first retry");
106+
retry_with_force(first_retry_id, Some(second_retry_id), true)
107+
.expect("record descendant retry");
108+
109+
let resubmitted = submit_plan_with_runtime_admission_on_runner(
110+
&plan,
111+
Some(first_retry_id),
112+
Some("fixture-runner".to_string()),
113+
|_| Ok(json!({ "runner": "fixture-runtime" })),
114+
)
115+
.expect("runner resubmits the existing retry record");
116+
117+
assert_eq!(resubmitted.metadata["retried_from"], source_id);
118+
assert_eq!(resubmitted.metadata["retry_root"], source_id);
119+
assert_eq!(resubmitted.metadata["retries"], json!([second_retry_id]));
120+
assert_eq!(resubmitted.metadata["retry_of"], source_id);
121+
assert_eq!(
122+
exact_record(first_retry_id)
123+
.expect("persisted resubmitted retry")
124+
.metadata["retries"],
125+
json!([second_retry_id])
126+
);
127+
});
128+
}
129+
47130
#[test]
48131
fn cook_progress_is_durable_across_active_and_terminal_lifecycle_states() {
49132
with_isolated_home(|_| {

0 commit comments

Comments
 (0)