Skip to content

fix(claude-status): stop showing an outdated status as current - #772

Merged
sirmalloc merged 6 commits into
sirmalloc:mainfrom
eric-engberg:fix/claude-status-cache
Oct 30, 2026
Merged

sirmalloc merged 6 commits into
sirmalloc:mainfrom
eric-engberg:fix/claude-status-cache

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

Two fixes to how Claude Status decides whether its cached status is still current:

  • A cache, failure lock or in-flight fetch claim dated ahead of the clock no longer counts as current. A timestamp may sit up to one window ahead (five minutes for the cache, 30 seconds for the locks) to allow for clocks a little apart. Further ahead, it was written under a clock that has since been set back, so the widget fetches again.
  • While status.claude.com can't be reached, the widget shows its last status only while it is under an hour old. After that it shows ?, as it already does when it has no status at all. A cache dated more than an hour ahead isn't served either, since its age can't be known.

Why

Clock set back. The cache counted as fresh while now - fetchedAt < 300s, and the lock as active while now - mtime < 30s. Neither check had a lower bound, so any timestamp in the future passed both. This happens when NTP corrects a clock that ran fast, as after a VM resume, or on a dual-boot machine that keeps the hardware clock in local time. After such a correction, the widget kept showing the cached status and made no request until real time caught up. Reproduced through the piped CLI on main, with the status page answered in-process and reporting major:

  • a cache saying ok dated 2 h ahead: Claude: ok, 0 requests, for the next 2 h 5 min
  • a 10-minute-old cache plus a failure lock dated 2 h ahead: Claude: ok, 0 requests

The docs say responses are "cached for about five minutes". The usage cache already treats files dated more than one lifetime ahead as stale.

Page unreachable. The fallback never looked at the cache's age. Take a cache from two days ago saying major, with a critical incident still open, and fail every request. The built CLI on main printed Claude: major ▮▮▮▮▮▮▮▮ on every render, under both Bun and Node. With history on, all eight six-hour blocks were red, because an open incident counts as running until now and so spread into every block nobody had observed. A 30-day-old cache printed the same. Only having no cache at all gave ?.

How

  • A small isWithinAge(stamp, now, maxAge) helper (|now - stamp| < maxAge) replaces the one-sided checks: memory cache, disk cache and failure lock, plus the two that [low] perf(claude-status): hold a lock while the status page fetch is in flight #635's in-flight fetch lock added (the cache recheck once the claim is held, and the age of another process's claim). A failed fetch rewrites the lock with the current time, so a lock dated in the future heals on the next render. A claim dated more than 30 seconds ahead is taken over like an expired one.
  • serveStaleOrError, the fallback used when a fetch fails or the failure backoff holds off fetching, now also requires the cache's timestamp to lie within STALE_MAX_AGE (one hour) of now, through the same helper.
    • An hour rides out a dropped connection or a short offline stretch (twelve refresh intervals), while a status that old says little about now.
    • Staying under one six-hour history block keeps the strip honest without touching the bucket math. Within the hour, the unobserved part of an open incident only reaches into the newest block, which the part already observed overlaps. Across 467,280 combinations of cache age (up to an hour either side of now), incident start (up to 54 h before the fetch) and impact, the blocks were identical whether the incident ended now or when last seen.
  • docs/USAGE.md now says the widget shows the last fetched status while it is under an hour old, and ? otherwise. docs/DEVELOPMENT.md says the stale cache is served while it is under an hour old.

Left as is:

  • A status served from the fallback looks the same as a fresh one. Nothing marks it as up to an hour old.

Demo

These are piped renders through the real CLI, each starting from a seeded cache or from no cache. status.claude.com is answered in-process (reporting major, or failing every request), so nothing reaches the network. Each line shows how many requests the render made.

Cache or lock dated ahead of the clock

The page reports major, and each render starts from a cache saying ok: fetched 2 minutes ago, dated 2 h ahead, and fetched 10 minutes ago with a failure lock dated 2 h ahead. Before: all three show Claude: ok with 0 requests. After: the fresh cache still shows ok with no request; both future-dated cases make 1 request and show Claude: major.

Powerline

Before:

Before: caches and locks dated ahead keep "ok" with no request, in Powerline mode

After:

After: caches and locks dated ahead fetch "major", in Powerline mode

Plain

Before:

Before: caches and locks dated ahead keep "ok" with no request, in plain mode

After:

After: caches and locks dated ahead fetch "major", in plain mode

Status page unreachable

History strip on. No cache, then a cache saying major with a critical incident open for an hour, fetched 30 minutes ago and 2 days ago. Before: the 2-day-old cache shows Claude: major with all eight blocks red. After: it shows Claude: ?, like no cache, while the 30-minute-old one is still served with only the newest block red.

Powerline

Before:

Before: a two-day-old cache still shows "major" with an all-red strip, in Powerline mode

After:

After: a two-day-old cache shows "?", while a 30-minute-old one is still served, in Powerline mode

Plain

Before:

Before: a two-day-old cache still shows "major" with an all-red strip, in plain mode

After:

After: a two-day-old cache shows "?", while a 30-minute-old one is still served, in plain mode

Testing

  • On main as of [low] perf(claude-status): hold a lock while the status page fetch is in flight #635 and [medium] perf(git-review): key the PR cache through the cached runGit #639 (62a371b): merges cleanly, bun run lint is clean, and bun test gives 3717 pass, 0 fail. The built CLI under Bun 1.4.4 and Node 26.13.0 matches main in plain and Powerline mode with the baseline configs, and the TUI opens and exits cleanly.
  • After [low] perf(claude-status): hold a lock while the status page fetch is in flight #635 (the in-flight fetch lock) merged: bun run lint clean; bun test 3717 pass, 0 fail across 196 files. Without the last commit, 2 of the earlier tests fail, because the recheck under the claim served a cache dated 2 h ahead. Three new tests cover the claim (10 s old, a few seconds ahead, 2 h ahead); without the claim's change, the 2 h case fails.
  • bun run lint: clean.
  • bun test: 3179 pass, 0 fail across 179 files (main: 3168 pass, 0 fail across 178). The 11 new tests are in src/utils/__tests__/claude-service-status-cache.test.ts. Like usage-fetch.test.ts, they run the module in a child process with a scratch HOME and a stubbed https.request, because the module resolves its cache paths when it loads.
    • Against main's code, 6 of the 11 fail. The other 5 are controls: a fresh cache, a cache or lock a few seconds ahead, a 10-second-old lock, and a 50-minute-old cache served while the page is down.
    • With only the first commit, the 4 tests that expect ? from the unreachable page still fail.
  • Vitest under Node 26.10.0 on the new file plus claude-service-status.test.ts, ClaudeStatus.test.ts and usage-fetch.test.ts: 52 failed, 71 passed (main, without the new file: 41 failed, 71 passed). The 48 tests in claude-service-status.test.ts and ClaudeStatus.test.ts pass on both. Every failure is ERR_MODULE_NOT_FOUND from a child-process probe that loads the TypeScript sources, whose extensionless imports Node can't resolve: the new file's 11, and usage-fetch.test.ts's existing 41.
  • Built CLI under Bun 1.4.2 and Node 26.10.0: with the baseline configs, output matches main in both render modes, and the TUI opens and exits cleanly. With Claude Status configs and seeded caches, both runtimes produce the same output:
    • A 2-day-old major cache with the page unreachable prints Claude: ? (on main: Claude: major ▮▮▮▮▮▮▮▮).
    • A cache dated 2 h ahead prints Claude: ? after a failed fetch, and again on the next render under the failure backoff (the failed fetch is unit-tested in claude-service-status-cache.test.ts; both renders were checked by hand under Bun and Node). On main it prints Claude: ok, with no request made.

Claude Status counted its cache as fresh while `now - fetchedAt` was
under five minutes, and its failure lock as active while `now - mtime`
was under 30 seconds. Neither check had a lower bound, so a timestamp
ahead of the clock always passed. After a clock that ran fast is set
back (an NTP correction after a VM resume, or a dual-boot machine that
keeps the hardware clock in local time), the widget kept showing the
cached status, "ok" through a major incident for instance, and fetched
nothing until real time caught up with the stamp: for as long as the
clock had been ahead. A lock written in that window held off every
fetch just as long.

Both checks now follow the rule the usage cache already uses: a stamp
counts while it lies within the window on either side of now. One a
little ahead still counts, since clocks sharing a home directory can
sit slightly apart. One further ahead than the window was written
under a clock since set back, so the cache is refetched and the lock
ignored; a failed fetch rewrites the lock with the current time, so it
heals on the next render.
While status.claude.com can't be reached (offline, a network that
blocks it, or the page itself failing), Claude Status falls back to its
cached status. It never checked how old that cache was, so the last
status stayed on screen as current for as long as fetches kept
failing: days later, a laptop that fetched during an incident still
showed "Claude: major". With the history strip on, an incident still
open in that cache counts as running until now, so it spread into a
new six-hour block every six hours, and two days on all eight blocks
showed red for hours nobody had seen.

The fallback now serves the cache only while it is under an hour old,
and after that shows `?`, the widget's existing state for status it
doesn't have. An hour rides out a dropped connection or a short
offline stretch, twelve refresh intervals, while a status that old
says little about now. It also keeps the strip honest: within the
hour, an open incident reaches only into the newest block, which the
part already seen overlaps, so it colors the same blocks it would had
it ended when last seen.
# Conflicts:
#	docs/DEVELOPMENT.md
# Conflicts:
#	src/utils/claude-service-status.ts
…h claim

The in-flight fetch lock rechecks the cache once it holds the claim, and
judges another process's claim by its age. Both compared ages directly, so a
cache or claim dated hours ahead by a clock since set back counted as current:
the recheck served a far-future cache without fetching, and a far-future claim
left behind by a stopped process held off every fetch until real time caught
up. Both now use the same window as the cache and failure lock checks.
@sirmalloc
sirmalloc merged commit 59b1bd7 into sirmalloc:main Oct 30, 2026
3 checks passed
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Nov 2, 2026
Upstream: weekly limit timers (sirmalloc#768), prompt cache miss widgets (sirmalloc#770), context window size env override (sirmalloc#352, sirmalloc#407), windowed speed fixes (sirmalloc#769, sirmalloc#771), separator/renderer fixes (sirmalloc#773), custom-command styling (sirmalloc#775), clickable cwd links (sirmalloc#744), account email from live credentials (sirmalloc#724), cache timer reported expiry + short toggle (sirmalloc#576, sirmalloc#592), claude-status lock (sirmalloc#635, sirmalloc#772), dedup token metrics (sirmalloc#622, sirmalloc#725, sirmalloc#735).
Not adopted: sirmalloc#765 terminal width from COLUMNS with fixed reserve - the fork keeps flexMode/compactThreshold and tmux pane priority.

Hand edits outside conflicts:
- README.md: kept fork Configure flex separator behavior bullet and older-updates details wrapper; dropped upstream Related Projects additions (claude-eta)
- AGENTS.md: kept fork terminal width wording (sirmalloc#765 not adopted)
- src/types/FlexMode.ts, src/tui/components/TerminalWidthMenu.tsx, src/tui/components/__tests__/TerminalWidthMenu.test.ts, src/tui/components/TerminalOptionsMenu.tsx, src/tui/components/__tests__/TerminalOptionsMenu.test.ts: restored, fork keeps terminal width settings
- src/tui/App.tsx, src/tui/components/index.ts, src/types/index.ts: re-wired terminal width menu and FlexMode export
- src/types/StatusJSON.ts: dropped fork duplicate prompt_cache key, upstream schema is a superset
- src/utils/terminal.ts: COLUMNS source gated on non-tmux so tmux pane width keeps priority
- src/widgets/WeeklyLimitTimer.ts, src/utils/usage-prefetch.ts, src/widgets/__tests__/WeeklyLimitTimer.test.ts: compat fix - fableUsage/fableResetAt -> weeklyFableUsage/weeklyFableResetAt
- src/widgets/ApiUsage.tsx: compat fix - Context Bar honors effective context window (override, clamp, 1.0M format); Weekly renders a valid value despite a session error
- src/widgets/__tests__/ApiUsage.localModelFallback.test.ts: output tokens no longer counted in context (upstream sirmalloc#730)
- src/utils/__tests__/renderer-flex-width.test.ts, src/utils/__tests__/renderer-ansi.test.ts, src/utils/__tests__/gradient.test.ts, src/utils/__tests__/renderer-separator-collapse.test.ts, src/utils/__tests__/config.test.ts, src/tui/__tests__/App.test.ts: fork deviation - fork width model (flexMode, 6-column full reserve, kept settings, Terminal Width menu entry)
- src/utils/__tests__/default-colors.test.ts: fork deviation - accept ansi256:/hex: defaults
- src/utils/__tests__/usage-prefetch.test.ts: fork deviation - weekly usage comes from the API, fork widget imports from ApiUsage
- src/widgets/__tests__/CurrentWorkingDir.test.ts, src/widgets/__tests__/CustomCommand.test.ts: Settings literal gains flexMode/compactThreshold
- src/utils/__tests__/context-window.test.ts, src/utils/__tests__/jsonl-metrics.test.ts: restored fork tests upstream dropped
- docs/test-retirements.md: ledger for the dev-reload snapshot-path and RemoteControlStatus glyph (sirmalloc#605) tests upstream replaced
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants