f7d69cde50
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>
533 lines
18 KiB
Rust
533 lines
18 KiB
Rust
//! 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());
|
|
}
|