Ignore a steering message that is re-delivered while still in flight - #333919
Ignore a steering message that is re-delivered while still in flight#333919Ryan Ewen (RyanEwen) wants to merge 2 commits into
Conversation
`setPendingMessages` is a state sync rather than a command. The queue drain contribution re-delivers the current steering message to the provider on every pending-message action, so the same `PendingMessage.id` arrives repeatedly until it is consumed. `ClaudeSdkPipeline.injectSteering` queued it each time, so a single steering message could reach the model twice, and the second copy could open a turn that no work would ever fill. Track the ids accepted into the queue and not yet consumed, and ignore a repeat. The set is cleared when the queue reports the steer consumed, and on abort, since an aborted queue consumes nothing. Codex and Copilot already guard this way; this brings the Claude path to parity with them.
📬 CODENOTIFYThe following users are being notified based on files changed in this PR: TylerLeonhardtMatched files:
|
There was a problem hiding this comment.
🟡 Changes recommended
Crash cleanup can retain steering IDs indefinitely, and the deduplication behavior lacks regression coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Prevents duplicate Claude steering messages during pending-state synchronization.
Changes:
- Tracks steering IDs until consumed.
- Clears tracking on consumption or abort.
File summaries
| File | Description |
|---|---|
claudeSdkPipeline.ts |
Adds steering-message deduplication and lifecycle cleanup. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
|
||
| private _wireAbortHandler(controller: AbortController): void { | ||
| controller.signal.addEventListener('abort', () => { | ||
| this._steeringInFlight.clear(); |
There was a problem hiding this comment.
Correct. Fixed in 84870a0: _failQueue fails the queue and clears the in-flight set together, and the aborted-rebind and fatal-stream paths both go through it, so those ids are released without an abort.
AI disclosure: this comment and the related code were written with the assistance of AI.
| if (this._steeringInFlight.has(pendingMessageId)) { | ||
| this._logService.trace(`[Claude:${this.sessionId}] injectSteering: already in flight id=${pendingMessageId}`); | ||
| return; |
There was a problem hiding this comment.
Added in 84870a0: a steering message re-delivered before it is consumed is only queued once delivers the same id three times before consumption and asserts exactly one priority: 'now' prompt reaches the SDK. It fails without the guard. A second test covers the same id being accepted again once the first delivery is consumed.
AI disclosure: this comment and the related code were written with the assistance of AI.
The guard released its tracked ids only when the abort controller fired, but two paths fail the queue without aborting it: an aborted rebind, and a fatal stream error in `_processMessages`. A steer whose id was recorded and never consumed stayed recorded, so every later delivery of that still-pending message was ignored. The message was silently dropped rather than duplicated, which is worse than the problem the guard exists to solve. Fail the queue through one helper that clears the tracking with it, so every failure path releases the ids. Adds two regression tests: the same id delivered repeatedly before it is consumed reaches the SDK once, and the same id is accepted again once the first delivery has been consumed.
|
Both points were correct and are fixed in 84870a0. The retained-ids pathCorrect, and the consequence is worse than the bug the guard was added for. Three paths fail the queue and only one aborts the controller first:
So after a crash or a failed rebind, a steer that was recorded and never consumed stayed recorded, and every later delivery of that still-pending message was ignored. The message was silently dropped rather than duplicated. Fixed by routing all three through one helper that fails the queue and clears the tracking together: private _failQueue(error: Error): void {
this._queue.failAll(error);
this._steeringInFlight.clear();
}That leaves a single choke point, which is the same shape #330785 already uses for its own steering state. The missing coverageAlso correct; there was none. Two tests added, both passing (290 passing, 0 failing in
One gap worth stating rather than hiding: I could not build a reliable test for acceptance after a failure specifically. Driving abort-then-rebind through the existing harness, the second steer never reaches a live parent in the rebuilt queue, so the test failed for reasons unrelated to the guard. Instrumenting it showed the rebind happening and the second turn starting, with no AI disclosure: this comment and the related code were written with the assistance of AI. |
IAgent.setPendingMessagesis a state sync rather than a command.QueueDrainContribution._syncPendingMessagescalls it with whateverstate.steeringMessagecurrently is, on everyChatPendingMessageSet, on everyChatPendingMessageRemovedof any kind, and onChatQueuedMessagesReordered. So while a steering message is pending, unrelated pending-message activity on that chat re-delivers the samePendingMessage.idto the provider.ClaudeSdkPipeline.injectSteeringqueued it every time it arrived. The effect is that a single steering message can be yielded to the model twice, under two different turns, and the second copy can leave a turn open that no work will ever fill.Observed in a dev container: the same id enqueued at two points about two and a half minutes apart, followed by a
chat/turnStartedfor that id with nochat/turnCompleteand no deltas after it.The other two providers already guard
This is not a new idea, and Codex names the cause exactly:
Copilot does the same in
copilotAgentSession.sendSteering. The Claude path had no equivalent, so this brings it to parity rather than introducing a new mechanism.What this changes
ClaudeSdkPipelinetracks the steering ids it has accepted into the queue and not yet seen consumed, and ignores a repeat of one already in flight. The set is cleared when the queue reports the steer consumed, and on abort, since an aborted queue consumes nothing.Clearing on consumption rather than only on abort matters: it keeps a genuinely new steer with a recycled id working, and it keeps the set bounded.
Related: #333174 groups this with the other provider-parity gaps.
AI disclosure: this pull request and the related code were written with the assistance of AI.