approvals: manager can withdraw pending approvals (closes #250)

This commit is contained in:
damocles 2026-05-27 11:57:27 +02:00 committed by Mara
commit c1f27e3b7b
8 changed files with 182 additions and 16 deletions

View file

@ -63,6 +63,21 @@ happens after a decision lands.
ApplyCommit kind) land in the manager's inbox, carrying both ApplyCommit kind) land in the manager's inbox, carrying both
the canonical sha and the terminal tag. the canonical sha and the terminal tag.
### Withdrawing a pending approval
The manager can call `cancel_loose_end(kind: "approval", id)` to
withdraw an approval that hasn't been acted on yet (closes #250).
The row transitions to `ApprovalStatus::Cancelled` (distinct from
`Denied`/`Failed`), the dashboard pulls the card out of the
pending pane, and `ApprovalResolved { status: "cancelled" }` fires
on the manager + dashboard channels. Approvals that have already
been approved/denied/failed return an error — the resolution is
final once the operator (or a lifecycle failure) acted on the row.
Sub-agent surface refuses the `approval` kind with a clear error:
sub-agents don't submit approvals, so they have nothing of their
own to withdraw. Manager-only.
`InitConfig` approvals are the first step in a two-step spawn `InitConfig` approvals are the first step in a two-step spawn
flow. On approve, hive-c0re seeds the proposed config repo with flow. On approve, hive-c0re seeds the proposed config repo with
a default `agent.nix` template and sends the manager a default `agent.nix` template and sends the manager

View file

@ -656,9 +656,11 @@ code {
.status-approved { color: var(--green); } .status-approved { color: var(--green); }
.status-denied { color: var(--red); } .status-denied { color: var(--red); }
.status-failed { color: var(--amber); } .status-failed { color: var(--amber); }
.status-cancelled { color: var(--muted); }
.glyph-approved { color: var(--green); } .glyph-approved { color: var(--green); }
.glyph-denied { color: var(--red); } .glyph-denied { color: var(--red); }
.glyph-failed { color: var(--amber); } .glyph-failed { color: var(--amber); }
.glyph-cancelled { color: var(--muted); }
.meta-inputs { .meta-inputs {
list-style: none; list-style: none;
padding: 0; padding: 0;

View file

@ -1598,6 +1598,7 @@ window.marked = marked;
const row = el('div', { class: 'row' }); const row = el('div', { class: 'row' });
const glyph = a.status === 'approved' ? '✓' const glyph = a.status === 'approved' ? '✓'
: a.status === 'denied' ? '✗' : a.status === 'denied' ? '✗'
: a.status === 'cancelled' ? '⊘'
: '⚠'; : '⚠';
row.append( row.append(
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',

View file

@ -261,8 +261,10 @@ fn parse_loose_end_kind(raw: &str) -> Result<hive_sh4re::CancelLooseEndKind, Str
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
"question" | "q" => Ok(hive_sh4re::CancelLooseEndKind::Question), "question" | "q" => Ok(hive_sh4re::CancelLooseEndKind::Question),
"reminder" | "r" => Ok(hive_sh4re::CancelLooseEndKind::Reminder), "reminder" | "r" => Ok(hive_sh4re::CancelLooseEndKind::Reminder),
"approval" | "a" => Ok(hive_sh4re::CancelLooseEndKind::Approval),
other => Err(format!( other => Err(format!(
"cancel_loose_end: unknown kind '{other}' (expected \"question\" or \"reminder\")" "cancel_loose_end: unknown kind '{other}' \
(expected \"question\", \"reminder\", or \"approval\")"
)), )),
} }
} }
@ -275,6 +277,7 @@ fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str {
match kind { match kind {
hive_sh4re::CancelLooseEndKind::Question => "question", hive_sh4re::CancelLooseEndKind::Question => "question",
hive_sh4re::CancelLooseEndKind::Reminder => "reminder", hive_sh4re::CancelLooseEndKind::Reminder => "reminder",
hive_sh4re::CancelLooseEndKind::Approval => "approval",
} }
} }
@ -1628,11 +1631,14 @@ impl ManagerServer {
#[tool( #[tool(
description = "Cancel any open thread in the swarm — a `question` (cancels \ description = "Cancel any open thread in the swarm — a `question` (cancels \
with the operator-override sentinel so the asker unblocks) or a `reminder` \ with the operator-override sentinel so the asker unblocks), a `reminder` \
(hard-deleted before fire). `kind` is `\"question\"` or `\"reminder\"`; `id` \ (hard-deleted before fire), or an `approval` (withdraws a pending approval \
is the row id from `get_loose_ends` or the original submission reply. \ you submitted; the dashboard pulls the card from pending and the row resolves \
Manager surface bypasses the owner check on the sub-agent flavour use for \ as `cancelled` instead of approved/denied/failed closes #250). `kind` is \
hive-wide cleanup of stuck or stale threads." `\"question\"`, `\"reminder\"`, or `\"approval\"`; `id` is the row id from \
`get_loose_ends` or the original submission reply. Manager surface bypasses \
the owner check on the sub-agent flavour use for hive-wide cleanup of \
stuck or stale threads, or to drop your own approvals that got superseded."
)] )]
async fn cancel_loose_end(&self, Parameters(args): Parameters<CancelLooseEndArgs>) -> String { async fn cancel_loose_end(&self, Parameters(args): Parameters<CancelLooseEndArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");

View file

@ -132,7 +132,7 @@ impl Approvals {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
FROM approvals FROM approvals
WHERE status IN ('approved', 'denied', 'failed') WHERE status IN ('approved', 'denied', 'failed', 'cancelled')
ORDER BY resolved_at DESC, id DESC ORDER BY resolved_at DESC, id DESC
LIMIT ?1", LIMIT ?1",
)?; )?;
@ -246,6 +246,67 @@ impl Approvals {
Ok(()) Ok(())
} }
/// Withdraw a pending approval (closes #250). Returns the now-updated
/// row so the caller can emit `ApprovalResolved` with the right
/// kind / agent / sha. Errors if the approval isn't pending — once
/// it's approved/denied/failed/cancelled, the resolution is final.
pub fn mark_cancelled(&self, id: i64, canceller: &str) -> Result<Approval> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let row: Option<(
String,
String,
String,
i64,
String,
Option<String>,
Option<String>,
)> = tx
.query_row(
"SELECT agent, kind, commit_ref, requested_at, status, fetched_sha, description
FROM approvals WHERE id = ?1",
params![id],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
))
},
)
.optional()?;
let Some((agent, kind, commit_ref, requested_at, status, fetched_sha, description)) = row
else {
bail!("approval {id} not found");
};
if status != "pending" {
bail!("approval {id} is {status}, not pending");
}
let resolved_at = now_unix();
let note = format!("cancelled by {canceller}");
tx.execute(
"UPDATE approvals SET status = 'cancelled', resolved_at = ?1, note = ?2 WHERE id = ?3",
params![resolved_at, note, id],
)?;
tx.commit()?;
Ok(Approval {
id,
agent,
kind: kind_from_str(&kind)?,
commit_ref,
requested_at,
status: ApprovalStatus::Cancelled,
resolved_at: Some(resolved_at),
note: Some(note),
fetched_sha,
description,
})
}
/// Mark every pending approval for `agent` as failed (returns rows affected). /// Mark every pending approval for `agent` as failed (returns rows affected).
/// Used by `destroy` to clear the queue of an agent that no longer exists. /// Used by `destroy` to clear the queue of an agent that no longer exists.
pub fn fail_pending_for_agent(&self, agent: &str, note: &str) -> Result<usize> { pub fn fail_pending_for_agent(&self, agent: &str, note: &str) -> Result<usize> {
@ -301,6 +362,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
"approved" => ApprovalStatus::Approved, "approved" => ApprovalStatus::Approved,
"denied" => ApprovalStatus::Denied, "denied" => ApprovalStatus::Denied,
"failed" => ApprovalStatus::Failed, "failed" => ApprovalStatus::Failed,
"cancelled" => ApprovalStatus::Cancelled,
other => { other => {
return Err(rusqlite::Error::FromSqlConversionFailure( return Err(rusqlite::Error::FromSqlConversionFailure(
5, 5,
@ -323,7 +385,11 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
}) })
} }
fn kind_to_str(kind: ApprovalKind) -> &'static str { /// Stable kind→str mapping used wherever we emit `ApprovalResolved`
/// or persist a kind to sqlite. `pub(crate)` so callers like
/// `questions::handle_cancel_loose_end` don't have to duplicate the
/// match; bumping a kind here is the single source of truth.
pub(crate) fn kind_to_str(kind: ApprovalKind) -> &'static str {
match kind { match kind {
ApprovalKind::ApplyCommit => "apply_commit", ApprovalKind::ApplyCommit => "apply_commit",
ApprovalKind::Spawn => "spawn", ApprovalKind::Spawn => "spawn",
@ -394,6 +460,41 @@ mod tests {
assert_eq!(pending.len(), 3, "all three kinds must be visible"); assert_eq!(pending.len(), 3, "all three kinds must be visible");
} }
#[test]
fn mark_cancelled_transitions_pending_row() {
// #250: manager withdraws a pending approval. Row leaves
// pending(), shows up in recent_resolved() with the cancelled
// status + a "cancelled by <who>" note.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("bitburner", ApprovalKind::ApplyCommit, "cafef00d", Some("test"))
.unwrap();
let row = db.mark_cancelled(id, "manager").expect("cancel");
assert_eq!(row.id, id);
assert!(matches!(row.status, ApprovalStatus::Cancelled));
assert_eq!(row.note.as_deref(), Some("cancelled by manager"));
assert!(row.resolved_at.is_some());
assert!(db.pending().unwrap().is_empty(), "row leaves pending");
let resolved = db.recent_resolved(10).unwrap();
assert_eq!(resolved.len(), 1);
assert!(matches!(resolved[0].status, ApprovalStatus::Cancelled));
}
#[test]
fn mark_cancelled_refuses_already_resolved_row() {
// Once approved/denied/failed/cancelled the resolution is
// final — re-cancelling errors instead of silently overwriting.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("a", ApprovalKind::Spawn, "deadbeef", None)
.unwrap();
db.mark_cancelled(id, "manager").expect("first cancel");
let err = db
.mark_cancelled(id, "manager")
.expect_err("second cancel must fail");
assert!(err.to_string().contains("not pending"), "{err}");
}
#[test] #[test]
fn unknown_kind_row_is_skipped_not_fatal() { fn unknown_kind_row_is_skipped_not_fatal() {
// A single malformed / future-kind row must not blank the // A single malformed / future-kind row must not blank the

View file

@ -602,6 +602,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
hive_sh4re::ApprovalStatus::Approved => "approved", hive_sh4re::ApprovalStatus::Approved => "approved",
hive_sh4re::ApprovalStatus::Denied => "denied", hive_sh4re::ApprovalStatus::Denied => "denied",
hive_sh4re::ApprovalStatus::Failed => "failed", hive_sh4re::ApprovalStatus::Failed => "failed",
hive_sh4re::ApprovalStatus::Cancelled => "cancelled",
// Pending shouldn't appear in recent_resolved, but be defensive. // Pending shouldn't appear in recent_resolved, but be defensive.
hive_sh4re::ApprovalStatus::Pending => "pending", hive_sh4re::ApprovalStatus::Pending => "pending",
}; };

View file

@ -18,6 +18,7 @@
use std::sync::Arc; use std::sync::Arc;
use crate::approvals::kind_to_str;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::limits; use crate::limits;
use crate::manager_server::spawn_question_watchdog; use crate::manager_server::spawn_question_watchdog;
@ -179,6 +180,35 @@ pub fn handle_cancel_loose_end(
tracing::info!(%id, %canceller, %owner, "reminder cancelled"); tracing::info!(%id, %canceller, %owner, "reminder cancelled");
Ok(()) Ok(())
} }
hive_sh4re::CancelLooseEndKind::Approval => {
// Manager-only: only the agent that can submit approvals
// is allowed to withdraw them. Sub-agents would have no
// pending approvals of their own to cancel anyway.
if canceller != hive_sh4re::MANAGER_AGENT {
return Err(
"cancel_loose_end: only the manager can cancel approval rows".to_owned(),
);
}
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(
approval.id,
&approval.agent,
kind_to_str(approval.kind),
sha_short,
"cancelled",
approval.note,
approval.description,
);
Ok(())
}
} }
} }

View file

@ -136,6 +136,11 @@ pub enum ApprovalStatus {
Approved, Approved,
Denied, Denied,
Failed, Failed,
/// Manager withdrew the request before the operator acted on it
/// (closes #250). Distinct from `Denied` (operator decision) and
/// `Failed` (post-approval lifecycle error) so the dashboard can
/// chip / sort cancellations separately.
Cancelled,
} }
/// Reminder activity statistics for an agent over a time window. /// Reminder activity statistics for an agent over a time window.
@ -271,10 +276,10 @@ pub enum ReminderTiming {
/// as a short bulleted list — the per-row fields are all the context /// as a short bulleted list — the per-row fields are all the context
/// needed without a follow-up fetch. /// needed without a follow-up fetch.
/// ///
/// `Question` and `Reminder` rows are cancellable via the /// All three variants are cancellable via `CancelLooseEnd` /
/// `CancelLooseEnd` request (and the `cancel_loose_end` MCP tool); /// `cancel_loose_end`. `Question` and `Reminder` can be cancelled
/// `Approval` rows are not (operator approves/denies via the /// from either surface (subject to ownership checks); `Approval`
/// dashboard, manager has no withdraw path today). /// is manager-only since sub-agents can't submit approvals.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum LooseEnd { pub enum LooseEnd {
@ -325,15 +330,20 @@ pub enum LooseEnd {
} }
/// Kind discriminator for `CancelLooseEnd`. Maps to which underlying /// Kind discriminator for `CancelLooseEnd`. Maps to which underlying
/// store the dispatcher reaches into (`OperatorQuestions` vs /// store the dispatcher reaches into (`OperatorQuestions` /
/// `Broker::reminders`). Approvals are deliberately not cancellable /// `Broker::reminders` / `Approvals`). The `Approval` variant is
/// — the operator approves/denies via the dashboard, manager has no /// manager-only — sub-agents can't submit approvals so they have
/// withdraw path today. /// nothing to withdraw (closes #250).
#[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, Question,
Reminder, 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.
Approval,
} }
/// Requests on a per-agent socket. The agent's identity is the socket /// Requests on a per-agent socket. The agent's identity is the socket