huskies: merge 1086 story Pipeline+Status split — Step C: migrate auto-assign, subscribers, and lifecycle transitions to read Pipeline + Status

This commit is contained in:
dave
2026-05-15 08:21:36 +00:00
parent 2d6105c778
commit e82602db77
11 changed files with 159 additions and 78 deletions
+20 -10
View File
@@ -1,29 +1,39 @@
//! Backlog promotion: scan `1_backlog/` and promote stories whose `depends_on` are all met.
//! Backlog promotion: scan items in `Pipeline::Backlog` and promote stories whose `depends_on` are all met.
use crate::pipeline_state::Stage;
use crate::pipeline_state::Pipeline;
use crate::slog;
use crate::slog_warn;
use super::super::AgentPool;
use super::scan::scan_stage_items;
use super::story_checks::{check_archived_dependencies, has_unmet_dependencies};
impl AgentPool {
/// Scan `1_backlog/` and promote any story whose `depends_on` are all met.
/// Scan items in `Pipeline::Backlog` and promote any story whose `depends_on` are all met.
///
/// A story is only promoted if it explicitly lists `depends_on` AND every
/// listed dependency has reached `5_done` or `6_archived`. Stories with no
/// `depends_on` are left in the backlog for human scheduling.
/// listed dependency has reached `Pipeline::Done` or `Pipeline::Archived`.
/// Stories with no `depends_on` are left in the backlog for human scheduling.
///
/// **Archived dep semantics:** a dep in `6_archived` counts as satisfied (since
/// stories auto-sweep from `5_done` to `6_archived` after 4 hours, and the
/// **Archived dep semantics:** a dep in `Pipeline::Archived` counts as satisfied
/// (since stories auto-sweep from `Done` to `Archived` after 4 hours, and the
/// dependent story would normally already be promoted by then). However, if a
/// dep was already in `6_archived` when the dependent story was created (e.g. it
/// dep was already archived when the dependent story was created (e.g. it
/// 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) {
let items = scan_stage_items(&Stage::Backlog);
// Story 1086: scan by Pipeline column, not Stage variant. Pipeline::Backlog
// covers Stage::Upcoming and Stage::Backlog uniformly.
let items: Vec<String> = {
use std::collections::BTreeSet;
let mut ids = BTreeSet::new();
for item in crate::pipeline_state::read_all_typed() {
if item.stage.pipeline() == Pipeline::Backlog {
ids.insert(item.story_id.0.clone());
}
}
ids.into_iter().collect()
};
for story_id in &items {
// Only promote stories that explicitly declare dependencies
// (story 929: read from the CRDT register, not YAML).
@@ -13,7 +13,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::pipeline_state::{MergeFailureKind, PipelineEvent, Stage, StoryId};
use crate::pipeline_state::{MergeFailureKind, PipelineEvent, Stage, Status, StoryId};
use crate::slog;
use crate::slog_warn;
@@ -95,6 +95,13 @@ fn on_transition(
counters: &mut HashMap<StoryId, (u32, MergeFailureKind)>,
recovery_running: bool,
) {
// Story 1086: gate on the typed `Status` projection — `Status::MergeFailure`
// is precisely the set of stages we count toward the block threshold. We
// still need the variant pattern below to read `kind`.
if fired.after.status() != Status::MergeFailure {
counters.remove(&fired.story_id);
return;
}
match &fired.after {
Stage::MergeFailure { kind, .. } => {
if recovery_running {
@@ -9,7 +9,7 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::pipeline_state::{MergeFailureKind, Stage};
use crate::pipeline_state::{MergeFailureKind, Stage, Status};
use crate::slog;
use crate::slog_warn;
@@ -26,6 +26,11 @@ use super::scan::{find_free_agent_for_stage, is_story_assigned_for_stage};
pub(crate) async fn reconcile_merge_failure(pool: &Arc<AgentPool>, project_root: &Path) {
use crate::pipeline_state::{MergeFailureKind, PipelineEvent, Stage, TransitionFired};
for item in crate::pipeline_state::read_all_typed() {
// Story 1086: scan via the Status projection; the variant pattern is
// still needed to read `kind`.
if item.stage.status() != Status::MergeFailure {
continue;
}
if let Stage::MergeFailure { ref kind, .. } = item.stage
&& matches!(kind, MergeFailureKind::ConflictDetected(_))
{
@@ -73,6 +78,11 @@ async fn on_merge_failure_transition(
project_root: &Path,
fired: &crate::pipeline_state::TransitionFired,
) {
// Story 1086: gate on the typed `Status` projection first; only the
// `MergeFailure` kind extraction needs the variant pattern.
if fired.after.status() != Status::MergeFailure {
return;
}
let Stage::MergeFailure { ref kind, .. } = fired.after else {
return;
};
@@ -9,7 +9,7 @@
use std::path::{Path, PathBuf};
use crate::pipeline_state::Stage;
use crate::pipeline_state::{Pipeline, Stage, Status};
use crate::slog;
use crate::slog_warn;
@@ -50,17 +50,15 @@ pub(crate) fn spawn_cost_rollup_subscriber(project_root: PathBuf) {
/// Returns `true` if `stage` is a terminal pipeline stage.
///
/// Terminal stages are those from which no further work is expected:
/// Done, Archived, Abandoned, Superseded, Rejected.
/// MergeFailure variants are NOT terminal — stories can recover from them.
/// Done, Archived, Abandoned, Superseded, Rejected. Story 1086 routes the
/// classification through the [`Status`] / [`Pipeline`] projection so future
/// Stage variants automatically participate. MergeFailure variants are NOT
/// terminal — stories can recover from them.
fn is_terminal(stage: &Stage) -> bool {
matches!(
stage,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
)
stage.status(),
Status::Done | Status::Abandoned | Status::Superseded | Status::Rejected
) || matches!(stage.pipeline(), Pipeline::Archived)
}
/// Snapshot the cost data for `fired.story_id` into the register when
+26 -17
View File
@@ -6,10 +6,20 @@
use std::path::{Path, PathBuf};
use crate::pipeline_state::Stage;
use crate::pipeline_state::{Pipeline, Stage, Status};
use crate::slog;
use crate::slog_warn;
/// Story 1086: matches the set of terminal stages used by the worktree-cleanup
/// subscriber via the typed [`Status`] / [`Pipeline`] projections. Excludes
/// `Status::Rejected` so rejected stories keep their worktree for human review.
fn is_cleanup_terminal(stage: &Stage) -> bool {
matches!(
stage.status(),
Status::Done | Status::Abandoned | Status::Superseded
) || matches!(stage.pipeline(), Pipeline::Archived)
}
/// Spawn a background task that creates a git worktree when a story enters `Stage::Coding`.
///
/// Subscribes to the pipeline transition broadcast channel. On each
@@ -22,7 +32,14 @@ pub(crate) fn spawn_worktree_create_subscriber(project_root: PathBuf, port: u16)
loop {
match rx.recv().await {
Ok(fired) => {
if matches!(fired.after, Stage::Coding { .. }) {
// Story 1086: classify by Pipeline column. `Pipeline::Coding`
// covers `Stage::Coding` and `Stage::Blocked` — but Blocked has
// no worktree to create, so we still need the Stage::Coding
// payload check. Use a layered match: pipeline first for fast
// skip, then variant guard.
if fired.after.pipeline() == Pipeline::Coding
&& matches!(fired.after, Stage::Coding { .. })
{
on_coding_transition(&project_root, port, &fired.story_id.0).await;
}
}
@@ -50,13 +67,7 @@ pub(crate) fn spawn_worktree_cleanup_subscriber(project_root: PathBuf) {
loop {
match rx.recv().await {
Ok(fired) => {
if matches!(
fired.after,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
) {
if is_cleanup_terminal(&fired.after) {
on_terminal_transition(&project_root, &fired.story_id.0).await;
}
}
@@ -79,7 +90,11 @@ pub(crate) fn spawn_worktree_cleanup_subscriber(project_root: PathBuf) {
/// so that Lagged events on the broadcast channel never leave Coding stories without worktrees.
pub(crate) async fn reconcile_worktree_create(project_root: &Path, port: u16) {
for item in crate::pipeline_state::read_all_typed() {
if matches!(item.stage, crate::pipeline_state::Stage::Coding { .. }) {
// Story 1086: filter by Pipeline column then narrow to the `Coding`
// variant (Blocked is in `Pipeline::Coding` but has no worktree).
if item.stage.pipeline() == Pipeline::Coding
&& matches!(item.stage, crate::pipeline_state::Stage::Coding { .. })
{
on_coding_transition(project_root, port, &item.story_id.0).await;
}
}
@@ -92,13 +107,7 @@ pub(crate) async fn reconcile_worktree_create(project_root: &Path, port: u16) {
/// the broadcast channel never leave terminal stories with dangling worktrees.
pub(crate) async fn reconcile_worktree_cleanup(project_root: &Path) {
for item in crate::pipeline_state::read_all_typed() {
if matches!(
item.stage,
crate::pipeline_state::Stage::Done { .. }
| crate::pipeline_state::Stage::Archived { .. }
| crate::pipeline_state::Stage::Abandoned { .. }
| crate::pipeline_state::Stage::Superseded { .. }
) {
if is_cleanup_terminal(&item.stage) {
on_terminal_transition(project_root, &item.story_id.0).await;
}
}