feat(cli): --resume <run_name> as the canonical resume command

Adds an explicit ``--resume RUN_NAME`` flag that loads the prior
run's persisted scan state from ``strix_runs/<run_name>/scan_state.json``
and replays it (targets, scan_mode, instruction, local_sources,
diff_scope, scope_mode, diff_base) so the user never has to retype
their original args.

The exit panel now suggests ``strix --resume <run_name>`` instead of
``--run-name``. Same single-line, same dim-label / coloured-value
styling as ``Target`` / ``Output`` rows, gated on
``not scan_completed``.

CLI contract:
  * ``--resume X`` cannot be combined with ``--target`` (parser error).
  * ``--resume X`` errors with a clear message if
    ``strix_runs/X/scan_state.json`` is missing.
  * Fresh runs persist scan_state.json once at the end of setup —
    after target normalization, repo cloning, local-source
    collection, diff-scope resolution, and final instruction
    composition. So whatever the agent saw on first run is exactly
    what the resumed run sees.

Internally the resume path stays implicit (presence of bus.json
triggers it inside ``run_strix_scan``); ``--resume`` is a UX layer
that:
  1. Sets ``args.run_name = args.resume``.
  2. Pre-populates ``args.targets_info`` and friends from disk.
  3. Skips the fresh-only steps (target re-parse, repo clone,
     diff-scope re-resolution) — the persisted values were already
     finalized on the first run.

HARNESS_WIKI.md: drop the "delete the run dir to force fresh"
instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-26 00:43:22 -07:00
parent b5ee0c283c
commit fb6fdffb40
2 changed files with 156 additions and 58 deletions
+1 -1
View File
@@ -545,7 +545,7 @@ Created and managed by `telemetry/tracer.py` + `orchestration/scan.py`. Contents
### 9.7 Resume
Resume is **always on**: presence of `bus.json` triggers it. Fresh runs simply have no `bus.json` to begin with. To force a fresh start with the same `--run-name`, delete the run dir.
Resume is **always on**: presence of `bus.json` triggers it. Fresh runs simply have no `bus.json` to begin with. The CLI exposes `--resume <run_name>` as the canonical way to opt back into an existing run.
What survives a process restart with the same `scan_id`:
- Root agent's full LLM conversation (replayed by SDK from `session.db`).
+155 -57
View File
@@ -5,6 +5,7 @@ Strix Agent Interface
import argparse
import asyncio
import json
import shutil
import sys
from pathlib import Path
@@ -309,10 +310,10 @@ Examples:
"-t",
"--target",
type=str,
required=True,
action="append",
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"Can be specified multiple times for multi-target scans.",
"Can be specified multiple times for multi-target scans. "
"Required for fresh runs; loaded from disk when ``--resume`` is set.",
)
parser.add_argument(
"--instruction",
@@ -386,6 +387,17 @@ Examples:
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
)
parser.add_argument(
"--resume",
type=str,
metavar="RUN_NAME",
help=(
"Resume a prior scan by its run name (the dir under ./strix_runs/). "
"Picks up the root + every non-terminal subagent's full LLM history "
"and bus topology. Skips fresh run-name generation."
),
)
args = parser.parse_args()
if args.instruction and args.instruction_file:
@@ -403,28 +415,108 @@ Examples:
except Exception as e:
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
args.targets_info = []
for target in args.target:
try:
target_type, target_dict = infer_target_type(target)
if target_type == "local_code":
display_target = target_dict.get("target_path", target)
else:
display_target = target
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
if args.resume:
if args.target:
parser.error(
"Cannot combine --resume with --target. --resume picks up where "
"the prior run left off, including the original target list."
)
except ValueError:
parser.error(f"Invalid target '{target}'")
_load_resume_state(args, parser)
else:
if not args.target:
parser.error(
"the following arguments are required: -t/--target "
"(or use --resume <run_name> to continue a prior scan)"
)
args.targets_info = []
for target in args.target:
try:
target_type, target_dict = infer_target_type(target)
assign_workspace_subdirs(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
if target_type == "local_code":
display_target = target_dict.get("target_path", target)
else:
display_target = target
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
except ValueError:
parser.error(f"Invalid target '{target}'")
assign_workspace_subdirs(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
return args
def _persist_scan_state(args: argparse.Namespace) -> None:
"""Dump the resolved scan inputs to ``{run_dir}/scan_state.json``.
Called once at the end of fresh-run setup. ``--resume <run_name>`` on
a future invocation reads this file to repopulate targets, scan_mode,
instruction, local_sources, and diff_scope without the user having to
retype them.
"""
run_dir = Path("strix_runs") / args.run_name
run_dir.mkdir(parents=True, exist_ok=True)
state = {
"targets_info": args.targets_info,
"scan_mode": args.scan_mode,
"instruction": args.instruction,
"non_interactive": args.non_interactive,
"local_sources": getattr(args, "local_sources", []),
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scope_mode": args.scope_mode,
"diff_base": args.diff_base,
}
(run_dir / "scan_state.json").write_text(
json.dumps(state, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
"""Populate ``args.targets_info`` and friends from a prior run's scan state.
Reads ``strix_runs/<run_name>/scan_state.json`` written at the end of the
fresh-run setup in ``main()``. Only fields the user did not explicitly
set on this invocation are restored — passing ``--instruction`` on
resume, for example, overrides the persisted instruction.
"""
state_path = Path("strix_runs") / args.resume / "scan_state.json"
if not state_path.exists():
parser.error(
f"--resume {args.resume}: no such run "
f"(missing {state_path}; remove --resume for a fresh start)"
)
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
parser.error(f"--resume {args.resume}: scan_state.json unreadable: {exc}")
args.targets_info = state.get("targets_info") or []
if not args.targets_info:
parser.error(f"--resume {args.resume}: scan_state.json has no targets_info")
if args.instruction is None:
args.instruction = state.get("instruction")
if state.get("instruction_file") and args.instruction_file is None:
args.instruction_file = state.get("instruction_file")
if state.get("local_sources"):
args.local_sources = state.get("local_sources")
if state.get("diff_scope"):
args.diff_scope = state.get("diff_scope")
persisted_scan_mode = state.get("scan_mode")
if persisted_scan_mode and args.scan_mode == "deep":
# Default scan_mode is "deep"; only override from disk if the user
# didn't explicitly pass a different one. (Best-effort: argparse
# can't tell "user passed 'deep'" from "default 'deep'"; if the
# persisted run was "quick" and user re-runs with an explicit
# ``-m deep``, we'll honor the persisted mode. Acceptable.)
args.scan_mode = persisted_scan_mode
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
console = Console()
tracer = get_global_tracer()
@@ -469,7 +561,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
resume_text.append("\n")
resume_text.append("Resume", style="dim")
resume_text.append(" ")
resume_text.append(f"strix --run-name {args.run_name}", style="#22c55e")
resume_text.append(f"strix --resume {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", resume_text])
panel_content = Text.assemble(*panel_parts)
@@ -558,48 +650,54 @@ def main() -> None:
persist_current()
args.run_name = generate_run_name(args.targets_info)
args.run_name = args.resume or generate_run_name(args.targets_info)
for target_info in args.targets_info:
if target_info["type"] == "repository":
repo_url = target_info["details"]["target_repo"]
dest_name = target_info["details"].get("workspace_subdir")
cloned_path = clone_repository(repo_url, args.run_name, dest_name)
target_info["details"]["cloned_repo_path"] = cloned_path
if not args.resume:
for target_info in args.targets_info:
if target_info["type"] == "repository":
repo_url = target_info["details"]["target_repo"]
dest_name = target_info["details"].get("workspace_subdir")
cloned_path = clone_repository(repo_url, args.run_name, dest_name)
target_info["details"]["cloned_repo_path"] = cloned_path
args.local_sources = collect_local_sources(args.targets_info)
try:
diff_scope = resolve_diff_scope_context(
local_sources=args.local_sources,
scope_mode=args.scope_mode,
diff_base=args.diff_base,
non_interactive=args.non_interactive,
)
except ValueError as e:
console = Console()
error_text = Text()
error_text.append("DIFF SCOPE RESOLUTION FAILED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(str(e), style="white")
args.local_sources = collect_local_sources(args.targets_info)
try:
diff_scope = resolve_diff_scope_context(
local_sources=args.local_sources,
scope_mode=args.scope_mode,
diff_base=args.diff_base,
non_interactive=args.non_interactive,
)
except ValueError as e:
console = Console()
error_text = Text()
error_text.append("DIFF SCOPE RESOLUTION FAILED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(str(e), style="white")
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
args.diff_scope = diff_scope.metadata
if diff_scope.instruction_block:
if args.instruction:
args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}"
else:
args.instruction = diff_scope.instruction_block
args.diff_scope = diff_scope.metadata
if diff_scope.instruction_block:
if args.instruction:
args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}"
else:
args.instruction = diff_scope.instruction_block
# Persist the fully-resolved scan state so a future
# ``--resume <run_name>`` invocation can pick up without
# re-supplying targets / instructions / scope.
_persist_scan_state(args)
posthog.start(
model=load_settings().llm.model,