Skip to content

[Bug]: Headless orca serve never starts usage polling, so every Claude account row stays blank until a desktop window opens #37256

Open

Description

@nachumi-a2zoperations

Operating system: macOS 26 (Apple Silicon). The gate is platform-independent, see "Where it breaks".
Orca version: 1.4.244; verified unchanged on main at 1c40f5404db (2026-11-01).

Details

Summary

On a supervised headless host (orca serve --supervise, packaged macOS, Electron serve runtime) the rate-limit service is never started. orca account list prints weekly — · fable — for every managed Claude account, including the active one, for the whole lifetime of the process. The rows only fill when (a) a Claude session happens to run, which fills the active row through the statusline, or (b) a paired desktop promotes the host and opens a window. Saved accounts never fill on a headless host.

Paired phones and web clients read these rows to decide where to launch or move the next session, so on a headless host they are flying blind.

Related, but not this

Steps to reproduce

  1. On a Mac with several managed Claude accounts, run the supervised serve. launchd runs orca serve --supervise --no-pairing --port 6768; the supervisor spawns Orca.app/Contents/MacOS/Orca --serve --serve-port 6768. The log confirms [serve] using Electron serve: packaged macOS serve stays on Electron so paired clients can still update it.
  2. Do not open the desktop window.
  3. Wait longer than the 15-minute poll interval.
  4. Run orca account list (or --json) against the host.

Observed: every row is weekly — · fable —; rateLimits.claude is null. No request to the usage endpoint is ever made. There is no log line either, so from the outside it looks like the accounts simply have no data.

Expected: the host polls usage for the active account and for the saved accounts the same way the desktop does.

Where it breaks (read on main at 1c40f5404db)

Two gates stack. Either one alone keeps the host blank.

 desktop launch                                   headless serve launch
 ──────────────                                   ─────────────────────
 openMainWindow()                                 launchServeMode()
   └─ attachMainWindowCoreServices()                └─ (never calls openMainWindow)
        ├─ rateLimits.attach(window)   ◄── gate 1 ──  rateLimits never constructed/started
        └─ rateLimits.start({fetchImmediately:false})

 every 15 min, and on window focus:
   shouldBackgroundPoll()
     mainWindow null/destroyed?   → false   ◄── gate 2 ── a headless process can never pass this
     !visible || minimized?       → false
     return mainWindow.isFocused()
  • Gate 1. rateLimits.attach(window) and rateLimits.start() are called only in attachMainWindowCoreServices (src/main/startup/main-window-core-services.ts:163-165), which is reached only from openMainWindow (src/main/startup/main-window-controller.ts). launchServeMode in src/main/startup/main-process-runtime-launch.ts never opens a window, so on the serve path the service is never started.
  • Gate 2. Even if it were started, shouldBackgroundPoll() (src/main/rate-limits/service/service-polling.ts:56-65) returns false whenever mainWindow is null, not visible, minimized, or not focused. Both the interval tick (line 27) and refreshIfWindowActive() (line 178) return early on it. The comment there says the gate exists to save API budget while the UI is inactive, which is right for the desktop and wrong for a host whose only UI is remote.

Suggested fix

We run this in a local build and are happy to open a PR.

sequenceDiagram
  participant S as launchServeMode
  participant R as RateLimitService
  participant U as usage endpoint
  S->>R: start({fetchImmediately:true, pollWithoutWindow:true, pollInactiveClaude:true})
  S->>S: log "[claude-usage] polling switch: mode=serve pollInactive=true pollWithoutWindow=true"
  R->>U: active account usage (no session needed)
  R->>U: saved accounts, in one cycle
  Note over R: every 15 min, no window required
  R->>R: skip? log "[claude-usage] inactive fetch skipped reason=<...>"
Loading
  1. Arm from the serve path. In launchServeMode, after the browser-identity line and before printServeReady, start the service with explicit options such as { fetchImmediately: true, pollWithoutWindow: true, pollInactiveClaude: true }.
  2. Let the gate honour the option. shouldBackgroundPoll() returns true when pollWithoutWindow is set and there is no window. The desktop keeps today's focus gate, so the budget-saving behaviour there does not change.
  3. Do not disarm on a late window. If a desktop promotion opens a window on an already-armed host, the window path must not call start({ fetchImmediately: false }) again, since that would clear the serve flags. Only the subscription that call installed is removed on window closed.
  4. Stop on a committed quit, leave running on a vetoed before-quit (main-process-quit.ts).
  5. Log the decision once, so the state is visible in the field without a debugger:
    • [claude-usage] polling switch: mode=<desktop|serve> pollInactive=<bool> pollWithoutWindow=<bool> at launch, including when the switch is off.
    • [claude-usage] inactive fetch skipped reason=<debounce|in-flight|no-accounts|not-attached|live-session|retry-after|already-read> when a cycle fetches nothing.
  6. Fill the active row too. account list reads the selected host account from state.claude, which today is only filled by a live statusline or the active OAuth cycle. The same poll should fetch the selected account onto state.claude when no live reading is fresh, so the active row shows numbers with no session open.

Result on the same host with the fix

Tests we added (for the PR)

  • initializeMainProcessReady with serve options and openMainWindow throwing: no window, the inactive fetch runs once at start and again after the poll interval, the log line says mode=serve pollInactive=true pollWithoutWindow=true.
  • Serve logs the true switch, desktop logs the false switch, a later window attach leaves an armed poll in place.
  • Selected account lands on state.claude; a fresh live reading and a same-cycle OAuth reading are skipped (live-session, already-read); a stale live reading is replaced; not-attached and no-accounts are logged.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions