From 4db90d9f367767e365765e8c33242386ff563a6f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:50:39 -0300 Subject: [PATCH] fix(supervisor): a bot is busy while its subagents and own turns run Claude Code ends a turn while background subagents still work, then starts turns by itself when they finish. The daemon only counted turns it had written, so the bot showed as available during all of that. A system/init with no pending turn is now a turn of its own, and system/background_tasks_changed keeps the bot busy while subagents run. Tested with Claude Code 2.1.284 (spec 19). --- crates/botloftd/src/chat/events.rs | 23 +++++++++- crates/botloftd/src/supervisor/relaunch.rs | 4 +- crates/botloftd/src/supervisor/slot.rs | 31 ++++++++++++- crates/botloftd/src/supervisor/spawn.rs | 6 +-- crates/botloftd/src/supervisor/turns.rs | 33 +++++++++++++- crates/botloftd/tests/supervisor_turns.rs | 53 ++++++++++++++++++++++ docs/spec.md | 12 +++-- 7 files changed, 149 insertions(+), 13 deletions(-) diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index 62e6a3a2..1001380b 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -37,7 +37,15 @@ pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value // Subagents work inside a tool call; their traffic stays out of the chat. let from_subagent = !event["parent_tool_use_id"].is_null(); match kind { - "system" if subtype == Some("init") => session(daemon, bot, event), + "system" if subtype == Some("init") => { + session(daemon, bot, event); + daemon.supervisor.turn_began(bot, generation); + } + "system" if subtype == Some("background_tasks_changed") => { + daemon + .supervisor + .agents_running(bot, generation, agent_count(event)); + } "stream_event" if !from_subagent => delta(daemon, bot, event), "assistant" if !from_subagent => assistant(daemon, bot, generation, event), "user" if event["isReplay"].as_bool() == Some(true) => replay(daemon, bot, event), @@ -48,6 +56,19 @@ pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value } } +/// How many of the background tasks Claude Code lists are subagents. Shell +/// commands and monitors can run for as long as the bot lives, so they do +/// not count as work. +fn agent_count(event: &Value) -> u32 { + let agents = event["tasks"].as_array().map_or(0, |tasks| { + tasks + .iter() + .filter(|task| task["task_type"] == "local_agent") + .count() + }); + u32::try_from(agents).unwrap_or(u32::MAX) +} + /// Every turn starts with the session id; the next start resumes it. fn session(daemon: &Daemon, bot: &BotId, event: &Value) { let Some(session) = event["session_id"].as_str() else { diff --git a/crates/botloftd/src/supervisor/relaunch.rs b/crates/botloftd/src/supervisor/relaunch.rs index 21f9626c..fb6b2b21 100644 --- a/crates/botloftd/src/supervisor/relaunch.rs +++ b/crates/botloftd/src/supervisor/relaunch.rs @@ -21,7 +21,7 @@ impl Supervisor { // The next start reads the new settings anyway. return; } - if slot.turns == 0 && slot.approvals == 0 { + if !slot.has_work() { self.relaunch(bot, slot); } else { slot.restart_when_idle = true; @@ -47,7 +47,7 @@ impl Supervisor { /// Called when a turn or an approval ends. pub(super) fn relaunch_if_idle(&self, bot: &BotId, slot: &mut Slot) { - if slot.restart_when_idle && slot.turns == 0 && slot.approvals == 0 && slot.stop.is_none() { + if slot.restart_when_idle && !slot.has_work() && slot.stop.is_none() { self.relaunch(bot, slot); } } diff --git a/crates/botloftd/src/supervisor/slot.rs b/crates/botloftd/src/supervisor/slot.rs index 7b1b605e..78d6584f 100644 --- a/crates/botloftd/src/supervisor/slot.rs +++ b/crates/botloftd/src/supervisor/slot.rs @@ -20,6 +20,11 @@ pub(super) struct Slot { pub stop: Option, /// Messages written to the process whose turn has not ended yet. pub turns: u32, + /// Claude Code is in a turn nobody asked for: a subagent it had running + /// finished, and it went on by itself. No message is behind it. + pub own_turn: bool, + /// Subagents Claude Code runs in the background, between turns too. + pub agents: u32, /// Permission requests waiting for the owner. pub approvals: u32, /// While `rate_limited`: Unix ms when the limit resets. @@ -40,17 +45,32 @@ impl Slot { fresh_next: false, stop: None, turns: 0, + own_turn: false, + agents: 0, approvals: 0, limited_until: None, restart_when_idle: false, } } + /// Forgets the work of a process that is gone or replaced. + pub fn clear_work(&mut self) { + self.turns = 0; + self.own_turn = false; + self.agents = 0; + self.approvals = 0; + } + + /// A turn, a subagent or an approval is in progress. + pub fn has_work(&self) -> bool { + self.turns > 0 || self.own_turn || self.agents > 0 || self.approvals > 0 + } + /// The state that follows from the counters once nothing blocks the bot. pub fn working_state(&self) -> BotState { if self.approvals > 0 { BotState::NeedsApproval - } else if self.turns > 0 { + } else if self.has_work() { BotState::Busy } else { BotState::Idle @@ -138,6 +158,15 @@ mod tests { assert_eq!(slot.working_state(), BotState::Busy); slot.approvals = 1; assert_eq!(slot.working_state(), BotState::NeedsApproval); + + let mut slot = Slot::new(Backoff::new(Duration::ZERO, Duration::ZERO)); + slot.own_turn = true; + assert_eq!(slot.working_state(), BotState::Busy); + slot.own_turn = false; + slot.agents = 1; + assert_eq!(slot.working_state(), BotState::Busy); + slot.clear_work(); + assert_eq!(slot.working_state(), BotState::Idle); } #[test] diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index 436a361d..d24e3e2e 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -66,8 +66,7 @@ impl Supervisor { slot.generation = Some(generation); slot.restart_at = None; slot.fresh_next = false; - slot.turns = 0; - slot.approvals = 0; + slot.clear_work(); slot.limited_until = None; slot.restart_when_idle = false; tokens.insert(launch.token_hash.clone(), (bot.id.clone(), generation)); @@ -121,8 +120,7 @@ impl Supervisor { return; }; tokens.remove(&running.token_hash); - slot.turns = 0; - slot.approvals = 0; + slot.clear_work(); slot.limited_until = None; let ran = running.started.elapsed(); let resumed = running.resumed; diff --git a/crates/botloftd/src/supervisor/turns.rs b/crates/botloftd/src/supervisor/turns.rs index cfab3617..2143bf13 100644 --- a/crates/botloftd/src/supervisor/turns.rs +++ b/crates/botloftd/src/supervisor/turns.rs @@ -37,10 +37,41 @@ impl Supervisor { }); } + /// A turn began (`system/init`, spec 8.1). With no message written + /// for it, it is Claude Code's own: a subagent finished and it went on + /// by itself. The bot is working all the same. + pub(crate) fn turn_began(&self, bot: &BotId, generation: u64) { + self.with_current(bot, generation, |supervisor, slot| { + if slot.turns == 0 { + slot.own_turn = true; + } + if slot.is_working() { + supervisor.set_state(bot, slot, slot.working_state()); + } + }); + } + + /// Claude Code lists the subagents it runs in the background (`system/ + /// background_tasks_changed`); the whole list comes each time. They work + /// after the turn that started them has ended. + pub(crate) fn agents_running(&self, bot: &BotId, generation: u64, agents: u32) { + self.with_current(bot, generation, |supervisor, slot| { + slot.agents = agents; + if slot.is_working() { + supervisor.set_state(bot, slot, slot.working_state()); + } + supervisor.relaunch_if_idle(bot, slot); + }); + } + /// A `result` closed one turn. pub(crate) fn turn_ended(&self, bot: &BotId, generation: u64) { self.with_current(bot, generation, |supervisor, slot| { - slot.turns = slot.turns.saturating_sub(1); + if slot.own_turn { + slot.own_turn = false; + } else { + slot.turns = slot.turns.saturating_sub(1); + } if slot.is_working() { supervisor.set_state(bot, slot, slot.working_state()); } diff --git a/crates/botloftd/tests/supervisor_turns.rs b/crates/botloftd/tests/supervisor_turns.rs index 3077a48c..025a62e8 100644 --- a/crates/botloftd/tests/supervisor_turns.rs +++ b/crates/botloftd/tests/supervisor_turns.rs @@ -40,6 +40,59 @@ async fn turns_and_approvals_drive_the_state() { assert_eq!(s.state(), BotState::Idle); } +/// Subagents finish after the turn that started them: Claude Code then goes +/// on by itself, with no message written for it (spec 7.2). +#[tokio::test(start_paused = true)] +async fn a_turn_claude_code_starts_on_its_own_is_busy() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + + process.emit(stream::init("session-1")).await; + s.until(BotState::Busy).await; + process.emit(stream::result(false)).await; + s.until(BotState::Idle).await; + + // A message written while it works keeps the bot busy past that turn. + process.emit(stream::init("session-1")).await; + s.until(BotState::Busy).await; + s.message("and this too"); + process.emit(stream::result(false)).await; + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(s.state(), BotState::Busy, "the message still has its turn"); + process.emit(stream::init("session-1")).await; + process.emit(stream::result(false)).await; + s.until(BotState::Idle).await; +} + +#[tokio::test(start_paused = true)] +async fn subagents_in_the_background_keep_the_bot_busy() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + s.message("research this"); + + let tasks = |kinds: &[&str]| { + let tasks: Vec<_> = kinds + .iter() + .enumerate() + .map(|(n, kind)| json!({ "task_id": format!("t{n}"), "task_type": kind })) + .collect(); + json!({ "type": "system", "subtype": "background_tasks_changed", "tasks": tasks }) + }; + process.emit(tasks(&["local_agent", "local_agent"])).await; + process.emit(stream::result(false)).await; + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(s.state(), BotState::Busy, "the agents are still working"); + + process.emit(tasks(&["local_agent"])).await; + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(s.state(), BotState::Busy); + // A shell command left running is not work. + process.emit(tasks(&["local_bash"])).await; + s.until(BotState::Idle).await; +} + /// What keeps the computer awake (spec 14): only `busy` counts. #[tokio::test(start_paused = true)] async fn the_busy_count_follows_turns_approvals_and_crashes() { diff --git a/docs/spec.md b/docs/spec.md index 7d391918..a8417815 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -172,8 +172,8 @@ default_deadline_minutes = 120 |---|---| | `offline` | Sem processo e sem restart agendado (crew ou bot pausado) | | `launching` | Processo criado, nos primeiros 1,5 s | -| `idle` | Processo vivo, sem turno em andamento | -| `busy` | Turno em andamento ou na fila do Claude Code | +| `idle` | Processo vivo, sem turno em andamento e sem subagente trabalhando | +| `busy` | Turno em andamento ou na fila do Claude Code, ou subagente trabalhando em segundo plano | | `needs_approval` | Uma ferramenta espera o dono aprovar (10.1) | | `rate_limited` | O limite de uso da conta foi atingido; espera `resetsAt` | | `auth_error` | Claude Code sem autenticação válida | @@ -189,7 +189,9 @@ Os estados saem do próprio fluxo de eventos (8.1); não há hooks. | spawn do processo | `launching` | | processo vivo há 1,5 s | `idle` (o Claude Code fica calado até a primeira mensagem) | | delivery escrita no stdin | `busy` (conta um turno pendente) | -| `result` | `idle` se não sobrou turno pendente, senão continua `busy` | +| `system/init` sem turno pendente | `busy`: o Claude Code começou um turno por conta própria (um subagente terminou e ele seguiu sozinho), sem message por trás. O `result` dele fecha esse turno sem descontar um turno pendente | +| `system/background_tasks_changed` | `busy` enquanto a lista trouxer tarefas `local_agent` (subagentes em segundo plano); a lista vem inteira a cada mudança. Comandos e monitores em segundo plano não contam: podem durar tanto quanto o bot | +| `result` | `idle` se não sobrou turno pendente nem subagente, senão continua `busy` | | a tool de aprovação é chamada | `needs_approval` | | aprovação respondida ou vencida | `busy` | | erro `rate_limit` num turno, ou `rate_limit_event` com status diferente de `allowed` | `rate_limited` até `resetsAt` (5 min se não vier), depois `idle` | @@ -264,7 +266,8 @@ Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB, | Evento | O que vira | |---|---| -| `system/init` | guarda `session_id` em `bots.session_id` (vem no começo de cada turno); segue `permissionMode` e guarda `model` (7.4) | +| `system/init` | guarda `session_id` em `bots.session_id` (vem no começo de cada turno); segue `permissionMode` e guarda `model` (7.4); um turno sem message por trás deixa o bot `busy` (7.2) | +| `system/background_tasks_changed` | quantos subagentes (`task_type: local_agent`) rodam em segundo plano (7.2) | | `user` com `isReplay: true` | a message com aquele `uuid` começou a ser processada: a delivery ganha `read_at` (9.1) | | `stream_event` com `text_delta` | texto ao vivo da resposta (`chat.delta`, 8.3); não é gravado | | `assistant`, bloco `text` | item `reply` com o texto (markdown) | @@ -732,6 +735,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | `--add-dir` em `-p` | 5, 7.4 | Confirmado (`cli-reference`, `memory`): pastas a mais que o Claude Code trata como de trabalho; o `CLAUDE.md` delas só carrega com `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1`. **Testado com 2.1.284** (`--setting-sources project,local`, `acceptEdits`): com a flag e a variável, o bot citou o `CLAUDE.md` da pasta sem abrir arquivo e gravou nela sem pedir; sem a flag, o `Write` na mesma pasta foi negado (`permission_denials`). A flag aceita vários valores: a linha de comando do daemon não tem prompt posicional, então nada é engolido | feito | | Chefe e `suggest_bot` com o Claude Code real | 10.2 | **Testado com 2.1.284** (daemon e app de dev): a crew criada pelo app subiu o chefe com `--model sonnet` e `--add-dir` na pasta da crew. Pedido um site, o chefe carregou `crew_roster`, `suggest_bot` e `send_message` pelo `ToolSearch` e sugeriu um Designer com papel, instruções, modelo e porquê; a chamada esperou o cartão. Com o modelo trocado para `haiku` no cartão, o bot subiu com `--model haiku`, e o chefe citou a troca. O chefe mandou uma task, o Designer entregou com `complete_task`, e o chefe conferiu o resultado e corrigiu o HTML. O log de `debug` não teve texto de mensagem | feito | | Rotinas com o Claude Code real | 20 | **Testado com 2.1.284** (daemon e app de dev): uma rotina "a cada 5 minutos" criada pelo app, num chefe em Haiku, rodou às 09:10 e às 09:15, contadas a partir da criação; cada message chegou com o envelope da rotina, apareceu no chat com "Rotina · " e fechou como `done` com o `result` do turno. Desligada, não rodou às 09:20. O log de `debug` só teve ids | feito | +| Subagentes em segundo plano e turnos do próprio Claude Code | 7.2 | **Testado com 2.1.284** (`-p`, `stream-json`, Haiku, dois subagentes no `Agent`): o `result` do turno chega enquanto os subagentes ainda rodam. Eles avisam com `system/background_tasks_changed` (`tasks: [{task_id, task_type: "local_agent", description}]`, a lista inteira de quem ainda roda; `[]` no fim), `task_started`, `task_progress`, `task_updated` e `task_notification` (`status`, `summary`). Ao terminarem, o Claude Code abre turnos por conta própria (`system/init` sem `user` com `isReplay` antes, `assistant` e `result`), sem message escrita. Um turno assim já apareceu num chat real: o bot ficou `idle` durante ele. Comandos `local_bash` de dentro de um subagente aparecem em `task_started` com `owned_by_subagent` | feito | | Regras `allow` do projeto em `-p` sem confiança | 7.4 | Confirmado (`permissions`): não são aplicadas numa pasta nunca confiada; `deny` vale sempre. Por isso `--allowedTools mcp__botloft` | M4.1 | | `--session-id`, `--resume` em `-p` | 7.3 | Confirmado (`cli-reference`, `sessions`): a sessão retoma histórico e modelo; flags como `--mcp-config` têm de ser passadas de novo. **Testado com 2.1.284**: depois de reiniciar o bot e depois de reiniciar o daemon, o bot lembrou arquivos, a imagem e a mensagem de outro bot | feito (M4.1) | | Tools MCP adiadas | 10 | **Visto com 2.1.284**: as tools do `botloft` chegam adiadas; antes da primeira `send_message` o bot chama `ToolSearch` com `select:mcp__botloft__send_message`. O chat mostra isso como "load send_message" | feito (M4.1) |