mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 00:24:50 +08:00
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.
This commit is contained in:
@@ -100,7 +100,7 @@ pub use plugin_lifecycle::{
|
|||||||
};
|
};
|
||||||
pub use policy_engine::{
|
pub use policy_engine::{
|
||||||
evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition,
|
evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition,
|
||||||
PolicyEngine, PolicyRule, ReviewStatus,
|
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
|
||||||
};
|
};
|
||||||
pub use prompt::{
|
pub use prompt::{
|
||||||
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
|
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub enum PolicyCondition {
|
|||||||
StaleBranch,
|
StaleBranch,
|
||||||
StartupBlocked,
|
StartupBlocked,
|
||||||
LaneCompleted,
|
LaneCompleted,
|
||||||
|
LaneReconciled,
|
||||||
ReviewPassed,
|
ReviewPassed,
|
||||||
ScopedDiff,
|
ScopedDiff,
|
||||||
TimedOut { duration: Duration },
|
TimedOut { duration: Duration },
|
||||||
@@ -61,6 +62,7 @@ impl PolicyCondition {
|
|||||||
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
|
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
|
||||||
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
|
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
|
||||||
Self::LaneCompleted => context.completed,
|
Self::LaneCompleted => context.completed,
|
||||||
|
Self::LaneReconciled => context.reconciled,
|
||||||
Self::ReviewPassed => context.review_status == ReviewStatus::Approved,
|
Self::ReviewPassed => context.review_status == ReviewStatus::Approved,
|
||||||
Self::ScopedDiff => context.diff_scope == DiffScope::Scoped,
|
Self::ScopedDiff => context.diff_scope == DiffScope::Scoped,
|
||||||
Self::TimedOut { duration } => context.branch_freshness >= *duration,
|
Self::TimedOut { duration } => context.branch_freshness >= *duration,
|
||||||
@@ -76,11 +78,25 @@ pub enum PolicyAction {
|
|||||||
Escalate { reason: String },
|
Escalate { reason: String },
|
||||||
CloseoutLane,
|
CloseoutLane,
|
||||||
CleanupSession,
|
CleanupSession,
|
||||||
|
Reconcile { reason: ReconcileReason },
|
||||||
Notify { channel: String },
|
Notify { channel: String },
|
||||||
Block { reason: String },
|
Block { reason: String },
|
||||||
Chain(Vec<PolicyAction>),
|
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 {
|
impl PolicyAction {
|
||||||
fn flatten_into(&self, actions: &mut Vec<PolicyAction>) {
|
fn flatten_into(&self, actions: &mut Vec<PolicyAction>) {
|
||||||
match self {
|
match self {
|
||||||
@@ -123,6 +139,7 @@ pub struct LaneContext {
|
|||||||
pub review_status: ReviewStatus,
|
pub review_status: ReviewStatus,
|
||||||
pub diff_scope: DiffScope,
|
pub diff_scope: DiffScope,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
|
pub reconciled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LaneContext {
|
impl LaneContext {
|
||||||
@@ -144,6 +161,22 @@ impl LaneContext {
|
|||||||
review_status,
|
review_status,
|
||||||
diff_scope,
|
diff_scope,
|
||||||
completed,
|
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::{
|
use super::{
|
||||||
evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine,
|
evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine,
|
||||||
PolicyRule, ReviewStatus, STALE_BRANCH_THRESHOLD,
|
PolicyRule, ReconcileReason, ReviewStatus, STALE_BRANCH_THRESHOLD,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn default_context() -> LaneContext {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2144,6 +2144,14 @@ enum LaneEventName {
|
|||||||
Finished,
|
Finished,
|
||||||
#[serde(rename = "lane.failed")]
|
#[serde(rename = "lane.failed")]
|
||||||
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|||||||
Reference in New Issue
Block a user