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
+90 -6
View File
@@ -11,6 +11,7 @@ use serde_json::json;
use super::super::state::{apply_and_persist, emit_event, get_crdt, rebuild_index};
use super::super::types::CrdtEvent;
use crate::io::story_metadata::QaMode;
use crate::pipeline_state::{Stage, stage_dir_name};
/// Set the typed `depends_on` CRDT register for a pipeline item.
///
@@ -103,6 +104,28 @@ pub fn set_review_hold(story_id: &str, value: bool) -> bool {
true
}
/// Set the `frozen` CRDT flag for a pipeline item (story 934, stage 4).
///
/// `true` freezes the story at its current `Stage` — the auto-assigner skips
/// it but the stage register is untouched. `false` unfreezes; the story
/// remains at its current stage and resumes auto-assignment. Both writes
/// are explicit (not removals) so the cleared state survives CRDT replay.
///
/// Returns `true` if the item was found and the op was applied, `false` otherwise.
pub fn set_frozen(story_id: &str, value: bool) -> bool {
let Some(state_mutex) = get_crdt() else {
return false;
};
let Ok(mut state) = state_mutex.lock() else {
return false;
};
let Some(&idx) = state.index.get(story_id) else {
return false;
};
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].frozen.set(value));
true
}
/// Set the `mergemaster_attempted` CRDT flag for a pipeline item.
///
/// Passing `true` records that a mergemaster session has been spawned for this
@@ -211,10 +234,13 @@ pub fn set_qa_mode(story_id: &str, mode: Option<QaMode>) -> bool {
///
/// When the stage changes (or a new item is created), a [`CrdtEvent`] is
/// broadcast so subscribers can react to the transition.
///
/// `stage` is the typed pipeline state; it is serialised to the canonical
/// clean wire form (story 934) via [`stage_dir_name`] at the CRDT boundary.
#[allow(clippy::too_many_arguments)]
pub fn write_item(
story_id: &str,
stage: &str,
stage: &Stage,
name: Option<&str>,
agent: Option<&str>,
retry_count: Option<i64>,
@@ -224,6 +250,7 @@ pub fn write_item(
claimed_at: Option<f64>,
merged_at: Option<f64>,
) {
let stage_str = stage_dir_name(stage);
let Some(state_mutex) = get_crdt() else {
return;
};
@@ -247,7 +274,7 @@ pub fn write_item(
// Update existing item registers.
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].stage.set(stage.to_string())
s.crdt.doc.items[idx].stage.set(stage_str.to_string())
});
if let Some(n) = name {
@@ -286,7 +313,7 @@ pub fn write_item(
}
// Broadcast a CrdtEvent if the stage actually changed.
let stage_changed = old_stage.as_deref() != Some(stage);
let stage_changed = old_stage.as_deref() != Some(stage_str);
if stage_changed {
// Read the current name from the CRDT document for the event.
let current_name = match state.crdt.doc.items[idx].name.view() {
@@ -296,7 +323,7 @@ pub fn write_item(
emit_event(CrdtEvent {
story_id: story_id.to_string(),
from_stage: old_stage,
to_stage: stage.to_string(),
to_stage: stage_str.to_string(),
name: current_name,
});
}
@@ -304,7 +331,7 @@ pub fn write_item(
// Insert new item.
let item_json: JsonValue = json!({
"story_id": story_id,
"stage": stage,
"stage": stage_str,
"name": name.unwrap_or(""),
"agent": agent.unwrap_or(""),
"retry_count": retry_count.unwrap_or(0) as f64,
@@ -318,6 +345,7 @@ pub fn write_item(
"review_hold": false,
"item_type": "",
"epic": "",
"frozen": false,
})
.into();
@@ -348,18 +376,74 @@ pub fn write_item(
item.review_hold.advance_seq(floor);
item.item_type.advance_seq(floor);
item.epic.advance_seq(floor);
item.frozen.advance_seq(floor);
}
// Broadcast a CrdtEvent for the new item.
emit_event(CrdtEvent {
story_id: story_id.to_string(),
from_stage: None,
to_stage: stage.to_string(),
to_stage: stage_str.to_string(),
name: name.map(String::from),
});
}
}
/// Test-only convenience that parses a wire-form stage string and forwards
/// to [`write_item`]. Existing tests seed CRDT items with legacy directory
/// strings (`"2_current"`, `"4_merge"`, etc.) — this shim keeps that idiom
/// working without forcing every test to construct typed `Stage` payloads.
///
/// Stages are normalised through [`Stage::from_dir`]: unknown strings cause
/// the write to be skipped (with a log line).
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub fn write_item_str(
story_id: &str,
stage: &str,
name: Option<&str>,
agent: Option<&str>,
retry_count: Option<i64>,
blocked: Option<bool>,
depends_on: Option<&str>,
claimed_by: Option<&str>,
claimed_at: Option<f64>,
merged_at: Option<f64>,
) {
// Normalise pre-934 directory-style strings to clean wire form so
// existing test fixtures keep working after stage 6 dropped the legacy
// aliases from `Stage::from_dir`. See `db::ops::normalise_stage_str`
// for the user-facing equivalent on the db boundary.
let normalised = 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",
other => other,
};
let Some(typed) = Stage::from_dir(normalised) else {
crate::slog!("[crdt_state] write_item_str: unknown stage '{stage}' for {story_id}");
return;
};
write_item(
story_id,
&typed,
name,
agent,
retry_count,
blocked,
depends_on,
claimed_by,
claimed_at,
merged_at,
);
}
/// Set `retry_count` to an explicit value for a pipeline item.
///
/// Pure metadata operation — the item's stage is not changed.