diff --git a/ROADMAP.md b/ROADMAP.md index 0fdf985..35e2778 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -803,7 +803,12 @@ Acceptance: - channel status updates stay short and machine-grounded - claws stop inferring state from raw build spam -### 6.5. Blocked-state subphase contract +### 133. Blocked-state subphase contract (was §6.5) +**Filed:** 2026-04-20 from dogfood cycle — previous cycle identified §4.44.5 provenance gap, this cycle targets §6.5 implementation. + +**Problem:** Currently `lane.blocked` is a single opaque state. Recovery recipes cannot distinguish trust-gate blockers from MCP handshake failures, branch freshness issues, or test hangs. All blocked lanes look the same, forcing pane-scrape triage. + +**Concrete implementation: When a lane is `blocked`, also expose the exact subphase where progress stopped, rather than forcing claws to infer from logs. Subphases should include at least: diff --git a/rust/crates/runtime/src/lane_events.rs b/rust/crates/runtime/src/lane_events.rs index 03d8ace..d5d709d 100644 --- a/rust/crates/runtime/src/lane_events.rs +++ b/rust/crates/runtime/src/lane_events.rs @@ -383,11 +383,31 @@ pub fn dedupe_terminal_events(events: &[LaneEvent]) -> Vec { result } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum BlockedSubphase { + #[serde(rename = "blocked.trust_prompt")] + TrustPrompt { gate_repo: String }, + #[serde(rename = "blocked.prompt_delivery")] + PromptDelivery { attempt: u32 }, + #[serde(rename = "blocked.plugin_init")] + PluginInit { plugin_name: String }, + #[serde(rename = "blocked.mcp_handshake")] + McpHandshake { server_name: String, attempt: u32 }, + #[serde(rename = "blocked.branch_freshness")] + BranchFreshness { behind_main: u32 }, + #[serde(rename = "blocked.test_hang")] + TestHang { elapsed_secs: u32, test_name: Option }, + #[serde(rename = "blocked.report_pending")] + ReportPending { since_secs: u32 }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LaneEventBlocker { #[serde(rename = "failureClass")] pub failure_class: LaneFailureClass, pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub subphase: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -487,16 +507,24 @@ impl LaneEvent { #[must_use] pub fn blocked(emitted_at: impl Into, blocker: &LaneEventBlocker) -> Self { - Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at) + let mut event = Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at) .with_failure_class(blocker.failure_class) - .with_detail(blocker.detail.clone()) + .with_detail(blocker.detail.clone()); + if let Some(ref subphase) = blocker.subphase { + event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize")); + } + event } #[must_use] pub fn failed(emitted_at: impl Into, blocker: &LaneEventBlocker) -> Self { - Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at) + let mut event = Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at) .with_failure_class(blocker.failure_class) - .with_detail(blocker.detail.clone()) + .with_detail(blocker.detail.clone()); + if let Some(ref subphase) = blocker.subphase { + event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize")); + } + event } #[must_use] @@ -570,9 +598,9 @@ mod tests { use super::{ compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events, - is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker, - LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass, - LaneOwnership, SessionIdentity, WatcherAction, + is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent, + LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, + LaneFailureClass, LaneOwnership, SessionIdentity, WatcherAction, }; #[test] @@ -641,6 +669,10 @@ mod tests { let blocker = LaneEventBlocker { failure_class: LaneFailureClass::McpStartup, detail: "broken server".to_string(), + subphase: Some(BlockedSubphase::McpHandshake { + server_name: "test-server".to_string(), + attempt: 1, + }), }; let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 432e1c1..217c7f4 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -84,9 +84,9 @@ pub use hooks::{ }; pub use lane_events::{ compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events, - is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker, - LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass, - LaneOwnership, SessionIdentity, WatcherAction, + is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent, + LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, + LaneFailureClass, LaneOwnership, SessionIdentity, WatcherAction, }; pub use mcp::{ mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp, diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 5cb2f1e..1890190 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -4459,6 +4459,7 @@ fn classify_lane_blocker(error: &str) -> LaneEventBlocker { LaneEventBlocker { failure_class: classify_lane_failure(error), detail, + subphase: None, } }