@@ -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+
19021909pub 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.
19762280pub 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