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

@ -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(),
},
);
}