huskies: merge 1010
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user