From e83522cec50560abc7e40af564189d3fb8e88e64 Mon Sep 17 00:00:00 2001 From: 0xallam Date: Sun, 26 Apr 2026 01:16:26 -0700 Subject: [PATCH] fix(scan): respawn-skip finalizes cancelled agents as ``stopped`` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ``_respawn_subagents`` skipped an agent because it was in ``bus.stopping`` (the user clicked stop before the crash), the bus state was left untouched — status stayed ``running`` forever, so ``view_agent_graph`` and the TUI tree showed phantom agents that would never make progress. Now the skip path collects those agent ids and finalizes each as ``stopped`` outside the lock, which transitions status correctly, clears the ``stopping`` entry (``finalize`` already discards it), moves the live stats to ``stats_completed``, and triggers the post-finalize snapshot. A subsequent ``view_agent_graph`` shows the truth: the agent is stopped. Co-Authored-By: Claude Opus 4.7 (1M context) --- strix/orchestration/scan.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/strix/orchestration/scan.py b/strix/orchestration/scan.py index fa1b115..4a85844 100644 --- a/strix/orchestration/scan.py +++ b/strix/orchestration/scan.py @@ -493,21 +493,30 @@ async def _respawn_subagents( TUI, but no task respawns. """ async with bus._lock: - candidates = [ - ( - aid, - bus.names.get(aid, aid), - bus.parent_of.get(aid), - dict(bus.metadata.get(aid, {})), - ) - for aid, status in bus.statuses.items() - if status in {"running", "waiting", "llm_failed"} - and bus.parent_of.get(aid) is not None - and aid != root_id - # Honour a previously-issued graceful stop — the user clicked - # "stop" before the crash; don't respawn what they cancelled. - and aid not in bus.stopping + # Snapshot the iteration view first so we can mutate via finalize + # below without "dict changed during iteration" trouble. + agents_snapshot = [ + (aid, status, dict(bus.metadata.get(aid, {}))) for aid, status in bus.statuses.items() ] + candidates: list[tuple[str, str, str | None, dict[str, Any]]] = [] + to_finalize_stopped: list[str] = [] + for aid, status, md in agents_snapshot: + if status not in {"running", "waiting", "llm_failed"}: + continue + if bus.parent_of.get(aid) is None or aid == root_id: + continue + if aid in bus.stopping: + # User clicked "stop" before the crash; don't respawn, + # and reconcile the bus so its status truthfully reflects + # "stopped" instead of staying "running" forever. + to_finalize_stopped.append(aid) + continue + candidates.append((aid, bus.names.get(aid, aid), bus.parent_of.get(aid), md)) + + # Finalize outside the lock — ``bus.finalize`` acquires it itself. + for aid in to_finalize_stopped: + logger.info("respawn-skip %s: previously-cancelled, finalizing as stopped", aid) + await bus.finalize(aid, "stopped") for child_id, name, parent_id, md in candidates: try: