11from __future__ import annotations
22
33from dataclasses import dataclass
4+ from datetime import datetime , timezone
45
56from research_agent .inno_common import load_stage_state , update_stage_state
67from research_agent .runtime .criteria import (
910 StageCriteria ,
1011 validate_stage_artifacts ,
1112)
13+ from research_agent .runtime .heartbeat import (
14+ parse_runtime_timestamp ,
15+ read_runtime_json ,
16+ write_heartbeat ,
17+ write_run_status ,
18+ )
1219
1320
1421@dataclass (frozen = True )
@@ -19,6 +26,14 @@ class GoalEvaluation:
1926 all_criteria_met : bool
2027
2128
29+ @dataclass (frozen = True )
30+ class StallEvaluation :
31+ stalled : bool
32+ current_stage : str | None
33+ age_seconds : float | None
34+ reason : str
35+
36+
2237class MasterRuntime :
2338 def __init__ (
2439 self ,
@@ -33,6 +48,22 @@ def __init__(
3348 def load_state (self ) -> dict :
3449 return load_stage_state (self .cache_path )
3550
51+ def load_heartbeat (self ) -> dict :
52+ return read_runtime_json (self .cache_path , "heartbeat.json" )
53+
54+ def load_run_status (self ) -> dict :
55+ return read_runtime_json (self .cache_path , "run_status.json" )
56+
57+ def latest_artifact (self ) -> str | None :
58+ state = self .load_state ()
59+ for stage_name in reversed (self .stage_order ):
60+ stage_state = state .get (stage_name , {})
61+ artifacts = stage_state .get ("artifacts" ) or {}
62+ for artifact_path in artifacts .values ():
63+ if artifact_path :
64+ return artifact_path
65+ return None
66+
3667 def stage_index (self , stage_name : str ) -> int :
3768 return self .stage_order .index (stage_name )
3869
@@ -98,3 +129,126 @@ def record_stage_completion(
98129 metadata = metadata or {},
99130 )
100131 return self .sync_stage_state ()
132+
133+ def record_stage_failure (
134+ self ,
135+ stage_name : str ,
136+ error_message : str ,
137+ metadata : dict | None = None ,
138+ ) -> dict :
139+ state = self .load_state ()
140+ stage_state = state .get (stage_name , {})
141+ merged_metadata = dict (stage_state .get ("metadata" , {}))
142+ merged_metadata .update (metadata or {})
143+ merged_metadata ["last_error" ] = error_message
144+ update_stage_state (
145+ self .cache_path ,
146+ stage_name ,
147+ "failed" ,
148+ artifacts = stage_state .get ("artifacts" , {}),
149+ metadata = merged_metadata ,
150+ )
151+ return self .load_state ()
152+
153+ def write_runtime_status (
154+ self ,
155+ * ,
156+ run_id : str ,
157+ status : str ,
158+ last_error : str | None = None ,
159+ metadata : dict | None = None ,
160+ ) -> dict [str , str ]:
161+ goal = self .evaluate_goal ()
162+ current_stage = goal .current_stage
163+ heartbeat_path = write_heartbeat (
164+ self .cache_path ,
165+ current_stage = current_stage ,
166+ status = status ,
167+ last_error = last_error ,
168+ metadata = metadata ,
169+ )
170+ run_status_path = write_run_status (
171+ self .cache_path ,
172+ run_id = run_id ,
173+ status = status ,
174+ current_stage = current_stage ,
175+ completed_stages = goal .completed_stages ,
176+ incomplete_stages = goal .incomplete_stages ,
177+ latest_artifact = self .latest_artifact (),
178+ last_error = last_error ,
179+ )
180+ return {
181+ "heartbeat" : heartbeat_path ,
182+ "run_status" : run_status_path ,
183+ }
184+
185+ def write_failure_status (
186+ self ,
187+ * ,
188+ run_id : str ,
189+ error_message : str ,
190+ stage_name : str | None = None ,
191+ metadata : dict | None = None ,
192+ ) -> dict [str , str ]:
193+ if stage_name :
194+ self .record_stage_failure (stage_name , error_message , metadata = metadata )
195+ return self .write_runtime_status (
196+ run_id = run_id ,
197+ status = "failed" ,
198+ last_error = error_message ,
199+ metadata = metadata ,
200+ )
201+
202+ def evaluate_stall (
203+ self ,
204+ * ,
205+ max_stale_seconds : int ,
206+ now : datetime | None = None ,
207+ ) -> StallEvaluation :
208+ heartbeat = self .load_heartbeat ()
209+ if not heartbeat :
210+ return StallEvaluation (False , None , None , "missing_heartbeat" )
211+
212+ status = heartbeat .get ("status" )
213+ current_stage = heartbeat .get ("current_stage" )
214+ if status != "running" :
215+ return StallEvaluation (False , current_stage , None , f"status_{ status or 'unknown' } " )
216+
217+ updated_at = parse_runtime_timestamp (heartbeat .get ("updated_at" ))
218+ if updated_at is None :
219+ return StallEvaluation (False , current_stage , None , "invalid_timestamp" )
220+
221+ effective_now = now or datetime .now (timezone .utc )
222+ age_seconds = max ((effective_now - updated_at ).total_seconds (), 0.0 )
223+ if age_seconds > max_stale_seconds :
224+ return StallEvaluation (True , current_stage , age_seconds , "heartbeat_stale" )
225+
226+ return StallEvaluation (False , current_stage , age_seconds , "healthy" )
227+
228+ def write_stalled_status (
229+ self ,
230+ * ,
231+ run_id : str ,
232+ reason : str ,
233+ stage_name : str | None = None ,
234+ metadata : dict | None = None ,
235+ ) -> dict [str , str ]:
236+ if stage_name :
237+ state = self .load_state ()
238+ stage_state = state .get (stage_name , {})
239+ merged_metadata = dict (stage_state .get ("metadata" , {}))
240+ merged_metadata .update (metadata or {})
241+ merged_metadata ["stalled_reason" ] = reason
242+ update_stage_state (
243+ self .cache_path ,
244+ stage_name ,
245+ "stalled" ,
246+ artifacts = stage_state .get ("artifacts" , {}),
247+ metadata = merged_metadata ,
248+ )
249+ return self .write_runtime_status (
250+ run_id = run_id ,
251+ status = "stalled" ,
252+ last_error = reason ,
253+ metadata = metadata ,
254+ )
0 commit comments