mirror of
https://github.com/usestrix/strix.git
synced 2026-09-22 00:31:25 +00:00
feat: add resume/checkpoint system for interrupted scans
- New strix/telemetry/checkpoint.py: Pydantic CheckpointModel + CheckpointManager with atomic writes (.tmp → rename), non-fatal errors, target-hash validation - base_agent.py: save checkpoint after every iteration (root agents only), delete on clean completion, guard against duplicate task message on resume - main.py: add --run-name, --resume, --new/--force-new CLI flags; _setup_checkpoint_on_args() handles load/validate/corrupt-recovery - cli.py: resume banner, history replay (previous vulns + last 3 thoughts), restore AgentState with fresh sandbox + extended max_iterations budget - tui.py: pre-populate tracer from checkpoint, restore AgentState in agent_config - README.md: add "Resuming Interrupted Scans" section with usage examples Original scan behavior is 100% preserved when --run-name is not used. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f0f8f3d4cc
commit
8a86a92e48
6 changed files with 454 additions and 5 deletions
33
README.md
33
README.md
|
|
@ -177,6 +177,39 @@ strix --target api.your-app.com --instruction "Focus on business logic flaws and
|
|||
strix --target api.your-app.com --instruction-file ./instruction.md
|
||||
```
|
||||
|
||||
### Resuming Interrupted Scans
|
||||
|
||||
Long scans can be interrupted by Ctrl+C, crashes, power loss, or Docker issues.
|
||||
Strix automatically saves a checkpoint after every agent iteration so you can resume exactly where you left off.
|
||||
|
||||
```bash
|
||||
# First run — starts fresh, saves checkpoint automatically
|
||||
strix --target https://example.com --run-name my-scan
|
||||
|
||||
# If interrupted, run the same command again — auto-resumes from checkpoint
|
||||
strix --target https://example.com --run-name my-scan
|
||||
|
||||
# Explicit resume flag (same effect, makes intent clear)
|
||||
strix --target https://example.com --run-name my-scan --resume
|
||||
|
||||
# Force a completely fresh scan (deletes existing checkpoint)
|
||||
strix --target https://example.com --run-name my-scan --new
|
||||
```
|
||||
|
||||
**What is restored on resume:**
|
||||
- Full LLM conversation history (the agent remembers everything it did)
|
||||
- Discovered vulnerabilities and findings
|
||||
- Iteration counter — the agent continues from exactly where it stopped
|
||||
- A fresh Docker sandbox is always created (old containers may be gone)
|
||||
|
||||
**Checkpoint location:** `strix_runs/<run-name>/checkpoint.json`
|
||||
Checkpoints are deleted automatically when a scan completes successfully.
|
||||
|
||||
> **Tip:** `--run-name` is optional. If omitted, Strix auto-generates a name like `example-com_a1b2`.
|
||||
> Auto-resume only works when you re-use the same `--run-name`.
|
||||
|
||||
---
|
||||
|
||||
### Headless Mode
|
||||
|
||||
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag—perfect for servers and automated jobs. The CLI prints real-time vulnerability findings, and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ class BaseAgent(metaclass=AgentMeta):
|
|||
self.state.waiting_timeout = 0
|
||||
self.llm = LLM(self.llm_config, agent_name=self.agent_name)
|
||||
|
||||
# Added for Resume Feature - optional, zero impact when absent
|
||||
self._checkpoint_manager = config.get("checkpoint_manager")
|
||||
self._scan_config: dict[str, Any] = config.get("scan_config", {})
|
||||
self._target_hash: str = config.get("target_hash", "")
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
self.llm.set_agent_identity(self.state.agent_name, self.state.agent_id)
|
||||
self._current_task: asyncio.Task[Any] | None = None
|
||||
|
|
@ -215,6 +220,18 @@ class BaseAgent(metaclass=AgentMeta):
|
|||
should_finish = await iteration_task
|
||||
self._current_task = None
|
||||
|
||||
# Added for Resume Feature — save checkpoint after every successful
|
||||
# iteration. Non-fatal: any error is caught inside save().
|
||||
# Only root agents checkpoint (parent_id is None).
|
||||
if self._checkpoint_manager and self.state.parent_id is None:
|
||||
self._checkpoint_manager.save(
|
||||
self.state,
|
||||
tracer,
|
||||
self._scan_config,
|
||||
self._target_hash,
|
||||
self.max_iterations,
|
||||
)
|
||||
|
||||
if should_finish is None and self.interactive:
|
||||
await self._enter_waiting_state(tracer, text_response=True)
|
||||
continue
|
||||
|
|
@ -224,6 +241,9 @@ class BaseAgent(metaclass=AgentMeta):
|
|||
self.state.set_completed({"success": True})
|
||||
if tracer:
|
||||
tracer.update_agent_status(self.state.agent_id, "completed")
|
||||
# Added for Resume Feature — clean completion, remove checkpoint
|
||||
if self._checkpoint_manager:
|
||||
self._checkpoint_manager.delete()
|
||||
return self.state.final_result or {}
|
||||
await self._enter_waiting_state(tracer, task_completed=True)
|
||||
continue
|
||||
|
|
@ -362,7 +382,12 @@ class BaseAgent(metaclass=AgentMeta):
|
|||
if not self.state.task:
|
||||
self.state.task = task
|
||||
|
||||
self.state.add_message("user", task)
|
||||
# Added for Resume Feature: skip adding the initial task message when
|
||||
# resuming because the full message history is already in state.messages.
|
||||
# On a fresh start state.messages is always empty here — original behavior
|
||||
# is 100% unchanged.
|
||||
if not self.state.messages:
|
||||
self.state.add_message("user", task)
|
||||
|
||||
async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool | None:
|
||||
final_response = None
|
||||
|
|
|
|||
|
|
@ -20,11 +20,117 @@ from .utils import (
|
|||
)
|
||||
|
||||
|
||||
# Added for Resume Feature — helpers for CLI resume banner and history replay
|
||||
|
||||
def _print_resume_banner(console: Console, run_name: str, iteration: int) -> None:
|
||||
"""Print the resume banner so the user knows the scan is continuing."""
|
||||
resume_text = Text()
|
||||
resume_text.append("Resuming interrupted scan", style="bold #22c55e")
|
||||
resume_text.append("\n\n")
|
||||
resume_text.append("Run name ", style="dim")
|
||||
resume_text.append(run_name, style="bold white")
|
||||
resume_text.append("\n")
|
||||
resume_text.append("Resuming from iteration ", style="dim")
|
||||
resume_text.append(str(iteration), style="bold white")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
resume_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="#22c55e",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def _replay_previous_output(
|
||||
console: Console, checkpoint_data: Any, display_vulnerability: Any
|
||||
) -> None:
|
||||
"""Re-render previous findings and key messages from the checkpoint.
|
||||
|
||||
Added for Resume Feature — gives the user a sense of what was already
|
||||
discovered before the scan was interrupted.
|
||||
"""
|
||||
# Replay vulnerability reports found so far
|
||||
for report in checkpoint_data.tracer_vulnerability_reports:
|
||||
report_id = report.get("id", "unknown")
|
||||
vuln_text = format_vulnerability_report(report)
|
||||
console.print(
|
||||
Panel(
|
||||
vuln_text,
|
||||
title=f"[bold red]{report_id.upper()} [dim](from previous session)[/]",
|
||||
title_align="left",
|
||||
border_style="dark_red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
# Print a summary of previous agent activity (last few assistant messages)
|
||||
chat_msgs = checkpoint_data.tracer_chat_messages
|
||||
assistant_msgs = [m for m in chat_msgs if m.get("role") == "assistant"]
|
||||
if assistant_msgs:
|
||||
last_msgs = assistant_msgs[-3:]
|
||||
history_text = Text()
|
||||
history_text.append("Last agent activity before interruption\n\n", style="dim")
|
||||
for msg in last_msgs:
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
# Truncate very long messages to keep replay readable
|
||||
snippet = content.strip()[:400]
|
||||
if len(content.strip()) > 400:
|
||||
snippet += "…"
|
||||
history_text.append(snippet + "\n\n", style="dim white")
|
||||
|
||||
if history_text.plain.strip():
|
||||
console.print(
|
||||
Panel(
|
||||
history_text,
|
||||
title="[dim]Previous session activity[/]",
|
||||
title_align="left",
|
||||
border_style="dim",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
console = Console()
|
||||
|
||||
# Added for Resume Feature — detect resume and restore state
|
||||
checkpoint_data = getattr(args, "_checkpoint_data", None)
|
||||
is_resuming = getattr(args, "resume_from_checkpoint", False) and checkpoint_data is not None
|
||||
checkpoint_manager = getattr(args, "_checkpoint_manager", None)
|
||||
target_hash = getattr(args, "_target_hash", "")
|
||||
|
||||
resumed_state = None
|
||||
if is_resuming:
|
||||
from strix.agents.state import AgentState
|
||||
|
||||
resumed_state = AgentState.model_validate(checkpoint_data.agent_state)
|
||||
|
||||
# Give the agent a fresh budget from the resume point so it never
|
||||
# stops just because it hit the original ceiling.
|
||||
# Added for Resume Feature — extend max_iterations dynamically.
|
||||
resumed_state.max_iterations = (
|
||||
resumed_state.iteration + checkpoint_data.original_max_iterations
|
||||
)
|
||||
resumed_state.max_iterations_warning_sent = False # Reset warning flag
|
||||
|
||||
# Clear sandbox so a fresh container is always created on resume
|
||||
# (the old container may be gone).
|
||||
resumed_state.sandbox_id = None
|
||||
resumed_state.sandbox_token = None
|
||||
resumed_state.sandbox_info = None
|
||||
|
||||
start_text = Text()
|
||||
start_text.append("Penetration test initiated", style="bold #22c55e")
|
||||
if is_resuming:
|
||||
start_text.append("Penetration test resumed", style="bold #22c55e")
|
||||
else:
|
||||
start_text.append("Penetration test initiated", style="bold #22c55e")
|
||||
|
||||
target_text = Text()
|
||||
target_text.append("Target", style="dim")
|
||||
|
|
@ -75,7 +181,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||
}
|
||||
|
||||
llm_config = LLMConfig(scan_mode=scan_mode)
|
||||
agent_config = {
|
||||
agent_config: dict[str, Any] = {
|
||||
"llm_config": llm_config,
|
||||
"max_iterations": 300,
|
||||
}
|
||||
|
|
@ -83,9 +189,30 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||
if getattr(args, "local_sources", None):
|
||||
agent_config["local_sources"] = args.local_sources
|
||||
|
||||
# Added for Resume Feature — inject checkpoint manager so the agent saves
|
||||
# state after every iteration.
|
||||
if checkpoint_manager:
|
||||
agent_config["checkpoint_manager"] = checkpoint_manager
|
||||
agent_config["target_hash"] = target_hash
|
||||
agent_config["scan_config"] = scan_config
|
||||
|
||||
# Added for Resume Feature — pass restored state into the agent config
|
||||
if resumed_state is not None:
|
||||
agent_config["state"] = resumed_state
|
||||
|
||||
tracer = Tracer(args.run_name)
|
||||
tracer.set_scan_config(scan_config)
|
||||
|
||||
# Added for Resume Feature — pre-populate tracer so stats/vulns are correct
|
||||
if is_resuming and checkpoint_data:
|
||||
tracer.chat_messages.extend(checkpoint_data.tracer_chat_messages)
|
||||
tracer.vulnerability_reports.extend(checkpoint_data.tracer_vulnerability_reports)
|
||||
|
||||
# Added for Resume Feature — show resume banner + replay previous output
|
||||
if is_resuming and checkpoint_data:
|
||||
_print_resume_banner(console, args.run_name, checkpoint_data.iteration)
|
||||
_replay_previous_output(console, checkpoint_data, None)
|
||||
|
||||
def display_vulnerability(report: dict[str, Any]) -> None:
|
||||
report_id = report.get("id", "unknown")
|
||||
|
||||
|
|
|
|||
|
|
@ -366,6 +366,35 @@ Examples:
|
|||
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
|
||||
)
|
||||
|
||||
# Added for Resume Feature — checkpoint / resume flags
|
||||
parser.add_argument(
|
||||
"--run-name",
|
||||
type=str,
|
||||
default=None,
|
||||
dest="run_name_override",
|
||||
help=(
|
||||
"Name for this scan run (used for checkpointing/resume). "
|
||||
"Auto-generated if omitted."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Resume from an existing checkpoint for this run-name + target. "
|
||||
"If no checkpoint is found, starts a fresh scan."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--new",
|
||||
"--force-new",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="force_new",
|
||||
help="Force a completely fresh scan, deleting any existing checkpoint.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.instruction and args.instruction_file:
|
||||
|
|
@ -517,6 +546,57 @@ def persist_config() -> None:
|
|||
save_current_config()
|
||||
|
||||
|
||||
def _setup_checkpoint_on_args(args: argparse.Namespace) -> None:
|
||||
"""Resolve checkpoint / resume state and attach it to ``args``.
|
||||
|
||||
Added for Resume Feature. Sets:
|
||||
- ``args._checkpoint_manager`` — CheckpointManager for this run
|
||||
- ``args._target_hash`` — hash of targets (for validation)
|
||||
- ``args._checkpoint_data`` — loaded CheckpointModel or None
|
||||
- ``args.resume_from_checkpoint`` — True when we should actually resume
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from strix.telemetry.checkpoint import CheckpointManager, compute_target_hash
|
||||
|
||||
run_dir = Path("strix_runs") / args.run_name
|
||||
mgr = CheckpointManager(args.run_name, run_dir)
|
||||
target_hash = compute_target_hash(args.targets_info)
|
||||
|
||||
args._checkpoint_manager = mgr
|
||||
args._target_hash = target_hash
|
||||
args._checkpoint_data = None
|
||||
args.resume_from_checkpoint = False
|
||||
|
||||
if args.force_new:
|
||||
mgr.delete()
|
||||
return
|
||||
|
||||
if mgr.exists():
|
||||
checkpoint = mgr.load()
|
||||
if checkpoint is None:
|
||||
# Corrupted checkpoint — warn and start fresh
|
||||
console = Console()
|
||||
console.print(
|
||||
"[bold yellow]Warning:[/] Checkpoint file is corrupted or unreadable. "
|
||||
"Starting a fresh scan."
|
||||
)
|
||||
return
|
||||
|
||||
if checkpoint.target_hash != target_hash:
|
||||
console = Console()
|
||||
console.print(
|
||||
"[bold yellow]Warning:[/] Checkpoint target mismatch "
|
||||
f"(run '{args.run_name}' was for a different target). "
|
||||
"Starting a fresh scan."
|
||||
)
|
||||
return
|
||||
|
||||
# Valid checkpoint found — auto-resume (or explicit --resume)
|
||||
args._checkpoint_data = checkpoint
|
||||
args.resume_from_checkpoint = True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
|
@ -534,7 +614,13 @@ def main() -> None:
|
|||
|
||||
persist_config()
|
||||
|
||||
args.run_name = generate_run_name(args.targets_info)
|
||||
# Added for Resume Feature — determine run_name and whether to resume
|
||||
if args.run_name_override:
|
||||
args.run_name = args.run_name_override
|
||||
else:
|
||||
args.run_name = generate_run_name(args.targets_info)
|
||||
|
||||
_setup_checkpoint_on_args(args)
|
||||
|
||||
for target_info in args.targets_info:
|
||||
if target_info["type"] == "repository":
|
||||
|
|
|
|||
|
|
@ -709,6 +709,13 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||
self.tracer.set_scan_config(self.scan_config)
|
||||
set_global_tracer(self.tracer)
|
||||
|
||||
# Added for Resume Feature — pre-populate tracer with checkpoint data so
|
||||
# stats and findings reflect the full scan history including past sessions.
|
||||
_cp = getattr(args, "_checkpoint_data", None)
|
||||
if _cp and getattr(args, "resume_from_checkpoint", False):
|
||||
self.tracer.chat_messages.extend(_cp.tracer_chat_messages)
|
||||
self.tracer.vulnerability_reports.extend(_cp.tracer_vulnerability_reports)
|
||||
|
||||
self.agent_nodes: dict[str, TreeNode] = {}
|
||||
|
||||
self._displayed_agents: set[str] = set()
|
||||
|
|
@ -749,7 +756,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||
scan_mode = getattr(args, "scan_mode", "deep")
|
||||
llm_config = LLMConfig(scan_mode=scan_mode, interactive=True)
|
||||
|
||||
config = {
|
||||
config: dict[str, Any] = {
|
||||
"llm_config": llm_config,
|
||||
"max_iterations": 300,
|
||||
}
|
||||
|
|
@ -757,6 +764,27 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||
if getattr(args, "local_sources", None):
|
||||
config["local_sources"] = args.local_sources
|
||||
|
||||
# Added for Resume Feature — restore agent state and wire up checkpoint
|
||||
_cp = getattr(args, "_checkpoint_data", None)
|
||||
if _cp and getattr(args, "resume_from_checkpoint", False):
|
||||
from strix.agents.state import AgentState
|
||||
|
||||
resumed_state = AgentState.model_validate(_cp.agent_state)
|
||||
# Fresh budget from the resume point
|
||||
resumed_state.max_iterations = resumed_state.iteration + _cp.original_max_iterations
|
||||
resumed_state.max_iterations_warning_sent = False
|
||||
# Always spin up a new sandbox (old container may be gone)
|
||||
resumed_state.sandbox_id = None
|
||||
resumed_state.sandbox_token = None
|
||||
resumed_state.sandbox_info = None
|
||||
config["state"] = resumed_state
|
||||
|
||||
_mgr = getattr(args, "_checkpoint_manager", None)
|
||||
if _mgr:
|
||||
config["checkpoint_manager"] = _mgr
|
||||
config["target_hash"] = getattr(args, "_target_hash", "")
|
||||
config["scan_config"] = self.scan_config
|
||||
|
||||
return config
|
||||
|
||||
def _setup_cleanup_handlers(self) -> None:
|
||||
|
|
|
|||
150
strix/telemetry/checkpoint.py
Normal file
150
strix/telemetry/checkpoint.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Checkpoint system for Strix scan resume feature.
|
||||
|
||||
Added for Resume Feature - Original behavior is 100% unchanged when
|
||||
checkpoint_manager is not injected into the agent config.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECKPOINT_VERSION = "1.0"
|
||||
|
||||
|
||||
class CheckpointModel(BaseModel):
|
||||
"""Pydantic model for a full scan checkpoint snapshot.
|
||||
|
||||
Added for Resume Feature.
|
||||
"""
|
||||
|
||||
version: str = CHECKPOINT_VERSION
|
||||
run_name: str
|
||||
target_hash: str # Short SHA-256 of sorted target strings — used for validation
|
||||
saved_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
# Agent loop progress
|
||||
iteration: int
|
||||
original_max_iterations: int # The max that was set when the scan started
|
||||
|
||||
# Full AgentState dump (messages, sandbox_id, sandbox_token, etc.)
|
||||
agent_state: dict[str, Any]
|
||||
|
||||
# Tracer state needed to restore stats and replay findings
|
||||
tracer_chat_messages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
tracer_vulnerability_reports: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
# Original scan configuration (passed to execute_scan)
|
||||
scan_config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def compute_target_hash(targets_info: list[dict[str, Any]]) -> str:
|
||||
"""Return a short stable hash of the target list for checkpoint validation.
|
||||
|
||||
Added for Resume Feature.
|
||||
"""
|
||||
target_strings = sorted(t.get("original", "") for t in (targets_info or []))
|
||||
combined = "|".join(target_strings)
|
||||
return hashlib.sha256(combined.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
class CheckpointManager:
|
||||
"""Saves and loads scan checkpoints to ``strix_runs/<run_name>/checkpoint.json``.
|
||||
|
||||
All operations are *non-fatal*: any I/O error is logged as a warning and
|
||||
the scan continues normally.
|
||||
|
||||
Added for Resume Feature.
|
||||
"""
|
||||
|
||||
def __init__(self, run_name: str, run_dir: Path) -> None:
|
||||
self.run_name = run_name
|
||||
self.run_dir = run_dir
|
||||
self.checkpoint_path = run_dir / "checkpoint.json"
|
||||
self._tmp_path = run_dir / "checkpoint.json.tmp"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Return True if a checkpoint file is present."""
|
||||
return self.checkpoint_path.exists()
|
||||
|
||||
def save(
|
||||
self,
|
||||
agent_state: Any,
|
||||
tracer: Any | None,
|
||||
scan_config: dict[str, Any],
|
||||
target_hash: str,
|
||||
original_max_iterations: int,
|
||||
) -> None:
|
||||
"""Atomically persist the current scan state.
|
||||
|
||||
Writes to a ``.tmp`` file then renames to prevent corruption during a
|
||||
crash mid-write. All errors are non-fatal (warning only).
|
||||
"""
|
||||
try:
|
||||
self.run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state_dict: dict[str, Any] = (
|
||||
agent_state.model_dump() if hasattr(agent_state, "model_dump") else {}
|
||||
)
|
||||
|
||||
tracer_chat_messages: list[dict[str, Any]] = []
|
||||
tracer_vulnerability_reports: list[dict[str, Any]] = []
|
||||
if tracer:
|
||||
tracer_chat_messages = list(getattr(tracer, "chat_messages", []))
|
||||
tracer_vulnerability_reports = list(
|
||||
getattr(tracer, "vulnerability_reports", [])
|
||||
)
|
||||
|
||||
checkpoint = CheckpointModel(
|
||||
run_name=self.run_name,
|
||||
target_hash=target_hash,
|
||||
iteration=agent_state.iteration,
|
||||
original_max_iterations=original_max_iterations,
|
||||
agent_state=state_dict,
|
||||
tracer_chat_messages=tracer_chat_messages,
|
||||
tracer_vulnerability_reports=tracer_vulnerability_reports,
|
||||
scan_config=scan_config,
|
||||
)
|
||||
|
||||
# Atomic write: .tmp → rename
|
||||
self._tmp_path.write_text(checkpoint.model_dump_json(indent=2), encoding="utf-8")
|
||||
os.rename(self._tmp_path, self.checkpoint_path)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[Resume] Checkpoint save failed (non-fatal): %s", e)
|
||||
|
||||
def load(self) -> "CheckpointModel | None":
|
||||
"""Load and parse the checkpoint file.
|
||||
|
||||
Returns ``None`` and logs a warning on any error (corruption, missing
|
||||
file, version mismatch).
|
||||
"""
|
||||
if not self.checkpoint_path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(self.checkpoint_path.read_text(encoding="utf-8"))
|
||||
return CheckpointModel.model_validate(raw)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[Resume] Checkpoint load failed: %s", e)
|
||||
return None
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Remove the checkpoint file (called when the scan finishes cleanly)."""
|
||||
try:
|
||||
if self.checkpoint_path.exists():
|
||||
self.checkpoint_path.unlink()
|
||||
if self._tmp_path.exists():
|
||||
self._tmp_path.unlink()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[Resume] Checkpoint delete failed (non-fatal): %s", e)
|
||||
Loading…
Add table
Reference in a new issue