Skip to content

fix(cost): quarantine unreadable session logs instead of failing every poll - #224

Open
rohanpoudel2 wants to merge 2 commits into
openai:mainfrom
rohanpoudel2:fix/cost-unreadable
Open

fix(cost): quarantine unreadable session logs instead of failing every poll#224
rohanpoudel2 wants to merge 2 commits into
openai:mainfrom
rohanpoudel2:fix/cost-unreadable

Conversation

@rohanpoudel2

Copy link
Copy Markdown

Fixes #223

Problem

readSessionUsage has two failure exits and only one of them records that the session is bad. The oversized-event exit sets session.unreadable = true before rethrowing, so the if (session.unreadable) return; guard skips that file on every later poll — one error, then the tracker moves on. The open() exit just rethrows, so the next poll reopens the same file and fails identically, for the life of the scan.

#readSessions amplifies it: a failure is only deferred to the included thread-tree filter when session.threadId !== null. An unopenable session never got a thread id, so if (session.threadId === null) throw error; fires and the error escapes the filter that exists precisely to ignore unrelated sessions. A root-owned rollout from one sudo codex run kills every subsequent scan.

The suite already pins the tolerant half — "ignores oversized events from unrelated prior credential sessions". This PR makes the unopenable case behave the same way.

Change

src/cost.ts, +11/−4. The three quarantine lines move into a quarantineSession(session) helper called from both exits, so an open failure is handled exactly like a parse failure. isMissingFile handling is untouched: a file that vanished between the directory walk and the open is still skipped silently, with no quarantine.

The stop() guard, and why tolerant is right

stop() now wraps await this.refresh() in try { … } catch {}. This deserves scrutiny because weakening cost handling could weaken --max-cost, so to be explicit about why it does not:

  • Enforcement is prospective and lives entirely in the polling path (start()refresh()onCost → abort). stop() runs after the turn is over. There is nothing left to abort, and rejecting cannot recover spend that already happened.
  • The error is still reported where it matters. Under a cost limit, polling already surfaced it through onError; swallowing it in stop() hides no first occurrence.
  • The alternative destroys authoritative data. fallbackUsage is the completed turn's own usage — better than the log-scraped snapshot, not worse. Rejecting discarded it and turned a successful scan into a failure.
  • Routing it through onError would have been self-defeating. In api.ts, onError aborts costAbortController and onFinalize calls throwIfAborted(signal, scanDir) immediately after stop(), so reporting from stop() would reconstruct the exact failure this fixes.
  • It matches existing intent. api.ts already does await costTracker?.stop().catch(() => null) on the failure path.

Precedence is unchanged — a real snapshot still wins over the fallback, and no max-of-the-two logic was added.

Verification

Two tests in tests-ts/cost.test.ts using the repository's testPosix pattern, each restoring 0o600 in a finally so afterEach teardown can still delete the fixture. The first is deliberately order-independent: whichever file readdir yields first, the first refresh() rejects and the second must succeed — that second call is the permanence bug.

Before:

EACCES: permission denied, open '.../sessions/2026/07/26/rollout-unrelated-thread.jsonl'
  at async readSessionUsage (src/cost.ts:207:18)
  at async #readSessions (src/cost.ts:132:15)
  at async refresh (src/cost.ts:95:11)
(fail) live scan cost tracking > keeps tracking after an unreadable unrelated session is reported
(fail) live scan cost tracking > falls back to the completed turn when session logs cannot be read
 13 pass / 2 fail

After: 15 pass / 0 fail.

The two halves are independently load-bearing: reverting only the stop() guard while keeping the quarantine gives 14 pass / 1 fail, with just the fallback test failing. So the quarantine alone fixes the permanence, and the stop() guard alone fixes the discarded fallback.

Full suite: 719 pass / 5 skip / 0 fail (717 baseline plus the 2 new tests). pnpm run types and pnpm run format are clean.

Deliberately out of scope

sessionFiles has the same shape at directory level — a non-ENOENT readdir error has no per-directory state to quarantine and would also repeat every poll. It is outside this trigger and needs new state rather than reuse of the existing flag, so I left it for a separate change. Noted in #223.

…y poll

`readSessionUsage` treated its two failure modes asymmetrically. An
oversized event marked the session `unreadable` before rethrowing, so the
next poll skipped that file and the failure surfaced once. A session file
that could not be opened at all — a non-ENOENT `open()` error such as
EACCES — was rethrown without marking anything, so every subsequent poll
reopened the same file and threw again, forever.

The permanence compounds in `#readSessions`, which only defers a failure
when the session's thread is already known. Because `open()` failed, the
thread id was never read, so the error bypasses the `included` thread-tree
filter and aborts the whole scan even when the file belongs to an
unrelated prior session. A root-owned rollout left behind by a single
`sudo codex` run is enough to trigger it: with a cost limit the poll's
`onError` aborts the scan almost immediately, and without one `stop()`
rejects and reports a scan that completed successfully as failed.

Quarantine unopenable sessions through the same path as oversized ones so
the failure is reported once and later polls keep tracking the sessions
they can read. `isMissingFile` handling is unchanged: a file that vanished
between the directory walk and the open is still skipped silently.

Also guard the `refresh()` inside `stop()`. `stop()` runs after the turn
is over, so it cannot abort anything and cannot under-enforce
`--max-cost`; enforcement happens in the polling path, which still reports
errors. Letting it reject only discarded the authoritative usage the
completed turn handed to it, which is exactly the fallback the existing
"falls back to the completed turn when session logs are unavailable" test
documents but which was unreachable whenever the sessions directory
existed and could not be scanned.
@github-actions github-actions Bot added the bug Something isn't working label Aug 3, 2026
@rohanpoudel2

Copy link
Copy Markdown
Author

Note on overlap: #177 and #198 also change src/cost.ts and tests-ts/cost.test.ts, but they address a different defect (coalescing overlapping cost refreshes, #31) in refresh/start, whereas this PR changes readSessionUsage's failure exits and the stop() fallback. I test-merged this branch onto both and each combination merges cleanly, so whichever lands first should not disturb the other.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An unreadable Codex session log fails every cost poll for the rest of the scan, and can fail a scan that succeeded

1 participant