huskies: merge 945
This commit is contained in:
@@ -82,15 +82,15 @@ pub fn set_epic(story_id: &str, epic_id: Option<&str>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Set the `review_hold` CRDT flag for a pipeline item (sub-story 932).
|
||||
/// Set the `resume_to` CRDT register for a pipeline item (story 945).
|
||||
///
|
||||
/// `true` marks the item as held for human review at a pipeline-stage boundary;
|
||||
/// the auto-assigner skips items with this flag. `false` clears the hold and
|
||||
/// is written explicitly (the register is not removed) so the cleared state
|
||||
/// survives CRDT replay correctly.
|
||||
/// This sibling register stores the wire-form stage name (`"coding"`, `"qa"`,
|
||||
/// etc.) that a `Stage::Frozen` or `Stage::ReviewHold` variant should resume
|
||||
/// to. Empty string means "no resume target stored" (defaults to `Coding`
|
||||
/// on read).
|
||||
///
|
||||
/// Returns `true` if the item was found and the op was applied, `false` otherwise.
|
||||
pub fn set_review_hold(story_id: &str, value: bool) -> bool {
|
||||
pub fn set_resume_to(story_id: &str, stage: &Stage) -> bool {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return false;
|
||||
};
|
||||
@@ -100,54 +100,8 @@ pub fn set_review_hold(story_id: &str, value: bool) -> bool {
|
||||
let Some(&idx) = state.index.get(story_id) else {
|
||||
return false;
|
||||
};
|
||||
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].review_hold.set(value));
|
||||
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
|
||||
/// item, preventing repeated auto-spawns across restarts.
|
||||
/// Passing `false` explicitly writes `false` (does not remove the register) so
|
||||
/// the cleared state is distinguishable from an unset register and survives
|
||||
/// CRDT replay correctly.
|
||||
///
|
||||
/// Returns `true` if the item was found and the op was applied, `false` otherwise.
|
||||
pub fn set_mergemaster_attempted(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].mergemaster_attempted.set(value)
|
||||
});
|
||||
let value = stage_dir_name(stage).to_string();
|
||||
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].resume_to.set(value));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -244,7 +198,6 @@ pub fn write_item(
|
||||
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>,
|
||||
@@ -292,9 +245,6 @@ pub fn write_item(
|
||||
s.crdt.doc.items[idx].retry_count.set(rc as f64)
|
||||
});
|
||||
}
|
||||
if let Some(b) = blocked {
|
||||
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].blocked.set(b));
|
||||
}
|
||||
if let Some(d) = depends_on {
|
||||
apply_and_persist(&mut state, |s| {
|
||||
s.crdt.doc.items[idx].depends_on.set(d.to_string())
|
||||
@@ -335,17 +285,14 @@ pub fn write_item(
|
||||
"name": name.unwrap_or(""),
|
||||
"agent": agent.unwrap_or(""),
|
||||
"retry_count": retry_count.unwrap_or(0) as f64,
|
||||
"blocked": blocked.unwrap_or(false),
|
||||
"depends_on": depends_on.unwrap_or(""),
|
||||
"claimed_by": claimed_by.unwrap_or(""),
|
||||
"claimed_at": claimed_at.unwrap_or(0.0),
|
||||
"merged_at": merged_at.unwrap_or(0.0),
|
||||
"qa_mode": "",
|
||||
"mergemaster_attempted": false,
|
||||
"review_hold": false,
|
||||
"item_type": "",
|
||||
"epic": "",
|
||||
"frozen": false,
|
||||
"resume_to": "",
|
||||
})
|
||||
.into();
|
||||
|
||||
@@ -366,17 +313,14 @@ pub fn write_item(
|
||||
item.name.advance_seq(floor);
|
||||
item.agent.advance_seq(floor);
|
||||
item.retry_count.advance_seq(floor);
|
||||
item.blocked.advance_seq(floor);
|
||||
item.depends_on.advance_seq(floor);
|
||||
item.claimed_by.advance_seq(floor);
|
||||
item.claimed_at.advance_seq(floor);
|
||||
item.merged_at.advance_seq(floor);
|
||||
item.qa_mode.advance_seq(floor);
|
||||
item.mergemaster_attempted.advance_seq(floor);
|
||||
item.review_hold.advance_seq(floor);
|
||||
item.item_type.advance_seq(floor);
|
||||
item.epic.advance_seq(floor);
|
||||
item.frozen.advance_seq(floor);
|
||||
item.resume_to.advance_seq(floor);
|
||||
}
|
||||
|
||||
// Broadcast a CrdtEvent for the new item.
|
||||
@@ -404,7 +348,6 @@ pub fn write_item_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>,
|
||||
@@ -436,7 +379,6 @@ pub fn write_item_str(
|
||||
name,
|
||||
agent,
|
||||
retry_count,
|
||||
blocked,
|
||||
depends_on,
|
||||
claimed_by,
|
||||
claimed_at,
|
||||
@@ -463,25 +405,6 @@ pub fn set_retry_count(story_id: &str, count: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the `blocked` register on a story to the given value.
|
||||
///
|
||||
/// Pure metadata operation — the item's stage is not changed.
|
||||
/// Use this alongside a state-machine transition out of `Blocked` /
|
||||
/// `MergeFailure` to keep the legacy `blocked` register in sync with the
|
||||
/// typed stage post-865 (where YAML side-effects no longer clear the
|
||||
/// register on their own).
|
||||
pub fn set_blocked(story_id: &str, blocked: bool) {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut state) = state_mutex.lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(&idx) = state.index.get(story_id) {
|
||||
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].blocked.set(blocked));
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment `retry_count` by 1 and return the new value.
|
||||
///
|
||||
/// Pure metadata operation — the item's stage is not changed.
|
||||
|
||||
@@ -212,8 +212,8 @@ fn legacy_stage_to_clean(s: &str) -> Option<&'static str> {
|
||||
/// 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.
|
||||
/// Story 945: the `Frozen` variant returns as `Stage::Frozen { resume_to }`,
|
||||
/// replacing the orthogonal CRDT register that briefly existed in 934 stage 4.
|
||||
///
|
||||
/// 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.
|
||||
@@ -222,8 +222,10 @@ pub fn migrate_legacy_stage_strings() {
|
||||
return;
|
||||
};
|
||||
|
||||
// First pass: collect (index, clean_stage, set_frozen) for items that
|
||||
// still carry legacy stage strings.
|
||||
// First pass: collect (index, clean_stage, was_frozen) for items that
|
||||
// still carry legacy stage strings. `was_frozen` triggers writing
|
||||
// `resume_to = "backlog"` so the post-945 typed projection reads back as
|
||||
// `Stage::Frozen { resume_to: Stage::Backlog }`.
|
||||
let migrations: Vec<(usize, &'static str, bool)> = {
|
||||
let Ok(state) = state_mutex.lock() else {
|
||||
return;
|
||||
@@ -239,7 +241,11 @@ pub fn migrate_legacy_stage_strings() {
|
||||
};
|
||||
let clean = legacy_stage_to_clean(¤t)?;
|
||||
let was_frozen = current == "7_frozen";
|
||||
Some((idx, clean, was_frozen))
|
||||
// For legacy frozen items, store the post-945 stage as
|
||||
// "frozen" rather than "backlog" so the typed projection
|
||||
// produces `Stage::Frozen` again.
|
||||
let stage_out = if was_frozen { "frozen" } else { clean };
|
||||
Some((idx, stage_out, was_frozen))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -258,12 +264,14 @@ pub fn migrate_legacy_stage_strings() {
|
||||
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));
|
||||
apply_and_persist(&mut state, |s| {
|
||||
s.crdt.doc.items[idx].resume_to.set("backlog".to_string())
|
||||
});
|
||||
}
|
||||
}
|
||||
slog!(
|
||||
"[crdt] Migrated {count} legacy stage strings to clean wire form \
|
||||
({frozen_count} of which were '7_frozen' → backlog + frozen=true)"
|
||||
({frozen_count} of which were '7_frozen' → frozen + resume_to=backlog)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,7 +300,6 @@ mod stage_migration_tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// Then overwrite the stage register with the raw legacy string,
|
||||
// bypassing `db::normalise_stage_str` / `write_item_str`'s mapping.
|
||||
@@ -353,27 +360,33 @@ mod stage_migration_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_collapses_7_frozen_to_backlog_and_sets_frozen_flag() {
|
||||
fn migrate_promotes_7_frozen_to_typed_frozen_variant() {
|
||||
init_for_test();
|
||||
let story_id = "9510_legacy_frozen";
|
||||
seed_with_raw_stage(story_id, "7_frozen");
|
||||
|
||||
// Sanity: before migration, the frozen flag is false.
|
||||
let before = read_item(story_id).expect("seeded item exists");
|
||||
assert!(!before.frozen(), "frozen flag should start false");
|
||||
// Sanity: before migration, the projection's legacy fallback maps
|
||||
// raw `"7_frozen"` → `Stage::Backlog` (frozen state is lost without the
|
||||
// migration's resume_to write).
|
||||
let before = read_item(story_id).expect("legacy 7_frozen should still project");
|
||||
assert!(
|
||||
matches!(before.stage(), crate::pipeline_state::Stage::Backlog),
|
||||
"raw 7_frozen should fall back to Backlog before migration; got {:?}",
|
||||
before.stage()
|
||||
);
|
||||
|
||||
migrate_legacy_stage_strings();
|
||||
|
||||
let after = read_item(story_id).expect("item must still exist after migration");
|
||||
assert!(
|
||||
matches!(after.stage(), crate::pipeline_state::Stage::Backlog),
|
||||
"7_frozen should collapse to Backlog: got {:?}",
|
||||
after.stage()
|
||||
);
|
||||
assert!(
|
||||
after.frozen(),
|
||||
"frozen flag should be set after 7_frozen migration"
|
||||
);
|
||||
match after.stage() {
|
||||
crate::pipeline_state::Stage::Frozen { resume_to } => {
|
||||
assert!(
|
||||
matches!(**resume_to, crate::pipeline_state::Stage::Backlog),
|
||||
"resume_to should default to Backlog for migrated 7_frozen items"
|
||||
);
|
||||
}
|
||||
other => panic!("7_frozen should migrate to Stage::Frozen; got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -390,7 +403,6 @@ mod stage_migration_tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
seed_with_raw_stage("9521_needs_migration", "2_current");
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ mod migrations;
|
||||
mod tests;
|
||||
|
||||
pub use item::{
|
||||
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,
|
||||
bump_retry_count, set_agent, set_depends_on, set_epic, set_item_type, set_name, set_qa_mode,
|
||||
set_resume_to, set_retry_count, write_item,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -100,7 +100,6 @@ fn migrate_story_ids_to_numeric_rewrites_slug_ids() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let result = migrate_story_ids_to_numeric();
|
||||
@@ -133,7 +132,6 @@ fn migrate_story_ids_to_numeric_is_idempotent() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// First call — nothing to migrate.
|
||||
@@ -163,20 +161,8 @@ fn migrate_story_ids_to_numeric_skips_conflict() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
write_item_str(
|
||||
"44",
|
||||
"2_current",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
write_item_str("44", "2_current", None, None, None, None, None, None, None);
|
||||
|
||||
let result = migrate_story_ids_to_numeric();
|
||||
// The slug entry must NOT be migrated because "44" is already occupied.
|
||||
@@ -210,7 +196,6 @@ fn migrate_story_ids_to_numeric_preserves_stage_and_name() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
migrate_story_ids_to_numeric();
|
||||
@@ -236,7 +221,6 @@ fn migrate_names_from_slugs_fills_empty_names() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Before migration the name should be empty.
|
||||
@@ -271,7 +255,6 @@ fn migrate_names_from_slugs_leaves_existing_names_unchanged() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
migrate_names_from_slugs();
|
||||
@@ -309,7 +292,6 @@ fn set_depends_on_round_trip_and_clear() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Set depends_on to [837] and verify CRDT register holds the list.
|
||||
@@ -349,62 +331,6 @@ fn set_depends_on_returns_false_for_unknown_story() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── set_mergemaster_attempted regression tests ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_mergemaster_attempted_true_then_false_flips_register() {
|
||||
init_for_test();
|
||||
|
||||
write_item_str(
|
||||
"873_story_mergemaster_flip",
|
||||
"4_merge",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Set true — register must read back as true.
|
||||
let ok = set_mergemaster_attempted("873_story_mergemaster_flip", true);
|
||||
assert!(
|
||||
ok,
|
||||
"set_mergemaster_attempted should return true for known item"
|
||||
);
|
||||
let view = read_item("873_story_mergemaster_flip").unwrap();
|
||||
assert_eq!(
|
||||
view.mergemaster_attempted,
|
||||
Some(true),
|
||||
"CRDT register should hold true after setting true"
|
||||
);
|
||||
|
||||
// Set false — register must flip back to false (not unset).
|
||||
let ok = set_mergemaster_attempted("873_story_mergemaster_flip", false);
|
||||
assert!(
|
||||
ok,
|
||||
"set_mergemaster_attempted(false) should return true for known item"
|
||||
);
|
||||
let view = read_item("873_story_mergemaster_flip").unwrap();
|
||||
assert_eq!(
|
||||
view.mergemaster_attempted,
|
||||
Some(false),
|
||||
"CRDT register should hold false after explicit clear"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_mergemaster_attempted_returns_false_for_unknown_story() {
|
||||
init_for_test();
|
||||
let ok = set_mergemaster_attempted("nonexistent_story_mm", true);
|
||||
assert!(
|
||||
!ok,
|
||||
"set_mergemaster_attempted should return false for unknown story_id"
|
||||
);
|
||||
}
|
||||
|
||||
// ── set_agent tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -421,7 +347,6 @@ fn set_agent_some_writes_name() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let found = set_agent("871_story_set_agent_write", Some("coder-1"));
|
||||
@@ -449,7 +374,6 @@ fn set_agent_none_clears_register() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Confirm agent is set.
|
||||
@@ -495,7 +419,6 @@ fn set_qa_mode_round_trip_server_then_human() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Set qa=server via typed path and assert CRDT register reflects it.
|
||||
@@ -551,7 +474,6 @@ fn bump_retry_count_increments_by_one() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let v1 = bump_retry_count("9001_story_bump_test");
|
||||
@@ -581,7 +503,6 @@ fn set_retry_count_resets_to_zero() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
set_retry_count("9002_story_set_test", 0);
|
||||
@@ -765,7 +686,6 @@ async fn tombstone_survives_concurrent_writes() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
read_item(story_id).is_some(),
|
||||
@@ -787,7 +707,6 @@ async fn tombstone_survives_concurrent_writes() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user