huskies: merge 492_story_remove_filesystem_pipeline_state_and_store_story_content_in_database
This commit is contained in:
@@ -30,11 +30,14 @@ impl AgentPool {
|
||||
let items = scan_stage_items(project_root, "1_backlog");
|
||||
for story_id in &items {
|
||||
// Only promote stories that explicitly declare dependencies.
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/1_backlog")
|
||||
.join(format!("{story_id}.md"));
|
||||
let has_deps = std::fs::read_to_string(&story_path)
|
||||
.ok()
|
||||
// Try content store first, fall back to filesystem.
|
||||
let contents = crate::db::read_content(story_id).or_else(|| {
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/1_backlog")
|
||||
.join(format!("{story_id}.md"));
|
||||
std::fs::read_to_string(&story_path).ok()
|
||||
});
|
||||
let has_deps = contents
|
||||
.and_then(|c| parse_front_matter(&c).ok())
|
||||
.and_then(|m| m.depends_on)
|
||||
.map(|d| !d.is_empty())
|
||||
@@ -121,17 +124,29 @@ impl AgentPool {
|
||||
"[auto-assign] Story '{story_id}' in 4_merge/ has no commits \
|
||||
on feature branch. Writing merge_failure and blocking."
|
||||
);
|
||||
let story_path = project_root
|
||||
.join(".huskies/work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let empty_diff_reason = "Feature branch has no code changes — the coder agent \
|
||||
did not produce any commits.";
|
||||
let _ = crate::io::story_metadata::write_merge_failure(
|
||||
&story_path,
|
||||
empty_diff_reason,
|
||||
);
|
||||
let _ = crate::io::story_metadata::write_blocked(&story_path);
|
||||
// Write merge_failure and blocked to content store.
|
||||
if let Some(contents) = crate::db::read_content(story_id) {
|
||||
let updated = crate::io::story_metadata::write_merge_failure_in_content(
|
||||
&contents,
|
||||
empty_diff_reason,
|
||||
);
|
||||
let blocked = crate::io::story_metadata::write_blocked_in_content(&updated);
|
||||
crate::db::write_content(story_id, &blocked);
|
||||
crate::db::write_item_with_content(story_id, stage_dir, &blocked);
|
||||
} else {
|
||||
// Fallback: filesystem.
|
||||
let story_path = project_root
|
||||
.join(".huskies/work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let _ = crate::io::story_metadata::write_merge_failure(
|
||||
&story_path,
|
||||
empty_diff_reason,
|
||||
);
|
||||
let _ = crate::io::story_metadata::write_blocked(&story_path);
|
||||
}
|
||||
let _ = self.watcher_tx.send(crate::io::watcher::WatcherEvent::StoryBlocked {
|
||||
story_id: story_id.to_string(),
|
||||
reason: empty_diff_reason.to_string(),
|
||||
|
||||
@@ -19,23 +19,32 @@ pub(in crate::agents::pool) fn is_agent_free(
|
||||
}
|
||||
|
||||
pub(super) fn scan_stage_items(project_root: &Path, stage_dir: &str) -> Vec<String> {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage_dir);
|
||||
if !dir.is_dir() {
|
||||
return Vec::new();
|
||||
use std::collections::BTreeSet;
|
||||
let mut items = BTreeSet::new();
|
||||
|
||||
// Include CRDT items — the primary source of truth for pipeline state.
|
||||
if let Some(all) = crate::crdt_state::read_all_items() {
|
||||
for item in &all {
|
||||
if item.stage == stage_dir {
|
||||
items.insert(item.story_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
|
||||
// Also include filesystem items (backwards compat / migration fallback).
|
||||
let dir = project_root.join(".huskies").join("work").join(stage_dir);
|
||||
if dir.is_dir() && let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md")
|
||||
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
||||
{
|
||||
items.push(stem.to_string());
|
||||
items.insert(stem.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
items.sort();
|
||||
items
|
||||
|
||||
items.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Return `true` if `story_id` has any active (pending/running) agent matching `stage`.
|
||||
|
||||
@@ -2,36 +2,45 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Read story contents from DB content store first, fall back to filesystem.
|
||||
fn read_story_contents(project_root: &Path, story_id: &str) -> Option<String> {
|
||||
// Primary: in-memory content store (backed by SQLite).
|
||||
if let Some(c) = crate::db::read_content(story_id) {
|
||||
return Some(c);
|
||||
}
|
||||
// Fallback: scan filesystem stages.
|
||||
for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
let path = project_root
|
||||
.join(".huskies/work")
|
||||
.join(stage)
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Ok(c) = std::fs::read_to_string(&path) {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the optional `agent:` field from the front matter of a story file.
|
||||
///
|
||||
/// Returns `Some(agent_name)` if the front matter specifies an agent, or `None`
|
||||
/// if the field is absent or the file cannot be read / parsed.
|
||||
pub(super) fn read_story_front_matter_agent(
|
||||
project_root: &Path,
|
||||
stage_dir: &str,
|
||||
_stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> Option<String> {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let contents = std::fs::read_to_string(path).ok()?;
|
||||
let contents = read_story_contents(project_root, story_id)?;
|
||||
parse_front_matter(&contents).ok()?.agent
|
||||
}
|
||||
|
||||
/// Return `true` if the story file in the given stage has `review_hold: true` in its front matter.
|
||||
pub(super) fn has_review_hold(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
||||
pub(super) fn has_review_hold(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
@@ -40,16 +49,11 @@ pub(super) fn has_review_hold(project_root: &Path, stage_dir: &str, story_id: &s
|
||||
}
|
||||
|
||||
/// Return `true` if the story file has `blocked: true` in its front matter.
|
||||
pub(super) fn is_story_blocked(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
||||
pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
@@ -81,16 +85,11 @@ pub(super) fn has_unmet_dependencies(
|
||||
}
|
||||
|
||||
/// Return `true` if the story file has a `merge_failure` field in its front matter.
|
||||
pub(super) fn has_merge_failure(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
||||
pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(stage_dir)
|
||||
.join(format!("{story_id}.md"));
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
let contents = match read_story_contents(project_root, story_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
|
||||
@@ -53,11 +53,7 @@ impl AgentPool {
|
||||
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(".huskies/work/2_current")
|
||||
.join(format!("{story_id}.md"));
|
||||
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa)
|
||||
resolve_qa_mode_from_store(&project_root, story_id, default_qa)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,24 +100,13 @@ impl AgentPool {
|
||||
if let Err(e) = crate::agents::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(".huskies/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}"
|
||||
);
|
||||
}
|
||||
write_review_hold_to_store(story_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Increment retry count and check if blocked.
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/2_current")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Some(reason) = should_block_story(&story_path, config.max_retries, story_id, "coder") {
|
||||
if let Some(reason) = should_block_story(story_id, config.max_retries, "coder") {
|
||||
// Story has exceeded retry limit — do not restart.
|
||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: story_id.to_string(),
|
||||
@@ -174,11 +159,9 @@ impl AgentPool {
|
||||
if item_type == "spike" {
|
||||
true // Spikes always need human review.
|
||||
} else {
|
||||
let qa_dir = project_root.join(".huskies/work/3_qa");
|
||||
let story_path = qa_dir.join(format!("{story_id}.md"));
|
||||
let default_qa = config.default_qa_mode();
|
||||
matches!(
|
||||
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa),
|
||||
resolve_qa_mode_from_store(&project_root, story_id, default_qa),
|
||||
crate::io::story_metadata::QaMode::Human
|
||||
)
|
||||
}
|
||||
@@ -186,15 +169,7 @@ impl AgentPool {
|
||||
|
||||
if needs_human_review {
|
||||
// Hold in 3_qa/ for human review.
|
||||
let qa_dir = project_root.join(".huskies/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}"
|
||||
);
|
||||
}
|
||||
write_review_hold_to_store(story_id);
|
||||
slog!(
|
||||
"[pipeline] QA passed for '{story_id}'. \
|
||||
Holding for human review. \
|
||||
@@ -220,51 +195,21 @@ impl AgentPool {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Some(reason) = should_block_story(&story_path, config.max_retries, story_id, "qa-coverage") {
|
||||
// Story has exceeded retry limit — do not restart.
|
||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: story_id.to_string(),
|
||||
reason,
|
||||
});
|
||||
} else {
|
||||
slog!(
|
||||
"[pipeline] QA coverage gate failed for '{story_id}'. Restarting QA."
|
||||
);
|
||||
let context = format!(
|
||||
"\n\n---\n## Coverage Gate Failed\n\
|
||||
The coverage gate (script/test_coverage) failed with the following output:\n{}\n\n\
|
||||
Please improve test coverage until the coverage gate passes.",
|
||||
coverage_output
|
||||
);
|
||||
if let Err(e) = self
|
||||
.start_agent(&project_root, story_id, Some("qa"), Some(&context))
|
||||
.await
|
||||
{
|
||||
slog_error!("[pipeline] Failed to restart qa for '{story_id}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Some(reason) = should_block_story(&story_path, config.max_retries, story_id, "qa") {
|
||||
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "qa-coverage") {
|
||||
// Story has exceeded retry limit — do not restart.
|
||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: story_id.to_string(),
|
||||
reason,
|
||||
});
|
||||
} else {
|
||||
slog!("[pipeline] QA failed gates for '{story_id}'. Restarting.");
|
||||
slog!(
|
||||
"[pipeline] QA coverage gate failed for '{story_id}'. Restarting QA."
|
||||
);
|
||||
let context = format!(
|
||||
"\n\n---\n## Previous QA Attempt Failed\n\
|
||||
The acceptance gates failed with the following output:\n{}\n\n\
|
||||
Please re-run and fix the issues.",
|
||||
completion.gate_output
|
||||
"\n\n---\n## Coverage Gate Failed\n\
|
||||
The coverage gate (script/test_coverage) failed with the following output:\n{}\n\n\
|
||||
Please improve test coverage until the coverage gate passes.",
|
||||
coverage_output
|
||||
);
|
||||
if let Err(e) = self
|
||||
.start_agent(&project_root, story_id, Some("qa"), Some(&context))
|
||||
@@ -273,6 +218,26 @@ impl AgentPool {
|
||||
slog_error!("[pipeline] Failed to restart qa for '{story_id}': {e}");
|
||||
}
|
||||
}
|
||||
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "qa") {
|
||||
// Story has exceeded retry limit — do not restart.
|
||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: story_id.to_string(),
|
||||
reason,
|
||||
});
|
||||
} else {
|
||||
slog!("[pipeline] QA failed gates for '{story_id}'. Restarting.");
|
||||
let context = format!(
|
||||
"\n\n---\n## Previous QA Attempt Failed\n\
|
||||
The acceptance gates failed with the following output:\n{}\n\n\
|
||||
Please re-run and fix the issues.",
|
||||
completion.gate_output
|
||||
);
|
||||
if let Err(e) = self
|
||||
.start_agent(&project_root, story_id, Some("qa"), Some(&context))
|
||||
.await
|
||||
{
|
||||
slog_error!("[pipeline] Failed to restart qa for '{story_id}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
PipelineStage::Mergemaster => {
|
||||
@@ -328,39 +293,34 @@ impl AgentPool {
|
||||
slog!(
|
||||
"[pipeline] Story '{story_id}' done. Worktree preserved for inspection."
|
||||
);
|
||||
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "mergemaster") {
|
||||
// Story has exceeded retry limit — do not restart.
|
||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: story_id.to_string(),
|
||||
reason,
|
||||
});
|
||||
} else {
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/4_merge")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Some(reason) = should_block_story(&story_path, config.max_retries, story_id, "mergemaster") {
|
||||
// Story has exceeded retry limit — do not restart.
|
||||
let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: story_id.to_string(),
|
||||
reason,
|
||||
});
|
||||
} else {
|
||||
slog!(
|
||||
"[pipeline] Post-merge tests failed for '{story_id}'. Restarting mergemaster."
|
||||
slog!(
|
||||
"[pipeline] Post-merge tests failed for '{story_id}'. Restarting mergemaster."
|
||||
);
|
||||
let context = format!(
|
||||
"\n\n---\n## Post-Merge Test Failed\n\
|
||||
The tests on master failed with the following output:\n{}\n\n\
|
||||
Please investigate and resolve the failures, then call merge_agent_work again.",
|
||||
output
|
||||
);
|
||||
if let Err(e) = self
|
||||
.start_agent(
|
||||
&project_root,
|
||||
story_id,
|
||||
Some("mergemaster"),
|
||||
Some(&context),
|
||||
)
|
||||
.await
|
||||
{
|
||||
slog_error!(
|
||||
"[pipeline] Failed to restart mergemaster for '{story_id}': {e}"
|
||||
);
|
||||
let context = format!(
|
||||
"\n\n---\n## Post-Merge Test Failed\n\
|
||||
The tests on master failed with the following output:\n{}\n\n\
|
||||
Please investigate and resolve the failures, then call merge_agent_work again.",
|
||||
output
|
||||
);
|
||||
if let Err(e) = self
|
||||
.start_agent(
|
||||
&project_root,
|
||||
story_id,
|
||||
Some("mergemaster"),
|
||||
Some(&context),
|
||||
)
|
||||
.await
|
||||
{
|
||||
slog_error!(
|
||||
"[pipeline] Failed to restart mergemaster for '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,43 +373,77 @@ pub(super) fn spawn_pipeline_advance(
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve QA mode from the content store (or filesystem fallback).
|
||||
fn resolve_qa_mode_from_store(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
default: crate::io::story_metadata::QaMode,
|
||||
) -> crate::io::story_metadata::QaMode {
|
||||
if let Some(contents) = crate::db::read_content(story_id) {
|
||||
return crate::io::story_metadata::resolve_qa_mode_from_content(&contents, default);
|
||||
}
|
||||
// Fallback: try filesystem.
|
||||
if let Ok(path) = crate::http::workflow::find_story_file_on_disk(project_root, story_id) {
|
||||
return crate::io::story_metadata::resolve_qa_mode(&path, default);
|
||||
}
|
||||
default
|
||||
}
|
||||
|
||||
/// Write review_hold to the content store.
|
||||
fn write_review_hold_to_store(story_id: &str) {
|
||||
if let Some(contents) = crate::db::read_content(story_id) {
|
||||
let updated = crate::io::story_metadata::write_review_hold_in_content(&contents);
|
||||
crate::db::write_content(story_id, &updated);
|
||||
// Also persist to SQLite via shadow write.
|
||||
let stage = crate::crdt_state::read_item(story_id)
|
||||
.map(|i| i.stage)
|
||||
.unwrap_or_else(|| "3_qa".to_string());
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
} else {
|
||||
slog_error!("[pipeline] Cannot write review_hold for '{story_id}': no content in store");
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment retry_count and block the story if it exceeds `max_retries`.
|
||||
///
|
||||
/// Returns `Some(reason)` if the story is now blocked (caller should NOT restart the agent).
|
||||
/// Returns `None` if the story may be retried.
|
||||
/// When `max_retries` is 0, retry limits are disabled.
|
||||
fn should_block_story(story_path: &Path, max_retries: u32, story_id: &str, stage_label: &str) -> Option<String> {
|
||||
use crate::io::story_metadata::{increment_retry_count, write_blocked};
|
||||
fn should_block_story(story_id: &str, max_retries: u32, stage_label: &str) -> Option<String> {
|
||||
use crate::io::story_metadata::{increment_retry_count_in_content, write_blocked_in_content};
|
||||
|
||||
if max_retries == 0 {
|
||||
// Retry limits disabled.
|
||||
return None;
|
||||
}
|
||||
|
||||
match increment_retry_count(story_path) {
|
||||
Ok(new_count) => {
|
||||
if new_count >= max_retries {
|
||||
slog_warn!(
|
||||
"[pipeline] Story '{story_id}' reached retry limit ({new_count}/{max_retries}) \
|
||||
at {stage_label} stage. Marking as blocked."
|
||||
);
|
||||
if let Err(e) = write_blocked(story_path) {
|
||||
slog_error!("[pipeline] Failed to write blocked flag for '{story_id}': {e}");
|
||||
}
|
||||
Some(format!(
|
||||
"Retry limit exceeded ({new_count}/{max_retries}) at {stage_label} stage"
|
||||
))
|
||||
} else {
|
||||
slog!(
|
||||
"[pipeline] Story '{story_id}' retry {new_count}/{max_retries} at {stage_label} stage."
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
slog_error!("[pipeline] Failed to increment retry_count for '{story_id}': {e}");
|
||||
None // Don't block on error — allow retry.
|
||||
if let Some(contents) = crate::db::read_content(story_id) {
|
||||
let (updated, new_count) = increment_retry_count_in_content(&contents);
|
||||
crate::db::write_content(story_id, &updated);
|
||||
let stage = crate::crdt_state::read_item(story_id)
|
||||
.map(|i| i.stage)
|
||||
.unwrap_or_else(|| "2_current".to_string());
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
|
||||
if new_count >= max_retries {
|
||||
slog_warn!(
|
||||
"[pipeline] Story '{story_id}' reached retry limit ({new_count}/{max_retries}) \
|
||||
at {stage_label} stage. Marking as blocked."
|
||||
);
|
||||
let blocked = write_blocked_in_content(&updated);
|
||||
crate::db::write_content(story_id, &blocked);
|
||||
crate::db::write_item_with_content(story_id, &stage, &blocked);
|
||||
Some(format!(
|
||||
"Retry limit exceeded ({new_count}/{max_retries}) at {stage_label} stage"
|
||||
))
|
||||
} else {
|
||||
slog!(
|
||||
"[pipeline] Story '{story_id}' retry {new_count}/{max_retries} at {stage_label} stage."
|
||||
);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
slog_error!("[pipeline] Failed to read content for '{story_id}' to increment retry_count");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,14 +462,15 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Set up story in 2_current/ (no qa frontmatter → uses project default "server")
|
||||
// Set up story in 2_current/ (no qa frontmatter → uses project default "server").
|
||||
// Use a unique high-numbered ID to avoid collision with the agent_qa test.
|
||||
let current = root.join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("50_story_test.md"), "test").unwrap();
|
||||
fs::write(current.join("9908_story_server_qa.md"), "test").unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.run_pipeline_advance(
|
||||
"50_story_test",
|
||||
"9908_story_server_qa",
|
||||
"coder-1",
|
||||
CompletionReport {
|
||||
summary: "done".to_string(),
|
||||
@@ -490,12 +485,12 @@ mod tests {
|
||||
|
||||
// With default qa: server, story skips QA and goes straight to 4_merge/
|
||||
assert!(
|
||||
root.join(".huskies/work/4_merge/50_story_test.md")
|
||||
root.join(".huskies/work/4_merge/9908_story_server_qa.md")
|
||||
.exists(),
|
||||
"story should be in 4_merge/"
|
||||
);
|
||||
assert!(
|
||||
!current.join("50_story_test.md").exists(),
|
||||
!current.join("9908_story_server_qa.md").exists(),
|
||||
"story should not still be in 2_current/"
|
||||
);
|
||||
}
|
||||
@@ -506,18 +501,19 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Set up story in 2_current/ with qa: agent frontmatter
|
||||
// Set up story in 2_current/ with qa: agent frontmatter.
|
||||
// Use a unique high-numbered ID to avoid collision with the server_qa test.
|
||||
let current = root.join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("50_story_test.md"),
|
||||
current.join("9909_story_agent_qa.md"),
|
||||
"---\nname: Test\nqa: agent\n---\ntest",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.run_pipeline_advance(
|
||||
"50_story_test",
|
||||
"9909_story_agent_qa",
|
||||
"coder-1",
|
||||
CompletionReport {
|
||||
summary: "done".to_string(),
|
||||
@@ -532,11 +528,11 @@ mod tests {
|
||||
|
||||
// With qa: agent, story should move to 3_qa/
|
||||
assert!(
|
||||
root.join(".huskies/work/3_qa/50_story_test.md").exists(),
|
||||
root.join(".huskies/work/3_qa/9909_story_agent_qa.md").exists(),
|
||||
"story should be in 3_qa/"
|
||||
);
|
||||
assert!(
|
||||
!current.join("50_story_test.md").exists(),
|
||||
!current.join("9909_story_agent_qa.md").exists(),
|
||||
"story should not still be in 2_current/"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1440,7 +1440,9 @@ stage = "coder"
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
let backlog = sk.join("work/1_backlog");
|
||||
let current = sk.join("work/2_current");
|
||||
std::fs::create_dir_all(&backlog).unwrap();
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
r#"
|
||||
@@ -1454,11 +1456,18 @@ stage = "coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let story_content = "---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n";
|
||||
std::fs::write(
|
||||
backlog.join("368_story_test.md"),
|
||||
"---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n",
|
||||
story_content,
|
||||
)
|
||||
.unwrap();
|
||||
// Also write to the filesystem current dir and content store so that
|
||||
// start_agent reads the correct front matter even when another test has
|
||||
// left a stale entry for "368_story_test" in the global CRDT.
|
||||
std::fs::write(current.join("368_story_test.md"), story_content).unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content("368_story_test", "2_current", story_content);
|
||||
|
||||
let pool = AgentPool::new_test(3011);
|
||||
// Preferred agent is busy — should NOT fall back to coder-sonnet.
|
||||
|
||||
@@ -24,6 +24,17 @@ impl AgentPool {
|
||||
/// story is not in any active stage (`2_current/`, `3_qa/`, `4_merge/`).
|
||||
pub(super) fn find_active_story_stage(project_root: &Path, story_id: &str) -> Option<&'static str> {
|
||||
const STAGES: [&str; 3] = ["2_current", "3_qa", "4_merge"];
|
||||
|
||||
// Try CRDT first — primary source of truth.
|
||||
if let Some(item) = crate::crdt_state::read_item(story_id) {
|
||||
for stage in &STAGES {
|
||||
if item.stage == *stage {
|
||||
return Some(stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check filesystem (backwards compat / tests).
|
||||
for stage in &STAGES {
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
|
||||
Reference in New Issue
Block a user