fix(#130b): enrich filesystem I/O errors with operation + path context

## What Was Broken (ROADMAP #130b, filed cycle #47)

In a fresh workspace, running:

    claw export latest --output /private/nonexistent/path/file.jsonl --output-format json

produced:

    {"error":"No such file or directory (os error 2)","hint":null,"kind":"unknown","type":"error"}

This violates the typed-error contract:
- Error message is a raw errno string with zero context
- Does not mention the operation that failed (export)
- Does not mention the target path
- Classifier defaults to "unknown" even though the code path knows
  this is a filesystem I/O error

## Root Cause (Traced)

run_export() at main.rs:~6915 does:

    fs::write(path, &markdown)?;

When this fails:
1. io::Error propagates via ? to main()
2. Converted to string via .to_string() in error handler
3. classify_error_kind() cannot match "os error" or "No such file"
4. Defaults to "kind": "unknown"

The information is there at the source (operation name, target path,
io::ErrorKind) but lost at the propagation boundary.

## What This Fix Does

Three changes:

1. **New helper: contextualize_io_error()** (main.rs:~260)
   Wraps an io::Error with operation name + target path into a
   recognizable message format:

       "{operation} failed: {target} ({error})"

2. **Classifier branch added** (classify_error_kind at main.rs:~270)
   Recognizes the new format and classifies as "filesystem_io_error":

       else if message.contains("export failed:") ||
               message.contains("diff failed:") ||
               message.contains("config failed:") {
           "filesystem_io_error"
       }

3. **run_export() wired** (main.rs:~6915)
   fs::write() call now uses .map_err() to enrich io::Error:

       fs::write(path, &markdown).map_err(|e| -> Box<dyn std::error::Error> {
           contextualize_io_error("export", &path.display().to_string(), e).into()
       })?;

## Dogfood Verification

Before fix:

    {"error":"No such file or directory (os error 2)","kind":"unknown","type":"error"}

After fix:

    {"error":"export failed: /private/nonexistent/path/file.jsonl (No such file or directory (os error 2))","kind":"filesystem_io_error","type":"error"}

The envelope now tells downstream claws:
- WHAT operation failed (export)
- WHERE it failed (the path)
- WHAT KIND of failure (filesystem_io_error)
- The original errno detail preserved for diagnosis

## Non-Regression Verification

- Successful export still works (emits "kind": "export" envelope as before)
- Session not found error still emits "session_not_found" (not filesystem)
- missing_credentials still works correctly
- cli_parse still works correctly
- All 180 binary tests pass
- All 466 library tests pass
- All 95 compat-harness tests pass

## Regression Tests Added

Inside the main CliAction test function:

- "export failed:" pattern classifies as "filesystem_io_error" (not "unknown")
- "diff failed:" pattern classifies as "filesystem_io_error"
- "config failed:" pattern classifies as "filesystem_io_error"
- contextualize_io_error() produces a message containing operation name
- contextualize_io_error() produces a message containing target path
- Messages produced by contextualize_io_error() are classifier-recognizable

## Scope

This is the minimum viable fix: enrich export's fs::write with context.
Future work (filed as part of #130b scope): apply same pattern to
other filesystem operations (diff, plugins, config fs reads, session
store writes, etc.). Each application is a copy-paste of the same
helper pattern.

## Pattern

Follows #145 (plugins parser interception), #248-249 (arm-level leak
templates). Helper + classifier + call site wiring. Minimal diff,
maximum observability gain.

## Related

- Closes #130b (filesystem error context preservation)
- Stacks on top of #251 (dispatch-order fix) — same worktree branch
- Ground truth for future #130 broader sweep (other io::Error sites)
This commit is contained in:
YeonGyu-Kim 2026-04-23 01:40:07 +09:00
parent dc274a0f96
commit d49a75cad5

View File

@ -257,10 +257,18 @@ Run `claw --help` for usage."
/// Returns a snake_case token that downstream consumers can switch on instead
/// of regex-scraping the prose. The classification is best-effort prefix/keyword
/// matching against the error messages produced throughout the CLI surface.
/// #130b: Wrap io::Error with operation context so classifier can recognize filesystem failures.
fn contextualize_io_error(operation: &str, target: &str, error: std::io::Error) -> String {
format!("{} failed: {} ({})", operation, target, error)
}
fn classify_error_kind(message: &str) -> &'static str {
// Check specific patterns first (more specific before generic)
if message.contains("missing Anthropic credentials") {
"missing_credentials"
} else if message.contains("export failed:") || message.contains("diff failed:") || message.contains("config failed:") {
// #130b: Filesystem operation errors enriched with operation+path context.
"filesystem_io_error"
} else if message.contains("Manifest source files are missing") {
"missing_manifests"
} else if message.contains("no worker state file found") {
@ -6908,7 +6916,10 @@ fn run_export(
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
if let Some(path) = output_path {
fs::write(path, &markdown)?;
// #130b: Wrap io::Error with operation context so classifier recognizes filesystem failures.
fs::write(path, &markdown).map_err(|e| -> Box<dyn std::error::Error> {
contextualize_io_error("export", &path.display().to_string(), e).into()
})?;
let report = format!(
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
path.display(),
@ -10302,6 +10313,45 @@ mod tests {
extra_err.contains("unexpected extra arguments"),
"extra-args error should be specific, got: {extra_err}"
);
// #130b: classify_error_kind must recognize filesystem operation errors.
// Messages produced by contextualize_io_error() must route to
// "filesystem_io_error" kind, not default "unknown". This closes the
// context-loss chain (run_export -> fs::write -> ? -> to_string ->
// classify miss -> unknown) that #130b identified.
let export_err_msg = "export failed: /tmp/bad/path (No such file or directory (os error 2))";
assert_eq!(
classify_error_kind(export_err_msg),
"filesystem_io_error",
"#130b: export fs::write errors must classify as filesystem_io_error, not unknown"
);
let diff_err_msg = "diff failed: /tmp/nowhere (Permission denied (os error 13))";
assert_eq!(
classify_error_kind(diff_err_msg),
"filesystem_io_error",
"#130b: diff fs errors must classify as filesystem_io_error"
);
let config_err_msg = "config failed: /tmp/x (Is a directory (os error 21))";
assert_eq!(
classify_error_kind(config_err_msg),
"filesystem_io_error",
"#130b: config fs errors must classify as filesystem_io_error"
);
// #130b: contextualize_io_error must produce messages that the classifier recognizes.
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
let enriched = super::contextualize_io_error("export", "/tmp/bad/path", io_err);
assert!(
enriched.contains("export failed:"),
"#130b: contextualize_io_error must include operation name, got: {enriched}"
);
assert!(
enriched.contains("/tmp/bad/path"),
"#130b: contextualize_io_error must include target path, got: {enriched}"
);
assert_eq!(
classify_error_kind(&enriched),
"filesystem_io_error",
"#130b: enriched messages must be classifier-recognizable"
);
// #147: empty / whitespace-only positional args must be rejected
// with a specific error instead of falling through to the prompt
// path (where they surface a misleading "missing Anthropic