diff --git a/strix.spec b/strix.spec index 60dd847..9714161 100644 --- a/strix.spec +++ b/strix.spec @@ -142,6 +142,7 @@ hiddenimports = [ 'strix.core.agents', 'strix.core.execution', 'strix.core.inputs', + 'strix.core.paths', 'strix.core.runner', 'strix.core.sessions', 'strix.report', diff --git a/strix/core/agents.py b/strix/core/agents.py index 5351af2..44fe343 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -120,15 +120,20 @@ class AgentCoordinator: session = runtime.session stream = runtime.stream interrupt = runtime.interrupt_on_message - if session is not None: - try: - await session.add_items([self._message_to_session_item(message)]) - except Exception: - logger.exception( - "agent.send failed to append to SDK session target=%s", - target_agent_id, - ) - return False + if session is None: + logger.warning( + "agent.send dropped target=%s because its SDK session is not attached", + target_agent_id, + ) + return False + try: + await session.add_items([self._message_to_session_item(message)]) + except Exception: + logger.exception( + "agent.send failed to append to SDK session target=%s", + target_agent_id, + ) + return False async with self._lock: self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1 self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set() @@ -158,6 +163,7 @@ class AgentCoordinator: session = self.runtimes.get(agent_id, AgentRuntime()).session if count <= 0: return 0, [] + await self._maybe_snapshot() if not include_items or session is None: return count, [] items = await session.get_items() diff --git a/strix/core/paths.py b/strix/core/paths.py new file mode 100644 index 0000000..c8e21b7 --- /dev/null +++ b/strix/core/paths.py @@ -0,0 +1,23 @@ +"""Run directory path helpers.""" + +from __future__ import annotations + +from pathlib import Path + + +RUNS_DIR_NAME = "strix_runs" +RUNTIME_STATE_DIR_NAME = ".state" +RUN_RECORD_FILENAME = "run.json" + + +def run_dir_for(run_name: str, *, cwd: Path | None = None) -> Path: + base = cwd or Path.cwd() + return base / RUNS_DIR_NAME / run_name + + +def runtime_state_dir(run_dir: Path) -> Path: + return run_dir / RUNTIME_STATE_DIR_NAME + + +def run_record_path(run_dir: Path) -> Path: + return run_dir / RUN_RECORD_FILENAME diff --git a/strix/core/runner.py b/strix/core/runner.py index 80be6bc..6240df9 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -12,7 +12,6 @@ import json import logging import uuid from collections.abc import Callable -from pathlib import Path from typing import TYPE_CHECKING, Any from agents import RunConfig @@ -35,6 +34,7 @@ from strix.core.inputs import ( build_scope_context, make_model_settings, ) +from strix.core.paths import run_dir_for, runtime_state_dir from strix.core.sessions import open_agent_session from strix.runtime import session_manager from strix.telemetry.logging import set_scan_id, setup_scan_logging @@ -69,13 +69,15 @@ async def run_strix_scan( # Resolve run_dir before any heavy bring-up so the log file captures # everything from sandbox start onwards. - run_dir = Path.cwd() / "strix_runs" / scan_id + run_dir = run_dir_for(scan_id) run_dir.mkdir(parents=True, exist_ok=True) + state_dir = runtime_state_dir(run_dir) + state_dir.mkdir(parents=True, exist_ok=True) teardown_logging = setup_scan_logging(run_dir) set_scan_id(scan_id) - agents_path = run_dir / "agents.json" - agents_db = run_dir / "agents.db" + agents_path = state_dir / "agents.json" + agents_db = state_dir / "agents.db" is_resume = agents_path.exists() logger.info( @@ -103,14 +105,14 @@ async def run_strix_scan( coordinator = AgentCoordinator() coordinator.set_snapshot_path(agents_path) - # Wire the per-agent todo store to ``{run_dir}/todos.json`` (mirrored + # Wire the per-agent todo store to ``{run_dir}/.state/todos.json`` (mirrored # on every CRUD) and reload any prior todos so respawned subagents # find their lists intact. Same for the shared notes store. from strix.tools.notes.tools import hydrate_notes_from_disk from strix.tools.todo.tools import hydrate_todos_from_disk - hydrate_todos_from_disk(run_dir) - hydrate_notes_from_disk(run_dir) + hydrate_todos_from_disk(state_dir) + hydrate_notes_from_disk(state_dir) root_id: str | None = None if is_resume: diff --git a/strix/interface/cli.py b/strix/interface/cli.py index 7f1c9c7..6b37f2e 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -89,6 +89,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, + "non_interactive": bool(getattr(args, "non_interactive", False)), + "local_sources": getattr(args, "local_sources", None) or [], + "scope_mode": getattr(args, "scope_mode", "auto"), + "diff_base": getattr(args, "diff_base", None), # 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. @@ -96,8 +100,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 } report_state = ReportState(args.run_name) - report_state.set_scan_config(scan_config) report_state.hydrate_from_run_dir() + report_state.set_scan_config(scan_config) + report_state.save_run_data() def display_vulnerability(report: dict[str, Any]) -> None: report_id = report.get("id", "unknown") @@ -121,7 +126,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 report_state.cleanup() def signal_handler(_signum: int, _frame: Any) -> None: - report_state.cleanup() + report_state.cleanup(status="interrupted") sys.exit(1) atexit.register(cleanup_on_exit) diff --git a/strix/interface/main.py b/strix/interface/main.py index cc8505a..6fafab1 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -5,9 +5,9 @@ Strix Agent Interface import argparse import asyncio -import json import shutil import sys +from datetime import UTC, datetime from pathlib import Path from agents.model_settings import ModelSettings @@ -24,6 +24,7 @@ from strix.config import ( persist_current, ) from strix.config.models import configure_sdk_model_defaults, normalize_model_name +from strix.core.paths import run_dir_for, runtime_state_dir from strix.interface.cli import run_cli from strix.interface.tui import run_tui from strix.interface.utils import ( @@ -42,6 +43,7 @@ from strix.interface.utils import ( validate_config_file, ) from strix.report.state import get_global_report_state +from strix.report.writer import read_run_record, write_run_record from strix.telemetry import posthog from strix.telemetry.logging import configure_dependency_logging @@ -433,7 +435,7 @@ Examples: "the prior run left off, including the original target list." ) _load_resume_state(args, parser) - agents_path = Path("strix_runs") / args.resume / "agents.json" + agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json" if not agents_path.exists(): parser.error( f"--resume {args.resume}: missing {agents_path}. The run was " @@ -469,17 +471,16 @@ Examples: 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 `` 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 +def _persist_run_record(args: argparse.Namespace) -> None: + """Write the single public run descriptor used by resume and reporting.""" + run_dir = run_dir_for(args.run_name) run_dir.mkdir(parents=True, exist_ok=True) - state = { + run_record = { + "run_id": args.run_name, + "run_name": args.run_name, + "status": "running", + "start_time": datetime.now(UTC).isoformat(), + "end_time": None, "targets_info": args.targets_info, "scan_mode": args.scan_mode, "instruction": args.instruction, @@ -489,34 +490,26 @@ def _persist_scan_state(args: argparse.Namespace) -> None: "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", - ) + write_run_record(run_dir, run_record) 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//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" + """Populate ``args.targets_info`` and friends from a prior run's run.json.""" + run_dir = run_dir_for(args.resume) + state_path = run_dir / "run.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}") + state = read_run_record(run_dir) + except RuntimeError as exc: + parser.error(f"--resume {args.resume}: run.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") + parser.error(f"--resume {args.resume}: run.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 @@ -540,8 +533,6 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser 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"): @@ -561,8 +552,8 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) -> report_state = get_global_report_state() scan_completed = False - if report_state and report_state.scan_results: - scan_completed = report_state.scan_results.get("scan_completed", False) + if report_state: + scan_completed = report_state.run_record.get("status") == "completed" completion_text = Text() if scan_completed: @@ -739,10 +730,10 @@ def main() -> None: else: args.instruction = diff_scope.instruction_block - # Persist the fully-resolved scan state so a future + # Persist the fully-resolved run descriptor so a future # ``--resume `` invocation can pick up without # re-supplying targets / instructions / scope. - _persist_scan_state(args) + _persist_run_record(args) posthog.start( model=load_settings().llm.model, @@ -767,9 +758,14 @@ def main() -> None: finally: report_state = get_global_report_state() if report_state: + status = {"interrupted": "interrupted", "error": "failed"}.get( + exit_reason, + "stopped", + ) + report_state.cleanup(status=status) posthog.end(report_state, exit_reason=exit_reason) - results_path = Path("strix_runs") / args.run_name + results_path = run_dir_for(args.run_name) display_completion_message(args, results_path) if args.non_interactive: diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py index e9865bb..310cc11 100644 --- a/strix/interface/tui/app.py +++ b/strix/interface/tui/app.py @@ -708,8 +708,9 @@ class StrixTUIApp(App): # type: ignore[misc] self.scan_config = self._build_scan_config(args) self.report_state = ReportState(self.scan_config["run_name"]) - self.report_state.set_scan_config(self.scan_config) self.report_state.hydrate_from_run_dir() + self.report_state.set_scan_config(self.scan_config) + self.report_state.save_run_data() set_global_report_state(self.report_state) self.live_view = TuiLiveView() self.live_view.hydrate_from_run_dir(self.report_state.get_run_dir()) @@ -759,6 +760,10 @@ 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"), + "non_interactive": bool(getattr(args, "non_interactive", False)), + "local_sources": getattr(args, "local_sources", None) or [], + "scope_mode": getattr(args, "scope_mode", "auto"), + "diff_base": getattr(args, "diff_base", None), # 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 "", @@ -769,7 +774,7 @@ class StrixTUIApp(App): # type: ignore[misc] self.report_state.cleanup() def signal_handler(_signum: int, _frame: Any) -> None: - self.report_state.cleanup() + self.report_state.cleanup(status="interrupted") sys.exit(0) atexit.register(cleanup_on_exit) @@ -1611,13 +1616,16 @@ class StrixTUIApp(App): # type: ignore[misc] ) target_agent_id = self.selected_agent_id - send_user_message_to_agent( + submitted = send_user_message_to_agent( coordinator=self.coordinator, loop=self._scan_loop, live_view=self.live_view, target_agent_id=target_agent_id, message=message, ) + if not submitted: + self.notify("Scan loop is not ready; message was not sent", severity="warning") + return self._displayed_events.clear() self._update_chat_view() diff --git a/strix/interface/tui/history.py b/strix/interface/tui/history.py index 9410283..1598b1a 100644 --- a/strix/interface/tui/history.py +++ b/strix/interface/tui/history.py @@ -8,6 +8,8 @@ import sqlite3 from datetime import UTC, datetime from typing import TYPE_CHECKING, Any +from strix.core.paths import runtime_state_dir + if TYPE_CHECKING: from pathlib import Path @@ -17,7 +19,7 @@ logger = logging.getLogger(__name__) def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[str, Any], str]]: - agents_db = run_dir / "agents.db" + agents_db = runtime_state_dir(run_dir) / "agents.db" session_ids = [aid for aid in agent_ids if isinstance(aid, str)] if not agents_db.exists() or not session_ids: return [] diff --git a/strix/interface/tui/live_view.py b/strix/interface/tui/live_view.py index 9d497ca..e14b352 100644 --- a/strix/interface/tui/live_view.py +++ b/strix/interface/tui/live_view.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from pathlib import Path +from strix.core.paths import runtime_state_dir from strix.interface.tui.history import load_session_history @@ -24,7 +25,8 @@ class TuiLiveView: self._tool_event_by_call_id: dict[str, dict[str, Any]] = {} def hydrate_from_run_dir(self, run_dir: Path) -> None: - agents_path = run_dir / "agents.json" + state_dir = runtime_state_dir(run_dir) + agents_path = state_dir / "agents.json" if not agents_path.exists(): return try: diff --git a/strix/interface/tui/messages.py b/strix/interface/tui/messages.py index 5e362a6..c1e473c 100644 --- a/strix/interface/tui/messages.py +++ b/strix/interface/tui/messages.py @@ -3,9 +3,13 @@ from __future__ import annotations import asyncio +import logging from typing import Any +logger = logging.getLogger(__name__) + + def send_user_message_to_agent( *, coordinator: Any, @@ -15,16 +19,26 @@ def send_user_message_to_agent( message: str, ) -> bool: """Record a local user message and enqueue it into the target SDK session.""" - live_view.record_user_message(target_agent_id, message) - if loop is None or loop.is_closed(): return False - asyncio.run_coroutine_threadsafe( + live_view.record_user_message(target_agent_id, message) + future = asyncio.run_coroutine_threadsafe( coordinator.send( target_agent_id, {"from": "user", "content": message, "type": "instruction"}, ), loop, ) + future.add_done_callback(_log_delivery_failure) return True + + +def _log_delivery_failure(future: Any) -> None: + try: + delivered = bool(future.result()) + except Exception: + logger.exception("TUI user message delivery failed") + return + if not delivered: + logger.warning("TUI user message was not persisted to the SDK session") diff --git a/strix/report/state.py b/strix/report/state.py index af0be95..8b84cd1 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -6,7 +6,13 @@ from pathlib import Path from typing import Any, Optional from uuid import uuid4 -from strix.report.writer import write_executive_report, write_run_metadata, write_vulnerabilities +from strix.core.paths import run_dir_for +from strix.report.writer import ( + read_run_record, + write_executive_report, + write_run_record, + write_vulnerabilities, +) from strix.telemetry import posthog @@ -45,13 +51,13 @@ class ReportState: self.scan_results: dict[str, Any] | None = None self.scan_config: dict[str, Any] | None = None - self.run_metadata: dict[str, Any] = { + self.run_record: dict[str, Any] = { "run_id": self.run_id, "run_name": self.run_name, "start_time": self.start_time, "end_time": None, - "targets": [], "status": "running", + "targets_info": [], } self._run_dir: Path | None = None self._saved_vuln_ids: set[str] = set() @@ -61,28 +67,22 @@ class ReportState: def get_run_dir(self) -> Path: if self._run_dir is None: - runs_dir = Path.cwd() / "strix_runs" - runs_dir.mkdir(exist_ok=True) - run_dir_name = self.run_name if self.run_name else self.run_id - self._run_dir = runs_dir / run_dir_name - self._run_dir.mkdir(exist_ok=True) + self._run_dir = run_dir_for(run_dir_name) + self._run_dir.mkdir(parents=True, exist_ok=True) return self._run_dir def hydrate_from_run_dir(self) -> None: """Reload prior-scan state from ``{run_dir}/`` for resume. - Called by :func:`run_strix_scan` before any new agent runs. Restores: - ``vulnerability_reports`` from ``vulnerabilities.json`` so :meth:`add_vulnerability_report` doesn't allocate a colliding ``vuln-0001`` and overwrite the prior on-disk MD. - - ``run_metadata`` (start_time, run_id, targets, status) from - ``run_metadata.json`` so audit-trail timestamps + the final - report's duration calc reflect the original scan, not just - this resume segment. + - ``run_record`` from ``run.json`` so timestamps, run inputs, + status, and final report state have one public source of truth. Idempotent on missing files (fresh runs land here too via the same code path). **Raises on corruption** — silently swallowing @@ -93,25 +93,18 @@ class ReportState: """ run_dir = self.get_run_dir() - meta_path = run_dir / "run_metadata.json" - if meta_path.exists(): - try: - data = json.loads(meta_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise RuntimeError( - f"run_metadata.json at {meta_path} is unreadable: {exc}", - ) from exc - if isinstance(data, dict): - if isinstance(data.get("start_time"), str): - self.start_time = data["start_time"] - self.run_metadata.update( - { - k: v - for k, v in data.items() - if k in {"run_id", "run_name", "start_time", "targets", "status"} - }, - ) - logger.info("scan store hydrated run_metadata from %s", meta_path) + data = read_run_record(run_dir) + if data: + self.run_record.update(data) + if isinstance(data.get("start_time"), str): + self.start_time = data["start_time"] + if isinstance(data.get("end_time"), str): + self.end_time = data["end_time"] + scan_results = data.get("scan_results") + if isinstance(scan_results, dict): + self.scan_results = scan_results + self.final_scan_result = self._format_final_scan_result(scan_results) + logger.info("report state hydrated run.json from %s", run_dir) json_path = run_dir / "vulnerabilities.json" if json_path.exists(): @@ -133,7 +126,8 @@ class ReportState: if isinstance(rid, str): self._saved_vuln_ids.add(rid) logger.info( - "scan store hydrated %d vulnerability report(s)", len(self.vulnerability_reports) + "report state hydrated %d vulnerability report(s)", + len(self.vulnerability_reports), ) def add_vulnerability_report( @@ -228,22 +222,8 @@ class ReportState: "success": True, } - self.final_scan_result = f"""# Executive Summary - -{executive_summary.strip()} - -# Methodology - -{methodology.strip()} - -# Technical Analysis - -{technical_analysis.strip()} - -# Recommendations - -{recommendations.strip()} -""" + self.final_scan_result = self._format_final_scan_result(self.scan_results) + self.run_record["scan_results"] = self.scan_results logger.info("Updated scan final fields") self.save_run_data(mark_complete=True) @@ -251,25 +231,61 @@ class ReportState: def set_scan_config(self, config: dict[str, Any]) -> None: self.scan_config = config - self.run_metadata.update( + self.run_record["status"] = "running" + self.run_record["end_time"] = None + self.run_record.pop("scan_results", None) + self.end_time = None + self.scan_results = None + self.final_scan_result = None + self.run_record.update( { - "targets": config.get("targets", []), - "user_instructions": config.get("user_instructions", ""), - "max_iterations": config.get("max_iterations", 200), + "targets_info": config.get("targets", []), + "instruction": config.get("user_instructions", ""), + "scan_mode": config.get("scan_mode", "deep"), + "diff_scope": config.get("diff_scope", {"active": False}), + "non_interactive": bool(config.get("non_interactive", False)), + "local_sources": config.get("local_sources", []), + "scope_mode": config.get("scope_mode", "auto"), + "diff_base": config.get("diff_base"), } ) - def save_run_data(self, mark_complete: bool = False) -> None: + def save_run_data(self, mark_complete: bool = False, status: str | None = None) -> None: if mark_complete: + self.end_time = datetime.now(UTC).isoformat() + self.run_record["end_time"] = self.end_time + self.run_record["status"] = "completed" + elif status and self.run_record.get("status") != "completed": + current_status = self.run_record.get("status") + if status == "stopped" and current_status in {"failed", "interrupted"}: + status = str(current_status) if self.end_time is None: self.end_time = datetime.now(UTC).isoformat() - self.run_metadata["end_time"] = self.end_time - self.run_metadata["status"] = "completed" + self.run_record["end_time"] = self.end_time + self.run_record["status"] = status self._save_artifacts() - def cleanup(self) -> None: - self.save_run_data(mark_complete=True) + def cleanup(self, status: str = "stopped") -> None: + self.save_run_data(status=status) + + def _format_final_scan_result(self, scan_results: dict[str, Any]) -> str: + return f"""# Executive Summary + +{str(scan_results.get("executive_summary", "")).strip()} + +# Methodology + +{str(scan_results.get("methodology", "")).strip()} + +# Technical Analysis + +{str(scan_results.get("technical_analysis", "")).strip()} + +# Recommendations + +{str(scan_results.get("recommendations", "")).strip()} +""" def _save_artifacts(self) -> None: """Write scan artifacts under ``run_dir``.""" @@ -283,7 +299,7 @@ class ReportState: if self.vulnerability_reports: write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids) - write_run_metadata(run_dir, self.run_metadata) + write_run_record(run_dir, self.run_record) logger.info("Essential scan data saved to: %s", run_dir) except (OSError, RuntimeError): diff --git a/strix/report/writer.py b/strix/report/writer.py index 8a08b3a..8118fe9 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -10,16 +10,31 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any +from strix.core.paths import run_record_path + logger = logging.getLogger(__name__) _SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} -def write_run_metadata(run_dir: Path, run_metadata: dict[str, Any]) -> None: +def read_run_record(run_dir: Path) -> dict[str, Any]: + path = run_record_path(run_dir) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"run.json at {path} is unreadable: {exc}") from exc + if not isinstance(data, dict): + raise RuntimeError(f"run.json at {path} is not an object") + return data + + +def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None: _atomic_write_text( - run_dir / "run_metadata.json", - json.dumps(run_metadata, ensure_ascii=False, indent=2, default=str), + run_record_path(run_dir), + json.dumps(run_record, ensure_ascii=False, indent=2, default=str), ) diff --git a/strix/tools/notes/tools.py b/strix/tools/notes/tools.py index 31c472e..aa21928 100644 --- a/strix/tools/notes/tools.py +++ b/strix/tools/notes/tools.py @@ -1,7 +1,7 @@ """Per-run notes (shared across agents). Module-level dict shared across every agent in the same scan process. -Mirrored to ``{run_dir}/notes.json`` after every CRUD via :func:`_persist` +Mirrored to ``{state_dir}/notes.json`` after every CRUD via :func:`_persist` so a process restart can :func:`hydrate_notes_from_disk` and the resumed scan picks up exactly where it left off. Concurrent writers are serialised by ``_notes_lock`` since each tool entry-point dispatches @@ -37,8 +37,8 @@ _DEFAULT_CONTENT_PREVIEW_CHARS = 280 _notes_path: Path | None = None -def hydrate_notes_from_disk(run_dir: Path) -> None: - """Wire the on-disk mirror at ``{run_dir}/notes.json`` and reload it. +def hydrate_notes_from_disk(state_dir: Path) -> None: + """Wire the on-disk mirror at ``{state_dir}/notes.json`` and reload it. Called by :func:`run_strix_scan` once at scan setup. Subsequent CRUD calls auto-persist after every mutation. Idempotent on missing file. @@ -46,7 +46,7 @@ def hydrate_notes_from_disk(run_dir: Path) -> None: the scan over a broken sidecar artifact. """ global _notes_path # noqa: PLW0603 - _notes_path = run_dir / "notes.json" + _notes_path = state_dir / "notes.json" with _notes_lock: _notes_storage.clear() if not _notes_path.exists(): @@ -76,7 +76,7 @@ def hydrate_notes_from_disk(run_dir: Path) -> None: def _persist() -> None: - """Atomic-rename mirror of ``_notes_storage`` → ``{run_dir}/notes.json``. + """Atomic-rename mirror of ``_notes_storage`` → ``{state_dir}/notes.json``. No-op when ``_notes_path`` isn't wired (tests). Errors are logged and swallowed — a disk hiccup must never tear down the agent's call. diff --git a/strix/tools/todo/tools.py b/strix/tools/todo/tools.py index 73d0623..a4a2956 100644 --- a/strix/tools/todo/tools.py +++ b/strix/tools/todo/tools.py @@ -1,7 +1,7 @@ """Per-agent todo tools. Per-agent in-memory dict, scoped via ``ctx.context['agent_id']``. The -table is mirrored to ``{run_dir}/todos.json`` after every mutation so a +table is mirrored to ``{state_dir}/todos.json`` after every mutation so a process restart can ``hydrate_todos_from_disk`` and each respawned agent finds its prior list intact. The persistence is best-effort — errors are logged and swallowed so a disk failure can't kill the agent @@ -54,8 +54,8 @@ _todos_path: Path | None = None _todos_io_lock = threading.RLock() -def hydrate_todos_from_disk(run_dir: Path) -> None: - """Wire the on-disk mirror at ``{run_dir}/todos.json`` and reload it. +def hydrate_todos_from_disk(state_dir: Path) -> None: + """Wire the on-disk mirror at ``{state_dir}/todos.json`` and reload it. Called by :func:`run_strix_scan` once at scan setup. Subsequent CRUD calls auto-persist after every mutation. Idempotent on missing file. @@ -63,7 +63,7 @@ def hydrate_todos_from_disk(run_dir: Path) -> None: the scan over a broken sidecar artifact. """ global _todos_path # noqa: PLW0603 - _todos_path = run_dir / "todos.json" + _todos_path = state_dir / "todos.json" with _todos_io_lock: _todos_storage.clear() if not _todos_path.exists(): @@ -99,7 +99,7 @@ def hydrate_todos_from_disk(run_dir: Path) -> None: def _persist() -> None: - """Atomic-rename mirror of ``_todos_storage`` → ``{run_dir}/todos.json``. + """Atomic-rename mirror of ``_todos_storage`` → ``{state_dir}/todos.json``. No-op when ``_todos_path`` isn't wired (tests). Errors are logged and swallowed.