ask: rename ask_operator → ask + optional 'to' for agent-to-agent Q&A

This commit is contained in:
damocles 2026-05-17 12:10:49 +02:00
parent 87f8f8a123
commit 82b0877c47
21 changed files with 640 additions and 266 deletions

View file

@ -97,34 +97,7 @@ fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) -> AgentResponse {
let broker = &coord.broker;
match req {
AgentRequest::Send { to, body } => {
if let Err(message) = crate::limits::check_size("send", body) {
return AgentResponse::Err { message };
}
// Handle broadcast sends (recipient = "*")
if to == "*" {
let errors = coord.broadcast_send(agent, body);
if errors.is_empty() {
AgentResponse::Ok
} else {
AgentResponse::Err {
message: format!("broadcast failed for agents: {}", errors.join(", ")),
}
}
} else {
// Normal unicast send
match broker.send(&Message {
from: agent.to_owned(),
to: to.clone(),
body: body.clone(),
}) {
Ok(()) => AgentResponse::Ok,
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
}
AgentRequest::Send { to, body } => handle_send(coord, agent, to, body),
AgentRequest::Recv { wait_seconds } => match broker
.recv_blocking(agent, recv_timeout(*wait_seconds))
.await
@ -170,12 +143,32 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
message: format!("{e:#}"),
},
},
AgentRequest::AskOperator {
AgentRequest::Ask {
question,
options,
multi,
ttl_seconds,
} => handle_ask_operator(coord, agent, question, options, *multi, *ttl_seconds),
to,
} => crate::questions::handle_ask(
coord,
agent,
question,
options,
*multi,
*ttl_seconds,
to.as_deref(),
)
.map_or_else(
|message| AgentResponse::Err { message },
|id| AgentResponse::QuestionQueued { id },
),
AgentRequest::Answer { id, answer } => crate::questions::handle_answer(
coord, agent, *id, answer,
)
.map_or_else(
|message| AgentResponse::Err { message },
|()| AgentResponse::Ok,
),
AgentRequest::Remind {
message,
timing,
@ -184,36 +177,31 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
}
}
fn handle_ask_operator(
coord: &Arc<Coordinator>,
agent: &str,
question: &str,
options: &[String],
multi: bool,
ttl_seconds: Option<u64>,
) -> AgentResponse {
if let Err(message) = crate::limits::check_size("question", question) {
/// Common Send handler shared between dispatch arms. Applies the
/// 1 KiB body cap, then routes broadcast (`to == "*"`) vs unicast
/// through their respective broker calls. Pulled out of `dispatch`
/// to keep that function under the clippy too-many-lines limit; the
/// behaviour is identical to inlining.
fn handle_send(coord: &Arc<Coordinator>, agent: &str, to: &str, body: &str) -> AgentResponse {
if let Err(message) = crate::limits::check_size("send", body) {
return AgentResponse::Err { message };
}
let deadline_at = ttl_seconds.and_then(|s| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
i64::try_from(s).ok().map(|s| now + s)
});
match coord
.questions
.submit(agent, question, options, multi, deadline_at)
{
Ok(id) => {
tracing::info!(%id, %agent, ?deadline_at, "agent question queued");
if let Some(ttl) = ttl_seconds {
crate::manager_server::spawn_question_watchdog(coord, id, ttl);
if to == "*" {
let errors = coord.broadcast_send(agent, body);
return if errors.is_empty() {
AgentResponse::Ok
} else {
AgentResponse::Err {
message: format!("broadcast failed for agents: {}", errors.join(", ")),
}
AgentResponse::QuestionQueued { id }
}
};
}
match coord.broker.send(&Message {
from: agent.to_owned(),
to: to.to_owned(),
body: body.to_owned(),
}) {
Ok(()) => AgentResponse::Ok,
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},

View file

@ -171,7 +171,7 @@ impl Coordinator {
let socket_path = Self::socket_path(name);
// Hand the full Coordinator to the per-agent socket — it
// needs broker + operator_questions to handle the agent-side
// `ask_operator` tool, not just the broker.
// `ask` / `answer` tools, not just the broker.
let socket = agent_server::start(name, &socket_path, self.clone())?;
self.agents.lock().unwrap().insert(name.to_owned(), socket);
Ok(agent_dir)
@ -264,9 +264,9 @@ impl Coordinator {
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
/// body = JSON-encoded event). Used to route `OperatorAnswered`
/// events back to the agent that called `ask_operator`, not just
/// the manager.
/// body = JSON-encoded event). Used to route `QuestionAnswered`
/// events back to the agent that called `ask`, `QuestionAsked`
/// events to the target of a peer question, etc.
pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::HelperEvent) {
let body = match serde_json::to_string(event) {
Ok(s) => s,

View file

@ -160,10 +160,12 @@ struct StateSnapshot {
/// Last 30 resolved approvals (approved / denied / failed), newest-
/// first. Drives the "history" tab on the approvals section.
approval_history: Vec<ApprovalHistoryView>,
/// Pending operator questions (currently only from the manager).
/// `ask_operator` returns immediately with the id; on `/answer-question`
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
/// into the manager's inbox.
/// Pending operator-targeted questions (`target IS NULL`). Any
/// agent can `ask` the operator and `ask` returns immediately with
/// the id; on `/answer-question` we mark the row answered and
/// fire `HelperEvent::QuestionAnswered` back into the asker's
/// inbox. Peer-to-peer questions live in the same table but never
/// surface here (see `OperatorQuestions::pending`).
questions: Vec<crate::operator_questions::OpQuestion>,
/// Last 20 answered questions, newest-first.
question_history: Vec<crate::operator_questions::OpQuestion>,
@ -827,15 +829,20 @@ async fn post_answer_question(
if answer.is_empty() {
return error_response("answer: required");
}
match state.coord.questions.answer(id, answer) {
Ok((question, asker)) => {
match state
.coord
.questions
.answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT)
{
Ok((question, asker, _target)) => {
tracing::info!(%id, %asker, "operator answered question");
state.coord.notify_agent(
&asker,
&hive_sh4re::HelperEvent::OperatorAnswered {
&hive_sh4re::HelperEvent::QuestionAnswered {
id,
question,
answer: answer.to_owned(),
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
},
);
Redirect::to("/").into_response()
@ -845,8 +852,8 @@ async fn post_answer_question(
}
/// Resolve a pending operator question with a sentinel answer when
/// the operator decides not to / can't answer. The manager harness
/// receives an `OperatorAnswered` event with `answer = "[cancelled]"`
/// the operator decides not to / can't answer. The asker harness
/// receives a `QuestionAnswered` event with `answer = "[cancelled]"`
/// so it can fall back on whatever default it had. Same code path as
/// a real answer — just lets the operator close the loop instead of
/// letting the question dangle forever.
@ -855,15 +862,20 @@ async fn post_cancel_question(
AxumPath(id): AxumPath<i64>,
) -> Response {
const SENTINEL: &str = "[cancelled]";
match state.coord.questions.answer(id, SENTINEL) {
Ok((question, asker)) => {
match state
.coord
.questions
.answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT)
{
Ok((question, asker, _target)) => {
tracing::info!(%id, %asker, "operator cancelled question");
state.coord.notify_agent(
&asker,
&hive_sh4re::HelperEvent::OperatorAnswered {
&hive_sh4re::HelperEvent::QuestionAnswered {
id,
question,
answer: SENTINEL.to_owned(),
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
},
);
Redirect::to("/").into_response()

View file

@ -10,10 +10,10 @@
//! about it — oversized reminder bodies get persisted to disk
//! transparently and the inbox sees a pointer.
/// Per-message body cap. Applies to `send`, `ask_operator` question
/// text, and the stored inline form of a reminder. 1 KiB is small
/// enough that 100 unread messages don't dominate a wake prompt,
/// large enough for routine cross-agent chatter.
/// Per-message body cap. Applies to `send`, `ask` question text,
/// `answer` body, and the stored inline form of a reminder. 1 KiB
/// is small enough that 100 unread messages don't dominate a wake
/// prompt, large enough for routine cross-agent chatter.
pub const MESSAGE_MAX_BYTES: usize = 1024;
/// Validate that `body` fits under [`MESSAGE_MAX_BYTES`]. Returns a

View file

@ -23,6 +23,7 @@ mod manager_server;
mod meta;
mod migrate;
mod operator_questions;
mod questions;
mod reminder_scheduler;
mod server;

View file

@ -244,39 +244,30 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
},
}
}
ManagerRequest::AskOperator {
ManagerRequest::Ask {
question,
options,
multi,
ttl_seconds,
} => {
if let Err(message) = crate::limits::check_size("question", question) {
return ManagerResponse::Err { message };
}
tracing::info!(%question, ?options, multi, ?ttl_seconds, "manager: ask_operator");
let deadline_at = ttl_seconds.and_then(|s| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
i64::try_from(s).ok().map(|s| now + s)
});
match coord
.questions
.submit(MANAGER_AGENT, question, options, *multi, deadline_at)
{
Ok(id) => {
tracing::info!(%id, ?deadline_at, "operator question queued");
if let Some(ttl) = *ttl_seconds {
spawn_question_watchdog(coord, id, ttl);
}
ManagerResponse::QuestionQueued { id }
}
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),
},
}
to,
} => crate::questions::handle_ask(
coord,
MANAGER_AGENT,
question,
options,
*multi,
*ttl_seconds,
to.as_deref(),
)
.map_or_else(
|message| ManagerResponse::Err { message },
|id| ManagerResponse::QuestionQueued { id },
),
ManagerRequest::Answer { id, answer } => {
crate::questions::handle_answer(coord, MANAGER_AGENT, *id, answer).map_or_else(
|message| ManagerResponse::Err { message },
|()| ManagerResponse::Ok,
)
}
ManagerRequest::GetLogs { agent, lines } => {
let n = lines.unwrap_or(50);
@ -402,28 +393,41 @@ async fn submit_apply_commit(
Ok((id, sha))
}
/// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then
/// try to resolve the question with `[expired]`. If the operator (or
/// any other path) already answered it, `answer()` returns Err and
/// we no-op silently. Otherwise fire the usual `OperatorAnswered`
/// helper event so the manager sees a terminal state.
/// On `Ask { ttl_seconds: Some(n) }`, sleep n seconds and then try to
/// resolve the question with `[expired]`. If the operator (or any
/// other path) already answered it, `answer()` returns Err and we
/// no-op silently. Otherwise fire a `QuestionAnswered` helper event
/// with `answerer = "ttl-watchdog"` so the asker can distinguish a
/// real answer from a deadline trip without parsing the answer text.
const TTL_SENTINEL: &str = "[expired]";
/// Synthetic `answerer` label used when the ttl watchdog resolves a
/// question instead of a real human / agent. Lives in a distinct
/// namespace from agent names + the operator so the asker can pattern
/// match `event.answerer == "ttl-watchdog"`.
const TTL_ANSWERER: &str = "ttl-watchdog";
pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64) {
let coord = coord.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await;
// `answer` returns Err if already resolved — that's the
// normal path when the operator responded before the ttl
// fired, so no-op silently.
if let Ok((question, asker)) = coord.questions.answer(id, TTL_SENTINEL) {
tracing::info!(%id, %asker, "operator question expired (ttl)");
// Watchdog has its own answerer label so the authorisation
// check in `answer()` permits it for any target. We bypass
// the public `answer()` path by calling it with the operator
// identity, since the operator is always permitted; the
// event we fire carries the real watchdog label for observers.
if let Ok((question, asker, _target)) =
coord
.questions
.answer(id, TTL_SENTINEL, hive_sh4re::OPERATOR_RECIPIENT)
{
tracing::info!(%id, %asker, "question expired (ttl)");
coord.notify_agent(
&asker,
&hive_sh4re::HelperEvent::OperatorAnswered {
&hive_sh4re::HelperEvent::QuestionAnswered {
id,
question,
answer: TTL_SENTINEL.to_owned(),
answerer: TTL_ANSWERER.to_owned(),
},
);
}

View file

@ -1,7 +1,13 @@
//! Operator question queue. Manager submits via `AskOperator`; the
//! operator answers via the dashboard. The manager-socket handler long-polls
//! the store until the answer lands, so claude's `ask_operator` tool call
//! returns the answer directly as its result.
//! Question queue. Agents submit via `Ask`; the answer comes from
//! either the operator (via the dashboard, for `target IS NULL`) or
//! a peer agent (via `Answer`, for agent-to-agent questions).
//!
//! Despite the file name (kept for git history sanity), this table
//! now stores *all* asynchronous questions in the hive — both the
//! operator-targeted ones and the peer-to-peer ones. `target IS
//! NULL` is the operator path (back-compat with rows written before
//! the column existed); `target = '<agent-name>'` is the
//! agent-to-agent path.
use std::path::Path;
use std::sync::Mutex;
@ -38,6 +44,15 @@ fn ensure_columns(conn: &Connection) -> Result<()> {
"deadline_at",
"ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;",
),
// `target` = recipient of the question. NULL = operator
// (back-compat default for rows written before agent-to-agent
// questions existed); a non-null agent name = peer-to-peer
// question. Dashboard's `pending()` filters on `target IS NULL`
// so peer questions never leak into the operator's queue.
(
"target",
"ALTER TABLE operator_questions ADD COLUMN target TEXT;",
),
] {
let has: bool = conn
.prepare(&format!(
@ -67,6 +82,12 @@ pub struct OpQuestion {
pub deadline_at: Option<i64>,
pub answered_at: Option<i64>,
pub answer: Option<String>,
/// Recipient of the question. `None` = the operator (dashboard
/// path); `Some(<agent>)` = a peer agent asked via
/// `Ask { to: Some(<agent>), ... }`. Agent-to-agent questions
/// never appear in `pending()` so the operator's queue stays clean.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
pub struct OperatorQuestions {
@ -97,57 +118,89 @@ impl OperatorQuestions {
options: &[String],
multi: bool,
deadline_at: Option<i64>,
target: Option<&str>,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
conn.execute(
"INSERT INTO operator_questions
(asker, question, options_json, multi, deadline_at, asked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
(asker, question, options_json, multi, deadline_at, target, asked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
asker,
question,
options_json,
i64::from(multi),
deadline_at,
target,
now_unix(),
],
)?;
Ok(conn.last_insert_rowid())
}
/// Mark the question answered. Returns the original question text so the
/// Mark a pending question answered. Returns `(question, asker)`
/// so the caller can both echo the question back in a helper
/// event AND route that event to whichever agent originally
/// asked it.
pub fn answer(&self, id: i64, answer: &str) -> Result<(String, String)> {
/// Mark a pending question answered. `answerer` is who's actually
/// answering: `"operator"` for the dashboard path, or an agent's
/// own name when responding via `Answer`. Authorisation:
///
/// - Operator-targeted questions (`target IS NULL`) can only be
/// answered by `"operator"`. (Agents must not be able to spoof
/// answers to operator questions — the dashboard is the
/// privileged path.)
/// - Agent-targeted questions can only be answered by the
/// declared target agent, OR by `"operator"` (operator override
/// for stuck threads — useful when an agent is offline/down
/// and someone has to close the loop).
///
/// Returns `(question, asker, target)` so the caller can fire the
/// `QuestionAnswered` event with the right answerer label and route
/// it back to the original asker.
pub fn answer(
&self,
id: i64,
answer: &str,
answerer: &str,
) -> Result<(String, String, Option<String>)> {
let conn = self.conn.lock().unwrap();
let row: Option<(String, String, Option<i64>)> = conn
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
.query_row(
"SELECT question, asker, answered_at FROM operator_questions WHERE id = ?1",
"SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
params![id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()?;
let Some((question, asker, answered_at)) = row else {
let Some((question, asker, target, answered_at)) = row else {
bail!("question {id} not found");
};
if answered_at.is_some() {
bail!("question {id} already answered");
}
// Authorisation check: must match the target, or be the operator
// (operator-targeted questions are operator-only; the operator
// can additionally override agent-to-agent questions to close
// stuck threads).
let authorised = match target.as_deref() {
None => answerer == hive_sh4re::OPERATOR_RECIPIENT,
Some(t) => answerer == t || answerer == hive_sh4re::OPERATOR_RECIPIENT,
};
if !authorised {
bail!(
"question {id} not addressed to '{answerer}' (target = {:?})",
target.as_deref().unwrap_or(hive_sh4re::OPERATOR_RECIPIENT)
);
}
conn.execute(
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
params![answer, now_unix(), id],
)?;
Ok((question, asker))
Ok((question, asker, target))
}
#[allow(dead_code)]
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions WHERE id = ?1",
params![id],
row_to_question,
@ -156,12 +209,15 @@ impl OperatorQuestions {
.map_err(Into::into)
}
/// Pending operator-targeted questions only (`target IS NULL`).
/// Drives the dashboard's pending-question pane — agent-to-agent
/// questions never appear here so the operator's queue stays clean.
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions
WHERE answered_at IS NULL
WHERE answered_at IS NULL AND target IS NULL
ORDER BY id ASC",
)?;
let rows = stmt.query_map([], row_to_question)?;
@ -169,13 +225,15 @@ impl OperatorQuestions {
.map_err(Into::into)
}
/// Last `limit` answered questions, newest-first.
/// Last `limit` answered operator-targeted questions, newest-first.
/// Same `target IS NULL` filter as `pending()` so the dashboard's
/// history view only shows operator-relevant rows.
pub fn recent_answered(&self, limit: u64) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions
WHERE answered_at IS NOT NULL
WHERE answered_at IS NOT NULL AND target IS NULL
ORDER BY answered_at DESC
LIMIT ?1",
)?;
@ -199,6 +257,7 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
answered_at: row.get(6)?,
answer: row.get(7)?,
deadline_at: row.get(8)?,
target: row.get(9)?,
})
}

128
hive-c0re/src/questions.rs Normal file
View file

@ -0,0 +1,128 @@
//! Shared dispatch helpers for the `Ask` / `Answer` flow. Both the
//! agent socket and the manager socket call into here so the routing
//! semantics — recipient = operator vs. peer agent, answerer
//! authorisation, asker-notification — only live in one place.
//!
//! Routing rules at a glance:
//!
//! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`;
//! the dashboard's `pending()` query surfaces it; operator answers
//! via the dashboard.
//! - `Ask { to: Some(<agent>) }` → stored with `target = <agent>`;
//! a `HelperEvent::QuestionAsked` is pushed into `<agent>`'s
//! inbox so they can `Answer { id, answer }` on their own socket.
//! - `Answer { id, answer }` → permission-checked in
//! `OperatorQuestions::answer` (only the target agent or the
//! operator can answer; both paths fire the same
//! `QuestionAnswered` event to the asker).
use std::sync::Arc;
use crate::coordinator::Coordinator;
use crate::limits;
use crate::manager_server::spawn_question_watchdog;
/// Cap on how long an asker can demand an answer before the watchdog
/// auto-resolves with `[expired]`. Six hours mirrors typical agent
/// session lifetimes — beyond that an unanswered question is
/// effectively a dead thread and should be re-asked, not blocked on.
const MAX_TTL_SECONDS: u64 = 6 * 60 * 60;
/// Handle either surface's `Ask` request. Returns the queued
/// question id on success or a caller-ready error string. Caller is
/// responsible for wrapping in the matching `*Response::Err` /
/// `QuestionQueued` variant.
pub fn handle_ask(
coord: &Arc<Coordinator>,
asker: &str,
question: &str,
options: &[String],
multi: bool,
ttl_seconds: Option<u64>,
to: Option<&str>,
) -> Result<i64, String> {
limits::check_size("question", question)?;
// Normalise `Some("operator")` → None so the storage layer
// only has to think about NULL vs. non-NULL targets, not
// "is this string the operator?".
let target = match to {
None => None,
Some(t) if t == hive_sh4re::OPERATOR_RECIPIENT => None,
Some("") => {
return Err("ask: `to` cannot be empty (omit it for the operator path)".to_owned());
}
Some(t) if t == asker => {
return Err("ask: cannot ask yourself a question (would loop forever)".to_owned());
}
Some(t) => Some(t),
};
let ttl = ttl_seconds.map(|s| s.min(MAX_TTL_SECONDS));
let deadline_at = ttl.and_then(|s| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
i64::try_from(s).ok().map(|s| now + s)
});
let id = coord
.questions
.submit(asker, question, options, multi, deadline_at, target)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %asker, ?target, ?deadline_at, "question queued");
// Agent-targeted questions need to wake the recipient — drop a
// QuestionAsked event into their inbox so the answerer doesn't
// have to poll. Operator-targeted questions show up on the
// dashboard's pending pane via `pending()` instead.
if let Some(target_agent) = target {
coord.notify_agent(
target_agent,
&hive_sh4re::HelperEvent::QuestionAsked {
id,
asker: asker.to_owned(),
question: question.to_owned(),
options: options.to_vec(),
multi,
},
);
}
if let Some(t) = ttl {
spawn_question_watchdog(coord, id, t);
}
Ok(id)
}
/// Handle either surface's `Answer` request. Returns `Ok(())` on
/// success or a caller-ready error string. Authorisation lives in
/// `OperatorQuestions::answer` — we only have to wire the result
/// back to the asker as a `QuestionAnswered` event.
pub fn handle_answer(
coord: &Arc<Coordinator>,
answerer: &str,
id: i64,
answer: &str,
) -> Result<(), String> {
limits::check_size("answer", answer)?;
let (question, asker, _target) = coord
.questions
.answer(id, answer, answerer)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %answerer, %asker, "question answered");
coord.notify_agent(
&asker,
&hive_sh4re::HelperEvent::QuestionAnswered {
id,
question,
answer: answer.to_owned(),
answerer: answerer.to_owned(),
},
);
Ok(())
}
// Real coverage needs a `Coordinator` fixture (broker + sqlite +
// in-memory questions). Skipped for now — the normalisation branches
// in `handle_ask` are short enough to read line-by-line; once we add
// a coord test harness, drop integration tests here for: self-target
// rejection, operator-string passthrough, agent-to-agent QuestionAsked
// emission, and `Answer` authorisation.