story-kit: merge 306_story_replace_manual_qa_boolean_with_configurable_qa_mode_field

This commit is contained in:
Dave
2026-03-19 11:56:39 +00:00
parent a058fa5f19
commit 2067abb2e5
12 changed files with 418 additions and 125 deletions

View File

@@ -854,16 +854,75 @@ impl AgentPool {
}
PipelineStage::Coder => {
if completion.gates_passed {
slog!(
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. Moving to QA."
);
if let Err(e) = super::lifecycle::move_story_to_qa(&project_root, story_id) {
slog_error!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
} else if let Err(e) = self
.start_agent(&project_root, story_id, Some("qa"), None)
.await
{
slog_error!("[pipeline] Failed to start qa agent for '{story_id}': {e}");
// Determine effective QA mode for this story.
let qa_mode = {
let item_type = super::lifecycle::item_type_from_id(story_id);
if item_type == "spike" {
crate::io::story_metadata::QaMode::Human
} else {
let default_qa = config.default_qa_mode();
// Story is in 2_current/ when a coder completes.
let story_path = project_root
.join(".story_kit/work/2_current")
.join(format!("{story_id}.md"));
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa)
}
};
match qa_mode {
crate::io::story_metadata::QaMode::Server => {
slog!(
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
qa: server — moving directly to merge."
);
if let Err(e) =
super::lifecycle::move_story_to_merge(&project_root, story_id)
{
slog_error!(
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
);
} else if let Err(e) = self
.start_agent(&project_root, story_id, Some("mergemaster"), None)
.await
{
slog_error!(
"[pipeline] Failed to start mergemaster for '{story_id}': {e}"
);
}
}
crate::io::story_metadata::QaMode::Agent => {
slog!(
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
qa: agent — moving to QA."
);
if let Err(e) = super::lifecycle::move_story_to_qa(&project_root, story_id) {
slog_error!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
} else if let Err(e) = self
.start_agent(&project_root, story_id, Some("qa"), None)
.await
{
slog_error!("[pipeline] Failed to start qa agent for '{story_id}': {e}");
}
}
crate::io::story_metadata::QaMode::Human => {
slog!(
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
qa: human — holding for human review."
);
if let Err(e) = super::lifecycle::move_story_to_qa(&project_root, story_id) {
slog_error!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
} else {
let qa_dir = project_root.join(".story_kit/work/3_qa");
let story_path = qa_dir.join(format!("{story_id}.md"));
if let Err(e) =
crate::io::story_metadata::write_review_hold(&story_path)
{
slog_error!(
"[pipeline] Failed to set review_hold on '{story_id}': {e}"
);
}
}
}
}
} else {
slog!(
@@ -911,10 +970,13 @@ impl AgentPool {
if item_type == "spike" {
true // Spikes always need human review.
} else {
// Stories/bugs: check the manual_qa front matter field (defaults to false).
let qa_dir = project_root.join(".story_kit/work/3_qa");
let story_path = qa_dir.join(format!("{story_id}.md"));
crate::io::story_metadata::requires_manual_qa(&story_path)
let default_qa = config.default_qa_mode();
matches!(
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa),
crate::io::story_metadata::QaMode::Human
)
}
};
@@ -937,7 +999,7 @@ impl AgentPool {
} else {
slog!(
"[pipeline] QA passed gates and coverage for '{story_id}'. \
manual_qa: false — moving directly to merge."
Moving directly to merge."
);
if let Err(e) =
super::lifecycle::move_story_to_merge(&project_root, story_id)
@@ -1730,21 +1792,82 @@ impl AgentPool {
eprintln!("[startup:reconcile] Gates passed for '{story_id}' (stage: {stage_dir}/).");
if stage_dir == "2_current" {
// Coder stage → advance to QA.
if let Err(e) = super::lifecycle::move_story_to_qa(project_root, story_id) {
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Failed to advance to QA: {e}"),
});
} else {
eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/.");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "advanced".to_string(),
message: "Gates passed — moved to QA.".to_string(),
});
// Coder stage — determine qa mode to decide next step.
let qa_mode = {
let item_type = super::lifecycle::item_type_from_id(story_id);
if item_type == "spike" {
crate::io::story_metadata::QaMode::Human
} else {
let default_qa = crate::config::ProjectConfig::load(project_root)
.unwrap_or_default()
.default_qa_mode();
let story_path = project_root
.join(".story_kit/work/2_current")
.join(format!("{story_id}.md"));
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa)
}
};
match qa_mode {
crate::io::story_metadata::QaMode::Server => {
if let Err(e) = super::lifecycle::move_story_to_merge(project_root, story_id) {
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Failed to advance to merge: {e}"),
});
} else {
eprintln!("[startup:reconcile] Moved '{story_id}' → 4_merge/ (qa: server).");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "advanced".to_string(),
message: "Gates passed — moved to merge (qa: server).".to_string(),
});
}
}
crate::io::story_metadata::QaMode::Agent => {
if let Err(e) = super::lifecycle::move_story_to_qa(project_root, story_id) {
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Failed to advance to QA: {e}"),
});
} else {
eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/.");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "advanced".to_string(),
message: "Gates passed — moved to QA.".to_string(),
});
}
}
crate::io::story_metadata::QaMode::Human => {
if let Err(e) = super::lifecycle::move_story_to_qa(project_root, story_id) {
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "failed".to_string(),
message: format!("Failed to advance to QA: {e}"),
});
} else {
let story_path = project_root
.join(".story_kit/work/3_qa")
.join(format!("{story_id}.md"));
if let Err(e) = crate::io::story_metadata::write_review_hold(&story_path) {
eprintln!(
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
);
}
eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/ (qa: human — holding for review).");
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
status: "review_hold".to_string(),
message: "Gates passed — holding for human review.".to_string(),
});
}
}
}
} else if stage_dir == "3_qa" {
// QA stage → run coverage gate before advancing to merge.
@@ -1788,7 +1911,13 @@ impl AgentPool {
let story_path = project_root
.join(".story_kit/work/3_qa")
.join(format!("{story_id}.md"));
crate::io::story_metadata::requires_manual_qa(&story_path)
let default_qa = crate::config::ProjectConfig::load(project_root)
.unwrap_or_default()
.default_qa_mode();
matches!(
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa),
crate::io::story_metadata::QaMode::Human
)
}
};
@@ -2681,18 +2810,17 @@ mod tests {
// ── pipeline advance tests ────────────────────────────────────────────────
#[tokio::test]
async fn pipeline_advance_coder_gates_pass_moves_story_to_qa() {
async fn pipeline_advance_coder_gates_pass_server_qa_moves_to_merge() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Set up story in 2_current/
// Set up story in 2_current/ (no qa frontmatter → uses project default "server")
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("50_story_test.md"), "test").unwrap();
let pool = AgentPool::new_test(3001);
// Call pipeline advance directly with completion data.
pool.run_pipeline_advance(
"50_story_test",
"coder-1",
@@ -2707,8 +2835,49 @@ mod tests {
)
.await;
// Story should have moved to 3_qa/ (start_agent for qa will fail in tests but
// the file move happens before that).
// With default qa: server, story skips QA and goes straight to 4_merge/
assert!(
root.join(".story_kit/work/4_merge/50_story_test.md")
.exists(),
"story should be in 4_merge/"
);
assert!(
!current.join("50_story_test.md").exists(),
"story should not still be in 2_current/"
);
}
#[tokio::test]
async fn pipeline_advance_coder_gates_pass_agent_qa_moves_to_qa() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Set up story in 2_current/ with qa: agent frontmatter
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(
current.join("50_story_test.md"),
"---\nname: Test\nqa: agent\n---\ntest",
)
.unwrap();
let pool = AgentPool::new_test(3001);
pool.run_pipeline_advance(
"50_story_test",
"coder-1",
CompletionReport {
summary: "done".to_string(),
gates_passed: true,
gate_output: String::new(),
},
Some(root.to_path_buf()),
None,
false,
)
.await;
// With qa: agent, story should move to 3_qa/
assert!(
root.join(".story_kit/work/3_qa/50_story_test.md").exists(),
"story should be in 3_qa/"
@@ -2728,10 +2897,10 @@ mod tests {
// Set up story in 3_qa/
let qa_dir = root.join(".story_kit/work/3_qa");
fs::create_dir_all(&qa_dir).unwrap();
// manual_qa: false so the story skips human review and goes straight to merge.
// qa: server so the story skips human review and goes straight to merge.
fs::write(
qa_dir.join("51_story_test.md"),
"---\nname: Test\nmanual_qa: false\n---\ntest",
"---\nname: Test\nqa: server\n---\ntest",
)
.unwrap();
@@ -2815,6 +2984,8 @@ mod tests {
fs::write(
root.join(".story_kit/project.toml"),
r#"
default_qa = "agent"
[[agent]]
name = "coder-1"
role = "Coder"
@@ -4984,12 +5155,12 @@ stage = "qa"
// simulating the "stuck" state from bug 295.
fs::write(
qa_dir.join("292_story_first.md"),
"---\nname: First\nmanual_qa: true\n---\n",
"---\nname: First\nqa: human\n---\n",
)
.unwrap();
fs::write(
qa_dir.join("293_story_second.md"),
"---\nname: Second\nmanual_qa: true\n---\n",
"---\nname: Second\nqa: human\n---\n",
)
.unwrap();
@@ -5015,7 +5186,7 @@ stage = "qa"
// Pipeline advance for QA with gates_passed=true will:
// 1. Run coverage gate (will "pass" trivially in test — no script/test_coverage)
// 2. Set review_hold on 292 (manual_qa: true)
// 2. Set review_hold on 292 (qa: human)
// 3. Call auto_assign_available_work (the fix from bug 295)
// 4. auto_assign should find 293 in 3_qa/ with no agent and start qa on it
pool.run_pipeline_advance(

View File

@@ -11,6 +11,10 @@ pub struct ProjectConfig {
pub agent: Vec<AgentConfig>,
#[serde(default)]
pub watcher: WatcherConfig,
/// Project-wide default QA mode: "server", "agent", or "human".
/// Per-story `qa` front matter overrides this. Default: "server".
#[serde(default = "default_qa")]
pub default_qa: String,
}
/// Configuration for the filesystem watcher's sweep behaviour.
@@ -46,6 +50,10 @@ fn default_done_retention_secs() -> u64 {
4 * 60 * 60 // 4 hours
}
fn default_qa() -> String {
"server".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct ComponentConfig {
@@ -124,6 +132,8 @@ struct LegacyProjectConfig {
agent: Option<AgentConfig>,
#[serde(default)]
watcher: WatcherConfig,
#[serde(default = "default_qa")]
default_qa: String,
}
impl Default for ProjectConfig {
@@ -145,6 +155,7 @@ impl Default for ProjectConfig {
inactivity_timeout_secs: default_inactivity_timeout_secs(),
}],
watcher: WatcherConfig::default(),
default_qa: default_qa(),
}
}
}
@@ -186,6 +197,7 @@ impl ProjectConfig {
component: legacy.component,
agent: vec![agent],
watcher: legacy.watcher,
default_qa: legacy.default_qa,
};
validate_agents(&config.agent)?;
return Ok(config);
@@ -206,6 +218,7 @@ impl ProjectConfig {
component: legacy.component,
agent: vec![agent],
watcher: legacy.watcher,
default_qa: legacy.default_qa,
};
validate_agents(&config.agent)?;
Ok(config)
@@ -214,12 +227,20 @@ impl ProjectConfig {
component: legacy.component,
agent: Vec::new(),
watcher: legacy.watcher,
default_qa: legacy.default_qa,
})
}
}
}
}
/// Return the project-wide default QA mode parsed from `default_qa`.
/// Falls back to `Server` if the value is unrecognised.
pub fn default_qa_mode(&self) -> crate::io::story_metadata::QaMode {
crate::io::story_metadata::QaMode::from_str(&self.default_qa)
.unwrap_or(crate::io::story_metadata::QaMode::Server)
}
/// Look up an agent config by name.
pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> {
self.agent.iter().find(|a| a.name == name)

View File

@@ -639,7 +639,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "update_story",
"description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, manual_qa). Auto-commits via the filesystem watcher.",
"description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, qa). Auto-commits via the filesystem watcher.",
"inputSchema": {
"type": "object",
"properties": {

View File

@@ -27,9 +27,9 @@ pub struct UpcomingStory {
/// True when the item is held in QA for human review.
#[serde(skip_serializing_if = "Option::is_none")]
pub review_hold: Option<bool>,
/// Whether the item requires manual QA (defaults to true when absent).
/// QA mode for this item: "human", "server", or "agent".
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_qa: Option<bool>,
pub qa: Option<String>,
}
pub struct StoryValidationResult {
@@ -123,12 +123,12 @@ fn load_stage_items(
.to_string();
let contents = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
let (name, error, merge_failure, review_hold, manual_qa) = match parse_front_matter(&contents) {
Ok(meta) => (meta.name, None, meta.merge_failure, meta.review_hold, meta.manual_qa),
let (name, error, merge_failure, review_hold, qa) = match parse_front_matter(&contents) {
Ok(meta) => (meta.name, None, meta.merge_failure, meta.review_hold, meta.qa.map(|m| m.as_str().to_string())),
Err(e) => (None, Some(e.to_string()), None, None, None),
};
let agent = agent_map.get(&story_id).cloned();
stories.push(UpcomingStory { story_id, name, error, merge_failure, agent, review_hold, manual_qa });
stories.push(UpcomingStory { story_id, name, error, merge_failure, agent, review_hold, qa });
}
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
@@ -1691,12 +1691,12 @@ mod tests {
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
let mut fields = HashMap::new();
fields.insert("manual_qa".to_string(), "true".to_string());
fields.insert("qa".to_string(), "human".to_string());
fields.insert("priority".to_string(), "high".to_string());
update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap();
let result = fs::read_to_string(&filepath).unwrap();
assert!(result.contains("manual_qa: \"true\""), "manual_qa field should be set");
assert!(result.contains("qa: \"human\""), "qa field should be set");
assert!(result.contains("priority: \"high\""), "priority field should be set");
assert!(result.contains("name: T"), "name field preserved");
}

View File

@@ -738,7 +738,7 @@ mod tests {
merge_failure: None,
agent: None,
review_hold: None,
manual_qa: None,
qa: None,
};
let resp = WsResponse::PipelineState {
backlog: vec![story],
@@ -877,7 +877,7 @@ mod tests {
merge_failure: None,
agent: None,
review_hold: None,
manual_qa: None,
qa: None,
}],
current: vec![UpcomingStory {
story_id: "2_story_b".to_string(),
@@ -886,7 +886,7 @@ mod tests {
merge_failure: None,
agent: None,
review_hold: None,
manual_qa: None,
qa: None,
}],
qa: vec![],
merge: vec![],
@@ -897,7 +897,7 @@ mod tests {
merge_failure: None,
agent: None,
review_hold: None,
manual_qa: None,
qa: None,
}],
};
let resp: WsResponse = state.into();
@@ -1055,7 +1055,7 @@ mod tests {
status: "running".to_string(),
}),
review_hold: None,
manual_qa: None,
qa: None,
}],
qa: vec![],
merge: vec![],

View File

@@ -99,7 +99,11 @@ const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
}
"#;
const DEFAULT_PROJECT_AGENTS_TOML: &str = r#"[[agent]]
const DEFAULT_PROJECT_AGENTS_TOML: &str = r#"# Project-wide default QA mode: "server", "agent", or "human".
# Per-story `qa` front matter overrides this setting.
default_qa = "server"
[[agent]]
name = "coder-1"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."

View File

@@ -2,6 +2,45 @@ use serde::Deserialize;
use std::fs;
use std::path::Path;
/// QA mode for a story: determines how the pipeline handles post-coder review.
///
/// - `Server` — skip the QA agent; rely on server gate checks (clippy + tests).
/// If gates pass, advance straight to merge.
/// - `Agent` — spin up a QA agent (Claude session) to review code and run gates.
/// - `Human` — hold in QA for human approval after server gates pass.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QaMode {
Server,
Agent,
Human,
}
impl QaMode {
/// Parse a string into a `QaMode`. Returns `None` for unrecognised values.
pub fn from_str(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"server" => Some(Self::Server),
"agent" => Some(Self::Agent),
"human" => Some(Self::Human),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Server => "server",
Self::Agent => "agent",
Self::Human => "human",
}
}
}
impl std::fmt::Display for QaMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StoryMetadata {
pub name: Option<String>,
@@ -9,7 +48,7 @@ pub struct StoryMetadata {
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub manual_qa: Option<bool>,
pub qa: Option<QaMode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -34,6 +73,9 @@ struct FrontMatter {
merge_failure: Option<String>,
agent: Option<String>,
review_hold: Option<bool>,
/// New configurable QA mode field: "human", "server", or "agent".
qa: Option<String>,
/// Legacy boolean field — mapped to `qa: human` (true) or ignored (false/absent).
manual_qa: Option<bool>,
}
@@ -63,13 +105,20 @@ pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaErro
}
fn build_metadata(front: FrontMatter) -> StoryMetadata {
// Resolve qa mode: prefer the new `qa` field, fall back to legacy `manual_qa`.
let qa = if let Some(ref qa_str) = front.qa {
QaMode::from_str(qa_str)
} else {
front.manual_qa.and_then(|v| if v { Some(QaMode::Human) } else { None })
};
StoryMetadata {
name: front.name,
coverage_baseline: front.coverage_baseline,
merge_failure: front.merge_failure,
agent: front.agent,
review_hold: front.review_hold,
manual_qa: front.manual_qa,
qa,
}
}
@@ -210,15 +259,19 @@ pub fn write_rejection_notes(path: &Path, notes: &str) -> Result<(), String> {
Ok(())
}
/// Check whether a story requires manual QA (defaults to false).
pub fn requires_manual_qa(path: &Path) -> bool {
/// Resolve the effective QA mode for a story file.
///
/// Reads the `qa` front matter field. If absent, falls back to `default`.
/// Spikes are **not** handled here — the caller is responsible for overriding
/// to `Human` for spikes.
pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode {
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
Err(_) => return default,
};
match parse_front_matter(&contents) {
Ok(meta) => meta.manual_qa.unwrap_or(false),
Err(_) => false,
Ok(meta) => meta.qa.unwrap_or(default),
Err(_) => default,
}
}
@@ -398,41 +451,69 @@ workflow: tdd
}
#[test]
fn parses_manual_qa_from_front_matter() {
let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n";
fn parses_qa_mode_from_front_matter() {
let input = "---\nname: Story\nqa: server\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.manual_qa, Some(false));
assert_eq!(meta.qa, Some(QaMode::Server));
let input = "---\nname: Story\nqa: agent\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Agent));
let input = "---\nname: Story\nqa: human\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Human));
}
#[test]
fn manual_qa_defaults_to_none() {
fn qa_mode_defaults_to_none() {
let input = "---\nname: Story\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.manual_qa, None);
assert_eq!(meta.qa, None);
}
#[test]
fn requires_manual_qa_defaults_false() {
fn legacy_manual_qa_true_maps_to_human() {
let input = "---\nname: Story\nmanual_qa: true\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Human));
}
#[test]
fn legacy_manual_qa_false_maps_to_none() {
let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, None);
}
#[test]
fn qa_field_takes_precedence_over_manual_qa() {
let input = "---\nname: Story\nqa: server\nmanual_qa: true\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Server));
}
#[test]
fn resolve_qa_mode_uses_file_value() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap();
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human);
}
#[test]
fn resolve_qa_mode_falls_back_to_default() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
assert!(!requires_manual_qa(&path));
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server);
assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent);
}
#[test]
fn requires_manual_qa_true_when_set() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\nmanual_qa: true\n---\n# Story\n").unwrap();
assert!(requires_manual_qa(&path));
}
#[test]
fn requires_manual_qa_false_when_set() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\nmanual_qa: false\n---\n# Story\n").unwrap();
assert!(!requires_manual_qa(&path));
fn resolve_qa_mode_missing_file_uses_default() {
let path = std::path::Path::new("/nonexistent/story.md");
assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server);
}
#[test]

View File

@@ -507,6 +507,7 @@ mod tests {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
// Should complete without panic
run_setup_commands(tmp.path(), &config).await;
@@ -524,6 +525,7 @@ mod tests {
}],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
// Should complete without panic
run_setup_commands(tmp.path(), &config).await;
@@ -541,6 +543,7 @@ mod tests {
}],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
// Setup command failures are non-fatal — should not panic or propagate
run_setup_commands(tmp.path(), &config).await;
@@ -558,6 +561,7 @@ mod tests {
}],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
// Teardown failures are best-effort — should not propagate
assert!(run_teardown_commands(tmp.path(), &config).await.is_ok());
@@ -574,6 +578,7 @@ mod tests {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
let info = create_worktree(&project_root, "42_fresh_test", &config, 3001)
.await
@@ -597,6 +602,7 @@ mod tests {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
// First creation
let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001)
@@ -636,6 +642,7 @@ mod tests {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await;
@@ -658,6 +665,7 @@ mod tests {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
create_worktree(&project_root, "88_remove_by_id", &config, 3001)
.await
@@ -711,6 +719,7 @@ mod tests {
}],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
// Even though setup commands fail, create_worktree must succeed
// so the agent can start and fix the problem itself.
@@ -736,6 +745,7 @@ mod tests {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
// First creation — no setup commands, should succeed
create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001)
@@ -751,6 +761,7 @@ mod tests {
}],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
// Second call — worktree exists, setup commands fail, must still succeed
let result =
@@ -773,6 +784,7 @@ mod tests {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
default_qa: "server".to_string(),
};
let info = create_worktree(&project_root, "77_remove_async", &config, 3001)
.await