huskies: merge 1010

This commit is contained in:
dave
2026-05-14 08:07:43 +00:00
parent 4520e0e6f9
commit 13ab97a615
27 changed files with 572 additions and 95 deletions
+5 -2
View File
@@ -112,7 +112,7 @@ impl Default for EventBus {
#[cfg(test)]
mod tests {
use super::super::BranchName;
use super::super::{BranchName, PlanState};
use super::*;
use std::num::NonZeroU32;
@@ -149,7 +149,10 @@ mod tests {
bus.fire(TransitionFired {
story_id: StoryId("test".into()),
before: Stage::Backlog,
after: Stage::Coding { claim: None },
after: Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
event: PipelineEvent::DepsMet,
at: Utc::now(),
});
+2 -1
View File
@@ -41,7 +41,8 @@ mod tests;
#[allow(unused_imports)]
pub use types::{
AgentClaim, AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind,
NodePubkey, PipelineItem, Stage, StoryId, TransitionError, stage_dir_name, stage_label,
NodePubkey, PipelineItem, PlanState, Stage, StoryId, TransitionError, stage_dir_name,
stage_label,
};
#[allow(unused_imports)]
+71 -3
View File
@@ -100,7 +100,7 @@ pub fn read_typed(story_id: &str) -> Result<Option<PipelineItem>, ProjectionErro
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline_state::{ArchiveReason, BranchName, GitSha, Stage};
use crate::pipeline_state::{ArchiveReason, BranchName, GitSha, PlanState, Stage};
use chrono::Utc;
use std::num::NonZeroU32;
@@ -157,7 +157,10 @@ mod tests {
fn project_current_item() {
let view = PipelineItemView::for_test(
"42_story_test",
Stage::Coding { claim: None },
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
"Test",
Some(crate::config::AgentName::Coder1),
2u32,
@@ -267,7 +270,10 @@ mod tests {
let view = make_view(
"42_story_test",
Stage::Frozen {
resume_to: Box::new(Stage::Coding { claim: None }),
resume_to: Box::new(Stage::Coding {
claim: None,
plan: PlanState::Missing,
}),
},
Some("Frozen Story"),
);
@@ -292,4 +298,66 @@ mod tests {
fn git_sha_constructs() {
let _ = GitSha("abc".to_string());
}
// ── PlanState projection ────────────────────────────────────────────
#[test]
fn project_coding_plan_missing() {
let view = make_view(
"42_story_test",
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
Some("Test"),
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
item.stage,
Stage::Coding {
plan: PlanState::Missing,
..
}
));
}
#[test]
fn project_coding_plan_drafted() {
let view = make_view(
"42_story_test",
Stage::Coding {
claim: None,
plan: PlanState::Drafted,
},
Some("Test"),
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
item.stage,
Stage::Coding {
plan: PlanState::Drafted,
..
}
));
}
#[test]
fn project_coding_plan_confirmed() {
let view = make_view(
"42_story_test",
Stage::Coding {
claim: None,
plan: PlanState::Confirmed,
},
Some("Test"),
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
item.stage,
Stage::Coding {
plan: PlanState::Confirmed,
..
}
));
}
}
+79 -16
View File
@@ -52,7 +52,10 @@ fn happy_path_backlog_through_archived() {
#[test]
fn happy_path_with_qa() {
let s = Stage::Coding { claim: None };
let s = Stage::Coding {
claim: None,
plan: PlanState::Missing,
};
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
@@ -69,7 +72,10 @@ fn happy_path_with_qa() {
#[test]
fn qa_retry_loop() {
let s = Stage::Coding { claim: None };
let s = Stage::Coding {
claim: None,
plan: PlanState::Missing,
};
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
@@ -154,7 +160,13 @@ fn cannot_start_gates_from_backlog() {
#[test]
fn cannot_accept_from_coding() {
let result = transition(Stage::Coding { claim: None }, PipelineEvent::Accepted);
let result = transition(
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
PipelineEvent::Accepted,
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
@@ -165,7 +177,14 @@ fn cannot_accept_from_coding() {
#[test]
fn block_from_any_active_stage() {
for s in [Stage::Backlog, Stage::Coding { claim: None }, Stage::Qa] {
for s in [
Stage::Backlog,
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
Stage::Qa,
] {
let result = transition(
s.clone(),
PipelineEvent::Block {
@@ -252,7 +271,10 @@ fn legacy_unblock_archived_blocked_returns_to_backlog() {
fn abandon_from_any_active_or_done() {
for s in [
Stage::Backlog,
Stage::Coding { claim: None },
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
Stage::Qa,
Stage::Done {
merged_at: chrono::Utc::now(),
@@ -268,7 +290,10 @@ fn abandon_from_any_active_or_done() {
fn supersede_from_any_active_or_done() {
for s in [
Stage::Backlog,
Stage::Coding { claim: None },
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
Stage::Qa,
Stage::Done {
merged_at: chrono::Utc::now(),
@@ -292,7 +317,14 @@ fn review_hold_from_active_stages() {
// Story 945: `ReviewHold` transitions to `Stage::ReviewHold { resume_to }`
// with the resume_to set to the originating stage, replacing the legacy
// boolean flag.
for s in [Stage::Backlog, Stage::Coding { claim: None }, Stage::Qa] {
for s in [
Stage::Backlog,
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
Stage::Qa,
] {
let result = transition(
s.clone(),
PipelineEvent::ReviewHold {
@@ -338,7 +370,10 @@ fn merge_failed_final() {
#[test]
fn merge_failed_only_from_merge() {
let result = transition(
Stage::Coding { claim: None },
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
PipelineEvent::MergeFailedFinal {
reason: "conflicts".into(),
},
@@ -483,7 +518,14 @@ fn cannot_deps_met_from_upcoming() {
#[test]
fn reject_from_active_stages() {
for s in [Stage::Backlog, Stage::Coding { claim: None }, Stage::Qa] {
for s in [
Stage::Backlog,
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
Stage::Qa,
] {
let result = transition(
s.clone(),
PipelineEvent::Reject {
@@ -989,7 +1031,10 @@ fn hotfix_requested_from_done_lands_in_coding() {
fn hotfix_requested_rejected_from_non_done_stages() {
for stage in [
Stage::Backlog,
Stage::Coding { claim: None },
Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
Stage::Qa,
Stage::Merge {
feature_branch: fb("feature/story-1"),
@@ -1016,7 +1061,10 @@ fn audit_entry_backlog_to_coding_exact_format() {
let fired = TransitionFired {
story_id: StoryId("1014_my_story".into()),
before: Stage::Backlog,
after: Stage::Coding { claim: None },
after: Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
event: PipelineEvent::DepsMet,
at,
};
@@ -1116,7 +1164,10 @@ fn audit_entry_done_to_archived() {
fn audit_entry_coding_to_blocked() {
let fired = TransitionFired {
story_id: StoryId("300_s".into()),
before: Stage::Coding { claim: None },
before: Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
after: Stage::Blocked {
reason: "waiting".into(),
},
@@ -1138,7 +1189,10 @@ fn audit_entry_blocked_to_coding() {
before: Stage::Blocked {
reason: "test".into(),
},
after: Stage::Coding { claim: None },
after: Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
event: PipelineEvent::Unblock,
at: chrono::Utc::now(),
};
@@ -1177,9 +1231,15 @@ fn audit_entry_merge_to_merge_failure() {
fn audit_entry_coding_to_frozen() {
let fired = TransitionFired {
story_id: StoryId("600_s".into()),
before: Stage::Coding { claim: None },
before: Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
after: Stage::Frozen {
resume_to: Box::new(Stage::Coding { claim: None }),
resume_to: Box::new(Stage::Coding {
claim: None,
plan: PlanState::Missing,
}),
},
event: PipelineEvent::Freeze,
at: chrono::Utc::now(),
@@ -1194,7 +1254,10 @@ fn audit_entry_coding_to_frozen() {
fn audit_entry_coding_to_abandoned() {
let fired = TransitionFired {
story_id: StoryId("700_s".into()),
before: Stage::Coding { claim: None },
before: Stage::Coding {
claim: None,
plan: PlanState::Missing,
},
after: Stage::Abandoned {
ts: chrono::Utc::now(),
},
+30 -9
View File
@@ -4,8 +4,8 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use super::{
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, Stage, StoryId,
TransitionError, stage_label,
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, PlanState,
Stage, StoryId, TransitionError, stage_label,
};
// ── Pipeline events ─────────────────────────────────────────────────────────
@@ -149,7 +149,10 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Upcoming, Triage) => Ok(Backlog),
// ── Forward path ────────────────────────────────────────────────
(Backlog, DepsMet) => Ok(Coding { claim: None }),
(Backlog, DepsMet) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
}),
(Coding { .. }, GatesStarted) => Ok(Qa),
(
Coding { .. },
@@ -173,7 +176,10 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
commits_ahead,
claim: None,
}),
(Qa, GatesFailed { .. }) => Ok(Coding { claim: None }),
(Qa, GatesFailed { .. }) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
}),
(Merge { .. }, MergeSucceeded { merge_commit }) => Ok(Done {
merged_at: now,
merge_commit,
@@ -312,7 +318,10 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Stage::ReviewHold { resume_to, .. }, ReviewHoldCleared) => Ok(*resume_to),
// ── FixupRequested: MergeFailure → Coding (coder fixup) ────────
(MergeFailure { .. }, FixupRequested) => Ok(Coding { claim: None }),
(MergeFailure { .. }, FixupRequested) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
}),
// ── FixupRequested: MergeFailureFinal → Coding (operator override)
//
@@ -321,19 +330,28 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
// the gate failure is fixable and send the story back for another
// coder attempt. The budget counter is a mergemaster bookkeeping
// detail, not a hard ceiling.
(MergeFailureFinal { .. }, FixupRequested) => Ok(Coding { claim: None }),
(MergeFailureFinal { .. }, FixupRequested) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
}),
// ── ReQueuedForQa: MergeFailure → Qa (re-review) ────────────────
(MergeFailure { .. }, ReQueuedForQa) => Ok(Qa),
// ── MergeAborted: Merge → Coding (abort in-flight merge) ─────────
(Merge { .. }, MergeAborted) => Ok(Coding { claim: None }),
(Merge { .. }, MergeAborted) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
}),
// ── HotfixRequested: Done → Coding (post-merge hotfix) ───────────
// Allows reopening a completed story so a coder can apply a hotfix.
// A fresh feature branch is forked from master when auto-assign spawns
// the coder.
(Done { .. }, HotfixRequested) => Ok(Coding { claim: None }),
(Done { .. }, HotfixRequested) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
}),
// ── MergemasterAttempted: MergeFailure → MergeFailureFinal ─────
(MergeFailure { kind, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }),
@@ -344,7 +362,10 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
(Stage::ReviewHold { resume_to, .. }, Unblock) => Ok(*resume_to),
// ── Unblock: Blocked → Coding ─────────────────────────────────
(Blocked { .. }, Unblock) => Ok(Coding { claim: None }),
(Blocked { .. }, Unblock) => Ok(Coding {
claim: None,
plan: PlanState::Missing,
}),
// ── Unblock MergeFailure → Merge (re-attempt) ────────────────────
// `unblock_story` on a failed merge re-queues it for merge, restoring
+61 -4
View File
@@ -125,6 +125,48 @@ pub struct AgentClaim {
pub claimed_at: DateTime<Utc>,
}
// ── Plan state (PLAN.md lifecycle inside Stage::Coding) ────────────────────
/// Lifecycle state of the `PLAN.md` file inside a coding worktree.
///
/// Updated by the filesystem watcher whenever PLAN.md is created, modified,
/// or removed in a story's worktree. Embedded in [`Stage::Coding`] so
/// callers access it via the typed projection instead of greping the filesystem.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PlanState {
/// No `PLAN.md` file exists in the worktree yet.
#[default]
Missing,
/// `PLAN.md` exists but contains `<TBD>` placeholders — the plan has been
/// drafted but not yet confirmed with real file paths and descriptions.
Drafted,
/// `PLAN.md` exists and contains no `<TBD>` placeholders — the plan is
/// considered confirmed.
Confirmed,
}
impl PlanState {
/// Wire-form string stored in the `plan_state` CRDT register.
pub fn as_str(&self) -> &'static str {
match self {
PlanState::Missing => "missing",
PlanState::Drafted => "drafted",
PlanState::Confirmed => "confirmed",
}
}
/// Parse from a `plan_state` CRDT register value.
///
/// Unknown or empty strings default to [`PlanState::Missing`].
pub fn from_str(s: &str) -> Self {
match s {
"drafted" => PlanState::Drafted,
"confirmed" => PlanState::Confirmed,
_ => PlanState::Missing,
}
}
}
// ── Synced pipeline stage (lives in CRDT, converges across nodes) ───────────
/// The pipeline stage for a work item.
@@ -167,7 +209,13 @@ pub enum Stage {
/// working on this item. `None` means the item is in the coding stage but
/// no agent has claimed it yet (e.g. just transitioned from Backlog and
/// waiting for an agent to pick it up).
Coding { claim: Option<AgentClaim> },
///
/// `plan` tracks the lifecycle of the `PLAN.md` file in the worktree,
/// updated by the filesystem watcher on create/modify/remove events.
Coding {
claim: Option<AgentClaim>,
plan: PlanState,
},
/// Coder has run; gates are running.
Qa,
@@ -299,7 +347,10 @@ impl Stage {
match s {
"upcoming" => Some(Stage::Upcoming),
"backlog" => Some(Stage::Backlog),
"coding" => Some(Stage::Coding { claim: None }),
"coding" => Some(Stage::Coding {
claim: None,
plan: PlanState::Missing,
}),
"blocked" => Some(Stage::Blocked {
reason: String::new(),
}),
@@ -318,10 +369,16 @@ impl Stage {
kind: MergeFailureKind::Other(String::new()),
}),
"frozen" => Some(Stage::Frozen {
resume_to: Box::new(Stage::Coding { claim: None }),
resume_to: Box::new(Stage::Coding {
claim: None,
plan: PlanState::Missing,
}),
}),
"review_hold" => Some(Stage::ReviewHold {
resume_to: Box::new(Stage::Coding { claim: None }),
resume_to: Box::new(Stage::Coding {
claim: None,
plan: PlanState::Missing,
}),
reason: String::new(),
}),
"done" => Some(Stage::Done {