@@ -37,7 +37,7 @@ pub use artifact_download::ArtifactDownload;
3737pub use broker_config:: { render_broker_config, BrokerConfig , BrokerConfigOptions , ServiceIdentity } ;
3838pub use control:: {
3939 adopt_orphaned_lease, artifact_content_url, ensure_running, fetch_artifact_to_path,
40- reconcile_leaseless_orphans, recover_missing_lease_state, start_background,
40+ reconcile_leaseless_orphans, recover_missing_lease_state, start_background, supervise ,
4141 ArtifactFetchOutcome ,
4242} ;
4343use patch_capture:: { capture_baseline, capture_patch_report} ;
@@ -88,6 +88,43 @@ pub struct DaemonStatus {
8888 /// operator can distinguish another runner from an attributable owner.
8989 #[ serde( default , skip_serializing_if = "Vec::is_empty" ) ]
9090 pub process_candidates : Vec < DaemonProcessCandidate > ,
91+ /// Launcher-owned evidence is best effort. A missing record explicitly does
92+ /// not imply an OS-level cause for a dead daemon.
93+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
94+ pub termination_evidence : Option < DaemonTerminationEvidence > ,
95+ }
96+
97+ #[ derive( Debug , Clone , Deserialize , Serialize , PartialEq , Eq ) ]
98+ #[ serde( rename_all = "snake_case" ) ]
99+ pub enum DaemonTerminationClassification {
100+ CleanStop ,
101+ UnexpectedExit ,
102+ }
103+
104+ /// Durable, bounded evidence from the process that launched the daemon.
105+ #[ derive( Debug , Clone , Deserialize , Serialize , PartialEq , Eq ) ]
106+ pub struct DaemonTerminationEvidence {
107+ pub classification : DaemonTerminationClassification ,
108+ pub observed_at : String ,
109+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
110+ pub lease_id : Option < String > ,
111+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
112+ pub pid : Option < u32 > ,
113+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
114+ pub binary_identity : Option < String > ,
115+ pub active_jobs : usize ,
116+ pub resource_evidence : String ,
117+ pub os_evidence : String ,
118+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
119+ pub exit_code : Option < i32 > ,
120+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
121+ pub signal : Option < i32 > ,
122+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
123+ pub stdout : Option < String > ,
124+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
125+ pub stderr : Option < String > ,
126+ #[ serde( default ) ]
127+ pub stop_requested : bool ,
91128}
92129
93130#[ derive( Debug , Clone , Deserialize , Serialize , PartialEq , Eq ) ]
@@ -168,6 +205,8 @@ pub struct DaemonFreshnessReport {
168205 #[ serde( skip_serializing_if = "Option::is_none" ) ]
169206 pub runtime_paths : Option < DaemonRuntimeSnapshot > ,
170207 pub active_jobs : usize ,
208+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
209+ pub termination_evidence : Option < DaemonTerminationEvidence > ,
171210 #[ serde( default , skip_serializing_if = "Vec::is_empty" ) ]
172211 pub repair_plan : Vec < DaemonRepairStep > ,
173212}
@@ -497,6 +536,44 @@ pub fn read_status() -> Result<DaemonStatus> {
497536 state_path,
498537 state_identity,
499538 process_candidates : control:: daemon_process_candidates ( & jobs_path) ?,
539+ termination_evidence : ( !validation. running )
540+ . then ( read_termination_evidence)
541+ . transpose ( ) ?
542+ . flatten ( ) ,
543+ } )
544+ }
545+
546+ fn read_termination_evidence ( ) -> Result < Option < DaemonTerminationEvidence > > {
547+ let path = paths:: daemon_termination_file ( ) ?;
548+ match fs:: read_to_string ( & path) {
549+ Ok ( body) => serde_json:: from_str ( & body) . map ( Some ) . map_err ( |error| {
550+ Error :: internal_json ( error. to_string ( ) , Some ( format ! ( "parse {}" , path. display( ) ) ) )
551+ } ) ,
552+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => Ok ( None ) ,
553+ Err ( error) => Err ( Error :: internal_io (
554+ error. to_string ( ) ,
555+ Some ( format ! ( "read {}" , path. display( ) ) ) ,
556+ ) ) ,
557+ }
558+ }
559+
560+ pub ( crate ) fn write_termination_evidence ( evidence : & DaemonTerminationEvidence ) -> Result < ( ) > {
561+ let path = paths:: daemon_termination_file ( ) ?;
562+ let parent = path. parent ( ) . expect ( "daemon termination path has parent" ) ;
563+ fs:: create_dir_all ( parent) . map_err ( |error| {
564+ Error :: internal_io (
565+ error. to_string ( ) ,
566+ Some ( format ! ( "create {}" , parent. display( ) ) ) ,
567+ )
568+ } ) ?;
569+ let body = serde_json:: to_vec_pretty ( evidence) . map_err ( |error| {
570+ Error :: internal_json (
571+ error. to_string ( ) ,
572+ Some ( "serialize daemon termination evidence" . to_string ( ) ) ,
573+ )
574+ } ) ?;
575+ fs:: write ( & path, body) . map_err ( |error| {
576+ Error :: internal_io ( error. to_string ( ) , Some ( format ! ( "write {}" , path. display( ) ) ) )
500577 } )
501578}
502579
@@ -672,6 +749,12 @@ fn freshness_report_from_validation(
672749 daemon_build_identity : state. map ( |state| state. build_identity . display . clone ( ) ) ,
673750 runtime_paths : state. map ( |state| state. runtime_paths . clone ( ) ) ,
674751 active_jobs,
752+ termination_evidence : ( !validation. running )
753+ . then ( read_termination_evidence)
754+ . transpose ( )
755+ . ok ( )
756+ . flatten ( )
757+ . flatten ( ) ,
675758 repair_plan,
676759 }
677760}
@@ -757,6 +840,26 @@ fn stop_unlocked() -> Result<DaemonStopResult> {
757840 ) ) ) ;
758841 }
759842
843+ if pid_is_running ( pid) {
844+ write_termination_evidence ( & DaemonTerminationEvidence {
845+ classification : DaemonTerminationClassification :: CleanStop ,
846+ observed_at : chrono:: Utc :: now ( ) . to_rfc3339 ( ) ,
847+ lease_id : Some ( state. lease_id . clone ( ) ) ,
848+ pid : Some ( pid) ,
849+ binary_identity : Some ( state. build_identity . display . clone ( ) ) ,
850+ active_jobs : JobStore :: active_count_at_path ( paths:: daemon_jobs_file ( ) ?) ?,
851+ resource_evidence : "unavailable: launcher does not collect OS resource snapshots"
852+ . to_string ( ) ,
853+ os_evidence :
854+ "unavailable: no OS termination evidence collected before operator-requested stop"
855+ . to_string ( ) ,
856+ exit_code : None ,
857+ signal : None ,
858+ stdout : None ,
859+ stderr : None ,
860+ stop_requested : true ,
861+ } ) ?;
862+ }
760863 let stopped = if pid_is_running ( pid) {
761864 terminate_pid ( pid) ?;
762865 true
0 commit comments