@@ -2,7 +2,7 @@ import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
22import { tmpdir } from "node:os" ;
33import { delimiter , join } from "node:path" ;
44import { afterEach , describe , expect , it , vi } from "vitest" ;
5- import { assertNoLegacySharedAiEnv , buildProvider , claudeErrorStatus , codexErrorFromStdout , createAnthropicAi , createChainAi , createClaudeCodeAi , createCodexAi , createOpenAiCompatibleAi , createSelfHostAi , extractCliText , extractCliUsage , isAiProviderHealthy , markAiProviderUnhealthyAtBoot , resetAiProviderCircuitBreakerForTest , resetAiProviderHealthForTest , resolveAiReviewerPlan , resolveClaudeCliTimeoutMs , resolveCodexAuthPath , resolveCodexCliTimeoutMs , resolveCodexEffort , resolveCodexFirstOutputTimeoutMs , resolveEffort , resolveModel , resolveProviderNames , resolveRequiredCliProviders , resolveSubscriptionCliPath , redactSecrets , routeProviders , shouldMarkAiProviderUnhealthyAtBoot , subscriptionCliEnv , withAdvisoryAiEnv } from "../../src/selfhost/ai" ;
5+ import { assertNoLegacySharedAiEnv , buildProvider , claudeErrorStatus , codexErrorFromStdout , createAnthropicAi , createChainAi , createClaudeCodeAi , createCodexAi , createOpenAiCompatibleAi , createSelfHostAi , extractCliText , extractCliUsage , isAiProviderHealthy , markAiProviderUnhealthyAtBoot , resetAiProviderCircuitBreakerForTest , resetAiProviderHealthForTest , resolveAiReviewerPlan , resolveClaudeCliTimeoutMs , resolveClaudeFirstOutputTimeoutMs , resolveCodexAuthPath , resolveCodexCliTimeoutMs , resolveCodexEffort , resolveCodexFirstOutputTimeoutMs , resolveEffort , resolveModel , resolveProviderNames , resolveRequiredCliProviders , resolveSubscriptionCliPath , redactSecrets , routeProviders , shouldMarkAiProviderUnhealthyAtBoot , subscriptionCliEnv , withAdvisoryAiEnv } from "../../src/selfhost/ai" ;
66import { labelSelfHostReviewerModel , labelSelfHostReviewerModels } from "../../src/selfhost/ai-config" ;
77import { renderMetrics , resetMetrics } from "../../src/selfhost/metrics" ;
88
@@ -78,6 +78,22 @@ describe("provider-specific CLI timeouts (#selfhost — no shared timeout ambigu
7878 // zero/negative also falls back (raw > 0 false branch)
7979 expect ( resolveCodexFirstOutputTimeoutMs ( { CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS : "0" } ) ) . toBe ( 30_000 ) ;
8080 } ) ;
81+ it ( "resolveClaudeFirstOutputTimeoutMs defaults to 30s, is independent of effort, and honors + clamps CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS (#4994)" , ( ) => {
82+ // absent → the 30s default (?? right side)
83+ expect ( resolveClaudeFirstOutputTimeoutMs ( { } ) ) . toBe ( 30_000 ) ;
84+ // effort must NOT scale this deadline — a slow COMPLETION is not a slow first byte.
85+ expect ( resolveClaudeFirstOutputTimeoutMs ( { CLAUDE_AI_EFFORT : "max" } ) ) . toBe ( 30_000 ) ;
86+ // present + valid → honored verbatim (?? left side, within bounds)
87+ expect ( resolveClaudeFirstOutputTimeoutMs ( { CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS : "15000" } ) ) . toBe ( 15_000 ) ;
88+ // clamped to the 1s floor
89+ expect ( resolveClaudeFirstOutputTimeoutMs ( { CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS : "1" } ) ) . toBe ( 1_000 ) ;
90+ // clamped to the 120s ceiling (well under the shortest full timeout, 120_000ms)
91+ expect ( resolveClaudeFirstOutputTimeoutMs ( { CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS : "999999" } ) ) . toBe ( 120_000 ) ;
92+ // non-finite/garbage falls back to the default (Number.isFinite false branch)
93+ expect ( resolveClaudeFirstOutputTimeoutMs ( { CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS : "not-a-number" } ) ) . toBe ( 30_000 ) ;
94+ // zero/negative also falls back (raw > 0 false branch)
95+ expect ( resolveClaudeFirstOutputTimeoutMs ( { CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS : "0" } ) ) . toBe ( 30_000 ) ;
96+ } ) ;
8197} ) ;
8298
8399afterEach ( ( ) => {
@@ -1348,6 +1364,35 @@ describe("subscription CLI helpers + fail-safe", () => {
13481364 }
13491365 } ) ;
13501366
1367+ // REGRESSION (GITTENSORY-K/M/8/Z, #4994): the real defaultSpawn fast-fail path against a genuinely-hung fake
1368+ // `claude` that writes nothing to either stream and never exits — mirrors the identical codex real-subprocess
1369+ // test below, proving createClaudeCodeAi's plumbing (not just a stubbed spawn) actually wires
1370+ // firstOutputTimeoutMs through to the shared defaultSpawn timer logic.
1371+ it ( "REAL subprocess: a fake claude that never writes to either stream is killed at the fast-fail deadline, not the full timeout" , async ( ) => {
1372+ const dir = mkdtempSync ( join ( tmpdir ( ) , "fakecli-" ) ) ;
1373+ const fake = join ( dir , "claude" ) ;
1374+ writeFileSync ( fake , "#!/usr/bin/env node\nprocess.stdin.on('data',()=>{});\nsetInterval(()=>{},1000);\n" ) ;
1375+ chmodSync ( fake , 0o755 ) ;
1376+ const origPath = process . env . PATH ;
1377+ try {
1378+ const start = Date . now ( ) ;
1379+ await expect (
1380+ createClaudeCodeAi ( {
1381+ PATH : `${ dir } :${ origPath ?? "" } ` ,
1382+ CLAUDE_CODE_OAUTH_TOKEN : "t" ,
1383+ // Full timeout stays large (60s) so a false-pass (hitting the FULL timeout instead of the fast one)
1384+ // would make this test hang for a minute rather than silently succeed for the wrong reason.
1385+ CLAUDE_AI_TIMEOUT_MS : "60000" ,
1386+ CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS : "200" ,
1387+ } ) . run ( "sonnet" , { prompt : "hello" } ) ,
1388+ ) . rejects . toThrow ( / c l a u d e _ s t a l l e d _ n o _ o u t p u t / ) ;
1389+ // Killed at ~200ms (the fast-fail deadline), nowhere near the 60_000ms full timeout.
1390+ expect ( Date . now ( ) - start ) . toBeLessThan ( 5_000 ) ;
1391+ } finally {
1392+ process . env . PATH = origPath ;
1393+ }
1394+ } , 10_000 ) ;
1395+
13511396 it ( "drives the REAL subprocess (defaultSpawn) against a fake `codex` on PATH" , async ( ) => {
13521397 const dir = mkdtempSync ( join ( tmpdir ( ) , "fakecli-" ) ) ;
13531398 const fake = join ( dir , "codex" ) ;
@@ -1540,6 +1585,32 @@ describe("subscription CLI helpers + fail-safe", () => {
15401585 ) ;
15411586 } ) ;
15421587
1588+ it ( "REGRESSION (GITTENSORY-K/M/8/Z, #4994): a stalled-no-output timeout is thrown as claude_stalled_no_output, distinct from subscription_cli_timeout, and passes firstOutputTimeoutMs through to spawn" , async ( ) => {
1589+ let capturedOpts : { timeoutMs : number ; firstOutputTimeoutMs ?: number } | undefined ;
1590+ const stalled : StubSpawn = async ( _cmd , _args , o ) => {
1591+ capturedOpts = o ;
1592+ return { stdout : "" , code : null , stderr : "" , timedOut : true , stalledNoOutput : true } ;
1593+ } ;
1594+ await expect ( createClaudeCodeAi ( { CLAUDE_CODE_OAUTH_TOKEN : "t" } , stalled ) . run ( "m" , { prompt : "x" } ) ) . rejects . toThrow (
1595+ / c l a u d e _ s t a l l e d _ n o _ o u t p u t / ,
1596+ ) ;
1597+ // Never the generic message — the whole point is that these two failure modes are separately observable.
1598+ await expect ( createClaudeCodeAi ( { CLAUDE_CODE_OAUTH_TOKEN : "t" } , stalled ) . run ( "m" , { prompt : "x" } ) ) . rejects . not . toThrow (
1599+ / ^ s u b s c r i p t i o n _ c l i _ t i m e o u t / ,
1600+ ) ;
1601+ // The fast-fail deadline defaults to 30s and is strictly less than the (180s-default) full timeout.
1602+ expect ( capturedOpts ?. firstOutputTimeoutMs ) . toBe ( 30_000 ) ;
1603+ expect ( capturedOpts ?. timeoutMs ) . toBe ( 180_000 ) ;
1604+ expect ( capturedOpts ?. firstOutputTimeoutMs ) . toBeLessThan ( capturedOpts ! . timeoutMs ) ;
1605+ } ) ;
1606+
1607+ it ( "a full timeout WITHOUT stalledNoOutput still throws the generic subscription_cli_timeout, not claude_stalled_no_output (some output was produced before the kill)" , async ( ) => {
1608+ const timedOutWithOutput : StubSpawn = async ( ) => ( { stdout : "partial output before kill" , code : null , timedOut : true , stalledNoOutput : false } ) ;
1609+ await expect ( createClaudeCodeAi ( { CLAUDE_CODE_OAUTH_TOKEN : "t" } , timedOutWithOutput ) . run ( "m" , { prompt : "x" } ) ) . rejects . toThrow (
1610+ / ^ s u b s c r i p t i o n _ c l i _ t i m e o u t $ / ,
1611+ ) ;
1612+ } ) ;
1613+
15431614 it ( "REGRESSION (GITTENSORY-K/GITTENSORY-M): a stalled-no-output timeout is thrown as codex_stalled_no_output, distinct from codex_timeout, and passes firstOutputTimeoutMs through to spawn" , async ( ) => {
15441615 let capturedOpts : { timeoutMs : number ; firstOutputTimeoutMs ?: number } | undefined ;
15451616 const stalled : StubSpawn = async ( _cmd , _args , o ) => {
@@ -1768,7 +1839,7 @@ describe("subscription CLI helpers + fail-safe", () => {
17681839 }
17691840 } ) ;
17701841
1771- it ( "defaultSpawn's spawn-error handler clears whichever timers were actually armed — firstOutputTimer present (codex) vs absent (claude-code )" , async ( ) => {
1842+ it ( "defaultSpawn's spawn-error handler clears the firstOutputTimer for both providers (#4994: both now arm one )" , async ( ) => {
17721843 // Explicit env (no ambient CODEX_HOME / GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER inherited from the operator's
17731844 // shell) so this reaches the REAL ENOENT spawn error deterministically, rather than short-circuiting on the
17741845 // credential-isolation guard the way an ambient CODEX_HOME would.
@@ -1778,8 +1849,9 @@ describe("subscription CLI helpers + fail-safe", () => {
17781849 { prompt : "x" } ,
17791850 ) ,
17801851 ) . rejects . toThrow ( / E N O E N T / ) ;
1781- // Claude Code never sets firstOutputTimeoutMs (no comparable prod hang), so this exercises the SAME spawn()
1782- // error path's firstOutputTimer-ABSENT branch — the option is simply never passed for this provider.
1852+ // Claude Code now also passes firstOutputTimeoutMs (#4994) — this exercises the SAME spawn() error path's
1853+ // firstOutputTimer-PRESENT branch for claude too, proving the error handler clears it cleanly (no leaked
1854+ // timer, no unhandled rejection) rather than only ever having been exercised via codex.
17831855 await expect (
17841856 createClaudeCodeAi ( { PATH : "/nonexistent-gittensory-empty" , CLAUDE_CODE_OAUTH_TOKEN : "t" } ) . run ( "sonnet" , { prompt : "x" } ) ,
17851857 ) . rejects . toThrow ( / E N O E N T / ) ;
0 commit comments