diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index adbf7ffc..6b8d8e28 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -40,7 +40,11 @@ pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value crate::screens::stream(daemon, bot, event); } "assistant" if !from_subagent => assistant(daemon, bot, generation, event), - "user" if event["isReplay"].as_bool() == Some(true) => replay(daemon, bot, event), + "user" if event["isReplay"].as_bool() == Some(true) => { + let uuid = event["uuid"].as_str(); + daemon.supervisor.message_began(bot, generation, uuid); + replay(daemon, bot, event); + } "user" if !from_subagent => tool_results(daemon, bot, event), "rate_limit_event" => account::rate_limit(daemon, bot, generation, event), "result" => result(daemon, bot, generation, event), diff --git a/crates/botloftd/src/context.rs b/crates/botloftd/src/context.rs index db8a9724..66be4485 100644 --- a/crates/botloftd/src/context.rs +++ b/crates/botloftd/src/context.rs @@ -254,14 +254,15 @@ pub fn compact(daemon: &Daemon, params: BotIdParams) -> ApiResult { if !ready { return Err(not_running()); } + let uuid = random_uuid(); let line = json!({ "type": "user", - "uuid": random_uuid(), + "uuid": uuid, "message": { "role": "user", "content": [{ "type": "text", "text": COMPACT }] }, }); daemon .supervisor - .write_message(&record.id, Bytes::from(format!("{line}\n"))) + .write_message(&record.id, &uuid, Bytes::from(format!("{line}\n"))) .map_err(|_| not_running())?; update(daemon, &record.id, |entry| entry.compacting = true); } diff --git a/crates/botloftd/src/courier/mod.rs b/crates/botloftd/src/courier/mod.rs index ceaed1b4..ee840d4f 100644 --- a/crates/botloftd/src/courier/mod.rs +++ b/crates/botloftd/src/courier/mod.rs @@ -138,10 +138,11 @@ fn send( return None; } } - let outcome = match daemon - .supervisor - .write_message(&delivery.bot_id, rendered.line.clone()) - { + let outcome = match daemon.supervisor.write_message( + &delivery.bot_id, + &rendered.uuid, + rendered.line.clone(), + ) { Ok(generation) => { debug!(delivery = %delivery.id, bot = %delivery.bot_id, generation, "delivery written"); DeliveryOutcome::Sent { diff --git a/crates/botloftd/src/routines/mod.rs b/crates/botloftd/src/routines/mod.rs index 640a20b2..bc4839e2 100644 --- a/crates/botloftd/src/routines/mod.rs +++ b/crates/botloftd/src/routines/mod.rs @@ -27,8 +27,9 @@ const MAX_SLEEP: Duration = Duration::from_secs(60); #[derive(Default)] pub struct Routines { wake: Notify, - /// The run whose message began each bot's current turn. - turns: Mutex>, + /// The runs whose messages each bot's current turn took up. Messages + /// that arrive during a turn may join it (spec 9.1). + turns: Mutex>>, } impl Routines { @@ -38,7 +39,7 @@ impl Routines { self.wake.notify_one(); } - fn turns(&self) -> MutexGuard<'_, HashMap> { + fn turns(&self) -> MutexGuard<'_, HashMap>> { self.turns .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -90,36 +91,39 @@ fn until_next(daemon: &Daemon) -> Duration { Duration::from_millis(left).min(MAX_SLEEP) } -/// The bot began the turn for `message`: remember the run it belongs to. +/// The bot took up `message`: remember the run it belongs to. pub fn turn_began(daemon: &Daemon, bot: &BotId, message: &MessageId) { let run = daemon.store().run_of_message(message).ok().flatten(); - let mut turns = daemon.routines.turns(); - match run { - Some(run) if run.status == RunStatus::Queued => { - turns.insert(bot.clone(), run.id); - } - _ => { - turns.remove(bot); - } + if let Some(run) = run + && run.status == RunStatus::Queued + { + daemon + .routines + .turns() + .entry(bot.clone()) + .or_default() + .push(run.id); } } -/// The bot's turn ended (`result`): its run is done, or failed on an error. +/// The bot's turn ended (`result`): its runs are done, or failed on an +/// error. pub fn turn_ended(daemon: &Daemon, bot: &BotId, failed: bool) { - let Some(run) = daemon.routines.turns().remove(bot) else { - return; - }; let status = if failed { RunStatus::Failed } else { RunStatus::Done }; - finish(daemon, &run, status); + let runs = daemon.routines.turns().remove(bot).unwrap_or_default(); + for run in runs { + finish(daemon, &run, status); + } } -/// The bot's process ended mid-turn: that run will not finish. +/// The bot's process ended mid-turn: those runs will not finish. pub fn process_ended(daemon: &Daemon, bot: &BotId) { - if let Some(run) = daemon.routines.turns().remove(bot) { + let runs = daemon.routines.turns().remove(bot).unwrap_or_default(); + for run in runs { finish(daemon, &run, RunStatus::Failed); } } diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 456d03a1..f92df41c 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -157,9 +157,10 @@ impl Supervisor { slot_entry(&mut inner.slots, bot, &self.settings) } - /// Writes one stream-json line to the bot's stdin (spec 9.2). A message - /// is one turn more for the bot. Returns the generation it went to. - pub fn write_message(&self, bot: &BotId, line: Bytes) -> Result { + /// Writes one stream-json line, the message with `uuid`, to the bot's + /// stdin (spec 9.2). The bot has work until Claude Code takes the + /// message up and ends that turn. Returns the generation it went to. + pub fn write_message(&self, bot: &BotId, uuid: &str, line: Bytes) -> Result { let mut inner = self.lock(); let slot = self.slot(&mut inner, bot); let (Some(running), Some(generation)) = (&slot.running, slot.generation) else { @@ -169,7 +170,7 @@ impl Supervisor { return Err(NotRunning); } running.control.write(line).map_err(|_| NotRunning)?; - slot.turns += 1; + slot.waiting.push(uuid.to_owned()); if slot.is_working() { self.set_state(bot, slot, slot.working_state()); } diff --git a/crates/botloftd/src/supervisor/slot.rs b/crates/botloftd/src/supervisor/slot.rs index 3b947f88..0b0fb3db 100644 --- a/crates/botloftd/src/supervisor/slot.rs +++ b/crates/botloftd/src/supervisor/slot.rs @@ -18,11 +18,13 @@ pub(super) struct Slot { pub fresh_next: bool, /// Why the running process is being killed, if the daemon asked. 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, + /// The uuids of messages written to the process that Claude Code has + /// not taken up yet (no replay with that uuid). + pub waiting: Vec, + /// Claude Code is in a turn: for a message, or on its own after a + /// subagent finished. Messages that arrive meanwhile may join it, so + /// one `result` can close the turn of several messages. + pub in_turn: bool, /// Subagents Claude Code runs in the background, between turns too. pub agents: u32, /// Permission requests waiting for the owner. @@ -44,8 +46,8 @@ impl Slot { restart_at: None, fresh_next: false, stop: None, - turns: 0, - own_turn: false, + waiting: Vec::new(), + in_turn: false, agents: 0, approvals: 0, limited_until: None, @@ -55,15 +57,15 @@ impl Slot { /// 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.waiting.clear(); + self.in_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 + !self.waiting.is_empty() || self.in_turn || self.agents > 0 || self.approvals > 0 } /// The state that follows from the counters once nothing blocks the bot. @@ -156,15 +158,15 @@ mod tests { fn counters_decide_the_working_state() { let mut slot = Slot::new(Backoff::new(Duration::ZERO, Duration::ZERO)); assert_eq!(slot.working_state(), BotState::Idle); - slot.turns = 2; + slot.waiting = vec!["a".into(), "b".into()]; 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; + slot.in_turn = true; assert_eq!(slot.working_state(), BotState::Busy); - slot.own_turn = false; + slot.in_turn = false; slot.agents = 1; assert_eq!(slot.working_state(), BotState::Busy); slot.clear_work(); diff --git a/crates/botloftd/src/supervisor/turns.rs b/crates/botloftd/src/supervisor/turns.rs index 802b723b..2f8049e7 100644 --- a/crates/botloftd/src/supervisor/turns.rs +++ b/crates/botloftd/src/supervisor/turns.rs @@ -41,9 +41,23 @@ impl Supervisor { /// 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; + slot.in_turn = true; + if slot.is_working() { + supervisor.set_state(bot, slot, slot.working_state()); + } + }); + } + + /// Claude Code gave back a user message (`isReplay`, spec 9.1): the + /// one written with `uuid`, now in a turn of its own or inside the turn + /// already running, which then ends with a single `result` for both. + /// `/compact` also gives back its output, with a uuid nobody wrote. + pub(crate) fn message_began(&self, bot: &BotId, generation: u64, uuid: Option<&str>) { + self.with_current(bot, generation, |supervisor, slot| { + if let Some(at) = slot.waiting.iter().position(|w| Some(w.as_str()) == uuid) { + slot.waiting.remove(at); } + slot.in_turn = true; if slot.is_working() { supervisor.set_state(bot, slot, slot.working_state()); } @@ -63,14 +77,10 @@ impl Supervisor { }); } - /// A `result` closed one turn. + /// A `result` closed the turn, with every message taken up in it. pub(crate) fn turn_ended(&self, bot: &BotId, generation: u64) { self.with_current(bot, generation, |supervisor, slot| { - if slot.own_turn { - slot.own_turn = false; - } else { - slot.turns = slot.turns.saturating_sub(1); - } + slot.in_turn = false; if slot.is_working() { supervisor.set_state(bot, slot, slot.working_state()); } diff --git a/crates/botloftd/tests/bot_effort.rs b/crates/botloftd/tests/bot_effort.rs index 55aef595..f5ba7c43 100644 --- a/crates/botloftd/tests/bot_effort.rs +++ b/crates/botloftd/tests/bot_effort.rs @@ -83,6 +83,7 @@ async fn a_busy_bot_finishes_its_turn_before_changing_effort() { assert_eq!(s.runtime.processes().len(), 1, "the turn is not cut short"); assert!(s.daemon.supervisor.relaunch_pending(&s.bot)); + first.emit(stream::began("session-1")).await; first.emit(stream::result(false)).await; let second = s.runtime.process(2).await; assert_eq!(arg_after(&second, "--effort").as_deref(), Some("max")); diff --git a/crates/botloftd/tests/bot_models.rs b/crates/botloftd/tests/bot_models.rs index d1079398..9d3c1550 100644 --- a/crates/botloftd/tests/bot_models.rs +++ b/crates/botloftd/tests/bot_models.rs @@ -71,6 +71,7 @@ async fn a_busy_bot_finishes_its_turn_before_changing_model() { assert_eq!(s.runtime.processes().len(), 1, "the turn is not cut short"); assert!(s.daemon.supervisor.relaunch_pending(&s.bot)); + first.emit(stream::began("session-1")).await; first.emit(stream::result(false)).await; let second = s.runtime.process(2).await; assert_eq!(arg_after(&second, "--model").as_deref(), Some("opus")); @@ -109,6 +110,7 @@ async fn a_model_the_account_cannot_use_is_told_in_the_chat() { "There's an issue with the selected model (fable).", )) .await; + process.emit(stream::began("session-1")).await; process.emit(stream::result(true)).await; s.until(BotState::Idle).await; diff --git a/crates/botloftd/tests/common/supervised.rs b/crates/botloftd/tests/common/supervised.rs index f374079a..1bfd01c5 100644 --- a/crates/botloftd/tests/common/supervised.rs +++ b/crates/botloftd/tests/common/supervised.rs @@ -83,7 +83,7 @@ impl Setup { json!({ "type": "user", "uuid": "u", "message": { "role": "user", "content": text } }); self.daemon .supervisor - .write_message(&self.bot, format!("{line}\n").into()) + .write_message(&self.bot, "u", format!("{line}\n").into()) .expect("running") } diff --git a/crates/botloftd/tests/compaction.rs b/crates/botloftd/tests/compaction.rs index c4b80530..a659520d 100644 --- a/crates/botloftd/tests/compaction.rs +++ b/crates/botloftd/tests/compaction.rs @@ -38,6 +38,16 @@ async fn compacted(process: &FakeProcess) { process.emit(status(Value::Null, json!("success"))).await; process.emit(stream::init("session-1")).await; process.emit(boundary("manual")).await; + // It gives back what it printed, then the command with its uuid. + let command = process + .input_lines() + .into_iter() + .rfind(|line| line["message"]["content"][0]["text"] == "/compact") + .expect("/compact written"); + process + .emit(stream::replay(&json!({ "uuid": "output" }))) + .await; + process.emit(stream::replay(&command)).await; process .emit(json!({ "type": "result", "subtype": "success", "is_error": false, "num_turns": 0, @@ -113,6 +123,7 @@ async fn a_compaction_waits_behind_the_turn_in_progress() { compact(&s).expect("compact"); assert_eq!(process.input_lines().len(), 2); + process.emit(stream::began("session-1")).await; process.emit(stream::result(false)).await; settle().await; assert_eq!( @@ -133,6 +144,7 @@ async fn claude_code_compacting_by_itself_is_told_in_the_chat() { s.until(BotState::Idle).await; holds(&process, 160_000, 200_000).await; s.message("read more"); + process.emit(stream::began("session-1")).await; process.emit(status(json!("requesting"), Value::Null)).await; settle().await; @@ -175,6 +187,8 @@ async fn nothing_to_compact_is_a_notice_not_a_reply() { "local_command_outcome": { "kind": "failed" }, })) .await; + let command = process.input_lines().remove(0); + process.emit(stream::replay(&command)).await; process .emit( json!({ "type": "result", "subtype": "success", "is_error": false, diff --git a/crates/botloftd/tests/context.rs b/crates/botloftd/tests/context.rs index 7d5474fb..11ef88a4 100644 --- a/crates/botloftd/tests/context.rs +++ b/crates/botloftd/tests/context.rs @@ -35,6 +35,7 @@ async fn the_size_follows_each_model_request_and_is_asked_again_when_a_turn_ends s.until(BotState::Idle).await; holds(&process, 22_000, 200_000).await; s.message("read the files"); + process.emit(stream::began("session-1")).await; let request = |cached: u64| { json!({ diff --git a/crates/botloftd/tests/permission_modes.rs b/crates/botloftd/tests/permission_modes.rs index d9071351..c1a7dae6 100644 --- a/crates/botloftd/tests/permission_modes.rs +++ b/crates/botloftd/tests/permission_modes.rs @@ -81,6 +81,7 @@ async fn a_busy_bot_finishes_its_turn_before_changing_mode() { assert_eq!(s.runtime.processes().len(), 1, "the turn is not cut short"); assert!(s.daemon.supervisor.relaunch_pending(&s.bot)); + first.emit(stream::began("session-1")).await; first.emit(stream::result(false)).await; let second = s.runtime.process(2).await; assert_eq!( @@ -149,6 +150,7 @@ async fn a_plan_approved_while_a_new_model_waits_still_leaves_plan_mode() { planning.emit(init_with_mode(&session, "default")).await; tokio::time::sleep(Duration::from_millis(50)).await; assert_eq!(stored_mode(&s), PermissionMode::Default); + planning.emit(stream::began("session-1")).await; planning.emit(stream::result(false)).await; let next = s.runtime.process(3).await; assert_eq!( diff --git a/crates/botloftd/tests/routine_runs.rs b/crates/botloftd/tests/routine_runs.rs index c370ce12..de19eb6e 100644 --- a/crates/botloftd/tests/routine_runs.rs +++ b/crates/botloftd/tests/routine_runs.rs @@ -8,11 +8,11 @@ use std::sync::Arc; use std::time::Duration; use botloft_core::protocol::{ - BotIdParams, Missed, Overlap, RoutinesCreateParams, RunStatus, Schedule, + BotIdParams, MessagesSendParams, Missed, Overlap, RoutinesCreateParams, RunStatus, Schedule, }; use botloft_store::DeliveryOutcome; use botloftd::routines; -use botloftd::service::{ApiError, bots, routines as service}; +use botloftd::service::{ApiError, bots, messages, routines as service}; use common::routines::*; use common::stream; @@ -59,6 +59,34 @@ async fn a_process_that_ends_mid_turn_fails_the_run() { assert_eq!(runs(&s, &routine.id)[0].status, RunStatus::Failed); } +/// A message that joins the run's turn ends with it (spec 9.1): the run +/// is still the turn's, and is done at its `result`. +#[tokio::test(start_paused = true)] +async fn a_message_that_joins_the_runs_turn_leaves_the_run_to_it() { + let s = routines_setup().await; + let routine = create(&s, every(5), Overlap::Skip, Missed::RunOnce); + tick_after(&s, 5 * MINUTE); + let lines = written(&s, 1).await; + let process = s.runtime.process(1).await; + process.emit(stream::replay(&lines[0])).await; + + messages::send( + &s.daemon, + MessagesSendParams { + bot_id: s.bot.clone(), + body: "one more thing".into(), + attachments: None, + }, + ) + .expect("send"); + s.clock.advance(Duration::from_secs(5)); + let lines = written(&s, 2).await; + process.emit(stream::replay(&lines[1])).await; + process.emit(stream::result(false)).await; + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(runs(&s, &routine.id)[0].status, RunStatus::Done); +} + #[tokio::test(start_paused = true)] async fn archiving_the_bot_archives_its_routines() { let s = routines_setup().await; diff --git a/crates/botloftd/tests/supervisor_turns.rs b/crates/botloftd/tests/supervisor_turns.rs index 025a62e8..d3e2f25a 100644 --- a/crates/botloftd/tests/supervisor_turns.rs +++ b/crates/botloftd/tests/supervisor_turns.rs @@ -30,9 +30,11 @@ async fn turns_and_approvals_drive_the_state() { s.daemon.supervisor.approval_closed(&s.bot, generation); assert_eq!(s.state(), BotState::Busy); + process.emit(stream::began("session-1")).await; process.emit(stream::result(false)).await; tokio::time::sleep(Duration::from_millis(20)).await; - assert_eq!(s.state(), BotState::Busy, "one turn is still queued"); + assert_eq!(s.state(), BotState::Busy, "one message is still waiting"); + process.emit(stream::began("session-1")).await; process.emit(stream::result(false)).await; s.until(BotState::Idle).await; // News about a process that is gone changes nothing. @@ -61,6 +63,26 @@ async fn a_turn_claude_code_starts_on_its_own_is_busy() { 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::began("session-1")).await; + process.emit(stream::result(false)).await; + s.until(BotState::Idle).await; +} + +/// A message written during a turn can join it: Claude Code takes it up +/// in the running turn, and one `result` ends the work of both. +#[tokio::test(start_paused = true)] +async fn a_message_that_joins_the_running_turn_ends_with_it() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + + s.message("check the inbox"); + process.emit(stream::began("session-1")).await; + process.emit(stream::init("session-1")).await; + s.message("a note from another bot"); + process.emit(stream::began("session-1")).await; + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(s.state(), BotState::Busy); process.emit(stream::result(false)).await; s.until(BotState::Idle).await; } @@ -71,6 +93,7 @@ async fn subagents_in_the_background_keep_the_bot_busy() { let process = s.runtime.process(1).await; s.until(BotState::Idle).await; s.message("research this"); + process.emit(stream::began("session-1")).await; let tasks = |kinds: &[&str]| { let tasks: Vec<_> = kinds @@ -108,6 +131,7 @@ async fn the_busy_count_follows_turns_approvals_and_crashes() { assert_eq!(*busy.borrow(), 0, "waiting for the owner is not working"); s.daemon.supervisor.approval_closed(&s.bot, generation); assert_eq!(*busy.borrow(), 1); + process.emit(stream::began("session-1")).await; process.emit(stream::result(false)).await; s.until(BotState::Idle).await; assert_eq!(*busy.borrow(), 0); @@ -166,6 +190,7 @@ async fn a_rate_limit_holds_the_bot_until_it_resets() { })) .await; s.until(BotState::RateLimited).await; + process.emit(stream::began("session-1")).await; process.emit(stream::result(true)).await; tokio::time::sleep(Duration::from_secs(10)).await; assert_eq!(s.state(), BotState::RateLimited); diff --git a/docs/spec.md b/docs/spec.md index 3b97d90f..443950fc 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -201,10 +201,11 @@ 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) | -| `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 | +| delivery escrita no stdin | `busy`; o `uuid` da linha fica esperando o replay | +| `user` com `isReplay` | o `uuid` escrito sai da espera e há um turno aberto. O replay pode vir no meio de um turno que já roda: uma message escrita entre duas chamadas de ferramenta entra nesse turno, e um só `result` fecha as duas (19). Um replay com `uuid` que ninguém escreveu (a saída do `/compact`, 8.6) só abre o turno | +| `system/init` | `busy`: há um turno aberto. Sem message por trás, é um turno do próprio Claude Code (um subagente terminou e ele seguiu sozinho) | | `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` | +| `result` | fecha o turno aberto; `idle` se nenhum `uuid` escrito espera o replay e não há 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` | @@ -317,7 +318,7 @@ Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB, |---|---| | `system/init` | 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). A conversa existe em disco a partir daqui: guarda `session_id` em `bots.session_id` (7.3) | +| `user` com `isReplay: true` | a message com aquele `uuid` começou a ser processada: a delivery ganha `read_at` (9.1) e o `uuid` sai da espera do supervisor (7.2). A conversa existe em disco a partir daqui: guarda `session_id` em `bots.session_id` (7.3) | | `result` com `No conversation found` em `errors` | o Claude Code não achou a conversa a retomar e vai sair: não é um turno, não vira item nem fecha turno pendente; o próximo start começa conversa nova (7.3) | | `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) | @@ -325,7 +326,7 @@ Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB, | `user`, bloco `tool_result` | atualiza o item `tool` do mesmo `tool_use_id`: `done` ou `failed` e um trecho da saída | | `assistant` com `error` | item `notice` e, conforme o erro, estado `rate_limited` ou `auth_error` (7.2); `model_not_found` vira o aviso `model_unavailable` | | `rate_limit_event` | uso da conta (janelas de 5 h e 7 dias) em `system.status.usage`; status diferente de `allowed` leva a `rate_limited` | -| `result` | item `turn` com duração, custo e erro (se houve); fecha um turno pendente. O daemon pergunta de novo o tamanho da conversa (8.6). O `result` de uma compactação (`local_command: "compact"`) fecha o turno sem item `turn` | +| `result` | item `turn` com duração, custo e erro (se houve); fecha o turno aberto, com todas as messages que entraram nele (7.2). O daemon pergunta de novo o tamanho da conversa (8.6). O `result` de uma compactação (`local_command: "compact"`) fecha o turno sem item `turn` | | `assistant` com `usage` | quanto a conversa ocupa naquele pedido ao modelo (8.6) | | `system/status` | `status: "compacting"`: o Claude Code começou a compactar a conversa; com `compact_result`, terminou (8.6) | | `system/compact_boundary` | a conversa foi compactada: aviso `compacted` ou `auto_compacted` no chat (8.6) | @@ -414,7 +415,7 @@ A compactação custa um pedido ao modelo com a conversa inteira (barato com o c 4. Se o bot não está em `idle`/`busy`/`needs_approval`: volta para `pending` com `next_attempt_at` em 5 s, **sem** contar tentativa. Bot ou crew arquivados: a delivery vira `dead` na hora. 5. Monta a mensagem (9.2) na hora do envio e escreve uma linha no stdin do processo, com timeout de 10 s. 6. Escrita aceita: `sent`, com a generation do processo. Falha de escrita (processo saindo): volta para `pending` sem contar tentativa. -7. Quando o Claude Code começa o turno daquela mensagem, ele a devolve no stdout com o mesmo `uuid` (`--replay-user-messages`), e a delivery ganha `read_at`. O app mostra isso como "lida", com os dois tiques em azul-claro (15.3). +7. Quando o Claude Code pega a mensagem, num turno novo ou no turno que já roda, ele a devolve no stdout com o mesmo `uuid` (`--replay-user-messages`), e a delivery ganha `read_at`. O app mostra isso como "lida", com os dois tiques em azul-claro (15.3). 8. **O processo morreu antes de ler:** a delivery ainda está `sent` sem `read_at` e com a generation que acabou. Ela volta para `pending` com `attempts += 1` e o backoff de `retry_backoff_initial_ms * 2^(attempts-1)`, limitado a `retry_backoff_max_ms`. Ao chegar em `max_attempts` vira `dead`: uma mensagem que derruba o processo toda vez não fica em laço. 9. Lease vencido (daemon caiu no meio): volta a `pending` no boot e a cada ciclo. 10. `deliveries.retry` devolve uma delivery `dead` para `pending`, com as tentativas zeradas. @@ -940,9 +941,9 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Item | Seção | Resultado | Teste real | |---|---|---|---| | Formato de entrada `--input-format stream-json` | 9.2 | **Não documentado**. **Testado com 2.1.284**: `{"type":"user","uuid":...,"message":{"role":"user","content":[blocos]}}` abre um turno; bloco `image` em base64 é aceito | feito (M4.1) | -| Vários turnos num processo; mensagem durante um turno | 9.2 | **Testado com 2.1.284**: dois turnos seguidos no mesmo processo; a mensagem escrita durante um turno virou o turno seguinte. Cada turno começa com `system/init` e termina com `result` | feito (M4.1) | +| Vários turnos num processo; mensagem durante um turno | 9.2 | **Testado com 2.1.284**: dois turnos seguidos no mesmo processo; a mensagem escrita durante um turno virou o turno seguinte. Cada turno começa com `system/init` e termina com `result`. **Testado de novo com 2.1.284** (Haiku, as flags do daemon): escrita enquanto o modelo escrevia uma resposta só de texto, a mensagem ganhou turno próprio (`system/init`, replay, `result`); escrita entre duas chamadas de ferramenta, voltou como replay logo depois do resultado de uma ferramenta, **dentro do mesmo turno**, sem `system/init` novo, e um só `result` (`num_turns: 5`) fechou as duas. Contar um turno por message escrita deixou um chefe `busy` para sempre num chat real; o supervisor conta pelos replays (7.2) | feito | | Processo sem entrada | 7.2 | **Testado com 2.1.284**: fica calado e vivo até a primeira mensagem; sai com 0 quando o stdin fecha | feito (M4.1) | -| `--replay-user-messages` | 9.1 | Flag no `--help` sem detalhes na documentação. **Testado com 2.1.284**: devolve a mensagem com `isReplay: true` e o mesmo `uuid` quando o turno dela começa, não quando é lida | feito (M4.1) | +| `--replay-user-messages` | 9.1 | Flag no `--help` sem detalhes na documentação. **Testado com 2.1.284**: devolve a mensagem com `isReplay: true` e o mesmo `uuid` quando o Claude Code a pega (no começo do turno dela ou no meio do turno em andamento, veja acima), não quando é lida. O `system/init` do turno vem antes do replay. Um `/compact` sem nada a compactar também devolve o comando com o `uuid` enviado | feito (M4.1) | | Eventos do `--output-format stream-json` | 8.1 | Parcialmente documentado (`headless`): `system/init`, `stream_event` com `text_delta` (exige `--verbose` e `--include-partial-messages`), `system/api_retry` com `error` (`rate_limit`, `authentication_failed`...), `result`. **Visto com 2.1.284**: também `rate_limit_event` (status, `resetsAt`, uso das janelas de 5 h e 7 dias), `system/post_turn_summary`, `system/task_summary`, `system/thinking_tokens`; erro de API vem em `assistant.error` e `result.terminal_reason` | feito (M4.1) | | `--permission-prompt-tool` | 10.1 | Confirmado (`cli-reference`, `headless`). **Testado com 2.1.284**: chama a tool com `{tool_name, input, tool_use_id}` e segue `{"behavior":"allow","updatedInput":...}`. Pelo daemon e pelo app: permitir depois de 95 s funcionou; negar com nota fez o bot citar a nota e não usar a ferramenta | feito (M4.1) | | `description` na entrada do `Bash` e do `PowerShell` | 10.1 | Confirmado (`hooks`, entrada do `PreToolUse`): nas duas ferramentas, `description` é "Optional description of what the command does", ao lado de `command`, `timeout` e `run_in_background`. **Testado com 2.1.284** em 2026-09-30 (`-p` com uma tool de aprovação que só grava a entrada e nega; depois com a linha de comando de 7.4; Haiku 4.5, Sonnet 5.5 e o padrão do plano, Opus 5.5): a tool de aprovação recebe a mesma entrada do bloco `tool_use`, e os 32 pedidos de `Bash` e `PowerShell` vistos traziam `description`. Sem regra, o texto é curto e técnico ("Install requests package"); com o dono escrevendo em português, Haiku e Opus descreveram em português e o Sonnet em inglês, também pelo daemon. Com a regra de 10.1, os três escreveram em português, em palavras simples e com o porquê ("Instala o pacote requests, que ajuda o Python a acessar sites"). Pelo daemon e pelo app de dev (Sonnet, modo Manual): o cartão mostrou a explicação, "Ver o comando" abriu o script em heredoc inteiro, e permitir rodou esse comando; o log de `debug` não teve o texto. **O campo não é garantido**: em uso real com 2.1.284, dois bots em Sonnet fizeram 28 comandos sem `description` (o `tool_use` já vinha só com `command`), e os outros bots, quase todos em Opus, mandaram nos 15 que fizeram. A falta não se repetiu em teste, então não foi visto se a regra a evita: o cartão tem o caso sem explicação | feito; manual (PR): ver se bots antigos em Sonnet passam a explicar depois de reiniciar | @@ -1026,7 +1027,7 @@ Cada disparo vira uma `routine_run`, com o horário marcado (`scheduled_for`), u | `failed` | a delivery morreu (`dead`) ou o turno terminou com erro | | `skipped` | não disparou; `reason`: `overlap`, `bot_paused` ou `missed` | -- **Fim de uma execução:** o daemon já sabe quando o turno de uma message começa (o replay com o mesmo `uuid`, 9.1 passo 7). O `result` seguinte fecha esse turno: a execução vira `done`, ou `failed` se o `result` trouxer erro. Se o processo morrer antes de ler a message, a delivery volta para a fila (9.1 passo 8) e a execução continua `queued`; se morrer no meio do turno, a execução vira `failed`, porque ninguém mais vai fechar aquele turno. Pelo mesmo motivo, ao subir, o daemon marca `failed` as execuções cujo turno começou sob o daemon anterior. Uma delivery `dead` também leva a execução a `failed`. +- **Fim de uma execução:** o daemon já sabe quando o bot pega uma message (o replay com o mesmo `uuid`, 9.1 passo 7). O `result` seguinte fecha o turno em que ela entrou, também quando outra message entrou no mesmo turno: a execução vira `done`, ou `failed` se o `result` trouxer erro. Se o processo morrer antes de ler a message, a delivery volta para a fila (9.1 passo 8) e a execução continua `queued`; se morrer no meio do turno, a execução vira `failed`, porque ninguém mais vai fechar aquele turno. Pelo mesmo motivo, ao subir, o daemon marca `failed` as execuções cujo turno começou sob o daemon anterior. Uma delivery `dead` também leva a execução a `failed`. - **Sobreposição:** o horário chega com a execução anterior ainda `queued` (o bot está lento, parado por limite de uso, sem login ou fora do ar). - `skip` (padrão): registra `skipped` com `reason: overlap`. Um bot lento ou fora do ar não acumula pedidos repetidos. - `queue`: grava mesmo assim, mas só uma execução espera atrás da aberta; as outras viram `skipped`.