Skip to content

Write-phase failures are recorded but never reported: compile answers ok:true and derive marks the job succeeded #57

Description

@lucasmaan

Corrected 2026-08-26. The first version of this issue cited the wrong code
path and claimed the Go side had no access to the error data. Both were wrong and
are fixed below; the behaviour being reported is unchanged and has since been
reproduced by construction. Details of what changed are at the bottom.

What happened

A write-phase failure is recorded per article and then dropped on the floor: the
compile still answers {"ok": true}, the process exits 0, and on the server path
the derive job is marked succeeded. An operator is told the run worked while
articles are missing the content they were supposed to receive.

Observed on a real derive run against a local model. One merge call exceeded the
write-phase call timeout and exhausted its retries:

api_timeout_error: op=merge model=qwen3.8:27b-mlx attempts=3 prompt_chars=68042 elapsed=930.0s

Nine documents' extracted content never reached wiki/project/openclaw.md, which
stayed in its pre-merge state (three of those documents ended up cited nowhere in
the KB at all). The run reported success, so nothing distinguished it from a clean
compile. The loss was only found by diffing article content afterwards.

The timeout itself is now configurable via KB_AI_WRITE_TIMEOUT_S (#58), which
helps an operator who already knows to set it. This issue is about the silence,
which is the part that turned one slow call into undetected data loss.

Reproduced by construction

With the extract and classify seams faked and mg.completion / mg.completion_json
raising openai.APITimeoutError, driven through kb_ai.__main__.main() with
argv=["kb-ai", "compile"]:

{"ok": true, "data": {"compiled": 0, "extracted": 2, "errors": [
  {"file": "raw/a.md", "error": "Request timed out.", "article": "wiki/concept/a.md"}, ...]}}

No SystemExit, process exit code 0, and wiki/concept/a.md absent from disk. So
ok: true is published alongside the per-article failures that contradict it.

Where it goes quiet

compile and derive both run through compile_kb, which keeps its own flat error
list:

  • py/src/kb_ai/commands/compile.py:248errors: list[dict] = []
  • py/src/kb_ai/commands/compile.py:505, :522, :549, :567, :587 — a failed
    write appends {file, error, article}
  • py/src/kb_ai/commands/compile.py:682 — the list is published in the result
  • py/src/kb_ai/commands/compile.py:723 — the command then calls
    respond_ok(data=result) regardless of what that list says
  • py/src/kb_ai/_protocol.py:21-26 — so the envelope is {"ok": true}

The exit code is not the right lever here: kb-ai deliberately communicates through
the JSON envelope and always exits 0 (py/src/kb_ai/__main__.py:88-89 responds and
then exits 0 even for an unknown command, and respond_error at _protocol.py:29
sets no exit code either). The defect is that ok: true is claimed while the payload
carries per-article errors.

The same shape exists a second time, in the daemon's streaming pipeline — a
separate code path, reached from py/src/kb_ai/commands/pipeline/_orchestrator.py:20
rather than from compile_kb:

  • py/src/kb_ai/commands/pipeline/_phase_write.py:103 and :252 — an LLM failure is
    appended to write_results[ch]["errors"] (note :96 is the pipeline-cancelled
    branch, not an LLM failure)
  • same file :268-270, :291-293 — the affected item's status becomes "error"
  • py/src/kb_ai/commands/pipeline/_entry.py:133 — the envelope is nevertheless
    respond(True, ...)

Worth fixing together, since a fix to only one leaves the other silent.

The Go side already receives the data

DeriveResponse has no dedicated field for write errors, but it does not need one:

  • py/src/kb_ai/derive/__init__.py:294report.compile is compile_kb's result
    with only "cost" removed, so the errors array is carried through verbatim
  • internal/bridge/api.go:181 — it arrives as Compile json.RawMessage
  • internal/derive/runner.go:146-161 — the job is marked
    store.DerivedStatusSucceeded whenever the bridge call itself returned no error;
    nothing looks inside Compile

So the server-side fix is to inspect a payload that is already there, not to plumb a
new field through the bridge.

Expected behavior

A run that failed to write content it was asked to write should be distinguishable
from one that did not, without diffing articles by hand. Concretely:

  1. The compile result should make the failure visible to a caller that only reads the
    envelope — either ok: false when write errors exist, or a dedicated field that
    callers are required to inspect. Both compile_kb and the streaming pipeline need
    this, not just one.
  2. internal/derive/runner.go should read the errors array out of
    DeriveResponse.Compile and mark the job failed — or a new partial state —
    instead of succeeded.
  3. The end-of-run summary should print the failed article paths to stderr. Right now
    the operator's only signal is the api_timeout_error line among the phase logs,
    which is easy to miss in a long run and says nothing about what content was lost.

Worth deciding as part of this: whether losing one article's merge should fail the
whole compile, or be reported as a partial success. The current behaviour is neither
— it is reported as a complete success. _phase_write.py:7 states the intent as
"failed writes don't crash the pipeline", which is reasonable; the gap is that not
crashing became not reporting.

Environment

  • Reproduced on a local compile through the CLI (kb-ai), macOS arm64, Python 3.13
  • Model: qwen3.8:27b-mlx served over an OpenAI-compatible endpoint, KB_WORKERS=1
  • Any LLM failure that is caught per article reaches the same path, so this is not
    specific to timeouts or to local models — a gateway 5xx on one merge behaves the
    same way

What was corrected in this issue

  • The original version cited _phase_write.py as the path the failing derive run
    took, and compile.py:723 as its envelope. Those are two different code paths:
    _phase_write.py belongs to the daemon's streaming pipeline (envelope
    _entry.py:133), while compile/derive use compile_kb's own inline write phase
    and error list. The line citations were accurate about their own files, but the
    chain they were spliced into was not. Both paths have the defect; they are now
    described separately.
  • The original version claimed the information "is not even available to act on" on
    the server path. That was wrong — DeriveResponse.Compile carries the errors
    array already. Only the dedicated field is absent, which makes this easier to fix
    than first stated, not harder.
  • __main__.py:88 was cited as the line that exits 0; :88 is the respond_error
    call and :89 is the sys.exit(0). _phase_write.py:96 was cited as an LLM
    failure path; it is the pipeline-cancelled branch.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions