feat(934): typed Stage enum replaces directory-string state model

The state machine's `Stage` enum becomes the source of truth for pipeline
state. Six stages of work land together:

  1. Clean wire vocabulary (`coding`, `merge`, `merge_failure`, ...) replaces
     legacy directory-style strings (`2_current`, `4_merge`, ...) on the wire.
     `Stage::from_dir` accepted both during deployment; new writes always
     emit the clean form via `stage_dir_name`. Lexicographic `dir >= "5_done"`
     checks in lifecycle.rs become typed `matches!` checks since the new
     vocabulary doesn't sort in pipeline order.
  2. `crdt_state::write_item` takes typed `&Stage`, serialising via
     `stage_dir_name` at the CRDT boundary. `#[cfg(test)] write_item_str`
     parses legacy strings for test fixtures.
  3. `WorkItem::stage()` returns typed `crdt_state::Stage`; `stage_str()`
     is gone from the public API. Projection dispatches on the typed enum.
  4. `frozen` becomes an orthogonal CRDT register. `Stage::Frozen` and
     `PipelineEvent::Freeze`/`Unfreeze` are removed; `transition_to_frozen`/
     `unfrozen` set the flag directly without touching the stage register.
  5. Watcher sweep and `tool_update_story`'s `blocked` setter route through
     `apply_transition` so the typed transition table validates every
     stage change. `update_story` gains a `frozen` field for symmetry.
  6. One-shot startup migration rewrites pre-934 directory-style stage
     registers (and sets `frozen=true` on items previously at `7_frozen`).
     `Stage::from_dir` drops legacy aliases. The db boundary keeps a small
     normaliser so callers with legacy strings (MCP, tests) still work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 22:31:59 +01:00
parent 93443e2ff1
commit d78dd9e8f9
55 changed files with 783 additions and 584 deletions
+24 -11
View File
@@ -98,20 +98,33 @@ pub fn apply_transition_str(
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
}
/// Freeze a story at its current stage.
/// Freeze a story.
///
/// Story 929: the YAML write of `resume_to_stage` is gone; the projection
/// layer no longer reads it (defaults to Coding). Story 934 will make
/// frozen a flag orthogonal to Stage, so the story stays in its current
/// Stage rather than encoding a "where to resume" payload — at which point
/// the read-side default also becomes moot.
pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
apply_transition(story_id, PipelineEvent::Freeze, None)
/// Story 934, stage 4: `frozen` is now a CRDT flag orthogonal to [`Stage`],
/// so the story stays at its current stage and only the boolean register
/// changes. Returns `Err(NotFound)` if no item exists for `story_id`.
pub fn transition_to_frozen(story_id: &str) -> Result<(), ApplyError> {
if read_typed(story_id)?.is_none() {
return Err(ApplyError::NotFound(story_id.to_string()));
}
crate::crdt_state::set_frozen(story_id, true);
crate::slog!("[pipeline/transition] #{}: Freeze (flag set)", story_id);
Ok(())
}
/// Unfreeze a story.
///
/// Story 929: paired with `transition_to_frozen`, no longer touches YAML.
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
apply_transition(story_id, PipelineEvent::Unfreeze, None)
/// Story 934, stage 4: paired with [`transition_to_frozen`]; clears the
/// CRDT `frozen` flag without touching the stage register. Returns
/// `Err(NotFound)` if no item exists for `story_id`.
pub fn transition_to_unfrozen(story_id: &str) -> Result<(), ApplyError> {
if read_typed(story_id)?.is_none() {
return Err(ApplyError::NotFound(story_id.to_string()));
}
crate::crdt_state::set_frozen(story_id, false);
crate::slog!(
"[pipeline/transition] #{}: Unfreeze (flag cleared)",
story_id
);
Ok(())
}
+5 -2
View File
@@ -19,8 +19,11 @@
//! - [`projection`] — CRDT → typed projection layer (`read_typed`, `read_all_typed`)
//! - [`subscribers`] — concrete subscriber stubs
// Some items are exercised by tests or used only in non-active code paths;
// the dead_code lint is suppressed for the module.
// Scaffolding types (AgentName, NodePubkey, ExecutionState, ExecutionEvent,
// execution_transition, apply_transition_str, to_crdt_fields, is_upcoming,
// MissingField/InvalidField) exist for stages 25 of story 934 and the
// per-node execution-state work; they are only exercised by tests today.
// Revisit and drop the allow once those stages land.
#![allow(dead_code)]
mod apply;
+49 -53
View File
@@ -1,15 +1,12 @@
//! Projection layer — converts loose CRDT views into typed `PipelineItem` enums.
#![allow(unused_imports, dead_code)]
use chrono::{DateTime, Utc};
use std::fmt;
use std::num::NonZeroU32;
use crate::crdt_state::{PipelineItemView, read_all_items, read_item};
use crate::crdt_state::PipelineItemView;
use super::{
ArchiveReason, BranchName, ExecutionState, GitSha, PipelineItem, Stage, StoryId, stage_dir_name,
};
use super::{ArchiveReason, BranchName, GitSha, PipelineItem, Stage, StoryId, stage_dir_name};
/// Errors from projecting loose CRDT data into typed enums.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -61,27 +58,33 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
stage,
depends_on,
retry_count,
frozen: view.frozen(),
})
}
}
/// Project the stage string + associated fields from a WorkItem into
/// a typed Stage enum. This is the one carefully-controlled boundary where
/// loose CRDT data becomes typed.
/// Project the typed low-level [`crdt_state::Stage`] plus the view's
/// associated fields into a rich [`Stage`] with payload defaults.
///
/// This is the one carefully-controlled boundary where the CRDT's
/// stringly-typed stage register gains payload fields (merge metadata,
/// archive reason, etc.) synthesised from sibling registers and sane
/// defaults. Unknown stage strings (forward-compat aliases) surface as
/// [`ProjectionError::UnknownStage`].
pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError> {
match view.stage_str() {
"0_upcoming" => Ok(Stage::Upcoming),
"1_backlog" => Ok(Stage::Backlog),
"2_blocked" => Ok(Stage::Blocked {
use crate::crdt_state::Stage as LowStage;
match view.stage() {
LowStage::Upcoming => Ok(Stage::Upcoming),
LowStage::Backlog => Ok(Stage::Backlog),
LowStage::Blocked => Ok(Stage::Blocked {
reason: String::new(),
}),
"2_current" => Ok(Stage::Coding),
"3_qa" => Ok(Stage::Qa),
"4_merge" => {
LowStage::Coding => Ok(Stage::Coding),
LowStage::Qa => Ok(Stage::Qa),
LowStage::Merge => {
// Merge stage in the current CRDT doesn't carry feature_branch or
// commits_ahead — those are computed at transition time. For
// projection from existing CRDT data, we synthesize defaults.
// The feature branch follows the naming convention.
let branch = format!("feature/story-{}", view.story_id());
// Existing CRDT data doesn't track commits_ahead, so we use 1 as
// a safe non-zero default (the item is in merge, so there must be
@@ -91,19 +94,19 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
})
}
"4_merge_failure" => {
// The reason is persisted in front-matter (merge_failure: "...") but
// is not part of the raw CRDT view; the projection uses an empty
// string here. Consumers that need the reason should read content.
LowStage::MergeFailure => {
// The reason is persisted in the content body but is not part of
// the raw CRDT view; the projection uses an empty string here.
// Consumers that need the reason should read content directly.
Ok(Stage::MergeFailure {
reason: String::new(),
})
}
"5_done" => {
LowStage::Done => {
// Use the stored merged_at timestamp if present. Legacy items
// that pre-date this field have merged_at = None, so we fall back
// to UNIX_EPOCH, which makes them older than any retention window
// and therefore eligible for immediate sweep to 6_archived.
// and therefore eligible for immediate sweep to archived.
let merged_at = view
.merged_at()
.map(|ts| {
@@ -115,14 +118,12 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
merge_commit: GitSha("legacy".to_string()),
})
}
"6_archived" => {
// Determine the archive reason from the CRDT fields.
LowStage::Archived => {
let reason = if view.blocked() {
ArchiveReason::Blocked {
reason: "migrated from legacy blocked field".to_string(),
}
} else {
// Default to Completed for legacy archived items.
ArchiveReason::Completed
};
Ok(Stage::Archived {
@@ -130,16 +131,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
reason,
})
}
"7_frozen" => {
// Story 929: resume_to was previously read from YAML front matter;
// we default to Coding here. Story 934 will obviate this — frozen
// becomes a flag orthogonal to Stage, so the story stays in its
// current Stage rather than encoding a "where to go next" payload.
Ok(Stage::Frozen {
resume_to: Box::new(Stage::Coding),
})
}
other => Err(ProjectionError::UnknownStage(other.to_string())),
LowStage::Unknown(s) => Err(ProjectionError::UnknownStage(s)),
}
}
@@ -158,7 +150,6 @@ impl PipelineItem {
..
}
);
// Frozen stories map to "7_frozen"; they are not "blocked" in the CRDT sense.
(dir, blocked)
}
}
@@ -199,7 +190,6 @@ pub fn read_typed(story_id: &str) -> Result<Option<PipelineItem>, ProjectionErro
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use std::num::NonZeroU32;
fn nz(n: u32) -> NonZeroU32 {
@@ -232,12 +222,13 @@ mod tests {
None,
None,
None,
None,
)
}
#[test]
fn project_upcoming_item() {
let view = make_view("42_story_test", "0_upcoming", Some("Test Story"));
let view = make_view("42_story_test", "upcoming", Some("Test Story"));
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Upcoming));
}
@@ -246,7 +237,7 @@ mod tests {
fn project_backlog_item() {
let view = PipelineItemView::for_test(
"42_story_test",
"1_backlog",
"backlog",
Some("Test Story".to_string()),
None,
None,
@@ -260,6 +251,7 @@ mod tests {
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
@@ -273,7 +265,7 @@ mod tests {
fn project_current_item() {
let view = PipelineItemView::for_test(
"42_story_test",
"2_current",
"coding",
Some("Test".to_string()),
Some("coder-1".to_string()),
Some(2),
@@ -287,6 +279,7 @@ mod tests {
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Coding));
@@ -295,7 +288,7 @@ mod tests {
#[test]
fn project_merge_item() {
let view = make_view("42_story_test", "4_merge", Some("Test"));
let view = make_view("42_story_test", "merge", Some("Test"));
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Merge { .. }));
if let Stage::Merge {
@@ -310,7 +303,7 @@ mod tests {
#[test]
fn project_blocked_item() {
let view = make_view("42_story_test", "2_blocked", Some("Test"));
let view = make_view("42_story_test", "blocked", Some("Test"));
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Blocked { .. }));
}
@@ -319,7 +312,7 @@ mod tests {
fn project_archived_blocked_item() {
let view = PipelineItemView::for_test(
"42_story_test",
"6_archived",
"archived",
Some("Test".to_string()),
None,
None,
@@ -333,6 +326,7 @@ mod tests {
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
@@ -348,7 +342,7 @@ mod tests {
fn project_archived_completed_item() {
let view = PipelineItemView::for_test(
"42_story_test",
"6_archived",
"archived",
Some("Test".to_string()),
None,
None,
@@ -362,6 +356,7 @@ mod tests {
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
@@ -388,23 +383,23 @@ mod tests {
#[test]
fn reverse_projection_stage_dirs() {
let cases: Vec<(Stage, &str, bool)> = vec![
(Stage::Upcoming, "0_upcoming", false),
(Stage::Backlog, "1_backlog", false),
(Stage::Coding, "2_current", false),
(Stage::Upcoming, "upcoming", false),
(Stage::Backlog, "backlog", false),
(Stage::Coding, "coding", false),
(
Stage::Blocked {
reason: "stuck".into(),
},
"2_blocked",
"blocked",
true,
),
(Stage::Qa, "3_qa", false),
(Stage::Qa, "qa", false),
(
Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
},
"4_merge",
"merge",
false,
),
(
@@ -412,7 +407,7 @@ mod tests {
merged_at: Utc::now(),
merge_commit: sha("abc"),
},
"5_done",
"done",
false,
),
(
@@ -420,7 +415,7 @@ mod tests {
archived_at: Utc::now(),
reason: ArchiveReason::Completed,
},
"6_archived",
"archived",
false,
),
(
@@ -430,7 +425,7 @@ mod tests {
reason: "stuck".into(),
},
},
"6_archived",
"archived",
true,
),
];
@@ -442,6 +437,7 @@ mod tests {
stage,
depends_on: vec![],
retry_count: 0,
frozen: false,
};
let (dir, blocked) = item.to_crdt_fields();
assert_eq!(dir, expected_dir);
+26 -104
View File
@@ -571,129 +571,51 @@ fn cannot_reject_from_archived() {
));
}
// ── Freeze / Unfreeze ───────────────────────────────────────────────
// ── Freeze / Unfreeze (story 934, stage 4: orthogonal flag) ────────────────
/// Freeze sets the `frozen` flag without changing the stage register.
/// Unfreeze clears the flag — the stage was never touched so there's nothing
/// to "restore". Tests the freeze/unfreeze API on the apply layer, since
/// freeze/unfreeze are no longer pure stage transitions.
#[test]
fn freeze_from_active_stages() {
for s in [Stage::Upcoming, Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(s.clone(), PipelineEvent::Freeze).unwrap();
assert!(
matches!(result, Stage::Frozen { .. }),
"expected Frozen from {s:?}"
);
if let Stage::Frozen { resume_to } = result {
assert_eq!(*resume_to, s);
}
}
}
#[test]
fn freeze_from_merge() {
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(m.clone(), PipelineEvent::Freeze).unwrap();
assert!(matches!(result, Stage::Frozen { .. }));
if let Stage::Frozen { resume_to } = result {
assert_eq!(*resume_to, m);
}
}
#[test]
fn unfreeze_restores_prior_stage() {
let prior = Stage::Coding;
let frozen = Stage::Frozen {
resume_to: Box::new(prior.clone()),
};
let result = transition(frozen, PipelineEvent::Unfreeze).unwrap();
assert_eq!(result, prior);
}
#[test]
fn cannot_freeze_done() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(s, PipelineEvent::Freeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_freeze_archived() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Completed,
};
let result = transition(s, PipelineEvent::Freeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_unfreeze_coding() {
let result = transition(Stage::Coding, PipelineEvent::Unfreeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
/// Regression test: freeze → unfreeze round-trip via `apply_transition`.
/// Verifies that the CRDT shows the correct prior stage restored.
#[test]
fn regression_freeze_unfreeze_restores_crdt_stage() {
fn freeze_sets_flag_without_changing_stage() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "9950_story_freeze_regression";
let content = "---\nname: Freeze Regression\n---\n# Story\n";
crate::db::write_item_with_content(
story_id,
"2_current",
content,
"---\nname: Freeze Regression\n---\n# Story\n",
crate::db::ItemMeta::named("Freeze Regression"),
);
// Confirm starting stage.
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
"should start at Coding"
);
assert!(matches!(item.stage, Stage::Coding));
assert!(!item.is_frozen());
// Freeze.
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Frozen { .. }),
"should be Frozen after freeze: {:?}",
matches!(item.stage, Stage::Coding),
"stage register stays at Coding after freeze: {:?}",
item.stage
);
if let Stage::Frozen { ref resume_to } = item.stage {
assert!(
matches!(**resume_to, Stage::Coding),
"resume_to should be Coding: {:?}",
resume_to
);
}
assert!(item.is_frozen(), "frozen flag should be set after freeze");
// Unfreeze.
super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
"should be restored to Coding after unfreeze: {:?}",
"stage register still at Coding after unfreeze: {:?}",
item.stage
);
assert!(
!item.is_frozen(),
"frozen flag should be cleared after unfreeze"
);
}
// ── Story 868: MergeFailure regression ─────────────────────────────
@@ -745,7 +667,7 @@ fn merge_failure_transition_emits_event_with_full_reason() {
.expect("item should exist");
assert_eq!(
item.stage.dir_name(),
"4_merge_failure",
"merge_failure",
"CRDT stage should be 4_merge_failure"
);
}
@@ -781,7 +703,7 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
let story_id = "99913_story_merge_failure_selfloop";
crate::db::write_item_with_content(
story_id,
"4_merge_failure",
"merge_failure",
"---\nname: MergeFailure Self-loop Test\n---\n# Story\n",
crate::db::ItemMeta::named("MergeFailure Self-loop Test"),
);
@@ -809,14 +731,14 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
fired.after
);
// Verify the CRDT stage is still 4_merge_failure.
// Verify the CRDT stage is still merge_failure.
let item = read_typed(story_id)
.expect("CRDT read should succeed")
.expect("item should still exist");
assert_eq!(
item.stage.dir_name(),
"4_merge_failure",
"CRDT stage should remain 4_merge_failure after self-loop"
"merge_failure",
"CRDT stage should remain merge_failure after self-loop"
);
// Simulate the caller's de-dup logic: since fired.before is already MergeFailure,
@@ -854,7 +776,7 @@ fn merge_failure_accept_moves_to_done_via_crdt() {
let story_id = "99892_story_merge_failure_accept";
crate::db::write_item_with_content(
story_id,
"4_merge_failure",
"merge_failure",
"---\nname: MergeFailure Accept Test\n---\n# Story\n",
crate::db::ItemMeta::named("MergeFailure Accept Test"),
);
@@ -883,14 +805,14 @@ fn merge_failure_accept_moves_to_done_via_crdt() {
fired.event
);
// CRDT reflects 5_done.
// CRDT reflects done.
let item = read_typed(story_id)
.expect("CRDT read should succeed")
.expect("item should exist");
assert_eq!(
item.stage.dir_name(),
"5_done",
"CRDT stage should be 5_done after MergeFailure + Accepted"
"done",
"CRDT stage should be done after MergeFailure + Accepted"
);
}
-17
View File
@@ -59,10 +59,6 @@ pub enum PipelineEvent {
Close,
/// Manual demotion back to backlog from an active stage.
Demote,
/// Freeze the story at its current stage (suspends pipeline and auto-assign).
Freeze,
/// Unfreeze the story, restoring it to the stage it was at when frozen.
Unfreeze,
}
// ── Per-node execution events ───────────────────────────────────────────────
@@ -102,8 +98,6 @@ pub fn event_label(e: &PipelineEvent) -> &'static str {
PipelineEvent::Triage => "Triage",
PipelineEvent::Close => "Close",
PipelineEvent::Demote => "Demote",
PipelineEvent::Freeze => "Freeze",
PipelineEvent::Unfreeze => "Unfreeze",
}
}
@@ -267,17 +261,6 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
Unblock,
) => Ok(Backlog),
// ── Freeze: any active stage → Frozen(resume_to=current) ────────
(stage @ (Upcoming | Backlog | Coding | Qa), Freeze) => Ok(Frozen {
resume_to: Box::new(stage),
}),
(stage @ Merge { .. }, Freeze) => Ok(Frozen {
resume_to: Box::new(stage),
}),
// ── Unfreeze: Frozen → resume_to ─────────────────────────────────
(Frozen { resume_to }, Unfreeze) => Ok(*resume_to),
// ── Everything else is invalid ──────────────────────────────────
_ => Err(invalid()),
}
+37 -39
View File
@@ -110,10 +110,6 @@ pub enum Stage {
/// awaiting human intervention or retry. Unlike `Archived(MergeFailed)`,
/// this is a recoverable intermediate state — `Unblock` returns to `Backlog`.
MergeFailure { reason: String },
/// Pipeline advancement and auto-assign are suspended. Resumes to
/// `resume_to` when unfrozen.
Frozen { resume_to: Box<Stage> },
}
/// Why a story was archived. Subsumes the old `blocked`, `merge_failure`,
@@ -144,11 +140,6 @@ impl Stage {
matches!(self, Stage::Coding | Stage::Qa | Stage::Merge { .. })
}
/// Returns true if this stage is `Frozen`.
pub fn is_frozen(&self) -> bool {
matches!(self, Stage::Frozen { .. })
}
/// Returns true if this is the Upcoming variant.
pub fn is_upcoming(&self) -> bool {
matches!(self, Stage::Upcoming)
@@ -183,35 +174,28 @@ impl Stage {
/// accessing the rich metadata fields.
pub fn from_dir(s: &str) -> Option<Self> {
match s {
"0_upcoming" => Some(Stage::Upcoming),
"1_backlog" => Some(Stage::Backlog),
"2_blocked" => Some(Stage::Blocked {
"upcoming" => Some(Stage::Upcoming),
"backlog" => Some(Stage::Backlog),
"coding" => Some(Stage::Coding),
"blocked" => Some(Stage::Blocked {
reason: String::new(),
}),
"2_current" => Some(Stage::Coding),
"3_qa" => Some(Stage::Qa),
"4_merge" => Some(Stage::Merge {
"qa" => Some(Stage::Qa),
"merge" => Some(Stage::Merge {
feature_branch: BranchName(String::new()),
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
}),
"5_done" => Some(Stage::Done {
"merge_failure" => Some(Stage::MergeFailure {
reason: String::new(),
}),
"done" => Some(Stage::Done {
merged_at: DateTime::<Utc>::UNIX_EPOCH,
merge_commit: GitSha(String::new()),
}),
"6_archived" => Some(Stage::Archived {
"archived" => Some(Stage::Archived {
archived_at: DateTime::<Utc>::UNIX_EPOCH,
reason: ArchiveReason::Completed,
}),
// Frozen: stub with Coding as resume_to — rich resume_to is loaded
// from front matter by the projection layer.
"4_merge_failure" => Some(Stage::MergeFailure {
reason: String::new(),
}),
// Frozen: stub with Coding as resume_to — rich resume_to is loaded
// from front matter by the projection layer.
"7_frozen" => Some(Stage::Frozen {
resume_to: Box::new(Stage::Coding),
}),
_ => None,
}
}
@@ -257,6 +241,18 @@ pub struct PipelineItem {
pub stage: Stage,
pub depends_on: Vec<StoryId>,
pub retry_count: u32,
/// Whether the item is frozen — orthogonal to [`Self::stage`].
/// Frozen items remain at their current stage but are skipped by the
/// auto-assigner until explicitly unfrozen (story 934, stage 4).
pub frozen: bool,
}
impl PipelineItem {
/// Whether the item is frozen. Frozen items stay at their current
/// [`Stage`] but are skipped by the auto-assigner until unfrozen.
pub fn is_frozen(&self) -> bool {
self.frozen
}
}
// ── Transition errors ───────────────────────────────────────────────────────
@@ -293,22 +289,24 @@ pub fn stage_label(s: &Stage) -> &'static str {
Stage::Done { .. } => "Done",
Stage::Blocked { .. } => "Blocked",
Stage::Archived { .. } => "Archived",
Stage::Frozen { .. } => "Frozen",
}
}
/// Map a Stage to the filesystem directory name used by the work pipeline.
/// Map a Stage to its canonical wire-format string (story 934).
///
/// Post-934 emits clean vocabulary with no numeric prefix. Legacy directory
/// strings (`"2_current"`, `"4_merge"`, etc.) are still accepted by `from_dir`
/// for migration but are never produced here.
pub fn stage_dir_name(s: &Stage) -> &'static str {
match s {
Stage::Upcoming => "0_upcoming",
Stage::Backlog => "1_backlog",
Stage::Coding => "2_current",
Stage::Blocked { .. } => "2_blocked",
Stage::Qa => "3_qa",
Stage::Merge { .. } => "4_merge",
Stage::MergeFailure { .. } => "4_merge_failure",
Stage::Done { .. } => "5_done",
Stage::Archived { .. } => "6_archived",
Stage::Frozen { .. } => "7_frozen",
Stage::Upcoming => "upcoming",
Stage::Backlog => "backlog",
Stage::Coding => "coding",
Stage::Blocked { .. } => "blocked",
Stage::Qa => "qa",
Stage::Merge { .. } => "merge",
Stage::MergeFailure { .. } => "merge_failure",
Stage::Done { .. } => "done",
Stage::Archived { .. } => "archived",
}
}