hive-sh4re + docs: extract LooseEnd wire-shape prose (#717 batch 4)

This commit is contained in:
iris 2026-05-31 15:59:43 +02:00 committed by mara
commit 22b3e542c5
2 changed files with 69 additions and 51 deletions

View file

@ -91,6 +91,58 @@ each id in a per-recipient in-memory set so the next `Recv` can tag
the row with `redelivered: true`. Idempotent + cheap when there's
nothing in flight, so the at-boot fire is unconditional.
### Loose-ends wire shape
`LooseEnd` is the per-row response shape for `GetLooseEnds` (both
the agent-flavour and manager-flavour requests). Tagged enum so
new thread kinds (forge PRs, long-running approvals from a
privileged bot, etc.) can land later without breaking existing
handlers. Each row carries enough context that the caller renders
it directly as a bulleted list, no follow-up fetch needed.
Per-flavour scoping is uniform across the three variants:
- **agent-flavour** `GetLooseEnds` only surfaces rows the calling
agent has standing in. `Approval` rows only appear when the
calling agent is the manager (sub-agents don't submit
approvals). `Question` rows surface where the agent is `asker`
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
the swarm — full audit view.
Per-variant fields:
- `Approval { id, agent, commit_ref, description?, age_seconds }`
`agent` is the affected agent (target of the spawn / config
commit), not the asker. `description` is the manager's free-text
blurb shown on the dashboard card. `commit_ref` is the
kind-specific payload (see `docs/approvals.md::Approval kinds
(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 }`
`due_at` is the absolute unix timestamp the scheduler is
targeting; clients compute time-until-fire as `due_at - now`.
`age_seconds` saturates at zero on any clock anomaly (back-step,
unsynchronised wall clock, etc.) so the bulleted list never
shows nonsense ages.
`CancelLooseEnd { kind, id }` is the matching write surface. The
`kind` enum (`Question` / `Reminder` / `Approval`) selects which
underlying store the dispatcher reaches into. `Question` and
`Reminder` cancel from either surface subject to ownership
checks (asker for the question, scheduler for the reminder).
`Approval` is manager-only — sub-agents don't submit approvals
so they have nothing of their own to withdraw; their wire
surface returns a clear error if they try. Cancelling an approval
transitions the row to `ApprovalStatus::Cancelled` and fires
`ApprovalResolved { status: "cancelled" }` so the dashboard pulls
the card out of the pending pane.
## Async forms
Dashboard + per-agent mutating forms carry `data-async`; a delegated

View file

@ -249,79 +249,50 @@ pub enum ReminderTiming {
}
/// One row in the response to `GetLooseEnds`. Tagged enum so new
/// thread kinds (forge PRs, long-running approvals from a privileged
/// bot, etc) can land later without breaking existing handlers. The
/// caller (claude in the agent harness) is expected to render these
/// as a short bulleted list — the per-row fields are all the context
/// needed without a follow-up fetch.
///
/// All three variants are cancellable via `CancelLooseEnd` /
/// `cancel_loose_end`. `Question` and `Reminder` can be cancelled
/// from either surface (subject to ownership checks); `Approval`
/// is manager-only since sub-agents can't submit approvals.
/// thread kinds can land without breaking existing handlers.
/// Per-flavour scoping + per-variant fields + clock-anomaly
/// saturation behaviour live in
/// `docs/conventions.md::Loose-ends wire shape`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LooseEnd {
/// A pending approval. For agent-flavour `GetLooseEnds` calls
/// this only surfaces when the agent itself is the manager
/// (sub-agents don't submit approvals). For manager-flavour calls
/// it lists every pending approval in the swarm. `agent` is the
/// affected agent (target of the spawn / config commit).
/// A pending approval row.
Approval {
id: i64,
agent: String,
commit_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Wall-clock seconds since `requested_at`. Saturates at zero on
/// any clock anomaly (back-step etc).
age_seconds: u64,
},
/// An unanswered question. For agent-flavour calls: only threads
/// where the agent is `asker` OR `target`. For manager-flavour
/// calls: every unanswered question in the swarm. `target = None`
/// means the question is addressed to the operator (dashboard
/// path); `Some(agent)` is a peer-to-peer thread.
/// An unanswered question row.
Question {
id: i64,
asker: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target: Option<String>,
question: String,
/// Wall-clock seconds since `asked_at`. Saturates at zero.
age_seconds: u64,
},
/// A scheduled but un-delivered reminder. For agent-flavour calls:
/// only the agent's own reminders (where `owner == self`). For
/// manager-flavour calls: every pending reminder in the swarm.
/// `owner` is the agent who scheduled it; `due_at` is the absolute
/// unix timestamp the scheduler is targeting.
/// A scheduled but un-delivered reminder row.
Reminder {
id: i64,
owner: String,
message: String,
due_at: i64,
/// Wall-clock seconds since the reminder was scheduled. Saturates
/// at zero on clock anomalies. (For time-until-fire, compute
/// `due_at - now` client-side from the wire timestamp.)
age_seconds: u64,
},
}
/// Kind discriminator for `CancelLooseEnd`. Maps to which underlying
/// store the dispatcher reaches into (`OperatorQuestions` /
/// `Broker::reminders` / `Approvals`). The `Approval` variant is
/// manager-only — sub-agents can't submit approvals so they have
/// nothing to withdraw (closes #250).
/// Kind discriminator for `CancelLooseEnd`. Per-kind store +
/// authorisation rules live in
/// `docs/conventions.md::Loose-ends wire shape`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CancelLooseEndKind {
Question,
Reminder,
/// Withdraw a pending approval (manager surface only). The row
/// transitions to `ApprovalStatus::Cancelled` and an
/// `ApprovalResolved` event fires so the dashboard pulls the card
/// out of the pending pane.
/// Withdraw a pending approval (manager surface only).
Approval,
}
@ -413,11 +384,9 @@ pub enum AgentRequest {
#[serde(default)]
file_path: Option<String>,
},
/// Loose-ends view: pending approvals + unanswered questions
/// pending against THIS agent. Approvals only surface if this
/// agent submitted them (which only ever happens for the
/// manager); questions surface where the agent is `asker` or
/// `target`. Cheap O(n) sweep server-side — no caching.
/// Loose-ends view: every pending row against THIS agent.
/// Per-flavour scoping in
/// `docs/conventions.md::Loose-ends wire shape`.
GetLooseEnds,
/// Count of this agent's pending (un-delivered) reminders. Used
/// by the harness's per-turn stats sink to snapshot "what was
@ -450,12 +419,9 @@ pub enum AgentRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Cancel an open thread the agent owns: a `Question` they asked
/// (returns `[cancelled by <self>]` as the answer to the asker)
/// or a `Reminder` they scheduled (hard-deletes the row).
/// Authorisation on the sub-agent surface: caller must own the
/// row. The manager surface uses the same wire variant but
/// accepts any id.
/// Cancel an open thread the agent owns. Authorisation +
/// per-kind semantics in
/// `docs/conventions.md::Loose-ends wire shape`.
CancelLooseEnd { kind: CancelLooseEndKind, id: i64 },
/// Mark every message popped since the last `AckTurn` as handled.
/// Harness↔broker pairing fired after `TurnOutcome::Ok`. See