@@ -515,11 +515,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
515515 // slot (which would drop the earlier response after both messages were
516516 // already removed from the queue).
517517 private queuedMessageDrainChain : Promise < unknown > = Promise . resolve ( )
518- // ID of the last drain-submitted queued message. A successful submit only
519- // posts into the pending ask-response slot; the message stays queued until
520- // an ask consumes that response. A later drain that claims this same ID
521- // must not re-post it, or it would overwrite a distinct pending response.
522- private pendingSubmittedQueuedMessageId : string | undefined
518+ // IDs of queued messages a drain submitted into the ask-response slot but
519+ // no ask has consumed yet. They stay queued on purpose, so two consumers
520+ // must read this set: a later drain, so it never re-posts the same message
521+ // into the slot, and an execution-gating ask (`queuedResponseForAsk` would
522+ // answer it with `yesButtonClicked`), which must never let between-turn
523+ // feedback stand in for an approval. Cleared only on consumption.
524+ private drainRetainedQueuedMessageIds = new Set < string > ( )
525+ // Number of `ask()` calls in flight. A drain must not submit while one is
526+ // pending: a background completion can land during any prompt, and its
527+ // submission would answer that ask with text written for another turn while
528+ // the retained message stayed queued for a second delivery.
529+ private pendingAskCount = 0
523530
524531 // Streaming
525532 isWaitingForFirstChunk = false
@@ -1245,10 +1252,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12451252 private handleQueuedAskResponse ( message : QueuedMessage , resolution : QueuedAskResolution ) : string | undefined {
12461253 this . handleWebviewAskResponse ( resolution . response , message . text , message . images )
12471254 // This ask consumed the message, so a later drain may submit the next
1248- // one instead of treating it as still pending.
1249- if ( this . pendingSubmittedQueuedMessageId === message . id ) {
1250- this . pendingSubmittedQueuedMessageId = undefined
1251- }
1255+ // one instead of treating it as still pending. The retention record is
1256+ // cleared on the same edge: a consumed message is no longer retained.
1257+ this . drainRetainedQueuedMessageIds . delete ( message . id )
12521258 if ( resolution . requiresDurableAck ) {
12531259 return message . id
12541260 }
@@ -1379,9 +1385,38 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13791385 * `isMessageQueued`/`isStatusMutable` keep `isEmpty()` semantics on purpose:
13801386 * flipping those would re-enable interactive prompt timers whenever a claim
13811387 * is outstanding.
1388+ *
1389+ * `excludeIds` carries the messages this ask may not consume at all; see
1390+ * `queuedClaimExclusionsForAsk`.
1391+ */
1392+ private mayDrainQueuedMessageForAsk ( excludeIds ?: ReadonlySet < string > ) : boolean {
1393+ return ! this . blanketDeniedCommandThisTurn && this . messageQueueService . hasUnclaimed ( excludeIds )
1394+ }
1395+
1396+ /**
1397+ * Queued messages this ask must not claim. `queuedResponseForAsk` answers a
1398+ * command, tool, or use_mcp_server ask with `yesButtonClicked`, so letting one
1399+ * of those claim a drain-retained message would turn between-turn feedback
1400+ * into an approval: the ask skips `checkAutoApproval`, and a stale "ship it"
1401+ * would run the next command with no prompt even with auto-approval off. Such
1402+ * asks exclude every retained ID and get a real prompt; the message stays
1403+ * queued for a conversational ask, the only consumer that carries its text
1404+ * into the model as feedback.
13821405 */
1383- private mayDrainQueuedMessageForAsk ( ) : boolean {
1384- return ! this . blanketDeniedCommandThisTurn && this . messageQueueService . hasUnclaimed ( )
1406+ private queuedClaimExclusionsForAsk ( type : ClineAsk , text ?: string ) : ReadonlySet < string > | undefined {
1407+ // Drop IDs the queue no longer holds (deleted from the webview or the API,
1408+ // or cleared by dispose) so the record cannot outlive its message.
1409+ const queuedIds = new Set ( this . messageQueueService . messages . map ( ( message ) => message . id ) )
1410+ for ( const id of this . drainRetainedQueuedMessageIds ) {
1411+ if ( ! queuedIds . has ( id ) ) {
1412+ this . drainRetainedQueuedMessageIds . delete ( id )
1413+ }
1414+ }
1415+
1416+ return this . drainRetainedQueuedMessageIds . size > 0 &&
1417+ queuedResponseForAsk ( type , text ) ?. response === "yesButtonClicked"
1418+ ? this . drainRetainedQueuedMessageIds
1419+ : undefined
13851420 }
13861421
13871422 /**
@@ -1864,6 +1899,36 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
18641899 * rejection: the denial is scoped to its own tool call.
18651900 */
18661901 autoDenyDetail ?: AutoDenyDetail
1902+ } > {
1903+ // Bracket the whole ask, not just the wait: the counter is the drain's
1904+ // "a prompt is in flight" signal, and a submission landing during any
1905+ // await below would answer this ask with text written for another turn.
1906+ this . pendingAskCount ++
1907+ try {
1908+ return await this . askWithPendingCount ( type , text , partial , progressStatus , isProtected , autoApprovalContext )
1909+ } finally {
1910+ this . pendingAskCount --
1911+ }
1912+ }
1913+
1914+ private async askWithPendingCount (
1915+ type : ClineAsk ,
1916+ text ?: string ,
1917+ partial ?: boolean ,
1918+ progressStatus ?: ToolProgressStatus ,
1919+ isProtected ?: boolean ,
1920+ autoApprovalContext ?: AutoApprovalContext ,
1921+ ) : Promise < {
1922+ response : ClineAskResponse
1923+ text ?: string
1924+ images ?: string [ ]
1925+ queuedMessageId ?: string
1926+ /**
1927+ * Present when the ask was resolved by an automatic (policy) denial
1928+ * rather than a user click. Consumers must not treat this as a user
1929+ * rejection: the denial is scoped to its own tool call.
1930+ */
1931+ autoDenyDetail ?: AutoDenyDetail
18671932 } > {
18681933 // If this Cline instance was aborted by the provider, then the only
18691934 // thing keeping us alive is a promise still running in the background,
@@ -1902,6 +1967,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
19021967 // has to see and stalling a hands-free session. Leaving the message
19031968 // unclaimed lets the policy decision stand and keeps the message queued.
19041969 const queueMayAnswerThisAsk = ! ( blanketDenyEngaged && type === "command" )
1970+ // Retained drain submissions are off-limits to any ask that would answer
1971+ // with `yesButtonClicked`; see `queuedClaimExclusionsForAsk`.
1972+ const queuedClaimExclusions = this . queuedClaimExclusionsForAsk ( type , text )
19051973 // A queued message must not short-circuit protected asks (e.g.
19061974 // DCG-blocked commands): claiming one here skips checkAutoApproval, and
19071975 // the drain below would auto-approve what protection intentionally
@@ -1911,9 +1979,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
19111979 type === "command_output" ||
19121980 isProtected ||
19131981 ! queueMayAnswerThisAsk ||
1914- ! this . mayDrainQueuedMessageForAsk ( )
1982+ ! this . mayDrainQueuedMessageForAsk ( queuedClaimExclusions )
19151983 ? undefined
1916- : this . messageQueueService . claimNextMessage ( )
1984+ : this . messageQueueService . claimNextMessage ( queuedClaimExclusions )
19171985 const queuedAskResolution = queuedMessage ? queuedResponseForAsk ( type , text ) : undefined
19181986 // `this.cwd`, not `provider.cwd`:
19191987 // The path inside `text` was made relative to this task's workspace,
@@ -2318,9 +2386,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
23182386 shouldDrainQueuedMessageForAsk &&
23192387 ! isProtected &&
23202388 ! queuedCommandPolicyCheck &&
2321- this . mayDrainQueuedMessageForAsk ( )
2389+ this . mayDrainQueuedMessageForAsk ( queuedClaimExclusions )
23222390 ) {
2323- const message = this . messageQueueService . claimNextMessage ( )
2391+ const message = this . messageQueueService . claimNextMessage ( queuedClaimExclusions )
23242392 const resolution = message ? queuedResponseForAsk ( type , text ) : undefined
23252393 if ( message && resolution ) {
23262394 if ( type === "command" ) {
@@ -2416,11 +2484,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
24162484 this . askResponse = askResponse
24172485 this . askResponseText = text
24182486 this . askResponseImages = images
2419- // A direct response overwrites whatever a drain submitted into the slot,
2420- // so that submission was not consumed and must not block a later drain.
2421- // The drain's own submit lands here before it records the ID, so this
2422- // cannot clear the tracker for the submission it just made.
2423- this . pendingSubmittedQueuedMessageId = undefined
2487+ // No retention bookkeeping here: a direct response overwrites the slot a
2488+ // drain submitted into, which means that submission was NOT consumed, so
2489+ // the message stays queued and stays excluded from approval asks. Only
2490+ // `handleQueuedAskResponse` clears retention, on the consume edge.
24242491
24252492 // Create a checkpoint whenever the user sends a message.
24262493 // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes.
@@ -6488,14 +6555,24 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
64886555 * delivered (task stopping, provider gone), the claim is released and the
64896556 * run resolves false.
64906557 *
6491- * Drains are serialized per task: each run claims only after the previous
6492- * run's submission handoff completed, so a background-completion drain and
6493- * a post-result drain cannot interleave two different messages into the
6494- * single pending ask-response.
6558+ * Retention is restricted to conversational consumption. A retained message
6559+ * is recorded in `drainRetainedQueuedMessageIds`, and every ask whose
6560+ * `queuedResponseForAsk` mapping would answer with `yesButtonClicked`
6561+ * (command, tool, use_mcp_server) excludes those IDs from its claim. Without
6562+ * that exclusion the retained text would silently approve a later execution
6563+ * ask with no prompt, even with auto-approval off.
6564+ *
6565+ * A drain also never submits while an `ask()` is pending: the submission
6566+ * would answer that ask with text written for another turn, bypassing the
6567+ * ask's own latch and denial authority, and the retained message would then
6568+ * be delivered a second time.
6569+ *
6570+ * Drains are serialized per task, so a background-completion drain and a
6571+ * post-result drain cannot interleave two messages into the single slot.
64956572 *
64966573 * @returns Promise resolving to true when a queued message was submitted
6497- * (and remains queued until consumed); false when the queue was empty or
6498- * the task is stopping.
6574+ * (and remains queued until consumed); false when the queue was empty, an
6575+ * ask was pending, or the task is stopping.
64996576 */
65006577 public processQueuedMessages ( ) : Promise < boolean > {
65016578 const run = this . queuedMessageDrainChain . then ( ( ) => this . claimAndSubmitNextQueuedMessage ( ) )
@@ -6516,6 +6593,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
65166593 if ( this . abort || this . abandoned ) {
65176594 return false
65186595 }
6596+ // An ask is waiting for its response. Submitting now would resolve that
6597+ // prompt with text written for another turn — the ask's own claim gate,
6598+ // blanket-deny latch, and protected-ask exemption never run — and the
6599+ // message would stay queued for a second delivery. Defer: the message
6600+ // keeps its place and the next post-ask drain submits it.
6601+ if ( this . pendingAskCount > 0 ) {
6602+ return false
6603+ }
65196604 const queued = this . messageQueueService . claimNextMessage ( )
65206605 if ( ! queued ) {
65216606 return false
@@ -6524,7 +6609,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
65246609 // consumed it yet: re-posting would overwrite a distinct pending
65256610 // response and lose it, so release the claim and keep the message
65266611 // queued for the ask.
6527- if ( this . pendingSubmittedQueuedMessageId === queued . id ) {
6612+ if ( this . drainRetainedQueuedMessageIds . has ( queued . id ) ) {
65286613 this . messageQueueService . releaseMessage ( queued . id )
65296614 return true
65306615 }
@@ -6543,10 +6628,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
65436628 throw error
65446629 }
65456630 // Submission succeeded, but the message is only removed once an ask
6546- // consumes it. Track the ID so a later drain can tell "already
6547- // submitted, still pending" from "not submitted yet", and release the
6548- // claim so the ask path can claim it.
6549- this . pendingSubmittedQueuedMessageId = queued . id
6631+ // consumes it. Record the ID so a later drain can tell "already
6632+ // submitted, still pending" from "not submitted yet" and so an
6633+ // execution-gating ask refuses to claim it, then release the claim so a
6634+ // conversational ask can take it.
6635+ this . drainRetainedQueuedMessageIds . add ( queued . id )
65506636 this . messageQueueService . releaseMessage ( queued . id )
65516637 return true
65526638 }
0 commit comments