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) |