Quellcode durchsuchen

feat(policy): add lane reconciliation events and policy support

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.
Jobdori vor 2 Monaten
Ursprung
Commit
d558a2d7ac

+ 1 - 1
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,

+ 124 - 1
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<PolicyAction>),
 }
 
+/// 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<PolicyAction>) {
         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<String>) -> 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);
+    }
 }

+ 8 - 0
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)]