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

@ -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()