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
+7 -3
View File
@@ -52,11 +52,15 @@ pub use types::{
TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, WorkItem,
};
pub use write::{
bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id,
set_agent, set_blocked, set_depends_on, set_epic, set_item_type, set_mergemaster_attempted,
set_name, set_qa_mode, set_retry_count, set_review_hold, write_item,
bump_retry_count, migrate_legacy_stage_strings, migrate_names_from_slugs,
migrate_story_ids_to_numeric, name_from_story_id, set_agent, set_blocked, set_depends_on,
set_epic, set_frozen, set_item_type, set_mergemaster_attempted, set_name, set_qa_mode,
set_retry_count, set_review_hold, write_item,
};
#[cfg(test)]
pub use write::write_item_str;
#[cfg(test)]
pub use state::init_for_test;
+2 -2
View File
@@ -190,7 +190,7 @@ pub fn apply_remote_op(op: SignedOp) -> bool {
mod tests {
use super::super::state::init_for_test;
use super::super::types::{NodePresenceCrdt, PipelineItemCrdt};
use super::super::write::write_item;
use super::super::write::write_item_str;
use super::*;
use bft_json_crdt::json_crdt::OpState;
use bft_json_crdt::keypair::make_keypair;
@@ -542,7 +542,7 @@ mod tests {
);
// Attempt resurrection via write_item — must be rejected by tombstone check.
write_item(
write_item_str(
story_id,
"1_backlog",
Some("Resurrected"),
+9 -3
View File
@@ -363,6 +363,11 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
_ => None,
};
let frozen = match item.frozen.view() {
JsonValue::Bool(b) => Some(b),
_ => None,
};
Some(PipelineItemView {
story_id,
stage,
@@ -379,6 +384,7 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
review_hold,
item_type,
epic,
frozen,
})
}
@@ -458,7 +464,7 @@ mod tests {
use super::super::state::init_for_test;
use super::super::state::rebuild_index;
use super::super::types::PipelineItemCrdt;
use super::super::write::write_item;
use super::super::write::write_item_str;
use super::*;
use bft_json_crdt::json_crdt::OpState;
use bft_json_crdt::keypair::make_keypair;
@@ -540,7 +546,7 @@ mod tests {
let story_id = "917_story_concurrent_evict";
// Insert the story locally (simulates node 1's insert).
write_item(
write_item_str(
story_id,
"1_backlog",
Some("Node 1 insert"),
@@ -611,7 +617,7 @@ mod tests {
);
// story_id must be in the tombstone set so write_item cannot resurrect it.
write_item(
write_item_str(
story_id,
"1_backlog",
Some("Resurrection attempt"),
+6 -6
View File
@@ -102,14 +102,14 @@ fn rebuild_index_maps_story_ids() {
#[tokio::test(flavor = "current_thread")]
async fn subscribe_receives_stage_transition_events() {
use super::super::types::CrdtEvent;
use super::super::write::write_item;
use super::super::write::write_item_str;
crate::crdt_state::init_for_test();
let mut rx = super::subscribe().expect("subscribe must return Some after init_for_test");
// Insert a new item — emit_event fires with from_stage=None.
write_item(
write_item_str(
"906_story_subscribe",
"1_backlog",
Some("Subscribe Wiring"),
@@ -125,10 +125,10 @@ async fn subscribe_receives_stage_transition_events() {
let evt: CrdtEvent = rx.try_recv().expect("expected CrdtEvent on insert");
assert_eq!(evt.story_id, "906_story_subscribe");
assert!(evt.from_stage.is_none());
assert_eq!(evt.to_stage, "1_backlog");
assert_eq!(evt.to_stage, "backlog");
// Update stage — emit_event fires again with the real from_stage.
write_item(
write_item_str(
"906_story_subscribe",
"2_current",
None,
@@ -143,8 +143,8 @@ async fn subscribe_receives_stage_transition_events() {
let evt: CrdtEvent = rx.try_recv().expect("expected CrdtEvent on stage change");
assert_eq!(evt.story_id, "906_story_subscribe");
assert_eq!(evt.from_stage.as_deref(), Some("1_backlog"));
assert_eq!(evt.to_stage, "2_current");
assert_eq!(evt.from_stage.as_deref(), Some("backlog"));
assert_eq!(evt.to_stage, "coding");
}
#[tokio::test]
+51 -29
View File
@@ -94,6 +94,12 @@ pub struct PipelineItemCrdt {
/// member of any epic. Sub-story 933; replaces the legacy `epic:` YAML
/// front-matter field that linked member work items to their epic.
pub epic: LwwRegisterCrdt<String>,
/// Set to `true` when a story is frozen. Frozen stories stay at their
/// current `Stage` but are skipped by the auto-assigner until explicitly
/// unfrozen. Orthogonal to `Stage` (story 934, stage 4); replaces the
/// pre-934 `Stage::Frozen { resume_to }` variant whose resume payload was
/// just "the stage you were in when you froze".
pub frozen: LwwRegisterCrdt<bool>,
}
/// CRDT node that holds a single peer's presence entry.
@@ -143,46 +149,56 @@ pub enum Stage {
Done,
/// Out of the active flow (`6_archived`).
Archived,
/// Frozen, awaiting human review (`7_frozen`).
Frozen,
/// An unrecognised stage string — forward-compatible catch-all.
Unknown(String),
}
impl Stage {
/// Parse a stage directory string into the typed enum.
/// Parse a stage wire string into the typed enum.
///
/// Accepts only the post-934 clean vocabulary (`"backlog"`, `"coding"`,
/// `"qa"`, `"merge"`, `"merge_failure"`, `"blocked"`, `"done"`,
/// `"archived"`, `"upcoming"`). Pre-934 directory-style strings
/// (`"2_current"`, `"4_merge"`, etc.) are no longer accepted — they are
/// rewritten at startup by `migrate_legacy_stage_strings`.
pub fn from_dir(s: &str) -> Self {
match s {
"0_upcoming" => Stage::Upcoming,
"1_backlog" => Stage::Backlog,
"2_current" => Stage::Coding,
"2_blocked" => Stage::Blocked,
"3_qa" => Stage::Qa,
"4_merge" => Stage::Merge,
"4_merge_failure" => Stage::MergeFailure,
"5_done" => Stage::Done,
"6_archived" => Stage::Archived,
"7_frozen" => Stage::Frozen,
"upcoming" => Stage::Upcoming,
"backlog" => Stage::Backlog,
"coding" => Stage::Coding,
"blocked" => Stage::Blocked,
"qa" => Stage::Qa,
"merge" => Stage::Merge,
"merge_failure" => Stage::MergeFailure,
"done" => Stage::Done,
"archived" => Stage::Archived,
other => Stage::Unknown(other.to_string()),
}
}
/// Convert back to the filesystem directory name string.
/// Convert back to the wire string for persistence into the CRDT.
///
/// Post-934: clean vocabulary (no numeric prefixes); the strings only
/// survive at this single CRDT-serialisation boundary.
pub fn as_dir(&self) -> &str {
match self {
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",
Stage::Unknown(s) => s.as_str(),
}
}
/// `true` if this is an "active" stage (`Coding`, `Qa`, or `Merge`).
pub fn is_active(&self) -> bool {
matches!(self, Stage::Coding | Stage::Qa | Stage::Merge)
}
}
/// A typed snapshot of a single pipeline work item derived from the CRDT document.
@@ -219,6 +235,8 @@ pub struct WorkItem {
pub(super) item_type: Option<String>,
/// Epic ID this item belongs to, or `None` (sub-story 933).
pub(super) epic: Option<String>,
/// Whether the item is frozen (story 934, stage 4). Orthogonal to `Stage`.
pub(super) frozen: Option<bool>,
}
impl WorkItem {
@@ -232,11 +250,6 @@ impl WorkItem {
Stage::from_dir(&self.stage)
}
/// Raw stage directory string (e.g. `"2_current"`).
pub fn stage_str(&self) -> &str {
&self.stage
}
/// Human-readable story name, or `None` when unset.
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
@@ -303,6 +316,13 @@ impl WorkItem {
self.epic.as_deref()
}
/// Whether the item is frozen (story 934, stage 4). Returns `false` when
/// the register is unset. Orthogonal to [`Self::stage`]: a frozen story
/// stays at its current stage but is skipped by the auto-assigner.
pub fn frozen(&self) -> bool {
self.frozen.unwrap_or(false)
}
/// Construct a `WorkItem` for use in tests outside `crdt_state::*`.
///
/// Within `crdt_state` use a struct literal directly (fields are `pub(super)`).
@@ -325,6 +345,7 @@ impl WorkItem {
review_hold: Option<bool>,
item_type: Option<String>,
epic: Option<String>,
frozen: Option<bool>,
) -> Self {
Self {
story_id: story_id.into(),
@@ -342,6 +363,7 @@ impl WorkItem {
review_hold,
item_type,
epic,
frozen,
}
}
}
+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.
+85
View File
@@ -181,3 +181,88 @@ pub fn migrate_names_from_slugs() {
}
slog!("[crdt] Migrated names for {count} items from story ID slugs");
}
/// Map a pre-934 legacy directory-style stage string to its clean wire form.
///
/// Returns `None` if `s` is already in clean wire form (or is genuinely
/// unknown), so the migration can quickly skip already-clean items.
fn legacy_stage_to_clean(s: &str) -> Option<&'static str> {
match s {
"0_upcoming" => Some("upcoming"),
"1_backlog" => Some("backlog"),
"2_current" => Some("coding"),
"2_blocked" => Some("blocked"),
"3_qa" => Some("qa"),
"4_merge" => Some("merge"),
"4_merge_failure" => Some("merge_failure"),
"5_done" => Some("done"),
"6_archived" => Some("archived"),
// Story 934, stage 4: `Stage::Frozen` no longer exists. Items that
// were previously frozen become orthogonal-flag-frozen: their stage
// register collapses to `backlog` (a safe "not progressing" default
// since the original resume_to payload was lost when the variant was
// dropped) and a separate write sets `frozen = true`.
"7_frozen" => Some("backlog"),
_ => None,
}
}
/// Rewrite every pipeline item whose `stage` register still carries a pre-934
/// directory-style string (`"2_current"`, `"4_merge"`, etc.) to the clean wire
/// vocabulary (`"coding"`, `"merge"`, etc.).
///
/// Items that were at `"7_frozen"` additionally get the new `frozen` flag set
/// — the stage variant `Frozen` was dropped in story 934 stage 4 in favour of
/// an orthogonal CRDT register.
///
/// One-time startup migration: items that have transitioned at least once
/// since story 934 stage 1 (which made writes emit clean form) are no-ops.
pub fn migrate_legacy_stage_strings() {
let Some(state_mutex) = get_crdt() else {
return;
};
// First pass: collect (index, clean_stage, set_frozen) for items that
// still carry legacy stage strings.
let migrations: Vec<(usize, &'static str, bool)> = {
let Ok(state) = state_mutex.lock() else {
return;
};
state
.index
.iter()
.filter_map(|(_story_id, &idx)| {
let item = &state.crdt.doc.items[idx];
let current = match item.stage.view() {
JsonValue::String(s) => s,
_ => return None,
};
let clean = legacy_stage_to_clean(&current)?;
let was_frozen = current == "7_frozen";
Some((idx, clean, was_frozen))
})
.collect()
};
if migrations.is_empty() {
return;
}
let Ok(mut state) = state_mutex.lock() else {
return;
};
let count = migrations.len();
let frozen_count = migrations.iter().filter(|(_, _, f)| *f).count();
for (idx, clean, was_frozen) in migrations {
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].stage.set(clean.to_string())
});
if was_frozen {
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].frozen.set(true));
}
}
slog!(
"[crdt] Migrated {count} legacy stage strings to clean wire form \
({frozen_count} of which were '7_frozen' → backlog + frozen=true)"
);
}
+8 -2
View File
@@ -10,7 +10,13 @@ mod migrations;
mod tests;
pub use item::{
bump_retry_count, set_agent, set_blocked, set_depends_on, set_epic, set_item_type,
bump_retry_count, set_agent, set_blocked, set_depends_on, set_epic, set_frozen, set_item_type,
set_mergemaster_attempted, set_name, set_qa_mode, set_retry_count, set_review_hold, write_item,
};
pub use migrations::{migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id};
#[cfg(test)]
pub use item::write_item_str;
pub use migrations::{
migrate_legacy_stage_strings, migrate_names_from_slugs, migrate_story_ids_to_numeric,
name_from_story_id,
};
+17 -17
View File
@@ -90,7 +90,7 @@ fn numeric_id_from_slug_returns_none_for_non_numeric_prefix() {
fn migrate_story_ids_to_numeric_rewrites_slug_ids() {
init_for_test();
write_item(
write_item_str(
"42_story_my_feature",
"1_backlog",
Some("My Feature"),
@@ -123,7 +123,7 @@ fn migrate_story_ids_to_numeric_rewrites_slug_ids() {
fn migrate_story_ids_to_numeric_is_idempotent() {
init_for_test();
write_item(
write_item_str(
"43",
"1_backlog",
Some("Already Numeric"),
@@ -153,7 +153,7 @@ fn migrate_story_ids_to_numeric_skips_conflict() {
init_for_test();
// Both the slug form AND its numeric target exist.
write_item(
write_item_str(
"44_story_foo",
"1_backlog",
None,
@@ -165,7 +165,7 @@ fn migrate_story_ids_to_numeric_skips_conflict() {
None,
None,
);
write_item(
write_item_str(
"44",
"2_current",
None,
@@ -200,7 +200,7 @@ fn migrate_story_ids_to_numeric_noop_when_crdt_not_initialised() {
fn migrate_story_ids_to_numeric_preserves_stage_and_name() {
init_for_test();
write_item(
write_item_str(
"45_bug_crash",
"2_current",
Some("Crash Bug"),
@@ -216,7 +216,7 @@ fn migrate_story_ids_to_numeric_preserves_stage_and_name() {
migrate_story_ids_to_numeric();
let item = read_item("45").expect("item must be accessible by numeric ID");
assert_eq!(item.stage, "2_current");
assert_eq!(item.stage, "coding");
assert_eq!(item.name.as_deref(), Some("Crash Bug"));
assert_eq!(item.agent.as_deref(), Some("coder-1"));
}
@@ -226,7 +226,7 @@ fn migrate_names_from_slugs_fills_empty_names() {
init_for_test();
// Write an item without a name.
write_item(
write_item_str(
"42_story_my_feature",
"1_backlog",
None,
@@ -261,7 +261,7 @@ fn migrate_names_from_slugs_fills_empty_names() {
fn migrate_names_from_slugs_leaves_existing_names_unchanged() {
init_for_test();
write_item(
write_item_str(
"43_story_named_item",
"1_backlog",
Some("Already Named"),
@@ -299,7 +299,7 @@ fn set_depends_on_round_trip_and_clear() {
use super::super::read::{check_unmet_deps_crdt, read_item};
init_for_test();
write_item(
write_item_str(
"872_test_target",
"1_backlog",
Some("Target"),
@@ -355,7 +355,7 @@ fn set_depends_on_returns_false_for_unknown_story() {
fn set_mergemaster_attempted_true_then_false_flips_register() {
init_for_test();
write_item(
write_item_str(
"873_story_mergemaster_flip",
"4_merge",
None,
@@ -411,7 +411,7 @@ fn set_mergemaster_attempted_returns_false_for_unknown_story() {
fn set_agent_some_writes_name() {
init_for_test();
write_item(
write_item_str(
"871_story_set_agent_write",
"2_current",
Some("Set Agent Write"),
@@ -439,7 +439,7 @@ fn set_agent_some_writes_name() {
fn set_agent_none_clears_register() {
init_for_test();
write_item(
write_item_str(
"871_story_set_agent_clear",
"2_current",
Some("Set Agent Clear"),
@@ -485,7 +485,7 @@ fn set_qa_mode_round_trip_server_then_human() {
use crate::io::story_metadata::QaMode;
init_for_test();
write_item(
write_item_str(
"869_story_qa_roundtrip",
"1_backlog",
None,
@@ -541,7 +541,7 @@ fn set_qa_mode_returns_false_for_unknown_story() {
#[test]
fn bump_retry_count_increments_by_one() {
init_for_test();
write_item(
write_item_str(
"9001_story_bump_test",
"2_current",
None,
@@ -571,7 +571,7 @@ fn bump_retry_count_increments_by_one() {
#[test]
fn set_retry_count_resets_to_zero() {
init_for_test();
write_item(
write_item_str(
"9002_story_set_test",
"2_current",
None,
@@ -755,7 +755,7 @@ async fn tombstone_survives_concurrent_writes() {
let story_id = "889_story_tombstone_concurrent";
write_item(
write_item_str(
story_id,
"2_current",
Some("Tombstone Concurrent Test"),
@@ -777,7 +777,7 @@ async fn tombstone_survives_concurrent_writes() {
let writer = tokio::task::spawn(async move {
while !stop_clone.load(Ordering::Relaxed) {
write_item(
write_item_str(
story_id,
"2_current",
Some("Tombstone Concurrent Test"),