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 <noreply@anthropic.com>
This commit is contained in:
root 2026-03-19 09:45:41 +01:00
parent 91fb78179c
commit d1cddf2a74
2 changed files with 32 additions and 20 deletions

View file

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

View file

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