"""Patch upstream Hermes auto-decomposition to respect execution history.""" import os from pathlib import Path def replace_once(source: str, before: str, after: str, label: str) -> str: """Replace one anchored upstream fragment and fail closed on source drift.""" count = source.count(before) if count != 1: raise SystemExit( f"Hermes {label} patch context changed: expected 1, found {count}" ) return source.replace(before, after, 1) source_root = Path(os.environ.get("HERMES_SOURCE_ROOT", "/opt/hermes")) decompose_path = source_root / "hermes_cli/kanban_decompose.py" decompose = decompose_path.read_text(encoding="utf-8") helper_anchor = ''' if chosen not in valid_names: return default_assignee return chosen ''' helper_replacement = helper_anchor + '''def _has_execution_history(task_id: str) -> bool: """Return whether a task has ever entered a worker run.""" with kb.connect_closing() as conn: return bool(kb.list_runs(conn, task_id)) ''' decompose = replace_once( decompose, helper_anchor, helper_replacement, "auto-decompose execution-history helper", ) signature_before = '''def decompose_task( task_id: str, *, author: Optional[str] = None, timeout: Optional[int] = None, ) -> DecomposeOutcome: ''' signature_after = '''def decompose_task( task_id: str, *, author: Optional[str] = None, timeout: Optional[int] = None, automatic: bool = False, ) -> DecomposeOutcome: ''' decompose = replace_once( decompose, signature_before, signature_after, "auto-decompose function signature", ) status_before = ''' if task.status != "triage": return DecomposeOutcome( task_id, False, f"task is not in triage (status={task.status!r})" ) cfg = _load_config() ''' status_after = ''' if task.status != "triage": return DecomposeOutcome( task_id, False, f"task is not in triage (status={task.status!r})" ) if automatic and _has_execution_history(task_id): return DecomposeOutcome( task_id, False, "task has execution history and requires deliberate manual triage", ) cfg = _load_config() ''' decompose = replace_once( decompose, status_before, status_after, "auto-decompose preflight guard", ) single_before = ''' with kb.connect_closing() as conn: ok = kb.specify_triage_task( ''' single_after = ''' if automatic and _has_execution_history(task_id): return DecomposeOutcome( task_id, False, "task gained execution history and requires deliberate manual triage", ) with kb.connect_closing() as conn: ok = kb.specify_triage_task( ''' decompose = replace_once( decompose, single_before, single_after, "auto-decompose single-task commit guard", ) fanout_before = ''' try: with kb.connect_closing() as conn: child_ids = kb.decompose_triage_task( ''' fanout_after = ''' if automatic and _has_execution_history(task_id): return DecomposeOutcome( task_id, False, "task gained execution history and requires deliberate manual triage", ) try: with kb.connect_closing() as conn: child_ids = kb.decompose_triage_task( ''' decompose = replace_once( decompose, fanout_before, fanout_after, "auto-decompose fanout commit guard", ) decompose_path.write_text(decompose, encoding="utf-8") watcher_path = source_root / "gateway/kanban_watchers.py" watcher = watcher_path.read_text(encoding="utf-8") call_before = ''' outcome = _decomp.decompose_task( tid, author="auto-decomposer", ) ''' call_after = ''' outcome = _decomp.decompose_task( tid, author="auto-decomposer", automatic=True, ) ''' watcher = replace_once( watcher, call_before, call_after, "gateway automatic-decomposition call", ) watcher_path.write_text(watcher, encoding="utf-8")