Skip to content

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

Open
rohanpoudel2 wants to merge 4 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 4 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.

@mldangelo-oai

Copy link
Copy Markdown
Collaborator

@codex review exact head fdc1be5

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fdc1be55f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread sdk/typescript/src/cost.ts Outdated
Comment thread sdk/typescript/src/cost.ts
@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: fdc1be55f4

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Two follow-ups to the quarantine change, both about usage the tracker
stops observing.

`stop()` swallows a failed final refresh so a completed turn is not
reported as a failure, but it then returned `#snapshot` purely because it
was non-null. When the last poll succeeded and the final refresh did not
- the scan's own rollout gaining an oversized event, or its permissions
changing as the turn completes - that snapshot predates the completed
turn, so the caller's authoritative `fallbackUsage` was discarded, no
fresh `onCost` fired, and `api.ts` recorded the stale below-limit cost
for a turn that had already passed `maxCostUsd`. `stop()` now tracks
whether this refresh succeeded and, when it did not, takes whichever of
the two charges more, so a stale poll can no longer hide spend and the
snapshot still wins whenever it counts delegated worker threads the
completed turn does not.

The open failure exit quarantined unconditionally, which is wrong for a
process-wide shortage: an EMFILE from momentary descriptor pressure
retires the session permanently, and every later refresh short-circuits
at the `session.unreadable` guard, so its usage is never observed again
even after descriptors free up. Quarantine is now limited to persistent
file-specific codes (EACCES, EPERM, EISDIR, ELOOP, ENAMETOOLONG,
ENOTDIR); anything else stays retryable. The root-owned rollout from
issue openai#223 is EACCES, so it is still reported once and then skipped.

Four tests in tests-ts/cost.test.ts: the stale snapshot losing to a
larger completed turn and winning against a smaller one, and an open
failure that recovers on a later poll for EMFILE while EACCES is never
reopened.
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

2 participants