Structure local command failures - #7
Conversation
Agents should not need to match prose to recover from failures in the local Foundation Plan commands. Give init and subject-id the same single-object stderr contract as push while preserving their success streams and exit classes. Use a distinct initialization-write code because none of the push recovery states describes a local creation failure. Exercise the contract through both the runner and installed tarball.
Technical reviewThe follow-up slice noted on #6, where
The changeFour call sites move to the existing Reusing The README table gained the dimension it neededThe contract table is now keyed by command as well as code:
That matters more than it looks. A caller needs to know which codes are reachable from the command it actually ran, and a flat list of seven would have implied that any of them can come from anywhere. The privacy line is not decorativeThe guarantee was extended to cover "runtime paths" and "raw filesystem" errors, which is exactly the risk I checked the detail string rather than taking the claim on faith: const PLAN_INIT_FAILED_DETAIL =
"Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.";No path, and the caught error is not interpolated anywhere. The installed-tarball smoke asserts exact stderr framing, empty stdout, the exit status, and the absence of the canary secret for all three new envelopes, so this is pinned through the published artifact rather than only the in-process runner. The stated boundary holds upThe body calls the
Worth testing that reasoning rather than accepting it, since the obvious objection is that an agent invoking a subcommand the installed CLI does not have would hit Leaving unexpected defects uncaught is also right. A programming error dressed up in the same envelope as a handled failure would tell a caller it had hit a known condition when it had not. Cross-repo notefirstdraft/skills#7 pins the CLI contract at Still, the Skill currently has no branch for |
Lesson: say where your error handling stopsMost advice about error handling is about what to catch. This PR is interesting for the opposite reason. It is careful about what it does not catch, and it writes that down. The README ends its list of error codes like this:
Three things declared out of scope, on purpose, in the same document that promises the contract. That sentence is doing real work, and the habit is worth copying. The two ways this normally goes wrongCatching too little is the one everybody worries about. A failure escapes, the user sees a stack trace, nothing is handled. Catching too much gets far less attention and is worse, because it is silent. You write this: def import(file)
parse_and_save(file)
rescue => e
Rails.logger.error(e)
{ status: "failed" }
endNow every failure is a "failed import." A malformed file, sure. But also a typo in a method name, a missing migration, a nil where you expected a record. All reported to the caller as though you understood what went wrong and handled it. The caller does the reasonable thing with A bare Where to draw the lineThe useful question is not "could this raise?" but "do I know what this means, and can the caller do something specific about it?" Look at the shape the CLI uses: } catch (error) {
if (!isFileSystemError(error)) throw error;
writeJson(stderr, {
error: "local_initialization_failed",
detail: PLAN_INIT_FAILED_DETAIL,
});
return 1;
}The guard clause is the whole idea. A filesystem error during init is understood: the directory may be half-written, and the caller should stop and look. Anything else gets rethrown, because the code has no idea what it is and pretending otherwise would be a lie. In Rails the same discipline usually means rescuing the specific class: rescue ActiveRecord::RecordNotFound
head :not_foundand letting everything else reach your error tracker, which is what it is for. The part people skipDeciding the boundary is half of it. The other half is telling whoever depends on you where it is. An undocumented boundary gets discovered in production. Someone assumed every failure came back as a clean error object, wrote code on that assumption, and it held right up until the day it did not. Nothing was broken. They just could not see where your promise ended. So write it down. In a README, in the API docs, in a comment above the rescue. One sentence covering:
That last one matters most. "Unexpected defects are uncaught" tells a caller they need a crash path, not just an error path. Without it they will assume the error path is enough. The uncomfortable versionA promise you cannot keep is worse than no promise. If your API says every failure returns a structured error, and one path escapes with a raw exception, you have not merely failed to handle a case. You have taught every caller to skip defensive handling that would have caught it. Narrow, honest, documented beats broad and aspirational. "I handle these four things, everything else crashes" is a contract someone can build on. "I handle errors" is not. |
|
Thanks for checking the boundary against the actual Skill discovery path. Agreed on the cross-repository follow-up: the Skill should consume |
Summary
invalid_argumentsJSON envelope for handledplan initandplan subject-idsyntax failureslocal_initialization_failedenvelope for filesystem failures that may leave.firstdraftincompleteContract
Handled failures from
plan init,plan subject-id, andplan pushnow write exactly one JSON object to stderr. Agents branch on the stableerrorfield rather thandetail; unexpected defects and failures before subcommand dispatch remain uncaught.This slice does not change
plan push, root-level usage output, success stdout, or exit-status classes.Verification
npm run checkon pinned Node.js 24.18.0npm audit --audit-level=lowreports zero vulnerabilities