Files
huskies/server/src/pipeline_state/transition.rs
T

316 lines
11 KiB
Rust
Raw Normal View History

2026-04-28 20:56:22 +00:00
//! Pure state transition functions for pipeline and execution state machines.
use chrono::Utc;
use serde::{Deserialize, Serialize};
use super::{
AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, Stage, StoryId, TransitionError,
stage_label,
};
// ── Pipeline events ─────────────────────────────────────────────────────────
/// Events that drive Stage transitions. Each variant carries the data needed
/// to construct the destination state, so the transition function can never
/// accidentally land in an underspecified state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PipelineEvent {
/// Dependencies met; promote from backlog.
DepsMet,
/// Coder starting gates.
GatesStarted,
/// Gates passed — ready to merge.
GatesPassed {
feature_branch: BranchName,
commits_ahead: std::num::NonZeroU32,
},
/// Gates failed; retry.
GatesFailed { reason: String },
/// QA mode is "server" — skip QA, go straight to merge.
QaSkipped {
feature_branch: BranchName,
commits_ahead: std::num::NonZeroU32,
},
/// Mergemaster squash succeeded.
MergeSucceeded { merge_commit: GitSha },
/// Mergemaster gave up after retry budget.
MergeFailedFinal { reason: String },
/// Story accepted (Done → Archived).
Accepted,
/// User blocked the story.
Block { reason: String },
/// User unblocked.
Unblock,
/// User abandoned.
Abandon,
/// Story superseded by another.
Supersede { by: StoryId },
/// Story put on review hold.
ReviewHold { reason: String },
2026-04-29 17:38:38 +00:00
/// Story rejected by QA or reviewer.
Reject { reason: String },
/// Story triaged from upcoming to backlog.
Triage,
/// Direct completion — item closed without going through the full pipeline
/// (e.g. spike auto-merge, bug closure, manual acceptance).
Close,
/// Manual demotion back to backlog from an active stage.
Demote,
2026-04-29 22:12:23 +00:00
/// Freeze the story at its current stage (suspends pipeline and auto-assign).
Freeze,
/// Unfreeze the story, restoring it to the stage it was at when frozen.
Unfreeze,
2026-04-28 20:56:22 +00:00
}
// ── Per-node execution events ───────────────────────────────────────────────
2026-04-29 10:41:32 +00:00
/// Events that drive per-node [`ExecutionState`] transitions (agent lifecycle).
2026-04-28 20:56:22 +00:00
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExecutionEvent {
SpawnRequested { agent: AgentName },
SpawnedSuccessfully,
Heartbeat,
HitRateLimit { resume_at: chrono::DateTime<Utc> },
Exited { exit_code: i32 },
Stopped,
Reset,
}
// ── Label helper ────────────────────────────────────────────────────────────
/// Human-readable label for a `PipelineEvent` variant.
pub fn event_label(e: &PipelineEvent) -> &'static str {
match e {
PipelineEvent::DepsMet => "DepsMet",
PipelineEvent::GatesStarted => "GatesStarted",
PipelineEvent::GatesPassed { .. } => "GatesPassed",
PipelineEvent::GatesFailed { .. } => "GatesFailed",
PipelineEvent::QaSkipped { .. } => "QaSkipped",
PipelineEvent::MergeSucceeded { .. } => "MergeSucceeded",
PipelineEvent::MergeFailedFinal { .. } => "MergeFailedFinal",
PipelineEvent::Accepted => "Accepted",
PipelineEvent::Block { .. } => "Block",
PipelineEvent::Unblock => "Unblock",
PipelineEvent::Abandon => "Abandon",
PipelineEvent::Supersede { .. } => "Supersede",
PipelineEvent::ReviewHold { .. } => "ReviewHold",
2026-04-29 17:38:38 +00:00
PipelineEvent::Reject { .. } => "Reject",
PipelineEvent::Triage => "Triage",
PipelineEvent::Close => "Close",
PipelineEvent::Demote => "Demote",
2026-04-29 22:12:23 +00:00
PipelineEvent::Freeze => "Freeze",
PipelineEvent::Unfreeze => "Unfreeze",
2026-04-28 20:56:22 +00:00
}
}
// ── The pipeline transition function ────────────────────────────────────────
/// Pure state transition. Takes the current Stage and an event, returns the
/// new Stage or a TransitionError. Side effects are dispatched separately
/// via the event bus.
pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, TransitionError> {
use PipelineEvent::*;
use Stage::*;
let sl = stage_label(&state);
let el = event_label(&event);
let invalid = || TransitionError::InvalidTransition {
from_stage: sl.to_string(),
event: el.to_string(),
};
let now = Utc::now();
match (state, event) {
2026-04-29 17:38:38 +00:00
// ── Triage: upcoming → backlog ──────────────────────────────────
(Upcoming, Triage) => Ok(Backlog),
2026-04-28 20:56:22 +00:00
// ── Forward path ────────────────────────────────────────────────
(Backlog, DepsMet) => Ok(Coding),
(Coding, GatesStarted) => Ok(Qa),
(
Coding,
QaSkipped {
feature_branch,
commits_ahead,
},
) => Ok(Merge {
feature_branch,
commits_ahead,
}),
(
Qa,
GatesPassed {
feature_branch,
commits_ahead,
},
) => Ok(Merge {
feature_branch,
commits_ahead,
}),
(Qa, GatesFailed { .. }) => Ok(Coding),
(Merge { .. }, MergeSucceeded { merge_commit }) => Ok(Done {
merged_at: now,
merge_commit,
}),
// ── Done → Archived(Completed) ──────────────────────────────────
(Done { .. }, Accepted) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Completed,
}),
// ── Stuck states (any active → Archived) ───────────────────────
(Backlog, Block { reason })
| (Coding, Block { reason })
| (Qa, Block { reason })
| (Merge { .. }, Block { reason }) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Blocked { reason },
}),
(Backlog, ReviewHold { reason })
| (Coding, ReviewHold { reason })
| (Qa, ReviewHold { reason })
| (Merge { .. }, ReviewHold { reason }) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::ReviewHeld { reason },
}),
(Merge { .. }, MergeFailedFinal { reason }) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::MergeFailed { reason },
}),
// ── Abandon / supersede from any active or done stage ───────────
2026-04-29 17:38:38 +00:00
(Upcoming, Abandon)
| (Backlog, Abandon)
2026-04-28 20:56:22 +00:00
| (Coding, Abandon)
| (Qa, Abandon)
| (Merge { .. }, Abandon)
| (Done { .. }, Abandon) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Abandoned,
}),
2026-04-29 17:38:38 +00:00
(Upcoming, Supersede { by })
| (Backlog, Supersede { by })
2026-04-28 20:56:22 +00:00
| (Coding, Supersede { by })
| (Qa, Supersede { by })
| (Merge { .. }, Supersede { by })
| (Done { .. }, Supersede { by }) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Superseded { by },
}),
2026-04-29 17:38:38 +00:00
// ── Reject from any active stage or QA ──────────────────────────
(Backlog, Reject { reason })
| (Coding, Reject { reason })
| (Qa, Reject { reason })
| (Merge { .. }, Reject { reason }) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Rejected { reason },
}),
// ── Demote: send an active item back to backlog ────────────────
(Coding, Demote) | (Qa, Demote) | (Merge { .. }, Demote) => Ok(Backlog),
// ── Close: direct completion from any active stage ─────────────
(Backlog, Close) | (Coding, Close) | (Qa, Close) | (Merge { .. }, Close) => Ok(Done {
merged_at: now,
merge_commit: GitSha("closed".to_string()),
}),
// ── Unblock: from Archived(Blocked) or Archived(MergeFailed) → Backlog
2026-04-28 20:56:22 +00:00
(
Archived {
reason: ArchiveReason::Blocked { .. },
..
},
Unblock,
2026-04-29 17:38:38 +00:00
)
| (
Archived {
reason: ArchiveReason::MergeFailed { .. },
..
},
Unblock,
2026-04-28 20:56:22 +00:00
) => Ok(Backlog),
2026-04-29 22:12:23 +00:00
// ── Freeze: any active stage → Frozen(resume_to=current) ────────
(stage @ (Upcoming | Backlog | Coding | Qa), Freeze) => Ok(Frozen {
resume_to: Box::new(stage),
}),
(stage @ Merge { .. }, Freeze) => Ok(Frozen {
resume_to: Box::new(stage),
}),
// ── Unfreeze: Frozen → resume_to ─────────────────────────────────
(Frozen { resume_to }, Unfreeze) => Ok(*resume_to),
2026-04-28 20:56:22 +00:00
// ── Everything else is invalid ──────────────────────────────────
_ => Err(invalid()),
}
}
// ── The execution state transition function ─────────────────────────────────
/// Pure execution-state transition. Takes the current ExecutionState and an
/// ExecutionEvent, returns the new ExecutionState or a TransitionError.
pub fn execution_transition(
state: ExecutionState,
event: ExecutionEvent,
) -> Result<ExecutionState, TransitionError> {
use ExecutionEvent::*;
use ExecutionState::*;
let now = Utc::now();
match (state, event) {
(Idle, SpawnRequested { agent }) => Ok(Pending { agent, since: now }),
(Pending { agent, .. }, SpawnedSuccessfully) => Ok(Running {
agent,
started_at: now,
last_heartbeat: now,
}),
(
Running {
agent, started_at, ..
},
Heartbeat,
) => Ok(Running {
agent,
started_at,
last_heartbeat: now,
}),
(Running { agent, .. }, HitRateLimit { resume_at })
| (Pending { agent, .. }, HitRateLimit { resume_at }) => {
Ok(RateLimited { agent, resume_at })
}
(RateLimited { agent, .. }, SpawnedSuccessfully) => Ok(Running {
agent,
started_at: now,
last_heartbeat: now,
}),
(Running { agent, .. }, Exited { exit_code })
| (Pending { agent, .. }, Exited { exit_code })
| (RateLimited { agent, .. }, Exited { exit_code }) => Ok(Completed {
agent,
exit_code,
completed_at: now,
}),
(_, Stopped) | (_, Reset) => Ok(Idle),
_ => Err(TransitionError::InvalidTransition {
from_stage: "ExecutionState".to_string(),
event: "<exec event>".to_string(),
}),
}
}