Skip to content

Commit 9362900

Browse files
committed
feat: ultraworkers#77 Phase 1 — machine-readable error classification in JSON error payloads
## Problem All JSON error payloads had the same three-field envelope: ```json {"type": "error", "error": "<prose with hint baked in>"} ``` Five distinct error classes were indistinguishable at the schema level: - missing_credentials (no API key) - missing_worker_state (no state file) - session_not_found / session_load_failed - cli_parse (unrecognized args) - invalid_model_syntax Downstream claws had to regex-scrape the prose to route failures. ## Fix 1. **Added `classify_error_kind()`** — prefix/keyword classifier that returns a snake_case discriminant token for 12 known error classes: `missing_credentials`, `missing_manifests`, `missing_worker_state`, `session_not_found`, `session_load_failed`, `no_managed_sessions`, `cli_parse`, `invalid_model_syntax`, `unsupported_command`, `unsupported_resumed_command`, `confirmation_required`, `api_http_error`, plus `unknown` fallback. 2. **Added `split_error_hint()`** — splits multi-line error messages into (short_reason, optional_hint) so the runbook prose stops being stuffed into the `error` field. 3. **Extended JSON envelope** at 4 emit sites: - Main error sink (line ~213) - Session load failure in resume_session - Stub command (unsupported_command) - Unknown resumed command (unsupported_resumed_command) ## New JSON shape ```json { "type": "error", "error": "short reason (first line)", "kind": "missing_credentials", "hint": "Hint: export ANTHROPIC_API_KEY..." } ``` `kind` is always present. `hint` is null when no runbook follows. `error` now carries only the short reason, not the full multi-line prose. ## Tests Added 2 new regression tests: - `classify_error_kind_returns_correct_discriminants` — all 9 known classes + fallback - `split_error_hint_separates_reason_from_runbook` — with and without hints All 179 rusty-claude-cli tests pass. Full workspace green. Closes ROADMAP ultraworkers#77 Phase 1.
1 parent ff45e97 commit 9362900

1 file changed

Lines changed: 93 additions & 2 deletions

File tree

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

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

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,17 @@ fn main() {
210210
.any(|w| w[0] == "--output-format" && w[1] == "json")
211211
|| argv.iter().any(|a| a == "--output-format=json");
212212
if json_output {
213+
// #77: classify error by prefix so downstream claws can route without
214+
// regex-scraping the prose. Split short-reason from hint-runbook.
215+
let kind = classify_error_kind(&message);
216+
let (short_reason, hint) = split_error_hint(&message);
213217
eprintln!(
214218
"{}",
215219
serde_json::json!({
216220
"type": "error",
217-
"error": message,
221+
"error": short_reason,
222+
"kind": kind,
223+
"hint": hint,
218224
})
219225
);
220226
} else if message.contains("`claw --help`") {
@@ -230,6 +236,55 @@ Run `claw --help` for usage."
230236
}
231237
}
232238

239+
/// #77: Classify a stringified error message into a machine-readable kind.
240+
///
241+
/// Returns a snake_case token that downstream consumers can switch on instead
242+
/// of regex-scraping the prose. The classification is best-effort prefix/keyword
243+
/// matching against the error messages produced throughout the CLI surface.
244+
fn classify_error_kind(message: &str) -> &'static str {
245+
// Check specific patterns first (more specific before generic)
246+
if message.contains("missing Anthropic credentials") {
247+
"missing_credentials"
248+
} else if message.contains("Manifest source files are missing") {
249+
"missing_manifests"
250+
} else if message.contains("no worker state file found") {
251+
"missing_worker_state"
252+
} else if message.contains("session not found") {
253+
"session_not_found"
254+
} else if message.contains("failed to restore session") {
255+
"session_load_failed"
256+
} else if message.contains("no managed sessions found") {
257+
"no_managed_sessions"
258+
} else if message.contains("unrecognized argument") || message.contains("unknown option") {
259+
"cli_parse"
260+
} else if message.contains("invalid model syntax") {
261+
"invalid_model_syntax"
262+
} else if message.contains("is not yet implemented") {
263+
"unsupported_command"
264+
} else if message.contains("unsupported resumed command") {
265+
"unsupported_resumed_command"
266+
} else if message.contains("confirmation required") {
267+
"confirmation_required"
268+
} else if message.contains("api failed") || message.contains("api returned") {
269+
"api_http_error"
270+
} else {
271+
"unknown"
272+
}
273+
}
274+
275+
/// #77: Split a multi-line error message into (short_reason, optional_hint).
276+
///
277+
/// The short_reason is the first line (up to the first newline), and the hint
278+
/// is the remaining text or `None` if there's no newline. This prevents the
279+
/// runbook prose from being stuffed into the `error` field that downstream
280+
/// parsers expect to be the short reason alone.
281+
fn split_error_hint(message: &str) -> (String, Option<String>) {
282+
match message.split_once('\n') {
283+
Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())),
284+
None => (message.to_string(), None),
285+
}
286+
}
287+
233288
/// Read piped stdin content when stdin is not a terminal.
234289
///
235290
/// Returns `None` when stdin is attached to a terminal (interactive REPL use),
@@ -2576,11 +2631,17 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
25762631
Ok(loaded) => loaded,
25772632
Err(error) => {
25782633
if output_format == CliOutputFormat::Json {
2634+
// #77: classify session load errors for downstream consumers
2635+
let full_message = format!("failed to restore session: {error}");
2636+
let kind = classify_error_kind(&full_message);
2637+
let (short_reason, hint) = split_error_hint(&full_message);
25792638
eprintln!(
25802639
"{}",
25812640
serde_json::json!({
25822641
"type": "error",
2583-
"error": format!("failed to restore session: {error}"),
2642+
"error": short_reason,
2643+
"kind": kind,
2644+
"hint": hint,
25842645
})
25852646
);
25862647
} else {
@@ -2632,6 +2693,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
26322693
serde_json::json!({
26332694
"type": "error",
26342695
"error": format!("/{cmd_root} is not yet implemented in this build"),
2696+
"kind": "unsupported_command",
26352697
"command": raw_command,
26362698
})
26372699
);
@@ -2650,6 +2712,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
26502712
serde_json::json!({
26512713
"type": "error",
26522714
"error": format!("unsupported resumed command: {raw_command}"),
2715+
"kind": "unsupported_resumed_command",
26532716
"command": raw_command,
26542717
})
26552718
);
@@ -8945,10 +9008,12 @@ mod tests {
89459008
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
89469009
format_ultraplan_report, format_unknown_slash_command,
89479010
format_unknown_slash_command_message, format_user_visible_api_error,
9011+
classify_error_kind,
89489012
merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args,
89499013
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
89509014
parse_history_count, permission_policy, print_help_to, push_output_block,
89519015
render_config_report, render_diff_report, render_diff_report_for, render_memory_report,
9016+
split_error_hint,
89529017
render_help_topic, render_prompt_history_report, render_repl_help, render_resume_usage,
89539018
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
89549019
resolve_repl_model, resolve_session_reference, response_to_events,
@@ -10348,6 +10413,32 @@ mod tests {
1034810413
);
1034910414
}
1035010415

10416+
#[test]
10417+
fn classify_error_kind_returns_correct_discriminants() {
10418+
// #77: error kind classification for JSON error payloads
10419+
assert_eq!(classify_error_kind("missing Anthropic credentials; export ..."), "missing_credentials");
10420+
assert_eq!(classify_error_kind("no worker state file found at /tmp/..."), "missing_worker_state");
10421+
assert_eq!(classify_error_kind("session not found: abc123"), "session_not_found");
10422+
assert_eq!(classify_error_kind("failed to restore session: no managed sessions found"), "session_load_failed");
10423+
assert_eq!(classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), "cli_parse");
10424+
assert_eq!(classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."), "invalid_model_syntax");
10425+
assert_eq!(classify_error_kind("unsupported resumed command: /blargh"), "unsupported_resumed_command");
10426+
assert_eq!(classify_error_kind("api failed after 3 attempts: ..."), "api_http_error");
10427+
assert_eq!(classify_error_kind("something completely unknown"), "unknown");
10428+
}
10429+
10430+
#[test]
10431+
fn split_error_hint_separates_reason_from_runbook() {
10432+
// #77: short reason / hint separation for JSON error payloads
10433+
let (short, hint) = split_error_hint("missing credentials\nHint: export ANTHROPIC_API_KEY");
10434+
assert_eq!(short, "missing credentials");
10435+
assert_eq!(hint, Some("Hint: export ANTHROPIC_API_KEY".to_string()));
10436+
10437+
let (short, hint) = split_error_hint("simple error with no hint");
10438+
assert_eq!(short, "simple error with no hint");
10439+
assert_eq!(hint, None);
10440+
}
10441+
1035110442
#[test]
1035210443
fn parses_bare_export_subcommand_targeting_latest_session() {
1035310444
// given

0 commit comments

Comments
 (0)