hive-c0re/hive-sh4re: remove the ask/answer wire protocol + core routing

This commit is contained in:
damocles 2026-08-30 01:18:17 +02:00
commit 2850270829
23 changed files with 177 additions and 851 deletions

View file

@ -94,7 +94,7 @@ umount-old / mount-new / restart-cascade step.
| `request_init_config` (spawn a new child) | any agent, child added under self | | `request_init_config` (spawn a new child) | any agent, child added under self |
| config change via forge PR (any descendant's config) | any ancestor | | config change via forge PR (any descendant's config) | any ancestor |
| `get_logs` (any descendant) | any ancestor | | `get_logs` (any descendant) | any ancestor |
| moderate questions / reminders (cancel any open thread of a descendant) | any ancestor | | moderate reminders (cancel any open thread of a descendant) | any ancestor |
| `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else | | `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else |
| `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) | | `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) |
@ -149,16 +149,17 @@ other agents don't:
- **Scattered ownership checks** — a handful of independent - **Scattered ownership checks** — a handful of independent
manager-only overrides exist across `hive-c0re` today: loose-ends manager-only overrides exist across `hive-c0re` today: loose-ends
visibility (manager sees hive-wide, sub-agents only their own), visibility (manager sees hive-wide, sub-agents only their own),
"manager can cancel any question/reminder" overrides on the owner `destroy` refusing to act on the manager, and crash-watch skipping
check, `destroy` refusing to act on the manager, and crash-watch the manager (it auto-restarts via systemd instead of going through
skipping the manager (it auto-restarts via systemd instead of going the crash-watch loop). Each is planned to become an
through the crash-watch loop). Each is planned to become an
ancestor/descendant check instead of a manager-name check — see the ancestor/descendant check instead of a manager-name check — see the
module docs for `loose_ends.rs`, `stores/operator_questions.rs`, module docs for `loose_ends.rs`, `stores/broker.rs`, `actions.rs`,
`stores/broker.rs`, `actions.rs`, and `workers/crash_watch.rs` for and `workers/crash_watch.rs` for the current owner-check logic in
the current owner-check logic in each. (Reminder cancellation has each. (Question/answer routing and its own manager-override cancel
since moved fully in-agent — see the note on path — formerly `hive-c0re/src/questions.rs` and
`CancelLooseEndKind::Reminder` in `hive-c0re/src/questions.rs`.) `stores/operator_questions.rs` — has been removed entirely; reminder
cancellation is now handled fully in-agent, see the note on
`CancelLooseEndKind::Reminder` in `hive-c0re/src/socket_server/mod.rs`.)
None of the above is a stable interface — treat the module doc None of the above is a stable interface — treat the module doc
comments as the source of truth for exactly which checks exist today. comments as the source of truth for exactly which checks exist today.

View file

@ -598,12 +598,6 @@ renders only for the root agent. The root agent's approval-gating
behaviour comes from its CLAUDE.md / agent-specific instructions, not behaviour comes from its CLAUDE.md / agent-specific instructions, not
the system prompt template. the system prompt template.
Any agent (root or not) can also ask a structured question of the
operator or a peer agent via the `ask`/`answer` MCP tools, independent
of the approval flow above — see
`docs/conventions.md#question-routing-ask--answer` for the routing
rules, `ttl_seconds` expiry, and cancellation.
## Helper events to the submitting agent ## Helper events to the submitting agent
`Coordinator::notify_submitter(approval_id, &HelperEvent)` routes the `Coordinator::notify_submitter(approval_id, &HelperEvent)` routes the
@ -635,18 +629,6 @@ root agent. Variants (`hive_sh4re::manager::HelperEvent`):
- `NeedsUpdate { agent }` — sub-agent's recorded flake rev is - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is
stale. The root agent calls `update(name)` to rebuild — idempotent, stale. The root agent calls `update(name)` to rebuild — idempotent,
no approval required. no approval required.
- `QuestionAnswered { id, question, answer, answerer }` — peer
`Answer` request (answerer = agent name) or ttl watchdog expiry
(answerer = `"ttl-watchdog"`, answer = `"[expired]"`). The dashboard's
own `/answer-question/{id}` trigger (answerer = `"operator"`) is
gone — removed along with the rest of the dashboard's ask/answer
surface, so an operator-targeted question now has no path to an
answer at all.
- `QuestionAsked { id, asker, question, options, multi }`
fired when an agent calls `Ask { to: Some(<this-agent>), ... }`.
The recipient responds via `Answer { id, answer }` and the
asker sees the matching `QuestionAnswered`.
The remaining lower-urgency lifecycle notices — `Rebuilt`, `Killed`, The remaining lower-urgency lifecycle notices — `Rebuilt`, `Killed`,
`Destroyed`, `NeedsLogin`, `LoggedIn`, `ConfigReady` — are "FYI, check `Destroyed`, `NeedsLogin`, `LoggedIn`, `ConfigReady` — are "FYI, check
when convenient" events with no reason to drive an immediate turn, so when convenient" events with no reason to drive an immediate turn, so
@ -668,11 +650,11 @@ or the dashboard `↻ R3BU1LD` button when the lock didn't move). When set,
bootstrap container yields the exact tree that was referenced. bootstrap container yields the exact tree that was referenced.
To add a new lifecycle notice: if it needs to drive an immediate turn To add a new lifecycle notice: if it needs to drive an immediate turn
(a live conversation like `Ask`/`Answer`, or something genuinely (something genuinely urgent, like `ContainerCrash`), add a
urgent), add a `HelperEvent` variant + call sites + update `HelperEvent` variant + call sites + update `prompts/system.md`'s
`prompts/system.md`'s message-event list. If it's "FYI, check when message-event list. If it's "FYI, check when convenient," call
convenient," call `push_todo`/`push_todo_submitter` directly instead — `push_todo`/`push_todo_submitter` directly instead — no new wire type
no new wire type needed. needed.
## Auto-update on startup ## Auto-update on startup

View file

@ -171,46 +171,6 @@ cutoff so a later `AckTurn` doesn't double-update and a stale
redelivery tag can't outlive its row. The operator-side sibling is redelivery tag can't outlive its row. The operator-side sibling is
the dashboard's "mark all read" (unbounded, per-agent). the dashboard's "mark all read" (unbounded, per-agent).
### Question routing (Ask / Answer)
`AgentRequest::Ask` (and the manager-flavour mirror) surfaces a
structured question that either lands in the operator-question queue
or in a peer agent's inbox. The recipient is the `to` field:
- `to = None` or `to = Some("operator")` — routes to the
operator-question queue. ⚠️ The dashboard's whole ask/answer
surface (the UI that used to render this queue and let the operator
reply) has been removed — an operator-targeted question currently
has no reader anywhere; this routing case is being removed too as
the same effort continues. The legacy `AskOperator` variant
collapses into this case.
- `to = Some(<agent>)` — peer Q&A. The target agent receives a
`HelperEvent::QuestionAsked { id, asker, question, options, multi }`
in their inbox. They reply via `AgentRequest::Answer` (or
`ManagerRequest::Answer` if they're the manager); the answer
threads back to the asker as a `HelperEvent::QuestionAnswered`
event.
Shape fields are uniform across both targets:
- `options` is advisory — a list is never enforced; peer-agent
recipients see it in their `QuestionAsked` event and can return any
string regardless.
- `multi = true` is a hint that the answerer should treat `options` as
a multi-select rather than pick-one. The answer comes back as a
single string with selections joined by `", "`.
- `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer:
"ttl-watchdog"`) when the wait becomes moot. `None` = wait
indefinitely or until manual cancel.
Response shape is always `QuestionQueued { id }` — the asker stores
the id and correlates the asynchronous answer event when it lands.
Authorisation on `Answer`: only the question's `target` agent, or the
operator, is permitted to reply; an answer attempt from anyone else
fails the wire-side check. (The operator's only path to invoke this
was the now-removed dashboard — see the note above; the
authorisation rule itself is unchanged.)
### Loose-ends wire shape ### Loose-ends wire shape
`LooseEnd` is the per-row response shape for `GetLooseEnds` (both `LooseEnd` is the per-row response shape for `GetLooseEnds` (both
@ -220,15 +180,12 @@ privileged bot, etc.) can land later without breaking existing
handlers. Each row carries enough context that the caller renders handlers. Each row carries enough context that the caller renders
it directly as a bulleted list, no follow-up fetch needed. it directly as a bulleted list, no follow-up fetch needed.
Per-flavour scoping is uniform across the three variants: Per-flavour scoping is uniform across the two cancellable variants:
- **agent-flavour** `GetLooseEnds` only surfaces rows the calling - **agent-flavour** `GetLooseEnds` only surfaces rows the calling
agent has standing in. `Approval` rows only appear when the agent has standing in. `Approval` rows only appear when the
calling agent is the manager (sub-agents don't submit calling agent is the manager (sub-agents don't submit
approvals). `Question` rows surface where the agent is `asker` approvals). `Reminder` rows are scoped to `owner == self`.
OR `target` (the routing semantics from the Ask/Answer
subsection above). `Reminder` rows are scoped to `owner ==
self`.
- **manager-flavour** `GetLooseEnds` lists every pending row in - **manager-flavour** `GetLooseEnds` lists every pending row in
the swarm — full audit view. the swarm — full audit view.
@ -240,9 +197,6 @@ Per-variant fields:
blurb shown on the dashboard card. `commit_ref` is the blurb shown on the dashboard card. `commit_ref` is the
kind-specific payload (see `docs/approvals.md::Approval kinds kind-specific payload (see `docs/approvals.md::Approval kinds
(wire shapes)`). (wire shapes)`).
- `Question { id, asker, target?, question, age_seconds }`
`target = None` = operator-routed (dashboard); `Some(agent)` =
peer-to-peer thread.
- `Reminder { id, owner, message, due_at, age_seconds }` - `Reminder { id, owner, message, due_at, age_seconds }`
`due_at` is the absolute time the scheduler is targeting (RFC `due_at` is the absolute time the scheduler is targeting (RFC
3339 on the wire, see *Timestamps on the wire* below); clients 3339 on the wire, see *Timestamps on the wire* below); clients
@ -265,10 +219,9 @@ unsynchronised wall clock, etc.) so the bulleted list never
shows nonsense ages. shows nonsense ages.
`CancelLooseEnd { kind, id }` is the matching write surface. The `CancelLooseEnd { kind, id }` is the matching write surface. The
`kind` enum (`Question` / `Reminder` / `Approval`) selects which `kind` enum (`Reminder` / `Approval`) selects which underlying store
underlying store the dispatcher reaches into. `Question` and the dispatcher reaches into. `Reminder` cancels from either surface
`Reminder` cancel from either surface subject to ownership subject to an ownership check (the scheduling agent).
checks (asker for the question, scheduler for the reminder).
`Approval` is manager-only — sub-agents don't submit approvals `Approval` is manager-only — sub-agents don't submit approvals
so they have nothing of their own to withdraw; their wire so they have nothing of their own to withdraw; their wire
surface returns a clear error if they try. Cancelling an approval surface returns a clear error if they try. Cancelling an approval

View file

@ -20,8 +20,8 @@ keeps its state, purging it doesn't.**
Beyond that: Beyond that:
- **Approvals and questions are kept forever** — they're audit trails, - **Approvals are kept forever** — they're an audit trail, not a
not caches. Nothing about them ever ages out. cache. Nothing about them ever ages out.
- **Broker messages**: acked ones vacuum after 30 days; anything - **Broker messages**: acked ones vacuum after 30 days; anything
undelivered or delivered-but-not-yet-acked is always kept, however undelivered or delivered-but-not-yet-acked is always kept, however
old. old.
@ -55,20 +55,11 @@ power-intent registry:
⚠️ The `mcp__hyperhive__remind` queue is **not** here any more: it ⚠️ The `mcp__hyperhive__remind` queue is **not** here any more: it
moved to a harness-local, per-agent store as part of the moved to a harness-local, per-agent store as part of the
loose-ends-v2 migration — see [`/harness/` contents loose-ends-v2 migration — see [`/harness/` contents
below](#state-dirs-per-agent) for where reminders (and todos, and below](#state-dirs-per-agent) for where reminders (and todos)
the questions mirror) actually live now. actually live now.
- `approvals` — the queue. `agent / kind (merge_config_pr | spawn | - `approvals` — the queue. `agent / kind (merge_config_pr | spawn |
init_config | update_meta_inputs | schedule_prompt) / init_config | update_meta_inputs | schedule_prompt) /
commit_ref / requested_at / status / resolved_at / note`. commit_ref / requested_at / status / resolved_at / note`.
- `operator_questions``ask` / `answer` queue (despite the
table name, stores both operator-targeted + agent-to-agent
questions since the `ask` rename).
`asker / question / options_json / multi / asked_at /
deadline_at (ttl) / answered_at / answer / target`. `target IS
NULL` = operator path (dashboard); `target = '<agent>'` = peer
Q&A (`HelperEvent::QuestionAsked` pushed into target's inbox,
answered via `Answer` request). Migrated via `ALTER TABLE ADD
COLUMN` against `pragma_table_info`.
- `scheduled_prompts` — recurring + one-shot prompt queue. - `scheduled_prompts` — recurring + one-shot prompt queue.
`owner / body / interval_seconds (NULL = one-shot) / `owner / body / interval_seconds (NULL = one-shot) /
next_fire_at_unix / created_at_unix / source ("operator" or next_fire_at_unix / created_at_unix / source ("operator" or
@ -98,9 +89,8 @@ Retention:
rows are always kept — the harness `ack_turn`s only after a rows are always kept — the harness `ack_turn`s only after a
successful turn, so an unacked row can still be requeued via successful turn, so an unacked row can still be requeued via
`requeue_inflight` on a crash. `requeue_inflight` on a crash.
- Approvals and questions are kept indefinitely — both are - Approvals are kept indefinitely — an audit trail. `actions::destroy`
audit trails. `actions::destroy` and answered questions stay rows stay visible to anything that queries by id.
visible to anything that queries by id.
- Scheduled prompts: one-shot rows are deleted on fire by the - Scheduled prompts: one-shot rows are deleted on fire by the
worker; recurring rows live until the operator cancels them worker; recurring rows live until the operator cancels them
(`cancel_schedule` MCP / dashboard ✗) which tombstones via (`cancel_schedule` MCP / dashboard ✗) which tombstones via
@ -301,9 +291,9 @@ Under `/var/lib/hyperhive/agents/<name>/`:
hours; non-terminal (still-running) tasks are never deleted. This hours; non-terminal (still-running) tasks are never deleted. This
used to be a host-side `hive-c0re` vacuum, moved in-container for used to be a host-side `hive-c0re` vacuum, moved in-container for
the same privsep-ownership reason as the events vacuum above. the same privsep-ownership reason as the events vacuum above.
- `hyperhive-state.sqlite` — consolidated loose-ends-v2 store: todos, - `hyperhive-state.sqlite` — consolidated loose-ends-v2 store: todos
reminders, and a questions mirror, one small table each in a single and reminders, one small table each in a single file (in-container
file (in-container daemons — `hive-bash-daemon`, `hive-matrix-daemon`, daemons — `hive-bash-daemon`, `hive-matrix-daemon`,
`hive-forge-notify` — upsert keyed todos here over the harness's `hive-forge-notify` — upsert keyed todos here over the harness's
in-agent socket, `HIVE_AGENT_SOCKET`; the harness merges them into in-agent socket, `HIVE_AGENT_SOCKET`; the harness merges them into
`get_loose_ends` output and clears a row on `mark_todo_done`). `get_loose_ends` output and clears a row on `mark_todo_done`).

View file

@ -180,8 +180,6 @@ tools (Read, Write, etc.) keep their name as-is.
| **Core hyperhive** | | | **Core hyperhive** | |
| `send*` | rich renderer: `send* → to · NL` (default-open body) | | `send*` | rich renderer: `send* → to · NL` (default-open body) |
| `recv*` | `recv*()` · `recv* wait Ns` · `recv* max N` | | `recv*` | `recv*()` · `recv* wait Ns` · `recv* max N` |
| `ask*` | rich renderer: `ask* → to` (no inline answer form — see [Inline ask-operator answer](#inline-ask-operator-answer)) |
| `answer*` | rich renderer: `answer* #id` |
| `remind*` | `remind* +Xm "preview"` or `remind* at HH:MMZ "preview"` | | `remind*` | `remind* +Xm "preview"` or `remind* at HH:MMZ "preview"` |
| `set_status*` | `set_status* "text"` | | `set_status*` | `set_status* "text"` |
| `get_loose_ends*` | `get_loose_ends*()` or `get_loose_ends* [agent]` | | `get_loose_ends*` | `get_loose_ends*()` or `get_loose_ends* [agent]` |
@ -242,15 +240,6 @@ This keeps less-frequent tools that don't have a specific
`fmt_tool_use` case from dumping raw JSON. Common matrix and `fmt_tool_use` case from dumping raw JSON. Common matrix and
hyperhive tools have their own cases and skip this path. hyperhive tools have their own cases and skip this path.
## Inline ask-operator answer
An `mcp__hyperhive__ask(to: "operator", ...)` row has no inline
answer form in this terminal — it renders like any other tool call.
The dashboard's own question-surfacing UI that used to let the
operator answer from the Y3R C4LL tab is gone (hyperhive#3721); there
is currently no UI path to answer an operator-targeted `ask()` at
all.
## Dashboard side (not covered here) ## Dashboard side (not covered here)
The main dashboard's message-flow pane is a different The main dashboard's message-flow pane is a different

View file

@ -170,14 +170,11 @@ from the harness-local store (same effect as `cancel_loose_end(kind:
"todo")`, just from the web UI instead of the agent's own tool calls). "todo")`, just from the web UI instead of the agent's own tool calls).
The todos flyout is the only per-agent flyout — there is no separate The todos flyout is the only per-agent flyout — there is no separate
"loose-ends" or "tasks" list. There is also no inline answer form for "loose-ends" or "tasks" list. There used to be a note here about the
`ask` tool calls in this terminal: an `ask` renders like any other `ask`/`answer` MCP tools having no inline answer form in this
tool call (see terminal — that whole mechanism (the tools, the dashboard's questions
[`terminal-rendering.md`](../terminal-rendering.md#inline-ask-operator-answer)). pane, the wire protocol) has since been removed entirely, so there's
The dashboard's own Y3R C4LL questions pane that used to let the nothing left to render a form for.
operator answer from there is gone (hyperhive#3721) — an
operator-targeted `ask()` currently has no UI path to an answer at
all until the removal finishes or a replacement lands.
## Live view ## Live view

View file

@ -9,7 +9,7 @@ would hit. HTTP is the sole transport; there is no stdio mode here.
## When to use it ## When to use it
This is where the core hyperhive tool surface lives: `send`, `recv`, This is where the core hyperhive tool surface lives: `send`, `recv`,
`ask`/`answer`, `remind`, `get_loose_ends`, `set_status`, `remind`, `get_loose_ends`, `set_status`,
`get_agent_meta`, lifecycle (`kill`/`start`/`restart`/`update` on `get_agent_meta`, lifecycle (`kill`/`start`/`restart`/`update` on
direct children), scheduling, and the approval-request tools. Reach direct children), scheduling, and the approval-request tools. Reach
for this crate when you're adding or changing a built-in tool rather for this crate when you're adding or changing a built-in tool rather
@ -25,7 +25,7 @@ or their own daemon instead of living here.
in-agent socket the `hive-agent` harness serves. in-agent socket the `hive-agent` harness serves.
- **`client.rs`** — socket client to the hyperhive broker. - **`client.rs`** — socket client to the hyperhive broker.
- **`send_allow.rs`** — enforces the per-agent - **`send_allow.rs`** — enforces the per-agent
`hyperhive.allowedRecipients` allow-list on `send`/`ask`. `hyperhive.allowedRecipients` allow-list on `send`.
- **`paths.rs`** — socket + state path resolution shared with the - **`paths.rs`** — socket + state path resolution shared with the
harness's own `paths.rs` conventions. harness's own `paths.rs` conventions.

View file

@ -159,14 +159,13 @@ pub struct UpdateArgs {
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CancelLooseEndArgs { pub struct CancelLooseEndArgs {
/// Which kind of thread to cancel — `"question"` for an open /// Which kind of thread to cancel — `"reminder"` for a scheduled
/// `ask` that's still waiting on an answer, `"reminder"` for a /// `remind` that hasn't fired yet, `"approval"` for a pending
/// scheduled `remind` that hasn't fired yet, or `"todo"` for a /// approval you submitted, or `"todo"` for a loose-ends-v2 todo
/// loose-ends-v2 todo (bash/matrix/forge). Use the `kind` /// (bash/matrix/forge). Use the `kind` field straight off the
/// field straight off the `get_loose_ends` row. /// `get_loose_ends` row.
pub kind: String, pub kind: String,
/// Row id from the matching `get_loose_ends` entry (or the /// Row id from the matching `get_loose_ends` entry.
/// `question_queued` reply when you submitted it).
pub id: i64, pub id: i64,
} }

View file

@ -194,19 +194,6 @@ fn render_one_loose_end(out: &mut String, t: &hive_sh4re::inbox::LooseEnd) {
"- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}" "- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}"
); );
} }
hive_sh4re::inbox::LooseEnd::Question {
id,
asker,
target,
question,
age_seconds,
} => {
let to = target.as_deref().unwrap_or("operator");
let _ = writeln!(
out,
"- question #{id} ({asker} → {to}, {age_seconds}s old): {question}"
);
}
hive_sh4re::inbox::LooseEnd::Reminder { hive_sh4re::inbox::LooseEnd::Reminder {
id, id,
owner, owner,
@ -459,23 +446,20 @@ pub(super) fn parse_loose_end_kind(
raw: &str, raw: &str,
) -> Result<hive_sh4re::inbox::CancelLooseEndKind, String> { ) -> Result<hive_sh4re::inbox::CancelLooseEndKind, String> {
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
"question" | "q" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Question),
"reminder" | "r" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Reminder), "reminder" | "r" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Reminder),
"approval" | "a" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Approval), "approval" | "a" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Approval),
other => Err(format!( other => Err(format!(
"cancel_loose_end: unknown kind '{other}' \ "cancel_loose_end: unknown kind '{other}' \
(expected \"question\", \"reminder\", \"approval\", or \"todo\")" (expected \"reminder\", \"approval\", or \"todo\")"
)), )),
} }
} }
/// Canonical user-facing label for a `CancelLooseEndKind` — used in /// Canonical user-facing label for a `CancelLooseEndKind` — used in
/// the success ack so the caller always sees `"question"` / /// the success ack so the caller always sees `"reminder"` instead of
/// `"reminder"` instead of whatever alias they passed in (`"q"` / /// whatever alias they passed in (`"r"`).
/// `"r"`).
pub(super) fn loose_end_kind_label(kind: hive_sh4re::inbox::CancelLooseEndKind) -> &'static str { pub(super) fn loose_end_kind_label(kind: hive_sh4re::inbox::CancelLooseEndKind) -> &'static str {
match kind { match kind {
hive_sh4re::inbox::CancelLooseEndKind::Question => "question",
hive_sh4re::inbox::CancelLooseEndKind::Reminder => "reminder", hive_sh4re::inbox::CancelLooseEndKind::Reminder => "reminder",
hive_sh4re::inbox::CancelLooseEndKind::Approval => "approval", hive_sh4re::inbox::CancelLooseEndKind::Approval => "approval",
} }

View file

@ -2,7 +2,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look: Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look:
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at. - **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time. - **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time.
- **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. - **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`.
- **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`. - **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`.
@ -15,11 +15,11 @@ Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agen
Approval boundary: lifecycle ops on _existing_ direct children (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). _Creating_ a new agent (two-step: `request_init_config` + `request_apply_commit`) and _changing_ any agent's config (`request_apply_commit`) both go through the approval queue (requires `approvals` tool group). The operator only signs off on changes; you run the day-to-day. Approval boundary: lifecycle ops on _existing_ direct children (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). _Creating_ a new agent (two-step: `request_init_config` + `request_apply_commit`) and _changing_ any agent's config (`request_apply_commit`) both go through the approval queue (requires `approvals` tool group). The operator only signs off on changes; you run the day-to-day.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes: Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`. Use these to react to lifecycle changes:
- `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval. - `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval.
- `container_crash` — restart with `start(name)`. If it crashes again, ask the operator. - `container_crash` — restart with `start(name)`. If it crashes again, ask the operator.
- otherwise pick up answers to questions you asked. - `approval_resolved` — one of your own submitted approvals (`request_init_config`, `request_apply_commit`, `request_update_meta_inputs`, a scheduled prompt, …) was approved, denied, or failed; the body carries the resolution.
Lifecycle notices that don't need an immediate turn — a new agent spawned, its config repo seeded, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them. Lifecycle notices that don't need an immediate turn — a new agent spawned, its config repo seeded, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them.

View file

@ -104,9 +104,9 @@ async fn main() -> Result<()> {
// ---------- shared turn helpers ---------- // ---------- shared turn helpers ----------
/// Surface a `SYSTEM_SENDER` message in the live event bus + tracing /// Surface a `SYSTEM_SENDER` message in the live event bus + tracing
/// log. Both agents and the manager receive `QuestionAnswered`, /// log. Both agents and the manager receive `ContainerCrash`,
/// `ContainerCrash`, reparent notifications, and friends; the parse /// reparent notifications, and friends; the parse and log path is
/// and log path is identical. Quiet no-op when `from` isn't /// identical. Quiet no-op when `from` isn't
/// `SYSTEM_SENDER`. /// `SYSTEM_SENDER`.
fn log_system_event(bus: &Bus, from: &str, body: &str) { fn log_system_event(bus: &Bus, from: &str, body: &str) {
if from != SYSTEM_SENDER { if from != SYSTEM_SENDER {

View file

@ -255,7 +255,7 @@ shared closer
assert!(!rendered.contains("<!-- /role:")); assert!(!rendered.contains("<!-- /role:"));
// Shared tools appear. // Shared tools appear.
assert!(rendered.contains("mcp__hyperhive__recv")); assert!(rendered.contains("mcp__hyperhive__recv"));
assert!(rendered.contains("mcp__hyperhive__ask")); assert!(rendered.contains("mcp__hyperhive__send"));
} }
#[test] #[test]

View file

@ -254,14 +254,7 @@ fn enrich_tool_use_entry(entry: &mut Value) {
/// `_category: "rich"` so the client can distinguish without re-implementing /// `_category: "rich"` so the client can distinguish without re-implementing
/// the tool name list. /// the tool name list.
fn is_rich_tool(name: &str) -> bool { fn is_rich_tool(name: &str) -> bool {
matches!( matches!(name, "Edit" | "mcp__bash__run" | "mcp__hyperhive__send")
name,
"Edit"
| "mcp__bash__run"
| "mcp__hyperhive__send"
| "mcp__hyperhive__ask"
| "mcp__hyperhive__answer"
)
} }
/// Pre-compute the expandable body for rich tool entries. /// Pre-compute the expandable body for rich tool entries.
@ -271,7 +264,7 @@ fn is_rich_tool(name: &str) -> bool {
/// - `"diff"` → `api.detailsDiff` (colour-coded `+`/`-` lines) /// - `"diff"` → `api.detailsDiff` (colour-coded `+`/`-` lines)
/// - `"plain"` → `api.details` (plain `<pre>` block) /// - `"plain"` → `api.details` (plain `<pre>` block)
/// - `"markdown"` → `api.detailsOpenMd` (markdown rendered via marked + `DOMPurify`, /// - `"markdown"` → `api.detailsOpenMd` (markdown rendered via marked + `DOMPurify`,
/// default-open; used for message-bearing tools: send, ask, answer) /// default-open; used for message-bearing tools: `send`)
/// ///
/// Returns `None` for tools that have no body at all. /// Returns `None` for tools that have no body at all.
/// ///
@ -318,9 +311,7 @@ fn rich_tool_body(name: &str, input: &Value) -> Option<(String, &'static str)> {
Some((format!("$ {cmd}"), "plain")) Some((format!("$ {cmd}"), "plain"))
} }
} }
// Message-bearing tools: body is markdown text rendered by the client. // Message-bearing tool: body is markdown text rendered by the client.
// The ask form (operator reply slot) is still mounted client-side;
// only the raw body text moves to the backend here.
"mcp__hyperhive__send" => { "mcp__hyperhive__send" => {
let body = input.get("body").and_then(Value::as_str).unwrap_or(""); let body = input.get("body").and_then(Value::as_str).unwrap_or("");
if body.is_empty() { if body.is_empty() {
@ -329,22 +320,6 @@ fn rich_tool_body(name: &str, input: &Value) -> Option<(String, &'static str)> {
Some((body.to_owned(), "markdown")) Some((body.to_owned(), "markdown"))
} }
} }
"mcp__hyperhive__ask" => {
let q = input.get("question").and_then(Value::as_str).unwrap_or("");
if q.is_empty() {
None
} else {
Some((q.to_owned(), "markdown"))
}
}
"mcp__hyperhive__answer" => {
let a = input.get("answer").and_then(Value::as_str).unwrap_or("");
if a.is_empty() {
None
} else {
Some((a.to_owned(), "markdown"))
}
}
_ => None, _ => None,
} }
} }
@ -354,8 +329,6 @@ fn tool_icon(name: &str) -> &'static str {
match name { match name {
"mcp__hyperhive__send" => "📤", "mcp__hyperhive__send" => "📤",
"mcp__hyperhive__recv" => "📥", "mcp__hyperhive__recv" => "📥",
"mcp__hyperhive__ask" => "",
"mcp__hyperhive__answer" => "✍️",
"mcp__hyperhive__remind" => "", "mcp__hyperhive__remind" => "",
"mcp__hyperhive__set_status" => "🏷️", "mcp__hyperhive__set_status" => "🏷️",
"mcp__hyperhive__get_loose_ends" => "🪢", "mcp__hyperhive__get_loose_ends" => "🪢",
@ -470,10 +443,10 @@ fn fmt_builtin_tool(name: &str, short: &str, input: &Value) -> String {
} }
} }
/// Summary for message-bearing hyperhive tools (send / ask / answer). /// Summary for `send`, the one message-bearing hyperhive tool.
/// ///
/// Format: `"{short} → {recipient}"` or `"{short} #{id}"` with ` · NL` /// Format: `"{short} → {recipient}"` with ` · NL` appended when the body
/// appended when the body spans multiple lines. /// spans multiple lines.
fn fmt_hyperhive_message_tool(name: &str, short: &str, input: &Value) -> String { fn fmt_hyperhive_message_tool(name: &str, short: &str, input: &Value) -> String {
match name { match name {
"mcp__hyperhive__send" => { "mcp__hyperhive__send" => {
@ -485,30 +458,6 @@ fn fmt_hyperhive_message_tool(name: &str, short: &str, input: &Value) -> String
format!("{short}{to}") format!("{short}{to}")
} }
} }
"mcp__hyperhive__ask" => {
let to = input
.get("to")
.and_then(Value::as_str)
.unwrap_or("operator");
let lines = sv(input, "question").lines().count();
if lines > 1 {
format!("{short}{to} · {lines}L")
} else {
format!("{short}{to}")
}
}
"mcp__hyperhive__answer" => {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
let lines = sv(input, "answer").lines().count();
if lines > 1 {
format!("{short} #{id} · {lines}L")
} else {
format!("{short} #{id}")
}
}
_ => fmt_args_generic(short, input), _ => fmt_args_generic(short, input),
} }
} }
@ -516,9 +465,7 @@ fn fmt_hyperhive_message_tool(name: &str, short: &str, input: &Value) -> String
/// `mcp__hyperhive__*` tools. /// `mcp__hyperhive__*` tools.
fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String { fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String {
match name { match name {
"mcp__hyperhive__send" | "mcp__hyperhive__ask" | "mcp__hyperhive__answer" => { "mcp__hyperhive__send" => fmt_hyperhive_message_tool(name, short, input),
fmt_hyperhive_message_tool(name, short, input)
}
"mcp__hyperhive__recv" => { "mcp__hyperhive__recv" => {
let mut parts = Vec::new(); let mut parts = Vec::new();
if let Some(w) = input.get("wait_seconds").and_then(Value::as_u64) { if let Some(w) = input.get("wait_seconds").and_then(Value::as_u64) {

View file

@ -15,7 +15,6 @@ use crate::broker::Broker;
use crate::container_view::{self, ContainerView}; use crate::container_view::{self, ContainerView};
use crate::dashboard_events::DashboardEvent; use crate::dashboard_events::DashboardEvent;
use crate::job_queue::RunningTransient; use crate::job_queue::RunningTransient;
use crate::operator_questions::OperatorQuestions;
use crate::socket_server::{self, AgentSocket}; use crate::socket_server::{self, AgentSocket};
/// Capacity of the dashboard event channel. Slow browser subscribers /// Capacity of the dashboard event channel. Slow browser subscribers
@ -32,7 +31,6 @@ const LAST_STOPPED_RUNNING_KEY: &str = "last_stopped_running";
pub struct Coordinator { pub struct Coordinator {
pub broker: Arc<Broker>, pub broker: Arc<Broker>,
pub approvals: Arc<Approvals>, pub approvals: Arc<Approvals>,
pub questions: Arc<OperatorQuestions>,
/// Scheduled-prompts queue. One sqlite connection, /// Scheduled-prompts queue. One sqlite connection,
/// internal mutex; the worker drains due rows and the manager /// internal mutex; the worker drains due rows and the manager
/// handlers insert / cancel through the same handle. /// handlers insert / cancel through the same handle.
@ -467,7 +465,6 @@ impl Coordinator {
} = env; } = env;
let broker = Broker::open(db_path).context("open broker")?; let broker = Broker::open(db_path).context("open broker")?;
let approvals = Approvals::open(db_path).context("open approvals")?; let approvals = Approvals::open(db_path).context("open approvals")?;
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;
let scheduled_prompts = crate::scheduled_prompts::ScheduledPrompts::open(db_path) let scheduled_prompts = crate::scheduled_prompts::ScheduledPrompts::open(db_path)
.context("open scheduled_prompts")?; .context("open scheduled_prompts")?;
// BuildLogs wants a directory (it picks its own `build_logs.sqlite` // BuildLogs wants a directory (it picks its own `build_logs.sqlite`
@ -496,7 +493,6 @@ impl Coordinator {
Ok(Self { Ok(Self {
broker: Arc::new(broker), broker: Arc::new(broker),
approvals: Arc::new(approvals), approvals: Arc::new(approvals),
questions: Arc::new(questions),
scheduled_prompts: Arc::new(scheduled_prompts), scheduled_prompts: Arc::new(scheduled_prompts),
build_logs, build_logs,
audit_log, audit_log,
@ -971,9 +967,8 @@ impl Coordinator {
std::fs::create_dir_all(&agent_dir) std::fs::create_dir_all(&agent_dir)
.with_context(|| format!("create agent dir {}", agent_dir.display()))?; .with_context(|| format!("create agent dir {}", agent_dir.display()))?;
let socket_path = Self::socket_path(name); let socket_path = Self::socket_path(name);
// Hand the full Coordinator to the per-agent socket — it // Hand the full Coordinator to the per-agent socket — it needs
// needs broker + operator_questions to handle the agent-side // more than just the broker (approvals, scheduled_prompts, ...).
// `ask` / `answer` tools, not just the broker.
let socket = socket_server::start(name, &socket_path, self.clone())?; let socket = socket_server::start(name, &socket_path, self.clone())?;
self.agents.lock().unwrap().insert(name.to_owned(), socket); self.agents.lock().unwrap().insert(name.to_owned(), socket);
Ok(agent_dir) Ok(agent_dir)
@ -1336,17 +1331,15 @@ impl Coordinator {
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded /// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`, /// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
/// body = JSON-encoded event). Used to route `QuestionAnswered` /// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`.
/// 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::manager::HelperEvent) { pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::manager::HelperEvent) {
self.notify_agent_from(hive_sh4re::manager::SYSTEM_SENDER, agent, event); self.notify_agent_from(hive_sh4re::manager::SYSTEM_SENDER, agent, event);
} }
/// Same as `notify_agent` but with an explicit sender. Use this /// Same as `notify_agent` but with an explicit sender. Use this
/// when the event originates from a known agent or the operator /// when the event originates from a known agent or the operator
/// (e.g. `QuestionAnswered` — the answerer should be the `from`, /// rather than the system itself, so the recipient's terminal
/// not `system`) so the recipient's terminal shows the right name. /// shows the right name instead of `system`.
pub fn notify_agent_from( pub fn notify_agent_from(
&self, &self,
from: &str, from: &str,

View file

@ -29,7 +29,6 @@ mod meta;
mod migrate; mod migrate;
mod paths; mod paths;
mod priv_client; mod priv_client;
mod questions;
mod server; mod server;
mod snapshot_push; mod snapshot_push;
mod socket_server; mod socket_server;
@ -47,9 +46,7 @@ pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups
pub(crate) use stats::{ pub(crate) use stats::{
container_stats, hive_stats, host_stats, otel_metrics, sweep_health, warnings, container_stats, hive_stats, host_stats, otel_metrics, sweep_health, warnings,
}; };
pub(crate) use stores::{ pub(crate) use stores::{approvals, audit_log, broker, build_logs, db, power, scheduled_prompts};
approvals, audit_log, broker, build_logs, db, operator_questions, power, scheduled_prompts,
};
pub(crate) use workers::{ pub(crate) use workers::{
agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker, agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker,
}; };

View file

@ -1,276 +0,0 @@
//! 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`.
//! ⚠️ As of the ask/answer removal's dashboard-backend slice, nothing
//! surfaces or answers an operator-targeted row any more — the
//! dashboard's questions pane, its `/api/answer-question` /
//! `/api/cancel-question` endpoints, and the `pending_all()`/
//! `recent_answered_all()` reads that fed them are all gone. An
//! operator-targeted `ask()` (if anything still calls it — the MCP
//! tool itself was removed earlier in the same effort) would queue a
//! row nothing can ever resolve. Left as-is rather than special-cased,
//! since the whole `Ask`/`Answer` flow this file implements is itself
//! slated for removal next.
//! - `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::socket_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::manager::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::manager::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");
// Use answerer as the broker `from` so the asker's terminal shows
// the real name (agent or "operator") instead of "system".
coord.notify_agent_from(
answerer,
&asker,
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
id,
question,
answer: answer.to_owned(),
answerer: answerer.to_owned(),
},
);
Ok(())
}
/// Handle `CancelLooseEnd` from a per-agent socket. Dispatches by kind, each
/// with its own auth check: question / reminder cancels are ownership-only
/// (an agent cancels its own), and approval cancels require the `approvals`
/// tool-group (the grantable capability) AND ownership — the canceller must
/// be the approval's submitter — so no positional / hardcoded privilege and
/// no cross-agent cancellation. (The operator's cancel-anything path is a
/// separate handler.)
/// On question cancel, fires the `QuestionAnswered` event back to the asker
/// so the harness loop can react (mirrors the operator-cancel dashboard path).
pub fn handle_cancel_loose_end(
coord: &Arc<Coordinator>,
canceller: &str,
kind: hive_sh4re::inbox::CancelLooseEndKind,
id: i64,
) -> Result<(), String> {
match kind {
hive_sh4re::inbox::CancelLooseEndKind::Question => {
// Agent-socket path: never privileged — an agent may only cancel
// its own question (ownership). The operator's cancel-anything
// path goes through a separate handler with `privileged = true`.
let (question, asker, _target) = coord
.questions
.cancel(id, canceller, false)
.map_err(|e| format!("{e:#}"))?;
let sentinel = format!("[cancelled by {canceller}]");
tracing::info!(%id, %canceller, %asker, "question cancelled");
// Only notify the asker if they didn't cancel it themselves.
// Self-cancels are already known to the canceller — sending
// a QuestionAnswered back would cause the harness to process
// its own cancel as an incoming answer.
if asker != canceller {
coord.notify_agent_from(
canceller,
&asker,
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
id,
question,
answer: sentinel.clone(),
answerer: canceller.to_owned(),
},
);
}
Ok(())
}
hive_sh4re::inbox::CancelLooseEndKind::Reminder => {
// Reminders are now agent-local (in-container store) — the
// agent-mcp `cancel_loose_end` tool branches on this kind and
// dials the agent's own socket directly, never forwarding to
// hive-c0re. This arm should be unreachable in practice; kept
// only so the match stays exhaustive.
Err(format!(
"reminder {id}: reminders are handled locally by the agent, \
not by hive-c0re"
))
}
hive_sh4re::inbox::CancelLooseEndKind::Approval => {
// Withdrawing an approval needs the grantable `approvals`
// tool-group (held by any approval-submitting orchestrator)
// AND ownership: only the agent that submitted the approval
// may withdraw it. Without the ownership check, any
// approvals-group agent could cancel any other's approval by
// id. A NULL submitter (legacy row predating the column) is
// treated as operator-initiated (no agent tracking predates
// the column).
check_can_cancel_approval(canceller)?;
let submitter = coord
.approvals
.submitter_of(id)
.map_err(|e| format!("{e:#}"))?
.unwrap_or_else(|| "operator".to_owned());
if submitter != canceller {
return Err(format!(
"cancel_loose_end: approval {id} was submitted by {submitter}, \
not {canceller}; only the submitting agent can withdraw it"
));
}
let approval = coord
.approvals
.mark_cancelled(id, canceller)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
let sha_short = approval
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: approval.id,
agent: approval.agent.as_str(),
approval_kind: approval.kind.as_str(),
sha_short,
status: "cancelled",
note: approval.note,
description: approval.description,
});
Ok(())
}
}
}
/// Capability guard on the `Approval` cancel arm: the caller must hold the
/// `approvals` tool-group (the grantable capability for approval-submitting
/// orchestrators), checked server-side via `tool_groups::groups_for`. Pulled
/// out so the auth check has its own focused unit test — exercising the full
/// `handle_cancel_loose_end` flow would need a `Coordinator` fixture (broker +
/// sqlite + in-memory questions) we don't have. Keys on a grantable capability,
/// not a positional / hardcoded privilege.
fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
const APPROVALS_GROUP: &str = "approvals";
if crate::tool_groups::groups_for(canceller)
.iter()
.any(|g| g == APPROVALS_GROUP)
{
Ok(())
} else {
Err(
"cancel_loose_end: cancelling approval rows requires the `approvals` tool group"
.to_owned(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn approval_cancel_rejects_callers_without_the_approvals_group() {
// A caller that doesn't hold the `approvals` tool-group must not be
// able to cancel approval rows even if it invents an id. The guard is
// server-side so client cooperation is irrelevant — and it keys on a
// grantable capability (the tool-group), not on any agent name.
// `groups_for` of a name with no tool_groups.json entry is empty.
let err = check_can_cancel_approval("nobody-with-no-groups").unwrap_err();
assert!(err.contains("approvals` tool group"), "{err}");
}
}
// 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.

View file

@ -208,37 +208,12 @@ pub(crate) async fn dispatch_shared(
} }
hive_core_agent_sock::Request::Wake { from, body } => handle_wake(coord, agent, from, body), hive_core_agent_sock::Request::Wake { from, body } => handle_wake(coord, agent, from, body),
hive_core_agent_sock::Request::Recent { limit } => handle_recent(coord, agent, *limit), hive_core_agent_sock::Request::Recent { limit } => handle_recent(coord, agent, *limit),
hive_core_agent_sock::Request::Ask {
question,
options,
multi,
ttl_seconds,
to,
} => crate::questions::handle_ask(
coord,
agent,
question,
options,
*multi,
*ttl_seconds,
to.as_ref().map(hive_types::Ident::as_str),
)
.map_or_else(
|message| hive_core_agent_sock::Response::Err { message },
|id| hive_core_agent_sock::Response::QuestionQueued { id },
),
hive_core_agent_sock::Request::Answer { id, answer } => {
crate::questions::handle_answer(coord, agent, *id, answer).map_or_else(
|message| hive_core_agent_sock::Response::Err { message },
|()| hive_core_agent_sock::Response::Ok,
)
}
hive_core_agent_sock::Request::SetStatus { text } => handle_set_status(coord, text), hive_core_agent_sock::Request::SetStatus { text } => handle_set_status(coord, text),
hive_core_agent_sock::Request::GetAgentMeta { name } => { hive_core_agent_sock::Request::GetAgentMeta { name } => {
handle_get_agent_meta(coord, agent, name.as_ref()).await handle_get_agent_meta(coord, agent, name.as_ref()).await
} }
hive_core_agent_sock::Request::CancelLooseEnd { kind, id } => { hive_core_agent_sock::Request::CancelLooseEnd { kind, id } => {
crate::questions::handle_cancel_loose_end(coord, agent, *kind, *id).map_or_else( handle_cancel_loose_end(coord, agent, *kind, *id).map_or_else(
|message| hive_core_agent_sock::Response::Err { message }, |message| hive_core_agent_sock::Response::Err { message },
|()| hive_core_agent_sock::Response::Ok, |()| hive_core_agent_sock::Response::Ok,
) )
@ -790,6 +765,97 @@ fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str, target: Option<&
} }
} }
/// Handle `CancelLooseEnd` from a per-agent socket. Dispatches by kind, each
/// with its own auth check: reminder cancels are handled entirely in-container
/// (this arm should be unreachable in practice, kept only so the match stays
/// exhaustive) and approval cancels require the `approvals` tool-group (the
/// grantable capability) AND ownership — the canceller must be the approval's
/// submitter — so no positional / hardcoded privilege and no cross-agent
/// cancellation. (The operator's cancel-anything path is a separate handler.)
fn handle_cancel_loose_end(
coord: &Arc<Coordinator>,
canceller: &str,
kind: hive_sh4re::inbox::CancelLooseEndKind,
id: i64,
) -> Result<(), String> {
match kind {
hive_sh4re::inbox::CancelLooseEndKind::Reminder => {
// Reminders are now agent-local (in-container store) — the
// agent-mcp `cancel_loose_end` tool branches on this kind and
// dials the agent's own socket directly, never forwarding to
// hive-c0re. This arm should be unreachable in practice; kept
// only so the match stays exhaustive.
Err(format!(
"reminder {id}: reminders are handled locally by the agent, \
not by hive-c0re"
))
}
hive_sh4re::inbox::CancelLooseEndKind::Approval => {
// Withdrawing an approval needs the grantable `approvals`
// tool-group (held by any approval-submitting orchestrator)
// AND ownership: only the agent that submitted the approval
// may withdraw it. Without the ownership check, any
// approvals-group agent could cancel any other's approval by
// id. A NULL submitter (legacy row predating the column) is
// treated as operator-initiated (no agent tracking predates
// the column).
check_can_cancel_approval(canceller)?;
let submitter = coord
.approvals
.submitter_of(id)
.map_err(|e| format!("{e:#}"))?
.unwrap_or_else(|| "operator".to_owned());
if submitter != canceller {
return Err(format!(
"cancel_loose_end: approval {id} was submitted by {submitter}, \
not {canceller}; only the submitting agent can withdraw it"
));
}
let approval = coord
.approvals
.mark_cancelled(id, canceller)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
let sha_short = approval
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: approval.id,
agent: approval.agent.as_str(),
approval_kind: approval.kind.as_str(),
sha_short,
status: "cancelled",
note: approval.note,
description: approval.description,
});
Ok(())
}
}
}
/// Capability guard on the `Approval` cancel arm: the caller must hold the
/// `approvals` tool-group (the grantable capability for approval-submitting
/// orchestrators), checked server-side via `tool_groups::groups_for`. Pulled
/// out so the auth check has its own focused unit test — exercising the full
/// `handle_cancel_loose_end` flow would need a `Coordinator` fixture (broker +
/// sqlite). Keys on a grantable capability, not a positional / hardcoded
/// privilege.
fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
const APPROVALS_GROUP: &str = "approvals";
if crate::tool_groups::groups_for(canceller)
.iter()
.any(|g| g == APPROVALS_GROUP)
{
Ok(())
} else {
Err(
"cancel_loose_end: cancelling approval rows requires the `approvals` tool group"
.to_owned(),
)
}
}
/// Resolve the target agent name for a *named* `GetLooseEnds` query. Rules: /// Resolve the target agent name for a *named* `GetLooseEnds` query. Rules:
/// ///
/// - `None` (or `Some(caller)`) → the caller's own threads (always allowed). /// - `None` (or `Some(caller)`) → the caller's own threads (always allowed).
@ -1077,43 +1143,18 @@ async fn handle_get_logs(agent: &str, lines: Option<u32>) -> Response {
} }
} }
/// On `Ask { ttl_seconds: Some(n) }`, sleep n seconds and then try to #[cfg(test)]
/// resolve the question with `[expired]`. If the operator (or any mod tests {
/// other path) already answered it, `answer()` returns Err and we use super::*;
/// 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) { #[test]
let coord = coord.clone(); fn approval_cancel_rejects_callers_without_the_approvals_group() {
tokio::spawn(async move { // A caller that doesn't hold the `approvals` tool-group must not be
tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await; // able to cancel approval rows even if it invents an id. The guard is
// Watchdog has its own answerer label so the authorisation // server-side so client cooperation is irrelevant — and it keys on a
// check in `answer()` permits it for any target. We bypass // grantable capability (the tool-group), not on any agent name.
// the public `answer()` path by calling it with the operator // `groups_for` of a name with no tool_groups.json entry is empty.
// identity, since the operator is always permitted; the let err = check_can_cancel_approval("nobody-with-no-groups").unwrap_err();
// event we fire carries the real watchdog label for observers. assert!(err.contains("approvals` tool group"), "{err}");
if let Ok((question, asker, _target)) = }
coord
.questions
.answer(id, TTL_SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT)
{
tracing::info!(%id, %asker, "question expired (ttl)");
coord.notify_agent(
&asker,
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
id,
question,
answer: TTL_SENTINEL.to_owned(),
answerer: TTL_ANSWERER.to_owned(),
},
);
}
});
} }

View file

@ -1,14 +1,12 @@
//! Sqlite-backed host-side stores (broker, approval / question / //! Sqlite-backed host-side stores (broker, approval / schedule queues,
//! schedule queues, build logs, audit trail, power intent) plus the //! build logs, audit trail, power intent) plus the shared connection
//! shared connection open/migration helper (`db`). Each submodule is //! open/migration helper (`db`). Each submodule is re-exported at the
//! re-exported at the crate root, so `crate::broker::…` etc. keep //! crate root, so `crate::broker::…` etc. keep working unchanged.
//! working unchanged.
pub mod approvals; pub mod approvals;
pub mod audit_log; pub mod audit_log;
pub mod broker; pub mod broker;
pub mod build_logs; pub mod build_logs;
pub mod db; pub mod db;
pub mod operator_questions;
pub mod power; pub mod power;
pub mod scheduled_prompts; pub mod scheduled_prompts;

View file

@ -1,216 +0,0 @@
//! Question queue. Agents submit via `Ask`; the answer comes from
//! either the operator (for `target IS NULL`) or a peer agent (via
//! `Answer`, for agent-to-agent questions). ⚠️ The dashboard no longer
//! has any UI or endpoint for the operator to actually answer a
//! `target IS NULL` row (removed along with the rest of the dashboard's
//! question surface) — see `questions.rs`'s module doc for the current
//! state of that gap.
//!
//! 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;
use anyhow::{Context, Result, bail};
use chrono::Utc;
use rusqlite::{Connection, OptionalExtension, params};
use crate::db::Migration;
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS operator_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asker TEXT NOT NULL,
question TEXT NOT NULL,
options_json TEXT NOT NULL,
asked_at INTEGER NOT NULL,
answered_at INTEGER,
answer TEXT
);
CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
ON operator_questions (id) WHERE answered_at IS NULL;
";
/// Ordered schema migrations tracked in `schema_versions` (key
/// `"operator_questions"`). Legacy databases are detected via the `target`
/// column — the last column added before versioning — and fast-forwarded
/// past all known migrations.
const MIGRATIONS: &[Migration] = &[
// v1: `multi` — checkbox-style multi-option questions.
Migration {
sql: "ALTER TABLE operator_questions ADD COLUMN \
multi INTEGER NOT NULL DEFAULT 0",
adds_column: Some(("operator_questions", "multi")),
},
// v2: `deadline_at` — optional TTL after which the watchdog auto-resolves.
Migration {
sql: "ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER",
adds_column: Some(("operator_questions", "deadline_at")),
},
// v3: `target` — recipient of the question. NULL = operator (back-compat
// default); non-null = peer-to-peer question.
Migration {
sql: "ALTER TABLE operator_questions ADD COLUMN target TEXT",
adds_column: Some(("operator_questions", "target")),
},
];
pub struct OperatorQuestions {
conn: Mutex<Connection>,
}
impl OperatorQuestions {
pub fn open(path: &Path) -> Result<Self> {
let conn = crate::db::open(path, "operator_questions")?;
conn.execute_batch(SCHEMA)
.context("apply operator_questions schema")?;
crate::db::apply_versioned_migrations(&conn, "operator_questions", MIGRATIONS)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn submit(
&self,
asker: &str,
question: &str,
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, target, asked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
asker,
question,
options_json,
i64::from(multi),
deadline_at,
target,
Utc::now().timestamp(),
],
)?;
Ok(conn.last_insert_rowid())
}
/// Mark a pending question answered. `answerer` is who's actually
/// answering: `"operator"`, 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 — though as of the dashboard's
/// ask/answer surface being removed, nothing currently calls
/// this with `answerer = "operator"` for a `target IS NULL` row
/// at all; the check stays as a guard, not a live 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<String>, Option<i64>)> = conn
.query_row(
"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.get(3)?)),
)
.optional()?;
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::manager::OPERATOR_RECIPIENT,
Some(t) => answerer == t || answerer == hive_sh4re::manager::OPERATOR_RECIPIENT,
};
if !authorised {
bail!(
"question {id} not addressed to '{answerer}' (target = {:?})",
target
.as_deref()
.unwrap_or(hive_sh4re::manager::OPERATOR_RECIPIENT)
);
}
conn.execute(
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
params![answer, Utc::now().timestamp(), id],
)?;
Ok((question, asker, target))
}
/// Cancel a pending question on behalf of `canceller`. Returns
/// `(question, asker, target)` so the caller can fire the usual
/// `QuestionAnswered` event to the asker with a `[cancelled by
/// <canceller>]` sentinel.
///
/// Auth: the canceller must be one of:
/// - the original asker (an agent withdrawing their own ask),
/// - the operator (already covered by the existing `answer` path
/// but allowed here too for symmetry / dashboard cancel),
/// - a `privileged` caller (one that arrived on the manager socket —
/// privileged hive-wide cleanup; derived from the socket, not a
/// name match).
///
/// Not the target — that's covered by `answer` (responding with
/// an actual reply, sentinel or otherwise).
pub fn cancel(
&self,
id: i64,
canceller: &str,
privileged: bool,
) -> Result<(String, String, Option<String>)> {
let conn = self.conn.lock().unwrap();
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
.query_row(
"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.get(3)?)),
)
.optional()?;
let Some((question, asker, target, answered_at)) = row else {
bail!("question {id} not found");
};
if answered_at.is_some() {
bail!("question {id} already answered/cancelled");
}
let authorised = privileged
|| canceller == asker
|| canceller == hive_sh4re::manager::OPERATOR_RECIPIENT;
if !authorised {
bail!("question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')");
}
let sentinel = format!("[cancelled by {canceller}]");
conn.execute(
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
params![sentinel, Utc::now().timestamp(), id],
)?;
Ok((question, asker, target))
}
}

View file

@ -6,8 +6,8 @@
//! Stored as the `agent_power` table in the coordinator DB //! Stored as the `agent_power` table in the coordinator DB
//! (`/var/lib/hyperhive/db/broker.sqlite`, one tiny row per agent) — //! (`/var/lib/hyperhive/db/broker.sqlite`, one tiny row per agent) —
//! same one-file-many-modules pattern as `approvals` / //! same one-file-many-modules pattern as `approvals` /
//! `operator_questions` / `scheduled_prompts`, each with its own //! `scheduled_prompts`, each with its own connection. Intent persists
//! connection. Intent persists across hive-c0re restarts; in-flight //! across hive-c0re restarts; in-flight
//! queue work deliberately does not. Setting `wanted` is never a //! queue work deliberately does not. Setting `wanted` is never a
//! queued node: operator/intent actions update the row synchronously //! queued node: operator/intent actions update the row synchronously
//! at request time, then submit the DAG whose terminal `Reconcile` //! at request time, then submit the DAG whose terminal `Reconcile`

View file

@ -63,25 +63,6 @@ pub enum Request {
/// Non-mutating — pulls from the broker without delivering. The /// Non-mutating — pulls from the broker without delivering. The
/// per-agent web UI uses this to render its own inbox section. /// per-agent web UI uses this to render its own inbox section.
Recent { limit: u64 }, Recent { limit: u64 },
/// Surface a question to either the operator or another agent.
/// Routing + shape: see
/// `docs/conventions.md::Question routing (Ask / Answer)`.
Ask {
question: String,
#[serde(default)]
options: Vec<String>,
#[serde(default)]
multi: bool,
#[serde(default)]
ttl_seconds: Option<u64>,
#[serde(default)]
to: Option<Ident>,
},
/// Answer a question previously routed to this agent via
/// `HelperEvent::QuestionAsked`. Authorised callers + threading
/// back via `HelperEvent::QuestionAnswered`: see
/// `docs/conventions.md::Question routing (Ask / Answer)`.
Answer { id: i64, answer: String },
/// Loose-ends view. On the agent socket: `None` = self; direct /// Loose-ends view. On the agent socket: `None` = self; direct
/// children are always accessible; non-children require the /// children are always accessible; non-children require the
/// `query_agent_state` capability — rejected with an error otherwise; /// `query_agent_state` capability — rejected with an error otherwise;
@ -282,9 +263,6 @@ pub enum Response {
Acked { count: u64 }, Acked { count: u64 },
/// `Recent` result: newest-first inbox rows. /// `Recent` result: newest-first inbox rows.
Recent { rows: Vec<InboxRow> }, Recent { rows: Vec<InboxRow> },
/// `Ask` result: the queued question id. The answer lands later
/// as `HelperEvent::QuestionAnswered` in this agent's inbox.
QuestionQueued { id: i64 },
/// `GetLooseEnds` result: list of loose ends pending against /// `GetLooseEnds` result: list of loose ends pending against
/// this agent. Ordered newest-first within each kind. /// this agent. Ordered newest-first within each kind.
LooseEnds { loose_ends: Vec<LooseEnd> }, LooseEnds { loose_ends: Vec<LooseEnd> },

View file

@ -134,15 +134,6 @@ pub enum LooseEnd {
description: Option<String>, description: Option<String>,
age_seconds: u64, age_seconds: u64,
}, },
/// An unanswered question row.
Question {
id: i64,
asker: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target: Option<String>,
question: String,
age_seconds: u64,
},
/// A scheduled but un-delivered reminder row. /// A scheduled but un-delivered reminder row.
Reminder { Reminder {
id: i64, id: i64,
@ -203,7 +194,6 @@ pub enum LooseEnd {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum CancelLooseEndKind { pub enum CancelLooseEndKind {
Question,
Reminder, Reminder,
/// Withdraw a pending approval (manager surface only). /// Withdraw a pending approval (manager surface only).
Approval, Approval,

View file

@ -85,27 +85,6 @@ pub enum HelperEvent {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>, note: Option<String>,
}, },
/// A question queued via `Ask` was answered. `id` matches the
/// originating `QuestionQueued.id`; `answerer` is `"operator"` /
/// a peer agent name / `"ttl-watchdog"` on expiry.
QuestionAnswered {
id: i64,
question: String,
answer: String,
answerer: String,
},
/// A peer (or the manager) asked this agent a question. Recipient
/// replies via `Answer { id, answer }`; the answer routes back to
/// the asker as `QuestionAnswered`.
QuestionAsked {
id: i64,
asker: String,
question: String,
#[serde(default)]
options: Vec<String>,
#[serde(default)]
multi: bool,
},
} }
/// Submission payload for `RequestSchedulePrompt`. Lives outside the /// Submission payload for `RequestSchedulePrompt`. Lives outside the