Count subagents, and keep a batch from looking free

Corrected from the previous commit, which had it backwards. When a batch
is running the session is working, not waiting: the main agent will pick
the results up and consolidate them itself, so sending you to that
terminal wastes the trip. That is the common shape of the work here --
ask for a batch, let it run.

Simply letting subagent tool calls set "busy" would mostly work and was
tempting, but it leaves a hole. Stop fires before the batch finishes, so
the session shows as free from the moment the turn ends until the first
subagent tool call lands -- and longer whenever the subagents are thinking
rather than calling tools. So subagents are counted instead:

  PreToolUse, matched to ^(Agent|Task)$   +1
  SubagentStop                            -1
  UserPromptSubmit                        reset to 0

While the count is above zero the session cannot read as waiting; Stop and
an idle_prompt nudge both leave it working. The session is freed by the
last subagent leaving, and only if the main agent has stopped by then.

The matcher is anchored because it is a regex: a bare "Task" also matches
TaskCreate and friends, which are not subagents. The hook re-checks the
tool name itself in case a future matcher behaves differently, and the
reset on UserPromptSubmit bounds a count that leaks because a subagent
died without its SubagentStop.

Measured, not assumed: a matched PreToolUse fires only on agent launches,
SubagentStop arrives once per subagent carrying agent_id, and a real
three-subagent run walks the count 0-1-2-3-2-0 before Stop frees it.

The menu shows the number, as asked. The panel does not: a batch of eight
is still one line saying "working 40 min", which is the right line.
This commit is contained in:
av
2026-08-09 19:26:25 +03:00
parent 09b9aae2eb
commit 5398f32826
6 changed files with 153 additions and 34 deletions
+39
View File
@@ -86,6 +86,45 @@ emit "$(ev SessionStart '"source":"compact"')"
check "compaction does not reset a live session" "busy" "$(field state)"
check "compaction does not reset the clock" "$since_before" "$(field since)"
# --- subagents -------------------------------------------------------------
# The scenario this exists for: you ask for a batch, the main agent launches it
# and ends its turn, and the session waits for the results to consolidate them.
# It is working, not waiting for you, and must not call you over.
emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')"
check "a new turn resets the count" "0" "$(field agents)"
emit "$(ev PreToolUse '"tool_name":"Agent"')"
emit "$(ev PreToolUse '"tool_name":"Agent"')"
check "two launches counted" "2" "$(field agents)"
emit "$(ev PreToolUse '"tool_name":"TaskCreate"')"
check "a tool that merely starts with Task is not a subagent" "2" "$(field agents)"
emit "$(ev Stop)"
check "main agent stopping does not free a running batch" "busy" "$(field state)"
emit "$(ev SubagentStop '"agent_id":"sub-1","agent_type":"general-purpose"')"
check "one down, still working" "busy" "$(field state)"
check "count decremented" "1" "$(field agents)"
emit "$(ev SubagentStop '"agent_id":"sub-2","agent_type":"general-purpose"')"
check "last subagent finishing frees the session" "waiting" "$(field state)"
check "count back to zero" "0" "$(field agents)"
# A batch that finishes while the main agent is still mid-turn must not free it.
emit "$(ev UserPromptSubmit '"prompt":"again"')"
emit "$(ev PreToolUse '"tool_name":"Agent"')"
emit "$(ev SubagentStop '"agent_id":"sub-3"')"
check "batch done mid-turn leaves the session working" "busy" "$(field state)"
# An idle nudge while a batch runs must not claim the session is free either.
emit "$(ev PreToolUse '"tool_name":"Agent"')"
emit "$(ev Stop)"
emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')"
check "idle nudge cannot free a running batch" "busy" "$(field state)"
emit "$(ev SubagentStop '"agent_id":"sub-4"')"
check "and it frees properly once the batch ends" "waiting" "$(field state)"
# --- concurrency -----------------------------------------------------------
# The hazard: the last PostToolUse of a turn is async and can still be running
# when the turn's Stop fires. Two mechanisms defend against it and they are