gate stale todo wakes on an emptiness check (#2678)

This commit is contained in:
damocles 2026-07-25 20:44:27 +02:00 committed by mara
commit 001ea38ea4
2 changed files with 65 additions and 3 deletions

View file

@ -500,22 +500,32 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
// acceptable since a from-scratch harness boot either has a writable
// harness dir or doesn't).
let todo_wake = Arc::new(tokio::sync::Notify::new());
match todos::Todos::open(&paths::state_db()) {
// Kept alongside `todo_wake` so the serve loop's `LocalTodo` arm can
// gate a wake on `has_any()` before spawning a turn — see its doc
// comment (the phantom-todo-wake issue: a burst of same-turn upserts
// can arm a second `Notify` permit that outlives the turn that
// already drained its payload).
let todos_store: Option<Arc<todos::Todos>> = match todos::Todos::open(&paths::state_db()) {
Ok(store) => {
let store = Arc::new(store);
let wake = todo_wake.clone();
let reminders = reminder_store.clone();
let bus_for_socket = bus.clone();
let store_for_socket = store.clone();
tokio::spawn(async move {
if let Err(e) = todo_server::run(store, wake, reminders, bus_for_socket).await {
if let Err(e) =
todo_server::run(store_for_socket, wake, reminders, bus_for_socket).await
{
tracing::error!(error = %e, "in-agent todo socket exited with error");
}
});
Some(store)
}
Err(e) => {
tracing::error!(error = ?e, "open todos db failed — in-agent todo socket disabled");
None
}
}
};
if matches!(initial, LoginState::NeedsLogin) {
login::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
} else {
@ -533,6 +543,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
stats,
&files,
todo_wake,
todos_store,
reminder_rx,
)
.await
@ -556,6 +567,7 @@ async fn serve_loop<S: Surface>(
stats: Option<TurnStats>,
files: &turn::TurnFiles,
todo_wake: Arc<tokio::sync::Notify>,
todos_store: Option<Arc<todos::Todos>>,
mut reminder_rx: tokio::sync::mpsc::UnboundedReceiver<hive_sh4re::DeliveredMessage>,
) -> Result<()> {
tracing::info!(socket = %socket.display(), "harness serve");
@ -586,6 +598,22 @@ async fn serve_loop<S: Surface>(
} {
RecvOutcome::Message(first) => first,
RecvOutcome::LocalTodo => {
// Gate on `has_any()` before spawning a turn: a burst of
// same-turn upserts can arm a second `Notify` permit that
// outlives the turn which already drained its payload
// (the phantom-todo-wake issue) — `notify_one` doesn't
// coalesce once the first permit's been consumed, so the surplus wake
// fires the instant the loop is back here even though
// there's nothing left to show. Fail open (drive a turn
// anyway) on a `has_any` error so a flaky sqlite read
// never silently swallows a real wake.
let has_any = todos_store
.as_ref()
.is_none_or(|store| store.has_any().unwrap_or(true));
if !has_any {
tracing::debug!("todo wake fired against an empty store — stale, skipping");
continue;
}
tracing::debug!("todo wake consumed, sending synthetic todo message");
synthetic_todo_message()
}