story-kit: merge 306_story_replace_manual_qa_boolean_with_configurable_qa_mode_field
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|||||||
@@ -854,8 +854,46 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
PipelineStage::Coder => {
|
PipelineStage::Coder => {
|
||||||
if completion.gates_passed {
|
if completion.gates_passed {
|
||||||
|
// 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!(
|
slog!(
|
||||||
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. Moving to QA."
|
"[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) {
|
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}");
|
slog_error!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
|
||||||
@@ -865,6 +903,27 @@ impl AgentPool {
|
|||||||
{
|
{
|
||||||
slog_error!("[pipeline] Failed to start qa agent for '{story_id}': {e}");
|
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!(
|
||||||
"[pipeline] Coder '{agent_name}' failed gates for '{story_id}'. Restarting."
|
"[pipeline] Coder '{agent_name}' failed gates for '{story_id}'. Restarting."
|
||||||
@@ -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,7 +1792,41 @@ 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.
|
||||||
|
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) {
|
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}");
|
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
|
||||||
let _ = progress_tx.send(ReconciliationEvent {
|
let _ = progress_tx.send(ReconciliationEvent {
|
||||||
@@ -1746,6 +1842,33 @@ impl AgentPool {
|
|||||||
message: "Gates passed — moved to QA.".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.
|
||||||
let wt_path_for_cov = wt_path.clone();
|
let wt_path_for_cov = wt_path.clone();
|
||||||
@@ -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(¤t).unwrap();
|
fs::create_dir_all(¤t).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(¤t).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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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![],
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user