use std::collections::BTreeMap;
use octl_core::plan::{Plan, Tier};
use serde::{Deserialize, Serialize};
use super::action::{Action, SpinoffScope};
use super::envelope::{DecisionEnvelope, TierViolation};
use super::orchestrator::{DecisionContext, DecisionTrigger, Orchestrator};
pub trait ActionExecutor {
fn execute(&mut self, action: &Action) -> Result<(), ExecError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[error("action execution failed for `{action}`: {message}")]
pub struct ExecError {
pub action: String,
pub message: String,
}
#[derive(Debug, Default)]
pub struct RecordingExecutor {
pub attempted: Vec<Action>,
fail_on: Vec<String>,
}
impl RecordingExecutor {
pub fn new() -> Self {
Self::default()
}
pub fn failing_on(names: &[&str]) -> Self {
Self {
attempted: Vec::new(),
fail_on: names.iter().map(|s| (*s).to_string()).collect(),
}
}
}
impl ActionExecutor for RecordingExecutor {
fn execute(&mut self, action: &Action) -> Result<(), ExecError> {
self.attempted.push(action.clone());
if self.fail_on.iter().any(|n| n == action.name()) {
return Err(ExecError {
action: action.name().to_string(),
message: "scripted failure".to_string(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum DecisionOutcome {
Applied,
RejectedTierViolation(TierViolation),
RejectedPrecondition {
reason: String,
},
ExecutionFailed(ExecError),
Superseded,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecisionRecord {
pub action: Action,
pub envelope: DecisionEnvelope,
pub outcome: DecisionOutcome,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LoopStatus {
Running,
Converged,
Escalated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChunkStatus {
Pending,
AwaitingVerify,
NeedsReverify,
Accepted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChunkState {
pub status: ChunkStatus,
pub tier: Tier,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscussionRecord {
pub topic: String,
pub severity: super::action::Severity,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpinoffRecord {
pub title: String,
pub kind: String,
pub rationale: String,
pub scope: SpinoffScope,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineState {
pub run_id: String,
pub plan_rev: u32,
pub intent_rev: u32,
pub status: LoopStatus,
pub chunks: BTreeMap<String, ChunkState>,
pub decisions: Vec<DecisionRecord>,
pub anomalies: Vec<String>,
pub discussions: Vec<DiscussionRecord>,
pub spinoffs: Vec<SpinoffRecord>,
pub escalation: Option<String>,
}
impl PipelineState {
fn from_plan(run_id: &str, plan: &Plan) -> Self {
let chunks = plan
.chunks
.iter()
.map(|c| {
(
c.id.clone(),
ChunkState {
status: ChunkStatus::Pending,
tier: c.tier,
},
)
})
.collect();
Self {
run_id: run_id.to_string(),
plan_rev: plan.plan_rev,
intent_rev: plan.intent_rev,
status: LoopStatus::Running,
chunks,
decisions: Vec::new(),
anomalies: Vec::new(),
discussions: Vec::new(),
spinoffs: Vec::new(),
escalation: None,
}
}
pub fn tier_violations(&self) -> impl Iterator<Item = &TierViolation> {
self.decisions.iter().filter_map(|d| match &d.outcome {
DecisionOutcome::RejectedTierViolation(v) => Some(v),
_ => None,
})
}
pub fn exec_failures(&self) -> impl Iterator<Item = &ExecError> {
self.decisions.iter().filter_map(|d| match &d.outcome {
DecisionOutcome::ExecutionFailed(e) => Some(e),
_ => None,
})
}
pub fn applied_actions(&self) -> impl Iterator<Item = &Action> {
self.decisions.iter().filter_map(|d| match &d.outcome {
DecisionOutcome::Applied => Some(&d.action),
_ => None,
})
}
fn absorb_trigger(&mut self, trigger: &DecisionTrigger) {
if let DecisionTrigger::ChunkCommitted { chunk_id } = trigger {
self.set_chunk_status(chunk_id, ChunkStatus::AwaitingVerify);
}
}
fn check_preconditions(&self, action: &Action) -> Result<(), String> {
for chunk_id in action.referenced_chunks() {
if !self.chunks.contains_key(chunk_id) {
return Err(format!(
"action `{}` referenced unknown chunk `{chunk_id}`",
action.name()
));
}
}
Ok(())
}
fn apply(&mut self, action: &Action) {
match action {
Action::ReCodeChunk { chunk_id, .. } => {
self.set_chunk_status(chunk_id, ChunkStatus::NeedsReverify);
}
Action::AcceptChunk { chunk_id } => {
self.set_chunk_status(chunk_id, ChunkStatus::Accepted);
}
Action::PromoteTier { chunk_id, tier } => {
if let Some(chunk) = self.chunks.get_mut(chunk_id) {
chunk.tier = *tier;
chunk.status = ChunkStatus::Pending;
}
}
Action::TriggerReSpec { chunk_ids, .. } => {
self.plan_rev = self.plan_rev.saturating_add(1);
for id in chunk_ids {
self.set_chunk_status(id, ChunkStatus::Pending);
}
}
Action::OpenDiscussion { topic, severity } => {
self.discussions.push(DiscussionRecord {
topic: topic.clone(),
severity: *severity,
});
}
Action::ProposeSpinoff {
title,
kind,
rationale,
scope,
} => {
self.spinoffs.push(SpinoffRecord {
title: title.clone(),
kind: kind.clone(),
rationale: rationale.clone(),
scope: *scope,
});
}
Action::DeclareConverged => {
self.status = LoopStatus::Converged;
}
Action::Escalate { reason } => {
self.escalate(reason.clone());
}
}
}
fn set_chunk_status(&mut self, chunk_id: &str, status: ChunkStatus) {
if let Some(chunk) = self.chunks.get_mut(chunk_id) {
chunk.status = status;
} else {
self.anomalies.push(format!(
"stage outcome referenced unknown chunk `{chunk_id}`"
));
}
}
fn escalate(&mut self, reason: String) {
self.status = LoopStatus::Escalated;
if self.escalation.is_none() {
self.escalation = Some(reason);
}
}
}
pub fn drive<O: Orchestrator, E: ActionExecutor>(
run_id: &str,
plan: &Plan,
triggers: Vec<DecisionTrigger>,
orchestrator: &O,
executor: &mut E,
) -> PipelineState {
let mut state = PipelineState::from_plan(run_id, plan);
for trigger in triggers {
if state.status != LoopStatus::Running {
break;
}
if let DecisionTrigger::CircuitBreakerTripped { reason } = &trigger {
state.escalate(format!("circuit breaker: {reason}"));
break;
}
state.absorb_trigger(&trigger);
let ctx = DecisionContext {
run_id: state.run_id.clone(),
plan_rev: state.plan_rev,
intent_rev: state.intent_rev,
chunks: state.chunks.clone(),
trigger,
};
for (action, envelope) in orchestrator.decide(&ctx) {
if state.status != LoopStatus::Running {
state.decisions.push(DecisionRecord {
action,
envelope,
outcome: DecisionOutcome::Superseded,
});
continue;
}
if let Err(violation) = envelope.validate_for(&action) {
state.decisions.push(DecisionRecord {
action,
envelope,
outcome: DecisionOutcome::RejectedTierViolation(violation),
});
state.escalate("tier invariant violation".to_string());
continue;
}
if let Err(reason) = state.check_preconditions(&action) {
state.anomalies.push(reason.clone());
state.decisions.push(DecisionRecord {
action,
envelope,
outcome: DecisionOutcome::RejectedPrecondition { reason },
});
continue;
}
match executor.execute(&action) {
Ok(()) => {
state.apply(&action);
state.decisions.push(DecisionRecord {
action,
envelope,
outcome: DecisionOutcome::Applied,
});
}
Err(e) => {
state.decisions.push(DecisionRecord {
action,
envelope,
outcome: DecisionOutcome::ExecutionFailed(e.clone()),
});
state.escalate(format!("execution failure: {}", e.action));
}
}
}
}
state
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::action::{Finding, FindingVerdict, Severity, SpinoffScope};
use crate::pipeline::envelope::DecisionTier;
use crate::pipeline::orchestrator::{
CoordinatorProposal, DeciderVerdict, ScriptedCoordinator, ScriptedDecider,
TieredOrchestrator,
};
use serde_json::json;
fn plan() -> Plan {
let v = json!({
"schema_version": 3, "plan_rev": 1, "intent_rev": 1,
"feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
"baseline": {"ref": "feat/f@fork", "commit_oid": "0123456789abcdef0123456789abcdef01234567", "toolchain": "rustc 1.97.1", "test_passlist_hash": "h", "clippy_warnings_hash": "h", "enumerated_targets_hash": "h"},
"acceptance": [{"kind": "check", "desc": "e2e", "run": "cargo test"}],
"chunks": [
{"id": "c1", "title": "t", "tier": "code", "brief": "b", "files_touched": ["a.rs"], "checks": [{"desc": "d", "run": "r"}]},
{"id": "c2", "title": "t", "tier": "code", "brief": "b", "deps": ["c1"], "files_touched": ["b.rs"], "checks": [{"desc": "d", "run": "r"}]},
],
});
octl_core::plan::parse_and_validate_plan(&v).expect("fixture plan must validate")
}
fn proposal(action: Action) -> CoordinatorProposal {
CoordinatorProposal {
action,
reason: "r".into(),
input_artifacts: vec!["a".into()],
}
}
fn finding(verdict: FindingVerdict) -> Finding {
Finding {
id: "f1".into(),
summary: "s".into(),
verdict,
severity: Severity::High,
}
}
fn verify(report_id: &str, findings: Vec<Finding>) -> DecisionTrigger {
DecisionTrigger::VerifyReport {
report_id: report_id.into(),
findings,
}
}
#[test]
fn routine_fix_loop_recodes_then_accepts() {
let coord = ScriptedCoordinator::new(vec![
vec![proposal(Action::ReCodeChunk {
chunk_id: "c1".into(),
findings: vec![finding(FindingVerdict::Fix)],
})],
vec![proposal(Action::AcceptChunk {
chunk_id: "c1".into(),
})],
]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let triggers = vec![
verify("v1", vec![finding(FindingVerdict::Fix)]),
verify("v2", vec![]),
];
let state = drive("run1", &plan(), triggers, &orch, &mut exec);
assert_eq!(state.status, LoopStatus::Running);
assert_eq!(state.chunks["c1"].status, ChunkStatus::Accepted);
assert_eq!(exec.attempted.len(), 2);
assert_eq!(state.applied_actions().count(), 2);
assert_eq!(state.decisions[0].action.name(), "re_code_chunk");
assert_eq!(state.decisions[1].action.name(), "accept_chunk");
assert!(state
.decisions
.iter()
.all(|d| d.envelope.decision_tier == DecisionTier::Coordinator));
assert_eq!(state.tier_violations().count(), 0);
assert!(state.anomalies.is_empty());
}
#[test]
fn chunk_committed_trigger_moves_chunk_to_awaiting_verify() {
let coord = ScriptedCoordinator::new(vec![vec![]]); let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let state = drive(
"run1",
&plan(),
vec![DecisionTrigger::ChunkCommitted {
chunk_id: "c1".into(),
}],
&orch,
&mut exec,
);
assert_eq!(state.chunks["c1"].status, ChunkStatus::AwaitingVerify);
assert!(exec.attempted.is_empty());
}
#[test]
fn decision_context_carries_chunk_snapshot() {
use crate::pipeline::envelope::DecisionEnvelope;
use std::cell::RefCell;
struct SnoopingOrchestrator {
seen: RefCell<Vec<BTreeMap<String, ChunkState>>>,
}
impl Orchestrator for SnoopingOrchestrator {
fn decide(&self, ctx: &DecisionContext) -> Vec<(Action, DecisionEnvelope)> {
self.seen.borrow_mut().push(ctx.chunks.clone());
Vec::new()
}
}
let orch = SnoopingOrchestrator {
seen: RefCell::new(Vec::new()),
};
let mut exec = RecordingExecutor::new();
drive(
"run1",
&plan(),
vec![DecisionTrigger::ChunkCommitted {
chunk_id: "c1".into(),
}],
&orch,
&mut exec,
);
let seen = orch.seen.into_inner();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0]["c1"].status, ChunkStatus::AwaitingVerify);
assert_eq!(seen[0]["c2"].status, ChunkStatus::Pending);
}
#[test]
fn declare_converged_is_decider_tier_and_terminal() {
let coord = ScriptedCoordinator::new(vec![vec![proposal(Action::DeclareConverged)]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let triggers = vec![
verify("v1", vec![]),
DecisionTrigger::ChunkCommitted {
chunk_id: "c1".into(),
},
];
let state = drive("run1", &plan(), triggers, &orch, &mut exec);
assert_eq!(state.status, LoopStatus::Converged);
assert_eq!(state.decisions.len(), 1);
assert_eq!(
state.decisions[0].envelope.decision_tier,
DecisionTier::Decider
);
assert_eq!(state.chunks["c1"].status, ChunkStatus::Pending);
}
#[test]
fn escalate_is_decider_tier_and_terminal() {
let coord = ScriptedCoordinator::new(vec![vec![proposal(Action::Escalate {
reason: "cannot converge".into(),
})]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let state = drive(
"run1",
&plan(),
vec![verify("v1", vec![])],
&orch,
&mut exec,
);
assert_eq!(state.status, LoopStatus::Escalated);
assert_eq!(state.escalation.as_deref(), Some("cannot converge"));
assert_eq!(
state.decisions[0].envelope.decision_tier,
DecisionTier::Decider
);
}
#[test]
fn circuit_breaker_escalates_deterministically_without_the_orchestrator() {
let coord = ScriptedCoordinator::new(vec![vec![proposal(Action::AcceptChunk {
chunk_id: "c1".into(),
})]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let state = drive(
"run1",
&plan(),
vec![DecisionTrigger::CircuitBreakerTripped {
reason: "cost ceiling".into(),
}],
&orch,
&mut exec,
);
assert_eq!(state.status, LoopStatus::Escalated);
assert_eq!(
state.escalation.as_deref(),
Some("circuit breaker: cost ceiling")
);
assert!(exec.attempted.is_empty());
assert!(state.decisions.is_empty());
}
#[test]
fn trigger_re_spec_bumps_plan_rev_and_reverts_chunks() {
let coord = ScriptedCoordinator::new(vec![vec![proposal(Action::TriggerReSpec {
reason: "spec cannot meet intent".into(),
chunk_ids: vec!["c1".into(), "c2".into()],
})]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let triggers = vec![
DecisionTrigger::ChunkCommitted {
chunk_id: "c1".into(),
},
verify("v1", vec![finding(FindingVerdict::SpecFlaw)]),
];
let state = drive("run1", &plan(), triggers, &orch, &mut exec);
assert_eq!(state.plan_rev, 2);
assert_eq!(state.chunks["c1"].status, ChunkStatus::Pending);
assert_eq!(state.chunks["c2"].status, ChunkStatus::Pending);
assert_eq!(
state.decisions.last().unwrap().envelope.decision_tier,
DecisionTier::Decider
);
assert_eq!(state.status, LoopStatus::Running);
}
#[test]
fn promote_tier_bumps_tier_and_resets_to_pending() {
let coord = ScriptedCoordinator::new(vec![vec![proposal(Action::PromoteTier {
chunk_id: "c1".into(),
tier: Tier::High,
})]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let triggers = vec![
DecisionTrigger::ChunkCommitted {
chunk_id: "c1".into(),
},
verify("v1", vec![]),
];
let state = drive("run1", &plan(), triggers, &orch, &mut exec);
assert_eq!(state.chunks["c1"].tier, Tier::High);
assert_eq!(state.chunks["c1"].status, ChunkStatus::Pending);
}
struct FixedOrchestrator(Vec<(Action, DecisionEnvelope)>);
use crate::pipeline::envelope::DecisionEnvelope;
impl Orchestrator for FixedOrchestrator {
fn decide(&self, _ctx: &DecisionContext) -> Vec<(Action, DecisionEnvelope)> {
self.0.clone()
}
}
#[test]
fn mis_tiered_consequential_action_is_rejected_and_escalates() {
let bad_envelope = DecisionEnvelope {
actor: "coordinator".into(),
input_artifacts: vec![],
reason: "fast tier over-reached".into(),
decision_tier: DecisionTier::Coordinator,
model: "cheap".into(),
prompt_version: "v1".into(),
};
let orch = FixedOrchestrator(vec![(Action::DeclareConverged, bad_envelope)]);
let mut exec = RecordingExecutor::new();
let state = drive(
"run1",
&plan(),
vec![verify("v1", vec![])],
&orch,
&mut exec,
);
assert_eq!(state.tier_violations().count(), 1);
assert_eq!(
state.tier_violations().next().unwrap().action,
"declare_converged"
);
assert!(exec.attempted.is_empty());
assert_eq!(state.status, LoopStatus::Escalated);
assert_eq!(
state.escalation.as_deref(),
Some("tier invariant violation")
);
assert_eq!(state.decisions.len(), 1);
}
#[test]
fn post_terminal_actions_in_a_batch_are_recorded_superseded() {
let coord = ScriptedCoordinator::new(vec![vec![
proposal(Action::DeclareConverged),
proposal(Action::ProposeSpinoff {
title: "later".into(),
kind: "improvement".into(),
rationale: "r".into(),
scope: SpinoffScope::Trivial,
}),
]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let state = drive(
"run1",
&plan(),
vec![verify("v1", vec![])],
&orch,
&mut exec,
);
assert_eq!(state.status, LoopStatus::Converged);
assert_eq!(state.decisions.len(), 2);
assert_eq!(state.decisions[0].outcome, DecisionOutcome::Applied);
assert_eq!(state.decisions[1].outcome, DecisionOutcome::Superseded);
assert!(state.spinoffs.is_empty());
}
#[test]
fn unknown_chunk_is_rejected_before_execution() {
let coord = ScriptedCoordinator::new(vec![vec![proposal(Action::AcceptChunk {
chunk_id: "ghost".into(),
})]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let state = drive(
"run1",
&plan(),
vec![verify("v1", vec![])],
&orch,
&mut exec,
);
assert!(exec.attempted.is_empty()); assert_eq!(state.anomalies.len(), 1);
assert!(matches!(
state.decisions[0].outcome,
DecisionOutcome::RejectedPrecondition { .. }
));
assert_eq!(state.status, LoopStatus::Running);
}
#[test]
fn execution_failure_escalates_the_loop() {
let coord = ScriptedCoordinator::new(vec![vec![proposal(Action::AcceptChunk {
chunk_id: "c1".into(),
})]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::failing_on(&["accept_chunk"]);
let state = drive(
"run1",
&plan(),
vec![verify("v1", vec![])],
&orch,
&mut exec,
);
assert_eq!(state.status, LoopStatus::Escalated);
assert_eq!(state.exec_failures().count(), 1);
assert_eq!(state.exec_failures().next().unwrap().action, "accept_chunk");
assert_eq!(state.chunks["c1"].status, ChunkStatus::Pending); }
#[test]
fn spinoff_and_discussion_are_recorded_non_blocking() {
let coord = ScriptedCoordinator::new(vec![vec![
proposal(Action::OpenDiscussion {
topic: "api shape".into(),
severity: Severity::Medium,
}),
proposal(Action::ProposeSpinoff {
title: "extract helper".into(),
kind: "refactor".into(),
rationale: "out of scope".into(),
scope: SpinoffScope::Trivial, }),
]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let state = drive(
"run1",
&plan(),
vec![verify(
"v1",
vec![
finding(FindingVerdict::Discuss),
finding(FindingVerdict::SpinOff),
],
)],
&orch,
&mut exec,
);
assert_eq!(state.discussions.len(), 1);
assert_eq!(state.discussions[0].topic, "api shape");
assert_eq!(state.spinoffs.len(), 1);
assert_eq!(state.spinoffs[0].title, "extract helper");
assert_eq!(state.spinoffs[0].scope, SpinoffScope::Trivial);
assert_eq!(state.status, LoopStatus::Running); }
#[test]
fn state_round_trips_through_serde() {
let coord = ScriptedCoordinator::new(vec![vec![proposal(Action::AcceptChunk {
chunk_id: "c1".into(),
})]]);
let orch = TieredOrchestrator::new(coord, ScriptedDecider::confirming());
let mut exec = RecordingExecutor::new();
let state = drive(
"run1",
&plan(),
vec![verify("v1", vec![])],
&orch,
&mut exec,
);
let v = serde_json::to_value(&state).unwrap();
let back: PipelineState = serde_json::from_value(v).unwrap();
assert_eq!(back, state);
}
}