Troubleshooting
Each entry gives a symptom, the check that confirms its cause, and the fix. To follow one request through the ledger, query log and traces, see debug a request.
Start here
Three commands answer most questions before you read any log:
curl -s http://localhost:7777/v1/health | jq '{status, ch_ok, ch_error, schema, problems}'
docker exec loom-server loom doctor # the same problems, plus pre-boot checks
curl -s -XPOST http://localhost:7777/v1/ops.health -H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' -d '{"org":"<org>"}' | jq # ClickHouse parts, workers, stubs (ORG_ADMIN)
Every entry in problems carries a fix; read it first. On a client machine,
loom doctor --client checks the server URL and token the SDK and hooks resolve. What
each field means is in monitoring.
Server and ClickHouse
Every operation fails and /v1/health returns 503
- Cause: ClickHouse is unreachable from the server. The boot also stops at the first migration.
- Check:
curl -s localhost:7777/v1/health | jq .ch_error, thendocker exec loom-server loom doctor(theclickhouse_reachableline). - Fix: correct
LOOM_CH_HOST/LOOM_CH_PORT/LOOM_CH_USER/LOOM_CH_PASSWORD. Port 8443 implies TLS; setLOOM_CH_SECURE=truefor TLS on any other port. On ClickHouse Cloud, check that the service is running (idle services pause) and that its IP allow-list admits the Loom host.
loom up times out, or the container exits at boot
- Cause: usually a migration or ClickHouse error, a malformed
.envvalue, or a process outside the compose files withoutLOOM_INSTANCE_LABEL. - Check:
docker logs loom-server; the failing migration file or setting is named there. Validate the env file withdocker compose --env-file .env -f dev/docker-compose.cloud.yml config >/dev/null(use the compose file you boot). - Fix: the error in the log. A deployment (
LOOM_DEPLOYMENT=1) with auth on and the checked-in devLOOM_JWT_SECRETrefuses to start by design: set a real secret (deployment).
Writes fail with TOO_MANY_PARTS, or merges are failing
- Symptom: writes fail with ClickHouse error 252
TOO_MANY_PARTS, orops.healthreportsclickhouse_parts: fail. -
Check: whether merges are failing, which is almost always the cause, not the write rate. Read the merge errors in
system.part_logfirst:SELECT table, error, count() AS failures, any(exception) FROM system.part_log WHERE database = 'loom' AND event_type = 'MergeParts' AND error != 0 AND event_time > now() - INTERVAL 1 HOUR GROUP BY table, error;
-
Fix: if the exception names a skip index the server cannot merge, exclude that index from merge-time materialization. The setting is metadata-only and reversible; inserted parts still build the index:
ALTER TABLE loom.<table> MODIFY SETTING exclude_materialize_skip_indexes_on_merge = '<existing list>,<index name>';Part counts fall within minutes once merges succeed. Do not drop the index first (rebuilding it later rewrites the table), and do not only raise
parts_to_throw_insert: with merges stopped the count keeps growing. If no merge is failing, look for a writer inserting one row per statement (system.query_loggrouped bylog_comment, query attribution).
Writes take seconds after a ClickHouse Cloud replica restart
- Symptom: write-heavy operations (
memory.set,run.*,conversation.append) take seconds while reads stay fast, with no errors, often right after a deploy that is not the cause. -
Check: replica uptime, and system-log re-attachment in progress:
SELECT hostName(), uptime() FROM clusterAllReplicas(default, system.one); SELECT count() FROM system.query_log WHERE event_time > now() - INTERVAL 15 MINUTE AND query ILIKE 'ATTACH TABLE system.%';
- Fix: none needed on the Loom side. After a restart the service re-attaches its system tables for a while, and inserts recover as that finishes (typically within an hour). Do not roll back a deploy because of it; contact ClickHouse Cloud support if it persists.
Requests fail with BACKEND_TIMEOUT or OVERLOADED
- Cause: a ClickHouse statement ran past its class deadline (504
BACKEND_TIMEOUT), or every pooled ClickHouse connection of the process stayed busy (503OVERLOADED). Under a sustained write rate, batched inserts (audit rows, transcript turns, the agent_events flush) hold their connection for up toLOOM_CH_BATCHED_INSERT_WAIT_MAX_MSand then the flush, and background ones (the usage-event and write-mark flushes) for up toLOOM_CH_BATCHED_INSERT_BACKGROUND_WAIT_MAX_MSand the flush (batched inserts).OVERLOADEDcan also mean a statement found ClickHouse short of the server's or the ClickHouse user's memory (code 241, shared by every query; the server log hasClickHouse is short of memorywith ClickHouse's line, once per 5 s). Fromconversation.deleteorconversation.compact,OVERLOADEDcan also mean writes to the thread's embeddings kept landing for 8 reconcile rounds; resend once the thread's writes settle. - Check: the slowest sources in
system.query_logbylog_comment(monitoring);ops.healthfor part pressure; for memory, the statements ofsystem.query_logwithexception_code = 241and the largestmemory_usagebeside them, against the server'smax_server_memory_usageand the user'smax_memory_usage_for_user. - Fix: the query or worker that dominates; for memory, that statement, or more
memory for the ClickHouse service. A Loom far from its ClickHouse (a WAN)
needs
LOOM_LATENCY_BUDGET_MULTIPLIERnear 20 so its latency budgets do not fire on every call. ForOVERLOADEDunder a sustained write rate, size the pool for the connections batched inserts hold (batched inserts): raiseLOOM_CH_POOL_MAXSIZE(a restart), switch batching off for the switched group withloom settings set ch_batched_inserts false(LOOM_CH_BATCHED_INSERTS=falseat startup; the waited async inserts still wait), or lower the insert waits live withloom settings set ch_batched_insert_wait_max_ms <ms>(andch_batched_insert_background_wait_max_ms). Runtime settings covers live overrides. The errors reference lists every code.
Providers and search quality
Searches fail with 503 EMBED_TIMEOUT
- Cause: the embedding provider did not answer the query embedding within
LOOM_MEMORY_SEARCH_EMBED_BOUND_S(2.5 s) on both of its attempts by default (one retry,LOOM_MEMORY_SEARCH_EMBED_BOUND_RETRIES). A query whose vector is in the embedding cache still answers. - Check: the server log's
query embed exceeded …s (attempt n/m)warnings, andloom.agent_eventsrows witherror_code = 'EMBED_TIMEOUT'. A provider that hangs sets no health flag;/v1/healthshowsprovider_failing:<name>only when it also answers errors. Also check the provider's status page. - Fix: a raw HTTP or MCP caller retries once; an SDK caller has already retried, so it backs off. If it repeats, the provider is slow or down: wait it out, or raise the bound for a provider that is slow but answering. Each failed search holds the server for up to one bound per attempt.
Search returns arbitrary results, or extraction writes nothing
- Symptom:
set_from_messageswrites no facts,memory.askreturns empty answers, or search ranks by character overlap. - Cause: the process runs on a fake provider: configured as fake
(
LOOM_LLM_PROVIDER=fake, which every stock.envsets, orfake-ngram-*embeddings in a checkout's.envor an upgraded install), or configured as real without its key. - Check:
curl -s localhost:7777/v1/health | jq '.problems[].code'showsllm_key_missing,embedder_key_missing,fake_llmorfake_embedder(fake_llm: true,fake_embedding: truein the body). - Fix: set the key named in the problem's
fix(OPENAI_API_KEY,ANTHROPIC_API_KEY,GEMINI_API_KEY) and the provider block in.env, then recreate the containers (docker compose … up -d --force-recreate). Setting a key alone does not change a provider the.envpins tofake. On the local stack the vector width is fixed when the tables are created, so switching from the fake embedder needsmake down-all && make dev. Rows embedded by the fake embedder needloom reembed. SetLOOM_REQUIRE_REAL_PROVIDERS=trueso this readsstatus: degradednext time.
Search quality dropped after an embedder change
- Cause: a new embedder moves every similarity score. Rows still embedded by the old model sit in a different vector space.
- Check:
loom reembed --dry-runcounts off-model rows;loom.search.top_similarityshows the shift. - Fix:
loom reembed(maintenance), then re-baseline your alert onloom.search.top_similarity. WithLOOM_ADAPTIVE_THRESHOLDSon (the default) the similarity gates follow the new embedder after the boot probe;loom recalibratereruns it immediately.
A write is missing from search, or was not stored
- Check:
loom drops --namespace NSfor what the ingest pipeline discarded or truncated, and the write's own response (status, conflict verdicts,pipeline_errors).loom.embed.pendingcounts rows still waiting for a vector. - Fix: follow the drop reason (maintenance); for a conflicted write, resolve it in the dashboard's Review queue (conflicts and refusal).
Auth and tokens
Requests fail with 401 AUTH_INVALID or 503 AUTH_UNAVAILABLE
- Symptom: 401
AUTH_INVALID, or 503AUTH_UNAVAILABLEwithRetry-After: 5. - Check: the
messagein the error detail (expired, bad signature, revoked, missing bearer). On a client machine,loom doctor --clienttests the token the hooks and the SDK would send. - Fix:
- A bad signature means the token was minted with a different
LOOM_JWT_SECRET(secret rotated, or minted against another deployment): re-mint withdocker exec loom-server loom mint-token …. - An expired or revoked token needs a new one;
loom revoke-token --listshows the revocations. AUTH_UNAVAILABLEright after boot means the revocation list has not loaded yet (revocation_not_loaded); the server refuses tokens until it has, so retry after a few seconds and check ClickHouse if it persists.- A stale
LOOM_API_KEYin the environment overrides~/.loom/tokenfor the SDK and the plugins. - After turning auth on for the first time, every call needs a token: mint one as above.
- A bad signature means the token was minted with a different
An id you know exists reads as missing
- Cause: an op addressed only by an org-wide id, such as
memory.get_by_id, answers as for a missing row rather than a 403 that would confirm the id exists. The usual cause is a token without the op's role on the row's namespace. The ops that resolve the id before the read also answer so when the caller reaches more than one namespace holding the id, as after anamespace.restore. Row-scoped operations has the rule and its exceptions. - Fix: use a token with the op's role on the row's namespace
(tokens and grants); for an id in several
namespaces, address the row by key in its namespace (
memory.get, ormemory.updatefor a write).
Schema and upgrades
Migrations pending or checksum drift
- Symptom:
/v1/healthreportspending_migrations, orloom migrate --dry-run --strictexits 1 (it does so on ledger-only rows or checksum drift, not on pending files alone), orredeploy.sh's preflight stops. - Check:
loom migrate --dry-run --strictprints pending files, thenledger-onlyrows (ledger filenames the repository no longer has) andchecksum drift(applied files whose text no longer matches the recorded SHA-256). -
Fix:
- Pending files:
loom migrate, or restart withLOOM_BOOT_MIGRATE=true. -
Checksum drift or a ledger-only row means a migration file was changed (or renamed and changed) after some server applied it, usually a server run from an unmerged branch. Compare the applied DDL with the repository file. If the effect is identical, record the current file (the newest row wins):
INSERT INTO loom._migrations (filename, checksum, applied_by) VALUES ('<file>', '<sha256 of the file>', 'operator');A ledger-only row whose DDL exists under the new name can be removed with
DELETE FROM loom._migrations WHERE filename = '<old name>'.
- If the effect differs, write a new migration that reconciles the schema; never edit an applied file (migrations).
- A boot that fails on a table "with rows in the old layout" needs
scripts/ops/table_rebuild.pyfor that table first (upgrades).
- Pending files:
Requests fail with 503 SCHEMA_PENDING
- Cause: the statement names a table that a migration this build carries would
create, and the service has not applied it (a process booted with
LOOM_BOOT_MIGRATE=false, or a migration failed). - Fix: as for pending migrations above.
Cells
A process with LOOM_CELL unset is gated
- Symptom: every request but the health probes answers 503,
/v1/health/readyfails itscellcheck, no worker runs, and the reason says the deployment "is a cell in enforced mode and LOOM_CELL is unset", "holds a partial cell state (... present)" or that its "cell state is not read yet". - Cause: under the Postgres authority every server process reads the deployment's
cell state (
loom.cell_first,loom.tenancy_mode,loom.cell_self,loom.tenancy). WithLOOM_CELLunset it serves only while none of them holds a row, or while they hold a first cell's full state inrecording(cells). -
Check: whether the router's directory holds a row for this deployment. Skip this on a deployment with no router. Start in a psql session on
LOOM_PG_DSN:SELECT deployment_id AS id FROM loom.authority \gset \connect '<router DSN>' SELECT cell, is_first, state, placed FROM loom_router.cells WHERE deployment_id = :'id';
- Fix, for "not read yet": the process booted with Postgres migrations pending
(
LOOM_BOOT_MIGRATEoff) or has not reached Postgres since; apply the migrations (/v1/healthnames them) or restore the connection, and the next read, within a second, opens or gates it as below. -
Fix otherwise, by what the check shows:
- The deployment is a cell (the directory holds its row and it is not an
unattached later cell, below): set
LOOM_CELLto the cell's name; for a partial state, runloom cell prepareagain, which completes it. -
The deployment is not meant to be a cell (no directory row, or no router), or an unattached later cell (its row reads
is_first = false,state = 'attaching'andplaced = 0) that you are turning back into a single deployment: clear the state in one transaction, in a psql session onLOOM_PG_DSN(\connectback to it after the check) as the role that owns schemaloom's tables (the role that migrates; altering a trigger needs the table's owner). The deployment is thenopen. An unattached later cell's directory row stays live; making the deployment a cell again needs that row removed and a new prepare. On a lock failure,ROLLBACKif psql is still open, then rerun the whole step.\set ON_ERROR_STOP on BEGIN; SET LOCAL lock_timeout = '100ms'; SELECT pg_advisory_xact_lock(7813586394288842610); -- the migration lock ALTER TABLE loom.cell_first DISABLE TRIGGER cell_first_immutable; DELETE FROM loom.cell_first; DELETE FROM loom.tenancy_mode; DELETE FROM loom.cell_self; DELETE FROM loom.tenancy; ALTER TABLE loom.cell_first ENABLE ALWAYS TRIGGER cell_first_immutable; ALTER TABLE loom.cell_first ENABLE ALWAYS TRIGGER cell_first_no_truncate; COMMIT;Then drop the org triggers, in the same session and as the same role, after the state transaction (so a migrating boot's
ensuredoes not create them again); on a lock failure,ROLLBACKif psql is still open, then rerun the step:\set ON_ERROR_STOP on BEGIN; SET LOCAL lock_timeout = '100ms'; SELECT pg_advisory_xact_lock(7813586394288842610); -- the migration lock SELECT format('DROP TRIGGER %I ON loom.%I', t.tgname, c.relname) FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'loom' AND t.tgname IN ('tenancy_insert', 'tenancy_update') \gexec COMMIT;
- A restored copy of a cell's Postgres on another deployment carries the source cell's deployment id, so the check returns the live source cell's row. Do not clear it, and do not serve it beside the source.
- The deployment is a cell (the directory holds its row and it is not an
unattached later cell, below): set
A boot logs "cell triggers: not applied"
- Symptom: a boot of a cell in any state but
openlogscell triggers: not applied on loom.<table> (...), andloom doctornames the table's missing or disabled trigger (loom.cell_first's in any such state, an org table's inenforced). - Cause: the boot creates a cell's org triggers, and sets
loom.cell_first's back to always, with a lock wait of 100 ms a try, three tries a second apart, and serves past a table in two cases. The log names the sessions that held or queued a conflicting lock when the table was still busy: a long transaction or a bulk write on it, or an autovacuum, which the short tries do not make yield and which is left to finish (a wait long enough to cancel it would queue every writer of the table behind the boot). It saysthis role cannot alter itwhen the migrating role does not own the table, whichloom doctornames as anownerdefect. Until a later boot finishes the table, a writer that runs no tenancy check is not refused there; Loom's own writes are unaffected. - Fix: for a busy table, end the transaction the log names if it is stale, or wait for
the autovacuum to finish (
pg_stat_progress_vacuum); for a table the role cannot alter, run the boot as the role that owns schemaloom's tables, or give the table to that role withALTER TABLE loom.<table> OWNER TO <role>. Then restart one pod withLOOM_BOOT_MIGRATEon, and repeat untilloom doctornames no missing or disabled trigger.
Writes are slow, or a script is refused, on a deployment that is not a cell
- Symptom: on a deployment whose cell state is
open(every process serves), Loom's own writes, and any script's or bulk loader's, take longer per row, or a role withoutSELECTonloom.cell_firstgets "permission denied for table cell_first". - Cause: the org triggers are still on its tables: a schema-only restore of a
prepared cell, or a cleanup that skipped the step that drops them. In
openthey let every row through, but each unmarked write still reads the state. -
Check:
SELECT c.relname, t.tgname FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'loom' AND t.tgname IN ('tenancy_insert', 'tenancy_update');
- Fix: run the drop step of
A process with LOOM_CELL unset is gated
as the role that owns schema
loom's tables.
Workers
A worker fails every pass
- Symptom:
loom.worker.consecutive_failuresstays at 3 or more; the workers log repeats<name>: pass failedorpass exceeded its deadline, thenDEGRADED. - Check:
GET /v1/admin/workers.statusfor the worker's last error, anddocker logs loom-workers 2>&1 | grep -E '<name>: (pass|DEGRADED)'for the traceback. - Fix: follow the exception. Repeated deadline cancellations mean the pass is too large for its interval (a big backlog, a slow provider). A provider error usually means a missing key or quota. For an LLM worker on the fake provider, disable the worker or configure a real one. To stop one worker while you investigate, set its flag to false and recreate the workers container; the others keep running.
No worker runs at all
- Check:
/v1/health?include=workers→workers_leaderon each process; one must hold the lease.workers_lease_absentat warn means no process holds a live one. Aworkers_lease_unreachableproblem names why a process could not ask ClickHouse;workers_lease_given_upthat its workers child kept failing to start. - Fix: run every replica with
LOOM_BACKGROUND_WORKERS=autoandLOOM_DEPLOYMENT=1; a process withoffnever runs them, and one outside the deployment runs none underauto. Setononly on a ClickHouse no deployment uses: it runs its own copy and takes no lease. During an upgrade an older build leaves the lease to a live newer one.
Host
The host or ClickHouse runs out of disk
- Symptom: the Loom host's disk fills (containers fail to write, image builds fail), or ClickHouse reports low free space.
- Check: on the host,
docker system dfand the size ofvar/code_repos; in ClickHouse, the per-table sizes andsystem.disksqueries in maintenance. When the disk is full, write diagnostic output to/dev/shm. - Fix: cap Docker's JSON logs, remove build cache and dangling images
(
docker builder prune,docker image prune). Do not prune stopped containers if a stopped container is your rollback. In ClickHouse, let merges collapse versions after large erasures, compact the code vector tables withloom code compact --apply, or opt into per-table retention.
Local stack
- The dashboard returns 503. The server runs from source (
make server) without a built frontend. Runmake frontend-build;/v1/admin/*answers either way. - The stack ignores your
.env. Adocker composecommand without--env-file .envboots the compose file's fallbacks (auth on, an OpenAI provider with no key). Usemake devor pass the flag. - A server started from source serves other code. See local development.
Clients, plugins and MCP
- The coding-agent plugins have their own symptom tables (a hook that finds no
Node, no primer, captures not arriving, tools listed twice):
Claude Code and
Codex. The plugins'
loom-statusskill runs the client check without a Loom checkout. - The ledger has no row for the request. The request never arrived.
loom doctor --clientnames the server URL and token the SDK and hooks resolve, and where each came from; a staleLOOM_API_KEYorLOOM_BASE_URLin the environment wins over~/.loom/.GET /v1/whoamiwith the token shows the subject, org, namespaces and roles the server verified. A 403 on a namespace the token does not name, orIDENTITY_INCOMPLETEfrom a scoped token with no namespace in the body, is authorization, not a bug (tokens and grants). Strandedloom-flush-*.jsonfiles in the temp directory are plugin captures that never reached the server (headless bots). - An MCP tool is missing from the listing. Listings vary by the MCP URL's
?toolset=, never by caller. Underall, an experimental op that neither the core nor the atomic roster lists is left out of the listing; it is still callable by name or throughloom_op. Thecode.*tools appear on the default toolset only withLOOM_MCP_CODE_TOOLS=true(MCP toolsets). - An MCP call returns
isError: true. A refusal other than 401 (LOCKED,PINNED_CONFLICT,BLACKLISTED,RATE_LIMITED,UNKNOWN_ARGUMENT, …) is a tool result withisErrorand the HTTP error detail instructuredContent; a 401 or a 5xx is a JSON-RPC error. Match oncode. - A field you sent was ignored. The response names it in
ignored_args, withdid_you_meanwhen a known field is close; a write operation refuses a close misspelling with 422UNKNOWN_ARGUMENT(unknown and removed fields). - 429
RATE_LIMITED. See the buckets in monitoring;Retry-Aftersays when.
Filing a bug
loom report prints the bug form's Environment field with the loom doctor diagnostic
(presence booleans only, never secret values); --open opens the bug form with the
versions filled in (filing an issue).