fix(scan): respawn-skip finalizes cancelled agents as `stopped`

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) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-26 01:16:26 -07:00
parent 671c69327b
commit e83522cec5
+23 -14
View File
@@ -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: