diff --git a/README.md b/README.md index 5a7b09b..0f65148 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,8 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me | `PostToolUse` | `busy` | | `PreCompact` | `busy` | | `Notification` | `blocked` или `waiting`, в зависимости от `notification_type` | -| `Stop` | `waiting` | +| `Stop` | `waiting` — если не работают сабагенты (см. ниже) | +| `SubagentStop` | обновляет число работающих сабагентов; последний освобождает сессию | | `SessionEnd` | файл удаляется | `PostToolUse` не избыточен: это единственное событие, срабатывающее после выдачи @@ -208,31 +209,45 @@ Agent SDK и интеграции с редакторами. Но `Stop` при этом приходит раньше, чем сабагенты закончат. Если верить ему буквально, сессия покажется свободной ровно тогда, когда в неё лезть бессмысленно. -Поэтому сабагенты считаются явно: -| Событие | Что делает | -|---|---| -| `PreToolUse` с матчером `^(Agent\|Task)$` | +1 к счётчику | -| `SubagentStop` | −1; последний освобождает сессию, если ход уже закончен | -| `UserPromptSubmit` | сбрасывает счётчик в 0 | +Сколько сабагентов ещё работает, хук не считает, а **берёт из самого события**: +`Stop` и `SubagentStop` несут поле `background_tasks` — список фоновых задач с их +`status`. Оттуда и берётся число: задачи с `type: "subagent"` и `status: +"running"`. Остальные события этого поля не несут, и тогда стоит последнее +известное значение. -Пока счётчик больше нуля, состояние `waiting` невозможно: и `Stop`, и напоминание -`idle_prompt` дают `busy`. Освобождает сессию только уход последнего сабагента — -и лишь если основной агент к тому времени остановился. +Пока число больше нуля, состояние `waiting` невозможно: и `Stop`, и напоминание +`idle_prompt` дают `busy`. Освобождает сессию только `SubagentStop`, пришедший в +момент, когда список пуст, — и лишь если основной агент к тому времени +остановился. -Матчер якорный не для красоты: это регулярка, и голое `Task` поймало бы -`TaskCreate`, `TaskUpdate` и прочее. Хук проверяет имя инструмента ещё раз, сам. -Сброс на `UserPromptSubmit` ограничивает ущерб, если сабагент умрёт, не прислав -`SubagentStop`: счётчик не переживёт следующего вашего сообщения. +Фоновая команда (`type: "bash"`) сабагентом не считается намеренно: поднятый +dev-сервер живёт часами и ничего не говорит о том, нужны вы сессии или нет, — а +приравняв его к работе, чип пришлось бы держать «работает» всё это время. + +### Почему не счётчик + +Сначала счётчик и был: `+1` на `PreToolUse` с матчером `^(Agent|Task)$`, `−1` на +`SubagentStop`. Он ошибался в худшую сторону — гасил `busy` у занятой сессии, — +и вот почему. Запуск сабагента виден хуку только на верхнем уровне: когда свой +сабагент разворачивает сабагент, событие приходит с `agent_id` и отбрасывается. +А `SubagentStop` приходит **за каждого сабагента на любой глубине**. Каждый +вложенный вычитал единицу из батча, в который никогда не входил. + +Замерено на живой сессии: один фоновый агент, четырнадцать вложенных `SubagentStop` +подряд — счётчик обнулился на первом же, и сессия с работающим батчем показалась +ждущей. Снимок вычитать нечего: он просто говорит, что запущено сейчас, и по той +же причине не течёт, если сабагент умрёт, не прислав `SubagentStop`. Собственные вызовы инструментов сабагентов игнорируются — они долетают до хуков -родителя как `PostToolUse` с `agent_id`, но счётчик уже всё сказал, а они добавили -бы только записи на диск. `Notification` — исключение: она означает, что нужен -человек, и это одинаково верно, в каком бы агенте ни заклинило. +родителя как `PostToolUse` с `agent_id`, но сессия занята и без них, а записей на +диск от одного сабагента набежали бы сотни. `Notification` — исключение: она +означает, что нужен человек, и это одинаково верно, в каком бы агенте ни заклинило. В меню число сабагентов показывается строкой «работает · 3 subagents». В панели — нет: батч из восьми задач остаётся одной строкой «работает 40 мин», и это -правильная строка. +правильная строка. Считаются фоновые: батч, которого основной агент дожидается +сам, и так виден по состоянию `busy`. ## zellij diff --git a/hooks/claude-status-hook.py b/hooks/claude-status-hook.py index eeaa887..e80000a 100755 --- a/hooks/claude-status-hook.py +++ b/hooks/claude-status-hook.py @@ -48,10 +48,11 @@ NOTIFICATION_STATES = { "agent_completed": "waiting", } -# Tools that spawn a subagent. Matched again here, not just in the hook -# registration: the matcher is a regex and a mistake there would silently -# inflate the count with TaskCreate, TaskUpdate and the like. -AGENT_TOOLS = {"Agent", "Task"} +# Background tasks that mean the session is still working. A background shell +# is not one: a dev server left running says nothing about whether the session +# needs you, and counting it would pin the chip at "working" for as long as it +# lives. +BUSY_TASK_TYPES = {"subagent"} # Flags that mean "run one prompt and exit" rather than "open a session". See # is_headless: such a run has no terminal to be called over to. @@ -140,22 +141,51 @@ def derive_state(event): # and that is just as true when the agent that got stuck is a subagent. if name == "Notification": return NOTIFICATION_STATES.get(event.get("notification_type")) - # SubagentStop is the counter's decrement and carries agent_id itself, so - # it has to pass the filter below. Its state is decided in apply_event, - # which is where the count is known. + # SubagentStop carries agent_id itself, so it has to pass the filter below. + # It is the one event that can free a session whose main agent stopped a + # long time ago; which way it goes needs the snapshot and is decided in + # apply_event. if name == "SubagentStop": return "busy" # A subagent's own tool calls also reach the parent session's hooks # (measured: PostToolUse carrying agent_id and agent_type). They are - # ignored, because the count already says a subagent is running and these - # would only add write traffic. + # ignored: the session is working either way, and a single subagent's shell + # commands alone would be hundreds of writes. if event.get("agent_id"): return None - if name == "PreToolUse" and event.get("tool_name") not in AGENT_TOOLS: - return None return EVENT_STATES.get(name) +def running_agents(event): + """How many subagents the session still has running, or None if this event + does not say. + + Read from the event's own ``background_tasks`` rather than counted from + starts and stops. Counting was what this did first, and it was wrong in the + direction that matters: ``PreToolUse`` only ever sees a top-level launch -- + a subagent spawning its own subagents does it through hooks carrying + ``agent_id``, which are dropped above -- while ``SubagentStop`` arrives for + every subagent at every depth. Each nested one therefore subtracted from a + batch it had never joined, and the session was declared free with its work + still running. Measured live: one background agent, fourteen nested stops + behind it, and a session shown as waiting from the first of them on. + + The field is present exactly where it decides something: on ``Stop`` and + ``SubagentStop``, the two events that can end a turn. Everywhere else it is + absent, and the stored value stands. + """ + tasks = event.get("background_tasks") + if not isinstance(tasks, list): + return None + running = 0 + for task in tasks: + if not isinstance(task, dict): + continue + if task.get("type") in BUSY_TASK_TYPES and task.get("status") == "running": + running += 1 + return min(running, 999) + + def read_environ(pid): """Environment of a process as a dict, empty if it is gone or not ours.""" try: @@ -402,25 +432,17 @@ def apply_event(event, state, path, now, claude_pid): previous_ts = 0 # corrupt, and trusting it would freeze this session stale = previous_ts > now - # The subagent count is different in kind: the mutations are deltas, and - # deltas commute, so every one has to land whatever order it arrives in. - # Dropping a "+1" because its timestamp lost a race leaves the count one - # short, and the batch then frees the session while a subagent is still - # running -- the exact failure counting was added to prevent. The timestamps - # cannot be trusted for this at all: each is taken when its hook process - # starts, tens of milliseconds before it reaches the lock. - if name in ("SessionStart", "UserPromptSubmit"): - # A new turn from you starts a new batch. This also bounds the damage - # when a subagent dies without its SubagentStop ever arriving: the count - # cannot leak past the next thing you type. - agents = 0 - elif name == "PreToolUse": - agents += 1 - elif name == "SubagentStop": - agents = max(0, agents - 1) + # Applied whether or not this event lost the race above: a snapshot says + # what was running when the event fired, and even a slightly stale one is a + # better answer than a number left over from an older event still. Nothing + # accumulates, so nothing leaks when a subagent dies without ever sending + # its SubagentStop -- the next event carrying a list corrects the count. + snapshot = running_agents(event) + if snapshot is not None: + agents = snapshot if stale: - # Keep the stored state and flag; the delta above still gets persisted. + # Keep the stored state and flag; the snapshot above still stands. state = previous.get("state", state) else: if name in ("SessionStart", "UserPromptSubmit"): diff --git a/hooks/install.py b/hooks/install.py index 227581f..12e1d4e 100755 --- a/hooks/install.py +++ b/hooks/install.py @@ -23,17 +23,11 @@ HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-h # before it writes -- observed with `claude -p`, which left a session stuck at # "busy" forever. They fire at most once per turn, so ~20 ms is free; the # per-tool-call events stay async so they never sit in the agent's loop. -# (async, matcher). PreToolUse is matched to the agent-spawning tools alone: -# unmatched it would fire on every tool call in every session, and the only -# thing it is here for is to count subagents as they start. The pattern is -# anchored because it is a regex -- a bare "Task" also matches TaskCreate, -# TaskUpdate and friends, which are not subagents. The hook re-checks the name -# anyway, in case a future matcher works differently. +# (async, matcher). EVENTS = { "SessionStart": (True, ""), "UserPromptSubmit": (True, ""), "Notification": (True, ""), - "PreToolUse": (True, "^(Agent|Task)$"), "PostToolUse": (True, ""), "PreCompact": (True, ""), "SubagentStop": (True, ""), @@ -41,6 +35,14 @@ EVENTS = { "SessionEnd": (False, ""), } +# Registered once, no longer. PreToolUse existed to count subagents as they +# started; the count now comes from the snapshot the events carry themselves +# (see running_agents in the hook). Listed rather than forgotten because both +# install and uninstall sweep these out: an entry left behind would go on +# spawning the hook on every agent launch for nothing, and an uninstall that +# leaves our registrations in settings.json is not an uninstall. +LEGACY_EVENTS = ("PreToolUse",) + def entry(spec): async_, matcher = spec @@ -90,9 +92,9 @@ def main(): shutil.copymode(path, path + ".bak") hooks = settings.setdefault("hooks", {}) - for event in EVENTS: + for event in list(EVENTS) + list(LEGACY_EVENTS): groups = [g for g in hooks.get(event, []) if not is_ours(g)] - if not uninstall: + if not uninstall and event in EVENTS: groups.append(entry(EVENTS[event])) if groups: hooks[event] = groups diff --git a/tests/test-hook.sh b/tests/test-hook.sh index 93a2ec9..a2d9510 100755 --- a/tests/test-hook.sh +++ b/tests/test-hook.sh @@ -149,39 +149,57 @@ check "compaction does not reset the clock" "$since_before" "$(field since)" # 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. +# +# What is running is not counted from starts and stops but taken from the list +# the events carry, exactly as measured on a live session: Stop and +# SubagentStop bring "background_tasks", the rest of the events bring nothing. +bg() { # running-subagent-count -> the snapshot as those events carry it + tasks="" + for i in $(seq 0 $(($1 - 1))); do + tasks="$tasks${tasks:+,}{\"id\":\"sub-$i\",\"type\":\"subagent\",\"status\":\"running\"}" + done + echo "\"background_tasks\":[$tasks]" +} + 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)" +emit "$(ev Stop "$(bg 2)")" check "main agent stopping does not free a running batch" "busy" "$(field state)" +check "the snapshot is what gets stored" "2" "$(field agents)" -emit "$(ev SubagentStop '"agent_id":"sub-1","agent_type":"general-purpose"')" +emit "$(ev SubagentStop "\"agent_id\":\"sub-0\",\"agent_type\":\"general-purpose\",$(bg 1)")" check "one down, still working" "busy" "$(field state)" -check "count decremented" "1" "$(field agents)" +check "the count follows the snapshot" "1" "$(field agents)" -emit "$(ev SubagentStop '"agent_id":"sub-2","agent_type":"general-purpose"')" +# The regression this replaced counting for: a subagent's own subagent stops, +# and its SubagentStop reaches this session just like a top-level one -- while +# its *launch* never did, because that event carried agent_id and was dropped. +# Subtracting it freed a session whose batch was still running. +emit "$(ev SubagentStop "\"agent_id\":\"nested-1\",\"agent_type\":\"\",$(bg 1)")" +check "a nested subagent stopping does not free the batch" "busy" "$(field state)" +check "and does not touch the count" "1" "$(field agents)" + +emit "$(ev SubagentStop "\"agent_id\":\"sub-1\",$(bg 0)")" check "last subagent finishing frees the session" "waiting" "$(field state)" check "count back to zero" "0" "$(field agents)" +# A background shell is a background task too, and must not be mistaken for +# work: a dev server left running would pin the chip at "working" for good. +emit "$(ev UserPromptSubmit '"prompt":"serve"')" +emit "$(ev Stop '"background_tasks":[{"id":"b1","type":"bash","status":"running"}]')" +check "a background shell is not a subagent" "waiting" "$(field state)" + # 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"')" +emit "$(ev SubagentStop "\"agent_id\":\"sub-3\",$(bg 0)")" 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)" +# It carries no snapshot of its own, so this also checks the stored one stands. +emit "$(ev Stop "$(bg 1)")" 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 "an event without a snapshot leaves the count alone" "1" "$(field agents)" +emit "$(ev SubagentStop "\"agent_id\":\"sub-4\",$(bg 0)")" check "and it frees properly once the batch ends" "waiting" "$(field state)" # --- concurrency -----------------------------------------------------------