Skip to content

Commit 00a96cf

Browse files
committed
Add runtime heartbeat and stall detection
1 parent b61175d commit 00a96cf

6 files changed

Lines changed: 384 additions & 6 deletions

File tree

‎research_agent/run_infer_idea.py‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ async def forward(self, instance_path: str, task_level: str, local_root: str, wo
7979
metadata = self.load_ins({"instance_path": instance_path, "task_level": task_level})
8080
runtime = MasterRuntime(self.cache_path)
8181
runtime.sync_stage_state()
82+
run_id = metadata.get("instance_id", task_level)
83+
runtime.write_runtime_status(
84+
run_id=run_id,
85+
status="running",
86+
metadata={"entrypoint": "run_infer_idea", "task_level": task_level},
87+
)
8288
stage_state = runtime.load_state()
8389
context_variables = {
8490
"working_dir": f"/{workplace_name}", # TODO: change to the codebase path
@@ -634,6 +640,11 @@ async def forward(self, instance_path: str, task_level: str, local_root: str, wo
634640
print(refine_res)
635641
runtime.sync_stage_state()
636642
goal_evaluation = runtime.evaluate_goal()
643+
runtime.write_runtime_status(
644+
run_id=run_id,
645+
status="completed" if goal_evaluation.all_criteria_met else "running",
646+
metadata={"entrypoint": "run_infer_idea", "task_level": task_level},
647+
)
637648
return {
638649
"task_id": metadata.get("instance_id", task_level),
639650
"query": idea_query,
@@ -721,9 +732,18 @@ def main(args, references):
721732
web_env = BrowserEnv(browsergym_eval_env = None, local_root=env_config.local_root, workplace_name=env_config.workplace_name)
722733
file_env = RequestsMarkdownBrowser(viewport_size=1024 * 4, local_root=env_config.local_root, workplace_name=env_config.workplace_name, downloads_folder=os.path.join(env_config.local_root, env_config.workplace_name, "downloads"))
723734
flow = InnoFlow(cache_path=cache_path, log_path="log_" + instance_id, code_env=code_env, web_env=web_env, file_env=file_env, model=args.model)
724-
# ml_result = await flow(instance_path=instance_path)
725-
result = asyncio.run(flow(instance_path=args.instance_path, task_level=args.task_level, local_root=local_root, workplace_name=args.workplace_name, max_iter_times=args.max_iter_times, category=args.category, references = references))
726-
return build_and_save_eval_result(result, cache_path)
735+
runtime = MasterRuntime(cache_path)
736+
try:
737+
result = asyncio.run(flow(instance_path=args.instance_path, task_level=args.task_level, local_root=local_root, workplace_name=args.workplace_name, max_iter_times=args.max_iter_times, category=args.category, references = references))
738+
return build_and_save_eval_result(result, cache_path)
739+
except Exception as exc:
740+
runtime.write_failure_status(
741+
run_id=instance_id,
742+
error_message=str(exc),
743+
stage_name=runtime.next_stage(),
744+
metadata={"entrypoint": "run_infer_idea", "task_level": args.task_level},
745+
)
746+
raise
727747
# print(judge_result)
728748

729749

‎research_agent/run_infer_plan.py‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ async def forward(self, instance_path: str, task_level: str, local_root: str, wo
7777
metadata = self.load_ins({"instance_path": instance_path, "task_level": task_level})
7878
runtime = MasterRuntime(self.cache_path)
7979
runtime.sync_stage_state()
80+
run_id = metadata.get("instance_id", task_level)
81+
runtime.write_runtime_status(
82+
run_id=run_id,
83+
status="running",
84+
metadata={"entrypoint": "run_infer_plan", "task_level": task_level},
85+
)
8086
stage_state = runtime.load_state()
8187
context_variables = {
8288
"working_dir": workplace_name, # Agent instructions already prepend "/"
@@ -603,6 +609,11 @@ async def forward(self, instance_path: str, task_level: str, local_root: str, wo
603609

604610
runtime.sync_stage_state()
605611
goal_evaluation = runtime.evaluate_goal()
612+
runtime.write_runtime_status(
613+
run_id=run_id,
614+
status="completed" if goal_evaluation.all_criteria_met else "running",
615+
metadata={"entrypoint": "run_infer_plan", "task_level": task_level},
616+
)
606617
return {
607618
"task_id": metadata.get("instance_id", task_level),
608619
"query": plan_query,
@@ -691,9 +702,18 @@ def main(args, ideas, references):
691702
web_env = BrowserEnv(browsergym_eval_env = None, local_root=env_config.local_root, workplace_name=env_config.workplace_name)
692703
file_env = RequestsMarkdownBrowser(viewport_size=1024 * 4, local_root=env_config.local_root, workplace_name=env_config.workplace_name, downloads_folder=os.path.join(env_config.local_root, env_config.workplace_name, "downloads"))
693704
flow = InnoFlow(cache_path=cache_path, log_path="log_" + instance_id, code_env=code_env, web_env=web_env, file_env=file_env, model=args.model)
694-
# ml_result = await flow(instance_path=instance_path)
695-
result = asyncio.run(flow(instance_path=args.instance_path, task_level=args.task_level, local_root=local_root, workplace_name=args.workplace_name, max_iter_times=args.max_iter_times, category=args.category, ideas = ideas, references = references))
696-
return build_and_save_eval_result(result, cache_path)
705+
runtime = MasterRuntime(cache_path)
706+
try:
707+
result = asyncio.run(flow(instance_path=args.instance_path, task_level=args.task_level, local_root=local_root, workplace_name=args.workplace_name, max_iter_times=args.max_iter_times, category=args.category, ideas = ideas, references = references))
708+
return build_and_save_eval_result(result, cache_path)
709+
except Exception as exc:
710+
runtime.write_failure_status(
711+
run_id=instance_id,
712+
error_message=str(exc),
713+
stage_name=runtime.next_stage(),
714+
metadata={"entrypoint": "run_infer_plan", "task_level": args.task_level},
715+
)
716+
raise
697717
# print(judge_result)
698718

699719

‎research_agent/runtime/__init__.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
StageCriteria,
44
validate_stage_artifacts,
55
)
6+
from research_agent.runtime.heartbeat import (
7+
parse_runtime_timestamp,
8+
read_runtime_json,
9+
write_heartbeat,
10+
write_run_status,
11+
)
612
from research_agent.runtime.master import GoalEvaluation, MasterRuntime
713

814
__all__ = [
@@ -11,4 +17,8 @@
1117
"MasterRuntime",
1218
"StageCriteria",
1319
"validate_stage_artifacts",
20+
"parse_runtime_timestamp",
21+
"read_runtime_json",
22+
"write_heartbeat",
23+
"write_run_status",
1424
]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
from datetime import datetime, timezone
6+
from typing import Any
7+
8+
9+
def utc_now_iso() -> str:
10+
return datetime.now(timezone.utc).isoformat()
11+
12+
13+
def write_runtime_json(cache_path: str, file_name: str, payload: dict[str, Any]) -> str:
14+
os.makedirs(cache_path, exist_ok=True)
15+
output_path = os.path.join(cache_path, file_name)
16+
with open(output_path, "w", encoding="utf-8") as f:
17+
json.dump(payload, f, ensure_ascii=False, indent=4)
18+
return output_path
19+
20+
21+
def read_runtime_json(cache_path: str, file_name: str) -> dict[str, Any]:
22+
output_path = os.path.join(cache_path, file_name)
23+
if not os.path.exists(output_path):
24+
return {}
25+
try:
26+
with open(output_path, "r", encoding="utf-8") as f:
27+
payload = json.load(f)
28+
return payload if isinstance(payload, dict) else {}
29+
except (OSError, json.JSONDecodeError):
30+
return {}
31+
32+
33+
def parse_runtime_timestamp(value: str | None) -> datetime | None:
34+
if not value:
35+
return None
36+
try:
37+
return datetime.fromisoformat(value)
38+
except ValueError:
39+
return None
40+
41+
42+
def write_heartbeat(
43+
cache_path: str,
44+
*,
45+
current_stage: str | None,
46+
status: str,
47+
last_error: str | None = None,
48+
metadata: dict[str, Any] | None = None,
49+
) -> str:
50+
return write_runtime_json(
51+
cache_path,
52+
"heartbeat.json",
53+
{
54+
"current_stage": current_stage,
55+
"status": status,
56+
"last_error": last_error,
57+
"updated_at": utc_now_iso(),
58+
"metadata": metadata or {},
59+
},
60+
)
61+
62+
63+
def write_run_status(
64+
cache_path: str,
65+
*,
66+
run_id: str,
67+
status: str,
68+
current_stage: str | None,
69+
completed_stages: list[str],
70+
incomplete_stages: list[str],
71+
latest_artifact: str | None = None,
72+
last_error: str | None = None,
73+
) -> str:
74+
return write_runtime_json(
75+
cache_path,
76+
"run_status.json",
77+
{
78+
"run_id": run_id,
79+
"status": status,
80+
"current_stage": current_stage,
81+
"completed_stages": completed_stages,
82+
"incomplete_stages": incomplete_stages,
83+
"latest_artifact": latest_artifact,
84+
"last_error": last_error,
85+
"updated_at": utc_now_iso(),
86+
},
87+
)

‎research_agent/runtime/master.py‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass
4+
from datetime import datetime, timezone
45

56
from research_agent.inno_common import load_stage_state, update_stage_state
67
from research_agent.runtime.criteria import (
@@ -9,6 +10,12 @@
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+
2237
class 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

Comments
 (0)