Files
strix/strix/orchestration/bus.py
T
0xallam 5fd2a64562 fix(persistence): close all 9 gaps from the resume audit
Three critical correctness fixes + six TUI/audit/UX fixes from the
parallel-agent audit. All changes verified by an end-to-end smoke
that builds, persists, and re-hydrates state across two simulated
process boundaries.

Critical (resume integrity):

1. ``bus.cancel_descendants_graceful`` now calls ``_maybe_snapshot``
   after mutating the ``stopping`` set. Previously, a process crash
   between user-initiated graceful-stop and the next finalize lost
   the stop signal — respawned agents would run forever instead of
   exiting. ``_respawn_subagents`` also gains a guard that skips
   agents in ``stopping`` so a previously-cancelled agent is not
   resurrected on resume.

2. ``Tracer.hydrate_from_run_dir`` now **raises** on corrupt
   ``vulnerabilities.json`` instead of swallowing the exception. The
   prior behaviour silently reset ``vulnerability_reports`` to empty,
   so the next ``add_vulnerability_report`` would allocate ``vuln-0001``
   and overwrite the prior MD on disk — silent data loss.

3. ``--instruction`` passed on resume now reaches the model. The CLI
   captures whether the user explicitly passed an instruction
   (``args.user_explicit_instruction``) before ``_load_resume_state``
   loads the persisted one. ``run_strix_scan`` reads
   ``scan_config["resume_instruction"]`` and, on resume, sends the
   new instruction to root's bus inbox before calling
   ``run_with_continuation`` (which uses ``initial_input=[]`` for SDK
   replay). The inject filter surfaces it on the next turn.

4. ``--resume X`` errors loudly when ``scan_state.json`` exists but
   ``bus.json`` doesn't. Previously this silently fresh-started in
   the same dir, confusing the user who explicitly asked to resume.

TUI / audit / UX:

5. ``Tracer.hydrate_from_run_dir`` now reads ``bus.json`` too and
   pre-populates ``tracer.agents`` from the snapshot's ``statuses`` /
   ``names`` / ``parent_of``. Before this, the TUI tree on resume
   showed only currently-running agents; completed/crashed children
   from the prior run were invisible.

6. ``Tracer.hydrate_from_run_dir`` also seeds ``self._llm_stats`` from
   ``bus.stats_live + bus.stats_completed`` so the resume's footer
   shows cumulative tokens / requests across the prior run plus the
   resume segment, instead of resetting to zero.

7. ``Tracer.save_run_data`` now also writes ``run_metadata.json``
   (start_time, run_id, run_name, targets, status), and
   ``hydrate_from_run_dir`` restores ``start_time`` from it. Prior
   behaviour reset start_time to ``now()`` on every Tracer init,
   breaking the final report's duration calc on resumed scans.

8. Per-agent todos persist to ``{run_dir}/todos.json`` (atomic write
   on every CRUD). ``hydrate_todos_from_disk`` (called from
   ``run_strix_scan``) reloads them so respawned subagents find
   their lists intact. Previously, the module-level
   ``_todos_storage`` was lost on every process restart.

9. ``_load_resume_state`` validates each ``cloned_repo_path`` from
   the persisted ``scan_state.json`` still exists on disk. Previously
   a deleted clone dir would let the resume proceed with an empty
   source tree, with agents silently scanning nothing.

Bonus: ``bus.finalize`` no longer pops ``parent_of`` and ``names``
for finalized agents. Routing protection (don't accept ``send`` to
finalized agents) comes from the ``statuses[id]`` terminal-state
check in ``send`` itself, so dropping those keys was overzealous and
made completed children invisible in ``view_agent_graph`` and the
TUI tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:57:52 -07:00

462 lines
19 KiB
Python

"""``AgentMessageBus`` — peer-to-peer multi-agent state for one scan.
A single ``asyncio.Lock``-protected dataclass that owns inboxes,
parent edges, statuses, and per-agent stats for the lifetime of one
Strix scan.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from agents.result import RunResultStreaming
logger = logging.getLogger(__name__)
_SNAPSHOT_VERSION = 1
@dataclass
class AgentMessageBus:
"""Shared state for multi-agent orchestration.
All mutations happen under ``_lock``; readers also take the lock for
consistent snapshots. The bus owns:
- ``inboxes``: per-agent FIFO list of pending messages (drained by the
``inject_messages_filter`` at the top of each LLM turn).
- ``tasks``: per-agent ``asyncio.Task`` handle so the parent (or signal
handler) can cancel descendants.
- ``streams``: per-agent ``RunResultStreaming`` handle so callers can
request graceful ``cancel(mode="after_turn")`` mid-stream — the SDK
saves the current turn to session before honoring the cancel.
- ``statuses``: per-agent lifecycle state — ``running | waiting |
completed | crashed | stopped | llm_failed``.
- ``parent_of``: tree edges; root agents have ``None``.
- ``names``: human-readable per-agent names.
- ``stats_live`` / ``stats_completed``: token + call counters that hooks
keep up to date for live and finalized agents respectively. Also
carries per-agent-lifetime warning flags (``warned_85``,
``warned_final``).
- ``stopping``: agent ids whose interactive outer-loop should exit on
next iteration instead of waiting for more messages.
"""
inboxes: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
tasks: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
streams: dict[str, RunResultStreaming] = field(default_factory=dict)
statuses: dict[str, str] = field(default_factory=dict)
parent_of: dict[str, str | None] = field(default_factory=dict)
names: dict[str, str] = field(default_factory=dict)
stats_live: dict[str, dict[str, Any]] = field(default_factory=dict)
stats_completed: dict[str, dict[str, Any]] = field(default_factory=dict)
stopping: set[str] = field(default_factory=set)
metadata: dict[str, dict[str, Any]] = field(default_factory=dict)
_events: dict[str, asyncio.Event] = field(default_factory=dict)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_snapshot_path: Path | None = None
def set_snapshot_path(self, path: Path) -> None:
"""Wire the on-disk snapshot path. Triggers persist after each lifecycle event."""
self._snapshot_path = path
async def register(
self,
agent_id: str,
name: str,
parent_id: str | None,
*,
task: str | None = None,
skills: list[str] | None = None,
is_whitebox: bool = False,
scan_mode: str = "deep",
diff_scope: dict[str, Any] | None = None,
) -> None:
"""Add a new agent to the bus before its Runner.run task starts.
``task`` / ``skills`` / ``is_whitebox`` / ``scan_mode`` /
``diff_scope`` are persisted on the bus's ``metadata`` map so a
process restart can rebuild the same agent from the snapshot.
"""
async with self._lock:
self.inboxes[agent_id] = []
self.statuses[agent_id] = "running"
self.parent_of[agent_id] = parent_id
self.names[agent_id] = name
self.stats_live[agent_id] = {
"in": 0,
"out": 0,
"cached": 0,
"cost": 0.0,
"calls": 0,
"warned_85": False,
"warned_final": False,
}
self.metadata[agent_id] = {
"task": task or "",
"skills": list(skills or []),
"is_whitebox": bool(is_whitebox),
"scan_mode": scan_mode,
"diff_scope": diff_scope,
}
logger.info("bus.register %s (%s) parent=%s", agent_id, name, parent_id or "-")
await self._maybe_snapshot()
async def send(self, target: str, msg: dict[str, Any]) -> None:
"""Append a message to ``target``'s inbox.
Messages addressed to a finalized agent are dropped silently —
:meth:`finalize` clears the inbox so they can't accumulate.
"""
async with self._lock:
if target not in self.statuses:
logger.debug("bus.send dropped (unknown target=%s)", target)
return
if self.statuses[target] in ("completed", "crashed", "stopped"):
logger.debug(
"bus.send dropped (target=%s status=%s)",
target,
self.statuses[target],
)
return
self.inboxes.setdefault(target, []).append(msg)
event = self._events.get(target)
if event is not None:
event.set()
sender = msg.get("from", "?")
msg_type = msg.get("type", "message")
content = str(msg.get("content", ""))
logger.debug(
"bus.send %s -> %s (type=%s len=%d): %s",
sender,
target,
msg_type,
len(content),
content[:200],
)
async def wait_for_message(self, agent_id: str) -> None:
"""Block until ``agent_id``'s inbox has at least one pending message.
Used by the interactive-mode outer loop in :func:`run_strix_scan` to
wake on the next user message between ``Runner.run`` cycles. Cheap
if the inbox already has content (returns immediately).
"""
async with self._lock:
if self.inboxes.get(agent_id):
return
event = self._events.setdefault(agent_id, asyncio.Event())
event.clear()
await event.wait()
async def wait_for_user_message(self, agent_id: str) -> None:
"""Block until ``agent_id``'s inbox has a message with ``from='user'``.
Used by the ``llm_failed`` recovery path: after a hard model failure,
only direct user input should resume the agent — peer messages can't
unstick a stuck model. Re-checks the inbox after each event in case
only peer messages arrived.
"""
while True:
async with self._lock:
for msg in self.inboxes.get(agent_id, []):
if msg.get("from") == "user":
return
event = self._events.setdefault(agent_id, asyncio.Event())
event.clear()
await event.wait()
async def drain(self, agent_id: str) -> list[dict[str, Any]]:
"""Atomically read and clear ``agent_id``'s pending messages.
Called by ``inject_messages_filter`` before every model call.
Filter output is captured by SDK in a lambda closure for retries
(verified `model_retry.py:34-35`), so a single drain per turn does
not lose messages on retry.
"""
async with self._lock:
msgs = self.inboxes.get(agent_id, [])
self.inboxes[agent_id] = []
if msgs:
logger.debug("bus.drain %s -> %d message(s)", agent_id, len(msgs))
return msgs
async def record_usage(self, agent_id: str, usage: Any) -> None:
"""Accumulate per-call usage from RunHooks.on_llm_end.
Tolerates ``usage=None`` (some providers omit usage on streaming).
Increments ``calls`` unconditionally so it doubles as a per-agent
lifetime turn counter (legacy ``state.iteration`` parity).
"""
async with self._lock:
stats = self.stats_live.setdefault(
agent_id,
{
"in": 0,
"out": 0,
"cached": 0,
"cost": 0.0,
"calls": 0,
"warned_85": False,
"warned_final": False,
},
)
stats["calls"] += 1
if usage is None:
return
stats["in"] += getattr(usage, "input_tokens", 0) or 0
stats["out"] += getattr(usage, "output_tokens", 0) or 0
details = getattr(usage, "input_tokens_details", None)
if details is not None:
stats["cached"] += getattr(details, "cached_tokens", 0) or 0
async def finalize(self, agent_id: str, status: str) -> None:
"""Move an agent from live to completed; clean up routing state.
Clears live routing state (``inboxes``, ``streams``,
``_events``, ``stopping``, ``metadata`` for the spawn args) and
moves stats from ``stats_live`` to ``stats_completed``. Keeps
``parent_of`` and ``names`` so the agent remains visible in
``view_agent_graph`` and the TUI tree post-finalize. Routing
protection against late ``send`` calls comes from the
``statuses[id]`` terminal-state check in :meth:`send`, not from
removing the parent/name keys.
"""
async with self._lock:
self.statuses[agent_id] = status
self.stats_completed[agent_id] = self.stats_live.pop(agent_id, {})
self.inboxes.pop(agent_id, None)
self.streams.pop(agent_id, None)
self.stopping.discard(agent_id)
self._events.pop(agent_id, None)
self.metadata.pop(agent_id, None)
logger.info("bus.finalize %s status=%s", agent_id, status)
await self._maybe_snapshot()
async def park(self, agent_id: str) -> None:
"""Mark an agent as ``waiting`` without finalizing.
Used in interactive mode for the root agent between ``Runner.run``
cycles: the run completed, but the agent stays alive on the bus
so user messages still land in its inbox until the next cycle
starts. Stats stay live (will be merged on actual finalize at
scan teardown).
"""
async with self._lock:
if agent_id in self.statuses:
self.statuses[agent_id] = "waiting"
logger.debug("bus.park %s", agent_id)
await self._maybe_snapshot()
async def mark_llm_failed(self, agent_id: str) -> None:
"""Mark an agent as ``llm_failed`` after retries exhausted.
Mirrors legacy ``state.llm_failed`` semantics: only direct user
input can resume the agent (see :meth:`wait_for_user_message`).
Status survives until the next ``Runner.run`` cycle starts and
``on_agent_start`` mirrors it back to ``running``, or finalize
clears it.
"""
async with self._lock:
if agent_id in self.statuses:
self.statuses[agent_id] = "llm_failed"
logger.warning("bus.mark_llm_failed %s — awaiting user resume", agent_id)
await self._maybe_snapshot()
@contextlib.asynccontextmanager
async def attach_stream(
self,
agent_id: str,
streamed: RunResultStreaming,
) -> AsyncIterator[None]:
"""Register ``streamed`` so ``request_interrupt`` can find it; clean up after."""
async with self._lock:
self.streams[agent_id] = streamed
try:
yield
finally:
async with self._lock:
if self.streams.get(agent_id) is streamed:
self.streams.pop(agent_id, None)
async def request_interrupt(
self,
agent_id: str,
mode: str = "after_turn",
) -> bool:
"""Ask the agent's active streaming run to cancel gracefully.
Returns True if a streaming run was attached (so a cancel request
was issued), False otherwise. ``mode='after_turn'`` lets the SDK
finish the current turn — including saving items to session — so
cancellation never leaves orphan tool outputs or truncated
assistant messages. ``mode='immediate'`` is the hard variant.
"""
async with self._lock:
streamed = self.streams.get(agent_id)
if streamed is None:
logger.debug("bus.request_interrupt %s — no active stream", agent_id)
return False
streamed.cancel(mode=mode) # type: ignore[arg-type] # mode is a Literal
logger.info("bus.request_interrupt %s mode=%s", agent_id, mode)
return True
async def total_stats(self) -> dict[str, Any]:
"""Snapshot of live + completed stats. Excludes warning flags."""
async with self._lock:
agg = {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0}
for stats in (*self.stats_live.values(), *self.stats_completed.values()):
for key in agg:
agg[key] += stats.get(key, 0)
return agg
async def cancel_descendants(self, root_agent_id: str) -> None:
"""Cancel ``root_agent_id`` and every transitive child, leaves first.
Wired into the CLI Ctrl+C handler and TUI stop button —
the SDK's ``result.cancel`` doesn't cascade to children spawned
via ``asyncio.create_task``, so we walk the tree ourselves.
This is the **hard** path: ``task.cancel()`` raises ``CancelledError``
immediately, which may truncate a turn mid-stream. For graceful
cascading stops use :meth:`cancel_descendants_graceful`.
"""
async with self._lock:
queue = [root_agent_id]
order: list[str] = []
while queue:
aid = queue.pop()
order.append(aid)
queue.extend(child for child, parent in self.parent_of.items() if parent == aid)
tasks_to_cancel = [self.tasks[a] for a in reversed(order) if a in self.tasks]
logger.info(
"bus.cancel_descendants %s (hard, %d task(s))",
root_agent_id,
len(tasks_to_cancel),
)
for task in tasks_to_cancel:
if not task.done():
task.cancel()
# Wait for cancellations to settle so on_agent_end can mark statuses.
await asyncio.gather(
*(t for t in tasks_to_cancel if not t.done()),
return_exceptions=True,
)
async def cancel_descendants_graceful(self, root_agent_id: str) -> None:
"""Graceful cascade: ``request_interrupt`` per node, leaves-first.
Each node's current turn finishes (and is saved to session) before
the run loop honors the cancel. The interactive outer loop sees
the agent in ``stopping`` and returns instead of awaiting more
messages, so finalize fires with status="stopped".
"""
async with self._lock:
queue = [root_agent_id]
order: list[str] = []
while queue:
aid = queue.pop()
order.append(aid)
queue.extend(child for child, parent in self.parent_of.items() if parent == aid)
for aid in order:
self.stopping.add(aid)
streams_to_cancel = [
(aid, self.streams[aid]) for aid in reversed(order) if aid in self.streams
]
logger.info(
"bus.cancel_descendants_graceful %s (%d active stream(s), %d total)",
root_agent_id,
len(streams_to_cancel),
len(order),
)
for _aid, streamed in streams_to_cancel:
streamed.cancel(mode="after_turn")
# Persist ``stopping`` so a process crash before any child finalizes
# doesn't lose the user's stop signal — otherwise resume would
# respawn the cancelled agents and they'd run forever.
await self._maybe_snapshot()
# ------------------------------------------------------------------
# Snapshot / restore — persist serializable state to ``bus.json`` so
# a process restart can rebuild the topology + per-agent metadata
# and respawn each non-terminal agent.
# ------------------------------------------------------------------
async def snapshot(self) -> dict[str, Any]:
"""Return a JSON-ready deep copy of every persistable bus field.
Held under ``_lock`` for the copy so the caller can't observe a
partial mutation. The actual JSON serialisation happens outside
the lock — that's fine for ``json.dumps``: pure-Python primitives,
no I/O.
"""
async with self._lock:
return {
"version": _SNAPSHOT_VERSION,
"inboxes": {aid: list(msgs) for aid, msgs in self.inboxes.items()},
"statuses": dict(self.statuses),
"parent_of": dict(self.parent_of),
"names": dict(self.names),
"stats_live": {aid: dict(s) for aid, s in self.stats_live.items()},
"stats_completed": {aid: dict(s) for aid, s in self.stats_completed.items()},
"stopping": list(self.stopping),
"metadata": {aid: dict(m) for aid, m in self.metadata.items()},
}
async def restore(self, snap: dict[str, Any]) -> None:
"""Repopulate from a prior :meth:`snapshot`. Tasks/streams/events
stay empty — they're rebuilt by the resume path as agents respawn.
"""
async with self._lock:
self.inboxes = {aid: list(msgs) for aid, msgs in snap.get("inboxes", {}).items()}
self.statuses = dict(snap.get("statuses", {}))
self.parent_of = dict(snap.get("parent_of", {}))
self.names = dict(snap.get("names", {}))
self.stats_live = {aid: dict(s) for aid, s in snap.get("stats_live", {}).items()}
self.stats_completed = {
aid: dict(s) for aid, s in snap.get("stats_completed", {}).items()
}
self.stopping = set(snap.get("stopping", []))
self.metadata = {aid: dict(m) for aid, m in snap.get("metadata", {}).items()}
async def _maybe_snapshot(self) -> None:
"""Persist the current state to ``_snapshot_path`` if one is set.
No-op when ``_snapshot_path`` is unset (e.g. in tests). Atomic
``tempfile`` + ``os.replace`` so a crash mid-write leaves the
previous valid file intact. Errors are logged + swallowed; a
snapshot failure should never tear down the run.
"""
path = self._snapshot_path
if path is None:
return
try:
data = await self.snapshot()
payload = json.dumps(data, ensure_ascii=False, default=str)
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)
except Exception:
logger.exception("bus snapshot to %s failed", path)