story-kit: merge 247_story_human_qa_gate_with_rejection_flow
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use crate::agents::{close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived, move_story_to_merge, move_story_to_qa, AgentStatus, PipelineStage};
|
||||
use crate::agents::{close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived, move_story_to_merge, move_story_to_qa, reject_story_from_qa, AgentStatus, PipelineStage};
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::log_buffer;
|
||||
use crate::slog;
|
||||
@@ -862,6 +862,52 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
"required": ["story_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "approve_qa",
|
||||
"description": "Approve a story that passed machine QA and is awaiting human review. Moves the story from work/3_qa/ to work/4_merge/ and starts the mergemaster agent.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Story identifier (e.g. '247_story_human_qa_gate')"
|
||||
}
|
||||
},
|
||||
"required": ["story_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reject_qa",
|
||||
"description": "Reject a story during human QA review. Moves the story from work/3_qa/ back to work/2_current/ with rejection notes so the coder agent can fix the issues.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Story identifier (e.g. '247_story_human_qa_gate')"
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"description": "Explanation of what is broken or needs fixing"
|
||||
}
|
||||
},
|
||||
"required": ["story_id", "notes"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "launch_qa_app",
|
||||
"description": "Launch the app from a story's worktree for manual QA testing. Automatically assigns a free port, writes it to .story_kit_port, and starts the backend server. Only one QA app instance runs at a time.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Story identifier whose worktree app to launch"
|
||||
}
|
||||
},
|
||||
"required": ["story_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_pipeline_status",
|
||||
"description": "Return a structured snapshot of the full work item pipeline. Includes all active stages (current, qa, merge, done) with each item's stage, name, and assigned agent. Also includes upcoming backlog items.",
|
||||
@@ -979,6 +1025,9 @@ async fn handle_tools_call(
|
||||
"report_merge_failure" => tool_report_merge_failure(&args, ctx),
|
||||
// QA tools
|
||||
"request_qa" => tool_request_qa(&args, ctx).await,
|
||||
"approve_qa" => tool_approve_qa(&args, ctx).await,
|
||||
"reject_qa" => tool_reject_qa(&args, ctx).await,
|
||||
"launch_qa_app" => tool_launch_qa_app(&args, ctx).await,
|
||||
// Pipeline status
|
||||
"get_pipeline_status" => tool_get_pipeline_status(ctx),
|
||||
// Diagnostics
|
||||
@@ -1947,6 +1996,159 @@ async fn tool_request_qa(args: &Value, ctx: &AppContext) -> Result<String, Strin
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
async fn tool_approve_qa(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 project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Clear review_hold before moving
|
||||
let qa_path = project_root
|
||||
.join(".story_kit/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if qa_path.exists() {
|
||||
let _ = crate::io::story_metadata::clear_front_matter_field(&qa_path, "review_hold");
|
||||
}
|
||||
|
||||
// Move story from work/3_qa/ to work/4_merge/
|
||||
move_story_to_merge(&project_root, story_id)?;
|
||||
|
||||
// Start the mergemaster agent
|
||||
let info = ctx
|
||||
.agents
|
||||
.start_agent(&project_root, story_id, Some("mergemaster"), None)
|
||||
.await?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": info.story_id,
|
||||
"agent_name": info.agent_name,
|
||||
"status": info.status.to_string(),
|
||||
"message": format!(
|
||||
"Story '{story_id}' approved. Moved to work/4_merge/ and mergemaster agent '{}' started.",
|
||||
info.agent_name
|
||||
),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
async fn tool_reject_qa(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 notes = args
|
||||
.get("notes")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: notes")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Move story from work/3_qa/ back to work/2_current/ with rejection notes
|
||||
reject_story_from_qa(&project_root, story_id, notes)?;
|
||||
|
||||
// Restart the coder agent with rejection context
|
||||
let story_path = project_root
|
||||
.join(".story_kit/work/2_current")
|
||||
.join(format!("{story_id}.md"));
|
||||
let agent_name = if story_path.exists() {
|
||||
let contents = std::fs::read_to_string(&story_path).unwrap_or_default();
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|meta| meta.agent)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let agent_name = agent_name.as_deref().unwrap_or("coder-opus");
|
||||
|
||||
let context = format!(
|
||||
"\n\n---\n## QA Rejection\n\
|
||||
Your previous implementation was rejected during human QA review.\n\
|
||||
Rejection notes:\n{notes}\n\n\
|
||||
Please fix the issues described above and try again."
|
||||
);
|
||||
if let Err(e) = ctx
|
||||
.agents
|
||||
.start_agent(&project_root, story_id, Some(agent_name), Some(&context))
|
||||
.await
|
||||
{
|
||||
slog_warn!("[qa] Failed to restart coder for '{story_id}' after rejection: {e}");
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Story '{story_id}' rejected and moved back to work/2_current/. Coder agent '{agent_name}' restarted with rejection notes."
|
||||
))
|
||||
}
|
||||
|
||||
async fn tool_launch_qa_app(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 project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Find the worktree path for this story
|
||||
let worktrees = crate::worktree::list_worktrees(&project_root)?;
|
||||
let wt = worktrees
|
||||
.iter()
|
||||
.find(|w| w.story_id == story_id)
|
||||
.ok_or_else(|| format!("No worktree found for story '{story_id}'"))?;
|
||||
let wt_path = wt.path.clone();
|
||||
|
||||
// Stop any existing QA app instance
|
||||
{
|
||||
let mut guard = ctx.qa_app_process.lock().unwrap();
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
slog!("[qa-app] Stopped previous QA app instance.");
|
||||
}
|
||||
}
|
||||
|
||||
// Find a free port starting from 3100
|
||||
let port = find_free_port(3100);
|
||||
|
||||
// Write .story_kit_port so the frontend dev server knows where to connect
|
||||
let port_file = wt_path.join(".story_kit_port");
|
||||
std::fs::write(&port_file, port.to_string())
|
||||
.map_err(|e| format!("Failed to write .story_kit_port: {e}"))?;
|
||||
|
||||
// Launch the server from the worktree
|
||||
let child = std::process::Command::new("cargo")
|
||||
.args(["run"])
|
||||
.env("STORYKIT_PORT", port.to_string())
|
||||
.current_dir(&wt_path)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch QA app: {e}"))?;
|
||||
|
||||
{
|
||||
let mut guard = ctx.qa_app_process.lock().unwrap();
|
||||
*guard = Some(child);
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"port": port,
|
||||
"worktree_path": wt_path.to_string_lossy(),
|
||||
"message": format!("QA app launched on port {port} from worktree at {}", wt_path.display()),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// Find a free TCP port starting from `start`.
|
||||
fn find_free_port(start: u16) -> u16 {
|
||||
for port in start..start + 100 {
|
||||
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
start // fallback
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
@@ -2383,11 +2585,14 @@ mod tests {
|
||||
assert!(names.contains(&"move_story_to_merge"));
|
||||
assert!(names.contains(&"report_merge_failure"));
|
||||
assert!(names.contains(&"request_qa"));
|
||||
assert!(names.contains(&"approve_qa"));
|
||||
assert!(names.contains(&"reject_qa"));
|
||||
assert!(names.contains(&"launch_qa_app"));
|
||||
assert!(names.contains(&"get_server_logs"));
|
||||
assert!(names.contains(&"prompt_permission"));
|
||||
assert!(names.contains(&"get_pipeline_status"));
|
||||
assert!(names.contains(&"rebuild_and_restart"));
|
||||
assert_eq!(tools.len(), 36);
|
||||
assert_eq!(tools.len(), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3934,6 +4139,80 @@ stage = "coder"
|
||||
assert!(!req_names.contains(&"agent_name"));
|
||||
}
|
||||
|
||||
// ── approve_qa in tools list ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn approve_qa_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"] == "approve_qa");
|
||||
assert!(tool.is_some(), "approve_qa missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
let required = t["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"));
|
||||
}
|
||||
|
||||
// ── reject_qa in tools list ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn reject_qa_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"] == "reject_qa");
|
||||
assert!(tool.is_some(), "reject_qa missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
let required = t["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(&"notes"));
|
||||
}
|
||||
|
||||
// ── launch_qa_app in tools list ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn launch_qa_app_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"] == "launch_qa_app");
|
||||
assert!(tool.is_some(), "launch_qa_app missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
let required = t["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"));
|
||||
}
|
||||
|
||||
// ── approve_qa missing story_id ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_approve_qa_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_approve_qa(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
// ── reject_qa missing arguments ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_reject_qa_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_reject_qa(&json!({"notes": "broken"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_reject_qa_missing_notes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_reject_qa(&json!({"story_id": "1_story_test"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("notes"));
|
||||
}
|
||||
|
||||
// ── tool_validate_stories with file content ───────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user