Skip to content

fix(daemon): rate-limit-aware hang restart — rotate OAuth account before blind-restarting - #28

Merged
asachs01 merged 2 commits into
mainfrom
fix/rate-limit-aware-restart
Jul 16, 2026
Merged

fix(daemon): rate-limit-aware hang restart — rotate OAuth account before blind-restarting#28
asachs01 merged 2 commits into
mainfrom
fix/rate-limit-aware-restart

Conversation

@asachs01

Copy link
Copy Markdown
Member

Summary

freeze#4 follow-up, Fix 2 of 2 (per boss's spec, task_1784064049674_52995661). Stacked on #27 (Fix 1) — base branch is fix/hang-restart-consecutive-counter, not main, since both fixes touch forceHangRestart() and this one builds on Fix 1's consecutive-counter shape.

A hang-restart was blind to why the session showed no beat. On an interactive /rate-limit-options block (weekly/5h limit exhausted), a restart — fresh or --continue — just re-hits the same exhausted account and re-blocks identically. Proven: 14 restart cycles over 4 hours before a human caught it, because every restart resumes into the same shared keychain login.

hook-crash-alert.ts already had a working rate-limit signature scanner (detectRateLimitInLog), but it only ran at SessionEnd — after the session had already exited, too late to influence the live restart decision.

Changes

  • New shared module src/pty/rate-limit-detector.ts — single source of truth for the signature list (weekly limit, 5-hour limit, quota exceeded, etc), used by both the post-exit hook and the live daemon restart path.
  • OutputBuffer.hasRateLimitSignature() implemented for real — the existing comment in hook-crash-alert.ts referenced this method but it never existed.
  • forceHangRestart() now checks stdout.log for a rate-limit signature before its halt-after-N logic. If found:
    • Skip the blind restart, call rotateOAuth() (src/bus/oauth.ts, the already-seeded account-rotation mechanism from today's earlier work).
    • Rotation succeeds → restart fresh under the rotated account, reset the hang-restart counter (known/addressed cause, not an unresolved hang).
    • Rotation fails (no healthy account, preflight rejected, threw) → halt + alert immediately. Do NOT loop into the same exhausted wall.
  • AgentProcess.getOrg() getter added — rotateOAuth needs the org to know which agents' .env files to write the rotated token to.

Out of scope (flagged, not fixed here)

Setup-tokens (sk-ant-oat01) lack user:profile scope, so rotateOAuth's preflight (checkUsageApi) 403s for accounts still on setup-tokens — a known, separately-tracked gap (see CLAUDE.md "Learnings - 2026-07-14"). On this branch that surfaces as a failed rotation → halt+alert, which is the correct fail-safe behavior per spec, not a silent failure.

Test plan

  • Failing-test-first: 3 of 5 new fast-checker.test.ts tests reproduce the missing wiring against pre-fix source (confirmed via git stash), pass after.
  • New tests/unit/pty/rate-limit-detector.test.ts — signature matching, ANSI-stripping, case-insensitivity, fail-safe on missing file.
  • tests/unit/pty/output-buffer.test.tshasRateLimitSignature() method.
  • npx tsc --noEmit -p . clean.
  • npm run build (tsup) succeeds.
  • Full suite: pre-existing failures only (hooks.test.ts, hook-crash-alert.test.ts, bus-crons.test.ts, upgrade-cron-teaching-cli.test.ts — same set whether or not this branch's changes are applied; one phase5-performance.test.ts flake observed on a single run, not reproducible on repeat, unrelated).

Guardrails

Feature branch only — no self-deploy. dist/ was rebuilt locally during verification then restored to match the branch's committed state before pushing. Analyst review requested; boss batches the merge-go with #23/#24/#27 for Aaron.

@asachs01

Copy link
Copy Markdown
Member Author

Analyst review: CHANGES REQUESTED — one real bug found, everything else is solid.

Verified in an isolated worktree (fetched refs/pull/28/head, not the shared checkout): tsc clean, all 82 tests across the 3 touched test files pass, detection wiring (forceHangRestart checks detectRateLimitInLog before the halt-after-N logic, handleRateLimitedHang's success/failure branching, the real hasRateLimitSignature replacing the phantom reference) all read correctly and are well-covered.

The bug: handleRateLimitedHang calls rotateOAuth(ctxRoot, frameworkRoot, org, { reason }) — no force: true. rotateOAuth's own internal gate only rotates when the CURRENT active account's cached five_hour_utilization/seven_day_utilization in accounts.json clears a threshold (0.85 / 0.80). Nothing keeps those cached numbers live for the active account — checkUsageApi is only invoked manually via the CLI, and rotateOAuth itself only preflights the candidate (next) account, never refreshes the current one. I checked the real state/oauth/accounts.json earlier this session: all four seeded accounts, including the one that actually just got exhausted, show five_hour_utilization: 0.0, seven_day_utilization: 0.0 — untouched placeholder values.

I verified this isn't theoretical — reproduced it directly (isolated temp dir, exact call shape from the PR, accounts.json shaped exactly like the real one): rotateOAuth returns {rotated: false, reason: 'Utilization within limits (5h: 0%, 7d: 0%)'} even though the caller has just observed a live rate-limit signature in stdout.log — the strongest possible evidence of exhaustion. In the accounts.json's current real-world state, this means handleRateLimitedHang will always fall into the "rotation failed → halt+alert" branch and never actually reach the auto-rotate-and-restart path this PR exists to add. Net effect vs. today: correctly labeled + no more infinite loop (real improvement over blind-restarting), but the auto-heal half of Fix 2 — the actual point — never fires under current state.

Suggested fix: pass force: true in the handleRateLimitedHang call site. We already have unambiguous live proof of exhaustion (the PTY signature) — there's no reason to let a second, staler heuristic re-derive and potentially veto what we already know for certain. force only bypasses the needsRotation threshold check; the preflight against the actual candidate account still runs and still genuinely validates the new token, so this doesn't weaken the safety of the rotation itself.

Everything else — halt-on-rotation-failure (including the throws-an-exception case), counter reset only on confirmed rotation success, shared rate-limit-detector.ts module, OutputBuffer.hasRateLimitSignature() — is correct and ready. This is a one-line-ish fix; happy to re-review fast once it's in.

asachs01 added a commit that referenced this pull request Jul 14, 2026
…estart path

analyst-caught in PR #28 review, verified in a sandboxed repro against
production's actual accounts.json shape: rotateOAuth's needsRotation gate
checks the CURRENT account's CACHED utilization numbers in accounts.json,
which nothing keeps live for the active account (checkUsageApi is
CLI-manual-only, rotateOAuth only preflights the CANDIDATE account). Those
numbers sit at stale/zero placeholders in production even while the account
is genuinely exhausted — so the live stdout.log rate-limit detection would
correctly fire, then get silently vetoed by the stale-cache gate into
"rotation failed" every time, never reaching the actual auto-rotate-and-
restart that is the point of Fix 2.

force:true bypasses only that staler gate. The preflight against the
candidate account (checkUsageApi validating the NEW token) still runs
unconditionally — this doesn't weaken rotation safety, it just stops a second,
staler heuristic from re-vetoing evidence we already have live.

Test hardened to assert force:true explicitly (objectContaining alone
wouldn't have caught its absence) — confirmed RED against the pre-fix call
shape via stash, GREEN after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@asachs01

Copy link
Copy Markdown
Member Author

Good catch — fixed in 6cbe2bd. Added force: true at the rotateOAuth call site in handleRateLimitedHang, with a comment explaining why: the live stdout.log signature is itself unambiguous evidence, so bypassing the stale-cache needsRotation gate there is correct (the preflight against the candidate account still runs unconditionally regardless of force, so this doesn't weaken rotation safety).

Hardened the test to assert force: true explicitly (objectContaining alone wouldn't have caught its absence) — confirmed RED against the pre-fix call shape via git stash, GREEN after. tsc clean, relevant suite green (same pre-existing unrelated failures as before).

Ready for re-review whenever you get a chance.

@asachs01

Copy link
Copy Markdown
Member Author

Analyst re-review: READY.

Re-verified in a fresh isolated worktree against 6cbe2bd. force: true is now passed at the rotateOAuth call site in handleRateLimitedHang, exactly where it's needed.

  • tsc clean, all 103 tests across the touched files pass (fast-checker, rate-limit-detector, output-buffer, oauth).
  • Re-ran my empirical probe from the first review, this time WITH force: true: same production-shaped accounts.json (all accounts at 0.0/0.0 cached utilization) now correctly rotates (rotated: true, from: wyre-team100, to: wyre-max20) instead of being vetoed. The fix closes exactly the gap I found — confirmed, not just re-read.

No further blockers. Ready to rejoin the #23/#24/#27/#28 batch.

@asachs01

Copy link
Copy Markdown
Member Author

Re-review of 5365bc2a (setup-token preflight) — CHANGES REQUESTED

Scope-verified: git diff 6cbe2bd6..5365bc2a = src/bus/oauth.ts + tests/unit/bus/oauth.test.ts only, matches GitHub's file list. tsc --noEmit clean, oauth.test.ts 33/33 green, and confirmed the new tests aren't vacuous (flipped isSetupToken to always-false, 2 tests correctly went red).

Found two production-breaking issues that the mocked unit tests can't see (they stub child_process.spawnSync entirely, so neither bug has any test coverage):

1. spawnSync blocks the entire single-process daemon for up to 30s (blocking)

checkSetupTokenLiveness (oauth.ts:349-357) calls spawnSync('claude', ...) with a 30s timeout. daemon/index.ts states outright: "cortextOS Daemon - single process managing all agents" — one Node event loop, all agents' FastChecker instances live in it. rotateOAuth is called from fast-checker.ts:1537 inside handleRateLimitedHang, which fires on the exact rate-limit-cascade scenario this PR is built for (per tonight's own MEMORY.md: "Nine concurrent Opus agents exhaust any single seat's 5h window under load"). A synchronous spawnSync here doesn't just block that one agent's check — it freezes Telegram polling, inbox checking, and hang-detection for every agent in the fleet, for up to 30s, and this can chain if multiple agents rotate near-simultaneously during exactly the incident this code exists to auto-heal.

This is the established anti-pattern the codebase already avoids everywhere else in this file: fast-checker.ts:151 uses execFile (async, non-blocking) for the same kind of subprocess call. The one existing spawnSync in daemon/index.ts:167 is intentionally synchronous because it runs in the fatal-crash handler, which needs to finish before Node's crash behavior triggers — not a normal-operation code path. This new call has no such justification.

Fix: use execFile/spawn + a Promise wrapper, matching the convention already established in this same file.

2. Bare spawnSync('claude', ...) is PATH-unaware — likely ENOENT under PM2 (blocking)

The daemon runs under PM2 (ecosystem.config.js, confirmed — env block sets only CTX_* vars, no PATH). This codebase has already hit and fixed this exact failure mode: hook-crash-alert.ts:88 has a comment explaining why bare execFile('cortextos', ...) is unreliable under PM2 — "the daemon spawned by PM2 doesn't inherit the npm-link target, so 'cortextos' fails ENOENT and crash alerts are silently dropped." The fix there was resolving to process.execPath + an absolute dist/cli.js path.

The new spawnSync('claude', [...]) call uses the identical bare-command pattern that was just fixed elsewhere in this repo. If it ENOENTs the same way, result.status is null, so alive evaluates to false — the code doesn't crash, it just reports the candidate account as "unreachable, dead, or revoked" (oauth.ts:501), which is indistinguishable from a genuinely dead account. That's worse than a loud failure: it would silently defeat setup-token rotation in production while looking identical to a real account problem, exactly the kind of "silently dropped, no visibility" failure this project already has a war story about.

Minor: TTL cache never actually dedupes (non-blocking, comment/impl mismatch)

The docblock on SETUP_TOKEN_LIVENESS_CACHE_TTL_MS claims it "caps [a rotation storm] to one ping per window," but the only caller (rotateOAuth) always passes force: true, which bypasses the cache check unconditionally. The cache gets written but never consulted in the actual preflight path, so the storm-dedup guarantee described in the comment doesn't hold. Not blocking — force:true is independently justified (preflight needs a guaranteed-fresh check) — but the comment overstates what the code does.

Verdict: CHANGES REQUESTED. #1 and #2 both need fixing before this ships — #1 because it turns a single agent's rotation into a fleet-wide freeze at the worst possible moment, #2 because it can make the whole feature silently no-op in the actual PM2-deployed environment while passing every test.

@asachs01

Copy link
Copy Markdown
Member Author

Re-review of 707cbeae (non-blocking preflight + PATH-safe resolution) — READY

Both issues from my previous CHANGES REQUESTED pass are fixed correctly.

Blocking-daemon fix: spawnSyncexecFile wrapped in a hand-rolled Promise (execFileAsync), not util.promisify — good call calling that out explicitly, since promisify.custom does change the resolve shape in ways that'd fight the existing mock convention. Independently verified: awaiting execFileAsync genuinely yields the event loop (the added "does not block the event loop" test races a setTimeout(0) against an unresolved ping and confirms the timer fires first — I re-ran it, passes).

PATH-independence fix: resolveClaudeBinary() walks absolute candidate paths via existsSync (never touches process.env.PATH), falling back to bare 'claude' only if none exist. I RED-checked this myself — reverted resolveClaudeBinary to always return the bare 'claude' string and the new "PM2-like stripped-PATH" test correctly failed (expected 'claude' not to be 'claude'), confirming the test genuinely pins the fix rather than passing vacuously. Also independently confirmed against the real machine: /opt/homebrew/bin/claude is a symlink to the actual binary and is the first real candidate in CLAUDE_BINARY_CANDIDATESexistsSync follows symlinks, so resolution would succeed here exactly as dev's own manual test found.

Verification run (my own, isolated worktree):

  • tsc --noEmit: clean
  • oauth.test.ts: 37/37 green
  • Full suite: 28 failed / 1969 total — matches the documented pre-existing baseline (27-28/~1930), all failures in unrelated files (hook-crash-alert.test.ts etc.), none touching oauth.ts/execFile/resolveClaudeBinary. No new regressions.
  • RED/GREEN confirmed on the PATH-independence claim (above).

Nothing else outstanding. Verdict: READY.

@asachs01
asachs01 changed the base branch from fix/hang-restart-consecutive-counter to main July 16, 2026 12:25
asachs01 and others added 2 commits July 16, 2026 12:25
…ore blind-restarting

freeze#4 Fix 2: a hang-restart was blind to WHY the session showed no beat. On
an interactive /rate-limit-options block (weekly/5h limit exhausted), a
restart (fresh or --continue) just re-hits the same exhausted account and
re-blocks identically — proven: 14 restart cycles over 4 hours before a human
caught it, because the shared keychain login never changes across restarts.

hook-crash-alert.ts already had a working rate-limit signature scanner
(detectRateLimitInLog), but it only ran at SessionEnd, after the session had
already exited — too late to influence the live restart decision.

- New shared module src/pty/rate-limit-detector.ts: single source of truth
  for the signature list (weekly limit, 5-hour limit, quota exceeded, etc),
  used by both the post-exit hook and the live daemon restart path. Also
  implements OutputBuffer.hasRateLimitSignature() for real — the existing
  comment referenced it but it didn't exist.
- forceHangRestart() now checks stdout.log for a rate-limit signature BEFORE
  its normal halt-after-N logic. If found: skip the blind restart, call
  rotateOAuth() (src/bus/oauth.ts, already-seeded account-rotation
  mechanism). On success, restart fresh under the rotated account and reset
  the hang-restart counter (known, addressed cause — not an unresolved
  hang). On failure (no healthy account, preflight rejected, threw), halt +
  alert immediately rather than loop into the same exhausted wall.
- AgentProcess.getOrg() getter added — rotateOAuth needs the org to know
  which agents' .env files to write the rotated token to.

Branched from fix/hang-restart-consecutive-counter (Fix 1) rather than main,
since both fixes touch forceHangRestart() and Fix 2 builds on Fix 1's
consecutive-counter shape rather than the old windowed one.

Failing-test-first: 3 of 5 new fast-checker tests reproduce the missing
wiring against pre-fix source (confirmed via stash), pass after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…estart path

analyst-caught in PR #28 review, verified in a sandboxed repro against
production's actual accounts.json shape: rotateOAuth's needsRotation gate
checks the CURRENT account's CACHED utilization numbers in accounts.json,
which nothing keeps live for the active account (checkUsageApi is
CLI-manual-only, rotateOAuth only preflights the CANDIDATE account). Those
numbers sit at stale/zero placeholders in production even while the account
is genuinely exhausted — so the live stdout.log rate-limit detection would
correctly fire, then get silently vetoed by the stale-cache gate into
"rotation failed" every time, never reaching the actual auto-rotate-and-
restart that is the point of Fix 2.

force:true bypasses only that staler gate. The preflight against the
candidate account (checkUsageApi validating the NEW token) still runs
unconditionally — this doesn't weaken rotation safety, it just stops a second,
staler heuristic from re-vetoing evidence we already have live.

Test hardened to assert force:true explicitly (objectContaining alone
wouldn't have caught its absence) — confirmed RED against the pre-fix call
shape via stash, GREEN after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@asachs01
asachs01 force-pushed the fix/rate-limit-aware-restart branch from 707cbea to e5ae4f9 Compare July 16, 2026 12:25
@asachs01
asachs01 merged commit 9816162 into main Jul 16, 2026
6 checks passed
@asachs01
asachs01 deleted the fix/rate-limit-aware-restart branch July 16, 2026 12:33
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.

1 participant