mirror of
https://github.com/usestrix/strix.git
synced 2026-08-23 11:22:37 +02:00
feat(orchestration): always-on resume across the agent graph
A scan that crashes or is stopped can now be resumed by re-invoking
``strix`` with the same ``--run-name``. Resume is implicit — presence
of ``{run_dir}/bus.json`` triggers it. To force a fresh start, delete
the run dir.
What survives a process restart with the same scan_id:
* Root agent's LLM history — already worked (root SDK SQLiteSession).
* Every non-terminal subagent's LLM history — new. ``create_agent``
now opens SQLiteSession(session_id=child_id,
db_path={run_dir}/sessions/{child_id}.db) per child and passes it
to ``run_with_continuation``.
* Bus topology — new. ``AgentMessageBus`` gains snapshot/restore/
_maybe_snapshot async methods plus a ``metadata`` field that holds
per-agent {task, skills, is_whitebox, scan_mode, diff_scope}.
``register``, ``finalize``, ``park``, and ``mark_llm_failed`` each
call ``_maybe_snapshot`` to atomically persist the bus to
{run_dir}/bus.json (tempfile + Path.replace).
* Vulnerability reports — new. ``ScanArtifactWriter._write_
vulnerabilities`` now also writes ``vulnerabilities.json``
(atomic). ``Tracer.hydrate_from_run_dir`` reads it on resume so
new vuln-NNNN ids don't collide with prior on-disk files.
What does not survive: the sandbox container itself (fresh per
process), so ``/workspace/scratch`` and Caido state are lost.
``/workspace/sources`` re-mounts from the host so source code is
unchanged.
``orchestration/scan.py:run_strix_scan`` does the actual resume:
1. Resolve run_dir up front; if bus.json exists it's a resume.
2. Acquire {run_dir}/.lock (fcntl.flock) so a second strix process
can't run concurrently on the same scan_id.
3. ``bus.set_snapshot_path(...)``, ``tracer.hydrate_from_run_dir()``.
4. On resume: load + bus.restore, find root_id from snapshot (the
agent with parent_of[id] is None), spawn the sandbox, skip the
root's bus.register (already in snapshot).
5. ``_respawn_subagents`` walks every agent with status in
running/waiting/llm_failed: reopens its SQLiteSession, rebuilds
the child agent via the captured factory, builds run config /
context, asyncio.create_task the run with initial_input=[] so
the SDK replays from session. Per-child failure (missing/corrupt
DB, factory raises) finalizes that child as crashed and continues.
6. Open root SQLiteSession at the same path, run the root with
initial_input=[] on resume (or the formatted root task on a
fresh run), and let SDK replay drive the next turn.
7. ``finally``: close every per-agent session, take a final
snapshot, tear down sandbox, release the lock.
HARNESS_WIKI.md updated with the new run-dir layout (sessions/,
bus.json, vulnerabilities.json, .lock) and the resume contract.
Net: +500 LoC across 7 files. No new deps.
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
81703e286f
commit
d538acf66b
@@ -1,19 +1,21 @@
|
||||
"""Per-scan artifact writer.
|
||||
|
||||
Writes the customer-facing penetration-test report and per-vulnerability
|
||||
markdown + a ``vulnerabilities.csv`` index under ``strix_runs/<run>/``.
|
||||
markdown + a ``vulnerabilities.csv`` index under ``strix_runs/<run>/``,
|
||||
plus a machine-readable ``vulnerabilities.json`` so a resumed scan's
|
||||
:class:`~strix.telemetry.tracer.Tracer` can hydrate its in-memory list
|
||||
back from disk (otherwise vuln-id allocation collides post-restart).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -107,6 +109,15 @@ class ScanArtifactWriter:
|
||||
},
|
||||
)
|
||||
|
||||
# JSON index: machine-readable mirror used by ``Tracer.hydrate_from_run_dir``
|
||||
# so a process restart (resume path in ``orchestration/scan.py``) can
|
||||
# rebuild ``vulnerability_reports`` and re-establish the next id slot
|
||||
# before any new ``add_vulnerability_report`` call collides on disk.
|
||||
_atomic_write_text(
|
||||
self._run_dir / "vulnerabilities.json",
|
||||
json.dumps(reports, ensure_ascii=False, indent=2, default=str),
|
||||
)
|
||||
|
||||
if new_reports:
|
||||
logger.info(
|
||||
"Saved %d new vulnerability report(s) to: %s",
|
||||
@@ -116,6 +127,22 @@ class ScanArtifactWriter:
|
||||
logger.info("Updated vulnerability index: %s", csv_path)
|
||||
|
||||
|
||||
def _atomic_write_text(path: Path, payload: str) -> None:
|
||||
"""``tempfile`` + atomic rename so a crash mid-write leaves the prior file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with 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)
|
||||
|
||||
|
||||
def _render_vulnerability_md(report: dict[str, Any]) -> str:
|
||||
lines: list[str] = [
|
||||
f"# {report.get('title', 'Untitled Vulnerability')}\n",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
@@ -93,6 +94,38 @@ class Tracer:
|
||||
|
||||
return self._run_dir
|
||||
|
||||
def hydrate_from_run_dir(self) -> None:
|
||||
"""Reload ``vulnerability_reports`` from ``{run_dir}/vulnerabilities.json``.
|
||||
|
||||
Called by the resume path in :func:`run_strix_scan` before any
|
||||
new agent runs. Ensures id allocation in
|
||||
:meth:`add_vulnerability_report` does not collide on disk
|
||||
(``vuln-0001`` re-used would otherwise overwrite the prior MD).
|
||||
Idempotent — calling without a JSON file is a no-op.
|
||||
"""
|
||||
try:
|
||||
json_path = self.get_run_dir() / "vulnerabilities.json"
|
||||
if not json_path.exists():
|
||||
return
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, list):
|
||||
return
|
||||
self.vulnerability_reports = [r for r in data if isinstance(r, dict)]
|
||||
# Pre-mark these ids as already-saved so the writer doesn't
|
||||
# re-emit per-vuln markdown on the next save() call.
|
||||
writer = self._get_writer()
|
||||
for r in self.vulnerability_reports:
|
||||
rid = r.get("id")
|
||||
if isinstance(rid, str):
|
||||
writer._saved_vuln_ids.add(rid)
|
||||
logger.info(
|
||||
"tracer hydrated %d vulnerability report(s) from %s",
|
||||
len(self.vulnerability_reports),
|
||||
json_path,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("tracer hydrate_from_run_dir failed; starting fresh")
|
||||
|
||||
def _get_writer(self) -> ScanArtifactWriter:
|
||||
if self._writer is None:
|
||||
self._writer = ScanArtifactWriter(self.get_run_dir())
|
||||
|
||||
Reference in New Issue
Block a user