hive-agent: guarantee a wake after a self-requested /compact

This commit is contained in:
damocles 2026-08-13 21:30:26 +02:00 committed by mara
commit 2c7872841a
8 changed files with 227 additions and 48 deletions

View file

@ -240,6 +240,21 @@ fn synthetic_todo_message(stern: bool) -> hive_sh4re::inbox::DeliveredMessage {
}
}
/// Synthetic message that drives the follow-up turn when a `/compact` call
/// (self-requested via the `compact` MCP tool's `wake_prompt` arg) finishes
/// and asked to be woken. Mirrors `synthetic_todo_message`'s "no broker row"
/// sentinel shape (`id = 0`) — the compact tool call itself is the durable
/// record that a wake was requested, not a broker message.
fn post_compact_wake_message(prompt: String) -> hive_sh4re::inbox::DeliveredMessage {
hive_sh4re::inbox::DeliveredMessage {
from: "compact".into(),
body: prompt,
id: 0,
redelivered: false,
in_reply_to: None,
}
}
/// Synthetic message that drives the single stop-checkpoint turn when c0re
/// signals a graceful stop. The agent gets one final turn to flush durable
/// `/state` before the container is stopped; new inbound is already fenced.
@ -772,8 +787,18 @@ async fn serve_loop<S: Surface>(
let compacted = turn::run_pending_compact(files, &bus, &session).await;
if !compacted {
tokio::time::sleep(interval).await;
continue;
}
continue;
// The compact that just ran may have carried a wake prompt
// (the agent's own `compact` tool, not the operator button —
// see `Bus::request_compact`). If so, drive it as a synthetic
// turn right now instead of looping back to `recv_next` and
// waiting for the next external event.
let Some(prompt) = bus.take_post_compact_wake() else {
continue;
};
tracing::debug!("post-compact wake queued, driving synthetic follow-up turn");
post_compact_wake_message(prompt)
}
RecvOutcome::TransportError => {
// `recv_next` already logged the detail; just retry.
@ -802,32 +827,83 @@ async fn serve_loop<S: Surface>(
return Ok(());
}
};
let ctrl = handle_turn::<S>(
let turn_ctx = TurnCtx {
socket,
&bus,
stats.as_ref(),
bus: &bus,
stats: stats.as_ref(),
files,
&session,
session: &session,
interrupted: &interrupted,
login_state: &login_state,
claude_dir: &claude_dir,
interval,
};
drive_turn_and_wake_chain::<S>(&turn_ctx, next, &mut todo_miss_streak).await;
}
}
/// Loop-invariant turn-driving context for `drive_turn_and_wake_chain`,
/// threaded as one bundle instead of double-digit positional args
/// (clippy's `too_many_arguments`). Everything here is constant for the
/// lifetime of one `serve_loop` call; only the message to drive and the
/// todo-miss streak vary per turn and stay as separate params.
struct TurnCtx<'a> {
socket: &'a Path,
bus: &'a Bus,
stats: Option<&'a TurnStats>,
files: &'a turn::TurnFiles,
session: &'a turn::AgentSession,
interrupted: &'a Arc<std::sync::atomic::AtomicBool>,
login_state: &'a Arc<Mutex<LoginState>>,
claude_dir: &'a Path,
interval: Duration,
}
/// Drive `next`, then keep driving synthetic follow-up turns for as long as
/// a compact that just ran carries a wake prompt
/// (`Bus::take_post_compact_wake`) — see `serve_loop`'s comment at the call
/// site for why this loops in place instead of returning to the outer
/// `select!`/`recv_next`. Ordinarily runs exactly one iteration; only chains
/// further if the follow-up turn itself requests another woken compact.
/// Split out of `serve_loop` purely to keep that function under clippy's
/// line limit.
async fn drive_turn_and_wake_chain<S: Surface>(
ctx: &TurnCtx<'_>,
mut next: hive_sh4re::inbox::DeliveredMessage,
todo_miss_streak: &mut u32,
) {
loop {
let ctrl = handle_turn::<S>(
ctx.socket,
ctx.bus,
ctx.stats,
ctx.files,
ctx.session,
next,
&interrupted,
ctx.interrupted,
)
.await;
apply_todo_wake_checked(ctrl.todo_wake_checked, &mut todo_miss_streak, &bus);
apply_todo_wake_checked(ctrl.todo_wake_checked, todo_miss_streak, ctx.bus);
if ctrl.auth_failed {
*login_state.lock().unwrap() = LoginState::NeedsLogin;
*ctx.login_state.lock().unwrap() = LoginState::NeedsLogin;
// Baseline the resume check on *this instant*, not on a
// directory snapshot taken after `wait_for_login` starts
// polling — closes the race where a login lands between the
// 401 and the first poll. See `wait_for_login`'s doc comment.
login::wait_for_login(
&claude_dir,
login_state.clone(),
&bus,
u64::try_from(interval.as_millis()).unwrap_or(2000),
ctx.claude_dir,
ctx.login_state.clone(),
ctx.bus,
u64::try_from(ctx.interval.as_millis()).unwrap_or(2000),
std::time::SystemTime::now(),
)
.await;
}
let Some(prompt) = ctx.bus.take_post_compact_wake() else {
break;
};
tracing::debug!("post-compact wake queued mid-turn-flow, driving synthetic follow-up turn");
next = post_compact_wake_message(prompt);
}
}