@@ -22,6 +22,66 @@ pub(crate) fn disconnect_with_force(
2222 disconnect_with_session ( runner_id, None , force)
2323}
2424
25+ /// Recover the controller-owned side of a wedged tunnel without observing or
26+ /// mutating the remote daemon. Remote jobs intentionally remain ambiguous.
27+ pub ( crate ) fn disconnect_local_recovery ( runner_id : & str ) -> Result < RunnerDisconnectReport > {
28+ let session = read_session ( runner_id) ?;
29+ let session_path = session_path ( runner_id) ?. display ( ) . to_string ( ) ;
30+ if let Some ( session) = session. as_ref ( ) {
31+ if session. mode == RunnerTunnelMode :: DirectSsh {
32+ if let Some ( pid) = session. tunnel_pid {
33+ terminate_pid ( pid) ;
34+ }
35+ }
36+ let removed = remove_session_if_matches ( runner_id, session) ?;
37+ if removed {
38+ let _ = remove_ownership_if_matches ( runner_id, session) ?;
39+ }
40+ return Ok ( RunnerDisconnectReport {
41+ runner_id : runner_id. to_string ( ) ,
42+ disconnected : removed,
43+ partial : true ,
44+ remote_error : Some (
45+ "remote daemon was not contacted; its jobs and lifecycle remain ambiguous"
46+ . to_string ( ) ,
47+ ) ,
48+ local_recovery_command : None ,
49+ session : ( !removed) . then ( || session. clone ( ) ) ,
50+ session_path,
51+ } ) ;
52+ }
53+ Ok ( RunnerDisconnectReport {
54+ runner_id : runner_id. to_string ( ) ,
55+ disconnected : false ,
56+ partial : true ,
57+ remote_error : Some (
58+ "no controller-local session was present; remote daemon was not contacted" . to_string ( ) ,
59+ ) ,
60+ local_recovery_command : None ,
61+ session : None ,
62+ session_path,
63+ } )
64+ }
65+
66+ fn partial_disconnect_report (
67+ runner_id : & str ,
68+ session : Option < RunnerSession > ,
69+ remote_error : impl Into < String > ,
70+ ) -> Result < RunnerDisconnectReport > {
71+ Ok ( RunnerDisconnectReport {
72+ runner_id : runner_id. to_string ( ) ,
73+ disconnected : false ,
74+ partial : true ,
75+ remote_error : Some ( remote_error. into ( ) ) ,
76+ local_recovery_command : Some ( format ! (
77+ "homeboy runner disconnect {} --local-recovery" ,
78+ shell:: quote_arg( runner_id)
79+ ) ) ,
80+ session,
81+ session_path : session_path ( runner_id) ?. display ( ) . to_string ( ) ,
82+ } )
83+ }
84+
2585/// Stop the daemon through the current live session after confirming it still
2686/// owns the remote daemon observed by a caller's promotion transaction.
2787pub ( crate ) fn disconnect_with_session (
@@ -66,8 +126,14 @@ pub(crate) fn disconnect_with_session(
66126 // SSH and clean up stale local tunnel processes only after its stop.
67127 let retained_generations =
68128 super :: super :: generation_store:: live_sessions ( runner_id, Some ( session) ) ?;
129+ let authoritative_status = match probe_authoritative_daemon_status ( runner_id) {
130+ Ok ( status) => status,
131+ Err ( error) => {
132+ return partial_disconnect_report ( runner_id, session. clone ( ) . into ( ) , error. message )
133+ }
134+ } ;
69135 if remote_daemon:: authoritative_stale_generations_are_dead (
70- & probe_authoritative_daemon_status ( runner_id ) ? ,
136+ & authoritative_status ,
71137 & eligible_stale_generation_leases ( & retained_generations) . unwrap_or_default ( ) ,
72138 ) {
73139 let leases =
@@ -78,13 +144,23 @@ pub(crate) fn disconnect_with_session(
78144 return Ok ( RunnerDisconnectReport {
79145 runner_id : runner_id. to_string ( ) ,
80146 disconnected : true ,
147+ partial : false ,
148+ remote_error : None ,
149+ local_recovery_command : None ,
81150 session : None ,
82151 session_path : session_path ( runner_id) ?. display ( ) . to_string ( ) ,
83152 } ) ;
84153 }
85- if let Some ( authoritative_session) =
86- reconcile_authoritative_idle_stale_generations ( runner_id, & retained_generations) ?
87- {
154+ let authoritative_session = match reconcile_authoritative_idle_stale_generations (
155+ runner_id,
156+ & retained_generations,
157+ ) {
158+ Ok ( session) => session,
159+ Err ( error) => {
160+ return partial_disconnect_report ( runner_id, session. clone ( ) . into ( ) , error. message )
161+ }
162+ } ;
163+ if let Some ( authoritative_session) = authoritative_session {
88164 * session = authoritative_session. clone ( ) ;
89165 }
90166 let mut reconciled_tunnel_pids = retained_generations
@@ -115,12 +191,18 @@ pub(crate) fn disconnect_with_session(
115191 }
116192 }
117193 if !unresolved. is_empty ( ) {
118- return Err ( Error :: validation_invalid_argument (
119- "disconnect" ,
120- format ! ( "runner `{runner_id}` has unresolved daemon generations; sessions and ledger were retained" ) ,
121- Some ( runner_id. to_string ( ) ) ,
122- Some ( unresolved. into_iter ( ) . map ( |entry| entry. to_string ( ) ) . collect ( ) ) ,
123- ) ) ;
194+ return partial_disconnect_report (
195+ runner_id,
196+ session. clone ( ) . into ( ) ,
197+ format ! (
198+ "remote daemon stop was not proven; sessions and ledger were retained: {}" ,
199+ unresolved
200+ . into_iter( )
201+ . map( |entry| entry. to_string( ) )
202+ . collect:: <Vec <_>>( )
203+ . join( ", " )
204+ ) ,
205+ ) ;
124206 }
125207 for pid in reconciled_tunnel_pids {
126208 terminate_pid ( pid) ;
@@ -136,12 +218,17 @@ pub(crate) fn disconnect_with_session(
136218 // new generation merely to clean that already-dead inventory.
137219 let retained_generations = super :: super :: generation_store:: live_sessions ( runner_id, None ) ?;
138220 let leases = eligible_stale_generation_leases ( & retained_generations) . unwrap_or_default ( ) ;
139- if !leases. is_empty ( )
140- && remote_daemon:: authoritative_stale_generations_are_dead (
141- & probe_authoritative_daemon_status ( runner_id) ?,
142- & leases,
143- )
144- {
221+ let authoritative_status = if leases. is_empty ( ) {
222+ None
223+ } else {
224+ match probe_authoritative_daemon_status ( runner_id) {
225+ Ok ( status) => Some ( status) ,
226+ Err ( error) => return partial_disconnect_report ( runner_id, None , error. message ) ,
227+ }
228+ } ;
229+ if authoritative_status. as_ref ( ) . is_some_and ( |status| {
230+ remote_daemon:: authoritative_stale_generations_are_dead ( status, & leases)
231+ } ) {
145232 super :: super :: generation_store:: tombstone_dead_direct_generations ( runner_id, & leases) ?;
146233 remove_ownership ( runner_id) ?;
147234 }
@@ -150,6 +237,9 @@ pub(crate) fn disconnect_with_session(
150237 Ok ( RunnerDisconnectReport {
151238 runner_id : runner_id. to_string ( ) ,
152239 disconnected : session. is_some ( ) ,
240+ partial : false ,
241+ remote_error : None ,
242+ local_recovery_command : None ,
153243 session,
154244 session_path : session_path ( runner_id) ?. display ( ) . to_string ( ) ,
155245 } )
@@ -880,6 +970,29 @@ mod tests {
880970 ) ;
881971 }
882972
973+ #[ test]
974+ fn local_recovery_removes_only_the_controller_session_without_remote_access ( ) {
975+ homeboy_core:: test_support:: with_isolated_home ( |_| {
976+ let mut session = direct_ssh_session ( "lease-wedged" ) ;
977+ session. tunnel_pid = None ;
978+ write_session ( & session) . expect ( "record controller session" ) ;
979+ write_ownership ( & session) . expect ( "record ownership" ) ;
980+
981+ let report = disconnect_local_recovery ( "homeboy-lab" ) . expect ( "local recovery" ) ;
982+
983+ assert ! ( report. disconnected) ;
984+ assert ! ( report. partial) ;
985+ assert ! ( report
986+ . remote_error
987+ . expect( "ambiguity is explicit" )
988+ . contains( "not contacted" ) ) ;
989+ assert ! ( read_session( "homeboy-lab" ) . expect( "read session" ) . is_none( ) ) ;
990+ assert ! ( read_ownership( "homeboy-lab" )
991+ . expect( "read ownership" )
992+ . is_none( ) ) ;
993+ } ) ;
994+ }
995+
883996 #[ test]
884997 fn foreign_loopback_html_is_identity_mismatch_and_never_receives_stop ( ) {
885998 let listener = TcpListener :: bind ( "127.0.0.1:0" ) . expect ( "foreign listener" ) ;
0 commit comments