fix(cli): unify not_found exit code to 2 across commands #78
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-49-exit-codes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Two slices shipped the same error class with two different CLI exit codes:
life item get <unknown>exited 1 whilelife operation get <unknown>exited 2, both as HTTP 404 witherror.code == "not_found". The two originating issue specs disagreed (docs/issues/009-…said "HTTP 404, CLI exit 1";docs/issues/006-…said "If 404: exit 2").docs/technical/cli-api.mddefines exit 2 as "Invalid input/configuration" and exit 1 as "Other failure, including authentication/authorization". A caller naming an ID this service does not hold made an input error, not an infrastructure failure, so exit 2 is correct andlife item getwas the outlier. Issue #49 asks for one answer everywhere rather than a per-command patch.Changes
src/api/mod.rs—ApiFailure::not_foundnow carriesexit_codes::INVALID_INPUTinstead ofGENERAL_ERROR(HTTP status stays 404,retryablestays false). This is the single source every route builds the failure from.src/client.rs— the shared CLI exit map (mapped_exit_code) movesnot_foundfrom the exit-1 group into the input-class group, so anot_foundenvelope exits 2 whatever HTTP status carried it.src/cli.rs— the per-commandnot_foundearly return inoperation_exit_codeis deleted as redundant:life operation get's error envelope now derives exit 2 from the shared map, while the function's remaining job — the operation's own recorded state deciding the settled (0) and not-established (6) codes — is unchanged.life operation getis the only command insrc/cli.rsthat overrode a daemon error code; the other code comparison there (report_conflict_hint) only prints a stderr hint.skills/life/references/errors.md— thenot_foundrow now documents exit 2, and the parenthetical that existed only to describelife operation getas an exception is gone. The "Recovery by operation ID" section already said exit 2 for an unknown operation; it is now simply the general rule.Routes audited (
grep -rn "not_found" src/)Every producer of a top-level
not_founderror code, and the exit code it now yields (all exit 2, all through the one shared map):GET /v1/items/{id}src/api/items.rs(not_found, 4 branches: removal in history, non-removal record, no record, unreadable history)life item getGET /v1/operations/{id}src/api/operations.rslife operation getGET /v1/proposals/{id}src/api/proposals.rslife proposal getPOST /v1/proposals/{id}/applysrc/api/proposals.rslife proposal applyPOST /v1/proposals/{id}/rejectsrc/api/proposals.rslife proposal rejectPOST /v1/tasks/{id}/{complete,reopen,cancel}src/api/lifecycle.rslife task complete|reopen|cancelPATCH /v1/tasks/{id},PATCH /v1/events/{id}src/api/update.rslife task update,life event updatePOST /v1/plans(anintended_outcomesid no live task carries)src/api/plans.rslife planPOST /v1/context,POST /v1/context/search(anitem_idabsent from the live calendar)src/api/context.rslife context add,life context searchsrc/api/mod.rs(ScopeFailure::Refused)--scopemutation (life task update --scope …,life event update --scope …)src/api/mod.rsrouter fallback (ErrorItem::new("not_found", …))Two
not_foundstrings that are not envelope surfaces, checked so the audit is not mistaken for a claim about them:src/calendar/mod.rs—CalendarError::error_code()mapsItemNotFound/CollectionNotFoundto the string"not_found", but that mapping is never called on a response path (both callers,live_read_failureinsrc/api/items.rsand the coverage classification insrc/api/agenda.rs, turn calendar failures intocalendar_unsupported/dependency_unavailable); its only caller is an in-file test.CalendarError::exit_code()is not called anywhere and still ends in_ => GENERAL_ERROR, which would be a second, contradictory mapping table if it were used.src/hindsight/client.rs—HindsightError::NotFoundreports the code"not_found", but it surfaces as a warning code (context_unavailable) or an outbox failure code, never as the envelope'serror.code.One exception, and why it is not an inconsistency
not_foundas the error member of apartialenvelope exits 6, not 2. The status is the semantic outcome and decides before the error code (client::exit_code_for), soPOST /v1/replans— which records the reported interruption as a fact and then fails to resolve a namedremaining_intended_outcomesid — answers HTTP 200 withstatus: "partial",error.code: "not_found",proposal: nulland exits 6. Reading that as exit 2 would tell a caller "repair your input" when the durable fact has already been recorded and the proposal was simply not generated.This is a pre-existing rule this change does not touch:
partialalways exits 6. The exception was found during this change's own route audit, before any review, and is recorded here rather than papered over by a claim of one uniform exit code. It is pinned at the route bytests/replan_tests.rs::the_fact_stands_when_the_named_outcomes_cannot_be_resolved(statuspartial+error.code == "not_found"), at the CLI by the newa_partial_envelope_carrying_not_found_still_exits_six, and for the general precedence rule bytests/cli_protocol_tests.rs::the_semantic_outcome_decides_before_the_http_status. Everywhere else — a terminalerror-status envelope —not_foundis exit 2.Deliberate consequence: the two rows that are not per-command reads —
POST /v1/plans,POST /v1/context*, the scoped preflight and the router fallback — also move from exit 1 to exit 2 when they answernot_foundas a terminal error. That is the point of "the mapping is uniform", and exit 2 is the documented class for an input the caller must correct (including a route this daemon version does not serve).Verification
Reproduced before the fix with the new end-to-end test against a real daemon over TCP (mock CalDAV backend + real journal), then re-run after:
an_unknown_item_id_is_not_found_with_exit_twofailed withleft: 1, right: 2— i.e. HTTP 404,error.code == "not_found", CLI exit 1 forlife item get <unknown>.an_unknown_operation_id_is_not_found_with_exit_twopassed — HTTP 404,error.code == "not_found", CLI exit 2 forlife operation get <unknown>.schema_version: 1,status: "error",data: null, emptywarnings,error.retryable == false) and that a present item still exits 0 through the same route.life operation getis now complete, each state observed through the real binary against a real daemon:applied→ 0 (crash_recovery_tests.rs::operation_get_calls_the_operation_route_and_exits_zero_when_settled),needs_reconciliation→ 6 (operation_get_exits_six_when_the_outcome_is_unresolved),failed→ 0 (dual_agent_acceptance.rs), and — new here — a durablypreparedrow → 6 (operation_get_exits_six_for_a_durably_prepared_operation, which seeds the R05 crash point with the existingseed_prepared_createfixture, asserts nothing was dispatched, and asserts exit 6 withdata.state == "prepared"and noerror). Thepreparedcase previously stopped at the HTTP envelope and never invoked the CLI, which left the mapping this change trimmed unguarded at the boundary an agent uses.a_partial_envelope_carrying_not_found_still_exits_sixobserves the exception end-to-end: the real client binary against a daemon answering thepartial+not_foundshape exits 6 and keepserror.code == "not_found"in its envelope. (It is a multi-threaded test because it spawns the real binary while the canned daemon serves on the same runtime; a single-threaded runtime deadlocks the daemon and the client reports a 30 s timeout — observed while writing it, fixed by the runtime flavour, not by loosening an assertion.)tests/issue49_exit_codes.rs(new) observes both commands at the boundary an agent uses — the route's HTTP status anderror.code, and the CLI's exit code — rather than asserting the mapping table against itself.Adjusted tests, with reasons:
tests/agenda_tasks_tests.rs—test_item_get_cli_end_to_end_against_daemonpinned the old contract (assert_eq!(…, exit_codes::GENERAL_ERROR)for an absent id, with the comment "Absent id maps to exit 1 (not_found)"). It now assertsINVALID_INPUT; this is the assertion the issue exists to change, not a weakened check — the assertion is still an exact exit-code comparison.tests/crash_recovery_tests.rs— one test added (operation_get_exits_six_for_a_durably_prepared_operation); no existing assertion in that suite was changed.tests/cli_protocol_tests.rs—the_exit_code_map_covers_every_code_the_service_can_emitkeeps its totality assertion intact; its route-code row fornot_foundwas updated from 1 to 2 so the table still states what the map does. No assertion was removed or relaxed.Suite and linters, run in the worktree with the repo's devshell:
cargo fmtthencargo fmt --check— clean.cargo test— 747 passed, 0 failed. Counting method: the summedN passedof the 43test resultlines the workspace emits (743 onmain+ 4 new tests: 3 intests/issue49_exit_codes.rs, 1 intests/crash_recovery_tests.rs).cargo clippy --all-targets -- -D warnings— 0 errors.Documentation
skills/life/references/errors.mdis updated (exit column and the now-redundant exception note).tests/skill_package_tests.rs::skill_package_documents_the_exit_the_cli_really_returns_per_codere-derives each documented exit from the CLI's own map, and passes.docs/technical/is the specification and is deliberately not committed from this worktree (docs/is excluded in.git/info/exclude;git ls-files docsis empty). No normative text was edited or needs to be:cli-api.md's exit table does not assign 404 to an exit, so this change does not contradict it — it resolves the silence the way issue #49 states. Nodocs/technical/verification.mdscenario covers exit-code uniformity; the issue's acceptance criteria are the contract for this change.Closes #49
not_foundexit code to 2 across commands (#49) 0ef718c1e7fix(cli): unifyto fix(cli): unifynot_foundexit code to 2 across commands (#49)not_foundexit code to 2 across commandsfix(cli): unifyto fix(cli): unifynot_foundexit code to 2 across commandsnot_foundexit code to 2 across commands (#49)partial+not_foundexit precedence (#49) 8f2a59683cfix(cli): unifyto fix(cli): unifynot_foundexit code to 2 across commands (#49)not_foundexit code to 2 across commandsREQUEST CHANGES
Per-criterion review
life item get <unknown-id> --jsonexits 2, remains HTTP 404, and returnserror.code == "not_found"with the same envelope shapesrc/api/mod.rs:260-265,src/client.rs:91-101, andtests/issue49_exit_codes.rs::an_unknown_item_id_is_not_found_with_exit_two(:188) exercise the real router over TCP and the CLI dispatch path.tests/agenda_tasks_tests.rs::test_item_get_cli_end_to_end_against_daemonalso keeps an exact exit-code assertion.life operation get <unknown-id> --jsonstill exits 2tests/issue49_exit_codes.rs::an_unknown_operation_id_is_not_found_with_exit_two(:220) observes HTTP 404, the real envelope, and CLI exit 2. The redundant command-specific override is gone and the shared map supplies 2.not_foundis classified correctly on every affected terminal surfaceApiFailure::not_foundand/orclient::mapped_exit_code. Exit 2 is appropriate for plans/context and scoped preflight because the caller must correct or reread the named resource; the refusal occurs after authorization and is not an authorization failure. Exit 2 is also appropriate for an unrouted path: it is a client route/version mistake, whereascli-api.mdreserves exit 1 for authentication/authorization/other failures.partial + not_found8f2a596tests/replan_tests.rs::the_fact_stands_when_the_named_outcomes_cannot_be_resolved(:760) proves the real route emits HTTP 200,status: partial, anderror.code: not_found. The follow-uptests/issue49_exit_codes.rs::a_partial_envelope_carrying_not_found_still_exits_six(:289) spawns the reallifebinary against a canned daemon and observes exit 6 plus the unchanged envelope. It is not a restatement of the mapping function.tests/cli_protocol_tests.rs::the_semantic_outcome_decides_before_the_http_status(:316) pins the general precedence rule. I independently found the same replan exception during review, but did not drive the follow-up commit; I reviewed the resulting final-head test and route citation.not_foundmapping and removes its redundant operation-specific override. The pre-existing partial-envelope rule still wins and exits 6.life operation getstate mapping remains pinned end to end forapplied,failed,prepared, andneeds_reconciliationcrash_recovery_tests.rs::operation_get_calls_the_operation_route_and_exits_zero_when_settled(:1210); needs-reconciliation byoperation_get_exits_six_when_the_outcome_is_unresolved(:1242); failed is exercised by the real-client dual-agent acceptance route (dual_agent_acceptance.rs:827-918). The prepared-state check atcrash_recovery_tests.rs:794-795stops at the HTTP envelope and never invokeslife operation get, so prepared → exit 6 is not pinned at the CLI boundary.the_exit_code_map_covers_every_code_the_service_can_emitremains a real behavior tabletests/cli_protocol_tests.rs:199-238enumerates actualApiFailureconstructors and compares both the shared code map and envelope+HTTP behavior to each constructor's exit code; the route-code table keeps an exactnot_foundrow at:288. No assertion was relaxed.main,ApiFailure::not_foundand the shared client map both selected exit 1, so its exact exit-2 assertion would fail while the operation test's old per-command override would still pass.skills/life/references/errors.md:50and:80now state exit 2.commands.mddid not document a conflictingnot_foundexit.tests/skill_package_tests.rs::skill_package_documents_the_exit_the_cli_really_returns_per_code(:572) parses the error table and checks each documented row through the real CLI exit mapper; it passed.Findings
src/cli.rs:2634-2639— prepared operation state lacks the required CLI-boundary regression test — after trimming the command-specific override, the suite covers the CLI outcomes for applied, failed, and needs-reconciliation, but the only prepared assertion stops at the API envelope. A regression in thelife operation gethandling of a durable prepared row therefore lacks the requested end-to-end guard. Correct looks like invoking the real CLI against an operation durably left inpreparedand asserting exit 6 anddata.state == "prepared".Verification I ran on final head
8f2a596nix develop --command cargo test— 746 passed, 0 failed, across 41 Cargo suite results (38 integration test targets plus lib, main, and doc tests).nix develop --command cargo clippy --all-targets -- -D warnings— clean; 0 warnings/errors.nix develop --command cargo fmt --check— clean; no output.PR-body claims
not_found, the partial-envelope exception, and the final test count reproduced.cargo test— 746 passed, 0 failed across 43 suites” overstates the suite count: my final-head run produced the same 746/0 test total but 41 Cargo suite results.left: 1, right: 2) was not independently rerun on a pre-fix checkout because this review targeted the final head without modifying tracked files. The diff does substantiate the behavior: the new test exercises the real route/CLI path, and both pre-fix mappings selected 1.Could not check
preparedstate at the CLI boundary (#49) 89551bf1acfix(cli): unifyto fix(cli): unifynot_foundexit code to 2 across commandsnot_foundexit code to 2 across commands (#49)Review finding fixed at head
89551bf.tests/crash_recovery_tests.rs::operation_get_exits_six_for_a_durably_prepared_operationcloses the CLI-boundary gap: it seeds the R05 crash point with the existingseed_prepared_createfixture, asserts nothing was dispatched, and runs the reallife operation get op-cli-prepared --jsonagainst the real daemon over TCP — exit 6,status: partial,data.state == "prepared", noerrormember. The state matrix is now pinned at the CLI for all four states: applied 0, failed 0, needs_reconciliation 6, prepared 6.Counts, with the method stated in the PR body: 747 passed / 0 failed, summed over the 43
test resultlines the workspace emits;cargo clippy --all-targets -- -D warningsclean;cargo fmt --checkclean.The
partial + not_foundprecedence exception (replans → exit 6) was found during this change's own route audit, before review; the PR body now says so and cites the route-level, CLI-level and precedence-rule tests that pin it.APPROVE
Re-verified final head
89551bf1acb5f9bf35cd3652dadc3bd4d2749f96.The new
tests/crash_recovery_tests.rs::operation_get_exits_six_for_a_durably_prepared_operationgenuinely observes the CLI boundary: it seeds a durablepreparedrow withseed_prepared_create, confirmsPUT == 0, starts the real daemon over TCP, launches the reallife operation get op-cli-prepared --jsonbinary, and asserts exit 6 plusstatus: partial,data.state: prepared, the operation identity/intent, and null error. A regression of the prepared mapping would fail this test. The four-state matrix is now complete: applied → 0, failed → 0, needs_reconciliation → 6, prepared → 6.No earlier findings remain open. The terminal
not_foundmapping is correctly exit 2; the real replan route'spartial + not_foundexception remains correctly exit 6 and is pinned by both route and real-binary tests. The 43 count is now clear: 43test resultlines (41 test binaries plus lib/main/doc-test result lines), summing to 747 passed and 0 failed.Verification on this head:
nix develop --command cargo test: 747 passed, 0 failed, 43test resultlines.nix develop --command cargo clippy --all-targets -- -D warnings: clean.nix develop --command cargo fmt --check: clean.fix(cli): unifyto fix(cli): unifynot_foundexit code to 2 across commands (#49)not_foundexit code to 2 across commandsfix(cli): unifyto fix(cli): unify not_found exit code to 2 across commandsnot_foundexit code to 2 across commands