Fix checkpoint not updating on second resume

Root cause: checkpoint and tracer run directories used CWD-relative
paths (Path("strix_runs") and Path.cwd() / "strix_runs"). Launching
strix from different directories across sessions created separate
checkpoint files that never updated each other, so the third session
always resumed from the first session's iteration.

Fix: use Path.home() / "strix_runs" as the canonical absolute path in
both tracer.py and main.py so all sessions write to the same location
regardless of CWD.

Also includes earlier serialization robustness fixes (mode="json" +
_json_default fallback) and explicit checkpoint save in action_custom_quit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ahmex000 2026-03-19 13:55:41 +01:00
parent daecf22290
commit 9cba355b54
5 changed files with 47 additions and 12 deletions

View file

@ -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")

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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