diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts index 219904da42..aa452f1cb3 100644 --- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts +++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts @@ -7,11 +7,11 @@ export type SelfHostEnvReferenceRow = { export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ { name: "AI_COMBINE", - firstReference: "src/selfhost/ai.ts:1028", + firstReference: "src/selfhost/ai.ts:1080", }, { name: "AI_DUAL_REVIEW", - firstReference: "src/selfhost/ai.ts:1003", + firstReference: "src/selfhost/ai.ts:1055", }, { name: "AI_EMBED_API_KEY", @@ -23,11 +23,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "AI_EMBED_MODEL", - firstReference: "src/selfhost/ai.ts:900", + firstReference: "src/selfhost/ai.ts:952", }, { name: "AI_ON_MERGE", - firstReference: "src/selfhost/ai.ts:1030", + firstReference: "src/selfhost/ai.ts:1082", }, { name: "AI_PROVIDER", @@ -35,15 +35,15 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "ANTHROPIC_AI_BASE_URL", - firstReference: "src/selfhost/ai.ts:904", + firstReference: "src/selfhost/ai.ts:956", }, { name: "ANTHROPIC_AI_MODEL", - firstReference: "src/selfhost/ai.ts:87", + firstReference: "src/selfhost/ai.ts:96", }, { name: "ANTHROPIC_API_KEY", - firstReference: "src/selfhost/ai.ts:903", + firstReference: "src/selfhost/ai.ts:955", }, { name: "BACKUP_ACKNOWLEDGED", @@ -55,31 +55,31 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "CLAUDE_AI_EFFORT", - firstReference: "src/selfhost/ai.ts:138", + firstReference: "src/selfhost/ai.ts:147", }, { name: "CLAUDE_AI_MODEL", - firstReference: "src/selfhost/ai.ts:79", + firstReference: "src/selfhost/ai.ts:88", }, { name: "CLAUDE_AI_TIMEOUT_MS", - firstReference: "src/selfhost/ai.ts:138", + firstReference: "src/selfhost/ai.ts:147", }, { name: "CODEX_AI_EFFORT", - firstReference: "src/selfhost/ai.ts:142", + firstReference: "src/selfhost/ai.ts:151", }, { name: "CODEX_AI_MODEL", - firstReference: "src/selfhost/ai.ts:83", + firstReference: "src/selfhost/ai.ts:92", }, { name: "CODEX_AI_TIMEOUT_MS", - firstReference: "src/selfhost/ai.ts:142", + firstReference: "src/selfhost/ai.ts:151", }, { name: "CODEX_HOME", - firstReference: "src/selfhost/ai.ts:309", + firstReference: "src/selfhost/ai.ts:318", }, { name: "CRON_INTERVAL_MS", @@ -151,7 +151,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "HOME", - firstReference: "src/selfhost/ai.ts:309", + firstReference: "src/selfhost/ai.ts:318", }, { name: "MAINTENANCE_ADMISSION_DEFER_MS", @@ -199,39 +199,39 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "OLLAMA_AI_API_KEY", - firstReference: "src/selfhost/ai.ts:897", + firstReference: "src/selfhost/ai.ts:949", }, { name: "OLLAMA_AI_BASE_URL", - firstReference: "src/selfhost/ai.ts:893", + firstReference: "src/selfhost/ai.ts:945", }, { name: "OLLAMA_AI_MODEL", - firstReference: "src/selfhost/ai.ts:91", + firstReference: "src/selfhost/ai.ts:100", }, { name: "OPENAI_AI_BASE_URL", - firstReference: "src/selfhost/ai.ts:895", + firstReference: "src/selfhost/ai.ts:947", }, { name: "OPENAI_AI_MODEL", - firstReference: "src/selfhost/ai.ts:92", + firstReference: "src/selfhost/ai.ts:101", }, { name: "OPENAI_API_KEY", - firstReference: "src/selfhost/ai.ts:897", + firstReference: "src/selfhost/ai.ts:949", }, { name: "OPENAI_COMPATIBLE_AI_API_KEY", - firstReference: "src/selfhost/ai.ts:897", + firstReference: "src/selfhost/ai.ts:949", }, { name: "OPENAI_COMPATIBLE_AI_BASE_URL", - firstReference: "src/selfhost/ai.ts:896", + firstReference: "src/selfhost/ai.ts:948", }, { name: "OPENAI_COMPATIBLE_AI_MODEL", - firstReference: "src/selfhost/ai.ts:93", + firstReference: "src/selfhost/ai.ts:102", }, { name: "ORB_AIR_GAP", @@ -390,25 +390,25 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| Name | First reference |", "| --- | --- |", - "| `AI_COMBINE` | `src/selfhost/ai.ts:1028` |", - "| `AI_DUAL_REVIEW` | `src/selfhost/ai.ts:1003` |", + "| `AI_COMBINE` | `src/selfhost/ai.ts:1080` |", + "| `AI_DUAL_REVIEW` | `src/selfhost/ai.ts:1055` |", "| `AI_EMBED_API_KEY` | `src/server.ts:441` |", "| `AI_EMBED_BASE_URL` | `src/server.ts:438` |", - "| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:900` |", - "| `AI_ON_MERGE` | `src/selfhost/ai.ts:1030` |", + "| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:952` |", + "| `AI_ON_MERGE` | `src/selfhost/ai.ts:1082` |", "| `AI_PROVIDER` | `src/selfhost/ai-config.ts:43` |", - "| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:904` |", - "| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:87` |", - "| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts:903` |", + "| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:956` |", + "| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:96` |", + "| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts:955` |", "| `BACKUP_ACKNOWLEDGED` | `src/server.ts:380` |", "| `BROWSER_WS_ENDPOINT` | `src/selfhost/stubs/puppeteer.ts:11` |", - "| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts:138` |", - "| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts:79` |", - "| `CLAUDE_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:138` |", - "| `CODEX_AI_EFFORT` | `src/selfhost/ai.ts:142` |", - "| `CODEX_AI_MODEL` | `src/selfhost/ai.ts:83` |", - "| `CODEX_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:142` |", - "| `CODEX_HOME` | `src/selfhost/ai.ts:309` |", + "| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts:147` |", + "| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts:88` |", + "| `CLAUDE_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:147` |", + "| `CODEX_AI_EFFORT` | `src/selfhost/ai.ts:151` |", + "| `CODEX_AI_MODEL` | `src/selfhost/ai.ts:92` |", + "| `CODEX_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:151` |", + "| `CODEX_HOME` | `src/selfhost/ai.ts:318` |", "| `CRON_INTERVAL_MS` | `src/server.ts:919` |", "| `DATABASE_PATH` | `src/server.ts:250` |", "| `DATABASE_URL` | `src/selfhost/preflight.ts:201` |", @@ -426,7 +426,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `GITHUB_INSTALLATION_CONCURRENCY_LIMIT` | `src/selfhost/installation-concurrency-admission.ts:43` |", "| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:289` |", "| `GITTENSORY_VERSION` | `src/selfhost/otel.ts:62` |", - "| `HOME` | `src/selfhost/ai.ts:309` |", + "| `HOME` | `src/selfhost/ai.ts:318` |", "| `MAINTENANCE_ADMISSION_DEFER_MS` | `src/selfhost/maintenance-admission.ts:171` |", "| `MAINTENANCE_ADMISSION_DRAIN_AGE_MS` | `src/selfhost/maintenance-admission.ts:145` |", "| `MAINTENANCE_ADMISSION_ENABLED` | `src/selfhost/maintenance-admission.ts:126` |", @@ -438,15 +438,15 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `MIGRATIONS_DIR` | `src/server.ts:393` |", "| `OBSERVABILITY_SMOKE_POLL_MS` | `scripts/smoke-observability-traces.mjs:8` |", "| `OBSERVABILITY_SMOKE_TIMEOUT_MS` | `scripts/smoke-observability-traces.mjs:6` |", - "| `OLLAMA_AI_API_KEY` | `src/selfhost/ai.ts:897` |", - "| `OLLAMA_AI_BASE_URL` | `src/selfhost/ai.ts:893` |", - "| `OLLAMA_AI_MODEL` | `src/selfhost/ai.ts:91` |", - "| `OPENAI_AI_BASE_URL` | `src/selfhost/ai.ts:895` |", - "| `OPENAI_AI_MODEL` | `src/selfhost/ai.ts:92` |", - "| `OPENAI_API_KEY` | `src/selfhost/ai.ts:897` |", - "| `OPENAI_COMPATIBLE_AI_API_KEY` | `src/selfhost/ai.ts:897` |", - "| `OPENAI_COMPATIBLE_AI_BASE_URL` | `src/selfhost/ai.ts:896` |", - "| `OPENAI_COMPATIBLE_AI_MODEL` | `src/selfhost/ai.ts:93` |", + "| `OLLAMA_AI_API_KEY` | `src/selfhost/ai.ts:949` |", + "| `OLLAMA_AI_BASE_URL` | `src/selfhost/ai.ts:945` |", + "| `OLLAMA_AI_MODEL` | `src/selfhost/ai.ts:100` |", + "| `OPENAI_AI_BASE_URL` | `src/selfhost/ai.ts:947` |", + "| `OPENAI_AI_MODEL` | `src/selfhost/ai.ts:101` |", + "| `OPENAI_API_KEY` | `src/selfhost/ai.ts:949` |", + "| `OPENAI_COMPATIBLE_AI_API_KEY` | `src/selfhost/ai.ts:949` |", + "| `OPENAI_COMPATIBLE_AI_BASE_URL` | `src/selfhost/ai.ts:948` |", + "| `OPENAI_COMPATIBLE_AI_MODEL` | `src/selfhost/ai.ts:102` |", "| `ORB_AIR_GAP` | `src/selfhost/orb-collector.ts:161` |", "| `ORB_ANONYMIZE` | `src/selfhost/orb-collector.ts:174` |", "| `ORB_APP_ID` | `src/selfhost/orb-collector.ts:59` |", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 9026421b40..c5a23aa185 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -6151,6 +6151,10 @@ export async function runAiReviewForAdvisory( // (the per-repo toggle). ANDed here with the operator flag + cutover allowlist to decide whether to ASK the // model for line-anchored inline findings. Absent/false ⇒ the reviewer prompt is byte-identical (no findings). reviewInlineComments?: boolean | undefined; + // The inbound webhook delivery id that triggered this review (#codex-timeout-fields) — forwarded to a + // self-host provider's failure log purely for operator correlation; never read by any review logic. Absent + // (e.g. a sweep/repair fan-out with no single originating delivery, or a unit test) ⇒ the log line omits it. + deliveryId?: string | undefined; }, ): Promise< | { @@ -6378,6 +6382,7 @@ export async function runAiReviewForAdvisory( diff: enrichmentDiff, actor: args.author, mode: args.settings.aiReviewMode === "block" ? "block" : "advisory", + jobId: args.deliveryId, providerKey, grounding, ragContext: ragContextResult?.text, @@ -7943,6 +7948,7 @@ async function maybePublishPrPublicSurface( reviewInstructions, reviewExcludePaths, reviewInlineComments, + deliveryId: webhook.deliveryId, }); // `persistable === false` (only the lock-contention placeholder — see runAiReviewForAdvisory's return // type doc comment) is excluded from EVERY write, not just the durable one: it describes a transient diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 6fcc64df29..0ffad0f690 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -20,6 +20,15 @@ interface AiRunOptions { text?: string[]; // embedding input — the core's embedTexts passes { text: string[] } max_tokens?: number; temperature?: number; + // Correlation context for a provider-failure log (#codex-timeout-fields): purely observational, never read by a + // provider's own request logic. The caller (runWorkersOpinion) passes whatever of these it already has in scope + // for THIS review — job id and attempt are per-attempt, repoFullName/pullNumber identify the PR being reviewed — + // so an operator can correlate a `selfhost_ai_provider_failed` line back to the job/PR without cross-referencing + // timestamps. All optional: absent ⇒ the log line is byte-identical to before. + jobId?: string; + repoFullName?: string; + pullNumber?: number; + attempt?: number; } /** A chat completion (`response`) or an embedding result (`data`). Both optional: the core reads whichever it * asked for (extractAiText → `response`, embedTexts → `data`), each defensive about the other being absent. @@ -580,6 +589,13 @@ function logSelfHostAiProviderFailed(input: { timeoutMs?: number | undefined; error: unknown; knownSecrets?: readonly string[] | undefined; + // Correlation context (#codex-timeout-fields), forwarded from the caller's AiRunOptions when supplied — never + // fabricated here. Undefined fields are dropped by JSON.stringify, so an omitted value keeps the log line + // byte-identical to before this field existed. + jobId?: string | undefined; + repoFullName?: string | undefined; + pullNumber?: number | undefined; + attempt?: number | undefined; }): void { console.error( JSON.stringify({ @@ -589,6 +605,10 @@ function logSelfHostAiProviderFailed(input: { model: input.model || "default", effort: input.effort, timeoutMs: input.timeoutMs, + jobId: input.jobId, + repoFullName: input.repoFullName, + pullNumber: input.pullNumber, + attempt: input.attempt, error: errorMessage(input.error, input.knownSecrets), }), ); @@ -636,7 +656,18 @@ export function createClaudeCodeAi(parentEnv: Record if (!text) throw new Error("claude_code_empty_output"); return { response: text, usage: cliUsageFromStdout("claude-code", claudeModel, effort, stdoutForMetrics) }; } catch (error) { - logSelfHostAiProviderFailed({ provider: "claude-code", model: claudeModel, effort, timeoutMs, error, knownSecrets: token ? [token] : [] }); + logSelfHostAiProviderFailed({ + provider: "claude-code", + model: claudeModel, + effort, + timeoutMs, + error, + knownSecrets: token ? [token] : [], + jobId: options.jobId, + repoFullName: options.repoFullName, + pullNumber: options.pullNumber, + attempt: options.attempt, + }); throw error; } finally { if (attempted) recordCliUsageMetrics("claude-code", claudeModel, effort, stdoutForMetrics); @@ -708,7 +739,17 @@ export function createCodexAi( if (!text) throw new Error("codex_empty_output"); return { response: text, usage: cliUsageFromStdout("codex", codexModel, effort, stdoutForMetrics) }; } catch (error) { - logSelfHostAiProviderFailed({ provider: "codex", model: codexModel, effort, timeoutMs, error }); + logSelfHostAiProviderFailed({ + provider: "codex", + model: codexModel, + effort, + timeoutMs, + error, + jobId: options.jobId, + repoFullName: options.repoFullName, + pullNumber: options.pullNumber, + attempt: options.attempt, + }); throw error; } finally { if (attempted) recordCliUsageMetrics("codex", codexModel, effort, stdoutForMetrics); @@ -805,7 +846,18 @@ export function createChainAi(providers: Array<{ name: string; ai: SelfHostAi }> } catch (error) { lastError = error; failures.push({ provider: p.name, error: errorMessage(error) }); - console.error(JSON.stringify({ level: "warn", event: "selfhost_ai_provider_failed_in_chain", provider: p.name, error: errorMessage(error) })); + console.error( + JSON.stringify({ + level: "warn", + event: "selfhost_ai_provider_failed_in_chain", + provider: p.name, + jobId: options.jobId, + repoFullName: options.repoFullName, + pullNumber: options.pullNumber, + attempt: options.attempt, + error: errorMessage(error), + }), + ); } } recordAiProvidersExhausted(); diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index b203cbcd7b..3cff5f476b 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -278,6 +278,13 @@ export type GittensoryAiReviewInput = { * has ANY test-path changes ⇒ no section is appended (byte-identical to today). */ changedFiles?: ReadonlyArray<{ path: string }> | null | undefined; + /** + * The inbound webhook delivery id that triggered this review (#codex-timeout-fields) — the closest thing this + * queue has to a job id. Forwarded to a self-host provider's `selfhost_ai_provider_failed` log purely for + * operator correlation; never read by any review logic. Absent (e.g. a sweep/repair fan-out with no single + * originating delivery, or a unit test) ⇒ the log line omits it, byte-identical to before this field existed. + */ + jobId?: string | undefined; }; /** A consensus critical defect, already public-safe, ready to become a gate blocker finding. */ @@ -719,6 +726,15 @@ function buildRepoInstructionsSystemAppend(repoInstructions: string | null | und : ""; } +/** Correlation context for a self-host provider-failure log (#codex-timeout-fields): forwarded to `env.AI.run`'s + * options purely for observability, never read by any provider's own request logic. `jobId` is the inbound + * webhook delivery id that triggered this review — the closest thing this queue has to a job id. */ +type AiRunCorrelation = { + jobId?: string | undefined; + repoFullName?: string | undefined; + pullNumber?: number | undefined; +}; + /** One reviewer opinion (whichever provider `env.AI` resolves to — self-host Codex/Claude Code/etc, or the * legacy Workers-AI pair) with a per-slot reliable fallback and a 3× retry on the primary. */ async function runWorkersOpinion( @@ -730,6 +746,7 @@ async function runWorkersOpinion( maxTokens: number, diagnostics: AiReviewDiagnostic[] = [], systemAppend = "", + correlation?: AiRunCorrelation, ): Promise { const ai = env.AI as unknown as AiRunner | undefined; if (!ai || typeof ai.run !== "function") return { review: null }; @@ -764,6 +781,10 @@ async function runWorkersOpinion( { role: "user", content: user }, ], ...(cliSystemAppend ? { systemAppend: cliSystemAppend } : {}), + ...(correlation?.jobId !== undefined ? { jobId: correlation.jobId } : {}), + ...(correlation?.repoFullName !== undefined ? { repoFullName: correlation.repoFullName } : {}), + ...(correlation?.pullNumber !== undefined ? { pullNumber: correlation.pullNumber } : {}), + attempt, }, extra, ); @@ -1340,6 +1361,12 @@ export async function runGittensoryAiReview( let advisoryReview: ModelReview | null; const reviewDiagnostics: AiReviewDiagnostic[] = []; const fallbackNotes: string[] = []; + // Forwarded to a self-host provider's failure log (#codex-timeout-fields) — never anything BYOK-billed reads. + const aiRunCorrelation: AiRunCorrelation = { + jobId: input.jobId, + repoFullName: input.repoFullName, + pullNumber: input.prNumber, + }; if (input.providerKey) { const outcome = await runProviderReview( input.providerKey, @@ -1361,6 +1388,7 @@ export async function runGittensoryAiReview( maxTokens, reviewDiagnostics, repoInstructionsSystemAppend, + aiRunCorrelation, ); advisoryReview = outcome.review; if (outcome.fallbackNote) fallbackNotes.push(outcome.fallbackNote); @@ -1387,6 +1415,7 @@ export async function runGittensoryAiReview( maxTokens, reviewDiagnostics, repoInstructionsSystemAppend, + aiRunCorrelation, ) : Promise.resolve({ review: advisoryReview }), runWorkersOpinion( @@ -1398,6 +1427,7 @@ export async function runGittensoryAiReview( maxTokens, reviewDiagnostics, repoInstructionsSystemAppend, + aiRunCorrelation, ), ]); if (a.fallbackNote) fallbackNotes.push(a.fallbackNote); @@ -1423,6 +1453,7 @@ export async function runGittensoryAiReview( maxTokens, reviewDiagnostics, repoInstructionsSystemAppend, + aiRunCorrelation, ) : ({ review: advisoryReview } as ReviewerOpinionOutcome); if (a.fallbackNote) fallbackNotes.push(a.fallbackNote); diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index e07fc1957d..0bdd693bf1 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -203,6 +203,57 @@ describe("createChainAi (fallback)", () => { const b = { name: "b", ai: { run: async () => { throw new Error("err-b"); } } }; await expect(createChainAi([a, b]).run("m", { prompt: "x" })).rejects.toThrow(/err-b/); }); + + it("REGRESSION (#codex-timeout-fields): a Codex timeout falls through to Claude Code, and the failure log carries job/PR/attempt context with no secret leaked", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const token = "oauth-tok-abcdef123456"; + const timedOut: StubSpawn = async () => ({ stdout: "", code: null, stderr: "connection reset", timedOut: true }); + const claudeOk: StubSpawn = async () => ({ stdout: JSON.stringify({ type: "result", result: "claude review" }), code: 0 }); + const codex = createCodexAi({ GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER: "1" }, timedOut, noAuthCheck); + const claudeCode = createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: token }, claudeOk); + const chain = createChainAi([ + { name: "codex", ai: codex }, + { name: "claude-code", ai: claudeCode }, + ]); + + const result = await chain.run("m", { + prompt: "review this diff", + jobId: "delivery-123", + repoFullName: "JSONbored/gittensory", + pullNumber: 42, + attempt: 0, + }); + + // (a) the review still completes successfully via the fallback provider. + expect(result.response).toBe("claude review"); + + const logged = errorSpy.mock.calls.map((call) => JSON.parse(String(call[0]))); + const codexFailure = logged.find((entry) => entry.event === "selfhost_ai_provider_failed" && entry.provider === "codex"); + const chainFailure = logged.find((entry) => entry.event === "selfhost_ai_provider_failed_in_chain" && entry.provider === "codex"); + + // (b) both the provider-level and chain-level failure logs carry the new correlation fields. + expect(codexFailure).toMatchObject({ jobId: "delivery-123", repoFullName: "JSONbored/gittensory", pullNumber: 42, attempt: 0 }); + expect(chainFailure).toMatchObject({ jobId: "delivery-123", repoFullName: "JSONbored/gittensory", pullNumber: 42, attempt: 0 }); + + // (c) the Codex timeout detail is present but no secret value ever appears in the logged error text. + expect(codexFailure.error).toContain("codex_timeout"); + expect(JSON.stringify(logged)).not.toContain(token); + + errorSpy.mockRestore(); + }); + + it("omits the correlation fields (byte-identical log shape) when the caller supplies none", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const failing = { name: "a", ai: { run: async () => { throw new Error("down"); } } }; + const working = { name: "b", ai: { run: async () => ({ response: "from b" }) } }; + await createChainAi([failing, working]).run("m", { prompt: "x" }); + const logged = JSON.parse(String(errorSpy.mock.calls[0]?.[0])); + expect(logged.jobId).toBeUndefined(); + expect(logged.repoFullName).toBeUndefined(); + expect(logged.pullNumber).toBeUndefined(); + expect(logged.attempt).toBeUndefined(); + errorSpy.mockRestore(); + }); }); describe("per-provider circuit breaker (#2540 — skip fast during a sustained outage)", () => {