From d1cddf2a749fdde6aef14fcc9e2aceebf5e1147a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Mar 2026 09:45:41 +0100 Subject: [PATCH] fix: remove ghost sub-agents and reset blocking state on resume Three bugs fixed: 1. Ghost sub-agents (root cause of all sub-agent issues): Restoring tracer.agents/tool_executions injected old sub-agent entries that had no live instances. The TUI showed them as interactive but they could not receive messages or run. Worse, they polluted the agent message-routing system so new sub-agents spawned after resume failed to communicate with the root agent. Fix: only restore chat_messages, vulnerability_reports, and the execution ID counter. The root agent's LLM context (message history) already knows what all sub-agents did. 2. Root agent stuck in wait state after resume: If the scan was interrupted while the root agent was in a wait state (waiting_for_input=True, stop_requested=True, etc.) the restored AgentState had those flags set and the loop froze immediately. Fix: reset all blocking flags on restore in both cli.py and tui.py. 3. Completed flag causing instant exit: If completed=True was serialised into the checkpoint (edge case) the loop would exit on the first should_stop() check. Fix: reset completed=False on restore. Co-Authored-By: Claude Sonnet 4.6 --- strix/interface/cli.py | 29 +++++++++++++++++++---------- strix/interface/tui.py | 23 +++++++++++++---------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/strix/interface/cli.py b/strix/interface/cli.py index 16d27da3..260acd65 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -120,12 +120,20 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 ) resumed_state.max_iterations_warning_sent = False # Reset warning flag - # Clear sandbox so a fresh container is always created on resume - # (the old container may be gone). + # Clear sandbox — old container is gone, always start fresh resumed_state.sandbox_id = None resumed_state.sandbox_token = None resumed_state.sandbox_info = None + # Fix: reset any blocking flags that were set when the scan was interrupted. + # If the scan was paused mid-wait the restored state would still have + # waiting_for_input=True / stop_requested=True and the loop would freeze. + resumed_state.waiting_for_input = False + resumed_state.waiting_start_time = None + resumed_state.stop_requested = False + resumed_state.completed = False + resumed_state.llm_failed = False + start_text = Text() if is_resuming: start_text.append("Penetration test resumed", style="bold #22c55e") @@ -203,17 +211,18 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 tracer = Tracer(args.run_name) tracer.set_scan_config(scan_config) - # Added for Resume Feature — pre-populate tracer so stats/vulns are correct - # Also restores sub-agent registry and tool executions so the full run is visible + # Added for Resume Feature — restore conversation history and findings. + # NOTE: We intentionally do NOT restore tracer.agents or tracer.tool_executions. + # Injecting old sub-agent entries creates "ghost" agents: they appear in the + # sidebar with no live instance, can't receive messages, and block real sub-agents + # spawned in the new session from communicating with the root agent. + # The root agent's full LLM context (in resumed_state.messages) already knows + # what every sub-agent did — that is sufficient to continue correctly. if is_resuming and checkpoint_data: tracer.chat_messages.extend(checkpoint_data.tracer_chat_messages) tracer.vulnerability_reports.extend(checkpoint_data.tracer_vulnerability_reports) - # Restore every agent (root + sub-agents) with their last-known status - tracer.agents.update(checkpoint_data.tracer_agents) - # Restore tool execution records; keys were serialised as str, restore as int - for k, v in checkpoint_data.tracer_tool_executions.items(): - tracer.tool_executions[int(k)] = v - # Advance the ID counter so new executions don't overwrite saved ones + # Advance execution ID counter past old IDs to avoid collisions with + # tool executions the live scan will create. if checkpoint_data.tracer_next_execution_id > tracer._next_execution_id: tracer._next_execution_id = checkpoint_data.tracer_next_execution_id diff --git a/strix/interface/tui.py b/strix/interface/tui.py index d99ec3c4..157705cf 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -709,20 +709,17 @@ class StrixTUIApp(App): # type: ignore[misc] self.tracer.set_scan_config(self.scan_config) set_global_tracer(self.tracer) - # Added for Resume Feature — pre-populate tracer with checkpoint data so - # stats and findings reflect the full scan history including past sessions. - # Also restores sub-agents and their tool executions so the TUI sidebar - # shows every agent (completed or in-progress) from the previous session. + # Added for Resume Feature — restore conversation history and findings. + # We do NOT restore tracer.agents or tracer.tool_executions because old + # sub-agent entries become "ghost" agents: visible in the sidebar, impossible + # to interact with, and they break message routing for new sub-agents spawned + # in the resumed session. The root agent's LLM message history already + # contains everything the sub-agents did — that is enough to resume correctly. _cp = getattr(args, "_checkpoint_data", None) if _cp and getattr(args, "resume_from_checkpoint", False): self.tracer.chat_messages.extend(_cp.tracer_chat_messages) self.tracer.vulnerability_reports.extend(_cp.tracer_vulnerability_reports) - # Restore full agent registry (root + all sub-agents) - self.tracer.agents.update(_cp.tracer_agents) - # Restore tool execution records (keys were serialised as str) - for k, v in _cp.tracer_tool_executions.items(): - self.tracer.tool_executions[int(k)] = v - # Advance execution ID counter to avoid collisions + # Advance execution ID counter past previous session IDs if _cp.tracer_next_execution_id > self.tracer._next_execution_id: self.tracer._next_execution_id = _cp.tracer_next_execution_id @@ -787,6 +784,12 @@ class StrixTUIApp(App): # type: ignore[misc] resumed_state.sandbox_id = None resumed_state.sandbox_token = None resumed_state.sandbox_info = None + # Reset any blocking flags set at the moment of interruption + resumed_state.waiting_for_input = False + resumed_state.waiting_start_time = None + resumed_state.stop_requested = False + resumed_state.completed = False + resumed_state.llm_failed = False config["state"] = resumed_state _mgr = getattr(args, "_checkpoint_manager", None)