Skip to content

Commit 49e60ef

Browse files
authored
Merge pull request #79 from httpsphl/perf/courier
perf(courier): sleep until the next job, and deliver as a bot gets ready
2 parents 7dfd36d + 089b319 commit 49e60ef

8 files changed

Lines changed: 146 additions & 18 deletions

File tree

‎crates/botloft-store/src/deliveries.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,5 +293,6 @@ impl Store {
293293
}
294294
}
295295

296+
mod courier;
296297
#[cfg(test)]
297298
mod tests;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//! What the courier asks to plan its sleep and to catch up with a bot that
2+
//! became ready (spec 9.1).
3+
4+
use botloft_core::ids::BotId;
5+
use rusqlite::params;
6+
7+
use crate::{Result, Store};
8+
9+
impl Store {
10+
/// When the courier has something to do next without being woken: the
11+
/// first delivery of a bot's queue comes due, a lease runs out or an
12+
/// open task passes its deadline. `None` when nothing waits for a time.
13+
pub fn courier_next_at(&self) -> Result<Option<i64>> {
14+
Ok(self
15+
.conn
16+
.prepare_cached(
17+
"SELECT MIN(at) FROM ( \
18+
SELECT MIN(d.next_attempt_at) AS at FROM deliveries AS d \
19+
WHERE d.state = 'pending' \
20+
AND d.rowid = (SELECT MIN(rowid) FROM deliveries \
21+
WHERE bot_id = d.bot_id AND state = 'pending') \
22+
UNION ALL \
23+
SELECT MIN(lease_until) FROM deliveries WHERE state = 'sending' \
24+
UNION ALL \
25+
SELECT MIN(deadline_at) FROM tasks WHERE status = 'open')",
26+
)?
27+
.query_row([], |row| row.get(0))?)
28+
}
29+
30+
/// A bot became ready: what waited only for that (never tried and
31+
/// failed) goes now instead of at its next check (spec 9.1). Deliveries
32+
/// that failed keep their backoff, so a message that makes the bot crash
33+
/// does not come straight back.
34+
pub fn hasten_deliveries(&self, bot: &BotId, now: i64) -> Result<usize> {
35+
Ok(self
36+
.conn
37+
.prepare_cached(
38+
"UPDATE deliveries SET next_attempt_at = ?2 \
39+
WHERE bot_id = ?1 AND state = 'pending' AND attempts = 0 AND next_attempt_at > ?2",
40+
)?
41+
.execute(params![bot.as_str(), now])?)
42+
}
43+
}

‎crates/botloftd/src/config.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ pub struct CourierConfig {
7575
impl Default for CourierConfig {
7676
fn default() -> Self {
7777
Self {
78-
poll_interval_ms: 500,
78+
poll_interval_ms: 10_000,
7979
lease_ms: 15_000,
8080
max_attempts: 8,
8181
retry_backoff_initial_ms: 2_000,

‎crates/botloftd/src/courier/mod.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
55
mod render;
66
mod settings;
7+
mod sleep;
78

89
use std::sync::Arc;
910
use std::time::Duration;
@@ -16,6 +17,7 @@ use tracing::{debug, warn};
1617

1718
use self::render::{Context, Rendered, render};
1819
pub use self::settings::CourierSettings;
20+
use self::sleep::{became_ready, hasten, next_wait};
1921
use crate::service::tasks;
2022
use crate::state::{Daemon, Event};
2123
use crate::{clock, routines};
@@ -45,13 +47,22 @@ impl Courier {
4547
}
4648

4749
/// Delivers until the daemon stops. Leases left by a previous run are
48-
/// recovered on the first cycle.
50+
/// recovered on the first cycle. Between cycles it sleeps until the next
51+
/// thing comes due, at most `poll_interval`; a new message, a finished
52+
/// send or a bot becoming ready wakes it sooner.
4953
pub async fn run(daemon: Arc<Daemon>) {
54+
let mut states = daemon.subscribe();
5055
loop {
5156
cycle(&daemon);
57+
let wait = next_wait(&daemon);
5258
tokio::select! {
53-
() = tokio::time::sleep(daemon.courier.settings.poll_interval) => {}
59+
() = tokio::time::sleep(wait) => {}
5460
() = daemon.courier.wake.notified() => {}
61+
ready = became_ready(&mut states) => {
62+
if let Some(bot) = ready {
63+
hasten(&daemon, &bot);
64+
}
65+
}
5566
}
5667
}
5768
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//! How long the courier sleeps between cycles, and catching up with a bot
2+
//! that became ready (spec 9.1).
3+
4+
use std::time::Duration;
5+
6+
use botloft_core::ids::BotId;
7+
use botloft_core::protocol::BotState;
8+
use tokio::sync::broadcast;
9+
use tracing::warn;
10+
11+
use crate::state::{Daemon, Event};
12+
13+
/// Shortest sleep between cycles, so a delivery that stays due (its
14+
/// prepare failed) cannot spin the courier.
15+
const MIN_WAIT: Duration = Duration::from_millis(250);
16+
17+
/// How long to sleep before the next cycle.
18+
pub(super) fn next_wait(daemon: &Daemon) -> Duration {
19+
let most = daemon.courier.settings.poll_interval;
20+
let next = match daemon.store().courier_next_at() {
21+
Ok(next) => next,
22+
Err(err) => {
23+
warn!("courier could not plan its next cycle: {err}");
24+
None
25+
}
26+
};
27+
let Some(at) = next else {
28+
return most;
29+
};
30+
let until = at.saturating_sub(daemon.clock.now_ms()).max(0);
31+
Duration::from_millis(u64::try_from(until).unwrap_or(0)).clamp(MIN_WAIT.min(most), most)
32+
}
33+
34+
/// The next bot that can take messages again (spec 9.1). `None` when
35+
/// changes were missed: a cycle catches up with all of them.
36+
pub(super) async fn became_ready(states: &mut broadcast::Receiver<Event>) -> Option<BotId> {
37+
loop {
38+
match states.recv().await {
39+
Ok(Event::BotState(change))
40+
if matches!(
41+
change.state,
42+
BotState::Idle | BotState::Busy | BotState::NeedsApproval
43+
) =>
44+
{
45+
return Some(change.bot_id);
46+
}
47+
Ok(_) => {}
48+
Err(broadcast::error::RecvError::Lagged(_)) => return None,
49+
Err(broadcast::error::RecvError::Closed) => std::future::pending().await,
50+
}
51+
}
52+
}
53+
54+
/// What waited only for `bot` to be ready goes in the next cycle, not at
55+
/// its next check.
56+
pub(super) fn hasten(daemon: &Daemon, bot: &BotId) {
57+
let now = daemon.clock.now_ms();
58+
if let Err(err) = daemon.store().hasten_deliveries(bot, now) {
59+
warn!(bot = %bot, "courier could not bring deliveries forward: {err}");
60+
}
61+
}

‎crates/botloftd/tests/courier.rs‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,7 @@ async fn an_owner_message_reaches_the_bot_as_its_user_and_is_read() {
7575
(&json!("owner"), &json!("Olá! Tudo bem?"))
7676
);
7777

78-
t.until_state(&bot, BotState::Idle).await;
79-
t.clock.advance(Duration::from_secs(5));
78+
// It waited for the bot to be ready, and went as soon as it was.
8079
let line = process.wait_lines(1).await.remove(0);
8180
assert_eq!(line["type"], "user");
8281
assert_eq!(
@@ -115,8 +114,6 @@ async fn messages_to_one_bot_arrive_in_order() {
115114
for body in ["one", "two", "three"] {
116115
send(&mut app, &bot, body).await;
117116
}
118-
t.until_state(&bot, BotState::Idle).await;
119-
t.clock.advance(Duration::from_secs(5));
120117
let lines = process.wait_lines(3).await;
121118
let texts: Vec<_> = lines.iter().map(text_of).collect();
122119
assert_eq!(texts, ["one", "two", "three"]);
@@ -162,9 +159,7 @@ async fn what_a_dead_process_never_read_goes_back_until_it_gives_up() {
162159
(&retried["state"], &retried["attempts"]),
163160
(&json!("pending"), &json!(0))
164161
);
165-
// The bot was still restarting at the retry: the courier waits 5 s.
166-
t.until_state(&bot, BotState::Idle).await;
167-
t.clock.advance(Duration::from_secs(5));
162+
// The bot was still restarting at the retry: it goes once it is ready.
168163
let fresh = t.process_of(&bot).await;
169164
assert_eq!(text_of(&fresh.wait_lines(1).await[0]), "hello");
170165
let again = app
@@ -260,3 +255,25 @@ async fn invalid_messages_are_refused() {
260255
.expect_err("missing");
261256
assert_eq!(err.code, NOT_FOUND);
262257
}
258+
259+
#[tokio::test]
260+
async fn a_message_that_waited_for_the_bot_goes_as_soon_as_it_is_ready() {
261+
let t = TestDaemon::start_supervised().await;
262+
let mut app = t.session().await;
263+
let bot = crew_and_bot(&mut app).await;
264+
t.until_state(&bot, BotState::Idle).await;
265+
let paused = |paused: bool| json!({ "botId": bot["id"], "paused": paused });
266+
app.call("bots.setPaused", paused(true))
267+
.await
268+
.expect("pause");
269+
send(&mut app, &bot, "hello").await;
270+
// The courier sees it and puts it off while the bot is paused.
271+
tokio::time::sleep(Duration::from_millis(100)).await;
272+
273+
app.call("bots.setPaused", paused(false))
274+
.await
275+
.expect("resume");
276+
// The manual clock never moves: the bot being ready is what it waited for.
277+
let process = t.runtime.process(2).await;
278+
assert_eq!(text_of(&process.wait_lines(1).await[0]), "hello");
279+
}

‎crates/botloftd/tests/delete_messages.rs‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
44
mod common;
55

6-
use std::time::Duration;
7-
8-
use botloft_core::protocol::BotState;
96
use common::Client;
107
use common::bots::{text_of, two_bots};
118
use serde_json::{Value, json};
@@ -133,8 +130,6 @@ async fn a_message_still_waiting_arrives_from_a_deleted_bot() {
133130
.expect("resume");
134131
// The lead's, the writer's first and the one the writer resumes in.
135132
let process = c.t.runtime.process(3).await;
136-
c.t.until_state(&writer, BotState::Idle).await;
137-
c.t.clock.advance(Duration::from_secs(5));
138133
let lines = process.wait_lines(1).await;
139134
assert_eq!(
140135
text_of(&lines[0]),

‎docs/spec.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ restart_backoff_max_ms = 300000
154154
fresh_start_if_dies_within_s = 15
155155

156156
[courier]
157-
poll_interval_ms = 500
157+
poll_interval_ms = 10000 # o maior intervalo entre ciclos do courier (9.1)
158158
lease_ms = 15000
159159
max_attempts = 8
160160
retry_backoff_initial_ms = 2000
@@ -422,9 +422,9 @@ O dono vê quantos tokens cada bot gasta: em cada turno, no chat, e somados por
422422
### 9.1 Fluxo
423423

424424
1. `messages.send` (owner) ou tool `send_message` (bot) grava `message`, `delivery` (`pending`) e o item `inbound` no chat do destinatário, numa transação. Anexos são gravados antes (9.5).
425-
2. O **courier** acorda a cada `poll_interval_ms` (e na hora, via notify, quando entra delivery nova ou termina um envio).
425+
2. O **courier** dorme até a próxima coisa com hora marcada: a primeira delivery da fila de algum bot vencer, um lease acabar ou uma task aberta passar do prazo; no mínimo 250 ms e no máximo `poll_interval_ms`. Acorda na hora, via notify, quando entra delivery nova ou termina um envio, e quando um bot passa a aceitar mensagens (`bot.state` em `idle`, `busy` ou `needs_approval`). Antes, acordava a cada 500 ms, parado ou não.
426426
3. Entrega em ordem, uma por vez por bot: de cada bot, só a delivery `pending` mais antiga pode sair, quando `next_attempt_at <= agora` e o bot não tem outra em `sending`. Ela vira `sending` com `lease_until`.
427-
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.
427+
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. Quando o bot passa a aceitar mensagens, as deliveries dele que nunca falharam (`attempts = 0`) vencem na hora, sem esperar esses 5 s; as que falharam mantêm o backoff, para uma mensagem que derruba o bot não voltar em seguida. Bot ou crew arquivados: a delivery vira `dead` na hora.
428428
5. Monta a mensagem (9.2) na hora do envio e escreve uma linha no stdin do processo, com timeout de 10 s.
429429
6. Escrita aceita: `sent`, com a generation do processo. Falha de escrita (processo saindo): volta para `pending` sem contar tentativa.
430430
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).

0 commit comments

Comments
 (0)