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:
+102
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user