sketch(520): typed pipeline state machine — bare and statig versions
Two parallel scratch experiments under server/examples/ exploring the
typed Rust state machine that should replace huskies's current
stringly-typed CRDT representation (story 520).
- pipeline_state_sketch_bare.rs — hand-rolled, plain enums + match
- pipeline_state_sketch_statig.rs — using the statig crate
Both sketches:
- Define the same Stage enum (Backlog, Coding, Qa, Merge, Done, Archived)
- Define ArchiveReason (subsumes refactor 436's blocked/merge_failure/review_hold)
- Define ExecutionState (per-node, separate from synced Stage) — bare only
- Define PipelineEvent and the valid transitions
- Make bug 519 unrepresentable: Stage::Merge requires NonZeroU32 commits_ahead
- Make bug 502 unrepresentable: Coder agents can't be assigned to Merge state
- Have happy-path tests, retry-loop tests, and invalid-transition tests
Differences:
- Bare uses pure pattern matching, no framework. ~720 lines.
- Statig uses #[state_machine] proc macro and gets free hierarchical
states via the `active` superstate that factors out the cross-cutting
Block / ReviewHold / Abandon / Supersede transitions across the four
active stages. ~440 lines, 11 passing tests.
Run with:
cargo run --example pipeline_state_sketch_bare -p huskies
cargo run --example pipeline_state_sketch_statig -p huskies
cargo test --example pipeline_state_sketch_bare -p huskies
cargo test --example pipeline_state_sketch_statig -p huskies
Adds statig 0.3 as a dev-dependency in server/Cargo.toml. Cargo.lock
updated to include statig + statig-macro and their transitive deps.
Not wired into the main codebase. Once we agree on which version to
adopt, story 520 promotes the chosen sketch into a real
server/src/pipeline_state.rs module.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
//! Pipeline state machine — design sketch (story 520) — STATIG version.
|
||||
//!
|
||||
//! Parallel to `pipeline_state_sketch_bare.rs`. Same domain types, same
|
||||
//! transitions, same event semantics — but the state machine is built using
|
||||
//! the `statig` crate (https://crates.io/crates/statig) instead of being
|
||||
//! hand-rolled.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --example pipeline_state_sketch_statig -p huskies
|
||||
//! Test with:
|
||||
//! cargo test --example pipeline_state_sketch_statig -p huskies
|
||||
//!
|
||||
//! Why both versions?
|
||||
//!
|
||||
//! - The **bare** version shows that plain Rust enums + a transition function
|
||||
//! are *enough* to make impossible states unrepresentable. No framework.
|
||||
//! - The **statig** version shows what we'd gain by adopting a state-machine
|
||||
//! crate: hierarchical states (the `active` superstate factors out the
|
||||
//! cross-cutting Block/ReviewHold/Abandon/Supersede transitions, which the
|
||||
//! bare version had to duplicate inline with `|` patterns), generated
|
||||
//! `State` enum with type-safe data-carrying constructors, and stateful
|
||||
//! `handle(&event)` dispatch. Type safety is preserved either way:
|
||||
//! `State::merge(BranchName, NonZeroU32)` requires both args at the
|
||||
//! constructor, just like `Stage::Merge { feature_branch, commits_ahead }`
|
||||
//! in the bare version.
|
||||
//!
|
||||
//! Trade-off: statig adds a dependency and a proc-macro layer, which makes
|
||||
//! the code harder to read for someone unfamiliar with the crate. The
|
||||
//! framework-free version is more transparent but requires manual
|
||||
//! pattern-matching and inline duplication for cross-cutting transitions.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use statig::prelude::*;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
// ── Newtypes (same as bare version) ──────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct StoryId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct BranchName(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct GitSha(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AgentName(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct NodePubkey(pub [u8; 32]);
|
||||
|
||||
// ── Archive reason (same as bare version) ────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ArchiveReason {
|
||||
Completed,
|
||||
Abandoned,
|
||||
Superseded { by: StoryId },
|
||||
Blocked { reason: String },
|
||||
MergeFailed { reason: String },
|
||||
ReviewHeld { reason: String },
|
||||
}
|
||||
|
||||
// ── Pipeline events (same as bare version) ───────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PipelineEvent {
|
||||
DepsMet,
|
||||
GatesStarted,
|
||||
GatesPassed {
|
||||
feature_branch: BranchName,
|
||||
commits_ahead: NonZeroU32,
|
||||
},
|
||||
GatesFailed {
|
||||
reason: String,
|
||||
},
|
||||
QaSkipped {
|
||||
feature_branch: BranchName,
|
||||
commits_ahead: NonZeroU32,
|
||||
},
|
||||
MergeSucceeded {
|
||||
merge_commit: GitSha,
|
||||
},
|
||||
MergeFailedFinal {
|
||||
reason: String,
|
||||
},
|
||||
Accepted,
|
||||
Block {
|
||||
reason: String,
|
||||
},
|
||||
Unblock,
|
||||
Abandon,
|
||||
Supersede {
|
||||
by: StoryId,
|
||||
},
|
||||
ReviewHold {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ── The state machine ────────────────────────────────────────────────────────
|
||||
//
|
||||
// statig requires a "context" struct (the `Self` of the impl block). For us
|
||||
// it's empty — all per-state data lives ON the state itself, carried forward
|
||||
// by the auto-generated `State::xxx(...)` constructors.
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PipelineMachine;
|
||||
|
||||
#[state_machine(
|
||||
initial = "State::backlog()",
|
||||
state(derive(Debug, Clone, PartialEq, Eq))
|
||||
)]
|
||||
impl PipelineMachine {
|
||||
// ── Active stages: backlog, coding, qa, merge ────────────────────────
|
||||
//
|
||||
// Each is a child of the `active` superstate, which handles the
|
||||
// cross-cutting transitions (Block / ReviewHold / Abandon / Supersede)
|
||||
// exactly once instead of being duplicated per state.
|
||||
|
||||
#[state(superstate = "active")]
|
||||
fn backlog(event: &PipelineEvent) -> Response<State> {
|
||||
match event {
|
||||
PipelineEvent::DepsMet => Transition(State::coding()),
|
||||
_ => Super, // defer to `active` (and ultimately to "unhandled")
|
||||
}
|
||||
}
|
||||
|
||||
#[state(superstate = "active")]
|
||||
fn coding(event: &PipelineEvent) -> Response<State> {
|
||||
match event {
|
||||
PipelineEvent::GatesStarted => Transition(State::qa()),
|
||||
PipelineEvent::QaSkipped {
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
} => Transition(State::merge(feature_branch.clone(), *commits_ahead)),
|
||||
_ => Super,
|
||||
}
|
||||
}
|
||||
|
||||
#[state(superstate = "active")]
|
||||
fn qa(event: &PipelineEvent) -> Response<State> {
|
||||
match event {
|
||||
PipelineEvent::GatesPassed {
|
||||
feature_branch,
|
||||
commits_ahead,
|
||||
} => Transition(State::merge(feature_branch.clone(), *commits_ahead)),
|
||||
PipelineEvent::GatesFailed { .. } => Transition(State::coding()),
|
||||
_ => Super,
|
||||
}
|
||||
}
|
||||
|
||||
#[state(superstate = "active")]
|
||||
fn merge(
|
||||
_feature_branch: &mut BranchName,
|
||||
_commits_ahead: &mut NonZeroU32,
|
||||
event: &PipelineEvent,
|
||||
) -> Response<State> {
|
||||
// Note: the type signature of this state function REQUIRES both
|
||||
// _feature_branch and _commits_ahead. There is no way to construct
|
||||
// a Merge state without them. NonZeroU32 makes "merge with zero
|
||||
// commits ahead" structurally unrepresentable (bug 519 fixed by
|
||||
// construction, same as the bare version).
|
||||
//
|
||||
// The fields are prefixed with `_` because this state function only
|
||||
// transitions forward and doesn't read them — but they're available
|
||||
// to inspect via the State::Merge variant generated by the macro.
|
||||
match event {
|
||||
PipelineEvent::MergeSucceeded { merge_commit } => Transition(State::done(
|
||||
Utc::now(),
|
||||
merge_commit.clone(),
|
||||
)),
|
||||
PipelineEvent::MergeFailedFinal { reason } => Transition(State::archived(
|
||||
Utc::now(),
|
||||
ArchiveReason::MergeFailed {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
)),
|
||||
_ => Super,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-cutting superstate ─────────────────────────────────────────
|
||||
//
|
||||
// This is the statig payoff: ONE place defines what Block/ReviewHold/
|
||||
// Abandon/Supersede do across all four active stages. The bare version
|
||||
// had to duplicate this with `|` patterns. Adding a new active stage
|
||||
// here means just adding it as a child of `active`; the cross-cutting
|
||||
// transitions come for free.
|
||||
|
||||
#[superstate]
|
||||
fn active(event: &PipelineEvent) -> Response<State> {
|
||||
let now = Utc::now();
|
||||
match event {
|
||||
PipelineEvent::Block { reason } => Transition(State::archived(
|
||||
now,
|
||||
ArchiveReason::Blocked {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
)),
|
||||
PipelineEvent::ReviewHold { reason } => Transition(State::archived(
|
||||
now,
|
||||
ArchiveReason::ReviewHeld {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
)),
|
||||
PipelineEvent::Abandon => {
|
||||
Transition(State::archived(now, ArchiveReason::Abandoned))
|
||||
}
|
||||
PipelineEvent::Supersede { by } => Transition(State::archived(
|
||||
now,
|
||||
ArchiveReason::Superseded { by: by.clone() },
|
||||
)),
|
||||
_ => Handled, // unhandled events are silently ignored
|
||||
}
|
||||
}
|
||||
|
||||
// ── Done is special: it's not a child of `active` because Block and ──
|
||||
// ── ReviewHold are NOT valid from Done (per the bare version's rules).
|
||||
// ── Abandon and Supersede ARE valid, so we have to handle them inline.
|
||||
|
||||
#[state]
|
||||
fn done(
|
||||
merged_at: &mut DateTime<Utc>,
|
||||
merge_commit: &mut GitSha,
|
||||
event: &PipelineEvent,
|
||||
) -> Response<State> {
|
||||
let now = Utc::now();
|
||||
let _ = merged_at; // currently unused; available for queries
|
||||
let _ = merge_commit;
|
||||
match event {
|
||||
PipelineEvent::Accepted => {
|
||||
Transition(State::archived(now, ArchiveReason::Completed))
|
||||
}
|
||||
PipelineEvent::Abandon => {
|
||||
Transition(State::archived(now, ArchiveReason::Abandoned))
|
||||
}
|
||||
PipelineEvent::Supersede { by } => Transition(State::archived(
|
||||
now,
|
||||
ArchiveReason::Superseded { by: by.clone() },
|
||||
)),
|
||||
_ => Handled,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Archived is terminal except for Unblock from Blocked → Backlog ───
|
||||
|
||||
#[state]
|
||||
fn archived(
|
||||
archived_at: &mut DateTime<Utc>,
|
||||
reason: &mut ArchiveReason,
|
||||
event: &PipelineEvent,
|
||||
) -> Response<State> {
|
||||
let _ = archived_at;
|
||||
match event {
|
||||
PipelineEvent::Unblock => {
|
||||
if matches!(reason, ArchiveReason::Blocked { .. }) {
|
||||
Transition(State::backlog())
|
||||
} else {
|
||||
Handled // unblock only valid from Blocked
|
||||
}
|
||||
}
|
||||
_ => Handled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nz(n: u32) -> NonZeroU32 {
|
||||
NonZeroU32::new(n).unwrap()
|
||||
}
|
||||
fn fb(name: &str) -> BranchName {
|
||||
BranchName(name.to_string())
|
||||
}
|
||||
fn sha(s: &str) -> GitSha {
|
||||
GitSha(s.to_string())
|
||||
}
|
||||
|
||||
// ── Happy path ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn happy_path_backlog_through_done() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
assert!(matches!(sm.state(), State::Backlog {}));
|
||||
|
||||
sm.handle(&PipelineEvent::DepsMet);
|
||||
assert!(matches!(sm.state(), State::Coding {}));
|
||||
|
||||
sm.handle(&PipelineEvent::QaSkipped {
|
||||
feature_branch: fb("feature/story-1"),
|
||||
commits_ahead: nz(3),
|
||||
});
|
||||
assert!(matches!(sm.state(), State::Merge { .. }));
|
||||
|
||||
sm.handle(&PipelineEvent::MergeSucceeded {
|
||||
merge_commit: sha("abc123"),
|
||||
});
|
||||
assert!(matches!(sm.state(), State::Done { .. }));
|
||||
|
||||
sm.handle(&PipelineEvent::Accepted);
|
||||
assert!(matches!(
|
||||
sm.state(),
|
||||
State::Archived {
|
||||
reason: ArchiveReason::Completed,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qa_retry_loop() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::DepsMet);
|
||||
sm.handle(&PipelineEvent::GatesStarted);
|
||||
assert!(matches!(sm.state(), State::Qa {}));
|
||||
|
||||
sm.handle(&PipelineEvent::GatesFailed {
|
||||
reason: "tests failed".into(),
|
||||
});
|
||||
assert!(matches!(sm.state(), State::Coding {}));
|
||||
}
|
||||
|
||||
// ── Bug 519 unrepresentability: Merge with zero commits ahead ──────────
|
||||
|
||||
#[test]
|
||||
fn merge_with_zero_commits_is_unrepresentable() {
|
||||
// Identical to the bare version: NonZeroU32::new(0) returns None,
|
||||
// so a State::merge(branch, ZERO) literally cannot be constructed.
|
||||
assert!(NonZeroU32::new(0).is_none());
|
||||
}
|
||||
|
||||
// ── Cross-cutting Block from any active stage (superstate proves it) ───
|
||||
|
||||
#[test]
|
||||
fn block_from_backlog_via_superstate() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::Block {
|
||||
reason: "stuck".into(),
|
||||
});
|
||||
assert!(matches!(
|
||||
sm.state(),
|
||||
State::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_from_coding_via_superstate() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::DepsMet);
|
||||
sm.handle(&PipelineEvent::Block {
|
||||
reason: "stuck".into(),
|
||||
});
|
||||
assert!(matches!(
|
||||
sm.state(),
|
||||
State::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_from_qa_via_superstate() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::DepsMet);
|
||||
sm.handle(&PipelineEvent::GatesStarted);
|
||||
sm.handle(&PipelineEvent::Block {
|
||||
reason: "stuck".into(),
|
||||
});
|
||||
assert!(matches!(
|
||||
sm.state(),
|
||||
State::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_from_merge_via_superstate() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::DepsMet);
|
||||
sm.handle(&PipelineEvent::QaSkipped {
|
||||
feature_branch: fb("f"),
|
||||
commits_ahead: nz(1),
|
||||
});
|
||||
sm.handle(&PipelineEvent::Block {
|
||||
reason: "stuck".into(),
|
||||
});
|
||||
assert!(matches!(
|
||||
sm.state(),
|
||||
State::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// ── Block from Done is NOT valid (Done isn't a child of `active`) ──────
|
||||
|
||||
#[test]
|
||||
fn block_from_done_is_ignored() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::DepsMet);
|
||||
sm.handle(&PipelineEvent::QaSkipped {
|
||||
feature_branch: fb("f"),
|
||||
commits_ahead: nz(1),
|
||||
});
|
||||
sm.handle(&PipelineEvent::MergeSucceeded {
|
||||
merge_commit: sha("abc"),
|
||||
});
|
||||
// Now in Done. Block should NOT transition us anywhere.
|
||||
sm.handle(&PipelineEvent::Block {
|
||||
reason: "stuck".into(),
|
||||
});
|
||||
assert!(matches!(sm.state(), State::Done { .. }));
|
||||
}
|
||||
|
||||
// ── Abandon from Done IS valid (handled inline in done()) ──────────────
|
||||
|
||||
#[test]
|
||||
fn abandon_from_done_works() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::DepsMet);
|
||||
sm.handle(&PipelineEvent::QaSkipped {
|
||||
feature_branch: fb("f"),
|
||||
commits_ahead: nz(1),
|
||||
});
|
||||
sm.handle(&PipelineEvent::MergeSucceeded {
|
||||
merge_commit: sha("abc"),
|
||||
});
|
||||
sm.handle(&PipelineEvent::Abandon);
|
||||
assert!(matches!(
|
||||
sm.state(),
|
||||
State::Archived {
|
||||
reason: ArchiveReason::Abandoned,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// ── Unblock from Archived(Blocked) → Backlog ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unblock_returns_to_backlog() {
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::Block {
|
||||
reason: "test".into(),
|
||||
});
|
||||
assert!(matches!(
|
||||
sm.state(),
|
||||
State::Archived {
|
||||
reason: ArchiveReason::Blocked { .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
sm.handle(&PipelineEvent::Unblock);
|
||||
assert!(matches!(sm.state(), State::Backlog {}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unblock_from_review_held_does_nothing() {
|
||||
// Unblock is specifically for Blocked, not for any Archived variant.
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
sm.handle(&PipelineEvent::ReviewHold {
|
||||
reason: "TBD".into(),
|
||||
});
|
||||
// Now in Archived(ReviewHeld). Unblock should NOT transition.
|
||||
sm.handle(&PipelineEvent::Unblock);
|
||||
assert!(matches!(
|
||||
sm.state(),
|
||||
State::Archived {
|
||||
reason: ArchiveReason::ReviewHeld { .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── main: a quick interactive demo ───────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
println!("─── Pipeline state machine sketch (story 520) — STATIG version ───\n");
|
||||
|
||||
let mut sm = PipelineMachine.state_machine();
|
||||
println!("Initial: {:?}\n", sm.state());
|
||||
|
||||
println!("→ DepsMet");
|
||||
sm.handle(&PipelineEvent::DepsMet);
|
||||
println!(" state: {:?}\n", sm.state());
|
||||
|
||||
println!("→ QaSkipped");
|
||||
sm.handle(&PipelineEvent::QaSkipped {
|
||||
feature_branch: BranchName("feature/story-100".into()),
|
||||
commits_ahead: NonZeroU32::new(3).unwrap(),
|
||||
});
|
||||
println!(" state: {:?}\n", sm.state());
|
||||
|
||||
println!("→ MergeSucceeded");
|
||||
sm.handle(&PipelineEvent::MergeSucceeded {
|
||||
merge_commit: GitSha("abc1234".into()),
|
||||
});
|
||||
println!(" state: {:?}\n", sm.state());
|
||||
|
||||
println!("→ Accepted");
|
||||
sm.handle(&PipelineEvent::Accepted);
|
||||
println!(" state: {:?}\n", sm.state());
|
||||
|
||||
println!("─── Trying invalid transition: Done → Unblock ───");
|
||||
let mut sm2 = PipelineMachine.state_machine();
|
||||
sm2.handle(&PipelineEvent::DepsMet);
|
||||
sm2.handle(&PipelineEvent::QaSkipped {
|
||||
feature_branch: BranchName("feature/story-101".into()),
|
||||
commits_ahead: NonZeroU32::new(2).unwrap(),
|
||||
});
|
||||
sm2.handle(&PipelineEvent::MergeSucceeded {
|
||||
merge_commit: GitSha("def5678".into()),
|
||||
});
|
||||
println!(" before Unblock: {:?}", sm2.state());
|
||||
sm2.handle(&PipelineEvent::Unblock); // silently ignored — no transition
|
||||
println!(" after Unblock: {:?} (no change — Unblock is a no-op from Done)", sm2.state());
|
||||
}
|
||||
Reference in New Issue
Block a user