hive-c0re/hive-sh4re: remove the ask/answer wire protocol + core routing
This commit is contained in:
parent
46183795dd
commit
2850270829
23 changed files with 177 additions and 851 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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> },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue