mirror of
https://github.com/usestrix/strix.git
synced 2026-08-20 10:33:34 +02:00
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:
co-authored by
Claude Opus 4.7
parent
fb6fdffb40
commit
5fd2a64562
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user