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
co-authored by Claude Opus 4.7
parent fb6fdffb40
commit 5fd2a64562
8 changed files with 320 additions and 25 deletions
+4
View File
@@ -111,6 +111,10 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
"run_name": args.run_name,
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scan_mode": scan_mode,
# Forward the new --instruction (if any) to the resume path so it
# can deliver it as a fresh user message after SDK session replay.
# Empty string when the user didn't pass one on resume — no-op.
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
}
tracer = Tracer(args.run_name)
+33
View File
@@ -415,6 +415,11 @@ Examples:
except Exception as e:
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
# Capture before ``_load_resume_state`` overrides — used by the resume
# path in ``run_strix_scan`` to decide whether to inject the new
# instruction into the root's bus inbox after session replay.
args.user_explicit_instruction = args.instruction if args.resume else None
if args.resume:
if args.target:
parser.error(
@@ -422,6 +427,14 @@ Examples:
"the prior run left off, including the original target list."
)
_load_resume_state(args, parser)
bus_path = Path("strix_runs") / args.resume / "bus.json"
if not bus_path.exists():
parser.error(
f"--resume {args.resume}: missing {bus_path}. The run was "
f"persisted but never reached its first bus snapshot — "
f"there's nothing to resume from. Pick a fresh --run-name "
f"or remove --resume to start over with the same targets."
)
else:
if not args.target:
parser.error(
@@ -499,6 +512,26 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
if not args.targets_info:
parser.error(f"--resume {args.resume}: scan_state.json has no targets_info")
# Validate any persisted ``cloned_repo_path`` still exists on disk.
# The resume path skips re-cloning, so a missing dir would mean the
# container mounts an empty source tree and agents silently scan
# nothing.
for target in args.targets_info:
if not isinstance(target, dict):
continue
details = target.get("details") or {}
if target.get("type") != "repository":
continue
cloned = details.get("cloned_repo_path")
if not cloned:
continue
if not Path(cloned).expanduser().exists():
parser.error(
f"--resume {args.resume}: cloned repo at {cloned} is missing. "
f"It was deleted between runs. Pick a fresh --run-name to "
f"re-clone, or restore the directory before resuming."
)
if args.instruction is None:
args.instruction = state.get("instruction")
if state.get("instruction_file") and args.instruction_file is None:
+3
View File
@@ -763,6 +763,9 @@ class StrixTUIApp(App): # type: ignore[misc]
"run_name": args.run_name,
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scan_mode": getattr(args, "scan_mode", "deep"),
# Forward the new --instruction (if any) so the resume path
# can deliver it as a fresh user message after session replay.
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
}
def _setup_cleanup_handlers(self) -> None: