mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-24 06:26:45 +00:00
Make G006 task policy state machine executable
Typed task packets, policy decisions, lane board status, and session liveness now have concrete runtime contracts and focused regressions for Stream 4. Constraint: G006 requires task/lane operation without pane scraping while preserving legacy task packet callers. Rejected: waiting on stale worker worktrees | all G006 worker worktrees remained at main with no commits, so leader integrated the verified slice directly. Confidence: high Scope-risk: moderate Directive: Keep task packet serde defaults when adding fields so older packets continue to deserialize. Tested: git diff --check; cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo check --manifest-path rust/Cargo.toml -p runtime -p tools -p rusty-claude-cli; cargo test --manifest-path rust/Cargo.toml -p runtime task_packet -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p runtime policy_engine -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p runtime task_registry -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p runtime session_heartbeat -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p tools run_task_packet_creates_packet_backed_task -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p tools lane_completion -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli status_json_surfaces -- --nocapture Not-tested: full workspace test suite; PR/issue reconciliation deferred to G011/G012 Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type GreenLevel = u8;
|
||||
|
||||
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_hours(1);
|
||||
@@ -46,6 +48,11 @@ pub enum PolicyCondition {
|
||||
ReviewPassed,
|
||||
ScopedDiff,
|
||||
TimedOut { duration: Duration },
|
||||
RetryAvailable,
|
||||
RebaseRequired,
|
||||
StaleCleanupRequired,
|
||||
ApprovalTokenPresent,
|
||||
ApprovalTokenMissing,
|
||||
}
|
||||
|
||||
impl PolicyCondition {
|
||||
@@ -68,6 +75,11 @@ impl PolicyCondition {
|
||||
Self::ReviewPassed => context.review_status == ReviewStatus::Approved,
|
||||
Self::ScopedDiff => context.diff_scope == DiffScope::Scoped,
|
||||
Self::TimedOut { duration } => context.branch_freshness >= *duration,
|
||||
Self::RetryAvailable => context.retry_count < context.retry_limit,
|
||||
Self::RebaseRequired => context.rebase_required,
|
||||
Self::StaleCleanupRequired => context.stale_cleanup_required,
|
||||
Self::ApprovalTokenPresent => context.approval_token.is_some(),
|
||||
Self::ApprovalTokenMissing => context.approval_token.is_none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,11 +89,15 @@ pub enum PolicyAction {
|
||||
MergeToDev,
|
||||
MergeForward,
|
||||
RecoverOnce,
|
||||
Retry { reason: String },
|
||||
Rebase { reason: String },
|
||||
Escalate { reason: String },
|
||||
CloseoutLane,
|
||||
CleanupSession,
|
||||
CleanupStale { reason: String },
|
||||
Reconcile { reason: ReconcileReason },
|
||||
Notify { channel: String },
|
||||
RequireApprovalToken { operation: String },
|
||||
Block { reason: String },
|
||||
Chain(Vec<PolicyAction>),
|
||||
}
|
||||
@@ -132,6 +148,44 @@ pub enum DiffScope {
|
||||
Scoped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApprovalToken {
|
||||
pub token_id: String,
|
||||
pub operation: String,
|
||||
pub granted_by: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PolicyDecisionKind {
|
||||
Retry,
|
||||
Rebase,
|
||||
Merge,
|
||||
Escalate,
|
||||
StaleCleanup,
|
||||
ApprovalRequired,
|
||||
Notify,
|
||||
Block,
|
||||
Closeout,
|
||||
Reconcile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PolicyDecisionEvent {
|
||||
pub lane_id: String,
|
||||
pub rule_name: String,
|
||||
pub priority: u32,
|
||||
pub kind: PolicyDecisionKind,
|
||||
pub explanation: String,
|
||||
pub approval_token_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PolicyEvaluation {
|
||||
pub actions: Vec<PolicyAction>,
|
||||
pub events: Vec<PolicyDecisionEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LaneContext {
|
||||
pub lane_id: String,
|
||||
@@ -143,6 +197,11 @@ pub struct LaneContext {
|
||||
pub diff_scope: DiffScope,
|
||||
pub completed: bool,
|
||||
pub reconciled: bool,
|
||||
pub retry_count: u32,
|
||||
pub retry_limit: u32,
|
||||
pub rebase_required: bool,
|
||||
pub stale_cleanup_required: bool,
|
||||
pub approval_token: Option<ApprovalToken>,
|
||||
}
|
||||
|
||||
impl LaneContext {
|
||||
@@ -166,6 +225,11 @@ impl LaneContext {
|
||||
diff_scope,
|
||||
completed,
|
||||
reconciled: false,
|
||||
retry_count: 0,
|
||||
retry_limit: 1,
|
||||
rebase_required: false,
|
||||
stale_cleanup_required: false,
|
||||
approval_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +246,11 @@ impl LaneContext {
|
||||
diff_scope: DiffScope::Full,
|
||||
completed: true,
|
||||
reconciled: true,
|
||||
retry_count: 0,
|
||||
retry_limit: 1,
|
||||
rebase_required: false,
|
||||
stale_cleanup_required: false,
|
||||
approval_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +259,31 @@ impl LaneContext {
|
||||
self.green_contract_satisfied = satisfied;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_retry_state(mut self, retry_count: u32, retry_limit: u32) -> Self {
|
||||
self.retry_count = retry_count;
|
||||
self.retry_limit = retry_limit;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_rebase_required(mut self, required: bool) -> Self {
|
||||
self.rebase_required = required;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_stale_cleanup_required(mut self, required: bool) -> Self {
|
||||
self.stale_cleanup_required = required;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_approval_token(mut self, token: ApprovalToken) -> Self {
|
||||
self.approval_token = Some(token);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -213,17 +307,119 @@ impl PolicyEngine {
|
||||
pub fn evaluate(&self, context: &LaneContext) -> Vec<PolicyAction> {
|
||||
evaluate(self, context)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn evaluate_with_events(&self, context: &LaneContext) -> PolicyEvaluation {
|
||||
evaluate_with_events(self, context)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn evaluate(engine: &PolicyEngine, context: &LaneContext) -> Vec<PolicyAction> {
|
||||
evaluate_with_events(engine, context).actions
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn evaluate_with_events(engine: &PolicyEngine, context: &LaneContext) -> PolicyEvaluation {
|
||||
let mut actions = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
for rule in &engine.rules {
|
||||
if rule.matches(context) {
|
||||
let before = actions.len();
|
||||
rule.action.flatten_into(&mut actions);
|
||||
for action in &actions[before..] {
|
||||
events.push(decision_event(rule, context, action));
|
||||
}
|
||||
}
|
||||
}
|
||||
actions
|
||||
PolicyEvaluation { actions, events }
|
||||
}
|
||||
|
||||
fn decision_event(
|
||||
rule: &PolicyRule,
|
||||
context: &LaneContext,
|
||||
action: &PolicyAction,
|
||||
) -> PolicyDecisionEvent {
|
||||
let (kind, explanation) = match action {
|
||||
PolicyAction::MergeToDev | PolicyAction::MergeForward => (
|
||||
PolicyDecisionKind::Merge,
|
||||
format!(
|
||||
"rule '{}' allows merge action for lane {}",
|
||||
rule.name, context.lane_id
|
||||
),
|
||||
),
|
||||
PolicyAction::RecoverOnce | PolicyAction::Retry { reason: _ } => (
|
||||
PolicyDecisionKind::Retry,
|
||||
format!(
|
||||
"rule '{}' allows retry {}/{} for lane {}",
|
||||
rule.name, context.retry_count, context.retry_limit, context.lane_id
|
||||
),
|
||||
),
|
||||
PolicyAction::Rebase { reason } => (
|
||||
PolicyDecisionKind::Rebase,
|
||||
format!("rule '{}' requires rebase: {reason}", rule.name),
|
||||
),
|
||||
PolicyAction::Escalate { reason } => (
|
||||
PolicyDecisionKind::Escalate,
|
||||
format!(
|
||||
"rule '{}' escalates lane {}: {reason}",
|
||||
rule.name, context.lane_id
|
||||
),
|
||||
),
|
||||
PolicyAction::CleanupStale { reason } => (
|
||||
PolicyDecisionKind::StaleCleanup,
|
||||
format!("rule '{}' requests cleanup: {reason}", rule.name),
|
||||
),
|
||||
PolicyAction::CleanupSession => (
|
||||
PolicyDecisionKind::StaleCleanup,
|
||||
format!("rule '{}' requests session cleanup", rule.name),
|
||||
),
|
||||
PolicyAction::CloseoutLane => (
|
||||
PolicyDecisionKind::Closeout,
|
||||
format!("rule '{}' closes out lane {}", rule.name, context.lane_id),
|
||||
),
|
||||
PolicyAction::Reconcile { reason } => (
|
||||
PolicyDecisionKind::Reconcile,
|
||||
format!(
|
||||
"rule '{}' reconciles lane {}: {reason:?}",
|
||||
rule.name, context.lane_id
|
||||
),
|
||||
),
|
||||
PolicyAction::Notify { channel } => (
|
||||
PolicyDecisionKind::Notify,
|
||||
format!("rule '{}' notifies {channel}", rule.name),
|
||||
),
|
||||
PolicyAction::RequireApprovalToken { operation } => (
|
||||
PolicyDecisionKind::ApprovalRequired,
|
||||
format!(
|
||||
"rule '{}' requires approval token for {operation}",
|
||||
rule.name
|
||||
),
|
||||
),
|
||||
PolicyAction::Block { reason } => (
|
||||
PolicyDecisionKind::Block,
|
||||
format!(
|
||||
"rule '{}' blocks lane {}: {reason}",
|
||||
rule.name, context.lane_id
|
||||
),
|
||||
),
|
||||
PolicyAction::Chain(_) => (
|
||||
PolicyDecisionKind::Notify,
|
||||
format!("rule '{}' expanded a chained action", rule.name),
|
||||
),
|
||||
};
|
||||
|
||||
PolicyDecisionEvent {
|
||||
lane_id: context.lane_id.clone(),
|
||||
rule_name: rule.name.clone(),
|
||||
priority: rule.priority,
|
||||
kind,
|
||||
explanation,
|
||||
approval_token_id: context
|
||||
.approval_token
|
||||
.as_ref()
|
||||
.map(|token| token.token_id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -231,8 +427,9 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{
|
||||
evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine,
|
||||
PolicyRule, ReconcileReason, ReviewStatus, STALE_BRANCH_THRESHOLD,
|
||||
evaluate, ApprovalToken, DiffScope, LaneBlocker, LaneContext, PolicyAction,
|
||||
PolicyCondition, PolicyDecisionKind, PolicyEngine, PolicyRule, ReconcileReason,
|
||||
ReviewStatus, STALE_BRANCH_THRESHOLD,
|
||||
};
|
||||
|
||||
fn default_context() -> LaneContext {
|
||||
@@ -532,6 +729,120 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_decision_table_emits_retry_rebase_merge_escalate_cleanup_and_approval_events() {
|
||||
let engine = PolicyEngine::new(vec![
|
||||
PolicyRule::new(
|
||||
"retry-available",
|
||||
PolicyCondition::RetryAvailable,
|
||||
PolicyAction::Retry {
|
||||
reason: "transient failure".to_string(),
|
||||
},
|
||||
1,
|
||||
),
|
||||
PolicyRule::new(
|
||||
"rebase-required",
|
||||
PolicyCondition::RebaseRequired,
|
||||
PolicyAction::Rebase {
|
||||
reason: "base branch moved".to_string(),
|
||||
},
|
||||
2,
|
||||
),
|
||||
PolicyRule::new(
|
||||
"stale-cleanup",
|
||||
PolicyCondition::StaleCleanupRequired,
|
||||
PolicyAction::CleanupStale {
|
||||
reason: "lease expired".to_string(),
|
||||
},
|
||||
3,
|
||||
),
|
||||
PolicyRule::new(
|
||||
"approval-required",
|
||||
PolicyCondition::ApprovalTokenMissing,
|
||||
PolicyAction::RequireApprovalToken {
|
||||
operation: "merge".to_string(),
|
||||
},
|
||||
4,
|
||||
),
|
||||
PolicyRule::new(
|
||||
"merge-approved",
|
||||
PolicyCondition::And(vec![
|
||||
PolicyCondition::ApprovalTokenPresent,
|
||||
PolicyCondition::GreenAt { level: 2 },
|
||||
PolicyCondition::ScopedDiff,
|
||||
PolicyCondition::ReviewPassed,
|
||||
]),
|
||||
PolicyAction::MergeToDev,
|
||||
5,
|
||||
),
|
||||
PolicyRule::new(
|
||||
"retry-exhausted",
|
||||
PolicyCondition::TimedOut {
|
||||
duration: Duration::from_secs(60),
|
||||
},
|
||||
PolicyAction::Escalate {
|
||||
reason: "lane timed out".to_string(),
|
||||
},
|
||||
6,
|
||||
),
|
||||
]);
|
||||
|
||||
let missing_token_context = LaneContext::new(
|
||||
"lane-cc2",
|
||||
2,
|
||||
Duration::from_secs(90),
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
false,
|
||||
)
|
||||
.with_green_contract_satisfied(true)
|
||||
.with_retry_state(0, 1)
|
||||
.with_rebase_required(true)
|
||||
.with_stale_cleanup_required(true);
|
||||
|
||||
let missing = engine.evaluate_with_events(&missing_token_context);
|
||||
assert!(missing.actions.contains(&PolicyAction::Retry {
|
||||
reason: "transient failure".to_string()
|
||||
}));
|
||||
assert!(missing.actions.contains(&PolicyAction::Rebase {
|
||||
reason: "base branch moved".to_string()
|
||||
}));
|
||||
assert!(missing.actions.contains(&PolicyAction::CleanupStale {
|
||||
reason: "lease expired".to_string()
|
||||
}));
|
||||
assert!(missing
|
||||
.actions
|
||||
.contains(&PolicyAction::RequireApprovalToken {
|
||||
operation: "merge".to_string()
|
||||
}));
|
||||
assert!(missing.actions.contains(&PolicyAction::Escalate {
|
||||
reason: "lane timed out".to_string()
|
||||
}));
|
||||
assert!(missing
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == PolicyDecisionKind::ApprovalRequired
|
||||
&& event.explanation.contains("approval token")));
|
||||
|
||||
let approved_context = missing_token_context.with_approval_token(ApprovalToken {
|
||||
token_id: "approval-123".to_string(),
|
||||
operation: "merge".to_string(),
|
||||
granted_by: "leader".to_string(),
|
||||
});
|
||||
let approved = engine.evaluate_with_events(&approved_context);
|
||||
assert!(approved.actions.contains(&PolicyAction::MergeToDev));
|
||||
let merge_event = approved
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.kind == PolicyDecisionKind::Merge)
|
||||
.expect("merge event should be emitted");
|
||||
assert_eq!(
|
||||
merge_event.approval_token_id.as_deref(),
|
||||
Some("approval-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconciled_lane_emits_reconcile_and_cleanup() {
|
||||
// given — a lane where branch is already merged, no PR needed, session stale
|
||||
|
||||
Reference in New Issue
Block a user