diff --git a/apps/server/src/provider/claudeHardLimitRotation.expbkt3.test.ts b/apps/server/src/provider/claudeHardLimitRotation.expbkt3.test.ts new file mode 100644 index 000000000000..01a52d61ce0d --- /dev/null +++ b/apps/server/src/provider/claudeHardLimitRotation.expbkt3.test.ts @@ -0,0 +1,306 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + EventId, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + runClaudeAutoswitchElection, + runClaudeHardLimitRotation, + type ClaudeAutoswitchElectionResult, +} from "./claudeHardLimitRotation.expbkt3.ts"; + +const claude = ProviderDriverKind.make("claudeAgent"); +const claudeInstance = ProviderInstanceId.make("claudeAgent"); +const affectedThread = ThreadId.make("affected-thread"); +const unrelatedThread = ThreadId.make("unrelated-thread"); +const encoder = new TextEncoder(); + +function rateLimitEvent(input: { + readonly eventId: string; + readonly threadId: ThreadId; + readonly status: "allowed" | "allowed_warning" | "rejected"; + readonly rateLimitType?: "five_hour" | "seven_day"; + readonly resetsAt?: number; +}): ProviderRuntimeEvent { + return { + type: "account.rate-limits.updated", + eventId: EventId.make(input.eventId), + provider: claude, + providerInstanceId: claudeInstance, + threadId: input.threadId, + createdAt: "2026-08-20T13:15:49.000Z", + payload: { + rateLimits: { + mode: "merge", + availability: "available", + windows: [], + observedAt: DateTime.makeUnsafe("2026-08-20T13:15:49.000Z"), + }, + }, + raw: { + source: "claude.sdk.message", + messageType: "rate_limit_event", + payload: { + type: "rate_limit_event", + session_id: "claude-session", + uuid: input.eventId, + rate_limit_info: { + status: input.status, + ...(input.rateLimitType ? { rateLimitType: input.rateLimitType } : {}), + ...(input.resetsAt ? { resetsAt: input.resetsAt } : {}), + }, + }, + }, + }; +} + +function commandHandle(input: { + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: Effect.Effect; + readonly code?: number; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: input.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(input.code ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(input.stdout ?? "")), + stderr: Stream.make(encoder.encode(input.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function electionLayer(input: Parameters[0]) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + assert.isTrue(ChildProcess.isStandardCommand(command)); + if (!ChildProcess.isStandardCommand(command)) return Effect.die("expected standard command"); + assert.equal(command.command, "/home/ubuntu/.local/bin/claude-autoswitch"); + assert.deepEqual(command.args, ["--hard-limit", "five_hour", "--json"]); + return Effect.succeed(commandHandle(input)); + }), + ); +} + +describe("Claude authoritative hard-limit handling", () => { + it.effect( + "elects once for repeated hard-limit evidence and recycles only the emitting thread", + () => + Effect.gen(function* () { + const electionRequests: Array = []; + const recycledThreads: Array = []; + const hardLimit = rateLimitEvent({ + eventId: "hard-limit-1", + threadId: affectedThread, + status: "rejected", + rateLimitType: "five_hour", + resetsAt: 1_776_725_705, + }); + + yield* runClaudeHardLimitRotation( + Stream.fromIterable([ + hardLimit, + { ...hardLimit, eventId: EventId.make("hard-limit-copy") }, + { + ...hardLimit, + eventId: EventId.make("hard-limit-other-thread"), + threadId: unrelatedThread, + }, + ]), + { + requestElection: (rateLimitType) => + Effect.sync(() => { + electionRequests.push(rateLimitType); + return { status: "switched" } as const; + }), + recycleSession: (threadId) => + Effect.sync(() => { + recycledThreads.push(threadId); + }), + }, + ); + + assert.deepEqual(electionRequests, ["five_hour"]); + assert.deepEqual(recycledThreads, [affectedThread]); + }), + ); + + it.effect("ignores warnings, unsupported limit shapes, and unrelated provider errors", () => + Effect.gen(function* () { + let electionRequests = 0; + const events: ReadonlyArray = [ + rateLimitEvent({ + eventId: "warning", + threadId: affectedThread, + status: "allowed_warning", + rateLimitType: "five_hour", + resetsAt: 1_776_725_705, + }), + rateLimitEvent({ + eventId: "missing-type", + threadId: affectedThread, + status: "rejected", + }), + { + type: "runtime.error", + eventId: EventId.make("provider-error"), + provider: claude, + providerInstanceId: claudeInstance, + threadId: affectedThread, + createdAt: "2026-08-20T13:16:00.000Z", + payload: { message: "HTTP 429" }, + raw: { + source: "claude.sdk.message", + messageType: "error", + payload: { + error: "rate_limit", + quotaLimits: { status: "rejected", rateLimitType: "five_hour" }, + }, + }, + }, + ]; + + yield* runClaudeHardLimitRotation(Stream.fromIterable(events), { + requestElection: () => + Effect.sync(() => { + electionRequests += 1; + return { status: "switched" } as const; + }), + recycleSession: () => Effect.die("non-hard-limit evidence must not recycle a session"), + }); + + assert.equal(electionRequests, 0); + }), + ); + + it.effect("leaves the session running for every unconfirmed election outcome", () => + Effect.gen(function* () { + const outcomes: ReadonlyArray = [ + { status: "no-op", failureKind: "host-no-op" }, + { status: "failure", failureKind: "nonzero-exit" }, + { status: "failure", failureKind: "invalid-response" }, + { status: "failure", failureKind: "timeout" }, + { status: "failure", failureKind: "command-error" }, + ]; + + for (const [index, outcome] of outcomes.entries()) { + let recycled = false; + yield* runClaudeHardLimitRotation( + Stream.make( + rateLimitEvent({ + eventId: `unconfirmed-${index}`, + threadId: affectedThread, + status: "rejected", + rateLimitType: "five_hour", + resetsAt: 1_776_725_705 + index, + }), + ), + { + requestElection: () => Effect.succeed(outcome), + recycleSession: () => + Effect.sync(() => { + recycled = true; + }), + }, + ); + assert.isFalse(recycled); + } + }), + ); +}); + +describe("claude-autoswitch hard-limit contract", () => { + it.effect("accepts only an exit-zero switched response", () => + Effect.gen(function* () { + const result = yield* runClaudeAutoswitchElection("five_hour").pipe( + Effect.provide( + electionLayer({ + stdout: + '{"status":"switched","hardLimitType":"five_hour","from":"profile-a","to":"profile-b","reason":"authoritative rejection"}', + }), + ), + ); + + assert.deepEqual(result, { status: "switched" }); + }), + ); + + it.effect("maps no-op, nonzero, and invalid JSON to unconfirmed outcomes", () => + Effect.gen(function* () { + const noOp = yield* runClaudeAutoswitchElection("five_hour").pipe( + Effect.provide( + electionLayer({ + stdout: '{"status":"no-op","hardLimitType":"five_hour","reason":"no eligible profile"}', + }), + ), + ); + const nonzero = yield* runClaudeAutoswitchElection("five_hour").pipe( + Effect.provide( + electionLayer({ + code: 1, + stdout: '{"status":"failure","hardLimitType":"five_hour","reason":"election failed"}', + }), + ), + ); + const invalid = yield* runClaudeAutoswitchElection("five_hour").pipe( + Effect.provide(electionLayer({ stdout: "not-json" })), + ); + + assert.deepEqual(noOp, { status: "no-op", failureKind: "host-no-op" }); + assert.deepEqual(nonzero, { status: "failure", failureKind: "nonzero-exit" }); + assert.deepEqual(invalid, { status: "failure", failureKind: "invalid-response" }); + }), + ); + + it.effect("times out without confirming a switch", () => + Effect.gen(function* () { + const resultFiber = yield* runClaudeAutoswitchElection("five_hour").pipe( + Effect.provide(electionLayer({ exitCode: Effect.never })), + Effect.forkChild, + ); + yield* TestClock.adjust("10 seconds"); + const result = yield* Fiber.join(resultFiber); + + assert.deepEqual(result, { status: "failure", failureKind: "timeout" }); + }), + ); + + it.effect("maps command startup errors to an unconfirmed outcome", () => + Effect.gen(function* () { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "autoswitch unavailable", + }), + ), + ); + const result = yield* runClaudeAutoswitchElection("five_hour").pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + assert.deepEqual(result, { status: "failure", failureKind: "command-error" }); + }), + ); +}); diff --git a/apps/server/src/provider/claudeHardLimitRotation.expbkt3.ts b/apps/server/src/provider/claudeHardLimitRotation.expbkt3.ts new file mode 100644 index 000000000000..e0bbc8f0056e --- /dev/null +++ b/apps/server/src/provider/claudeHardLimitRotation.expbkt3.ts @@ -0,0 +1,245 @@ +/** + * T3-CUSTOM(expbkt3): rotate Claude after an authoritative usage rejection. + * + * The provider event retains Claude's typed rate-limit message in `raw`. This + * listener delegates the machine-global election to the host-owned autoswitch + * command and stops only the emitting thread after a validated switched result. + * It never reads, copies, logs, or moves account credentials. + */ +import type { ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import * as ProviderService from "./Services/ProviderService.ts"; + +const CLAUDE_AUTOSWITCH_COMMAND = "/home/ubuntu/.local/bin/claude-autoswitch"; +const CLAUDE_AUTOSWITCH_TIMEOUT = "10 seconds"; +const MAX_AUTOSWITCH_OUTPUT_BYTES = 4_096; +const MAX_HANDLED_CONDITIONS = 128; + +const ClaudeRateLimitType = Schema.Literals([ + "five_hour", + "seven_day", + "seven_day_opus", + "seven_day_sonnet", + "overage", +]); +export type ClaudeRateLimitType = typeof ClaudeRateLimitType.Type; + +const ClaudeRateLimitMessage = Schema.Struct({ + type: Schema.Literal("rate_limit_event"), + rate_limit_info: Schema.Struct({ + status: Schema.Literals(["allowed", "allowed_warning", "rejected"]), + rateLimitType: Schema.optional(ClaudeRateLimitType), + resetsAt: Schema.optional(Schema.Number), + overageResetsAt: Schema.optional(Schema.Number), + }), +}); +const decodeClaudeRateLimitMessage = Schema.decodeUnknownOption(ClaudeRateLimitMessage); + +const ClaudeAutoswitchHostResult = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("switched"), + hardLimitType: ClaudeRateLimitType, + from: Schema.String, + to: Schema.String, + reason: Schema.String, + }), + Schema.Struct({ + status: Schema.Literal("no-op"), + hardLimitType: ClaudeRateLimitType, + reason: Schema.String, + }), + Schema.Struct({ + status: Schema.Literal("failure"), + hardLimitType: ClaudeRateLimitType, + reason: Schema.String, + }), +]); +const decodeClaudeAutoswitchHostResult = Schema.decodeUnknownOption( + Schema.fromJsonString(ClaudeAutoswitchHostResult), +); + +export type ClaudeAutoswitchElectionResult = + | { readonly status: "switched" } + | { readonly status: "no-op"; readonly failureKind: "host-no-op" } + | { + readonly status: "failure"; + readonly failureKind: "command-error" | "timeout" | "invalid-response" | "nonzero-exit"; + }; + +interface ClaudeHardLimitCondition { + readonly key: string; + readonly rateLimitType: ClaudeRateLimitType; + readonly threadId: ThreadId; +} + +export interface ClaudeHardLimitRotationDependencies { + readonly requestElection: ( + rateLimitType: ClaudeRateLimitType, + ) => Effect.Effect; + readonly recycleSession: (threadId: ThreadId) => Effect.Effect; +} + +function hardLimitCondition(event: ProviderRuntimeEvent): ClaudeHardLimitCondition | null { + if ( + event.provider !== "claudeAgent" || + event.type !== "account.rate-limits.updated" || + event.raw?.source !== "claude.sdk.message" || + event.raw.messageType !== "rate_limit_event" + ) { + return null; + } + + const decoded = decodeClaudeRateLimitMessage(event.raw.payload); + if (Option.isNone(decoded)) return null; + const info = decoded.value.rate_limit_info; + if (info.status !== "rejected" || info.rateLimitType === undefined) return null; + + const resetAt = + info.rateLimitType === "overage" ? (info.overageResetsAt ?? info.resetsAt) : info.resetsAt; + return { + key: `${String(event.providerInstanceId ?? event.provider)}\u0000${info.rateLimitType}\u0000${String(resetAt ?? "unknown")}`, + rateLimitType: info.rateLimitType, + threadId: event.threadId, + }; +} + +function rememberCondition(handled: Set, key: string): boolean { + if (handled.has(key)) return false; + if (handled.size >= MAX_HANDLED_CONDITIONS) { + const oldest = handled.values().next().value; + if (oldest !== undefined) handled.delete(oldest); + } + handled.add(key); + return true; +} + +export const runClaudeHardLimitRotation = Effect.fn("ClaudeHardLimitRotation.run")(function* ( + events: Stream.Stream, + dependencies: ClaudeHardLimitRotationDependencies, +) { + const handled = new Set(); + + yield* events.pipe( + Stream.runForEach((event) => { + const condition = hardLimitCondition(event); + if (condition === null || !rememberCondition(handled, condition.key)) return Effect.void; + + return dependencies + .requestElection(condition.rateLimitType) + .pipe( + Effect.flatMap((result) => + result.status === "switched" + ? dependencies.recycleSession(condition.threadId) + : Effect.void, + ), + ); + }), + ); +}); + +export const runClaudeAutoswitchElection = Effect.fn("ClaudeHardLimitRotation.elect")(function* ( + rateLimitType: ClaudeRateLimitType, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const attempt = yield* Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(CLAUDE_AUTOSWITCH_COMMAND, ["--hard-limit", rateLimitType, "--json"]), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + const [stdout, , exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ + stream: child.stdout, + maxBytes: MAX_AUTOSWITCH_OUTPUT_BYTES, + }), + collectUint8StreamText({ + stream: child.stderr, + maxBytes: MAX_AUTOSWITCH_OUTPUT_BYTES, + }), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + return { stdout: stdout.text.trim(), exitCode: Number(exitCode) }; + }).pipe(Effect.scoped, Effect.timeoutOption(CLAUDE_AUTOSWITCH_TIMEOUT), Effect.result); + + if (Result.isFailure(attempt)) { + return { status: "failure", failureKind: "command-error" } as const; + } + if (Option.isNone(attempt.success)) { + return { status: "failure", failureKind: "timeout" } as const; + } + + const commandResult = attempt.success.value; + const decoded = decodeClaudeAutoswitchHostResult(commandResult.stdout); + if (Option.isNone(decoded) || decoded.value.hardLimitType !== rateLimitType) { + return { status: "failure", failureKind: "invalid-response" } as const; + } + if (commandResult.exitCode !== 0) { + return { status: "failure", failureKind: "nonzero-exit" } as const; + } + if (decoded.value.status === "switched") return { status: "switched" } as const; + if (decoded.value.status === "no-op") { + return { status: "no-op", failureKind: "host-no-op" } as const; + } + return { status: "failure", failureKind: "invalid-response" } as const; +}); + +function immediateRotationEnabled(): boolean { + const configured = process.env.T3_CLAUDE_HARD_LIMIT_ROTATION; + return configured === undefined || !["0", "false", "off"].includes(configured.toLowerCase()); +} + +const makeLive = Effect.gen(function* () { + if (!immediateRotationEnabled()) { + yield* Effect.logInfo("claude hard-limit rotation disabled"); + return; + } + + const providers = yield* ProviderService.ProviderService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const requestElection = (rateLimitType: ClaudeRateLimitType) => + runClaudeAutoswitchElection(rateLimitType).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.tap((result) => + result.status === "switched" + ? Effect.logInfo("claude hard-limit rotation confirmed profile election", { + rateLimitType, + }) + : Effect.logWarning("claude hard-limit rotation left provider session running", { + rateLimitType, + electionStatus: result.status, + failureKind: result.failureKind, + }), + ), + ); + const recycleSession = (threadId: ThreadId) => + providers.stopSession({ threadId }).pipe( + Effect.tap(() => + Effect.logInfo("claude hard-limit rotation recycled affected provider session", { + threadId, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("claude hard-limit rotation failed to recycle provider session", { + threadId, + cause, + }), + ), + ); + + yield* runClaudeHardLimitRotation(providers.streamEvents, { + requestElection, + recycleSession, + }).pipe(Effect.forkScoped); +}); + +export const layer = Layer.effectDiscard(makeLive); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 404786c74e0d..0f57ed0c201a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -81,6 +81,8 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import * as ProviderRateLimits from "./provider/ProviderRateLimits.ts"; +// T3-CUSTOM(expbkt3): recycle only the Claude thread that reports a hard usage limit. +import * as ClaudeHardLimitRotation from "./provider/claudeHardLimitRotation.expbkt3.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; @@ -443,10 +445,12 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll( ), ); -const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( - Layer.provideMerge(ProviderLayerLive), - Layer.provideMerge(OrchestrationLayerLive), -); +// T3-CUSTOM(expbkt3): hard-limit rotation consumes the same provider event bus +// and lifecycle service as the reaper, without widening upstream contracts. +const ProviderRuntimeLayerLive = Layer.mergeAll( + ProviderSessionReaperLive, + ClaudeHardLimitRotation.layer, +).pipe(Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive)); // T3-CUSTOM(expbkt3): the supervisor records session recovery desired-state, // so its repository is composed in here rather than at every call site. diff --git a/docs/operations/claude-account-switching.md b/docs/operations/claude-account-switching.md index ed0f0c47ff15..5c33f787ce4d 100644 --- a/docs/operations/claude-account-switching.md +++ b/docs/operations/claude-account-switching.md @@ -80,7 +80,7 @@ Two caveats: ## Automatic switching on usage limits -`claude-autoswitch` runs every five minutes from `claude-autoswitch.timer` and elects a +`claude-autoswitch` runs every 30 seconds from `claude-autoswitch.timer` and elects a different profile when the active account is nearly spent. It makes **no network requests and never spawns `claude`**. Usage comes from @@ -95,6 +95,39 @@ claude-autoswitch --dry-run # decide without switching tail ~/.claude-profiles/.autoswitch.jsonl # every decision, with its inputs ``` +### Authoritative provider rejection + +The timer remains the fallback, but it cannot act promptly when Claude rejects a request before +its local utilization cache catches up. T3 therefore listens for the SDK's typed +`rate_limit_event`. Only `rate_limit_info.status == "rejected"` with one of `five_hour`, +`seven_day`, `seven_day_opus`, `seven_day_sonnet`, or `overage` is authoritative. Warnings, +ordinary HTTP/provider errors, missing limit types, and every other event are ignored. + +For an authoritative rejection, T3 invokes the credential-free host contract: + +```bash +/home/ubuntu/.local/bin/claude-autoswitch --hard-limit --json +``` + +The hard-limit and timer paths hold the same machine-wide lock across candidate election, atomic +symlink replacement, and autoswitch state persistence. Hard-limit mode bypasses the stale cache +for the rejected account and the normal cooldown for that decision only; it does not rewrite the +cache. The host command returns one JSON object: + +- exit 0 with `status: "switched"`: the election committed; +- exit 0 with `status: "no-op"`: no eligible authorized profile was available; +- exit 1 with `status: "failure"`: election failed. + +T3 validates the JSON, requested limit type, exit code, and `switched` status. Only then does it +stop the Claude provider session for the thread that emitted the rejection, so its next resume +spawns through `~/.claude-active` and reads the newly elected profile. Other Claude threads keep +running. Repeated copies of the same provider-instance, limit-type, and reset condition are +consumed once. A no-op, nonzero exit, invalid response, timeout, or command error is logged and +leaves the affected session running for the timer or an operator to recover. + +Manual selection semantics remain in the host election policy. The timer only reverses its own +automatic move; the event-driven path does not inspect profiles or credentials in T3. + Config lives in `~/.claude-profiles/.autoswitch.json`: | key | default | meaning | @@ -128,6 +161,36 @@ Disable with `"enabled": false`, or: sudo systemctl disable --now claude-autoswitch.timer ``` +That disables the fallback timer. To disable only T3's immediate-rejection listener while keeping +the timer active, set `T3_CLAUDE_HARD_LIMIT_ROTATION=0` in the deployment service environment and +restart that environment. + +## Staging verification + +Verify on `expbkt3` before promotion: + +1. Run the focused behavior test: + `VITEST_MAX_THREADS=2 vp test run apps/server/src/provider/claudeHardLimitRotation.expbkt3.test.ts --maxWorkers=2`. +2. Keep two Claude threads active and record both thread identifiers. Inject one typed five-hour + rejection fixture while the cached utilization is below its normal trip threshold. Confirm one + `confirmed profile election` log followed by one `recycled affected provider session` log for + the emitting thread. +3. Deliver the same rejection again. Confirm there is no second host command or recycle log, and + confirm the unrelated Claude thread remains active. +4. Inject an `allowed_warning`, a generic HTTP 429/runtime error, and a rejected event without a + supported `rateLimitType`. Confirm none invokes the host command or stops a session. +5. Exercise host no-op, nonzero, invalid-JSON, and timeout fixtures. Confirm each logs why the + election was unconfirmed and leaves the emitting session active. + +## Manual rollback + +Set `T3_CLAUDE_HARD_LIMIT_ROTATION=0` and restart `t3-expbkt3.service` to roll back the application +hook without disabling the timer. If the host prerequisite itself must be rolled back, an operator +can restore `/home/ubuntu/.local/bin/claude-autoswitch.bak-20260820-tec961` to +`/home/ubuntu/.local/bin/claude-autoswitch`; the application will then treat the missing hard-limit +contract as an unconfirmed command response and will not recycle a session. Revert the bkt3 commit +before promotion for a complete rollback. + ## Operational notes The election is **machine-global**. Whichever profile is elected absorbs the usage of @@ -147,7 +210,7 @@ why the machine-global point above matters more than the account count. | `~/.local/share/claude-profile/shell-init.sh` | `cs` shell function, sourced from `~/.bashrc` | | `~/.local/bin/claude-autoswitch` | usage-based switcher (python3, stdlib only) | | `~/.local/bin/claude-elected` | unused fallback wrapper; see note below | -| `/etc/systemd/system/claude-autoswitch.{service,timer}` | five-minute timer, `User=ubuntu` | +| `/etc/systemd/system/claude-autoswitch.{service,timer}` | 30-second timer, `User=ubuntu` | | `~/.claude-profiles/.autoswitch{.json,-state.json,.jsonl}` | config, state, decision log | `claude-elected` injects the elected config directory via a wrapper set as `binaryPath`.