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
+4 -4
View File
@@ -328,7 +328,7 @@ mod tests {
write_item_with_content(story_id, "2_current", content, meta);
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
assert_eq!(view.stage_str(), "2_current");
assert_eq!(view.stage().as_dir(), "coding");
assert_eq!(view.name(), Some("Typed Name"));
assert_eq!(view.agent(), Some("coder-1"));
assert_eq!(view.retry_count(), 2);
@@ -353,7 +353,7 @@ mod tests {
write_item_with_content(story_id, "2_current", content, ItemMeta::default());
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
assert_eq!(view.stage_str(), "2_current");
assert_eq!(view.stage().as_dir(), "coding");
assert_eq!(
view.name(),
None,
@@ -406,7 +406,7 @@ mod tests {
// Seed the story in 2_current with retry_count = 3 (a coder that
// burned all its retries).
crate::crdt_state::write_item(
crate::crdt_state::write_item_str(
story_id,
"2_current",
Some("Retry reset test"),
@@ -433,7 +433,7 @@ mod tests {
let typed_after = crate::pipeline_state::read_typed(story_id)
.expect("read should succeed")
.expect("story exists in CRDT");
assert_eq!(typed_after.stage.dir_name(), "4_merge");
assert_eq!(typed_after.stage.dir_name(), "merge");
assert_eq!(
typed_after.retry_count, 0,
"retry_count must reset to 0 on stage transition"
+45 -14
View File
@@ -33,6 +33,33 @@ impl ItemMeta {
}
}
/// Normalise a stage string at the db boundary.
///
/// Accepts the clean post-934 vocabulary (passthrough) and the pre-934
/// directory-style strings (`"2_current"`, `"4_merge"`, etc.) by mapping
/// them to the clean form before handing off to `Stage::from_dir` (which
/// itself only accepts clean form after stage 6). This keeps the public
/// db API tolerant for callers that still pass legacy strings while the
/// internal type stays strict.
fn normalise_stage_str(stage: &str) -> &str {
match stage {
"0_upcoming" => "upcoming",
"1_backlog" => "backlog",
"2_current" => "coding",
"2_blocked" => "blocked",
"3_qa" => "qa",
"4_merge" => "merge",
"4_merge_failure" => "merge_failure",
"5_done" => "done",
"6_archived" => "archived",
// `7_frozen` has no direct clean equivalent (the variant was
// removed in story 934 stage 4). Returning the unmapped string
// makes `Stage::from_dir` return None, so the write is logged and
// skipped — frozen items should be seeded via the `frozen` flag.
other => other,
}
}
/// Write a pipeline item from in-memory content (no filesystem access).
///
/// This is the primary write path for the DB-backed pipeline. It updates
@@ -52,16 +79,18 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str, meta:
write_content(story_id, content);
// Primary: CRDT ops.
let merged_at_ts = if crate::pipeline_state::Stage::from_dir(stage)
.is_some_and(|s| matches!(s, crate::pipeline_state::Stage::Done { .. }))
{
Some(chrono::Utc::now().timestamp() as f64)
} else {
None
let stage = normalise_stage_str(stage);
let Some(typed_stage) = crate::pipeline_state::Stage::from_dir(stage) else {
crate::slog!(
"[db] write_item_with_content: unknown stage '{stage}' for {story_id}; skipping CRDT write"
);
return;
};
let merged_at_ts = matches!(typed_stage, crate::pipeline_state::Stage::Done { .. })
.then(|| chrono::Utc::now().timestamp() as f64);
crate::crdt_state::write_item(
story_id,
stage,
&typed_stage,
meta.name.as_deref(),
meta.agent.as_deref(),
meta.retry_count,
@@ -114,16 +143,18 @@ pub fn move_item_stage(
// CRDT typed registers — no need to re-derive it from the content body's
// YAML front matter on every stage transition. Pass `None` for those
// fields so write_item leaves the existing registers untouched.
let merged_at_ts = if crate::pipeline_state::Stage::from_dir(new_stage)
.is_some_and(|s| matches!(s, crate::pipeline_state::Stage::Done { .. }))
{
Some(chrono::Utc::now().timestamp() as f64)
} else {
None
let new_stage = normalise_stage_str(new_stage);
let Some(typed_stage) = crate::pipeline_state::Stage::from_dir(new_stage) else {
crate::slog!(
"[db] move_item_stage: unknown stage '{new_stage}' for {story_id}; skipping CRDT write"
);
return;
};
let merged_at_ts = matches!(typed_stage, crate::pipeline_state::Stage::Done { .. })
.then(|| chrono::Utc::now().timestamp() as f64);
crate::crdt_state::write_item(
story_id,
new_stage,
&typed_stage,
None,
None,
None,