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:248 — errors: 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:294 — report.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:
- 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.
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.
- 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.
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 paththe derive job is marked
succeeded. An operator is told the run worked whilearticles are missing the content they were supposed to receive.
Observed on a real
deriverun against a local model. One merge call exceeded thewrite-phase call timeout and exhausted its retries:
Nine documents' extracted content never reached
wiki/project/openclaw.md, whichstayed 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), whichhelps 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_jsonraising
openai.APITimeoutError, driven throughkb_ai.__main__.main()withargv=["kb-ai", "compile"]:No
SystemExit, process exit code 0, andwiki/concept/a.mdabsent from disk. Sook: trueis published alongside the per-article failures that contradict it.Where it goes quiet
compileandderiveboth run throughcompile_kb, which keeps its own flat errorlist:
py/src/kb_ai/commands/compile.py:248—errors: list[dict] = []py/src/kb_ai/commands/compile.py:505,:522,:549,:567,:587— a failedwrite appends
{file, error, article}py/src/kb_ai/commands/compile.py:682— the list is published in the resultpy/src/kb_ai/commands/compile.py:723— the command then callsrespond_ok(data=result)regardless of what that list sayspy/src/kb_ai/_protocol.py:21-26— so the envelope is{"ok": true}The exit code is not the right lever here:
kb-aideliberately communicates throughthe JSON envelope and always exits 0 (
py/src/kb_ai/__main__.py:88-89responds andthen exits 0 even for an unknown command, and
respond_errorat_protocol.py:29sets no exit code either). The defect is that
ok: trueis claimed while the payloadcarries 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:20rather than from
compile_kb:py/src/kb_ai/commands/pipeline/_phase_write.py:103and:252— an LLM failure isappended to
write_results[ch]["errors"](note:96is the pipeline-cancelledbranch, not an LLM failure)
:268-270,:291-293— the affected item'sstatusbecomes"error"py/src/kb_ai/commands/pipeline/_entry.py:133— the envelope is neverthelessrespond(True, ...)Worth fixing together, since a fix to only one leaves the other silent.
The Go side already receives the data
DeriveResponsehas no dedicated field for write errors, but it does not need one:py/src/kb_ai/derive/__init__.py:294—report.compileiscompile_kb's resultwith only
"cost"removed, so theerrorsarray is carried through verbatiminternal/bridge/api.go:181— it arrives asCompile json.RawMessageinternal/derive/runner.go:146-161— the job is markedstore.DerivedStatusSucceededwhenever the bridge call itself returned no error;nothing looks inside
CompileSo 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:
envelope — either
ok: falsewhen write errors exist, or a dedicated field thatcallers are required to inspect. Both
compile_kband the streaming pipeline needthis, not just one.
internal/derive/runner.goshould read theerrorsarray out ofDeriveResponse.Compileand mark the jobfailed— or a new partial state —instead of
succeeded.the operator's only signal is the
api_timeout_errorline 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:7states the intent as"failed writes don't crash the pipeline", which is reasonable; the gap is that not
crashing became not reporting.
Environment
kb-ai), macOS arm64, Python 3.13qwen3.8:27b-mlxserved over an OpenAI-compatible endpoint,KB_WORKERS=1specific to timeouts or to local models — a gateway 5xx on one merge behaves the
same way
What was corrected in this issue
_phase_write.pyas the path the failingderiveruntook, and
compile.py:723as its envelope. Those are two different code paths:_phase_write.pybelongs to the daemon's streaming pipeline (envelope_entry.py:133), whilecompile/deriveusecompile_kb's own inline write phaseand 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 server path. That was wrong —
DeriveResponse.Compilecarries theerrorsarray already. Only the dedicated field is absent, which makes this easier to fix
than first stated, not harder.
__main__.py:88was cited as the line that exits 0;:88is therespond_errorcall and
:89is thesys.exit(0)._phase_write.py:96was cited as an LLMfailure path; it is the pipeline-cancelled branch.