@@ -142,6 +142,17 @@ import { type TaskExecutionContext } from "./providerHandoff"
142142
143143const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
144144const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
145+ // Upper bound on awaiting lazily loaded model metadata. Some model-catalog
146+ // fetchers (e.g. OpenRouter's bare axios GET) have no request timeout, so a
147+ // hung endpoint must not stall streaming, condense, or context-window
148+ // handling. On expiry the caller aborts its per-call AbortSignal (detaching
149+ // the provider-side waiter) and falls back to the handler's existing
150+ // getModel().info metadata — the same degradation a rejected fetch produces.
151+ // Kept in sync with PREVIEW_MODEL_FETCH_TIMEOUT_MS in the preview path
152+ // (src/core/webview/generateSystemPrompt.ts). Deliberately duplicated, not
153+ // shared: importing from the webview layer would close a
154+ // Task -> generateSystemPrompt -> ClineProvider -> Task circular import.
155+ export const MODEL_FETCH_TIMEOUT_MS = 5_000
145156const QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS = [ 250 , 1_000 , 4_000 ] as const
146157
147158type QueuedAskResolution = { response : ClineAskResponse ; requiresDurableAck : boolean }
@@ -313,6 +324,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
313324 private readonly globalStoragePath : string
314325 abort : boolean = false
315326 currentRequestAbortController ?: AbortController
327+ /**
328+ * Controller for the waiter on an in-flight `ensureModelFetched()` call (see
329+ * safeEnsureModelFetched). Aborting it detaches this task from the provider-side
330+ * metadata fetch; it is aborted when the bounded wait expires and when the task's
331+ * current request is cancelled (cancel/dispose path in cancelCurrentRequest).
332+ */
333+ metadataFetchAbortController ?: AbortController
316334 skipPrevResponseIdOnce : boolean = false
317335
318336 // TaskStatus
@@ -1858,8 +1876,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
18581876 const state = await this . providerRef . deref ( ) ?. getState ( )
18591877 const requestModelInfo = await this . safeEnsureModelFetched ( )
18601878
1861- // A cancellation landing during the metadata wait must stop manual
1862- // condensation before any prompt build or summarization request.
1879+ // A cancellation landing during the bounded metadata wait must stop
1880+ // manual condensation before any prompt build or summarization request.
18631881 if ( this . abort || this . abandoned ) {
18641882 return
18651883 }
@@ -1920,6 +1938,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
19201938
19211939 const filesReadByRoo = await this . getFilesReadByRooSafely ( "condenseContext" )
19221940
1941+ // A cancellation landing while the summarization inputs are gathered must
1942+ // stop manual condensation before any summarization request is issued.
1943+ if ( this . abort || this . abandoned ) {
1944+ return
1945+ }
1946+
19231947 const {
19241948 messages,
19251949 summary,
@@ -1941,6 +1965,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
19411965 cwd : this . cwd ,
19421966 rooIgnoreController : this . rooIgnoreController ,
19431967 } )
1968+ // A cancellation landing during the summarization request must stop
1969+ // manual condensation before it replaces and persists the history.
1970+ if ( this . abort || this . abandoned ) {
1971+ return
1972+ }
19441973 if ( error ) {
19451974 await this . say (
19461975 "condense_context_error" ,
@@ -2629,6 +2658,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26292658 this . currentRequestAbortController . abort ( )
26302659 this . currentRequestAbortController = undefined
26312660 }
2661+ // A metadata-fetch waiter still in flight is as stale as an abandoned
2662+ // stream: detach it from the provider-side fetch on cancel/dispose.
2663+ if ( this . metadataFetchAbortController ) {
2664+ this . metadataFetchAbortController . abort ( )
2665+ this . metadataFetchAbortController = undefined
2666+ }
26322667 }
26332668
26342669 /**
@@ -4283,28 +4318,63 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
42834318 /**
42844319 * Ensures router-provider model metadata is loaded before getModel() is used for
42854320 * context management or streaming. Failures fall back to hardcoded defaults rather
4286- * than aborting the task.
4321+ * than aborting the task; the wait is bounded by MODEL_FETCH_TIMEOUT_MS (see the
4322+ * constant for the degradation semantics). On expiry or cancellation the per-call
4323+ * AbortSignal detaches this task's waiter from the provider-side fetch instead of
4324+ * leaving a handler-side promise waiting on it indefinitely.
42874325 *
42884326 * The return value is the settled post-wait read of getModel().info: request
42894327 * entry points (attemptApiRequest, condenseContext) await this once before
42904328 * prompt generation and share the returned snapshot between getSystemPrompt
4291- * and every tool-array build of the same request, so a fetch that resolves
4292- * mid-request cannot move model-specific tool policy between prompt time and
4293- * request time. Callers that do not thread a snapshot keep awaiting this
4294- * immediately before their own getModel() read; that per-site guard remains the
4295- * standalone/fallback read path, and repeat awaits stay cheap once a fetch has
4296- * succeeded because the provider caches successes. RouterProvider already
4297- * negative-caches catalog misses with a TTL (`missingModelRefreshAt`).
4329+ * and every tool-array build of the same request, so a fetch that resolves after
4330+ * the bounded wait was abandoned cannot move model-specific tool policy between
4331+ * prompt time and request time. Callers that do not thread a snapshot keep
4332+ * awaiting this immediately before their own getModel() read; that per-site guard
4333+ * remains the standalone/fallback read path, and repeat awaits stay cheap once a
4334+ * fetch has succeeded because the provider caches successes. RouterProvider
4335+ * already negative-caches catalog misses with a TTL (`missingModelRefreshAt`).
42984336 */
42994337 private async safeEnsureModelFetched ( ) : Promise < ModelInfo > {
4338+ // Per-call controller: its signal makes ensureModelFetched() settle on
4339+ // abort, so neither this race nor the provider-side waiter outlives the
4340+ // bounded wait. cancel/dispose aborts it early via cancelCurrentRequest.
4341+ const controller = new AbortController ( )
4342+ this . metadataFetchAbortController = controller
4343+ // Promise.race attaches handlers to both inputs, so the fetch rejecting
4344+ // after the abort is already considered handled — no extra .catch needed.
4345+ let timeoutId : ReturnType < typeof setTimeout > | undefined
4346+ let timedOut = false
43004347 try {
4301- await this . api . ensureModelFetched ?.( )
4348+ await Promise . race ( [
4349+ this . api . ensureModelFetched ?.( controller . signal ) ,
4350+ new Promise < void > ( ( resolve ) => {
4351+ timeoutId = setTimeout ( ( ) => {
4352+ timedOut = true
4353+ resolve ( )
4354+ } , MODEL_FETCH_TIMEOUT_MS )
4355+ } ) ,
4356+ ] )
4357+ if ( timedOut ) {
4358+ console . warn (
4359+ `[Task#${ this . taskId } ] Timed out after ${ MODEL_FETCH_TIMEOUT_MS } ms fetching model metadata; using fallback model info.` ,
4360+ )
4361+ }
43024362 } catch ( error ) {
43034363 console . error (
43044364 `[Task#${ this . taskId } ] Failed to fetch model metadata:` ,
43054365 error instanceof Error ? error . message : error ,
43064366 )
4367+ } finally {
4368+ if ( timeoutId ) {
4369+ clearTimeout ( timeoutId )
4370+ }
4371+ if ( this . metadataFetchAbortController === controller ) {
4372+ this . metadataFetchAbortController = undefined
4373+ }
4374+ // Unconditional: settles any waiter still attached to this call.
4375+ controller . abort ( )
43074376 }
4377+ // The post-wait read is the settled snapshot callers must share per request.
43084378 return this . api . getModel ( ) . info
43094379 }
43104380
@@ -4518,9 +4588,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
45184588 // Thread the request state snapshot into prompt generation so the prompt
45194589 // and the runtime tools (built below from the same `state`) stay aligned
45204590 // even if settings change while this method waits on MCP or rate limits.
4521- // Capture one model-info snapshot per request, shared by the prompt and
4522- // every tool array built below; prefer the caller's snapshot when one was
4523- // threaded.
4591+ // Capture one bounded-wait model-info snapshot per request, shared by the
4592+ // prompt and every tool array built below; prefer the caller's snapshot
4593+ // when one was threaded.
45244594 const requestModelInfo = options . requestModelInfo ?? ( await this . safeEnsureModelFetched ( ) )
45254595 // Retry recursions must reuse this snapshot instead of re-deriving it: a
45264596 // metadata fetch landing between attempts would otherwise move
@@ -4530,8 +4600,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
45304600 const retryOptions = options . requestModelInfo === undefined ? { ...options , requestModelInfo } : options
45314601 const systemPrompt = await this . getSystemPrompt ( state , requestModelInfo )
45324602
4533- // A cancellation landing during the rate-limit countdown, the metadata wait,
4534- // or the MCP wait inside getSystemPrompt must stop this request before any
4603+ // A cancellation landing during the rate-limit countdown, the bounded metadata
4604+ // wait, or the MCP wait inside getSystemPrompt must stop this request before any
45354605 // tool array, AbortController, or createMessage call is issued for it.
45364606 if ( this . abort || this . abandoned ) {
45374607 throw new Error (
0 commit comments