huskies: merge 960

This commit is contained in:
dave
2026-05-13 13:17:46 +00:00
parent a47fbc4179
commit 77dc09668c
14 changed files with 138 additions and 193 deletions
@@ -16,7 +16,7 @@ impl AgentPool {
/// 3. Trigger server-side merges (or auto-spawn mergemaster) for `4_merge/`.
pub async fn auto_assign_available_work(&self, project_root: &Path) {
// Promote any backlog stories whose dependencies are all done.
self.promote_ready_backlog_stories(project_root);
self.promote_ready_backlog_stories();
let config = match ProjectConfig::load(project_root) {
Ok(c) => c,
@@ -1,7 +1,6 @@
//! Backlog promotion: scan `1_backlog/` and promote stories whose `depends_on` are all met.
use std::path::Path;
use crate::pipeline_state::Stage;
use crate::slog;
use crate::slog_warn;
@@ -23,8 +22,8 @@ impl AgentPool {
/// was abandoned/superseded before the dependent existed), a prominent warning is
/// logged so the user can see the promotion was triggered by an archived dep, not
/// a clean completion.
pub(super) fn promote_ready_backlog_stories(&self, project_root: &Path) {
let items = scan_stage_items(project_root, "1_backlog");
pub(super) fn promote_ready_backlog_stories(&self) {
let items = scan_stage_items(&Stage::Backlog);
for story_id in &items {
// Only promote stories that explicitly declare dependencies
// (story 929: read from the CRDT register, not YAML).
@@ -35,11 +34,11 @@ impl AgentPool {
continue;
}
// Check whether any dependencies are still unmet.
if has_unmet_dependencies(project_root, "1_backlog", story_id) {
if has_unmet_dependencies(story_id) {
continue;
}
// Warn if any deps were satisfied via archive rather than via clean done.
let archived_deps = check_archived_dependencies(project_root, "1_backlog", story_id);
let archived_deps = check_archived_dependencies(story_id);
if !archived_deps.is_empty() {
slog_warn!(
"[auto-assign] Story '{story_id}' is being promoted because deps \
+18 -9
View File
@@ -1,8 +1,10 @@
//! Merge stage dispatch: trigger server-side merges and auto-spawn mergemaster for content conflicts.
use std::num::NonZeroU32;
use std::path::Path;
use crate::config::ProjectConfig;
use crate::pipeline_state::{BranchName, Stage};
use crate::slog;
use crate::slog_error;
use crate::slog_warn;
@@ -34,22 +36,26 @@ impl AgentPool {
// written to the CRDT and a notification is emitted; the story stays in
// 4_merge/ until a human intervenes or an explicit `start_agent mergemaster`
// call invokes the LLM-driven recovery path.
let merge_items = scan_stage_items(project_root, "4_merge");
let merge_stage = Stage::Merge {
feature_branch: BranchName(String::new()),
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
};
let merge_items = scan_stage_items(&merge_stage);
for story_id in &merge_items {
if has_review_hold(project_root, "4_merge", story_id) {
if has_review_hold(story_id) {
continue;
}
if is_story_frozen(project_root, "4_merge", story_id) {
if is_story_frozen(story_id) {
slog!("[auto-assign] Story '{story_id}' in 4_merge/ is frozen; skipping.");
continue;
}
if is_story_blocked(project_root, "4_merge", story_id) {
if is_story_blocked(story_id) {
continue;
}
if has_unmet_dependencies(project_root, "4_merge", story_id) {
if has_unmet_dependencies(story_id) {
slog!("[auto-assign] Story '{story_id}' in 4_merge/ has unmet deps; skipping.");
continue;
}
@@ -113,11 +119,14 @@ impl AgentPool {
// Stories transition to 4_merge_failure when the server-side merge fails.
// Content conflicts get one automatic mergemaster attempt; other failures
// require human intervention.
let merge_failure_items = scan_stage_items(project_root, "4_merge_failure");
let merge_failure_stage = Stage::MergeFailure {
reason: String::new(),
feature_branch: BranchName(String::new()),
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
};
let merge_failure_items = scan_stage_items(&merge_failure_stage);
for story_id in &merge_failure_items {
if has_content_conflict_failure(project_root, "4_merge_failure", story_id)
&& !has_mergemaster_attempted(project_root, "4_merge_failure", story_id)
{
if has_content_conflict_failure(story_id) && !has_mergemaster_attempted(story_id) {
let mergemaster_agent = {
let agents = match self.agents.lock() {
Ok(a) => a,
+12 -11
View File
@@ -3,6 +3,7 @@
use std::path::Path;
use crate::config::ProjectConfig;
use crate::pipeline_state::Stage;
use crate::slog;
use crate::slog_error;
@@ -25,13 +26,14 @@ impl AgentPool {
/// guards. Agent front-matter preferences and stage-mismatch fallback are handled
/// here as well.
pub(super) async fn assign_pipeline_stages(&self, project_root: &Path, config: &ProjectConfig) {
let stages: [(&str, PipelineStage); 2] = [
("2_current", PipelineStage::Coder),
("3_qa", PipelineStage::Qa),
let stages: [(Stage, PipelineStage); 2] = [
(Stage::Coding, PipelineStage::Coder),
(Stage::Qa, PipelineStage::Qa),
];
for (stage_dir, stage) in &stages {
let items = scan_stage_items(project_root, stage_dir);
for (pipeline_stage, stage) in &stages {
let stage_dir = pipeline_stage.dir_name();
let items = scan_stage_items(pipeline_stage);
if items.is_empty() {
continue;
}
@@ -39,23 +41,23 @@ impl AgentPool {
for story_id in &items {
// Items marked with review_hold (e.g. spikes after QA passes) stay
// in their current stage for human review — don't auto-assign agents.
if has_review_hold(project_root, stage_dir, story_id) {
if has_review_hold(story_id) {
continue;
}
// Skip frozen stories — pipeline advancement is suspended.
if is_story_frozen(project_root, stage_dir, story_id) {
if is_story_frozen(story_id) {
slog!("[auto-assign] Story '{story_id}' is frozen; skipping until unfrozen.");
continue;
}
// Skip blocked stories (retry limit exceeded).
if is_story_blocked(project_root, stage_dir, story_id) {
if is_story_blocked(story_id) {
continue;
}
// Skip stories whose dependencies haven't landed yet.
if has_unmet_dependencies(project_root, stage_dir, story_id) {
if has_unmet_dependencies(story_id) {
slog!(
"[auto-assign] Story '{story_id}' has unmet dependencies; skipping until deps are done."
);
@@ -64,8 +66,7 @@ impl AgentPool {
// Re-acquire the lock on each iteration to see state changes
// from previous start_agent calls in the same pass.
let preferred_agent =
read_story_front_matter_agent(project_root, stage_dir, story_id);
let preferred_agent = read_story_front_matter_agent(story_id);
// Check max_coders limit for the Coder stage before agent selection.
// If the pool is full, all remaining items in this stage wait.
+8 -45
View File
@@ -2,7 +2,6 @@
use crate::config::ProjectConfig;
use std::collections::HashMap;
use std::path::Path;
use super::super::super::{AgentStatus, PipelineStage, agent_config_stage, pipeline_stage};
use super::super::StoryAgent;
@@ -18,32 +17,14 @@ pub(in crate::agents::pool) fn is_agent_free(
})
}
pub(super) fn scan_stage_items(_project_root: &Path, stage_dir: &str) -> Vec<String> {
/// Return all story_ids in the given pipeline `Stage`, sourced from the CRDT.
pub(super) fn scan_stage_items(want: &crate::pipeline_state::Stage) -> Vec<String> {
use std::collections::BTreeSet;
let mut items = BTreeSet::new();
// Accept legacy directory-style strings (`"2_current"`, `"4_merge"`,
// etc.) at the boundary; `Stage::from_dir` itself is strict post-934
// stage 6, so we normalise here.
let normalised = match stage_dir {
"0_upcoming" => "upcoming",
"1_backlog" => "backlog",
"2_current" => "coding",
"2_blocked" => "blocked",
"3_qa" => "qa",
"4_merge" => "merge",
"4_merge_failure" => "merge_failure",
"5_done" => "done",
"6_archived" => "archived",
other => other,
};
let Some(want) = crate::pipeline_state::Stage::from_dir(normalised) else {
return Vec::new();
};
// CRDT is the only source of truth — no filesystem fallback.
for item in crate::pipeline_state::read_all_typed() {
if std::mem::discriminant(&item.stage) == std::mem::discriminant(&want) {
if std::mem::discriminant(&item.stage) == std::mem::discriminant(want) {
items.insert(item.story_id.0.clone());
}
}
@@ -181,6 +162,7 @@ mod tests {
// attempt to promote an archived story.
#[test]
fn scan_stage_items_skips_filesystem_item_known_to_crdt_at_different_stage() {
use crate::pipeline_state::Stage;
crate::db::ensure_content_store();
// Write the story into the CRDT as 6_archived.
crate::db::write_item_with_content(
@@ -190,34 +172,16 @@ mod tests {
crate::db::ItemMeta::named("Archived"),
);
// Also place a stale .md file in a temp 1_backlog/ dir.
let tmp = tempfile::tempdir().unwrap();
let backlog = tmp.path().join(".huskies/work/1_backlog");
std::fs::create_dir_all(&backlog).unwrap();
std::fs::write(
backlog.join("9970_story_archived.md"),
"---\nname: Archived\n---\n",
)
.unwrap();
let items = scan_stage_items(tmp.path(), "1_backlog");
let items = scan_stage_items(&Stage::Backlog);
assert!(
!items.contains(&"9970_story_archived".to_string()),
"archived CRDT story must not appear in 1_backlog scan via stale filesystem shadow"
"archived CRDT story must not appear in backlog scan"
);
}
#[test]
fn scan_stage_items_returns_empty_for_missing_dir() {
// Use a unique stage name that no other test writes to, so
// the global CRDT store won't contribute stale items.
let tmp = tempfile::tempdir().unwrap();
let items = scan_stage_items(tmp.path(), "9_nonexistent");
assert!(items.is_empty());
}
#[test]
fn scan_stage_items_returns_sorted_story_ids() {
use crate::pipeline_state::Stage;
// Write items via the CRDT store (the primary source of truth).
crate::db::ensure_content_store();
crate::db::write_item_with_content(
@@ -239,8 +203,7 @@ mod tests {
crate::db::ItemMeta::named("baz"),
);
let tmp = tempfile::tempdir().unwrap();
let items = scan_stage_items(tmp.path(), "2_current");
let items = scan_stage_items(&Stage::Coding);
// The global CRDT may contain items from other tests, so check
// that our three items are present and appear in sorted order.
assert!(
@@ -1,18 +1,12 @@
//! Front-matter checks for story files: review holds, blocked state, and merge failures.
use std::path::Path;
/// Read the optional `agent:` pin for a story.
///
/// After story 871 the agent assignment lives in the CRDT typed register
/// (`PipelineItemView.agent`), not the YAML front matter. We check the CRDT
/// first; falling back to legacy YAML parsing keeps behaviour intact for any
/// stories whose CRDT entry doesn't yet have the field set.
pub(super) fn read_story_front_matter_agent(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> Option<String> {
pub(super) fn read_story_front_matter_agent(story_id: &str) -> Option<String> {
// Story 929: agent name comes from the CRDT register. The previous
// YAML fallback is gone — post-891 every story has its CRDT entry,
// and any story without one is treated as having no pinned agent.
@@ -26,7 +20,7 @@ pub(super) fn read_story_front_matter_agent(
/// The auto-assigner uses this to keep human-QA items / spikes parked after
/// gates pass until a reviewer explicitly clears the hold (e.g. via
/// `tool_approve_qa`).
pub(super) fn has_review_hold(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
pub(super) fn has_review_hold(story_id: &str) -> bool {
crate::crdt_state::read_item(story_id)
.map(|w| w.stage().is_review_hold())
.unwrap_or(false)
@@ -37,7 +31,7 @@ pub(super) fn has_review_hold(_project_root: &Path, _stage_dir: &str, story_id:
///
/// The typed pipeline stage register is the only source consulted — the legacy
/// `blocked: true` YAML front-matter field is no longer checked.
pub(super) fn is_story_blocked(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
pub(super) fn is_story_blocked(story_id: &str) -> bool {
crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
@@ -52,11 +46,7 @@ pub(super) fn is_story_blocked(_project_root: &Path, _stage_dir: &str, story_id:
/// The typed stage register is consulted first; the CRDT content store is then
/// scanned for conflict markers (the projection layer does not carry the reason
/// string). No YAML front-matter parsing is performed.
pub(super) fn has_content_conflict_failure(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
pub(super) fn has_content_conflict_failure(story_id: &str) -> bool {
let is_merge_failure = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
@@ -86,11 +76,7 @@ pub(super) fn has_content_conflict_failure(
/// the legacy `mergemaster_attempted: bool` CRDT register has been deleted.
/// Used to prevent the auto-assigner from repeatedly spawning mergemaster for
/// the same story after a failed mergemaster session.
pub(super) fn has_mergemaster_attempted(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
pub(super) fn has_mergemaster_attempted(story_id: &str) -> bool {
crate::crdt_state::read_item(story_id)
.map(|view| view.stage().is_mergemaster_attempted())
.unwrap_or(false)
@@ -98,22 +84,14 @@ pub(super) fn has_mergemaster_attempted(
/// Return `true` if the story has any `depends_on` entries that are not yet in
/// `5_done` or `6_archived`. Reads dependency state from the CRDT (story 929).
pub(super) fn has_unmet_dependencies(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
pub(super) fn has_unmet_dependencies(story_id: &str) -> bool {
!crate::crdt_state::check_unmet_deps_crdt(story_id).is_empty()
}
/// Return the list of dependency story numbers that are in `6_archived` (satisfied
/// via archive rather than via a clean `5_done` completion). Reads from the CRDT
/// (story 929).
pub(super) fn check_archived_dependencies(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> Vec<u32> {
pub(super) fn check_archived_dependencies(story_id: &str) -> Vec<u32> {
crate::crdt_state::check_archived_deps_crdt(story_id)
}
@@ -123,7 +101,7 @@ pub(super) fn check_archived_dependencies(
/// the legacy `frozen: bool` CRDT register has been deleted. Frozen stories
/// are skipped by the auto-assigner until `Unfreeze` returns them to
/// `resume_to`.
pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
pub(super) fn is_story_frozen(story_id: &str) -> bool {
crate::crdt_state::read_item(story_id)
.map(|view| view.stage().is_frozen())
.unwrap_or(false)
@@ -141,7 +119,6 @@ mod tests {
fn has_review_hold_returns_true_when_flag_set() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// Story 945: review_hold is now a typed Stage variant, seeded via
// the wire-form stage register directly.
crate::crdt_state::write_item_str(
@@ -155,14 +132,13 @@ mod tests {
None,
None,
);
assert!(has_review_hold(tmp.path(), "3_qa", "890_spike_held"));
assert!(has_review_hold("890_spike_held"));
}
#[test]
fn has_review_hold_returns_false_when_flag_unset() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"890_spike_active_qa",
"3_qa",
@@ -174,13 +150,12 @@ mod tests {
None,
None,
);
assert!(!has_review_hold(tmp.path(), "3_qa", "890_spike_active_qa"));
assert!(!has_review_hold("890_spike_active_qa"));
}
#[test]
fn has_review_hold_returns_false_when_story_unknown() {
let tmp = tempfile::tempdir().unwrap();
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
assert!(!has_review_hold("99_spike_missing"));
}
// ── is_story_blocked — regression: typed stage is sole authority ──────────
@@ -189,25 +164,19 @@ mod tests {
fn is_story_blocked_set_via_typed_stage_returns_true() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
crate::db::write_item_with_content(
"890_story_blocked_set",
"2_blocked",
"---\nname: Blocked Story\n---\n",
crate::db::ItemMeta::named("Blocked Story"),
);
assert!(is_story_blocked(
tmp.path(),
"2_blocked",
"890_story_blocked_set"
));
assert!(is_story_blocked("890_story_blocked_set"));
}
#[test]
fn is_story_blocked_cleared_via_typed_stage_returns_false() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// First set to blocked.
crate::db::write_item_with_content(
"890_story_blocked_clear",
@@ -222,18 +191,13 @@ mod tests {
"---\nname: Clearable Story\n---\n",
crate::db::ItemMeta::named("Clearable Story"),
);
assert!(!is_story_blocked(
tmp.path(),
"2_current",
"890_story_blocked_clear"
));
assert!(!is_story_blocked("890_story_blocked_clear"));
}
#[test]
fn is_story_blocked_stale_yaml_is_ignored() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// YAML front matter says `blocked: true`, but the typed CRDT stage is backlog.
// After removing the YAML fallback, the function must return false.
crate::db::write_item_with_content(
@@ -243,7 +207,7 @@ mod tests {
crate::db::ItemMeta::named("Stale"),
);
assert!(
!is_story_blocked(tmp.path(), "1_backlog", "890_story_stale_yaml"),
!is_story_blocked("890_story_stale_yaml"),
"stale YAML `blocked: true` must not be reported as blocked when typed stage is Backlog"
);
}
@@ -253,7 +217,6 @@ mod tests {
#[test]
fn has_unmet_dependencies_returns_true_when_dep_not_done() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"10_story_blocked",
"2_current",
@@ -265,17 +228,12 @@ mod tests {
None,
None,
);
assert!(has_unmet_dependencies(
tmp.path(),
"2_current",
"10_story_blocked"
));
assert!(has_unmet_dependencies("10_story_blocked"));
}
#[test]
fn has_unmet_dependencies_returns_false_when_dep_done() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"999_story_dep",
"5_done",
@@ -298,17 +256,12 @@ mod tests {
None,
None,
);
assert!(!has_unmet_dependencies(
tmp.path(),
"2_current",
"10_story_ok"
));
assert!(!has_unmet_dependencies("10_story_ok"));
}
#[test]
fn has_unmet_dependencies_returns_false_when_no_deps() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"5_story_free",
"2_current",
@@ -320,11 +273,7 @@ mod tests {
None,
None,
);
assert!(!has_unmet_dependencies(
tmp.path(),
"2_current",
"5_story_free"
));
assert!(!has_unmet_dependencies("5_story_free"));
}
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
@@ -333,7 +282,6 @@ mod tests {
#[test]
fn check_archived_dependencies_returns_archived_ids() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"500_spike_crdt",
"6_archived",
@@ -356,8 +304,7 @@ mod tests {
None,
None,
);
let archived_deps =
check_archived_dependencies(tmp.path(), "1_backlog", "503_story_dependent");
let archived_deps = check_archived_dependencies("503_story_dependent");
assert_eq!(archived_deps, vec![500]);
}
@@ -365,7 +312,6 @@ mod tests {
#[test]
fn check_archived_dependencies_empty_when_dep_in_done() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"490_story_done",
"5_done",
@@ -388,8 +334,7 @@ mod tests {
None,
None,
);
let archived_deps =
check_archived_dependencies(tmp.path(), "1_backlog", "503_story_waiting");
let archived_deps = check_archived_dependencies("503_story_waiting");
assert!(archived_deps.is_empty());
}
}