@@ -1083,3 +1083,155 @@ describe("ChatDirector tool-only loop protection", () => {
10831083 } ) ;
10841084 } ) ;
10851085} ) ;
1086+
1087+ // CL-6910: the harness's own retry policy (vendor/intx-inference/src/
1088+ // retry-policy.ts) already owns `timeout`/`retryable`/`quota_exhausted` and
1089+ // exhausts its full attempt budget (3 attempts) before an `inference.error`
1090+ // of one of those categories ever reaches the director. The director must
1091+ // not re-wrap those categories in another `capabilities.infer()` call — that
1092+ // multiplied the two layers' attempt budgets (up to 9 identical full-context
1093+ // sends per turn) instead of composing them. `aborted` (internal-recovery)
1094+ // is the one category the harness never retries at all, so it remains the
1095+ // director's to recover, and that recovery does not compound with harness
1096+ // attempts.
1097+ function inferenceErrorEvent (
1098+ category : "retryable" | "timeout" | "aborted" | "quota_exhausted" ,
1099+ raw ?: unknown ,
1100+ ) : ReactorInboundEvent {
1101+ return {
1102+ type : "inference.error" ,
1103+ error : { category, message : "boom" , raw } ,
1104+ } as unknown as ReactorInboundEvent ;
1105+ }
1106+
1107+ describe ( "ChatDirector inference-error recovery (CL-6910)" , ( ) => {
1108+ const providerlessPolicy = { providerName : "test-provider" } ;
1109+
1110+ test . each ( [ "retryable" , "timeout" , "quota_exhausted" ] as const ) (
1111+ "does not re-issue inference for a %s error already exhausted by the harness" ,
1112+ async ( category ) => {
1113+ const director = createChatDirector ( "system" , [ ] , {
1114+ onTasksChange : ( ) => { } ,
1115+ provider : providerlessPolicy ,
1116+ } ) ;
1117+ const capabilities = makeCapabilities ( ) ;
1118+
1119+ const actions = actionsArray (
1120+ await director . decide ( inferenceErrorEvent ( category ) , mockState , capabilities ) ,
1121+ ) ;
1122+
1123+ // No additional full-context send: the base director's terminal
1124+ // checkpoint + reply is the only outcome, not another `infer`.
1125+ expect ( actions . some ( ( a ) => a . type === "infer" ) ) . toBe ( false ) ;
1126+ expect ( actions . some ( ( a ) => a . type === "reply" ) ) . toBe ( true ) ;
1127+ } ,
1128+ ) ;
1129+
1130+ test ( "still recovers on internal-recovery abort, bounded by MAX_INFERENCE_RECOVERIES" , async ( ) => {
1131+ const director = createChatDirector ( "system" , [ ] , {
1132+ onTasksChange : ( ) => { } ,
1133+ provider : providerlessPolicy ,
1134+ } ) ;
1135+ const capabilities = makeCapabilities ( ) ;
1136+ const internalAbort = inferenceErrorEvent ( "aborted" , { origin : "internal-recovery" } ) ;
1137+
1138+ // Recovery 1 of 2: re-issues inference.
1139+ const first = actionsArray ( await director . decide ( internalAbort , mockState , capabilities ) ) ;
1140+ expect ( first . some ( ( a ) => a . type === "infer" ) ) . toBe ( true ) ;
1141+
1142+ // Recovery 2 of 2: re-issues inference.
1143+ const second = actionsArray ( await director . decide ( internalAbort , mockState , capabilities ) ) ;
1144+ expect ( second . some ( ( a ) => a . type === "infer" ) ) . toBe ( true ) ;
1145+
1146+ // Budget exhausted: no further infer, terminal reply instead.
1147+ const third = actionsArray ( await director . decide ( internalAbort , mockState , capabilities ) ) ;
1148+ expect ( third . some ( ( a ) => a . type === "infer" ) ) . toBe ( false ) ;
1149+ expect ( third . some ( ( a ) => a . type === "reply" ) ) . toBe ( true ) ;
1150+ } ) ;
1151+
1152+ test ( "an unrelated aborted error (not internal-recovery) is not recovered by the director" , async ( ) => {
1153+ const director = createChatDirector ( "system" , [ ] , {
1154+ onTasksChange : ( ) => { } ,
1155+ provider : providerlessPolicy ,
1156+ } ) ;
1157+ const capabilities = makeCapabilities ( ) ;
1158+
1159+ const actions = actionsArray (
1160+ await director . decide (
1161+ inferenceErrorEvent ( "aborted" , { origin : "user-stop" } ) ,
1162+ mockState ,
1163+ capabilities ,
1164+ ) ,
1165+ ) ;
1166+ expect ( actions . some ( ( a ) => a . type === "infer" ) ) . toBe ( false ) ;
1167+ } ) ;
1168+
1169+ test ( "inference-recovery budget resets at the next turn boundary" , async ( ) => {
1170+ const director = createChatDirector ( "system" , [ ] , {
1171+ onTasksChange : ( ) => { } ,
1172+ provider : providerlessPolicy ,
1173+ } ) ;
1174+ const capabilities = makeCapabilities ( ) ;
1175+ const internalAbort = inferenceErrorEvent ( "aborted" , { origin : "internal-recovery" } ) ;
1176+
1177+ await director . decide ( internalAbort , mockState , capabilities ) ;
1178+ await director . decide ( internalAbort , mockState , capabilities ) ;
1179+ // Budget exhausted for this turn.
1180+ const exhausted = actionsArray ( await director . decide ( internalAbort , mockState , capabilities ) ) ;
1181+ expect ( exhausted . some ( ( a ) => a . type === "infer" ) ) . toBe ( false ) ;
1182+
1183+ // A fresh turn boundary (inference.done) resets the budget.
1184+ await director . decide ( toolOnlyTurn ( "post-boundary" ) , mockState , capabilities ) ;
1185+ const afterBoundary = actionsArray (
1186+ await director . decide ( internalAbort , mockState , capabilities ) ,
1187+ ) ;
1188+ expect ( afterBoundary . some ( ( a ) => a . type === "infer" ) ) . toBe ( true ) ;
1189+ } ) ;
1190+
1191+ // Bounds the worst-case number of on-wire full-context sends per logical
1192+ // turn across the two layers that can legitimately fire: the harness's
1193+ // own retry policy (up to 3 attempts per `infer()` call — see
1194+ // vendor/intx-inference/src/retry-policy.ts MAX_ATTEMPTS) and the
1195+ // director's internal-recovery-only budget (up to 2 extra `infer()`
1196+ // calls). Before this fix, `retryable`/`timeout` re-entered this same
1197+ // director budget on top of the harness's exhausted 3, multiplying to 9.
1198+ // After this fix, `retryable`/`timeout`/`quota_exhausted` are harness-only
1199+ // (bounded at 3, asserted against createDefaultRetryPolicy behavior in
1200+ // retry-policy.test.ts), and `aborted` is director-only: each of the
1201+ // director's up-to-3 infer() calls (1 initial + 2 recoveries) is a single
1202+ // harness attempt because the harness's own policy never retries
1203+ // `aborted`. Worst case across a turn that alternates categories is
1204+ // bounded, not open-ended, and never reaches 9.
1205+ test ( "worst case: director-owned recovery path issues at most 1 + MAX_INFERENCE_RECOVERIES infer calls" , async ( ) => {
1206+ const director = createChatDirector ( "system" , [ ] , {
1207+ onTasksChange : ( ) => { } ,
1208+ provider : providerlessPolicy ,
1209+ } ) ;
1210+ const capabilities = makeCapabilities ( ) ;
1211+ const internalAbort = inferenceErrorEvent ( "aborted" , { origin : "internal-recovery" } ) ;
1212+
1213+ let inferCount = 0 ;
1214+ for ( let i = 0 ; i < 10 ; i ++ ) {
1215+ const actions = actionsArray ( await director . decide ( internalAbort , mockState , capabilities ) ) ;
1216+ if ( actions . some ( ( a ) => a . type === "infer" ) ) inferCount ++ ;
1217+ else break ;
1218+ }
1219+ expect ( inferCount ) . toBe ( 2 ) ; // MAX_INFERENCE_RECOVERIES
1220+ } ) ;
1221+
1222+ test ( "timeout category produces the timeout preamble, not the fatal fallback" , async ( ) => {
1223+ const director = createChatDirector ( "system" , [ ] , {
1224+ onTasksChange : ( ) => { } ,
1225+ provider : providerlessPolicy ,
1226+ } ) ;
1227+ const capabilities = makeCapabilities ( ) ;
1228+
1229+ const actions = actionsArray (
1230+ await director . decide ( inferenceErrorEvent ( "timeout" ) , mockState , capabilities ) ,
1231+ ) ;
1232+ const reply = actions . find ( ( a ) => a . type === "reply" ) ;
1233+ expect ( reply ) . toBeDefined ( ) ;
1234+ expect ( ( reply as { content : string } ) . content ) . toContain ( "did not respond in time" ) ;
1235+ expect ( ( reply as { content : string } ) . content ) . not . toContain ( "unrecoverable inference error" ) ;
1236+ } ) ;
1237+ } ) ;
0 commit comments