fix: add --all to cargo fmt in script/test and autoformat codebase
cargo fmt without --all fails with "Failed to find targets" in workspace repos. This was blocking every story's gates. Also ran cargo fmt --all to fix all existing formatting issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,9 +36,7 @@ pub(crate) fn worktree_has_committed_work(wt_path: &Path) -> bool {
|
||||
.current_dir(wt_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
!String::from_utf8_lossy(&out.stdout).trim().is_empty()
|
||||
}
|
||||
Ok(out) if out.status.success() => !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -258,14 +256,21 @@ mod tests {
|
||||
let script_dir = path.join("script");
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
let script_test = script_dir.join("test");
|
||||
fs::write(&script_test, "#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n").unwrap();
|
||||
fs::write(
|
||||
&script_test,
|
||||
"#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script_test, perms).unwrap();
|
||||
|
||||
let (passed, output) = run_project_tests(path).unwrap();
|
||||
assert!(passed, "script/test exiting 0 should pass");
|
||||
assert!(output.contains("script/test"), "output should mention script/test");
|
||||
assert!(
|
||||
output.contains("script/test"),
|
||||
"output should mention script/test"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -286,7 +291,10 @@ mod tests {
|
||||
|
||||
let (passed, output) = run_project_tests(path).unwrap();
|
||||
assert!(!passed, "script/test exiting 1 should fail");
|
||||
assert!(output.contains("script/test"), "output should mention script/test");
|
||||
assert!(
|
||||
output.contains("script/test"),
|
||||
"output should mention script/test"
|
||||
);
|
||||
}
|
||||
|
||||
// ── run_coverage_gate tests ───────────────────────────────────────────────
|
||||
@@ -347,7 +355,10 @@ mod tests {
|
||||
let script = script_dir.join("test_coverage");
|
||||
{
|
||||
let mut f = fs::File::create(&script).unwrap();
|
||||
f.write_all(b"#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n").unwrap();
|
||||
f.write_all(
|
||||
b"#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n",
|
||||
)
|
||||
.unwrap();
|
||||
f.sync_all().unwrap();
|
||||
}
|
||||
let mut perms = fs::metadata(&script).unwrap().permissions();
|
||||
|
||||
@@ -37,9 +37,7 @@ fn move_item<'a>(
|
||||
// Use the typed projection for compile-safe stage comparison.
|
||||
if let Ok(Some(typed_item)) = crate::pipeline_state::read_typed(story_id) {
|
||||
let current_dir = typed_item.stage.dir_name();
|
||||
if current_dir == target_dir
|
||||
|| extra_done_dirs.contains(¤t_dir)
|
||||
{
|
||||
if current_dir == target_dir || extra_done_dirs.contains(¤t_dir) {
|
||||
return Ok(None); // Idempotent: already there.
|
||||
}
|
||||
|
||||
@@ -77,11 +75,7 @@ fn move_item<'a>(
|
||||
}))
|
||||
};
|
||||
|
||||
crate::db::move_item_stage(
|
||||
story_id,
|
||||
target_dir,
|
||||
transform.as_ref().map(|f| f.as_ref()),
|
||||
);
|
||||
crate::db::move_item_stage(story_id, target_dir, transform.as_ref().map(|f| f.as_ref()));
|
||||
|
||||
slog!("[lifecycle] Moved '{story_id}' from work/{src_dir}/ to work/{target_dir}/");
|
||||
return Ok(Some(src_dir));
|
||||
@@ -121,7 +115,16 @@ fn move_item<'a>(
|
||||
/// that has already advanced past the coding stage.
|
||||
/// Idempotent: if already in `2_current/`, returns Ok. If not found, logs and returns Ok.
|
||||
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||
move_item(project_root, story_id, &["1_backlog"], "2_current", &[], true, &[]).map(|_| ())
|
||||
move_item(
|
||||
project_root,
|
||||
story_id,
|
||||
&["1_backlog"],
|
||||
"2_current",
|
||||
&[],
|
||||
true,
|
||||
&[],
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Check whether a feature branch `feature/story-{story_id}` exists and has
|
||||
@@ -205,12 +208,25 @@ pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), Strin
|
||||
}
|
||||
|
||||
/// Move a story from `work/3_qa/` back to `work/2_current/`, clearing `review_hold` and writing notes.
|
||||
pub fn reject_story_from_qa(project_root: &Path, story_id: &str, notes: &str) -> Result<(), String> {
|
||||
let moved = move_item(project_root, story_id, &["3_qa"], "2_current", &[], false, &["review_hold"])?;
|
||||
pub fn reject_story_from_qa(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
notes: &str,
|
||||
) -> Result<(), String> {
|
||||
let moved = move_item(
|
||||
project_root,
|
||||
story_id,
|
||||
&["3_qa"],
|
||||
"2_current",
|
||||
&[],
|
||||
false,
|
||||
&["review_hold"],
|
||||
)?;
|
||||
if moved.is_some() && !notes.is_empty() {
|
||||
// Append rejection notes to the stored content.
|
||||
if let Some(content) = crate::db::read_content(story_id) {
|
||||
let updated = crate::io::story_metadata::write_rejection_notes_to_content(&content, notes);
|
||||
let updated =
|
||||
crate::io::story_metadata::write_rejection_notes_to_content(&content, notes);
|
||||
crate::db::write_content(story_id, &updated);
|
||||
// Re-sync to DB.
|
||||
crate::db::write_item_with_content(story_id, "2_current", &updated);
|
||||
@@ -251,8 +267,16 @@ pub fn move_story_to_stage(
|
||||
|
||||
let all_dirs: Vec<&str> = STAGES.iter().map(|(_, dir)| *dir).collect();
|
||||
|
||||
match move_item(project_root, story_id, &all_dirs, target_dir, &[], false, &[])
|
||||
.map_err(|_| format!("Work item '{story_id}' not found in any pipeline stage."))?
|
||||
match move_item(
|
||||
project_root,
|
||||
story_id,
|
||||
&all_dirs,
|
||||
target_dir,
|
||||
&[],
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.map_err(|_| format!("Work item '{story_id}' not found in any pipeline stage."))?
|
||||
{
|
||||
Some(src_dir) => {
|
||||
let from_stage = STAGES
|
||||
|
||||
@@ -248,7 +248,9 @@ pub(crate) fn run_squash_merge(
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check merge diff: {e}"))?;
|
||||
let changed_files = String::from_utf8_lossy(&diff_check.stdout);
|
||||
let has_code_changes = changed_files.lines().any(|f| !f.starts_with(".huskies/work/"));
|
||||
let has_code_changes = changed_files
|
||||
.lines()
|
||||
.any(|f| !f.starts_with(".huskies/work/"));
|
||||
if !has_code_changes {
|
||||
all_output.push_str(
|
||||
"=== Merge commit contains only .huskies/ file moves, no code changes ===\n",
|
||||
@@ -423,7 +425,14 @@ pub(crate) fn run_squash_merge(
|
||||
// Exclude .huskies/work/ (pipeline file moves) but keep .huskies/project.toml
|
||||
// and other config files which are legitimate deliverables.
|
||||
let diff_stat = Command::new("git")
|
||||
.args(["diff", "--stat", "HEAD~1..HEAD", "--", ".", ":(exclude).huskies/work"])
|
||||
.args([
|
||||
"diff",
|
||||
"--stat",
|
||||
"HEAD~1..HEAD",
|
||||
"--",
|
||||
".",
|
||||
":(exclude).huskies/work",
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
|
||||
@@ -64,8 +64,7 @@ impl AgentPool {
|
||||
}
|
||||
// All deps met — promote from backlog to current.
|
||||
slog!("[auto-assign] Story '{story_id}' deps met; promoting from backlog to current.");
|
||||
if let Err(e) =
|
||||
crate::agents::lifecycle::move_story_to_current(project_root, story_id)
|
||||
if let Err(e) = crate::agents::lifecycle::move_story_to_current(project_root, story_id)
|
||||
{
|
||||
slog!("[auto-assign] Failed to promote '{story_id}' to current: {e}");
|
||||
}
|
||||
@@ -160,10 +159,12 @@ impl AgentPool {
|
||||
);
|
||||
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(),
|
||||
});
|
||||
let _ = self
|
||||
.watcher_tx
|
||||
.send(crate::io::watcher::WatcherEvent::StoryBlocked {
|
||||
story_id: story_id.to_string(),
|
||||
reason: empty_diff_reason.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -570,9 +571,12 @@ mod tests {
|
||||
pool.auto_assign_available_work(root).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let has_pending = agents
|
||||
.values()
|
||||
.any(|a| matches!(a.status, crate::agents::AgentStatus::Pending | crate::agents::AgentStatus::Running));
|
||||
let has_pending = agents.values().any(|a| {
|
||||
matches!(
|
||||
a.status,
|
||||
crate::agents::AgentStatus::Pending | crate::agents::AgentStatus::Running
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
has_pending,
|
||||
"story with all deps done should be auto-assigned"
|
||||
|
||||
@@ -161,17 +161,19 @@ impl AgentPool {
|
||||
|
||||
match qa_mode {
|
||||
crate::io::story_metadata::QaMode::Server => {
|
||||
if let Err(e) =
|
||||
crate::agents::move_story_to_merge(project_root, story_id)
|
||||
{
|
||||
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 4_merge/: {e}");
|
||||
if let Err(e) = crate::agents::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).");
|
||||
eprintln!(
|
||||
"[startup:reconcile] Moved '{story_id}' → 4_merge/ (qa: server)."
|
||||
);
|
||||
let _ = progress_tx.send(ReconciliationEvent {
|
||||
story_id: story_id.clone(),
|
||||
status: "advanced".to_string(),
|
||||
@@ -180,10 +182,10 @@ impl AgentPool {
|
||||
}
|
||||
}
|
||||
crate::io::story_metadata::QaMode::Agent => {
|
||||
if let Err(e) =
|
||||
crate::agents::move_story_to_qa(project_root, story_id)
|
||||
{
|
||||
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
|
||||
if let Err(e) = crate::agents::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(),
|
||||
@@ -199,10 +201,10 @@ impl AgentPool {
|
||||
}
|
||||
}
|
||||
crate::io::story_metadata::QaMode::Human => {
|
||||
if let Err(e) =
|
||||
crate::agents::move_story_to_qa(project_root, story_id)
|
||||
{
|
||||
eprintln!("[startup:reconcile] Failed to move '{story_id}' to 3_qa/: {e}");
|
||||
if let Err(e) = crate::agents::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(),
|
||||
@@ -219,7 +221,9 @@ impl AgentPool {
|
||||
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
eprintln!("[startup:reconcile] Moved '{story_id}' → 3_qa/ (qa: human — holding for review).");
|
||||
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(),
|
||||
@@ -284,9 +288,7 @@ impl AgentPool {
|
||||
let story_path = project_root
|
||||
.join(".huskies/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if let Err(e) =
|
||||
crate::io::story_metadata::write_review_hold(&story_path)
|
||||
{
|
||||
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}"
|
||||
);
|
||||
|
||||
@@ -31,7 +31,9 @@ pub(super) fn scan_stage_items(project_root: &Path, stage_dir: &str) -> Vec<Stri
|
||||
|
||||
// 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) {
|
||||
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")
|
||||
@@ -576,7 +578,9 @@ stage = "coder"
|
||||
);
|
||||
|
||||
let count = count_active_agents_for_stage(&config, &agents, &PipelineStage::Coder);
|
||||
assert_eq!(count, 1, "Only Running coder should be counted, not Completed");
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Only Running coder should be counted, not Completed"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -52,18 +52,18 @@ pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id:
|
||||
///
|
||||
/// Reads dependency state from the CRDT document first. Falls back to the
|
||||
/// filesystem when the CRDT layer is not initialised.
|
||||
pub(super) fn has_unmet_dependencies(
|
||||
project_root: &Path,
|
||||
stage_dir: &str,
|
||||
story_id: &str,
|
||||
) -> bool {
|
||||
pub(super) fn has_unmet_dependencies(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
||||
// Prefer CRDT-based check.
|
||||
let crdt_deps = crate::crdt_state::check_unmet_deps_crdt(story_id);
|
||||
if !crdt_deps.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// If the CRDT had the item and returned empty deps, it means all are met.
|
||||
if crate::pipeline_state::read_typed(story_id).ok().flatten().is_some() {
|
||||
if crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Fallback: filesystem check (CRDT not initialised or item not yet in CRDT).
|
||||
@@ -82,7 +82,11 @@ pub(super) fn check_archived_dependencies(
|
||||
story_id: &str,
|
||||
) -> Vec<u32> {
|
||||
// Prefer CRDT-based check when the item is known to CRDT.
|
||||
if crate::pipeline_state::read_typed(story_id).ok().flatten().is_some() {
|
||||
if crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return crate::crdt_state::check_archived_deps_crdt(story_id);
|
||||
}
|
||||
// Fallback: filesystem.
|
||||
@@ -146,7 +150,11 @@ mod tests {
|
||||
"---\nname: Blocked\ndepends_on: [999]\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_unmet_dependencies(tmp.path(), "2_current", "10_story_blocked"));
|
||||
assert!(has_unmet_dependencies(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"10_story_blocked"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -162,7 +170,11 @@ mod tests {
|
||||
"---\nname: Ok\ndepends_on: [999]\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!has_unmet_dependencies(tmp.path(), "2_current", "10_story_ok"));
|
||||
assert!(!has_unmet_dependencies(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"10_story_ok"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -171,7 +183,11 @@ mod tests {
|
||||
let current = tmp.path().join(".huskies/work/2_current");
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
std::fs::write(current.join("5_story_free.md"), "---\nname: Free\n---\n").unwrap();
|
||||
assert!(!has_unmet_dependencies(tmp.path(), "2_current", "5_story_free"));
|
||||
assert!(!has_unmet_dependencies(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"5_story_free"
|
||||
));
|
||||
}
|
||||
|
||||
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
|
||||
@@ -184,7 +200,11 @@ mod tests {
|
||||
let archived = tmp.path().join(".huskies/work/6_archived");
|
||||
std::fs::create_dir_all(&backlog).unwrap();
|
||||
std::fs::create_dir_all(&archived).unwrap();
|
||||
std::fs::write(archived.join("500_spike_crdt.md"), "---\nname: CRDT Spike\n---\n").unwrap();
|
||||
std::fs::write(
|
||||
archived.join("500_spike_crdt.md"),
|
||||
"---\nname: CRDT Spike\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
backlog.join("503_story_dependent.md"),
|
||||
"---\nname: Dependent\ndepends_on: [500]\n---\n",
|
||||
|
||||
@@ -84,8 +84,8 @@ impl AgentPool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::super::{AgentPool, composite_key};
|
||||
use super::*;
|
||||
|
||||
// ── check_orphaned_agents return value tests (bug 161) ──────────────────
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Agent pool — manages the set of active agents across all pipeline stages.
|
||||
mod auto_assign;
|
||||
mod pipeline;
|
||||
mod start;
|
||||
mod stop;
|
||||
mod wait;
|
||||
mod process;
|
||||
mod query;
|
||||
mod start;
|
||||
mod stop;
|
||||
mod types;
|
||||
mod wait;
|
||||
mod worktree;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -68,10 +68,15 @@ impl AgentPool {
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
};
|
||||
let (story_id, agent_name) = match &event {
|
||||
WatcherEvent::RateLimitWarning { story_id, agent_name }
|
||||
| WatcherEvent::RateLimitHardBlock { story_id, agent_name, .. } => {
|
||||
(story_id.clone(), agent_name.clone())
|
||||
WatcherEvent::RateLimitWarning {
|
||||
story_id,
|
||||
agent_name,
|
||||
}
|
||||
| WatcherEvent::RateLimitHardBlock {
|
||||
story_id,
|
||||
agent_name,
|
||||
..
|
||||
} => (story_id.clone(), agent_name.clone()),
|
||||
_ => continue,
|
||||
};
|
||||
let key = composite_key(&story_id, &agent_name);
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
//! Pipeline advance — moves stories forward through pipeline stages after agent completion.
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::slog;
|
||||
use crate::slog_error;
|
||||
use crate::slog_warn;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::super::super::{
|
||||
CompletionReport, PipelineStage,
|
||||
agent_config_stage, pipeline_stage,
|
||||
};
|
||||
use super::super::super::{CompletionReport, PipelineStage, agent_config_stage, pipeline_stage};
|
||||
use super::super::{AgentPool, StoryAgent};
|
||||
|
||||
impl AgentPool {
|
||||
@@ -66,14 +63,16 @@ impl AgentPool {
|
||||
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
||||
qa: server — moving directly to merge."
|
||||
);
|
||||
if let Err(e) =
|
||||
crate::agents::lifecycle::move_story_to_merge(&project_root, story_id)
|
||||
{
|
||||
if let Err(e) = crate::agents::lifecycle::move_story_to_merge(
|
||||
&project_root,
|
||||
story_id,
|
||||
) {
|
||||
slog_error!(
|
||||
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||
);
|
||||
} else {
|
||||
self.start_mergemaster_or_block(&project_root, story_id).await;
|
||||
self.start_mergemaster_or_block(&project_root, story_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
crate::io::story_metadata::QaMode::Agent => {
|
||||
@@ -81,13 +80,17 @@ impl AgentPool {
|
||||
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
||||
qa: agent — moving to QA."
|
||||
);
|
||||
if let Err(e) = crate::agents::lifecycle::move_story_to_qa(&project_root, story_id) {
|
||||
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 if let Err(e) = self
|
||||
.start_agent(&project_root, story_id, Some("qa"), None, None)
|
||||
.await
|
||||
{
|
||||
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 => {
|
||||
@@ -95,7 +98,9 @@ impl AgentPool {
|
||||
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. \
|
||||
qa: human — holding for human review."
|
||||
);
|
||||
if let Err(e) = crate::agents::lifecycle::move_story_to_qa(&project_root, story_id) {
|
||||
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 {
|
||||
write_review_hold_to_store(story_id);
|
||||
@@ -104,7 +109,8 @@ impl AgentPool {
|
||||
}
|
||||
} else {
|
||||
// Increment retry count and check if blocked.
|
||||
if let Some(reason) = should_block_story(story_id, config.max_retries, "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(),
|
||||
@@ -144,13 +150,14 @@ impl AgentPool {
|
||||
.clone()
|
||||
.unwrap_or_else(|| project_root.clone());
|
||||
let cp = coverage_path.clone();
|
||||
let coverage_result =
|
||||
tokio::task::spawn_blocking(move || crate::agents::gates::run_coverage_gate(&cp))
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
slog_warn!("[pipeline] Coverage gate task panicked: {e}");
|
||||
Ok((false, format!("Coverage gate task panicked: {e}")))
|
||||
});
|
||||
let coverage_result = tokio::task::spawn_blocking(move || {
|
||||
crate::agents::gates::run_coverage_gate(&cp)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
slog_warn!("[pipeline] Coverage gate task panicked: {e}");
|
||||
Ok((false, format!("Coverage gate task panicked: {e}")))
|
||||
});
|
||||
let (coverage_passed, coverage_output) = match coverage_result {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => (false, e),
|
||||
@@ -184,17 +191,21 @@ impl AgentPool {
|
||||
"[pipeline] QA passed gates and coverage for '{story_id}'. \
|
||||
Moving directly to merge."
|
||||
);
|
||||
if let Err(e) =
|
||||
crate::agents::lifecycle::move_story_to_merge(&project_root, story_id)
|
||||
{
|
||||
if let Err(e) = crate::agents::lifecycle::move_story_to_merge(
|
||||
&project_root,
|
||||
story_id,
|
||||
) {
|
||||
slog_error!(
|
||||
"[pipeline] Failed to move '{story_id}' to 4_merge/: {e}"
|
||||
);
|
||||
} else {
|
||||
self.start_mergemaster_or_block(&project_root, story_id).await;
|
||||
self.start_mergemaster_or_block(&project_root, story_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else if let Some(reason) = should_block_story(story_id, config.max_retries, "qa-coverage") {
|
||||
} 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(),
|
||||
@@ -217,7 +228,8 @@ 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") {
|
||||
} 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(),
|
||||
@@ -272,13 +284,14 @@ impl AgentPool {
|
||||
"[pipeline] Mergemaster completed for '{story_id}'. Running post-merge tests on master."
|
||||
);
|
||||
let root = project_root.clone();
|
||||
let test_result =
|
||||
tokio::task::spawn_blocking(move || crate::agents::gates::run_project_tests(&root))
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
slog_warn!("[pipeline] Post-merge test task panicked: {e}");
|
||||
Ok((false, format!("Test task panicked: {e}")))
|
||||
});
|
||||
let test_result = tokio::task::spawn_blocking(move || {
|
||||
crate::agents::gates::run_project_tests(&root)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
slog_warn!("[pipeline] Post-merge test task panicked: {e}");
|
||||
Ok((false, format!("Test task panicked: {e}")))
|
||||
});
|
||||
let (passed, output) = match test_result {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => (false, e),
|
||||
@@ -309,7 +322,9 @@ 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") {
|
||||
} 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(),
|
||||
@@ -564,7 +579,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_content("9909_story_agent_qa", "---\nname: Test\nqa: agent\n---\ntest");
|
||||
crate::db::write_content(
|
||||
"9909_story_agent_qa",
|
||||
"---\nname: Test\nqa: agent\n---\ntest",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.run_pipeline_advance(
|
||||
@@ -758,10 +776,26 @@ stage = "qa"
|
||||
let root = tmp.path();
|
||||
|
||||
// Init a bare git repo on master with one empty commit.
|
||||
Command::new("git").args(["init"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "Test"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "--allow-empty", "-m", "init"]).current_dir(root).output().unwrap();
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create a feature branch that points at master HEAD (zero commits ahead).
|
||||
// This replicates the incident where the worktree was reset to master.
|
||||
@@ -775,7 +809,11 @@ stage = "qa"
|
||||
let current = root.join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(root.join(".huskies/work/4_merge")).unwrap();
|
||||
fs::write(current.join("9919_story_no_commits.md"), "---\nname: Test\n---\n").unwrap();
|
||||
fs::write(
|
||||
current.join("9919_story_no_commits.md"),
|
||||
"---\nname: Test\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_content("9919_story_no_commits", "---\nname: Test\n---\n");
|
||||
|
||||
@@ -835,8 +873,8 @@ stage = "qa"
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_advance_picks_up_waiting_qa_stories_after_completion() {
|
||||
use std::fs;
|
||||
use super::super::super::auto_assign::is_agent_free;
|
||||
use std::fs;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
@@ -908,8 +946,7 @@ stage = "qa"
|
||||
// After pipeline advance, auto_assign should have started QA on story 293.
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let qa_on_293 = agents.values().any(|a| {
|
||||
a.agent_name == "qa"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
a.agent_name == "qa" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
qa_on_293,
|
||||
@@ -940,10 +977,26 @@ stage = "qa"
|
||||
let root = tmp.path();
|
||||
|
||||
// Init a git repo so post-merge tests would pass if they ran.
|
||||
Command::new("git").args(["init"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "Test"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "--allow-empty", "-m", "init"]).current_dir(root).output().unwrap();
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Set up pipeline dirs.
|
||||
fs::create_dir_all(root.join(".huskies/work/5_done")).unwrap();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Agent completion handling — processes exit results and triggers pipeline advancement.
|
||||
use crate::slog;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::slog;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::super::super::{AgentEvent, AgentStatus, CompletionReport, PipelineStage, pipeline_stage};
|
||||
use super::super::super::{
|
||||
AgentEvent, AgentStatus, CompletionReport, PipelineStage, pipeline_stage,
|
||||
};
|
||||
use super::super::{AgentPool, StoryAgent, composite_key};
|
||||
use super::advance::spawn_pipeline_advance;
|
||||
|
||||
@@ -207,7 +209,10 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
|
||||
// hold the build lock while gates try to run.
|
||||
if let Some(wt_path) = worktree_path.as_ref()
|
||||
&& let Ok(output) = std::process::Command::new("pgrep")
|
||||
.args(["-f", &format!("--manifest-path {}/Cargo.toml", wt_path.display())])
|
||||
.args([
|
||||
"-f",
|
||||
&format!("--manifest-path {}/Cargo.toml", wt_path.display()),
|
||||
])
|
||||
.output()
|
||||
{
|
||||
let pids = String::from_utf8_lossy(&output.stdout);
|
||||
@@ -216,7 +221,9 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
|
||||
crate::slog!(
|
||||
"[agents] Killing stale cargo process (pid {pid}) for '{story_id}' before running gates"
|
||||
);
|
||||
unsafe { libc::kill(pid, libc::SIGKILL); }
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,8 +318,8 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::super::AgentPool;
|
||||
use super::*;
|
||||
use crate::agents::{AgentEvent, AgentStatus, CompletionReport};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -85,10 +85,11 @@ impl AgentPool {
|
||||
let sid = story_id.to_string();
|
||||
let br = branch.clone();
|
||||
|
||||
let merge_result =
|
||||
tokio::task::spawn_blocking(move || crate::agents::merge::run_squash_merge(&root, &br, &sid))
|
||||
.await
|
||||
.map_err(|e| format!("Merge task panicked: {e}"))??;
|
||||
let merge_result = tokio::task::spawn_blocking(move || {
|
||||
crate::agents::merge::run_squash_merge(&root, &br, &sid)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Merge task panicked: {e}"))??;
|
||||
|
||||
if !merge_result.success {
|
||||
return Ok(crate::agents::merge::MergeReport {
|
||||
@@ -185,8 +186,8 @@ impl AgentPool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::super::AgentPool;
|
||||
use super::*;
|
||||
use crate::agents::merge::{MergeJob, MergeJobStatus};
|
||||
use std::process::Command;
|
||||
|
||||
|
||||
@@ -34,7 +34,11 @@ impl AgentPool {
|
||||
|
||||
/// Test helper: inject a child killer into the registry.
|
||||
#[cfg(test)]
|
||||
pub fn inject_child_killer(&self, key: &str, killer: Box<dyn portable_pty::ChildKiller + Send + Sync>) {
|
||||
pub fn inject_child_killer(
|
||||
&self,
|
||||
key: &str,
|
||||
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
|
||||
) {
|
||||
let mut killers = self.child_killers.lock().unwrap();
|
||||
killers.insert(key.to_string(), killer);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::path::PathBuf;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::super::{AgentEvent, AgentInfo, AgentStatus, PipelineStage, agent_config_stage};
|
||||
use super::types::{agent_info_from_entry, composite_key};
|
||||
use super::AgentPool;
|
||||
use super::types::{agent_info_from_entry, composite_key};
|
||||
|
||||
impl AgentPool {
|
||||
/// Return the names of configured agents for `stage` that are not currently
|
||||
|
||||
@@ -6,14 +6,15 @@ use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::super::runtime::{
|
||||
AgentRuntime, ClaudeCodeRuntime, GeminiRuntime, OpenAiRuntime, RuntimeContext,
|
||||
};
|
||||
use super::super::{
|
||||
AgentEvent, AgentInfo, AgentStatus, PipelineStage, agent_config_stage,
|
||||
pipeline_stage,
|
||||
AgentEvent, AgentInfo, AgentStatus, PipelineStage, agent_config_stage, pipeline_stage,
|
||||
};
|
||||
use super::types::{PendingGuard, StoryAgent, composite_key};
|
||||
use super::{AgentPool, auto_assign};
|
||||
use super::worktree::find_active_story_stage;
|
||||
use super::super::runtime::{AgentRuntime, ClaudeCodeRuntime, GeminiRuntime, OpenAiRuntime, RuntimeContext};
|
||||
use super::{AgentPool, auto_assign};
|
||||
|
||||
impl AgentPool {
|
||||
/// Start an agent for a story: load config, create worktree, spawn agent.
|
||||
@@ -102,7 +103,9 @@ impl AgentPool {
|
||||
// the auto_assign path (bug 379).
|
||||
let front_matter_agent: Option<String> = if agent_name.is_none() {
|
||||
crate::db::read_content(story_id).and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents).ok()?.agent
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()?
|
||||
.agent
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -446,7 +449,10 @@ impl AgentPool {
|
||||
|
||||
let run_result = match runtime_name {
|
||||
"claude-code" => {
|
||||
let runtime = ClaudeCodeRuntime::new(child_killers_clone.clone(), watcher_tx_clone.clone());
|
||||
let runtime = ClaudeCodeRuntime::new(
|
||||
child_killers_clone.clone(),
|
||||
watcher_tx_clone.clone(),
|
||||
);
|
||||
let ctx = RuntimeContext {
|
||||
story_id: sid.clone(),
|
||||
agent_name: aname.clone(),
|
||||
@@ -514,7 +520,10 @@ impl AgentPool {
|
||||
.find_agent(&aname)
|
||||
.and_then(|a| a.model.clone());
|
||||
let record = crate::agents::token_usage::build_record(
|
||||
&sid, &aname, model, usage.clone(),
|
||||
&sid,
|
||||
&aname,
|
||||
model,
|
||||
usage.clone(),
|
||||
);
|
||||
if let Err(e) = crate::agents::token_usage::append_record(pr, &record) {
|
||||
slog_error!(
|
||||
@@ -568,15 +577,13 @@ impl AgentPool {
|
||||
// re-dispatches a new mergemaster if the story still needs
|
||||
// merging. This avoids an async call to start_agent inside
|
||||
// a tokio::spawn (which would require Send).
|
||||
let _ = watcher_tx_clone.send(
|
||||
crate::io::watcher::WatcherEvent::WorkItem {
|
||||
stage: "4_merge".to_string(),
|
||||
item_id: sid.clone(),
|
||||
action: "reassign".to_string(),
|
||||
commit_msg: String::new(),
|
||||
from_stage: None,
|
||||
},
|
||||
);
|
||||
let _ = watcher_tx_clone.send(crate::io::watcher::WatcherEvent::WorkItem {
|
||||
stage: "4_merge".to_string(),
|
||||
item_id: sid.clone(),
|
||||
action: "reassign".to_string(),
|
||||
commit_msg: String::new(),
|
||||
from_stage: None,
|
||||
});
|
||||
} else {
|
||||
// Server-owned completion: run acceptance gates automatically
|
||||
// when the agent process exits normally.
|
||||
@@ -712,7 +719,9 @@ stage = "coder"
|
||||
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
||||
pool.inject_test_agent("story-2", "coder-2", AgentStatus::Pending);
|
||||
|
||||
let result = pool.start_agent(tmp.path(), "story-3", None, None, None).await;
|
||||
let result = pool
|
||||
.start_agent(tmp.path(), "story-3", None, None, None)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
@@ -744,7 +753,9 @@ stage = "coder"
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.inject_test_agent("story-1", "coder-1", AgentStatus::Running);
|
||||
|
||||
let result = pool.start_agent(tmp.path(), "story-3", None, None, None).await;
|
||||
let result = pool
|
||||
.start_agent(tmp.path(), "story-3", None, None, None)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
@@ -782,7 +793,9 @@ stage = "coder"
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
|
||||
let result = pool.start_agent(tmp.path(), "story-5", None, None, None).await;
|
||||
let result = pool
|
||||
.start_agent(tmp.path(), "story-5", None, None, None)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@@ -843,7 +856,9 @@ stage = "coder"
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.inject_test_agent("story-a", "qa", AgentStatus::Running);
|
||||
|
||||
let result = pool.start_agent(root, "story-b", Some("qa"), None, None).await;
|
||||
let result = pool
|
||||
.start_agent(root, "story-b", Some("qa"), None, None)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
@@ -870,7 +885,9 @@ stage = "coder"
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.inject_test_agent("story-a", "qa", AgentStatus::Completed);
|
||||
|
||||
let result = pool.start_agent(root, "story-b", Some("qa"), None, None).await;
|
||||
let result = pool
|
||||
.start_agent(root, "story-b", Some("qa"), None, None)
|
||||
.await;
|
||||
|
||||
if let Err(ref e) = result {
|
||||
assert!(
|
||||
@@ -962,7 +979,9 @@ stage = "coder"
|
||||
let pool = AgentPool::new_test(3099);
|
||||
pool.inject_test_agent("story-x", "qa", AgentStatus::Running);
|
||||
|
||||
let result = pool.start_agent(root, "story-y", Some("qa"), None, None).await;
|
||||
let result = pool
|
||||
.start_agent(root, "story-y", Some("qa"), None, None)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
@@ -1247,11 +1266,7 @@ stage = "coder"
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"310_story_foo",
|
||||
"2_current",
|
||||
"---\nname: Foo\n---\n",
|
||||
);
|
||||
crate::db::write_item_with_content("310_story_foo", "2_current", "---\nname: Foo\n---\n");
|
||||
|
||||
let pool = AgentPool::new_test(3099);
|
||||
let result = pool
|
||||
@@ -1323,11 +1338,7 @@ stage = "coder"
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"55_story_baz",
|
||||
"4_merge",
|
||||
"---\nname: Baz\n---\n",
|
||||
);
|
||||
crate::db::write_item_with_content("55_story_baz", "4_merge", "---\nname: Baz\n---\n");
|
||||
|
||||
let pool = AgentPool::new_test(3099);
|
||||
let result = pool
|
||||
@@ -1459,7 +1470,13 @@ stage = "coder"
|
||||
|
||||
let pool = AgentPool::new_test(3098);
|
||||
let result = pool
|
||||
.start_agent(root, "502_story_split_brain", Some("mergemaster"), None, None)
|
||||
.start_agent(
|
||||
root,
|
||||
"502_story_split_brain",
|
||||
Some("mergemaster"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Stage check must not reject mergemaster.
|
||||
@@ -1475,11 +1492,15 @@ stage = "coder"
|
||||
// Before the fix, line 53 of start.rs would have demoted it to
|
||||
// 2_current/ via move_story_to_current finding the 1_backlog shadow.
|
||||
assert!(
|
||||
sk_dir.join("work/4_merge/502_story_split_brain.md").exists(),
|
||||
sk_dir
|
||||
.join("work/4_merge/502_story_split_brain.md")
|
||||
.exists(),
|
||||
"story must still be in 4_merge/ after start_agent(mergemaster, ...)"
|
||||
);
|
||||
assert!(
|
||||
!sk_dir.join("work/2_current/502_story_split_brain.md").exists(),
|
||||
!sk_dir
|
||||
.join("work/2_current/502_story_split_brain.md")
|
||||
.exists(),
|
||||
"story must NOT have been demoted to 2_current/ — that's bug 502"
|
||||
);
|
||||
}
|
||||
@@ -1564,11 +1585,7 @@ 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"),
|
||||
story_content,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(backlog.join("368_story_test.md"), 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.
|
||||
@@ -1583,7 +1600,10 @@ stage = "coder"
|
||||
let result = pool
|
||||
.start_agent(tmp.path(), "368_story_test", None, None, None)
|
||||
.await;
|
||||
assert!(result.is_err(), "expected error when preferred agent is busy");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected error when preferred agent is busy"
|
||||
);
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("coder-opus"),
|
||||
|
||||
@@ -4,8 +4,8 @@ use crate::slog_error;
|
||||
use std::path::Path;
|
||||
|
||||
use super::super::{AgentEvent, AgentStatus};
|
||||
use super::types::composite_key;
|
||||
use super::AgentPool;
|
||||
use super::types::composite_key;
|
||||
|
||||
impl AgentPool {
|
||||
/// Stop a running agent. Worktree is preserved for inspection.
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::super::{AgentEvent, AgentStatus, CompletionReport};
|
||||
use super::types::{StoryAgent, composite_key};
|
||||
use super::AgentPool;
|
||||
use super::types::{StoryAgent, composite_key};
|
||||
|
||||
impl AgentPool {
|
||||
/// Test helper: inject a pre-built agent entry so unit tests can exercise
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Agent wait — blocks until an agent reaches a terminal state with optional timeout.
|
||||
use super::super::{AgentEvent, AgentInfo, AgentStatus};
|
||||
use super::types::{agent_info_from_entry, composite_key};
|
||||
use super::AgentPool;
|
||||
use super::types::{agent_info_from_entry, composite_key};
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ impl AgentPool {
|
||||
|
||||
/// Return the active pipeline stage directory name for `story_id`, or `None` if the
|
||||
/// 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> {
|
||||
pub(super) fn find_active_story_stage(
|
||||
_project_root: &Path,
|
||||
story_id: &str,
|
||||
) -> Option<&'static str> {
|
||||
if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id)
|
||||
&& item.stage.is_active()
|
||||
{
|
||||
@@ -39,11 +42,7 @@ mod tests {
|
||||
#[test]
|
||||
fn find_active_story_stage_detects_current() {
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"10_story_test",
|
||||
"2_current",
|
||||
"---\nname: Test\n---\n",
|
||||
);
|
||||
crate::db::write_item_with_content("10_story_test", "2_current", "---\nname: Test\n---\n");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
find_active_story_stage(tmp.path(), "10_story_test"),
|
||||
@@ -54,23 +53,18 @@ mod tests {
|
||||
#[test]
|
||||
fn find_active_story_stage_detects_qa() {
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"11_story_test",
|
||||
"3_qa",
|
||||
"---\nname: Test\n---\n",
|
||||
);
|
||||
crate::db::write_item_with_content("11_story_test", "3_qa", "---\nname: Test\n---\n");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_eq!(find_active_story_stage(tmp.path(), "11_story_test"), Some("3_qa"));
|
||||
assert_eq!(
|
||||
find_active_story_stage(tmp.path(), "11_story_test"),
|
||||
Some("3_qa")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_active_story_stage_detects_merge() {
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"12_story_test",
|
||||
"4_merge",
|
||||
"---\nname: Test\n---\n",
|
||||
);
|
||||
crate::db::write_item_with_content("12_story_test", "4_merge", "---\nname: Test\n---\n");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
find_active_story_stage(tmp.path(), "12_story_test"),
|
||||
|
||||
+20
-18
@@ -237,10 +237,23 @@ fn run_agent_pty_blocking(
|
||||
story_id.replace(['_', '.'], "-")
|
||||
);
|
||||
let session_count = std::fs::read_dir(&session_dir)
|
||||
.map(|d| d.filter(|e| e.as_ref().map(|e| e.path().extension().is_some_and(|ext| ext == "jsonl")).unwrap_or(false)).count())
|
||||
.map(|d| {
|
||||
d.filter(|e| {
|
||||
e.as_ref()
|
||||
.map(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let session_bytes: u64 = std::fs::read_dir(&session_dir)
|
||||
.map(|d| d.filter_map(|e| e.ok()).filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl")).filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum())
|
||||
.map(|d| {
|
||||
d.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
slog!(
|
||||
@@ -373,12 +386,7 @@ fn run_agent_pty_blocking(
|
||||
"stream_event" => {
|
||||
if let Some(event) = json.get("event") {
|
||||
handle_agent_stream_event(
|
||||
event,
|
||||
story_id,
|
||||
agent_name,
|
||||
tx,
|
||||
event_log,
|
||||
log_writer,
|
||||
event, story_id, agent_name, tx, event_log, log_writer,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -409,8 +417,7 @@ fn run_agent_pty_blocking(
|
||||
t
|
||||
}
|
||||
None => {
|
||||
let default = chrono::Utc::now()
|
||||
+ chrono::Duration::minutes(5);
|
||||
let default = chrono::Utc::now() + chrono::Duration::minutes(5);
|
||||
slog!(
|
||||
"[agent:{story_id}:{agent_name}] API rate limit hard block \
|
||||
(status={status}); no reset_at in rate_limit_info, \
|
||||
@@ -469,14 +476,10 @@ fn run_agent_pty_blocking(
|
||||
let wait_result = child.wait();
|
||||
match &wait_result {
|
||||
Ok(status) => {
|
||||
slog!(
|
||||
"[agent:{story_id}:{agent_name}] Child exited: {status:?}"
|
||||
);
|
||||
slog!("[agent:{story_id}:{agent_name}] Child exited: {status:?}");
|
||||
}
|
||||
Err(e) => {
|
||||
slog!(
|
||||
"[agent:{story_id}:{agent_name}] Child wait error: {e}"
|
||||
);
|
||||
slog!("[agent:{story_id}:{agent_name}] Child wait error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,8 +712,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let log_writer =
|
||||
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-emit").unwrap();
|
||||
let log_writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-emit").unwrap();
|
||||
let log_mutex = Mutex::new(log_writer);
|
||||
|
||||
let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::agent_log::AgentLogWriter;
|
||||
@@ -135,14 +135,15 @@ impl AgentRuntime for GeminiRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
slog!("[gemini] Turn {turn} for {}:{}", ctx.story_id, ctx.agent_name);
|
||||
|
||||
let request_body = build_generate_content_request(
|
||||
&system_instruction,
|
||||
&contents,
|
||||
&gemini_tools,
|
||||
slog!(
|
||||
"[gemini] Turn {turn} for {}:{}",
|
||||
ctx.story_id,
|
||||
ctx.agent_name
|
||||
);
|
||||
|
||||
let request_body =
|
||||
build_generate_content_request(&system_instruction, &contents, &gemini_tools);
|
||||
|
||||
let url = format!(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
||||
);
|
||||
@@ -201,8 +202,7 @@ impl AgentRuntime for GeminiRuntime {
|
||||
text_parts.push(text.to_string());
|
||||
}
|
||||
if let Some(fc) = part.get("functionCall")
|
||||
&& let (Some(name), Some(args)) =
|
||||
(fc["name"].as_str(), fc.get("args"))
|
||||
&& let (Some(name), Some(args)) = (fc["name"].as_str(), fc.get("args"))
|
||||
{
|
||||
function_calls.push(GeminiFunctionCall {
|
||||
name: name.to_string(),
|
||||
@@ -263,18 +263,14 @@ impl AgentRuntime for GeminiRuntime {
|
||||
text: format!("\n[Tool call: {}]\n", fc.name),
|
||||
});
|
||||
|
||||
let tool_result =
|
||||
call_mcp_tool(&client, &mcp_base, &fc.name, &fc.args).await;
|
||||
let tool_result = call_mcp_tool(&client, &mcp_base, &fc.name, &fc.args).await;
|
||||
|
||||
let response_value = match &tool_result {
|
||||
Ok(result) => {
|
||||
emit(AgentEvent::Output {
|
||||
story_id: ctx.story_id.clone(),
|
||||
agent_name: ctx.agent_name.clone(),
|
||||
text: format!(
|
||||
"[Tool result: {} chars]\n",
|
||||
result.len()
|
||||
),
|
||||
text: format!("[Tool result: {} chars]\n", result.len()),
|
||||
});
|
||||
json!({ "result": result })
|
||||
}
|
||||
@@ -453,7 +449,10 @@ async fn fetch_and_convert_mcp_tools(
|
||||
});
|
||||
}
|
||||
|
||||
slog!("[gemini] Loaded {} MCP tools as function declarations", declarations.len());
|
||||
slog!(
|
||||
"[gemini] Loaded {} MCP tools as function declarations",
|
||||
declarations.len()
|
||||
);
|
||||
Ok(declarations)
|
||||
}
|
||||
|
||||
@@ -560,10 +559,7 @@ async fn call_mcp_tool(
|
||||
// MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } }
|
||||
let content = &body["result"]["content"];
|
||||
if let Some(arr) = content.as_array() {
|
||||
let texts: Vec<&str> = arr
|
||||
.iter()
|
||||
.filter_map(|c| c["text"].as_str())
|
||||
.collect();
|
||||
let texts: Vec<&str> = arr.iter().filter_map(|c| c["text"].as_str()).collect();
|
||||
if !texts.is_empty() {
|
||||
return Ok(texts.join("\n"));
|
||||
}
|
||||
@@ -747,7 +743,10 @@ mod tests {
|
||||
|
||||
let body = build_generate_content_request(&system, &contents, &tools);
|
||||
assert!(body["tools"][0]["functionDeclarations"].is_array());
|
||||
assert_eq!(body["tools"][0]["functionDeclarations"][0]["name"], "my_tool");
|
||||
assert_eq!(
|
||||
body["tools"][0]["functionDeclarations"][0]["name"],
|
||||
"my_tool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -151,8 +151,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn claude_code_runtime_get_status_returns_idle() {
|
||||
use std::collections::HashMap;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use std::collections::HashMap;
|
||||
let killers = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
|
||||
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
|
||||
@@ -161,8 +161,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn claude_code_runtime_stream_events_empty() {
|
||||
use std::collections::HashMap;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use std::collections::HashMap;
|
||||
let killers = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
|
||||
let runtime = ClaudeCodeRuntime::new(killers, watcher_tx);
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::agent_log::AgentLogWriter;
|
||||
@@ -471,10 +471,7 @@ async fn call_mcp_tool(
|
||||
// MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } }
|
||||
let content = &body["result"]["content"];
|
||||
if let Some(arr) = content.as_array() {
|
||||
let texts: Vec<&str> = arr
|
||||
.iter()
|
||||
.filter_map(|c| c["text"].as_str())
|
||||
.collect();
|
||||
let texts: Vec<&str> = arr.iter().filter_map(|c| c["text"].as_str()).collect();
|
||||
if !texts.is_empty() {
|
||||
return Ok(texts.join("\n"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user