Skip to content

Commit e2a43fc

Browse files
committed
feat: ultraworkers#143 phase 1 — claw status degrades gracefully on malformed config
Previously `claw status` hard-failed on any config parse error, emitting a bare error string and exiting 1. This took down the entire health surface for a single malformed MCP entry, even though workspace, git, model, permission, and sandbox state could all be reported independently. `claw doctor` already degraded gracefully on the exact same input. This commit matches `claw status` to that contract. Changes: - Add `StatusContext::config_load_error: Option<String>` to capture parse errors without aborting. - Rewrite `status_context()` to match on `ConfigLoader::load()`: on Err, fall back to default `SandboxConfig` for sandbox resolution and record the parse error, then continue populating workspace/git/memory fields. - JSON output gains top-level `status: "ok" | "degraded"` marker and a `config_load_error` string (null on clean runs). All other existing fields preserved for backward compat. - Text output prepends a "Config load error" block with Details + Hint when config failed to parse, then a "Status (degraded)" header on the main block. Clean runs show the usual "Status" header. - Doctor path updated to pass the config load error through StatusContext. Regression test `status_degrades_gracefully_on_malformed_mcp_config_143`: - Injects a .claw.json with one valid + one malformed mcpServers entry - Asserts status_context() returns Ok (not Err) - Asserts config_load_error names the malformed field path - Asserts workspace/sandbox fields still populated in JSON - Asserts top-level status is 'degraded' - Asserts clean config path still returns status: 'ok' Verified live on /Users/yeongyu/clawd (contains deliberately broken MCP entries): $ claw status --output-format json { "status": "degraded", "config_load_error": ".../mcpServers.missing-command: missing string field command", "model": "claude-opus-4-6", "workspace": {...}, "sandbox": {...}, ... } Phase 2 (typed error object joining ultraworkers#4.44 taxonomy) tracked separately. Full workspace test green except pre-existing resume_latest flake (unrelated). Closes ROADMAP ultraworkers#143 phase 1.
1 parent fcd5b49 commit e2a43fc

1 file changed

Lines changed: 159 additions & 11 deletions

File tree

  • rust/crates/rusty-claude-cli/src

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 159 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1649,6 +1649,9 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
16491649
git_branch,
16501650
git_summary,
16511651
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
1652+
// Doctor path has its own config check; StatusContext here is only
1653+
// fed into health renderers that don't read config_load_error.
1654+
config_load_error: config.as_ref().err().map(ToString::to_string),
16521655
};
16531656
Ok(DoctorReport {
16541657
checks: vec![
@@ -2482,6 +2485,13 @@ struct StatusContext {
24822485
git_branch: Option<String>,
24832486
git_summary: GitWorkspaceSummary,
24842487
sandbox_status: runtime::SandboxStatus,
2488+
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
2489+
/// we capture the parse error here and still populate every field that
2490+
/// doesn't depend on runtime config (workspace, git, sandbox defaults,
2491+
/// discovery counts). Top-level JSON output then reports
2492+
/// `status: "degraded"` so claws can distinguish "status ran but config
2493+
/// is broken" from "status ran cleanly".
2494+
config_load_error: Option<String>,
24852495
}
24862496

24872497
#[derive(Debug, Clone, Copy)]
@@ -5119,8 +5129,16 @@ fn status_json_value(
51195129
permission_mode: &str,
51205130
context: &StatusContext,
51215131
) -> serde_json::Value {
5132+
// #143: top-level `status` marker so claws can distinguish
5133+
// a clean run from a degraded run (config parse failed but other fields
5134+
// are still populated). `config_load_error` carries the parse-error string
5135+
// when present; it's a string rather than a typed object in Phase 1 and
5136+
// will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44).
5137+
let degraded = context.config_load_error.is_some();
51225138
json!({
51235139
"kind": "status",
5140+
"status": if degraded { "degraded" } else { "ok" },
5141+
"config_load_error": context.config_load_error,
51245142
"model": model,
51255143
"permission_mode": permission_mode,
51265144
"usage": {
@@ -5175,22 +5193,43 @@ fn status_context(
51755193
let cwd = env::current_dir()?;
51765194
let loader = ConfigLoader::default_for(&cwd);
51775195
let discovered_config_files = loader.discover().len();
5178-
let runtime_config = loader.load()?;
5196+
// #143: degrade gracefully on config parse failure rather than hard-fail.
5197+
// `claw doctor` already does this; `claw status` now matches that contract
5198+
// so that one malformed `mcpServers.*` entry doesn't take down the whole
5199+
// health surface (workspace, git, model, permission, sandbox can still be
5200+
// reported independently).
5201+
let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() {
5202+
Ok(runtime_config) => (
5203+
runtime_config.loaded_entries().len(),
5204+
resolve_sandbox_status(runtime_config.sandbox(), &cwd),
5205+
None,
5206+
),
5207+
Err(err) => (
5208+
0,
5209+
// Fall back to defaults for sandbox resolution so claws still see
5210+
// a populated sandbox section instead of a missing field. Defaults
5211+
// produce the same output as a runtime config with no sandbox
5212+
// overrides, which is the right degraded-mode shape: we cannot
5213+
// report what the user *intended*, only what is actually in effect.
5214+
resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd),
5215+
Some(err.to_string()),
5216+
),
5217+
};
51795218
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
51805219
let (project_root, git_branch) =
51815220
parse_git_status_metadata(project_context.git_status.as_deref());
51825221
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
5183-
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
51845222
Ok(StatusContext {
51855223
cwd,
51865224
session_path: session_path.map(Path::to_path_buf),
5187-
loaded_config_files: runtime_config.loaded_entries().len(),
5225+
loaded_config_files,
51885226
discovered_config_files,
51895227
memory_file_count: project_context.instruction_files.len(),
51905228
project_root,
51915229
git_branch,
51925230
git_summary,
51935231
sandbox_status,
5232+
config_load_error,
51945233
})
51955234
}
51965235

@@ -5200,9 +5239,24 @@ fn format_status_report(
52005239
permission_mode: &str,
52015240
context: &StatusContext,
52025241
) -> String {
5203-
[
5242+
// #143: if config failed to parse, surface a degraded banner at the top
5243+
// of the text report so humans see the parse error before the body, while
5244+
// the body below still reports everything that could be resolved without
5245+
// config (workspace, git, sandbox defaults, etc.).
5246+
let status_line = if context.config_load_error.is_some() {
5247+
"Status (degraded)"
5248+
} else {
5249+
"Status"
5250+
};
5251+
let mut blocks: Vec<String> = Vec::new();
5252+
if let Some(err) = context.config_load_error.as_deref() {
5253+
blocks.push(format!(
5254+
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
5255+
));
5256+
}
5257+
blocks.extend([
52045258
format!(
5205-
"Status
5259+
"{status_line}
52065260
Model {model}
52075261
Permission mode {permission_mode}
52085262
Messages {}
@@ -5255,12 +5309,8 @@ fn format_status_report(
52555309
context.memory_file_count,
52565310
),
52575311
format_sandbox_report(&context.sandbox_status),
5258-
]
5259-
.join(
5260-
"
5261-
5262-
",
5263-
)
5312+
]);
5313+
blocks.join("\n\n")
52645314
}
52655315

52665316
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
@@ -9623,6 +9673,103 @@ mod tests {
96239673
}
96249674
}
96259675

9676+
#[test]
9677+
fn status_degrades_gracefully_on_malformed_mcp_config_143() {
9678+
// #143: previously `claw status` hard-failed on any config parse error,
9679+
// taking down the entire health surface for one malformed MCP entry.
9680+
// `claw doctor` already degrades gracefully; this test locks `status`
9681+
// to the same contract.
9682+
let _guard = env_lock();
9683+
let root = temp_dir();
9684+
let cwd = root.join("project-with-malformed-mcp");
9685+
std::fs::create_dir_all(&cwd).expect("project dir should exist");
9686+
// One valid server + one malformed entry missing `command`.
9687+
std::fs::write(
9688+
cwd.join(".claw.json"),
9689+
r#"{
9690+
"mcpServers": {
9691+
"everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]},
9692+
"missing-command": {"args": ["arg-only-no-command"]}
9693+
}
9694+
}
9695+
"#,
9696+
)
9697+
.expect("write malformed .claw.json");
9698+
9699+
let context = with_current_dir(&cwd, || {
9700+
super::status_context(None).expect("status_context should not hard-fail on config parse errors (#143)")
9701+
});
9702+
9703+
// Phase 1 contract: config_load_error is populated with the parse error.
9704+
let err = context
9705+
.config_load_error
9706+
.as_ref()
9707+
.expect("config_load_error should be Some when config parse fails");
9708+
assert!(
9709+
err.contains("mcpServers.missing-command"),
9710+
"config_load_error should name the malformed field path: {err}"
9711+
);
9712+
assert!(
9713+
err.contains("missing string field command"),
9714+
"config_load_error should carry the underlying parse error: {err}"
9715+
);
9716+
9717+
// Phase 1 contract: workspace/git/sandbox fields are still populated
9718+
// (independent of config parse). Sandbox falls back to defaults.
9719+
assert_eq!(context.cwd, cwd.canonicalize().unwrap_or(cwd.clone()));
9720+
assert_eq!(
9721+
context.loaded_config_files, 0,
9722+
"loaded_config_files should be 0 when config parse fails"
9723+
);
9724+
assert!(
9725+
context.discovered_config_files > 0,
9726+
"discovered_config_files should still count the file that failed to parse"
9727+
);
9728+
9729+
// JSON output contract: top-level `status: "degraded"` + config_load_error field.
9730+
let usage = super::StatusUsage {
9731+
message_count: 0,
9732+
turns: 0,
9733+
latest: runtime::TokenUsage::default(),
9734+
cumulative: runtime::TokenUsage::default(),
9735+
estimated_tokens: 0,
9736+
};
9737+
let json = super::status_json_value(Some("test-model"), usage, "workspace-write", &context);
9738+
assert_eq!(
9739+
json.get("status").and_then(|v| v.as_str()),
9740+
Some("degraded"),
9741+
"top-level status marker should be 'degraded' when config parse failed: {json}"
9742+
);
9743+
assert!(
9744+
json.get("config_load_error")
9745+
.and_then(|v| v.as_str())
9746+
.is_some_and(|s| s.contains("mcpServers.missing-command")),
9747+
"config_load_error should surface in JSON output: {json}"
9748+
);
9749+
// Independent fields still populated.
9750+
assert_eq!(
9751+
json.get("model").and_then(|v| v.as_str()),
9752+
Some("test-model")
9753+
);
9754+
assert!(json.get("workspace").is_some(), "workspace field still reported");
9755+
assert!(json.get("sandbox").is_some(), "sandbox field still reported");
9756+
9757+
// Clean path: no config error → status: "ok", config_load_error: null.
9758+
let clean_cwd = root.join("project-with-clean-config");
9759+
std::fs::create_dir_all(&clean_cwd).expect("clean project dir");
9760+
let clean_context = with_current_dir(&clean_cwd, || {
9761+
super::status_context(None).expect("clean status_context should succeed")
9762+
});
9763+
assert!(clean_context.config_load_error.is_none());
9764+
let clean_json =
9765+
super::status_json_value(Some("test-model"), usage, "workspace-write", &clean_context);
9766+
assert_eq!(
9767+
clean_json.get("status").and_then(|v| v.as_str()),
9768+
Some("ok"),
9769+
"clean run should report status: 'ok'"
9770+
);
9771+
}
9772+
96269773
#[test]
96279774
fn state_error_surfaces_actionable_worker_commands_139() {
96289775
// #139: the error for missing `.claw/worker-state.json` must name
@@ -10761,6 +10908,7 @@ mod tests {
1076110908
conflicted_files: 0,
1076210909
},
1076310910
sandbox_status: runtime::SandboxStatus::default(),
10911+
config_load_error: None,
1076410912
},
1076510913
);
1076610914
assert!(status.contains("Status"));

0 commit comments

Comments
 (0)