Skip to content

Commit 722fec9

Browse files
authored
fix: recover substantive Cook candidates (#10600)
* fix: recover substantive Cook candidates * test: update Cook notification fixtures
1 parent 70e8fab commit 722fec9

11 files changed

Lines changed: 1149 additions & 41 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ pub(crate) fn record_aggregate(
653653
crate::controller_scratch::finalize_run(&record.run_id)?;
654654
store::write_aggregate_and_record(record, aggregate)?;
655655
record_terminal_artifact_projection(record, aggregate)?;
656+
update_cook_candidate_after_completion(record, aggregate, None)?;
656657
Ok(record.clone())
657658
}
658659

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

Lines changed: 314 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,7 @@ pub fn reconcile_terminal_artifact_projection(run_id: &str) -> Result<bool> {
11121112
let _plan = store::read_controller_plan(&record.run_id)?;
11131113
let aggregate = store::read_aggregate(&record.run_id)?;
11141114
record_terminal_artifact_projection(&mut record, &aggregate)?;
1115+
update_cook_candidate_after_completion(&record, &aggregate, None)?;
11151116
Ok(true)
11161117
}
11171118

@@ -1899,8 +1900,26 @@ pub fn read_aggregate(run_id: &str) -> Result<AgentTaskAggregate> {
18991900
store::read_aggregate(&run_id)
19001901
}
19011902

1903+
/// Read an immutable attempt directly; unlike `read_aggregate`, this never
1904+
/// treats a Cook ID as an alias for its latest attempt.
1905+
pub fn read_attempt_aggregate(run_id: &str) -> Result<AgentTaskAggregate> {
1906+
store::read_aggregate(&sanitize_run_id(run_id))
1907+
}
1908+
19021909
pub fn aggregate_source(run_id: &str) -> Result<(String, PathBuf)> {
1903-
let record = status(run_id)?;
1910+
let selected_run_id = match cook_index(run_id).and_then(|_| select_cook_candidate(run_id)) {
1911+
Ok(selection) if selection.incomplete => {
1912+
return Err(Error::validation_invalid_argument(
1913+
"cook_id",
1914+
"candidate selection is incomplete after its bounded recovery window",
1915+
Some(run_id.to_string()),
1916+
None,
1917+
));
1918+
}
1919+
Ok(selection) if !selection.run_id.is_empty() => selection.run_id,
1920+
_ => run_id.to_string(),
1921+
};
1922+
let record = status(&selected_run_id)?;
19041923
record.aggregate_path.as_ref().ok_or_else(|| {
19051924
Error::validation_invalid_argument(
19061925
"run_id",
@@ -1934,7 +1953,15 @@ pub fn record_cook_attempt(
19341953
metadata.insert("cook_id".to_string(), json!(sanitize_run_id(cook_id)));
19351954
metadata.insert("cook_attempt".to_string(), json!(attempt));
19361955
store::write_record(&record)?;
1937-
store::write_cook_index_attempt(cook_id, attempt, run_id, recorded_at)
1956+
// Completion can precede Cook registration during handoff recovery. Re-read
1957+
// its persisted aggregate after the Cook identity is durable, then commit the
1958+
// attempt and substantive pointer in the same index write.
1959+
let candidate = store::read_aggregate(&record.run_id)
1960+
.ok()
1961+
.and_then(|aggregate| {
1962+
substantive_candidate_from_aggregate(&record.run_id, attempt, &aggregate, None)
1963+
});
1964+
store::write_cook_index_attempt(cook_id, attempt, run_id, recorded_at, candidate)
19381965
}
19391966

19401967
/// Record the controller-owned boundary that a resumed Cook must advance.
@@ -1971,6 +1998,283 @@ pub fn cook_index(cook_id: &str) -> Result<AgentTaskCookIndex> {
19711998
store::read_cook_index(&sanitize_run_id(cook_id))
19721999
}
19732000

2001+
#[cfg(test)]
2002+
pub(crate) fn replace_cook_index_for_test(index: &AgentTaskCookIndex) -> Result<()> {
2003+
store::write_cook_index_for_test(index)
2004+
}
2005+
2006+
/// The bounded controller-owned answer to which Cook attempt still owns a
2007+
/// candidate. The mutable latest-attempt alias is chronological history, not
2008+
/// candidate authority: a later metadata-only attempt must not erase a patch.
2009+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2010+
pub struct AgentTaskCookCandidateSelection {
2011+
pub schema: String,
2012+
pub cook_id: String,
2013+
pub run_id: String,
2014+
pub attempt: u32,
2015+
pub latest_attempt_run_id: String,
2016+
pub reason: String,
2017+
#[serde(default)]
2018+
pub incomplete: bool,
2019+
#[serde(default, skip_serializing_if = "Option::is_none")]
2020+
pub selected_task_id: Option<String>,
2021+
#[serde(default, skip_serializing_if = "Option::is_none")]
2022+
pub selected_artifact_id: Option<String>,
2023+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
2024+
pub skipped_newer_attempts: Vec<AgentTaskCookCandidateSkippedAttempt>,
2025+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
2026+
pub skipped_newer_run_ids: Vec<String>,
2027+
}
2028+
2029+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2030+
pub struct AgentTaskCookCandidateSkippedAttempt {
2031+
pub run_id: String,
2032+
pub reason: String,
2033+
}
2034+
2035+
const COOK_CANDIDATE_SELECTION_WINDOW: usize = 64;
2036+
2037+
/// Select the latest attempt with controller-readable actionable patch bytes.
2038+
/// Ties use run ID so duplicate attempt numbers remain deterministic. When no
2039+
/// attempt has candidate bytes, retain the legacy latest attempt for old runs.
2040+
pub fn select_cook_candidate(cook_id: &str) -> Result<AgentTaskCookCandidateSelection> {
2041+
let index = cook_index(cook_id)?;
2042+
if let Some(candidate) = index.latest_substantive_candidate.as_ref() {
2043+
if substantive_candidate(&candidate.run_id).as_ref()
2044+
== Some(&(candidate.task_id.clone(), candidate.artifact_id.clone()))
2045+
{
2046+
return Ok(AgentTaskCookCandidateSelection {
2047+
schema: "homeboy/agent-task-cook-candidate-selection/v1".to_string(),
2048+
cook_id: index.cook_id,
2049+
run_id: candidate.run_id.clone(),
2050+
attempt: candidate.attempt,
2051+
latest_attempt_run_id: index.latest_run_id,
2052+
reason: "latest_substantive_candidate_pointer".to_string(),
2053+
incomplete: false,
2054+
selected_task_id: Some(candidate.task_id.clone()),
2055+
selected_artifact_id: Some(candidate.artifact_id.clone()),
2056+
skipped_newer_attempts: Vec::new(),
2057+
skipped_newer_run_ids: Vec::new(),
2058+
});
2059+
}
2060+
}
2061+
// Legacy indexes predate the durable pointer. Their recovery path reads at
2062+
// most this fixed tail window and reports incomplete rather than widening.
2063+
let attempts = index
2064+
.attempts
2065+
.iter()
2066+
.rev()
2067+
.take(COOK_CANDIDATE_SELECTION_WINDOW)
2068+
.collect::<Vec<_>>();
2069+
let latest_attempt_run_id = index.latest_run_id.clone();
2070+
let mut skipped_newer_run_ids = Vec::new();
2071+
let mut skipped_newer_attempts = Vec::new();
2072+
for attempt in attempts.iter().take(COOK_CANDIDATE_SELECTION_WINDOW) {
2073+
if let Some((task_id, artifact_id)) = substantive_candidate(&attempt.run_id) {
2074+
return Ok(AgentTaskCookCandidateSelection {
2075+
schema: "homeboy/agent-task-cook-candidate-selection/v1".to_string(),
2076+
cook_id: index.cook_id,
2077+
run_id: attempt.run_id.clone(),
2078+
attempt: attempt.attempt,
2079+
latest_attempt_run_id,
2080+
reason: if skipped_newer_run_ids.is_empty() {
2081+
"latest_attempt_has_substantive_candidate".to_string()
2082+
} else {
2083+
"latest_substantive_candidate_after_non_substantive_attempts".to_string()
2084+
},
2085+
incomplete: false,
2086+
selected_task_id: Some(task_id),
2087+
selected_artifact_id: Some(artifact_id),
2088+
skipped_newer_attempts,
2089+
skipped_newer_run_ids,
2090+
});
2091+
}
2092+
skipped_newer_run_ids.push(attempt.run_id.clone());
2093+
skipped_newer_attempts.push(AgentTaskCookCandidateSkippedAttempt {
2094+
run_id: attempt.run_id.clone(),
2095+
reason: "no_verified_canonical_promotable_patch".to_string(),
2096+
});
2097+
}
2098+
if index.attempts.len() > COOK_CANDIDATE_SELECTION_WINDOW {
2099+
return Ok(AgentTaskCookCandidateSelection {
2100+
schema: "homeboy/agent-task-cook-candidate-selection/v1".to_string(),
2101+
cook_id: index.cook_id,
2102+
run_id: String::new(),
2103+
attempt: 0,
2104+
latest_attempt_run_id,
2105+
reason: "selection_window_exhausted_without_promotable_candidate".to_string(),
2106+
incomplete: true,
2107+
selected_task_id: None,
2108+
selected_artifact_id: None,
2109+
skipped_newer_attempts,
2110+
skipped_newer_run_ids,
2111+
});
2112+
}
2113+
let latest = attempts.first().ok_or_else(|| {
2114+
Error::validation_invalid_argument(
2115+
"cook_id",
2116+
"durable Cook index has no attempts",
2117+
Some(cook_id.to_string()),
2118+
None,
2119+
)
2120+
})?;
2121+
Ok(AgentTaskCookCandidateSelection {
2122+
schema: "homeboy/agent-task-cook-candidate-selection/v1".to_string(),
2123+
cook_id: index.cook_id,
2124+
run_id: latest.run_id.clone(),
2125+
attempt: latest.attempt,
2126+
latest_attempt_run_id,
2127+
reason: "no_substantive_candidate_evidence_preserve_latest_attempt_compatibility"
2128+
.to_string(),
2129+
incomplete: false,
2130+
selected_task_id: None,
2131+
selected_artifact_id: None,
2132+
skipped_newer_attempts,
2133+
skipped_newer_run_ids,
2134+
})
2135+
}
2136+
2137+
pub(crate) fn update_cook_candidate_after_completion(
2138+
record: &AgentTaskRunRecord,
2139+
aggregate: &AgentTaskAggregate,
2140+
promotion: Option<Value>,
2141+
) -> Result<()> {
2142+
let Some(cook_id) = record.metadata.get("cook_id").and_then(Value::as_str) else {
2143+
return Ok(());
2144+
};
2145+
let Some(attempt) = record.metadata.get("cook_attempt").and_then(Value::as_u64) else {
2146+
return Ok(());
2147+
};
2148+
let Some(candidate) =
2149+
substantive_candidate_from_aggregate(&record.run_id, attempt as u32, aggregate, promotion)
2150+
else {
2151+
return Ok(());
2152+
};
2153+
store::update_cook_index(cook_id, |index| {
2154+
replace_latest_substantive_candidate(index, candidate)
2155+
})?;
2156+
Ok(())
2157+
}
2158+
2159+
fn replace_latest_substantive_candidate(
2160+
index: &mut AgentTaskCookIndex,
2161+
candidate: AgentTaskCookLatestSubstantiveCandidate,
2162+
) -> bool {
2163+
let replace = index
2164+
.latest_substantive_candidate
2165+
.as_ref()
2166+
.is_none_or(|current| {
2167+
candidate.attempt > current.attempt
2168+
|| (candidate.attempt == current.attempt && candidate.run_id >= current.run_id)
2169+
});
2170+
if replace {
2171+
index.latest_substantive_candidate = Some(candidate);
2172+
}
2173+
replace
2174+
}
2175+
2176+
fn substantive_candidate_from_aggregate(
2177+
run_id: &str,
2178+
attempt: u32,
2179+
aggregate: &AgentTaskAggregate,
2180+
promotion: Option<Value>,
2181+
) -> Option<AgentTaskCookLatestSubstantiveCandidate> {
2182+
let (task_id, artifact_id) = substantive_candidate_in_aggregate(run_id, aggregate)?;
2183+
let outcome = aggregate
2184+
.outcomes
2185+
.iter()
2186+
.find(|outcome| outcome.task_id == task_id)?;
2187+
let artifact = outcome
2188+
.artifacts
2189+
.iter()
2190+
.find(|artifact| artifact.id == artifact_id)?;
2191+
let promotion_provenance = promotion
2192+
.as_ref()
2193+
.and_then(|value| value.get("provenance").cloned());
2194+
let destination_provenance = promotion.as_ref().map(|value| {
2195+
json!({
2196+
"to_worktree": value.get("to_worktree"),
2197+
"target": value.get("target"),
2198+
})
2199+
});
2200+
Some(AgentTaskCookLatestSubstantiveCandidate {
2201+
schema: "homeboy/agent-task-cook-latest-substantive-candidate/v1".to_string(),
2202+
run_id: run_id.to_string(),
2203+
attempt,
2204+
task_id,
2205+
artifact_id,
2206+
artifact_kind: artifact.kind.clone(),
2207+
artifact_sha256: artifact.sha256.clone(),
2208+
artifact_size_bytes: artifact.size_bytes,
2209+
integrity: json!({
2210+
"sha256": artifact.sha256,
2211+
"size_bytes": artifact.size_bytes,
2212+
"controller_projection": "verified",
2213+
"canonical_patch": true,
2214+
}),
2215+
promotion_provenance,
2216+
destination_provenance,
2217+
recorded_at: now_timestamp(),
2218+
})
2219+
}
2220+
2221+
fn substantive_candidate(run_id: &str) -> Option<(String, String)> {
2222+
// Candidate recovery is a bounded scan. Avoid the aggregate reader's
2223+
// reconciliation path when this controller record never projected one.
2224+
let record = exact_record(run_id).ok()?;
2225+
let aggregate_path = record.aggregate_path?;
2226+
if !std::path::Path::new(&aggregate_path).exists() {
2227+
return None;
2228+
}
2229+
let Ok(aggregate) = store::read_aggregate(run_id) else {
2230+
return None;
2231+
};
2232+
substantive_candidate_in_aggregate(run_id, &aggregate)
2233+
}
2234+
2235+
fn substantive_candidate_in_aggregate(
2236+
run_id: &str,
2237+
aggregate: &AgentTaskAggregate,
2238+
) -> Option<(String, String)> {
2239+
let outcome = aggregate.selected_outcome().or_else(|| {
2240+
(aggregate.outcomes.len() == 1)
2241+
.then(|| aggregate.outcomes.first())
2242+
.flatten()
2243+
});
2244+
let Some(outcome) = outcome else {
2245+
return None;
2246+
};
2247+
// Metadata alone (and typed artifact envelopes) cannot authorize recovery.
2248+
// Selection requires controller-readable bytes that pass the same integrity
2249+
// and canonical patch normalization used by promotion.
2250+
outcome.artifacts.iter().find_map(|artifact| {
2251+
if !crate::agent_task_timeout_artifacts::is_actionable_patch_artifact(artifact) {
2252+
return None;
2253+
}
2254+
let Some(path) = crate::agent_task_lifecycle::verified_controller_artifact_projection_path(
2255+
run_id,
2256+
&outcome.task_id,
2257+
artifact,
2258+
)
2259+
.ok()
2260+
.flatten() else {
2261+
return None;
2262+
};
2263+
std::fs::canonicalize(path)
2264+
.ok()
2265+
.and_then(|path| std::fs::read_to_string(path).ok())
2266+
.and_then(|bytes| {
2267+
(crate::agent_task_promotion::validate_artifact_content(artifact, &bytes).is_ok()
2268+
&& crate::agent_task_promotion::normalize_promotion_patch(
2269+
&bytes,
2270+
"candidate-selection",
2271+
)
2272+
.is_ok_and(|patch| !patch.content.trim().is_empty()))
2273+
.then(|| (outcome.task_id.clone(), artifact.id.clone()))
2274+
})
2275+
})
2276+
}
2277+
19742278
/// Read one durable attempt without resolving a cook ID through its latest
19752279
/// index entry. Recovery must inspect historical source attempts directly.
19762280
pub fn exact_record(run_id: &str) -> Result<AgentTaskRunRecord> {
@@ -2011,13 +2315,17 @@ pub fn record_promotion(run_id: &str, promotion: Value) -> Result<AgentTaskRunRe
20112315
.as_array_mut()
20122316
.expect("promotions array")
20132317
.push(promotion.clone());
2014-
metadata.insert("latest_promotion".to_string(), promotion);
2318+
metadata.insert("latest_promotion".to_string(), promotion.clone());
20152319
true
20162320
})?;
2017-
match record {
2018-
Some(record) => Ok(record),
2019-
None => store::read_record(&run_id),
2321+
let record = match record {
2322+
Some(record) => record,
2323+
None => store::read_record(&run_id)?,
2324+
};
2325+
if let Ok(aggregate) = store::read_aggregate(&run_id) {
2326+
update_cook_candidate_after_completion(&record, &aggregate, Some(promotion))?;
20202327
}
2328+
Ok(record)
20212329
}
20222330

20232331
/// Persist the controller publication result separately from promotion so a

0 commit comments

Comments
 (0)