Use shared agent persistence files

This commit is contained in:
0xallam
2026-04-26 09:30:13 -07:00
parent 5ec1e0786f
commit dc03f1f4ed
6 changed files with 75 additions and 119 deletions
+3 -3
View File
@@ -31,8 +31,8 @@ Removed custom orchestration modules:
- `hooks.py` - `hooks.py`
- `run_loop.py` - `run_loop.py`
`bus.json` remains only as the backward-compatible snapshot filename for old runs and tracer `agents.json` is the only graph/status snapshot. `agents.db` is the only SDK session database;
hydration. It is no longer backed by `AgentMessageBus`. each agent uses its own SDK `session_id` inside that shared database.
## Lifecycle Semantics ## Lifecycle Semantics
@@ -65,5 +65,5 @@ Unknown agent id is the only invalid message target. There is no routing-closed
- Interactive `wait_for_message` parks and returns control to the continuation loop. - Interactive `wait_for_message` parks and returns control to the continuation loop.
- Non-interactive `wait_for_message` returns the newly appended session message content. - Non-interactive `wait_for_message` returns the newly appended session message content.
- `finish_scan` blocks while child agents are active. - `finish_scan` blocks while child agents are active.
- Resume rebuilds parked child runners from `bus.json` plus per-agent session DBs. - Resume rebuilds parked child runners from `agents.json` plus the shared `agents.db`.
- Graceful stop works for both active-stream and parked agents. - Graceful stop works for both active-stream and parked agents.
+6 -6
View File
@@ -394,7 +394,7 @@ Examples:
help=( help=(
"Resume a prior scan by its run name (the dir under ./strix_runs/). " "Resume a prior scan by its run name (the dir under ./strix_runs/). "
"Picks up the root + every non-terminal subagent's full LLM history " "Picks up the root + every non-terminal subagent's full LLM history "
"and bus topology. Skips fresh run-name generation." "and agent topology. Skips fresh run-name generation."
), ),
) )
@@ -417,7 +417,7 @@ Examples:
# Capture before ``_load_resume_state`` overrides — used by the resume # Capture before ``_load_resume_state`` overrides — used by the resume
# path in ``run_strix_scan`` to decide whether to inject the new # path in ``run_strix_scan`` to decide whether to inject the new
# instruction into the root's bus inbox after session replay. # instruction into the root's SDK session after replay.
args.user_explicit_instruction = args.instruction if args.resume else None args.user_explicit_instruction = args.instruction if args.resume else None
if args.resume: if args.resume:
@@ -427,11 +427,11 @@ Examples:
"the prior run left off, including the original target list." "the prior run left off, including the original target list."
) )
_load_resume_state(args, parser) _load_resume_state(args, parser)
bus_path = Path("strix_runs") / args.resume / "bus.json" agents_path = Path("strix_runs") / args.resume / "agents.json"
if not bus_path.exists(): if not agents_path.exists():
parser.error( parser.error(
f"--resume {args.resume}: missing {bus_path}. The run was " f"--resume {args.resume}: missing {agents_path}. The run was "
f"persisted but never reached its first bus snapshot — " f"persisted but never reached its first agent snapshot — "
f"there's nothing to resume from. Pick a fresh --run-name " f"there's nothing to resume from. Pick a fresh --run-name "
f"or remove --resume to start over with the same targets." f"or remove --resume to start over with the same targets."
) )
+1 -13
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "llm_failed"] Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "llm_failed"]
ACTIVE_AGENT_STATUSES = {"running", "waiting", "llm_failed"} ACTIVE_AGENT_STATUSES = {"running", "waiting", "llm_failed"}
_SNAPSHOT_VERSION = 2 _SNAPSHOT_VERSION = 3
_WAITING_TIMEOUT_SUBAGENT = 300.0 _WAITING_TIMEOUT_SUBAGENT = 300.0
_TIMEOUT_RESUME_MESSAGE = "Waiting timeout reached. Resuming execution." _TIMEOUT_RESUME_MESSAGE = "Waiting timeout reached. Resuming execution."
@@ -78,7 +78,6 @@ class AgentCoordinator:
is_whitebox: bool = False, is_whitebox: bool = False,
scan_mode: str = "deep", scan_mode: str = "deep",
diff_scope: dict[str, Any] | None = None, diff_scope: dict[str, Any] | None = None,
session_path: str | Path | None = None,
) -> None: ) -> None:
async with self._lock: async with self._lock:
self.statuses[agent_id] = "running" self.statuses[agent_id] = "running"
@@ -93,7 +92,6 @@ class AgentCoordinator:
"is_whitebox": bool(is_whitebox), "is_whitebox": bool(is_whitebox),
"scan_mode": scan_mode, "scan_mode": scan_mode,
"diff_scope": diff_scope, "diff_scope": diff_scope,
"session_path": str(session_path) if session_path is not None else "",
} }
self.runtimes.setdefault(agent_id, AgentRuntime()) self.runtimes.setdefault(agent_id, AgentRuntime())
logger.info("agent.register %s (%s) parent=%s", agent_id, name, parent_id or "-") logger.info("agent.register %s (%s) parent=%s", agent_id, name, parent_id or "-")
@@ -362,8 +360,6 @@ class AgentCoordinator:
aid: [dict(msg) for msg in msgs] for aid, msgs in self.queued_messages.items() aid: [dict(msg) for msg in msgs] for aid, msgs in self.queued_messages.items()
}, },
"stats_live": {aid: dict(s) for aid, s in self.stats_live.items()}, "stats_live": {aid: dict(s) for aid, s in self.stats_live.items()},
# Kept for old tracer hydration shape.
"stats_completed": {},
} }
async def restore(self, snap: dict[str, Any]) -> None: async def restore(self, snap: dict[str, Any]) -> None:
@@ -378,15 +374,7 @@ class AgentCoordinator:
aid: [dict(msg) for msg in msgs] aid: [dict(msg) for msg in msgs]
for aid, msgs in snap.get("queued_messages", {}).items() for aid, msgs in snap.get("queued_messages", {}).items()
} }
for aid in snap.get("stopping", []):
if aid in self.statuses:
self.statuses[aid] = "stopped"
self.stats_live = {aid: dict(s) for aid, s in snap.get("stats_live", {}).items()} self.stats_live = {aid: dict(s) for aid, s in snap.get("stats_live", {}).items()}
for aid, msgs in snap.get("inboxes", {}).items():
# Legacy bus snapshots used inboxes. Preserve them as queued
# messages so attaching the SDK session makes them visible.
self.pending_counts[aid] = max(self.pending_counts.get(aid, 0), len(msgs))
self.queued_messages.setdefault(aid, []).extend(dict(msg) for msg in msgs)
for aid in self.statuses: for aid in self.statuses:
self.runtimes.setdefault(aid, AgentRuntime()) self.runtimes.setdefault(aid, AgentRuntime())
+33 -52
View File
@@ -1,23 +1,23 @@
"""Top-level scan entry point with auto-resume. """Top-level scan entry point with auto-resume.
1. Build (or take from caller) the per-scan ``AgentCoordinator``. 1. Build (or take from caller) the per-scan ``AgentCoordinator``.
2. Wire a snapshot path so every lifecycle event auto-persists ``bus.json``. 2. Wire a snapshot path so lifecycle events auto-persist ``agents.json``.
3. Acquire an advisory file lock so a second ``strix`` process can't run 3. Acquire an advisory file lock so a second ``strix`` process can't run
on the same ``scan_id`` concurrently. on the same ``scan_id`` concurrently.
4. **Resume detection**: if ``{run_dir}/bus.json`` already exists, restore 4. **Resume detection**: if ``{run_dir}/agents.json`` already exists, restore
the coordinator, hydrate the tracer, reuse the persisted ``root_id`` instead the coordinator, hydrate the tracer, reuse the persisted ``root_id`` instead
of generating a fresh one, and respawn every non-terminal subagent of generating a fresh one, and respawn every non-terminal subagent
from its per-child ``SQLiteSession`` before starting the root. from the shared SDK ``agents.db`` before starting the root.
5. Bring up (or reuse) a sandbox session for ``scan_id``. 5. Bring up (or reuse) a sandbox session for ``scan_id``.
6. Build the root ``Agent`` + child factory. 6. Build the root ``Agent`` + child factory.
7. Open root ``SQLiteSession`` at the same path so the SDK replays prior 7. Open root ``SQLiteSession`` in ``agents.db`` so the SDK replays prior
turns on resume. turns on resume.
8. Call ``Runner.run`` (via ``run_with_continuation``). 8. Call ``Runner.run`` (via ``run_with_continuation``).
9. ``finally``: close every per-agent session, take a final snapshot, 9. ``finally``: close every per-agent session, take a final snapshot,
tear down the sandbox, release the lock. tear down the sandbox, release the lock.
Resume is **always on**: there is no flag — presence of ``bus.json`` is Resume is **always on**: there is no flag — presence of ``agents.json`` is
the trigger. Fresh runs simply have no ``bus.json`` to begin with. the trigger. Fresh runs simply have no ``agents.json`` to begin with.
""" """
from __future__ import annotations from __future__ import annotations
@@ -229,10 +229,9 @@ async def run_strix_scan(
teardown_logging = setup_scan_logging(run_dir) teardown_logging = setup_scan_logging(run_dir)
set_scan_id(scan_id) set_scan_id(scan_id)
bus_path = run_dir / "bus.json" agents_path = run_dir / "agents.json"
is_resume = bus_path.exists() agents_db = run_dir / "agents.db"
sessions_dir = run_dir / "sessions" is_resume = agents_path.exists()
sessions_dir.mkdir(parents=True, exist_ok=True)
lock_handle = _acquire_run_lock(run_dir) lock_handle = _acquire_run_lock(run_dir)
@@ -258,7 +257,7 @@ async def run_strix_scan(
# commands while the scan loop runs in another thread. # commands while the scan loop runs in another thread.
if coordinator is None: if coordinator is None:
coordinator = AgentCoordinator() coordinator = AgentCoordinator()
coordinator.set_snapshot_path(bus_path) coordinator.set_snapshot_path(agents_path)
if tracer is not None and hasattr(tracer, "hydrate_from_run_dir"): if tracer is not None and hasattr(tracer, "hydrate_from_run_dir"):
tracer.hydrate_from_run_dir() tracer.hydrate_from_run_dir()
@@ -275,12 +274,17 @@ async def run_strix_scan(
root_id: str | None = None root_id: str | None = None
if is_resume: if is_resume:
try: try:
snap = json.loads(bus_path.read_text(encoding="utf-8")) snap = json.loads(agents_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
_release_run_lock(lock_handle) _release_run_lock(lock_handle)
raise RuntimeError( raise RuntimeError(
f"Cannot resume scan {scan_id}: bus.json is unreadable: {exc}", f"Cannot resume scan {scan_id}: agents.json is unreadable: {exc}",
) from exc ) from exc
if not agents_db.exists():
_release_run_lock(lock_handle)
raise RuntimeError(
f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}",
)
await coordinator.restore(snap) await coordinator.restore(snap)
for aid, parent in coordinator.parent_of.items(): for aid, parent in coordinator.parent_of.items():
if parent is None: if parent is None:
@@ -289,7 +293,7 @@ async def run_strix_scan(
if root_id is None: if root_id is None:
_release_run_lock(lock_handle) _release_run_lock(lock_handle)
raise RuntimeError( raise RuntimeError(
f"Cannot resume scan {scan_id}: bus.json has no root agent (parent=None)", f"Cannot resume scan {scan_id}: agents.json has no root agent (parent=None)",
) )
logger.info( logger.info(
"Resume: restored coordinator with %d agent(s); root=%s", "Resume: restored coordinator with %d agent(s); root=%s",
@@ -368,6 +372,7 @@ async def run_strix_scan(
"diff_scope": diff_scope, "diff_scope": diff_scope,
"run_id": run_id, "run_id": run_id,
"agent_factory": agent_factory, "agent_factory": agent_factory,
"agents_db_path": agents_db,
"_sessions_to_close": sessions_to_close, "_sessions_to_close": sessions_to_close,
} }
@@ -395,7 +400,7 @@ async def run_strix_scan(
if is_resume: if is_resume:
await _respawn_subagents( await _respawn_subagents(
coordinator=coordinator, coordinator=coordinator,
sessions_dir=sessions_dir, agents_db_path=agents_db,
factory=agent_factory, factory=agent_factory,
parent_ctx=context, parent_ctx=context,
resolved_model=resolved_model, resolved_model=resolved_model,
@@ -404,18 +409,16 @@ async def run_strix_scan(
sessions_to_close=sessions_to_close, sessions_to_close=sessions_to_close,
) )
# Root SDK session — same path on fresh + resume so SDK replay # All agents share one SQLite database; SDK session_id separates
# picks up prior turns automatically when ``initial_input`` is # each agent's conversation inside that database.
# an empty list. root_session = open_agent_session(root_id, agents_db)
session_db = run_dir / "session.db"
root_session = SQLiteSession(session_id=scan_id, db_path=session_db)
sessions_to_close.append(root_session) sessions_to_close.append(root_session)
await coordinator.attach_runtime(root_id, session=root_session) await coordinator.attach_runtime(root_id, session=root_session)
initial_input: Any = [] if is_resume else _build_root_task(scan_config) initial_input: Any = [] if is_resume else _build_root_task(scan_config)
# Resume + new ``--instruction``: SDK replay drives root from # Resume + new ``--instruction``: SDK replay drives root from
# session.db with ``initial_input=[]``, so a brand-new instruction # agents.db with ``initial_input=[]``, so a brand-new instruction
# passed on the resume CLI would otherwise be silently ignored. # passed on the resume CLI would otherwise be silently ignored.
# Inject it as a fresh user message in root's SDK session; the # Inject it as a fresh user message in root's SDK session; the
# next run cycle will replay it with the rest of the session. # next run cycle will replay it with the rest of the session.
@@ -431,7 +434,7 @@ async def run_strix_scan(
}, },
) )
logger.info( logger.info(
"Resume: injected new instruction into root inbox (len=%d)", "Resume: injected new instruction into root SDK session (len=%d)",
len(resume_instruction), len(resume_instruction),
) )
@@ -489,7 +492,7 @@ async def run_strix_scan(
async def _respawn_subagents( async def _respawn_subagents(
*, *,
coordinator: AgentCoordinator, coordinator: AgentCoordinator,
sessions_dir: Path, agents_db_path: Path,
factory: Any, factory: Any,
parent_ctx: dict[str, Any], parent_ctx: dict[str, Any],
resolved_model: str, resolved_model: str,
@@ -499,14 +502,12 @@ async def _respawn_subagents(
) -> None: ) -> None:
"""Re-spawn subagent runners from a restored coordinator snapshot. """Re-spawn subagent runners from a restored coordinator snapshot.
Each child gets its own :class:`SQLiteSession` reopened at Each child gets its own SDK ``session_id`` inside the shared
``sessions_dir/<child_id>.db`` so the SDK replays its prior ``agents.db`` so the SDK replays its prior conversation. Interactive
conversation. Per-child failure (missing/corrupt session DB, mode respawns every registered child as a parked runner unless it was
factory raising) finalizes that child as ``crashed`` and continues. actively running before the crash. That keeps completed/stopped/
Interactive mode respawns every registered child as a parked runner crashed/failed agents addressable: a later message wakes the SDK
unless it was actively running before the crash. That keeps session instead of being dropped into a dead inbox.
completed/stopped/crashed/failed agents addressable: a later message
wakes the SDK session instead of being dropped into a dead inbox.
""" """
interactive = bool(parent_ctx.get("interactive", False)) interactive = bool(parent_ctx.get("interactive", False))
async with coordinator._lock: async with coordinator._lock:
@@ -534,26 +535,6 @@ async def _respawn_subagents(
for child_id, name, parent_id, md in candidates: for child_id, name, parent_id, md in candidates:
try: try:
session_path = sessions_dir / f"{child_id}.db"
if not session_path.exists():
if interactive:
logger.warning(
"respawn %s (%s): session db missing at %s; starting parked with empty session",
child_id,
name,
session_path,
)
else:
logger.warning(
"respawn %s (%s): session db missing at %s — marking crashed",
child_id,
name,
session_path,
)
await coordinator.set_status(child_id, "crashed")
continue
session_path.parent.mkdir(parents=True, exist_ok=True)
restored_status = str(md.get("_restored_status") or "running") restored_status = str(md.get("_restored_status") or "running")
start_parked = interactive and restored_status != "running" start_parked = interactive and restored_status != "running"
@@ -565,7 +546,7 @@ async def _respawn_subagents(
restored_status, restored_status,
) )
child_session = open_agent_session(child_id, session_path) child_session = open_agent_session(child_id, agents_db_path)
sessions_to_close.append(child_session) sessions_to_close.append(child_session)
await coordinator.attach_runtime(child_id, session=child_session) await coordinator.attach_runtime(child_id, session=child_session)
+26 -30
View File
@@ -167,29 +167,29 @@ class Tracer:
json_path, json_path,
) )
bus_path = run_dir / "bus.json" agents_path = run_dir / "agents.json"
if bus_path.exists(): if agents_path.exists():
try: try:
bus_data = json.loads(bus_path.read_text(encoding="utf-8")) agents_data = json.loads(agents_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
# Caller will surface this same corruption via ``bus.restore``; # Caller will surface this same corruption via coordinator restore;
# no need to fail twice. Skip the agents/stats hydrate path. # no need to fail twice. Skip the agents/stats hydrate path.
bus_data = None agents_data = None
if isinstance(bus_data, dict): if isinstance(agents_data, dict):
self._hydrate_agents_tree(bus_data) self._hydrate_agents_tree(agents_data)
self._hydrate_llm_stats(bus_data) self._hydrate_llm_stats(agents_data)
def _hydrate_agents_tree(self, bus_data: dict[str, Any]) -> None: def _hydrate_agents_tree(self, agents_data: dict[str, Any]) -> None:
"""Populate ``self.agents`` from a bus snapshot. """Populate ``self.agents`` from the coordinator snapshot.
Without this, the TUI tree on resume would only show agents Without this, the TUI tree on resume would only show agents
currently running (mirrored by ``on_agent_start``); completed / currently running (mirrored by ``on_agent_start``); completed /
crashed / stopped children from the prior run would be invisible crashed / stopped children from the prior run would be invisible
even though the bus knows about them. even though the coordinator knows about them.
""" """
statuses = bus_data.get("statuses") or {} statuses = agents_data.get("statuses") or {}
names = bus_data.get("names") or {} names = agents_data.get("names") or {}
parent_of = bus_data.get("parent_of") or {} parent_of = agents_data.get("parent_of") or {}
if not isinstance(statuses, dict): if not isinstance(statuses, dict):
return return
timestamp = self.start_time timestamp = self.start_time
@@ -206,29 +206,25 @@ class Tracer:
} }
logger.info("tracer hydrated %d agent(s) into tree", len(self.agents)) logger.info("tracer hydrated %d agent(s) into tree", len(self.agents))
def _hydrate_llm_stats(self, bus_data: dict[str, Any]) -> None: def _hydrate_llm_stats(self, agents_data: dict[str, Any]) -> None:
"""Seed ``self._llm_stats`` from the bus snapshot's per-agent counters. """Seed ``self._llm_stats`` from the coordinator snapshot's counters.
Aggregates ``stats_live + stats_completed`` so the resumed scan's This keeps the resumed scan's TUI footer cumulative instead of
TUI footer shows cumulative tokens / requests across the prior resetting to zero.
run plus whatever the resume adds, instead of resetting to zero.
""" """
totals = {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0, "requests": 0} totals = {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0, "requests": 0}
for bucket_key in ("stats_live", "stats_completed"): bucket = agents_data.get("stats_live") or {}
bucket = bus_data.get(bucket_key) or {} if isinstance(bucket, dict):
if not isinstance(bucket, dict):
continue
for entry in bucket.values(): for entry in bucket.values():
if not isinstance(entry, dict): if isinstance(entry, dict):
continue totals["input_tokens"] += int(entry.get("in", 0) or 0)
totals["input_tokens"] += int(entry.get("in", 0) or 0) totals["output_tokens"] += int(entry.get("out", 0) or 0)
totals["output_tokens"] += int(entry.get("out", 0) or 0) totals["cached_tokens"] += int(entry.get("cached", 0) or 0)
totals["cached_tokens"] += int(entry.get("cached", 0) or 0) totals["requests"] += int(entry.get("calls", 0) or 0)
totals["requests"] += int(entry.get("calls", 0) or 0)
for k, v in totals.items(): for k, v in totals.items():
self._llm_stats[k] = v self._llm_stats[k] = v
logger.info( logger.info(
"tracer hydrated llm stats from bus (in=%d out=%d cached=%d requests=%d)", "tracer hydrated llm stats from agents snapshot (in=%d out=%d cached=%d requests=%d)",
totals["input_tokens"], totals["input_tokens"],
totals["output_tokens"], totals["output_tokens"],
totals["cached_tokens"], totals["cached_tokens"],
+6 -15
View File
@@ -492,13 +492,6 @@ async def create_agent(
default=str, default=str,
) )
tracer = inner.get("tracer")
if tracer is not None and hasattr(tracer, "get_run_dir"):
child_session_path = tracer.get_run_dir() / "sessions" / f"{child_id}.db"
else:
run_id = inner.get("run_id") or "default"
child_session_path = Path.cwd() / "strix_runs" / str(run_id) / "sessions" / f"{child_id}.db"
await coordinator.register( await coordinator.register(
child_id, child_id,
name, name,
@@ -508,7 +501,6 @@ async def create_agent(
is_whitebox=bool(inner.get("is_whitebox", False)), is_whitebox=bool(inner.get("is_whitebox", False)),
scan_mode=str(inner.get("scan_mode", "deep")), scan_mode=str(inner.get("scan_mode", "deep")),
diff_scope=inner.get("diff_scope"), diff_scope=inner.get("diff_scope"),
session_path=child_session_path,
) )
# ``ctx.turn_input`` carries the parent's full conversation up to and # ``ctx.turn_input`` carries the parent's full conversation up to and
@@ -569,13 +561,12 @@ async def create_agent(
"_sessions_to_close": inner.get("_sessions_to_close", []), "_sessions_to_close": inner.get("_sessions_to_close", []),
} }
# Per-child SQLiteSession at ``{run_dir}/sessions/{child_id}.db`` so # Every agent gets its own SDK session_id inside the shared agents.db.
# this subagent's full conversation survives a process restart and agents_db_path = inner.get("agents_db_path")
# can be replayed by the SDK on resume. Path is derived from the if not isinstance(agents_db_path, Path):
# tracer's run_dir (root-side construction in run_id = inner.get("run_id") or "default"
# :func:`run_strix_scan`); fall back to ``./strix_runs/{run_id}/`` if agents_db_path = Path.cwd() / "strix_runs" / str(run_id) / "agents.db"
# the tracer is absent (unit-test path). child_session = open_agent_session(child_id, agents_db_path)
child_session = open_agent_session(child_id, child_session_path)
sessions_list = child_ctx.get("_sessions_to_close") sessions_list = child_ctx.get("_sessions_to_close")
if isinstance(sessions_list, list): if isinstance(sessions_list, list):
sessions_list.append(child_session) sessions_list.append(child_session)