diff --git a/strix/interface/cli.py b/strix/interface/cli.py index 9a654664..3c2982cb 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -314,7 +314,8 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 results_text = Text() results_text.append("Output", style="dim") results_text.append(" ") - results_text.append(f"strix_runs/{args.run_name}", style="#60a5fa") + from pathlib import Path + results_text.append(str(Path.home() / "strix_runs" / args.run_name), style="#60a5fa") note_text = Text() note_text.append("\n\n", style="dim") diff --git a/strix/interface/main.py b/strix/interface/main.py index 9b5ad343..ac793806 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -606,20 +606,22 @@ def _setup_checkpoint_on_args(args: argparse.Namespace) -> None: args._checkpoint_data = None args.resume_from_checkpoint = False + strix_runs = Path.home() / "strix_runs" + if args.force_new: # Delete any checkpoint for the current run name (if explicit) and start fresh if args.run_name: - run_dir = Path("strix_runs") / args.run_name + run_dir = strix_runs / args.run_name CheckpointManager(args.run_name, run_dir).delete() if not args.run_name: args.run_name = generate_run_name(args.targets_info) - run_dir = Path("strix_runs") / args.run_name + run_dir = strix_runs / args.run_name args._checkpoint_manager = CheckpointManager(args.run_name, run_dir) return # If no explicit run name, try auto-detect by target hash if not args.run_name: - found = _find_checkpoint_by_target_hash(Path("strix_runs"), target_hash) + found = _find_checkpoint_by_target_hash(strix_runs, target_hash) if found: run_name, checkpoint = found console = Console() @@ -629,7 +631,7 @@ def _setup_checkpoint_on_args(args: argparse.Namespace) -> None: f"[dim]Use --new to start fresh.[/]" ) args.run_name = run_name - run_dir = Path("strix_runs") / run_name + run_dir = strix_runs / run_name args._checkpoint_manager = CheckpointManager(run_name, run_dir) args._checkpoint_data = checkpoint args.resume_from_checkpoint = True @@ -638,7 +640,7 @@ def _setup_checkpoint_on_args(args: argparse.Namespace) -> None: args.run_name = generate_run_name(args.targets_info) # Explicit run name (or freshly generated) — look for its checkpoint - run_dir = Path("strix_runs") / args.run_name + run_dir = strix_runs / args.run_name mgr = CheckpointManager(args.run_name, run_dir) args._checkpoint_manager = mgr @@ -727,7 +729,7 @@ def main() -> None: if tracer: posthog.end(tracer, exit_reason=exit_reason) - results_path = Path("strix_runs") / args.run_name + results_path = Path.home() / "strix_runs" / args.run_name display_completion_message(args, results_path) if args.non_interactive: diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 50e1a784..2d8b8454 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -2139,6 +2139,22 @@ class StrixTUIApp(App): # type: ignore[misc] self._scan_thread.join(timeout=1.0) + # Explicitly save checkpoint here so it is always persisted before the + # TUI exits, regardless of whether atexit handlers run reliably after + # Textual's exit() call. + _mgr = self.agent_config.get("checkpoint_manager") + _agent = getattr(self, "_current_agent", None) + if _mgr and _agent: + import contextlib + with contextlib.suppress(Exception): + _mgr.save( + _agent.state, + self.tracer, + self.scan_config, + self.agent_config.get("target_hash", ""), + _agent.max_iterations, + ) + self.tracer.cleanup() self.exit() diff --git a/strix/telemetry/checkpoint.py b/strix/telemetry/checkpoint.py index c8f76b50..ac6cef72 100644 --- a/strix/telemetry/checkpoint.py +++ b/strix/telemetry/checkpoint.py @@ -12,10 +12,21 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any + from pydantic import BaseModel, Field logger = logging.getLogger(__name__) + +def _json_default(obj: Any) -> Any: + """Fallback serialiser for objects json.dumps cannot handle natively.""" + if hasattr(obj, "isoformat"): + return obj.isoformat() + if hasattr(obj, "model_dump"): + return obj.model_dump(mode="json") + return str(obj) + + CHECKPOINT_VERSION = "1.0" @@ -106,7 +117,7 @@ class CheckpointManager: 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 {} + agent_state.model_dump(mode="json") if hasattr(agent_state, "model_dump") else {} ) tracer_chat_messages: list[dict[str, Any]] = [] @@ -137,7 +148,7 @@ class CheckpointManager: s = getattr(inst, "state", None) if s is not None and s.parent_id is not None: sub_agent_states[sid] = ( - s.model_dump() if hasattr(s, "model_dump") else {} + s.model_dump(mode="json") if hasattr(s, "model_dump") else {} ) except Exception: # noqa: BLE001 pass @@ -157,8 +168,13 @@ class CheckpointManager: sub_agent_states=sub_agent_states, ) - # Atomic write: .tmp → rename - self._tmp_path.write_text(checkpoint.model_dump_json(indent=2), encoding="utf-8") + # Atomic write: .tmp → rename. Use json.dumps with a fallback + # serialiser so non-standard objects (datetime, Pydantic models, + # etc.) never silently abort the save. + json_str = json.dumps( + checkpoint.model_dump(mode="json"), indent=2, default=_json_default + ) + self._tmp_path.write_text(json_str, encoding="utf-8") os.rename(self._tmp_path, self.checkpoint_path) except Exception as e: # noqa: BLE001 diff --git a/strix/telemetry/tracer.py b/strix/telemetry/tracer.py index bde9750a..0f5459cc 100644 --- a/strix/telemetry/tracer.py +++ b/strix/telemetry/tracer.py @@ -294,7 +294,7 @@ class Tracer: def get_run_dir(self) -> Path: if self._run_dir is None: - runs_dir = Path.cwd() / "strix_runs" + runs_dir = Path.home() / "strix_runs" runs_dir.mkdir(exist_ok=True) run_dir_name = self.run_name if self.run_name else self.run_id