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
+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"
);
}