hyperhive/hive-agent/src/questions.rs

235 lines
8.1 KiB
Rust

//! Harness-local questions mirror — the second increment of the
//! loose-ends-v2 migration's questions phase (see the design comment on
//! the tracking issue). c0re stays the `Ask`/`Answer` routing + delivery
//! rendezvous (mara's "2a" call); this store only holds the *durable "I
//! still owe/am owed a reply" view* `get_loose_ends` renders, so that view
//! survives a hive migration the same way todos/reminders already do.
//!
//! One row per outstanding question **from this agent's point of view**,
//! keyed by the c0re-assigned question id (globally unique — an agent is
//! never both asker and target of the same question, self-asks are
//! rejected at the c0re layer):
//!
//! - `role = "asked"`: this agent called `ask()`; `peer` is the target
//! (`"operator"` when asked with `to: None`). Cleared when the
//! `question_answered` system event for `id` arrives (see `main.rs`'s
//! inbound-event hook).
//! - `role = "answering"`: this agent received a `question_asked` system
//! event for `id`; `peer` is the asker. Cleared when this agent calls
//! `answer()` for `id` (see `hive-agent-mcp`'s tool impl).
//!
//! Known gap: if the asker cancels their own question, the target is not
//! proactively notified today (`hive-c0re::questions::handle_cancel_loose_end`
//! only notifies a *different* canceller than the asker, which never
//! happens via the ownership-gated agent-socket cancel path). A target's
//! `answering` row is cleared the next time they call `answer()` — c0re's
//! "already answered"/"not found" rejection is treated as resolved-without-
//! us and clears the mirror row (see `hive-agent-mcp`'s `answer()` tool) —
//! but if they never call `answer()` at all, the row lingers with no
//! proactive nudge. Flagged on the tracking issue rather than fully fixed.
use std::path::Path;
use std::sync::Mutex;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY,
role TEXT NOT NULL,
peer TEXT NOT NULL,
question TEXT NOT NULL,
asked_at INTEGER NOT NULL
);
";
/// Which side of the question this agent is on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// This agent asked; `peer` is who it's waiting on.
Asked,
/// This agent was asked; `peer` is who's waiting on it.
Answering,
}
impl Role {
const fn as_str(self) -> &'static str {
match self {
Role::Asked => "asked",
Role::Answering => "answering",
}
}
fn parse(s: &str) -> Option<Self> {
match s {
"asked" => Some(Role::Asked),
"answering" => Some(Role::Answering),
_ => None,
}
}
}
/// One mirrored question row.
#[derive(Debug, Clone)]
pub struct QuestionMirror {
pub id: i64,
pub role: Role,
pub peer: String,
pub question: String,
pub asked_at: DateTime<Utc>,
}
/// The harness-local questions mirror. Same sharing/locking shape as
/// [`crate::reminders::Reminders`] — cheap behind an `Arc`, short sqlite
/// writes guarded by a `Mutex`.
pub struct Questions {
conn: Mutex<Connection>,
}
impl Questions {
/// Open (creating if needed) the questions mirror at `path`.
///
/// # Errors
///
/// Propagates sqlite open / schema-apply failures.
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)
.with_context(|| format!("open questions db {}", path.display()))?;
conn.execute_batch(SCHEMA)
.context("apply questions schema")?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Record a new outstanding row. `INSERT OR REPLACE` so a caller that
/// re-observes the same id (e.g. a redelivered `question_asked`) is a
/// harmless no-op rather than a unique-constraint error.
///
/// # Errors
///
/// Propagates the sqlite insert failure.
///
/// # Panics
///
/// Panics if the connection mutex is poisoned.
pub fn record(&self, id: i64, role: Role, peer: &str, question: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO questions (id, role, peer, question, asked_at) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, role.as_str(), peer, question, Utc::now().timestamp()],
)?;
Ok(())
}
/// Drop the mirror row for `id` (either role) — the question resolved
/// (answered/cancelled) from this agent's side. Returns the number of
/// rows removed (0 = no local row for that id, a harmless no-op).
///
/// # Errors
///
/// Propagates the sqlite delete failure.
///
/// # Panics
///
/// Panics if the connection mutex is poisoned.
pub fn clear(&self, id: i64) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n = conn.execute("DELETE FROM questions WHERE id = ?1", params![id])?;
Ok(n)
}
/// List every mirrored row, oldest-asked first — for `get_loose_ends`
/// rendering. A row whose `role` column doesn't parse (corruption —
/// should never happen via this module's own writes) is logged and
/// skipped rather than either propagating a hard error (which would
/// hide every other, valid row) or silently misattributing it as
/// `Asked` (which would lie about who owes whom a reply).
///
/// # Errors
///
/// Propagates the sqlite prepare / query / column-read failures.
///
/// # Panics
///
/// Panics if the connection mutex is poisoned.
pub fn list(&self) -> Result<Vec<QuestionMirror>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, role, peer, question, asked_at FROM questions ORDER BY asked_at ASC",
)?;
let mut out = Vec::new();
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let role_str: String = row.get(1)?;
let Some(role) = Role::parse(&role_str) else {
tracing::warn!(%id, %role_str, "questions mirror: unknown role, skipping row");
continue;
};
let asked_at_secs: i64 = row.get(4)?;
out.push(QuestionMirror {
id,
role,
peer: row.get(2)?,
question: row.get(3)?,
asked_at: chrono::DateTime::from_timestamp(asked_at_secs, 0)
.unwrap_or_else(Utc::now),
});
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (tempfile::TempDir, Questions) {
let dir = tempfile::tempdir().unwrap();
let db = Questions::open(&dir.path().join("questions.sqlite")).unwrap();
(dir, db)
}
#[test]
fn record_and_list_both_roles() {
let (_dir, s) = store();
s.record(1, Role::Asked, "atlas", "are we there yet")
.unwrap();
s.record(2, Role::Answering, "mara", "is this fine")
.unwrap();
let rows = s.list().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id, 1);
assert_eq!(rows[0].role, Role::Asked);
assert_eq!(rows[0].peer, "atlas");
assert_eq!(rows[1].id, 2);
assert_eq!(rows[1].role, Role::Answering);
assert_eq!(rows[1].peer, "mara");
}
#[test]
fn clear_removes_the_row() {
let (_dir, s) = store();
s.record(1, Role::Asked, "atlas", "q").unwrap();
assert_eq!(s.clear(1).unwrap(), 1);
assert!(s.list().unwrap().is_empty());
assert_eq!(s.clear(1).unwrap(), 0, "already-cleared id is a no-op");
assert_eq!(s.clear(999).unwrap(), 0, "unknown id is a no-op");
}
#[test]
fn record_is_idempotent_on_redelivery() {
let (_dir, s) = store();
s.record(1, Role::Answering, "mara", "q").unwrap();
s.record(1, Role::Answering, "mara", "q").unwrap();
assert_eq!(
s.list().unwrap().len(),
1,
"re-observing the same id doesn't duplicate"
);
}
}