@@ -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
16141621pub 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
0 commit comments