@@ -358,52 +358,20 @@ export class WindsurfClient {
358358 }
359359 }
360360
361- // Stall detection — two flavors:
362- // (a) "cold stall": 30s+ ACTIVE but never saw any text or tool
363- // call → planner is deadlocked before even starting to
364- // produce output. Rotate account, don't make the user wait.
365- // (b) "warm stall": we already streamed some text, but it hasn't
366- // grown for 15s while status is still non-IDLE → planner is
367- // stuck in a tool round-trip or upstream throttle. Accept
368- // what we have as a complete response rather than waiting
369- // out the full 180s maxWait with the client hanging.
361+ // Cold stall: 30s+ ACTIVE but never saw any text or tool call.
370362 const elapsed = Date . now ( ) - startTime ;
371- // Cap at maxWait (180s): long-context requests can legitimately take
372- // that long to emit the first token from Cascade. Was 90s which
373- // still tripped on very long prompts (issue #5).
374- const coldStallMs = Math . min ( maxWait , 30_000 + Math . floor ( inputChars / 1500 ) * 5_000 ) ;
363+ const effectiveChars = inputChars + ( toolPreamble ?. length ?? 0 ) ;
364+ const coldStallMs = Math . min ( maxWait , 30_000 + Math . floor ( effectiveChars / 1500 ) * 5_000 ) ;
375365 if ( elapsed > coldStallMs && sawActive && ! sawText && seenToolCallIds . size === 0 ) {
376366 log . warn ( `Cascade cold stall: ${ elapsed } ms active without any text or tool call (threshold=${ coldStallMs } ms, inputChars=${ inputChars } ), bailing` ) ;
377367 endReason = 'stall_cold' ;
378368 const err = new Error ( `Cascade planner stalled — no output after ${ Math . round ( coldStallMs / 1000 ) } s` ) ;
379369 err . isModelError = true ;
380370 throw err ;
381371 }
382- if ( sawText && lastStatus !== 1 && ( Date . now ( ) - lastGrowthAt ) > NO_GROWTH_STALL_MS ) {
383- const diag = {
384- msSinceGrowth : Date . now ( ) - lastGrowthAt ,
385- textLen : totalYielded ,
386- thinkingLen : totalThinking ,
387- stepCount : yieldedByStep . size ,
388- toolCalls : seenToolCallIds . size ,
389- lastStatus,
390- } ;
391- // Short-reply stall → treat as error so handlers/chat.js retries on
392- // another account. A 50-char preamble is worse than no reply at all
393- // because the client accepts it as "successful" and shows it to the
394- // user. Retry only if we haven't streamed anything substantial yet
395- // (if we did, partial delivery + idle end is fine).
396- if ( totalYielded < STALL_RETRY_MIN_TEXT ) {
397- log . warn ( 'Cascade warm stall (short, retrying on next account)' , diag ) ;
398- endReason = 'stall_warm_retry' ;
399- const err = new Error ( 'Cascade planner stalled after preamble — no progress for 25s' ) ;
400- err . isModelError = true ;
401- throw err ;
402- }
403- log . warn ( 'Cascade warm stall (accepting partial)' , diag ) ;
404- endReason = 'stall_warm' ;
405- break ; // return what we have as a successful response
406- }
372+
373+ // NOTE: warm stall check moved AFTER step loop (below) so
374+ // lastGrowthAt reflects data read in this poll, not the previous one.
407375
408376 // Any trajectory change counts as forward progress. A new step, a new
409377 // tool call proposal, or thinking growth all reset the stall timer so
@@ -482,6 +450,22 @@ export class WindsurfClient {
482450 }
483451 }
484452
453+ // Warm stall: text stopped growing for 25s while planner is active.
454+ // Placed AFTER the step loop so lastGrowthAt is current-poll fresh.
455+ if ( sawText && lastStatus !== 1 && ( Date . now ( ) - lastGrowthAt ) > NO_GROWTH_STALL_MS ) {
456+ const diag = { msSinceGrowth : Date . now ( ) - lastGrowthAt , textLen : totalYielded , thinkingLen : totalThinking , stepCount : yieldedByStep . size , toolCalls : seenToolCallIds . size , lastStatus } ;
457+ if ( totalYielded < STALL_RETRY_MIN_TEXT ) {
458+ log . warn ( 'Cascade warm stall (short, retrying on next account)' , diag ) ;
459+ endReason = 'stall_warm_retry' ;
460+ const err = new Error ( 'Cascade planner stalled after preamble — no progress for 25s' ) ;
461+ err . isModelError = true ;
462+ throw err ;
463+ }
464+ log . warn ( 'Cascade warm stall (accepting partial)' , diag ) ;
465+ endReason = 'stall_warm' ;
466+ break ;
467+ }
468+
485469 // Check status
486470 const statusProto = buildGetTrajectoryRequest ( cascadeId ) ;
487471 const statusResp = await grpcUnary (
@@ -506,7 +490,8 @@ export class WindsurfClient {
506490 idleCount ++ ;
507491 // Require at least a little text OR a long idle streak before
508492 // accepting "done", so we don't race the first visible chunk.
509- const canBreak = sawText ? idleCount >= 2 : idleCount >= 4 ;
493+ const growthSettled = ( Date . now ( ) - lastGrowthAt ) > pollInterval * 3 ;
494+ const canBreak = sawText ? ( idleCount >= 3 && growthSettled ) : idleCount >= 4 ;
510495 if ( canBreak ) {
511496 // Final sweep
512497 const finalResp = await grpcUnary (
0 commit comments