diff --git a/docs/conventions.md b/docs/conventions.md index c92bcb56..a15b4a83 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -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 diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 4544b0ad..0ec71a67 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -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, - /// 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, 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, }, - /// 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, }, - /// Cancel an open thread the agent owns: a `Question` they asked - /// (returns `[cancelled by ]` 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