diff --git a/src/server/management/request-history-routes.ts b/src/server/management/request-history-routes.ts index fd9df76550..882331510b 100644 --- a/src/server/management/request-history-routes.ts +++ b/src/server/management/request-history-routes.ts @@ -151,6 +151,11 @@ export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promis return jsonResponse({ requestId, routeDecision: trace, + // The account decision belongs in the why-this-route answer: a rebound with its cause is + // the difference between "the pool moved this conversation" and "this is a new session". + affinity: entry.affinity + ? { move: entry.affinity, reason: entry.affinityReason ?? null } + : null, attemptSequence: entry.attempts ?? [], outcome: { status: entry.status, diff --git a/src/usage/log.ts b/src/usage/log.ts index 51a682910e..a15cc8b256 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -2,6 +2,7 @@ import { createHash, type Hash } from "node:crypto"; import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config"; +import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing"; import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { sanitizeLogMetadataString } from "../lib/redact"; @@ -187,6 +188,13 @@ export interface PersistedUsageEntry { transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; /** Whether the terminal came from upstream or a proxy-generated tail. */ terminalSource?: "upstream" | "synthetic"; + /** + * What happened to this request's Codex pool binding, and why (#4546). A move discards the + * prompt-cache prefix warmed on the previous account, so it is recorded as an event rather + * than left to be inferred from account labels across rows. Additive; older rows omit it. + */ + affinity?: CodexAffinityMove; + affinityReason?: CodexAffinityReason; /** * Bounded route-decision trace (RI-01): why this provider/model/account was * selected. Additive field; old rows without it parse unchanged. Never @@ -248,6 +256,28 @@ export function isKnownTerminalSource(value: unknown): value is NonNullable); } +/** + * The persisted entry is built by an explicit whitelist, so a field the writer sets but this + * normalizer does not name is dropped without a word. #4592 added the affinity record at the + * call site and it never reached disk for exactly that reason. + */ +const KNOWN_AFFINITY_MOVES = new Set>([ + "reused", "held", "detour", "rebound", "new_bind", "cleared", +]); +const KNOWN_AFFINITY_REASONS = new Set>([ + "healthy", "quota_headroom", "quota_refusal", "transient", "transient_hold_expired", + "unusable", "paused", "plan_excluded", "cooldown", "quota_avoided", "generation", + "expired", "model_lane", +]); + +export function isKnownAffinityMove(value: unknown): value is NonNullable { + return typeof value === "string" && KNOWN_AFFINITY_MOVES.has(value as NonNullable); +} + +export function isKnownAffinityReason(value: unknown): value is NonNullable { + return typeof value === "string" && KNOWN_AFFINITY_REASONS.has(value as NonNullable); +} + export function usageLogPath(configDir?: string): string { return join(configDir ?? getConfigDir(), "usage.jsonl"); } @@ -590,6 +620,11 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); const transportPhase = isKnownTransportPhase(entry.transportPhase) ? entry.transportPhase : undefined; const terminalSource = isKnownTerminalSource(entry.terminalSource) ? entry.terminalSource : undefined; + const affinity = isKnownAffinityMove(entry.affinity) ? entry.affinity : undefined; + // A reason without a move describes nothing, so it is only kept alongside one. + const affinityReason = affinity !== undefined && isKnownAffinityReason(entry.affinityReason) + ? entry.affinityReason + : undefined; const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; @@ -660,6 +695,8 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(Array.isArray(entry.attempts) ? { attempts } : {}), ...(transportPhase ? { transportPhase } : {}), ...(terminalSource ? { terminalSource } : {}), + ...(affinity ? { affinity } : {}), + ...(affinityReason ? { affinityReason } : {}), ...(entry.errorCode ? { errorCode: entry.errorCode } : {}), ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}), ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), diff --git a/tests/cli/route-explainability.test.ts b/tests/cli/route-explainability.test.ts index 0a583692f5..2b813464ff 100644 --- a/tests/cli/route-explainability.test.ts +++ b/tests/cli/route-explainability.test.ts @@ -120,6 +120,27 @@ describe("route explainability (RI-09)", () => { expect(response.status).toBe(404); }); + test("the account decision and its cause are part of the route explanation (#4546)", async () => { + // An operator asking why a request is on this account should not have to compare account + // labels across rows, which is how the original incident had to be diagnosed. + appendUsageEntry({ + ...tracedEntry("explain-affinity"), + affinity: "rebound", + affinityReason: "quota_refusal", + }); + const response = await apiGet("/api/request-history/explain-affinity/route-decision", config()); + expect(response.status).toBe(200); + const body = await response.json() as { affinity?: { move?: string; reason?: string | null } }; + expect(body.affinity).toEqual({ move: "rebound", reason: "quota_refusal" }); + }); + + test("a row with no account decision explains with a null affinity block", async () => { + appendUsageEntry(tracedEntry("explain-no-affinity")); + const response = await apiGet("/api/request-history/explain-no-affinity/route-decision", config()); + const body = await response.json() as { affinity?: unknown }; + expect(body.affinity).toBeNull(); + }); + test("pre-trace rows explain with null routeDecision and their attempts", async () => { appendUsageEntry({ requestId: "legacy-row",