Skip to content

[Bug]: management-triggered catalog convergence reports reason:"disk" with no disk failure and discards the original error #1784

Description

@brunoflma

Client or integration

Direct HTTP/API client

Area

Catalog / models

Summary

Catalog convergence triggered from the management API fails deterministically and reports reason: "disk" when there is no disk problem. The underlying exception is discarded, so the real cause cannot be recovered from outside — and, as far as I can tell, from inside either.

PUT /api/subagent-models returns 200 ok with:

{"status":"failed","reason":"disk","phase":"gather","retryable":false,"partialWrite":false}

There is no disk failure. The Codex catalog is 871 KB, parses, holds 37 entries, and the volume has 47 GB free. partialWrite:false says nothing was written, and ocx ensure writes the same catalog successfully seconds before and after.

Three things compound here, and I think the third is the actual bug:

  1. reason: "disk" is a hard-coded label, not an observation. unexpectedCatalogFailure() in src/codex/management-convergence.ts returns reason: "disk" unconditionally, and the wrapper in src/server/management-api.ts does the same in its own catch. Nothing in either path checked a disk.

  2. retryable: false is asserted, and is wrong at least sometimes. The identical code path, driven out-of-process against the same config, returns {"status":"committed","changed":true,"degraded":true,"notices":["fallback","provider-auth"]}. An operation that succeeds when invoked differently is not non-retryable.

  3. Three unrelated causes collapse into one indistinguishable disposition, and the original error is destroyed. All of these produce byte-identical {failed, disk, gather, false}:

    • the request-shape guard in createManagementConvergeCodex (request.scope !== "catalog"),
    • the admissionFailure() fallthrough, when a thrown message matches neither "config generation is busy" nor "config generation is database",
    • the catch in management-api.ts before convergenceInvoked is set.

    None of the three logs anything. service.log contains zero lines about these failures. admissionFailure() classifying by substring match on error.message means any new or reworded throw upstream of it silently becomes a non-retryable disk failure.

I could not determine which of the three fires here, or what the underlying exception is, despite reading the source and probing the path directly. That is the point of the report: with full source access and a deliberate effort, the diagnostic is unrecoverable. An operator sees "disk failure", checks a healthy disk, and has nowhere to go.

Suggested direction: preserve the original error (log it, or carry a cause/detail field on the disposition), and distinguish the three paths — a request-shape mismatch is a programming error, an admission refusal is contention, and a write failure is disk. Only the last one is "disk".

Reproduction

Verified on 2.20.0, deterministic, 3/3.

  1. Start the proxy normally: ocx restart && ocx ensure — note that ensure reports the catalog written successfully.
  2. Read the current subagent roster and write the same list back, so the request is a no-op in content:
node -e "const fs=require('fs'),h=require('http');const T=fs.readFileSync(process.env.USERPROFILE+'/.opencodex/admin-api-token','utf8').trim();const o={host:'127.0.0.1',port:10100,headers:{authorization:'Bearer '+T}};h.get({...o,path:'/api/subagent-models'},r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{const b=JSON.stringify({models:JSON.parse(d).chosen});const q=h.request({...o,path:'/api/subagent-models',method:'PUT',headers:{...o.headers,'content-type':'application/json','content-length':Buffer.byteLength(b)}},x=>{let e='';x.on('data',c=>e+=c);x.on('end',()=>console.log(x.statusCode,e))});q.write(b);q.end()})})"
  1. Observe catalogRefresh: {"status":"failed","reason":"disk","phase":"gather","retryable":false,"partialWrite":false}. Repeating the call reproduces it every time.

The control that makes this a defect rather than an environment problem — the same path, out-of-process, succeeds:

const { loadConfig } = await import("<pkg>/src/config.ts");
const { createCatalogConvergeRequest } = await import("<pkg>/src/codex/catalog-admission.ts");
const { createManagementConvergeCodex } = await import("<pkg>/src/codex/management-convergence.ts");
const config = loadConfig();
const outcome = await createManagementConvergeCodex(config)(createCatalogConvergeRequest({ deadlineMs: 1_000 }));
console.log(outcome.kind, JSON.stringify(outcome.catalogRefresh));

Output:

catalog-only {"status":"committed","changed":true,"degraded":true,"notices":["fallback","provider-auth"]}

Same machine, same config, same second. In-process it is failed/disk/gather 3/3; out-of-process it commits. Whatever differs is process state — which is exactly what the discarded exception would have named.

Expected: the response identifies the real cause, or at minimum the original error reaches the log so it can be identified.

Version

2.20.0

Operating system

Windows 11 Home Single Language 26200

Provider and model

Not provider-specific — the failure is in catalog convergence, before any provider is contacted.

Logs or error output

# three consecutive PUTs with identical bodies
tentativa 1: HTTP 200  catalogRefresh={"status":"failed","reason":"disk","phase":"gather","retryable":false,"partialWrite":false}
tentativa 2: HTTP 200  catalogRefresh={"status":"failed","reason":"disk","phase":"gather","retryable":false,"partialWrite":false}
tentativa 3: HTTP 200  catalogRefresh={"status":"failed","reason":"disk","phase":"gather","retryable":false,"partialWrite":false}

# the disk is fine
catalog: C:\Users\<user>\.codex\opencodex-catalog.json  871413 bytes, parses, 37 entries
volume: 465G total, 47G available

# service.log contains no line about any of these failures
$ grep -c "converge\|catalog.*fail" service.log
0

Redacted configuration

{
  "subagentModels": [
    "PROVIDER-A/model-a",
    "PROVIDER-B/model-b"
  ]
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcatalogModel catalog, slugs, visibility, routed entriesproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions