Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion crates/botloftd/src/chat/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions crates/botloftd/src/supervisor/relaunch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
Expand Down
31 changes: 30 additions & 1 deletion crates/botloftd/src/supervisor/slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ pub(super) struct Slot {
pub stop: Option<StopIntent>,
/// 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.
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 2 additions & 4 deletions crates/botloftd/src/supervisor/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 32 additions & 1 deletion crates/botloftd/src/supervisor/turns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
53 changes: 53 additions & 0 deletions crates/botloftd/tests/supervisor_turns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 8 additions & 4 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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` |
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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 · <nome>" 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) |
Expand Down
Loading