Skip to content

fix(search): a daemon error object is an ERROR with a code, never 0 hits (#526) - #545

Merged
jphein merged 5 commits into
mainfrom
dream/feat-526-busy-error
Sep 19, 2026
Merged

jphein merged 5 commits into
mainfrom
dream/feat-526-busy-error

Conversation

@jphein

@jphein jphein commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

fix(search): a daemon error object is an ERROR with a code, never 0 hits (#526, PR 4 of 4)

Consumer of #536's option-C error shape ({error, code, source, status?, detail?} — lucid-error-contract owns the shape; this PR adds one key to the documented set and emits it). Part of #526 — deliberately not "Closes": #526 enumerates three fixes (banner → #542, retrieval → #534, dedup → #543), all merged before this PR; this fourth PR is a defect found while measuring those three (a busy daemon rendering as an empty corpus), adjacent to the umbrella rather than one of its parts. #526 can be closed by hand once this lands, citing the four.

Defect, measured on production. A saturated daemon answered /search/hybrid with HTTP 200 and
{"error": {"code": -32003, "message": "daemon busy: 8 MCP tool call(s) in flight (PALACE_MCP_TOOL_MAX_INFLIGHT=8)"}}.
The REST transports returned it verbatim, the search helpers read .get("results") or [], and the CLI printed
0 hits, exit 1. A busy daemon was indistinguishable from an empty corpus — and the depth banner would then
have said no curated document was in the top N. That is #526's own error class inside #526's fix.

The base defect is wider than search, and one grade worse in one verb. On base 8408188, status --json
against a 200 + -32003 error object exited 0, echoing the error object as if it were the payload — a
SUCCESS exit carrying an error (Oracle PART 51). On this head every daemon-backed verb that reaches a transport —
7/7 driven by Oracle with a busy body — exits 2 with code: "daemon_busy", because the classification lives in the
three transports rather than in any one verb.

Change.

piece rule
classifier _raise_if_daemon_error_object(body, route) keyed on the PAYLOAD: dict with error and neither results nor result. -32003 or "busy" in the message → DaemonBusyError; anything else → DaemonError("daemon error <code> on <route>: <msg>") (the prefix _fail_daemon keys its reachable line on). A body with results beside an error passes through.
producers all three transports call it — _call_daemon_rest, _post_daemon_rest, _call_daemon_tool — so REST and MCP agree. In _call_daemon_tool the classifier raises for every error envelope, so the pre-existing raise DaemonError("daemon error <code>: <msg>") below it was dead and is deleted; the message now carries the route (… on /mcp <tool>: …), which the old one lacked — kept on purpose. Tests that build that string by hand as a side effect are unaffected.
renderer cmd_search's except DaemonError → _fail_daemon(e, want_json, route=…, query=…) (the one search-shaped site #536 did not reach). New busy branch emits a dict literal: {"error": prose, "code": "daemon_busy", "source": "daemon", "detail": <daemon words>, "route": …}, exit 2; prose: palace daemon at <url> is busy — <words>; retry shortly on stderr
documented set daemon_busy appended to the header line AND to BRANCHABLE_CODES in test_cli_daemon_error_contract.py — both or neither
optimisations degrade, never silently _deep_fetch_when_nothing_curated(..., warnings=) appends deeper fetch unavailable: <daemon words>; auto-mode's swallowed hybrid fallback appends hybrid fallback unavailable: …. The header already prints warnings, so curated_first_rank: null is read as "the depth was not checked"

Why a literal. test_every_code_we_emit_is_in_the_documented_set walks dict literals only (emitted ⊆ documented).
Adding daemon_busy to the header without a literal emitter would be unverified vocabulary reading as a contract.
test_daemon_busy_is_emitted_as_a_dict_literal is the converse for this one key — safe here because the emitter
is known to be a literal; the general converse would false-positive on _fail_daemon's computed code.

Pre-registration amendment, stated. I pre-registered "2nd response busy → exit 2, never curated_first_rank: null".
That contradicts the tested contract that the deeper fetch is an optimisation (test_widen_tolerates_a_failed_second_call
degrades to the shallow hits). Replacement: degrade AND carry the daemon's words in warnings; joint producer→consumer
test (test_busy_deeper_fetch_keeps_the_shallow_hits_and_warns + test_the_header_prints_that_warning).

Positive control on the base (#536 @9441576b, unfixed). 15/15 substantive tests FAIL there (4 exit/code, 3 degrade,
3 documented/literal, 4 classifier, 1 MCP). A filler test written to justify an import was deleted rather than kept.

Blast radius. cli.py: DaemonBusyError (new class), _raise_if_daemon_error_object (new), _call_daemon_tool,
_call_daemon_rest, _post_daemon_rest, _fail_daemon (busy branch), cmd_search except, _deep_fetch_when_nothing_curated
(warnings= kwarg), _daemon_search_fast (adds warnings only when non-empty — return shape otherwise unchanged),
_daemon_search_hybrid, _daemon_search_auto; header contract line; BRANCHABLE_CODES. _window_daemon_get is
deliberately untouched — different producer, same payload rule would apply if it ever returned a 200 error object.

Verification. Stacked on #536 @9441576b: full suite 7596 passed / 82 skipped / 0 failed (rc from the same
invocation); 330/330 across 12 files including #536's own contract/4xx/one-message/exit-propagation/window-source/cypher/stats
suites; ruff check + ruff format --check clean; check-docs (see entry commit). Lands after #536; expect one
generated-only rebase at go.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 19, 2026 02:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 87dc20b9-a482-4e54-b716-d618a3224d78

📥 Commits

Reviewing files that changed from the base of the PR and between a5d8903 and 101d667.

📒 Files selected for processing (8)
  • FORK_CHANGELOG.md
  • README.md
  • docs/fork-changes/2026-09-18-search-daemon-error-object.yaml
  • mempalace/cli.py
  • tests/test_cli_daemon_error_contract.py
  • tests/test_search_daemon_error_object.py
  • website/public/llms-full.txt
  • website/reference/python-api/cli.md
 _______________________________
< When in doubt, review it out. >
 -------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

jphein and others added 5 commits September 19, 2026 08:16
Measured on production (#526, PR 4 of 4): a saturated daemon answered
/search/hybrid with HTTP 200 and a JSON-RPC error body
{"error": {"code": -32003, "message": "daemon busy: 8 MCP tool call(s) in
flight …"}}. The REST transports returned it verbatim, the search helpers
read .get("results") or [], and the CLI printed 0 hits, exit 1 — "the
palace was reachable and had nothing to say". A busy daemon was
indistinguishable from an empty corpus, and the depth banner would then
have said no curated document was in the top N: #526's own error class.

One classifier, _raise_if_daemon_error_object, keyed on the PAYLOAD (a
dict with `error` and neither `results` nor `result`), called by all
three transports (_call_daemon_rest, _post_daemon_rest, _call_daemon_tool).
-32003 or "busy" raises DaemonBusyError; anything else a DaemonError with
the "daemon error" prefix _fail_daemon keys on. cmd_search's except now
goes through _fail_daemon, whose new busy branch emits a dict LITERAL —
{"error", "code": "daemon_busy", "source", "detail", "route"} — exit 2,
so the contract test's literal walker sees the key. `daemon_busy` joins
the documented set in the header and in BRANCHABLE_CODES together.

Where the failing call is an optimisation — the deeper fetch, auto-mode's
hybrid fallback — the real hits are still returned and the daemon's words
travel in `warnings`, which the header prints: "curated_first_rank: null"
is read as "the depth was not checked", never as "nothing curated exists".

Stacked on #536 (the header block it appends to). Part of #526

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
--next-seq said 163 after the final fetch, but #543 (open) already carries
163 on its branch; 164 avoids colliding with my own open PR (162 is on
main via #536). commit: HEAD for the merge step to resolve. Four
renderers (changelog, README, llms-full, python-api — DaemonBusyError and
the classifier gained docstrings); check-docs clean.

Part of #526

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tests.…`

tests/ has no __init__.py, so `from tests.test_cli_daemon_error_contract
import BRANCHABLE_CODES` is a namespace-package lookup that the editable
.pth resolves to the MAIN checkout's copy — green on main and CI, red in
every worktree (#546, proven there with a tests/__init__.py positive
control). importlib.util.spec_from_file_location on the sibling next to
__file__ reads the copy in THIS tree.

Part of #526

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_raise_if_daemon_error_object raises for EVERY error envelope, so the
`raise DaemonError(f"daemon error {code}: {message}")` two lines below it
in _call_daemon_tool could never run (Oracle PART 51). Deleted. The
classifier's message keeps the route — "daemon error <code> on /mcp
<tool>: <message>" — which names the failing tool; the old message did
not. The "daemon error" prefix _fail_daemon keys on is unchanged.

Part of #526

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
a5d8903

164 landed with #541, 165 with #542 and 166 with #543; --next-seq after
the final fetch says 167. The rebase also merged #543's `short=` and this
PR's `warnings=` on _deep_fetch_when_nothing_curated and its two call
sites — both kept. All four renderers; check-docs clean.

Part of #526

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants