ask: rename ask_operator → ask + optional 'to' for agent-to-agent Q&A
This commit is contained in:
parent
87f8f8a123
commit
82b0877c47
21 changed files with 640 additions and 266 deletions
|
|
@ -37,7 +37,7 @@
|
|||
// ─── browser notifications ──────────────────────────────────────────────
|
||||
// Fires OS notifications on three operator-bound signals:
|
||||
// - new approval landed in the queue
|
||||
// - new operator question queued (ask_operator)
|
||||
// - new operator question queued (ask, target IS NULL)
|
||||
// - broker message sent `to: "operator"`
|
||||
// permission grant is per-browser; a localStorage "muted" toggle lets
|
||||
// the operator silence without revoking. Secure-context only (HTTPS /
|
||||
|
|
|
|||
|
|
@ -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:#}"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ mod manager_server;
|
|||
mod meta;
|
||||
mod migrate;
|
||||
mod operator_questions;
|
||||
mod questions;
|
||||
mod reminder_scheduler;
|
||||
mod server;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
128
hive-c0re/src/questions.rs
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue