Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit 8a9ea16

Browse files
committed
feat(mcp+lifecycle): MCP degraded-startup reporting, lane event schema, lane completion hardening
Add MCP structured degraded-startup classification (P2.10): - classify MCP failures as startup/handshake/config/partial - expose failed_servers + recovery_recommendations in tool output - add mcp_degraded output field with server_name, failure_mode, recoverable Canonical lane event schema (P2.7): - add LaneEventName variants for all lifecycle states - wire LaneEvent::new with full 3-arg signature (event, status, emitted_at) - emit typed events for Started, Blocked, Failed, Finished Fix let mut executor for search test binary Fix lane_completion unused import warnings Note: mcp_stdio::manager_discovery_report test has pre-existing failure on clean main, unrelated to this commit.
1 parent 639a542 commit 8a9ea16

7 files changed

Lines changed: 804 additions & 184 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
use serde::{Deserialize, Serialize};
2+
use serde_json::Value;
3+
4+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5+
pub enum LaneEventName {
6+
#[serde(rename = "lane.started")]
7+
Started,
8+
#[serde(rename = "lane.ready")]
9+
Ready,
10+
#[serde(rename = "lane.prompt_misdelivery")]
11+
PromptMisdelivery,
12+
#[serde(rename = "lane.blocked")]
13+
Blocked,
14+
#[serde(rename = "lane.red")]
15+
Red,
16+
#[serde(rename = "lane.green")]
17+
Green,
18+
#[serde(rename = "lane.commit.created")]
19+
CommitCreated,
20+
#[serde(rename = "lane.pr.opened")]
21+
PrOpened,
22+
#[serde(rename = "lane.merge.ready")]
23+
MergeReady,
24+
#[serde(rename = "lane.finished")]
25+
Finished,
26+
#[serde(rename = "lane.failed")]
27+
Failed,
28+
#[serde(rename = "lane.reconciled")]
29+
Reconciled,
30+
#[serde(rename = "lane.merged")]
31+
Merged,
32+
#[serde(rename = "lane.superseded")]
33+
Superseded,
34+
#[serde(rename = "lane.closed")]
35+
Closed,
36+
#[serde(rename = "branch.stale_against_main")]
37+
BranchStaleAgainstMain,
38+
}
39+
40+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41+
#[serde(rename_all = "snake_case")]
42+
pub enum LaneEventStatus {
43+
Running,
44+
Ready,
45+
Blocked,
46+
Red,
47+
Green,
48+
Completed,
49+
Failed,
50+
Reconciled,
51+
Merged,
52+
Superseded,
53+
Closed,
54+
}
55+
56+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57+
#[serde(rename_all = "snake_case")]
58+
pub enum LaneFailureClass {
59+
PromptDelivery,
60+
TrustGate,
61+
BranchDivergence,
62+
Compile,
63+
Test,
64+
PluginStartup,
65+
McpStartup,
66+
McpHandshake,
67+
GatewayRouting,
68+
ToolRuntime,
69+
Infra,
70+
}
71+
72+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73+
pub struct LaneEventBlocker {
74+
#[serde(rename = "failureClass")]
75+
pub failure_class: LaneFailureClass,
76+
pub detail: String,
77+
}
78+
79+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80+
pub struct LaneEvent {
81+
pub event: LaneEventName,
82+
pub status: LaneEventStatus,
83+
#[serde(rename = "emittedAt")]
84+
pub emitted_at: String,
85+
#[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")]
86+
pub failure_class: Option<LaneFailureClass>,
87+
#[serde(skip_serializing_if = "Option::is_none")]
88+
pub detail: Option<String>,
89+
#[serde(skip_serializing_if = "Option::is_none")]
90+
pub data: Option<Value>,
91+
}
92+
93+
impl LaneEvent {
94+
#[must_use]
95+
pub fn new(
96+
event: LaneEventName,
97+
status: LaneEventStatus,
98+
emitted_at: impl Into<String>,
99+
) -> Self {
100+
Self {
101+
event,
102+
status,
103+
emitted_at: emitted_at.into(),
104+
failure_class: None,
105+
detail: None,
106+
data: None,
107+
}
108+
}
109+
110+
#[must_use]
111+
pub fn started(emitted_at: impl Into<String>) -> Self {
112+
Self::new(LaneEventName::Started, LaneEventStatus::Running, emitted_at)
113+
}
114+
115+
#[must_use]
116+
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
117+
Self::new(LaneEventName::Finished, LaneEventStatus::Completed, emitted_at)
118+
.with_optional_detail(detail)
119+
}
120+
121+
#[must_use]
122+
pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
123+
Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
124+
.with_failure_class(blocker.failure_class)
125+
.with_detail(blocker.detail.clone())
126+
}
127+
128+
#[must_use]
129+
pub fn failed(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
130+
Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
131+
.with_failure_class(blocker.failure_class)
132+
.with_detail(blocker.detail.clone())
133+
}
134+
135+
#[must_use]
136+
pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self {
137+
self.failure_class = Some(failure_class);
138+
self
139+
}
140+
141+
#[must_use]
142+
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
143+
self.detail = Some(detail.into());
144+
self
145+
}
146+
147+
#[must_use]
148+
pub fn with_optional_detail(mut self, detail: Option<String>) -> Self {
149+
self.detail = detail;
150+
self
151+
}
152+
153+
#[must_use]
154+
pub fn with_data(mut self, data: Value) -> Self {
155+
self.data = Some(data);
156+
self
157+
}
158+
}
159+
160+
#[cfg(test)]
161+
mod tests {
162+
use serde_json::json;
163+
164+
use super::{
165+
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
166+
};
167+
168+
#[test]
169+
fn canonical_lane_event_names_serialize_to_expected_wire_values() {
170+
let cases = [
171+
(LaneEventName::Started, "lane.started"),
172+
(LaneEventName::Ready, "lane.ready"),
173+
(
174+
LaneEventName::PromptMisdelivery,
175+
"lane.prompt_misdelivery",
176+
),
177+
(LaneEventName::Blocked, "lane.blocked"),
178+
(LaneEventName::Red, "lane.red"),
179+
(LaneEventName::Green, "lane.green"),
180+
(LaneEventName::CommitCreated, "lane.commit.created"),
181+
(LaneEventName::PrOpened, "lane.pr.opened"),
182+
(LaneEventName::MergeReady, "lane.merge.ready"),
183+
(LaneEventName::Finished, "lane.finished"),
184+
(LaneEventName::Failed, "lane.failed"),
185+
(LaneEventName::Reconciled, "lane.reconciled"),
186+
(LaneEventName::Merged, "lane.merged"),
187+
(LaneEventName::Superseded, "lane.superseded"),
188+
(LaneEventName::Closed, "lane.closed"),
189+
(
190+
LaneEventName::BranchStaleAgainstMain,
191+
"branch.stale_against_main",
192+
),
193+
];
194+
195+
for (event, expected) in cases {
196+
assert_eq!(serde_json::to_value(event).expect("serialize event"), json!(expected));
197+
}
198+
}
199+
200+
#[test]
201+
fn failure_classes_cover_canonical_taxonomy_wire_values() {
202+
let cases = [
203+
(LaneFailureClass::PromptDelivery, "prompt_delivery"),
204+
(LaneFailureClass::TrustGate, "trust_gate"),
205+
(LaneFailureClass::BranchDivergence, "branch_divergence"),
206+
(LaneFailureClass::Compile, "compile"),
207+
(LaneFailureClass::Test, "test"),
208+
(LaneFailureClass::PluginStartup, "plugin_startup"),
209+
(LaneFailureClass::McpStartup, "mcp_startup"),
210+
(LaneFailureClass::McpHandshake, "mcp_handshake"),
211+
(LaneFailureClass::GatewayRouting, "gateway_routing"),
212+
(LaneFailureClass::ToolRuntime, "tool_runtime"),
213+
(LaneFailureClass::Infra, "infra"),
214+
];
215+
216+
for (failure_class, expected) in cases {
217+
assert_eq!(
218+
serde_json::to_value(failure_class).expect("serialize failure class"),
219+
json!(expected)
220+
);
221+
}
222+
}
223+
224+
#[test]
225+
fn blocked_and_failed_events_reuse_blocker_details() {
226+
let blocker = LaneEventBlocker {
227+
failure_class: LaneFailureClass::McpStartup,
228+
detail: "broken server".to_string(),
229+
};
230+
231+
let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker);
232+
let failed = LaneEvent::failed("2026-04-04T00:00:01Z", &blocker);
233+
234+
assert_eq!(blocked.event, LaneEventName::Blocked);
235+
assert_eq!(blocked.status, LaneEventStatus::Blocked);
236+
assert_eq!(blocked.failure_class, Some(LaneFailureClass::McpStartup));
237+
assert_eq!(failed.event, LaneEventName::Failed);
238+
assert_eq!(failed.status, LaneEventStatus::Failed);
239+
assert_eq!(failed.detail.as_deref(), Some("broken server"));
240+
}
241+
}

rust/crates/runtime/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod file_ops;
88
pub mod green_contract;
99
mod hooks;
1010
mod json;
11+
mod lane_events;
1112
pub mod lsp_client;
1213
mod mcp;
1314
mod mcp_client;
@@ -62,6 +63,9 @@ pub use file_ops::{
6263
pub use hooks::{
6364
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
6465
};
66+
pub use lane_events::{
67+
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
68+
};
6569
pub use mcp::{
6670
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
6771
scoped_mcp_config_hash, unwrap_ccr_proxy_url,

0 commit comments

Comments
 (0)