open_threads: new get_open_threads MCP tool on agent + manager surfaces

This commit is contained in:
damocles 2026-05-17 22:39:10 +02:00
parent 9ec0d60308
commit dc1ce1f236
11 changed files with 305 additions and 9 deletions

View file

@ -185,6 +185,46 @@ pub enum ReminderTiming {
At { unix_timestamp: i64 },
}
/// One row in the response to `GetOpenThreads`. 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.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OpenThread {
/// A pending approval. For agent-flavour `GetOpenThreads` 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).
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.
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,
},
}
/// Requests on a per-agent socket. The agent's identity is the socket
/// it came in on; `Send.from` is filled in by the server, not the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -263,6 +303,12 @@ 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.
GetOpenThreads,
}
/// Responses on a per-agent socket.
@ -284,6 +330,9 @@ pub enum AgentResponse {
/// `Ask` result: the queued question id. The answer lands later
/// as `HelperEvent::QuestionAnswered` in this agent's inbox.
QuestionQueued { id: i64 },
/// `GetOpenThreads` result: list of loose ends pending against
/// this agent. Ordered newest-first within each kind.
OpenThreads { threads: Vec<OpenThread> },
}
// -----------------------------------------------------------------------------
@ -541,6 +590,12 @@ pub enum ManagerRequest {
#[serde(default)]
file_path: Option<String>,
},
/// Hive-wide loose-ends view: EVERY pending approval + EVERY
/// unanswered question in the swarm. Used by the manager to scan
/// for stalled coordination — the per-agent equivalent on the
/// sub-agent surface is `AgentRequest::GetOpenThreads` which
/// only returns rows where the agent itself is asker / target.
GetOpenThreads,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -573,4 +628,10 @@ pub enum ManagerResponse {
Logs {
content: String,
},
/// `GetOpenThreads` result: hive-wide loose ends (approvals +
/// unanswered questions). Same `OpenThread` variants as the
/// agent surface; the manager's view is unfiltered.
OpenThreads {
threads: Vec<OpenThread>,
},
}