diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 02a52afcd..f6dccb8c6 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -849,6 +849,7 @@ def _build_child_agent( parent_id=parent_subagent_id, depth=tui_depth, model=effective_model_for_cb, + toolsets=child_toolsets, ) # Each subagent gets its own iteration budget capped at max_iterations diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e6519afab..50cfa966a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1753,6 +1753,42 @@ def _spawn_tree_session_dir(session_id: str): return d +# Per-session append-only index of lightweight snapshot metadata. Read by +# `spawn_tree.list` so scanning doesn't require reading every full snapshot +# file (Copilot review on #14045). One JSON object per line. +_SPAWN_TREE_INDEX = "_index.jsonl" + + +def _append_spawn_tree_index(session_dir, entry: dict) -> None: + try: + with (session_dir / _SPAWN_TREE_INDEX).open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError as exc: + # Index is a cache — losing a line just means list() falls back + # to a directory scan for that entry. Never block the save. + logger.debug("spawn_tree index append failed: %s", exc) + + +def _read_spawn_tree_index(session_dir) -> list[dict]: + index_path = session_dir / _SPAWN_TREE_INDEX + if not index_path.exists(): + return [] + out: list[dict] = [] + try: + with index_path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + except OSError: + return [] + return out + + @method("spawn_tree.save") def _(rid, params: dict) -> dict: session_id = str(params.get("session_id") or "").strip() @@ -1780,6 +1816,15 @@ def _(rid, params: dict) -> dict: except OSError as exc: return _err(rid, 5000, f"spawn_tree.save failed: {exc}") + _append_spawn_tree_index(d, { + "path": str(path), + "session_id": session_id, + "started_at": payload["started_at"], + "finished_at": payload["finished_at"], + "label": label, + "count": len(subagents), + }) + return _ok(rid, {"path": str(path), "session_id": session_id}) @@ -1789,19 +1834,27 @@ def _(rid, params: dict) -> dict: limit = int(params.get("limit") or 50) cross_session = bool(params.get("cross_session")) - roots = [] if cross_session: root = _spawn_trees_root() roots = [p for p in root.iterdir() if p.is_dir()] else: roots = [_spawn_tree_session_dir(session_id or "default")] - entries = [] + entries: list[dict] = [] for d in roots: + indexed = _read_spawn_tree_index(d) + if indexed: + # Skip index entries whose snapshot file was manually deleted. + entries.extend(e for e in indexed if (p := e.get("path")) and Path(p).exists()) + continue + + # Fallback for legacy (pre-index) sessions: full scan. O(N) reads + # but only runs once per session until the next save writes the index. for p in d.glob("*.json"): + if p.name == _SPAWN_TREE_INDEX: + continue try: stat = p.stat() - # Peek at the header for label/counts without parsing the full list. try: raw = json.loads(p.read_text(encoding="utf-8")) except Exception: diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 31d64f54c..cb9cd74b6 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -138,7 +138,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: }, ms) } - const keepCompletedElseRunning = (s: SubagentProgress['status']) => (s === 'completed' ? s : 'running') + // Terminal statuses are never overwritten by late-arriving live events — + // otherwise a stale `subagent.start` / `spawn_requested` can clobber a + // `failed` or `interrupted` terminal state (Copilot review #14045). + const isTerminalStatus = (s: SubagentProgress['status']) => + s === 'completed' || s === 'failed' || s === 'interrupted' + + const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running') const handleReady = (skin?: GatewaySkin) => { if (skin) { @@ -381,7 +387,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'subagent.spawn_requested': // Child built but not yet running (waiting on ThreadPoolExecutor slot). // Preserve completed state if a later event races in before this one. - turnController.upsertSubagent(ev.payload, c => (c.status === 'completed' ? {} : { status: 'queued' })) + turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' })) // Prime the status-bar HUD: fetch caps (once every 5s) so we can // warn as depth/concurrency approaches the configured ceiling. @@ -394,7 +400,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'subagent.start': - turnController.upsertSubagent(ev.payload, c => (c.status === 'completed' ? {} : { status: 'running' })) + turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' })) return case 'subagent.thinking': { @@ -405,7 +411,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } turnController.upsertSubagent(ev.payload, c => ({ - status: keepCompletedElseRunning(c.status), + status: keepTerminalElseRunning(c.status), thinking: pushThinking(c.thinking, text) })) @@ -419,7 +425,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ) turnController.upsertSubagent(ev.payload, c => ({ - status: keepCompletedElseRunning(c.status), + status: keepTerminalElseRunning(c.status), tools: pushTool(c.tools, line) })) @@ -435,7 +441,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: turnController.upsertSubagent(ev.payload, c => ({ notes: pushNote(c.notes, text), - status: keepCompletedElseRunning(c.status) + status: keepTerminalElseRunning(c.status) })) return diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 1e46272de..2fe2e6a5b 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -8,7 +8,7 @@ import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' -import { buildSubagentTree, treeTotals } from '../lib/subagentTree.js' +import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' import { fmtK } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' @@ -82,10 +82,14 @@ function SpawnHud({ t }: { t: Theme }) { const depth = Math.max(0, totals.maxDepthFromHere) const active = totals.activeCount - // Concurrency here is "concurrent top-level spawns per parent at the - // tightest branch" — approximated by the widest level in the tree. + // `max_concurrent_children` is a per-parent cap, not a global one. + // `activeCount` sums every running agent across the tree and would + // over-warn for multi-orchestrator runs. The widest level of the tree + // is a closer proxy to "most concurrent spawns that could be hitting a + // single parent's slot budget". + const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0) const depthRatio = maxDepth ? depth / maxDepth : 0 - const concRatio = maxConc ? active / maxConc : 0 + const concRatio = maxConc ? widestLevel / maxConc : 0 const ratio = Math.max(depthRatio, concRatio) const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim @@ -101,8 +105,13 @@ function SpawnHud({ t }: { t: Theme }) { pieces.push(`d${depthLabel}`) if (active > 0) { - const concLabel = maxConc ? `${active}/${maxConc}` : `${active}` - pieces.push(`⚡${concLabel}`) + // Label pairs the widest-level count (drives concRatio above) with + // the total active count for context. `W/cap` triggers the warn, + // `+N` is everything else currently running across the tree. + const extra = Math.max(0, active - widestLevel) + const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}` + const suffix = extra > 0 ? `+${extra}` : '' + pieces.push(`⚡${widthLabel}${suffix}`) } }