476 lines
18 KiB
Rust
476 lines
18 KiB
Rust
//! 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<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::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<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) {
|
|
// ── 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<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(),
|
|
}),
|
|
}
|
|
}
|