Story 44: Agent Completion Report via MCP

- report_completion MCP tool for agents to signal done
- Rejects if worktree has uncommitted changes
- Runs acceptance gates (clippy, tests) automatically
- Stores completion status on agent record
- 10 new tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 15:02:34 +00:00
parent 679370e48a
commit 1b71449dd0
5 changed files with 464 additions and 18 deletions

View File

@@ -5,6 +5,7 @@ use serde::Serialize;
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
@@ -69,6 +70,14 @@ impl std::fmt::Display for AgentStatus {
}
}
/// Report produced by an agent calling `report_completion`.
#[derive(Debug, Serialize, Clone)]
pub struct CompletionReport {
pub summary: String,
pub gates_passed: bool,
pub gate_output: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct AgentInfo {
pub story_id: String,
@@ -77,6 +86,7 @@ pub struct AgentInfo {
pub session_id: Option<String>,
pub worktree_path: Option<String>,
pub base_branch: Option<String>,
pub completion: Option<CompletionReport>,
}
struct StoryAgent {
@@ -88,6 +98,8 @@ struct StoryAgent {
task_handle: Option<tokio::task::JoinHandle<()>>,
/// Accumulated events for polling via get_agent_output.
event_log: Arc<Mutex<Vec<AgentEvent>>>,
/// Set when the agent calls report_completion.
completion: Option<CompletionReport>,
}
/// Build an `AgentInfo` snapshot from a `StoryAgent` map entry.
@@ -105,6 +117,7 @@ fn agent_info_from_entry(story_id: &str, agent: &StoryAgent) -> AgentInfo {
.worktree_info
.as_ref()
.map(|wt| wt.base_branch.clone()),
completion: agent.completion.clone(),
}
}
@@ -179,6 +192,7 @@ impl AgentPool {
tx: tx.clone(),
task_handle: None,
event_log: event_log.clone(),
completion: None,
},
);
}
@@ -269,6 +283,7 @@ impl AgentPool {
session_id: None,
worktree_path: Some(wt_path_str),
base_branch: Some(wt_info.base_branch.clone()),
completion: None,
})
}
@@ -431,6 +446,7 @@ impl AgentPool {
session_id,
worktree_path: None,
base_branch: None,
completion: None,
}
});
}
@@ -476,6 +492,91 @@ impl AgentPool {
worktree::create_worktree(project_root, story_id, &config, self.port).await
}
/// Report that an agent has finished work on a story.
///
/// - Rejects with an error if the worktree has uncommitted changes.
/// - Runs acceptance gates (cargo clippy + cargo nextest run / cargo test).
/// - Stores the `CompletionReport` on the agent record.
/// - Transitions status to `Completed` (gates passed) or `Failed` (gates failed).
/// - Emits a `Done` event so `wait_for_agent` unblocks.
pub async fn report_completion(
&self,
story_id: &str,
agent_name: &str,
summary: &str,
) -> Result<CompletionReport, String> {
let key = composite_key(story_id, agent_name);
// Verify agent exists, is Running, and grab its worktree path.
let worktree_path = {
let agents = self.agents.lock().map_err(|e| e.to_string())?;
let agent = agents
.get(&key)
.ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?;
if agent.status != AgentStatus::Running {
return Err(format!(
"Agent '{agent_name}' for story '{story_id}' is not running (status: {}). \
report_completion can only be called by a running agent.",
agent.status
));
}
agent
.worktree_info
.as_ref()
.map(|wt| wt.path.clone())
.ok_or_else(|| {
format!(
"Agent '{agent_name}' for story '{story_id}' has no worktree. \
Cannot run acceptance gates."
)
})?
};
let path = worktree_path.clone();
// Run gate checks in a blocking thread to avoid stalling the async runtime.
let (gates_passed, gate_output) = tokio::task::spawn_blocking(move || {
// Step 1: Reject if worktree is dirty.
check_uncommitted_changes(&path)?;
// Step 2: Run clippy + tests and return (passed, output).
run_acceptance_gates(&path)
})
.await
.map_err(|e| format!("Gate check task panicked: {e}"))??;
let report = CompletionReport {
summary: summary.to_string(),
gates_passed,
gate_output,
};
// Store the completion report and advance status.
let (tx, session_id) = {
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
let agent = agents.get_mut(&key).ok_or_else(|| {
format!("Agent '{agent_name}' for story '{story_id}' disappeared during gate check")
})?;
agent.completion = Some(report.clone());
agent.status = if gates_passed {
AgentStatus::Completed
} else {
AgentStatus::Failed
};
(agent.tx.clone(), agent.session_id.clone())
};
// Emit Done so wait_for_agent unblocks.
let _ = tx.send(AgentEvent::Done {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
session_id,
});
Ok(report)
}
/// Return the port this server is running on.
pub fn port(&self) -> u16 {
self.port
@@ -511,10 +612,135 @@ impl AgentPool {
tx: tx.clone(),
task_handle: None,
event_log: Arc::new(Mutex::new(Vec::new())),
completion: None,
},
);
tx
}
/// Test helper: inject an agent with a specific worktree path for testing
/// gate-related logic.
#[cfg(test)]
pub fn inject_test_agent_with_path(
&self,
story_id: &str,
agent_name: &str,
status: AgentStatus,
worktree_path: PathBuf,
) -> broadcast::Sender<AgentEvent> {
let (tx, _) = broadcast::channel::<AgentEvent>(64);
let key = composite_key(story_id, agent_name);
let mut agents = self.agents.lock().unwrap();
agents.insert(
key,
StoryAgent {
agent_name: agent_name.to_string(),
status,
worktree_info: Some(WorktreeInfo {
path: worktree_path,
branch: format!("feature/story-{story_id}"),
base_branch: "master".to_string(),
}),
session_id: None,
tx: tx.clone(),
task_handle: None,
event_log: Arc::new(Mutex::new(Vec::new())),
completion: None,
},
);
tx
}
}
// ── Acceptance-gate helpers ───────────────────────────────────────────────────
/// Check whether the given directory has any uncommitted git changes.
/// Returns `Err` with a descriptive message if there are any.
fn check_uncommitted_changes(path: &Path) -> Result<(), String> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run git status: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
return Err(format!(
"Worktree has uncommitted changes. Commit your work before calling \
report_completion:\n{stdout}"
));
}
Ok(())
}
/// Run `cargo clippy` and `cargo nextest run` (falling back to `cargo test`) in
/// the given directory. Returns `(gates_passed, combined_output)`.
fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
let mut all_output = String::new();
let mut all_passed = true;
// ── cargo clippy ──────────────────────────────────────────────
let clippy = Command::new("cargo")
.args(["clippy", "--all-targets", "--all-features"])
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run cargo clippy: {e}"))?;
all_output.push_str("=== cargo clippy ===\n");
let clippy_stdout = String::from_utf8_lossy(&clippy.stdout);
let clippy_stderr = String::from_utf8_lossy(&clippy.stderr);
if !clippy_stdout.is_empty() {
all_output.push_str(&clippy_stdout);
}
if !clippy_stderr.is_empty() {
all_output.push_str(&clippy_stderr);
}
all_output.push('\n');
if !clippy.status.success() {
all_passed = false;
}
// ── cargo nextest run (fallback: cargo test) ──────────────────
all_output.push_str("=== tests ===\n");
let (test_success, test_out) = match Command::new("cargo")
.args(["nextest", "run"])
.current_dir(path)
.output()
{
Ok(o) => {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
(o.status.success(), combined)
}
Err(_) => {
// nextest not available — fall back to cargo test
let o = Command::new("cargo")
.args(["test"])
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run cargo test: {e}"))?;
let combined = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
(o.status.success(), combined)
}
};
all_output.push_str(&test_out);
all_output.push('\n');
if !test_success {
all_passed = false;
}
Ok((all_passed, all_output))
}
/// Spawn claude agent in a PTY and stream events through the broadcast channel.
@@ -792,4 +1018,66 @@ mod tests {
let info = pool.wait_for_agent("s5", "bot", 2000).await.unwrap();
assert_eq!(info.story_id, "s5");
}
// ── report_completion tests ────────────────────────────────────
#[tokio::test]
async fn report_completion_rejects_nonexistent_agent() {
let pool = AgentPool::new(3001);
let result = pool
.report_completion("no_story", "no_bot", "done")
.await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("No agent"), "unexpected: {msg}");
}
#[tokio::test]
async fn report_completion_rejects_non_running_agent() {
let pool = AgentPool::new(3001);
pool.inject_test_agent("s6", "bot", AgentStatus::Completed);
let result = pool.report_completion("s6", "bot", "done").await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("not running"),
"expected 'not running' in: {msg}"
);
}
#[tokio::test]
async fn report_completion_rejects_dirty_worktree() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
// Init a real git repo and make an initial commit
Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.unwrap();
// Write an uncommitted file
fs::write(repo.join("dirty.txt"), "not committed").unwrap();
let pool = AgentPool::new(3001);
pool.inject_test_agent_with_path("s7", "bot", AgentStatus::Running, repo.to_path_buf());
let result = pool.report_completion("s7", "bot", "done").await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("uncommitted"),
"expected 'uncommitted' in: {msg}"
);
}
}

View File

@@ -59,7 +59,11 @@ fn default_agent_command() -> String {
}
fn default_agent_prompt() -> String {
"Read .story_kit/README.md, then pick up story {{story_id}}".to_string()
"You are working in a git worktree on story {{story_id}}. \
Read .story_kit/README.md to understand the dev process, then pick up the story. \
When all work is committed, call report_completion with story_id='{{story_id}}', \
agent_name='{{agent_name}}', and a brief summary as your final action."
.to_string()
}
/// Legacy config format with `agent` as an optional single table (`[agent]`).
@@ -187,10 +191,12 @@ impl ProjectConfig {
};
let bb = base_branch.unwrap_or("master");
let aname = agent.name.as_str();
let render = |s: &str| {
s.replace("{{worktree_path}}", worktree_path)
.replace("{{story_id}}", story_id)
.replace("{{base_branch}}", bb)
.replace("{{agent_name}}", aname)
};
let command = render(&agent.command);

View File

@@ -577,6 +577,28 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
"required": ["worktree_path"]
}
},
{
"name": "report_completion",
"description": "Report that the agent has finished work on a story. Rejects if the worktree has uncommitted changes. Runs acceptance gates (cargo clippy + tests) automatically. Stores the completion status and gate results on the agent record for retrieval by wait_for_agent or the supervisor. Call this as your final action after committing all changes.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (e.g. '44_my_story')"
},
"agent_name": {
"type": "string",
"description": "Agent name (as configured in project.toml)"
},
"summary": {
"type": "string",
"description": "Brief summary of the work completed"
}
},
"required": ["story_id", "agent_name", "summary"]
}
}
]
}),
@@ -618,6 +640,8 @@ async fn handle_tools_call(
"remove_worktree" => tool_remove_worktree(&args, ctx).await,
// Editor tools
"get_editor_command" => tool_get_editor_command(&args, ctx),
// Completion reporting
"report_completion" => tool_report_completion(&args, ctx).await,
_ => Err(format!("Unknown tool: {tool_name}")),
};
@@ -903,6 +927,12 @@ async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result<String, S
_ => None,
};
let completion = info.completion.as_ref().map(|r| json!({
"summary": r.summary,
"gates_passed": r.gates_passed,
"gate_output": r.gate_output,
}));
serde_json::to_string_pretty(&json!({
"story_id": info.story_id,
"agent_name": info.agent_name,
@@ -911,6 +941,7 @@ async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result<String, S
"worktree_path": info.worktree_path,
"base_branch": info.base_branch,
"commits": commits,
"completion": completion,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
@@ -976,6 +1007,40 @@ fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<String, Str
Ok(format!("{editor} {worktree_path}"))
}
async fn tool_report_completion(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let agent_name = args
.get("agent_name")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: agent_name")?;
let summary = args
.get("summary")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: summary")?;
let report = ctx
.agents
.report_completion(story_id, agent_name, summary)
.await?;
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"agent_name": agent_name,
"summary": report.summary,
"gates_passed": report.gates_passed,
"gate_output": report.gate_output,
"message": if report.gates_passed {
"Completion accepted. All acceptance gates passed."
} else {
"Completion recorded but acceptance gates failed. Review gate_output for details."
}
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
/// summaries, or `None` if git is unavailable or there are no new commits.
async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option<Vec<String>> {
@@ -1044,6 +1109,7 @@ fn parse_test_cases(value: Option<&Value>) -> Result<Vec<TestCaseResult>, String
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::store::StoreOps;
// ── Unit tests ────────────────────────────────────────────────
@@ -1125,7 +1191,8 @@ mod tests {
assert!(names.contains(&"list_worktrees"));
assert!(names.contains(&"remove_worktree"));
assert!(names.contains(&"get_editor_command"));
assert_eq!(tools.len(), 17);
assert!(names.contains(&"report_completion"));
assert_eq!(tools.len(), 18);
}
#[test]
@@ -1369,6 +1436,73 @@ mod tests {
assert_eq!(parsed["agent_name"], "worker");
// commits key present (may be null since no real worktree)
assert!(parsed.get("commits").is_some());
// completion key present (null for agents that didn't call report_completion)
assert!(parsed.get("completion").is_some());
}
// ── report_completion tool tests ──────────────────────────────
#[test]
fn report_completion_in_tools_list() {
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let tool = tools
.iter()
.find(|t| t["name"] == "report_completion")
.expect("report_completion missing from tools list");
// Schema has required fields
let required = tool["inputSchema"]["required"].as_array().unwrap();
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(req_names.contains(&"story_id"));
assert!(req_names.contains(&"agent_name"));
assert!(req_names.contains(&"summary"));
}
#[tokio::test]
async fn report_completion_tool_missing_story_id() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_report_completion(&json!({"agent_name": "bot", "summary": "done"}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("story_id"));
}
#[tokio::test]
async fn report_completion_tool_missing_agent_name() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_report_completion(&json!({"story_id": "44_test", "summary": "done"}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("agent_name"));
}
#[tokio::test]
async fn report_completion_tool_missing_summary() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_report_completion(
&json!({"story_id": "44_test", "agent_name": "bot"}),
&ctx,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("summary"));
}
#[tokio::test]
async fn report_completion_tool_nonexistent_agent() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_report_completion(
&json!({"story_id": "99_nope", "agent_name": "bot", "summary": "done"}),
&ctx,
)
.await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("No agent"), "unexpected: {msg}");
}
// ── Editor command tool tests ─────────────────────────────────