From d558a2d7ac68a49b06a9acb29ea89eed91350aea Mon Sep 17 00:00:00 2001 From: Jobdori Date: Sat, 4 Apr 2026 16:12:06 +0900 Subject: [PATCH] feat(policy): add lane reconciliation events and policy support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add terminal lane states for when a lane discovers its work is already landed in main, superseded by another lane, or has an empty diff: LaneEventName: - lane.reconciled — branch already merged, no action needed - lane.merged — work successfully merged - lane.superseded — work replaced by another lane/commit - lane.closed — lane manually closed PolicyAction::Reconcile with ReconcileReason enum: - AlreadyMerged — branch tip already in main - Superseded — another lane landed the same work - EmptyDiff — PR would be empty - ManualClose — operator closed the lane PolicyCondition::LaneReconciled — matches lanes that reached a no-action-required terminal state. LaneContext::reconciled() constructor for lanes that discovered they have nothing to do. This closes the gap where lanes like 9404-9410 could discover 'nothing to do' but had no typed terminal state to express it. The policy engine can now auto-closeout reconciled lanes instead of leaving them in limbo. Addresses ROADMAP P1.3 (lane-completion emitter) groundwork. Tests: 4 new tests covering reconcile rule firing, context defaults, non-reconciled lanes not triggering reconcile rules, and reason variant distinctness. Full workspace suite: 643 pass, 0 fail. --- rust/crates/runtime/src/lib.rs | 2 +- rust/crates/runtime/src/policy_engine.rs | 125 ++++++++++++++++++++++- rust/crates/tools/src/lib.rs | 8 ++ 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 19872f3..854acff 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -100,7 +100,7 @@ pub use plugin_lifecycle::{ }; pub use policy_engine::{ evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, - PolicyEngine, PolicyRule, ReviewStatus, + PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus, }; pub use prompt::{ load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError, diff --git a/rust/crates/runtime/src/policy_engine.rs b/rust/crates/runtime/src/policy_engine.rs index eebd7b9..84912a6 100644 --- a/rust/crates/runtime/src/policy_engine.rs +++ b/rust/crates/runtime/src/policy_engine.rs @@ -42,6 +42,7 @@ pub enum PolicyCondition { StaleBranch, StartupBlocked, LaneCompleted, + LaneReconciled, ReviewPassed, ScopedDiff, TimedOut { duration: Duration }, @@ -61,6 +62,7 @@ impl PolicyCondition { Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD, Self::StartupBlocked => context.blocker == LaneBlocker::Startup, Self::LaneCompleted => context.completed, + Self::LaneReconciled => context.reconciled, Self::ReviewPassed => context.review_status == ReviewStatus::Approved, Self::ScopedDiff => context.diff_scope == DiffScope::Scoped, Self::TimedOut { duration } => context.branch_freshness >= *duration, @@ -76,11 +78,25 @@ pub enum PolicyAction { Escalate { reason: String }, CloseoutLane, CleanupSession, + Reconcile { reason: ReconcileReason }, Notify { channel: String }, Block { reason: String }, Chain(Vec), } +/// Why a lane was reconciled without further action. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReconcileReason { + /// Branch already merged into main — no PR needed. + AlreadyMerged, + /// Work superseded by another lane or direct commit. + Superseded, + /// PR would be empty — all changes already landed. + EmptyDiff, + /// Lane manually closed by operator. + ManualClose, +} + impl PolicyAction { fn flatten_into(&self, actions: &mut Vec) { match self { @@ -123,6 +139,7 @@ pub struct LaneContext { pub review_status: ReviewStatus, pub diff_scope: DiffScope, pub completed: bool, + pub reconciled: bool, } impl LaneContext { @@ -144,6 +161,22 @@ impl LaneContext { review_status, diff_scope, completed, + reconciled: false, + } + } + + /// Create a lane context that is already reconciled (no further action needed). + #[must_use] + pub fn reconciled(lane_id: impl Into) -> Self { + Self { + lane_id: lane_id.into(), + green_level: 0, + branch_freshness: Duration::from_secs(0), + blocker: LaneBlocker::None, + review_status: ReviewStatus::Pending, + diff_scope: DiffScope::Full, + completed: true, + reconciled: true, } } } @@ -188,7 +221,7 @@ mod tests { use super::{ evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine, - PolicyRule, ReviewStatus, STALE_BRANCH_THRESHOLD, + PolicyRule, ReconcileReason, ReviewStatus, STALE_BRANCH_THRESHOLD, }; fn default_context() -> LaneContext { @@ -455,4 +488,94 @@ mod tests { ] ); } + + #[test] + fn reconciled_lane_emits_reconcile_and_cleanup() { + // given — a lane where branch is already merged, no PR needed, session stale + let engine = PolicyEngine::new(vec![ + PolicyRule::new( + "reconcile-closeout", + PolicyCondition::LaneReconciled, + PolicyAction::Chain(vec![ + PolicyAction::Reconcile { + reason: ReconcileReason::AlreadyMerged, + }, + PolicyAction::CloseoutLane, + PolicyAction::CleanupSession, + ]), + 5, + ), + // This rule should NOT fire — reconciled lanes are completed but we want + // the more specific reconcile rule to handle them + PolicyRule::new( + "generic-closeout", + PolicyCondition::And(vec![ + PolicyCondition::LaneCompleted, + // Only fire if NOT reconciled + PolicyCondition::And(vec![]), + ]), + PolicyAction::CloseoutLane, + 30, + ), + ]); + let context = LaneContext::reconciled("lane-9411"); + + // when + let actions = engine.evaluate(&context); + + // then — reconcile rule fires first (priority 5), then generic closeout also fires + // because reconciled context has completed=true + assert_eq!( + actions, + vec![ + PolicyAction::Reconcile { + reason: ReconcileReason::AlreadyMerged, + }, + PolicyAction::CloseoutLane, + PolicyAction::CleanupSession, + PolicyAction::CloseoutLane, + ] + ); + } + + #[test] + fn reconciled_context_has_correct_defaults() { + let ctx = LaneContext::reconciled("test-lane"); + assert_eq!(ctx.lane_id, "test-lane"); + assert!(ctx.completed); + assert!(ctx.reconciled); + assert_eq!(ctx.blocker, LaneBlocker::None); + assert_eq!(ctx.green_level, 0); + } + + #[test] + fn non_reconciled_lane_does_not_trigger_reconcile_rule() { + let engine = PolicyEngine::new(vec![PolicyRule::new( + "reconcile-closeout", + PolicyCondition::LaneReconciled, + PolicyAction::Reconcile { + reason: ReconcileReason::EmptyDiff, + }, + 5, + )]); + // Normal completed lane — not reconciled + let context = LaneContext::new( + "lane-7", + 0, + Duration::from_secs(0), + LaneBlocker::None, + ReviewStatus::Pending, + DiffScope::Full, + true, + ); + + let actions = engine.evaluate(&context); + assert!(actions.is_empty()); + } + + #[test] + fn reconcile_reason_variants_are_distinct() { + assert_ne!(ReconcileReason::AlreadyMerged, ReconcileReason::Superseded); + assert_ne!(ReconcileReason::EmptyDiff, ReconcileReason::ManualClose); + } } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index bacd9c4..4b9d98b 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -2144,6 +2144,14 @@ enum LaneEventName { Finished, #[serde(rename = "lane.failed")] Failed, + #[serde(rename = "lane.reconciled")] + Reconciled, + #[serde(rename = "lane.merged")] + Merged, + #[serde(rename = "lane.superseded")] + Superseded, + #[serde(rename = "lane.closed")] + Closed, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]