fix(persistence): close all 9 gaps from the resume audit

Three critical correctness fixes + six TUI/audit/UX fixes from the
parallel-agent audit. All changes verified by an end-to-end smoke
that builds, persists, and re-hydrates state across two simulated
process boundaries.

Critical (resume integrity):

1. ``bus.cancel_descendants_graceful`` now calls ``_maybe_snapshot``
   after mutating the ``stopping`` set. Previously, a process crash
   between user-initiated graceful-stop and the next finalize lost
   the stop signal — respawned agents would run forever instead of
   exiting. ``_respawn_subagents`` also gains a guard that skips
   agents in ``stopping`` so a previously-cancelled agent is not
   resurrected on resume.

2. ``Tracer.hydrate_from_run_dir`` now **raises** on corrupt
   ``vulnerabilities.json`` instead of swallowing the exception. The
   prior behaviour silently reset ``vulnerability_reports`` to empty,
   so the next ``add_vulnerability_report`` would allocate ``vuln-0001``
   and overwrite the prior MD on disk — silent data loss.

3. ``--instruction`` passed on resume now reaches the model. The CLI
   captures whether the user explicitly passed an instruction
   (``args.user_explicit_instruction``) before ``_load_resume_state``
   loads the persisted one. ``run_strix_scan`` reads
   ``scan_config["resume_instruction"]`` and, on resume, sends the
   new instruction to root's bus inbox before calling
   ``run_with_continuation`` (which uses ``initial_input=[]`` for SDK
   replay). The inject filter surfaces it on the next turn.

4. ``--resume X`` errors loudly when ``scan_state.json`` exists but
   ``bus.json`` doesn't. Previously this silently fresh-started in
   the same dir, confusing the user who explicitly asked to resume.

TUI / audit / UX:

5. ``Tracer.hydrate_from_run_dir`` now reads ``bus.json`` too and
   pre-populates ``tracer.agents`` from the snapshot's ``statuses`` /
   ``names`` / ``parent_of``. Before this, the TUI tree on resume
   showed only currently-running agents; completed/crashed children
   from the prior run were invisible.

6. ``Tracer.hydrate_from_run_dir`` also seeds ``self._llm_stats`` from
   ``bus.stats_live + bus.stats_completed`` so the resume's footer
   shows cumulative tokens / requests across the prior run plus the
   resume segment, instead of resetting to zero.

7. ``Tracer.save_run_data`` now also writes ``run_metadata.json``
   (start_time, run_id, run_name, targets, status), and
   ``hydrate_from_run_dir`` restores ``start_time`` from it. Prior
   behaviour reset start_time to ``now()`` on every Tracer init,
   breaking the final report's duration calc on resumed scans.

8. Per-agent todos persist to ``{run_dir}/todos.json`` (atomic write
   on every CRUD). ``hydrate_todos_from_disk`` (called from
   ``run_strix_scan``) reloads them so respawned subagents find
   their lists intact. Previously, the module-level
   ``_todos_storage`` was lost on every process restart.

9. ``_load_resume_state`` validates each ``cloned_repo_path`` from
   the persisted ``scan_state.json`` still exists on disk. Previously
   a deleted clone dir would let the resume proceed with an empty
   source tree, with agents silently scanning nothing.

Bonus: ``bus.finalize`` no longer pops ``parent_of`` and ``names``
for finalized agents. Routing protection (don't accept ``send`` to
finalized agents) comes from the ``statuses[id]`` terminal-state
check in ``send`` itself, so dropping those keys was overzealous and
made completed children invisible in ``view_agent_graph`` and the
TUI tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-26 00:57:52 -07:00
parent fb6fdffb40
commit 5fd2a64562
8 changed files with 320 additions and 25 deletions
+102 -4
View File
@@ -1,21 +1,32 @@
"""Per-agent todo tools.
In-memory only — todos live for the lifetime of one scan, scoped per
agent via ``ctx.context['agent_id']``. Bulk forms are preserved so the
prompt-template documentation still works (``todos`` / ``updates`` /
``todo_ids`` accept JSON strings or comma-separated strings).
Per-agent in-memory dict, scoped via ``ctx.context['agent_id']``. The
table is mirrored to ``{run_dir}/todos.json`` after every mutation so a
process restart can ``hydrate_todos_from_disk`` and each respawned
agent finds its prior list intact. The persistence is best-effort —
errors are logged and swallowed so a disk failure can't kill the agent
mid-call. Bulk forms are preserved so the prompt-template documentation
still works (``todos`` / ``updates`` / ``todo_ids`` accept JSON strings
or comma-separated strings).
"""
from __future__ import annotations
import json
import logging
import tempfile
import threading
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from agents import RunContextWrapper, function_tool
logger = logging.getLogger(__name__)
VALID_PRIORITIES = ["low", "normal", "high", "critical"]
VALID_STATUSES = ["pending", "in_progress", "done"]
@@ -36,6 +47,86 @@ def _todo_sort_key(todo: dict[str, Any]) -> tuple[int, int, str]:
# don't see each other's lists.
_todos_storage: dict[str, dict[str, dict[str, Any]]] = {}
# On-disk mirror path. Set by ``hydrate_todos_from_disk`` once per scan;
# unset means "no persistence" (e.g. unit tests). All writes go through
# ``_persist`` which is a no-op until the path is set.
_todos_path: Path | None = None
_todos_io_lock = threading.RLock()
def hydrate_todos_from_disk(run_dir: Path) -> None:
"""Wire the on-disk mirror at ``{run_dir}/todos.json`` and reload it.
Called by :func:`run_strix_scan` once at scan setup. Subsequent CRUD
calls auto-persist after every mutation. Idempotent on missing file.
Tolerant of corruption — logs and starts empty rather than failing
the scan over a broken sidecar artifact.
"""
global _todos_path # noqa: PLW0603
_todos_path = run_dir / "todos.json"
with _todos_io_lock:
_todos_storage.clear()
if not _todos_path.exists():
return
try:
data = json.loads(_todos_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.exception(
"todos.json at %s is unreadable; starting with empty todos",
_todos_path,
)
return
if not isinstance(data, dict):
return
loaded = 0
for aid, by_id in data.items():
if not isinstance(aid, str) or not isinstance(by_id, dict):
continue
cleaned = {
str(tid): t
for tid, t in by_id.items()
if isinstance(tid, str) and isinstance(t, dict)
}
if cleaned:
_todos_storage[aid] = cleaned
loaded += len(cleaned)
logger.info(
"todos hydrated from %s (%d agent(s), %d todo(s))",
_todos_path,
len(_todos_storage),
loaded,
)
def _persist() -> None:
"""Atomic-rename mirror of ``_todos_storage`` → ``{run_dir}/todos.json``.
No-op when ``_todos_path`` isn't wired (tests). Errors are logged
and swallowed.
"""
path = _todos_path
if path is None:
return
try:
payload = json.dumps(_todos_storage, ensure_ascii=False, default=str)
path.parent.mkdir(parents=True, exist_ok=True)
with (
_todos_io_lock,
tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(path.parent),
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as tmp,
):
tmp.write(payload)
tmp_path = Path(tmp.name)
tmp_path.replace(path)
except Exception:
logger.exception("todos persist to %s failed", path)
def _agent_id_from(ctx: RunContextWrapper) -> str:
inner = ctx.context if isinstance(ctx.context, dict) else {}
@@ -279,6 +370,7 @@ async def create_todo(
default=str,
)
_persist()
return json.dumps(
{
"success": True,
@@ -419,6 +511,8 @@ async def update_todo(
except (ValueError, TypeError) as e:
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
if updated:
_persist()
response: dict[str, Any] = {
"success": len(errors) == 0,
"updated": updated,
@@ -464,6 +558,8 @@ def _mark(
except (ValueError, TypeError) as e:
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
if marked:
_persist()
key = "marked_done" if new_status == "done" else "marked_pending"
response: dict[str, Any] = {
"success": len(errors) == 0,
@@ -556,6 +652,8 @@ async def delete_todo(
except (ValueError, TypeError) as e:
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
if deleted:
_persist()
response: dict[str, Any] = {
"success": len(errors) == 0,
"deleted": deleted,