//! Pure state transition functions for pipeline and execution state machines. use chrono::Utc; use serde::{Deserialize, Serialize}; use super::{ AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, PlanState, 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 }, /// Merge pipeline failed (conflicts or gate failures); story moves to /// `Stage::MergeFailure` awaiting human intervention or retry. MergeFailed { kind: MergeFailureKind }, /// 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 }, /// 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, /// Story 945: freeze a story at its current stage. Freeze, /// Story 945: unfreeze a frozen story, returning to `resume_to`. Unfreeze, /// Story 945: clear a `ReviewHold`, returning to `resume_to`. ReviewHoldCleared, /// Story 945: mergemaster has been auto-spawned and gave up; transitions /// `Stage::MergeFailure` → `Stage::MergeFailureFinal`. MergemasterAttempted, /// Story 971: user sends a MergeFailure story back to Coding for coder fixup. FixupRequested, /// Story 972: user sends a MergeFailure story back to Qa for re-review. ReQueuedForQa, /// Story 973: user aborts an in-flight merge, sending the story back to Coding. MergeAborted, /// Story 974: user re-opens a Done story for a post-merge hotfix, sending it back to Coding. HotfixRequested, } // ── Per-node execution events ─────────────────────────────────────────────── /// Events that drive per-node [`ExecutionState`] transitions (agent lifecycle). #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ExecutionEvent { SpawnRequested { agent: AgentName }, SpawnedSuccessfully, Heartbeat, HitRateLimit { resume_at: chrono::DateTime }, 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::MergeFailed { .. } => "MergeFailed", PipelineEvent::MergeFailedFinal { .. } => "MergeFailedFinal", PipelineEvent::Accepted => "Accepted", PipelineEvent::Block { .. } => "Block", PipelineEvent::Unblock => "Unblock", PipelineEvent::Abandon => "Abandon", PipelineEvent::Supersede { .. } => "Supersede", PipelineEvent::ReviewHold { .. } => "ReviewHold", PipelineEvent::Reject { .. } => "Reject", PipelineEvent::Triage => "Triage", PipelineEvent::Close => "Close", PipelineEvent::Demote => "Demote", PipelineEvent::Freeze => "Freeze", PipelineEvent::Unfreeze => "Unfreeze", PipelineEvent::ReviewHoldCleared => "ReviewHoldCleared", PipelineEvent::MergemasterAttempted => "MergemasterAttempted", PipelineEvent::FixupRequested => "FixupRequested", PipelineEvent::ReQueuedForQa => "ReQueuedForQa", PipelineEvent::MergeAborted => "MergeAborted", PipelineEvent::HotfixRequested => "HotfixRequested", } } // ── 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 { 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) { // ── Triage: upcoming → backlog ────────────────────────────────── (Upcoming, Triage) => Ok(Backlog), // ── Forward path ──────────────────────────────────────────────── (Backlog, DepsMet) => Ok(Coding { claim: None, plan: PlanState::Missing, }), (Coding { .. }, GatesStarted) => Ok(Qa), ( Coding { .. }, QaSkipped { feature_branch, commits_ahead, }, ) => Ok(Merge { feature_branch, commits_ahead, claim: None, }), ( Qa, GatesPassed { feature_branch, commits_ahead, }, ) => Ok(Merge { feature_branch, commits_ahead, claim: None, }), (Qa, GatesFailed { .. }) => Ok(Coding { claim: None, plan: PlanState::Missing, }), (Merge { .. }, MergeSucceeded { merge_commit }) => Ok(Done { merged_at: now, merge_commit, }), // ── Done → Archived(Completed) ────────────────────────────────── (Done { .. }, Accepted) => Ok(Archived { archived_at: now, reason: ArchiveReason::Completed, }), // ── MergeFailure → Done (manual recovery) ─────────────────────── // Allows a human operator to accept a story whose merge permanently // failed, marking it Done without going through the normal merge path. (MergeFailure { .. }, Accepted) => Ok(Done { merged_at: now, merge_commit: GitSha("manual".to_string()), }), // ── Block: any active → Blocked ────────────────────────────── (Backlog, Block { reason }) | (Coding { .. }, Block { reason }) | (Qa, Block { reason }) | (Merge { .. }, Block { reason }) | (MergeFailure { .. }, Block { reason }) | (MergeFailureFinal { .. }, Block { reason }) => Ok(Blocked { reason }), // Story 945: ReviewHold no longer auto-archives. It transitions the // story to `Stage::ReviewHold { resume_to, reason }`, preserving the // current stage as the resume target so a reviewer can clear the // hold and continue. ( s @ (Backlog | Coding { .. } | Qa | Merge { .. }), PipelineEvent::ReviewHold { reason }, ) => Ok(Stage::ReviewHold { resume_to: Box::new(s), reason, }), // ── MergeFailed: Merge → MergeFailure (recoverable intermediate) ── ( Merge { feature_branch, commits_ahead, .. }, MergeFailed { kind }, ) => Ok(MergeFailure { kind, feature_branch, commits_ahead, }), // ── MergeFailure self-loop: repeated failure is a no-op ───────────── // When the mergemaster retries and fails again while the story is already // in MergeFailure, treat it as a silent self-transition so callers can // detect the no-op via `fired.before == MergeFailure` and skip re-notifying. ( MergeFailure { feature_branch, commits_ahead, .. }, MergeFailed { kind }, ) => Ok(MergeFailure { kind, feature_branch, commits_ahead, }), (Merge { .. }, MergeFailedFinal { reason }) => Ok(Archived { archived_at: now, reason: ArchiveReason::MergeFailed { reason }, }), // ── Abandon / supersede from any active or done stage ─────────── (Upcoming, Abandon) | (Backlog, Abandon) | (Coding { .. }, Abandon) | (Qa, Abandon) | (Merge { .. }, Abandon) | (Done { .. }, Abandon) => Ok(Abandoned { ts: now }), (Upcoming, Supersede { by }) | (Backlog, Supersede { by }) | (Coding { .. }, Supersede { by }) | (Qa, Supersede { by }) | (Merge { .. }, Supersede { by }) | (Done { .. }, Supersede { by }) => Ok(Superseded { ts: now, superseded_by: by, }), // ── Reject from any active stage or QA ────────────────────────── (Backlog, Reject { reason }) | (Coding { .. }, Reject { reason }) | (Qa, Reject { reason }) | (Merge { .. }, Reject { reason }) => Ok(Rejected { ts: now, reason }), // ── Demote: send an active item back to backlog ──────────────── // `Blocked + Demote → Backlog` lets operators park a stuck story in // the backlog while waiting on dependent fixes, without losing it to // Archived. Unlike `Unblock` (Blocked → Coding), this does not // re-enter the active flow. (Coding { .. }, Demote) | (Qa, Demote) | (Merge { .. }, Demote) | (Blocked { .. }, 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()), }) } // ── Freeze: any non-terminal stage → Frozen { resume_to } ────── ( s @ (Upcoming | Backlog | Coding { .. } | Qa | Merge { .. } | Blocked { .. } | MergeFailure { .. } | MergeFailureFinal { .. } | Stage::ReviewHold { .. }), Freeze, ) => Ok(Frozen { resume_to: Box::new(s), }), // ── Unfreeze: Frozen → resume_to ─────────────────────────────── (Frozen { resume_to }, Unfreeze) => Ok(*resume_to), // ── ReviewHoldCleared: ReviewHold → resume_to ────────────────── (Stage::ReviewHold { resume_to, .. }, ReviewHoldCleared) => Ok(*resume_to), // ── FixupRequested: MergeFailure → Coding (coder fixup) ──────── (MergeFailure { .. }, FixupRequested) => Ok(Coding { claim: None, plan: PlanState::Missing, }), // ── FixupRequested: MergeFailureFinal → Coding (operator override) // // The exhausted-respawn-budget terminal state is not actually // terminal as far as the operator is concerned; a human can decide // the gate failure is fixable and send the story back for another // coder attempt. The budget counter is a mergemaster bookkeeping // detail, not a hard ceiling. (MergeFailureFinal { .. }, FixupRequested) => Ok(Coding { claim: None, plan: PlanState::Missing, }), // ── ReQueuedForQa: MergeFailure → Qa (re-review) ──────────────── (MergeFailure { .. }, ReQueuedForQa) => Ok(Qa), // ── MergeAborted: Merge → Coding (abort in-flight merge) ───────── (Merge { .. }, MergeAborted) => Ok(Coding { claim: None, plan: PlanState::Missing, }), // ── HotfixRequested: Done → Coding (post-merge hotfix) ─────────── // Allows reopening a completed story so a coder can apply a hotfix. // A fresh feature branch is forked from master when auto-assign spawns // the coder. (Done { .. }, HotfixRequested) => Ok(Coding { claim: None, plan: PlanState::Missing, }), // ── MergemasterAttempted: MergeFailure → MergeFailureFinal ───── (MergeFailure { kind, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }), (MergeFailureFinal { kind }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }), // ── Unblock: from Frozen/ReviewHold → resume_to ──────────────── (Frozen { resume_to }, Unblock) => Ok(*resume_to), (Stage::ReviewHold { resume_to, .. }, Unblock) => Ok(*resume_to), // ── Unblock: Blocked → Coding ───────────────────────────────── (Blocked { .. }, Unblock) => Ok(Coding { claim: None, plan: PlanState::Missing, }), // ── Unblock MergeFailure → Merge (re-attempt) ──────────────────── // `unblock_story` on a failed merge re-queues it for merge, restoring // the exact `Merge { feature_branch, commits_ahead }` that was in place // before the failure so the mergemaster can retry immediately. ( MergeFailure { feature_branch, commits_ahead, .. }, Unblock, ) => Ok(Merge { feature_branch, commits_ahead, claim: None, }), // ── Demote MergeFailure → Backlog (manual parking) ─────────────── // Lets operators park a failed-merge story in the backlog without an // agent retry. Complements `Unblock` (→ Coding) which triggers an // immediate retry by a coder agent. (MergeFailure { .. }, Demote) => Ok(Backlog), // ── Legacy unblock: Archived(Blocked|MergeFailed) → Backlog ── ( Archived { reason: ArchiveReason::Blocked { .. }, .. }, Unblock, ) | ( Archived { reason: ArchiveReason::MergeFailed { .. }, .. }, Unblock, ) => Ok(Backlog), // ── 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 { 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: "".to_string(), }), } }