Skip to content

Commit 76cdba9

Browse files
committed
feat(usage-limits): full kill-switch + flip default to opt-in
Default flip (false) — AIF_USAGE_LIMITS_ENABLED now defaults off so the live refresh path (Codex FS scan, Claude identity lookup) is skipped unless explicitly enabled. Perf win on /runtime-profiles cold: 6794ms → 109ms (62× faster); k6 throughput 3.2×. Extended the gate beyond /runtime-profiles + UI surfaces to cover the remaining paths that previously kept running regardless of the flag: * runtime.ts — observeRuntimeLimitEvent / extractLatestRuntimeLimitSnapshot / extractRuntimeLimitSnapshotFromError wrap-functions short-circuit; broadcastRuntimeLimitUpdate early-returns (no project:runtime_limit_updated WS fan-out) * chat.ts — normalizeOptionalRuntimeLimitSnapshot returns null when disabled, so chat responses no longer carry runtimeLimitSnapshot * stageErrorHandler.ts — limitSnapshot not persisted onto tasks * ChatPanel — CHAT_USAGE_LIMIT fallback banner gated on the flag Tests updated to seed AIF_USAGE_LIMITS_ENABLED=true where they assert on snapshot persistence / emission (stageErrorHandler, coordinator, chat.test, runtimeService.test, runtimeProfiles.test, hooks.test). Adjacent dev-loop fixes bundled in the same change because they share the Ctrl+C / port-3009 failure surface users hit when iterating on the gate: * api/index.ts + ws.ts — SIGINT/SIGTERM handler terminates all WebSocket clients and calls server.close() synchronously so the port frees on shutdown * mcp/src/index.ts — same handler in HTTP transport * api / agent / mcp dev scripts — tsx watch → node --watch --import tsx, removes the "Previous process hasn't exited yet. Force killing..." restart race (node --watch still needs 2× Ctrl+C by design, accepted tradeoff) * turbo.json — globalPassThroughEnv so env changes don't break task hashing Docs: docs/configuration.md + .env.example describe the full list of gated surfaces, not just the live-refresh path.
1 parent 36f2279 commit 76cdba9

27 files changed

Lines changed: 510 additions & 190 deletions

.env.example

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,22 @@ LOG_LEVEL=debug
173173
# AGENT_WAKE_ENABLED=true
174174

175175
# ----------------------------------------------------------
176-
# Usage-limits feature toggle
177-
# ----------------------------------------------------------
178-
# When true (default) the API refreshes Codex/Claude rate-limit snapshots on
179-
# every /runtime-profiles request. On machines with thousands of Codex session
180-
# rollouts this incurs filesystem scans. Set to false to disable the live
181-
# refresh (persisted snapshots are still returned but never updated).
182-
# AIF_USAGE_LIMITS_ENABLED=true
176+
# Usage-limits feature toggle (master switch)
177+
# ----------------------------------------------------------
178+
# Default false. When false the full usage-limits pipeline is off:
179+
# * /runtime-profiles skips the ~/.codex/sessions filesystem scan and the
180+
# Claude provider-identity lookup.
181+
# * Runtime service skips observeRuntimeLimitEvent /
182+
# extractLatestRuntimeLimitSnapshot / extractRuntimeLimitSnapshotFromError
183+
# during every runtime run, and suppresses the
184+
# project:runtime_limit_updated WebSocket broadcast.
185+
# * Agent stage error handler does not persist limitSnapshot onto tasks.
186+
# * Chat API omits runtimeLimitSnapshot from responses.
187+
# * Web UI hides the USAGE button, TaskCard/TaskDetailHeader badges,
188+
# Recent Limit Signals panel, and Chat active-limit banner.
189+
# Persisted snapshots are still returned on read but never refreshed.
190+
# Enable only if you actively monitor rate-limit windows.
191+
# AIF_USAGE_LIMITS_ENABLED=false
183192

184193
# ----------------------------------------------------------
185194
# Codex OAuth login in Docker (dev-only broker)

docs/configuration.md

Lines changed: 47 additions & 47 deletions
Large diffs are not rendered by default.

packages/agent/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"private": true,
77
"type": "module",
88
"scripts": {
9-
"dev": "tsx watch src/index.ts",
9+
"dev": "node --watch --import tsx src/index.ts",
1010
"build": "tsc",
1111
"start": "node dist/index.ts",
1212
"test": "vitest run --configLoader runner",

packages/agent/src/__tests__/coordinator.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
2-
import { tasks, projects, runtimeProfiles } from "@aif/shared";
2+
import { tasks, projects, runtimeProfiles, resetEnvCache } from "@aif/shared";
33
import { createTestDb } from "@aif/shared/server";
44
import { RuntimeExecutionError } from "@aif/runtime";
55
import { eq } from "drizzle-orm";
66

7+
// Flag defaults to false (opt-in). Coordinator tests assert on persisted
8+
// limitSnapshot, which requires the gate to be open.
9+
process.env.AIF_USAGE_LIMITS_ENABLED = "true";
10+
resetEnvCache();
11+
712
// Set up test db
813
const testDb = { current: createTestDb() };
914
const blockTaskForRuntimeGateIfEligibleMock = vi.fn();

packages/agent/src/__tests__/hooks.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ function makeEnv(overrides: Record<string, unknown> = {}) {
6767
AGENT_AUTO_REVIEW_STRATEGY: "full_re_review" as const,
6868
AGENT_USE_SUBAGENTS: true,
6969
AGENT_FIRST_ACTIVITY_TIMEOUT_MS: 60_000,
70-
AIF_USAGE_LIMITS_ENABLED: true,
70+
AIF_USAGE_LIMITS_ENABLED: false,
7171
AIF_ENABLE_CODEX_LOGIN_PROXY: false,
7272
AIF_CODEX_LOGIN_BROKER_PORT: 3010,
7373
AIF_CODEX_LOGIN_LOOPBACK_PORT: 1455,

packages/agent/src/__tests__/stageErrorHandler.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { RuntimeExecutionError, type RuntimeLimitSnapshot } from "@aif/runtime";
3+
import { resetEnvCache } from "@aif/shared";
4+
5+
// Flag defaults to false (opt-in). These tests assert on limitSnapshot
6+
// persistence, which requires the gate to be open.
7+
process.env.AIF_USAGE_LIMITS_ENABLED = "true";
8+
resetEnvCache();
39

410
const { mockWarn, mockError } = vi.hoisted(() => ({
511
mockWarn: vi.fn(),

packages/agent/src/stageErrorHandler.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import type { RuntimeLimitSnapshot } from "@aif/runtime";
88
import {
9+
getEnv,
910
logger,
1011
mapSafeRuntimeErrorReason,
1112
redactProviderTextForLogs,
@@ -80,7 +81,12 @@ function resolveRetryAfter(err: unknown): {
8081
limitSnapshot: RuntimeLimitSnapshot | null;
8182
} {
8283
const runtimeError = findRuntimeExecutionError(err);
83-
const limitSnapshot = runtimeError?.limitSnapshot ?? null;
84+
// When usage-limits feature is disabled, don't persist the limit snapshot
85+
// onto the task. Retry/backoff still applies — we just skip the surface
86+
// that feeds the (also-gated) UI.
87+
const limitSnapshot = getEnv().AIF_USAGE_LIMITS_ENABLED
88+
? (runtimeError?.limitSnapshot ?? null)
89+
: null;
8490

8591
if (runtimeError?.resetAt) {
8692
const resetAtMs = Date.parse(runtimeError.resetAt);
@@ -126,7 +132,9 @@ export function classifyStageError(input: StageErrorInput): ErrorRecovery {
126132
if (runtimeError && NON_RETRYABLE_RUNTIME_CATEGORIES.has(runtimeError.category)) {
127133
const safeReason = mapSafeRuntimeErrorReason(runtimeError);
128134
const blockedReason = `${safeReason.reason} Manual action required before retry.`;
129-
const limitSnapshot = runtimeError.limitSnapshot ?? null;
135+
const limitSnapshot = getEnv().AIF_USAGE_LIMITS_ENABLED
136+
? (runtimeError.limitSnapshot ?? null)
137+
: null;
130138

131139
logActivity(
132140
taskId,

packages/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"private": true,
77
"type": "module",
88
"scripts": {
9-
"dev": "tsx watch src/index.ts",
9+
"dev": "node --watch --import tsx src/index.ts",
1010
"build": "tsc",
1111
"start": "node dist/index.js",
1212
"test": "vitest run --configLoader runner",

packages/api/src/__tests__/chat.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { Hono } from "hono";
33
import { RuntimeExecutionError, type RuntimeAdapter, type RuntimeRunInput } from "@aif/runtime";
4+
import { resetEnvCache } from "@aif/shared";
5+
6+
// Flag defaults to false (opt-in). These tests assert on runtime limit
7+
// snapshots being emitted to chat responses, which needs the gate open.
8+
process.env.AIF_USAGE_LIMITS_ENABLED = "true";
9+
resetEnvCache();
410

511
const mockFindProjectById = vi.fn();
612
const mockFindTaskById = vi.fn();
@@ -119,6 +125,7 @@ vi.mock("@aif/shared", async (importOriginal) => {
119125
AGENT_CHAT_MAX_TURNS: 50,
120126
AIF_DEFAULT_RUNTIME_ID: "claude",
121127
AIF_DEFAULT_PROVIDER_ID: "anthropic",
128+
AIF_USAGE_LIMITS_ENABLED: true,
122129
}),
123130
};
124131
});

packages/api/src/__tests__/runtimeProfiles.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { Hono } from "hono";
33
import { eq } from "drizzle-orm";
44
import { createTestDb } from "@aif/shared/server";
@@ -62,8 +62,15 @@ function createApp() {
6262

6363
describe("runtimeProfiles API", () => {
6464
let app: ReturnType<typeof createApp>;
65+
let previousUsageLimitsEnv: string | undefined;
6566

6667
beforeEach(() => {
68+
previousUsageLimitsEnv = process.env.AIF_USAGE_LIMITS_ENABLED;
69+
// Flag defaults to false (opt-in). Most tests here exercise the live
70+
// refresh path, so enable it per-test. Cases that need the disabled
71+
// path override and restore this inside the test body.
72+
process.env.AIF_USAGE_LIMITS_ENABLED = "true";
73+
resetEnvCache();
6774
testDb.current = createTestDb();
6875
app = createApp();
6976
mockValidateConnection.mockReset();
@@ -109,6 +116,15 @@ describe("runtimeProfiles API", () => {
109116
]);
110117
});
111118

119+
afterEach(() => {
120+
if (previousUsageLimitsEnv === undefined) {
121+
delete process.env.AIF_USAGE_LIMITS_ENABLED;
122+
} else {
123+
process.env.AIF_USAGE_LIMITS_ENABLED = previousUsageLimitsEnv;
124+
}
125+
resetEnvCache();
126+
});
127+
112128
it("lists runtime descriptors", async () => {
113129
const res = await app.request("/runtime-profiles/runtimes");
114130
expect(res.status).toBe(200);

0 commit comments

Comments
 (0)