feat(#2635): wire harness-local questions mirror (inc2 pt2)

This commit is contained in:
damocles 2026-07-23 21:10:51 +02:00 committed by mara
commit e5ef5a72be
8 changed files with 465 additions and 191 deletions

View file

@ -84,21 +84,82 @@ async fn main() -> Result<()> {
/// `ContainerCrash`, reparent notifications, and friends; the parse
/// and log path is identical. Quiet no-op when `from` isn't
/// `SYSTEM_SENDER`.
fn log_system_event(bus: &Bus, from: &str, body: &str) {
///
/// Also keeps the harness-local questions mirror in sync on the two
/// question-shaped events: `QuestionAsked` (a peer/manager asked *this*
/// agent — mirror an `Answering` row) and
/// `QuestionAnswered` (a question *this* agent asked got a reply —
/// clear the mirrored `Asked` row). Best-effort loopback dial of the
/// in-agent socket (see `todo_server::dial`'s docs on why a dial beats
/// threading an `Arc<Questions>` through this whole call chain) — a
/// dial failure is a missed mirror update, not a turn failure, so it's
/// logged and swallowed rather than propagated.
async fn log_system_event(bus: &Bus, from: &str, body: &str) {
if from != SYSTEM_SENDER {
return;
}
let parsed = serde_json::from_str::<HelperEvent>(body).ok();
if let Some(event) = parsed {
if let Some(event) = &parsed {
tracing::info!(?event, "helper event");
} else {
tracing::info!(%from, %body, "system message");
}
match parsed {
Some(HelperEvent::QuestionAsked {
id,
asker,
question,
..
}) => {
mirror_question(hive_agent_sock::Request::RecordAnsweringQuestion {
id,
asker,
question,
})
.await;
}
Some(HelperEvent::QuestionAnswered { id, .. }) => {
mirror_question(hive_agent_sock::Request::ClearQuestion { id }).await;
}
_ => {}
}
bus.emit(LiveEvent::Note {
text: format!("[system] {body}"),
});
}
/// Open the harness-local questions mirror against the consolidated state
/// db — same open-alongside shape as the reminders store
/// (`reminders::Reminders::open`) opened just above this call site in
/// `serve_main`, sharing the same file (distinct table, see
/// `questions::SCHEMA`). `None` on open failure disables question ops the
/// same way a failed reminders open disables reminder ops
/// (`no_questions_store` in `todo_server`). Pulled out to its own fn to
/// keep `serve_main` under the pedantic line-count lint.
fn open_question_store() -> Option<Arc<questions::Questions>> {
match questions::Questions::open(&paths::state_db()) {
Ok(store) => Some(Arc::new(store)),
Err(e) => {
tracing::error!(error = ?e, "open questions db failed — question mirror disabled");
None
}
}
}
/// Fire-and-forget helper for `log_system_event`'s questions-mirror dial:
/// logs a warning on dial failure / an `Err` response, otherwise silent.
async fn mirror_question(req: hive_agent_sock::Request) {
match todo_server::dial(&req).await {
Some(hive_agent_sock::Response::Err { message }) => {
tracing::warn!(%message, ?req, "questions mirror dial returned an error");
}
None => {
tracing::warn!(?req, "questions mirror dial failed (socket unavailable?)");
}
Some(_) => {}
}
}
/// Body string for the turn-failure notification we route to
/// `<parent>` on `TurnError::Failed`. Reads the hive-qualified
/// identity so the receiver sees `agent@hive` rather than relying on
@ -393,6 +454,56 @@ impl Surface for AgentSurface {
// ---------- generic turn loop ----------
/// Opens the todos store and spawns the in-agent todo socket (loose-ends
/// v2 + harness-local reminders + questions): the harness owns the todo +
/// reminder + question stores locally and serves the in-container
/// producers on `HIVE_AGENT_SOCKET`. A new/changed todo upsert fires the
/// returned `Notify` so the serve loop drives a turn directly — no broker
/// round-trip, no marker files. Best-effort: if the todos store can't
/// open, the whole socket isn't served (reminder + question ops ride
/// along on the same listener, so they're gated on the same store —
/// acceptable since a from-scratch harness boot either has a writable
/// harness dir or doesn't). Split out of `serve_main` to keep it under
/// clippy's `too_many_lines` limit; kept alongside the returned `Notify`
/// 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).
fn spawn_todo_socket(
reminder_store: Option<Arc<reminders::Reminders>>,
question_store: Option<Arc<questions::Questions>>,
bus: &Bus,
) -> (Arc<tokio::sync::Notify>, Option<Arc<todos::Todos>>) {
let todo_wake = Arc::new(tokio::sync::Notify::new());
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 bus_for_socket = bus.clone();
let store_for_socket = store.clone();
tokio::spawn(async move {
if let Err(e) = todo_server::run(
store_for_socket,
wake,
reminder_store,
question_store,
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
}
};
(todo_wake, todos_store)
}
/// Boot — wires up the web UI, login state, stats, plugins, forge
/// notifier, and either drops into `serve_loop` directly (`Online`) or
/// parks on the login flow first (`NeedsLogin`). See
@ -491,42 +602,9 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
}
};
tokio::spawn(reminder_timer::run(reminder_store.clone(), reminder_tx));
// In-agent todo socket (loose-ends v2 + harness-local reminders): the
// harness owns the todo + reminder stores locally and serves the
// in-container producers on `HIVE_AGENT_SOCKET`. A new/changed todo
// upsert fires `todo_wake` so the serve loop drives a turn directly —
// no broker round-trip, no marker files. Best-effort: if the todos
// store can't open, the whole socket isn't served (reminder ops ride
// along on the same listener, so they're gated on the same store —
// 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());
// 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_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
}
};
let question_store = open_question_store();
let (todo_wake, todos_store) =
spawn_todo_socket(reminder_store.clone(), question_store.clone(), &bus);
if matches!(initial, LoginState::NeedsLogin) {
login::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
} else {
@ -690,7 +768,7 @@ async fn handle_turn<S: Surface>(
let body = first.body;
let redelivered = first.redelivered;
let msg_id = first.id;
log_system_event(bus, &from, &body);
log_system_event(bus, &from, &body).await;
tracing::info!(%from, %body, %redelivered, "inbox");
let unread = S::inbox_unread(socket).await;
bus.emit(LiveEvent::TurnStart {