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

@@ -1,3 +1,7 @@
# Project-wide default QA mode: "server", "agent", or "human".
# Per-story `qa` front matter overrides this setting.
default_qa = "server"
[[component]] [[component]]
name = "frontend" name = "frontend"
path = "frontend" path = "frontend"

View File

@@ -34,7 +34,7 @@ export interface PipelineStageItem {
merge_failure: string | null; merge_failure: string | null;
agent: AgentAssignment | null; agent: AgentAssignment | null;
review_hold: boolean | null; review_hold: boolean | null;
manual_qa: boolean | null; qa: string | null;
} }
export interface PipelineState { export interface PipelineState {

View File

@@ -60,7 +60,7 @@ describe("AgentLozenge fixed intrinsic width", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
const pipeline = makePipeline({ current: items }); const pipeline = makePipeline({ current: items });
@@ -114,7 +114,7 @@ describe("LozengeFlyProvider fly-in visibility", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -156,7 +156,7 @@ describe("LozengeFlyProvider fly-in visibility", () => {
status: "running", status: "running",
}, },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -220,7 +220,7 @@ describe("LozengeFlyProvider fly-in clone", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -263,7 +263,7 @@ describe("LozengeFlyProvider fly-in clone", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -312,7 +312,7 @@ describe("LozengeFlyProvider fly-in clone", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -383,7 +383,7 @@ describe("LozengeFlyProvider fly-out", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: "haiku", status: "completed" }, agent: { agent_name: "coder-1", model: "haiku", status: "completed" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -410,7 +410,7 @@ describe("LozengeFlyProvider fly-out", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -444,7 +444,7 @@ describe("AgentLozenge idle vs active appearance", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
const { container } = render( const { container } = render(
@@ -470,7 +470,7 @@ describe("AgentLozenge idle vs active appearance", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "pending" }, agent: { agent_name: "coder-1", model: null, status: "pending" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
const { container } = render( const { container } = render(
@@ -496,7 +496,7 @@ describe("AgentLozenge idle vs active appearance", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
const { container } = render( const { container } = render(
@@ -549,7 +549,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -572,7 +572,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -596,7 +596,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -658,7 +658,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "completed" }, agent: { agent_name: "coder-1", model: null, status: "completed" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -671,7 +671,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -715,7 +715,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "completed" }, agent: { agent_name: "coder-1", model: null, status: "completed" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -728,7 +728,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -803,7 +803,7 @@ describe("LozengeFlyProvider agent swap (name change)", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -816,7 +816,7 @@ describe("LozengeFlyProvider agent swap (name change)", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-2", model: "haiku", status: "running" }, agent: { agent_name: "coder-2", model: "haiku", status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -902,7 +902,7 @@ describe("LozengeFlyProvider fly-out without roster element", () => {
status: "completed", status: "completed",
}, },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -915,7 +915,7 @@ describe("LozengeFlyProvider fly-out without roster element", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -988,7 +988,7 @@ describe("FlyingLozengeClone initial non-flying render", () => {
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -1065,7 +1065,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -1078,7 +1078,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-2", model: "haiku", status: "running" }, agent: { agent_name: "coder-2", model: "haiku", status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -1146,7 +1146,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" }, agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -1159,7 +1159,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", ()
merge_failure: null, merge_failure: null,
agent: { agent_name: "coder-2", model: null, status: "running" }, agent: { agent_name: "coder-2", model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });
@@ -1246,7 +1246,7 @@ describe("Bug 137: animations remain functional through sustained agent activity
merge_failure: null, merge_failure: null,
agent: { agent_name: agentName, model: null, status: "running" }, agent: { agent_name: agentName, model: null, status: "running" },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
], ],
}); });

View File

@@ -18,7 +18,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Current" items={items} />); render(<StagePanel title="Current" items={items} />);
@@ -40,7 +40,7 @@ describe("StagePanel", () => {
status: "running", status: "running",
}, },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Current" items={items} />); render(<StagePanel title="Current" items={items} />);
@@ -61,7 +61,7 @@ describe("StagePanel", () => {
status: "running", status: "running",
}, },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Current" items={items} />); render(<StagePanel title="Current" items={items} />);
@@ -81,7 +81,7 @@ describe("StagePanel", () => {
status: "pending", status: "pending",
}, },
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="QA" items={items} />); render(<StagePanel title="QA" items={items} />);
@@ -97,7 +97,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Current" items={items} />); render(<StagePanel title="Current" items={items} />);
@@ -113,7 +113,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Upcoming" items={items} />); render(<StagePanel title="Upcoming" items={items} />);
@@ -129,7 +129,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Upcoming" items={items} />); render(<StagePanel title="Upcoming" items={items} />);
@@ -147,7 +147,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Current" items={items} />); render(<StagePanel title="Current" items={items} />);
@@ -165,7 +165,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="QA" items={items} />); render(<StagePanel title="QA" items={items} />);
@@ -183,7 +183,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Done" items={items} />); render(<StagePanel title="Done" items={items} />);
@@ -201,7 +201,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Upcoming" items={items} />); render(<StagePanel title="Upcoming" items={items} />);
@@ -222,7 +222,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Current" items={items} />); render(<StagePanel title="Current" items={items} />);
@@ -240,7 +240,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="QA" items={items} />); render(<StagePanel title="QA" items={items} />);
@@ -258,7 +258,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Done" items={items} />); render(<StagePanel title="Done" items={items} />);
@@ -276,7 +276,7 @@ describe("StagePanel", () => {
merge_failure: "Squash merge failed: conflicts in Cargo.lock", merge_failure: "Squash merge failed: conflicts in Cargo.lock",
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Merge" items={items} />); render(<StagePanel title="Merge" items={items} />);
@@ -297,7 +297,7 @@ describe("StagePanel", () => {
merge_failure: null, merge_failure: null,
agent: null, agent: null,
review_hold: null, review_hold: null,
manual_qa: null, qa: null,
}, },
]; ];
render(<StagePanel title="Merge" items={items} />); render(<StagePanel title="Merge" items={items} />);

View File

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

View File

@@ -11,6 +11,10 @@ pub struct ProjectConfig {
pub agent: Vec<AgentConfig>, pub agent: Vec<AgentConfig>,
#[serde(default)] #[serde(default)]
pub watcher: WatcherConfig, 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. /// Configuration for the filesystem watcher's sweep behaviour.
@@ -46,6 +50,10 @@ fn default_done_retention_secs() -> u64 {
4 * 60 * 60 // 4 hours 4 * 60 * 60 // 4 hours
} }
fn default_qa() -> String {
"server".to_string()
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct ComponentConfig { pub struct ComponentConfig {
@@ -124,6 +132,8 @@ struct LegacyProjectConfig {
agent: Option<AgentConfig>, agent: Option<AgentConfig>,
#[serde(default)] #[serde(default)]
watcher: WatcherConfig, watcher: WatcherConfig,
#[serde(default = "default_qa")]
default_qa: String,
} }
impl Default for ProjectConfig { impl Default for ProjectConfig {
@@ -145,6 +155,7 @@ impl Default for ProjectConfig {
inactivity_timeout_secs: default_inactivity_timeout_secs(), inactivity_timeout_secs: default_inactivity_timeout_secs(),
}], }],
watcher: WatcherConfig::default(), watcher: WatcherConfig::default(),
default_qa: default_qa(),
} }
} }
} }
@@ -186,6 +197,7 @@ impl ProjectConfig {
component: legacy.component, component: legacy.component,
agent: vec![agent], agent: vec![agent],
watcher: legacy.watcher, watcher: legacy.watcher,
default_qa: legacy.default_qa,
}; };
validate_agents(&config.agent)?; validate_agents(&config.agent)?;
return Ok(config); return Ok(config);
@@ -206,6 +218,7 @@ impl ProjectConfig {
component: legacy.component, component: legacy.component,
agent: vec![agent], agent: vec![agent],
watcher: legacy.watcher, watcher: legacy.watcher,
default_qa: legacy.default_qa,
}; };
validate_agents(&config.agent)?; validate_agents(&config.agent)?;
Ok(config) Ok(config)
@@ -214,12 +227,20 @@ impl ProjectConfig {
component: legacy.component, component: legacy.component,
agent: Vec::new(), agent: Vec::new(),
watcher: legacy.watcher, 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. /// Look up an agent config by name.
pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> { pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> {
self.agent.iter().find(|a| a.name == name) 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", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -27,9 +27,9 @@ pub struct UpcomingStory {
/// True when the item is held in QA for human review. /// True when the item is held in QA for human review.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub review_hold: Option<bool>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub manual_qa: Option<bool>, pub qa: Option<String>,
} }
pub struct StoryValidationResult { pub struct StoryValidationResult {
@@ -123,12 +123,12 @@ fn load_stage_items(
.to_string(); .to_string();
let contents = fs::read_to_string(&path) let contents = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?; .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) { 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.manual_qa), 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), Err(e) => (None, Some(e.to_string()), None, None, None),
}; };
let agent = agent_map.get(&story_id).cloned(); 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)); 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(); fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
let mut fields = HashMap::new(); 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()); fields.insert("priority".to_string(), "high".to_string());
update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap(); update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap();
let result = fs::read_to_string(&filepath).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("priority: \"high\""), "priority field should be set");
assert!(result.contains("name: T"), "name field preserved"); assert!(result.contains("name: T"), "name field preserved");
} }

View File

@@ -738,7 +738,7 @@ mod tests {
merge_failure: None, merge_failure: None,
agent: None, agent: None,
review_hold: None, review_hold: None,
manual_qa: None, qa: None,
}; };
let resp = WsResponse::PipelineState { let resp = WsResponse::PipelineState {
backlog: vec![story], backlog: vec![story],
@@ -877,7 +877,7 @@ mod tests {
merge_failure: None, merge_failure: None,
agent: None, agent: None,
review_hold: None, review_hold: None,
manual_qa: None, qa: None,
}], }],
current: vec![UpcomingStory { current: vec![UpcomingStory {
story_id: "2_story_b".to_string(), story_id: "2_story_b".to_string(),
@@ -886,7 +886,7 @@ mod tests {
merge_failure: None, merge_failure: None,
agent: None, agent: None,
review_hold: None, review_hold: None,
manual_qa: None, qa: None,
}], }],
qa: vec![], qa: vec![],
merge: vec![], merge: vec![],
@@ -897,7 +897,7 @@ mod tests {
merge_failure: None, merge_failure: None,
agent: None, agent: None,
review_hold: None, review_hold: None,
manual_qa: None, qa: None,
}], }],
}; };
let resp: WsResponse = state.into(); let resp: WsResponse = state.into();
@@ -1055,7 +1055,7 @@ mod tests {
status: "running".to_string(), status: "running".to_string(),
}), }),
review_hold: None, review_hold: None,
manual_qa: None, qa: None,
}], }],
qa: vec![], qa: vec![],
merge: 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" name = "coder-1"
stage = "coder" stage = "coder"
role = "Full-stack engineer. Implements features across all components." role = "Full-stack engineer. Implements features across all components."

View File

@@ -2,6 +2,45 @@ use serde::Deserialize;
use std::fs; use std::fs;
use std::path::Path; 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)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StoryMetadata { pub struct StoryMetadata {
pub name: Option<String>, pub name: Option<String>,
@@ -9,7 +48,7 @@ pub struct StoryMetadata {
pub merge_failure: Option<String>, pub merge_failure: Option<String>,
pub agent: Option<String>, pub agent: Option<String>,
pub review_hold: Option<bool>, pub review_hold: Option<bool>,
pub manual_qa: Option<bool>, pub qa: Option<QaMode>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -34,6 +73,9 @@ struct FrontMatter {
merge_failure: Option<String>, merge_failure: Option<String>,
agent: Option<String>, agent: Option<String>,
review_hold: Option<bool>, 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>, manual_qa: Option<bool>,
} }
@@ -63,13 +105,20 @@ pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaErro
} }
fn build_metadata(front: FrontMatter) -> StoryMetadata { 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 { StoryMetadata {
name: front.name, name: front.name,
coverage_baseline: front.coverage_baseline, coverage_baseline: front.coverage_baseline,
merge_failure: front.merge_failure, merge_failure: front.merge_failure,
agent: front.agent, agent: front.agent,
review_hold: front.review_hold, 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(()) Ok(())
} }
/// Check whether a story requires manual QA (defaults to false). /// Resolve the effective QA mode for a story file.
pub fn requires_manual_qa(path: &Path) -> bool { ///
/// 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) { let contents = match fs::read_to_string(path) {
Ok(c) => c, Ok(c) => c,
Err(_) => return false, Err(_) => return default,
}; };
match parse_front_matter(&contents) { match parse_front_matter(&contents) {
Ok(meta) => meta.manual_qa.unwrap_or(false), Ok(meta) => meta.qa.unwrap_or(default),
Err(_) => false, Err(_) => default,
} }
} }
@@ -398,41 +451,69 @@ workflow: tdd
} }
#[test] #[test]
fn parses_manual_qa_from_front_matter() { fn parses_qa_mode_from_front_matter() {
let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n"; let input = "---\nname: Story\nqa: server\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter"); 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] #[test]
fn manual_qa_defaults_to_none() { fn qa_mode_defaults_to_none() {
let input = "---\nname: Story\n---\n# Story\n"; let input = "---\nname: Story\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter"); let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.manual_qa, None); assert_eq!(meta.qa, None);
} }
#[test] #[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 tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md"); let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); 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] #[test]
fn requires_manual_qa_true_when_set() { fn resolve_qa_mode_missing_file_uses_default() {
let tmp = tempfile::tempdir().unwrap(); let path = std::path::Path::new("/nonexistent/story.md");
let path = tmp.path().join("story.md"); assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server);
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));
} }
#[test] #[test]

View File

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