Summary
ChatSession.resetTranscript() synchronously clears the transcript and sets status to 'idle', but an in-flight runAgent() call's finally block runs asynchronously in a microtask after the reset. The finally block then calls setStatus('idle') again and emits a turn-end event for a turn that no longer exists in the transcript, corrupting session state and potentially causing duplicate background agent streams.
Panel verdict: P0, VERIFIED, EXISTING_DEFECT, consensus.
Affected Files
| File |
Lines |
Issue |
src/server/chat.ts |
L157–L162 |
resetTranscript() clears state synchronously |
src/server/chat.ts |
L295–L301 |
runAgent() finally block runs after reset, re-corrupts state |
src/server/chat.ts |
L150 |
sendMessage() fires this.runAgent() as a detached void promise |
Root Cause — Code Evidence
resetTranscript() (src/server/chat.ts, lines 157–162):
resetTranscript(): void {
this.abort(); // signals the abort controller
this.transcript = []; // synchronously empties transcript
this.setStatus('idle'); // emits status: idle
this.emit({ type: 'transcript', turns: this.transcript, status: this.status });
}
runAgent() finally block (src/server/chat.ts, lines 297–301):
} finally {
this.abortCtrl = null;
this.emit({ type: 'turn-end', turnId: assistantTurn.id }); // ← turn no longer in transcript
this.setStatus('idle'); // ← overwrites the 'idle' already set by reset, but
// also re-emits status event confusing clients
}
sendMessage() fires agent as detached void (src/server/chat.ts, line 150):
async sendMessage(text: string): Promise<void> {
// ...
void this.runAgent(); // ← detached; finally block runs independently of caller
}
Race condition timeline:
T=0 sendMessage() called → runAgent() starts, status = 'streaming'
T=1 User clicks Reset → resetTranscript() runs:
abort() called
transcript = []
status = 'idle' ← correct state set
T=2 runAgent() finally block fires (microtask):
turn-end emitted for ghost turn (turnId not in transcript)
setStatus('idle') emitted again → clients see duplicate status event
T=3 If sendMessage() is called again immediately:
runAgent() may start while a prior agent stream is still draining
Impact
- Clients (SSE listeners) receive
turn-end events referencing turn IDs that no longer exist, causing UI desync.
- Status fluctuates between
'idle' → 'idle' with no corresponding transcript change, causing client re-render thrash.
- If
sendMessage() is called quickly after reset, two concurrent agent streams can be active simultaneously (both share this.abortCtrl), leading to interleaved output.
Steps to Reproduce
- Start a chat session and send a message.
- While the agent is streaming, immediately click the Reset button.
- Immediately send another message.
- Observe: the SSE stream may show a
turn-end for a non-existent turn, and/or the new response may contain text from the previous aborted run.
Remediation
Option A — Introduce a generation counter (recommended)
private _generation = 0;
resetTranscript(): void {
this._generation++; // invalidate all in-flight runs
this.abort();
this.transcript = [];
this.setStatus('idle');
this.emit({ type: 'transcript', turns: this.transcript, status: this.status });
}
private async runAgent(): Promise<void> {
const myGen = this._generation;
this.setStatus('streaming');
// ...
try {
for await (const ev of runChatAgent(...)) {
if (this._generation !== myGen) return; // silently discard after reset
// ... handle events
}
} finally {
if (this._generation === myGen) { // only update state if still our generation
this.abortCtrl = null;
this.emit({ type: 'turn-end', turnId: assistantTurn.id });
this.setStatus('idle');
}
}
}
Option B — Await the agent before allowing new sends
Change sendMessage to await this.runAgent() and guard the entire run under a mutex/lock, preventing concurrent streams entirely.
Summary
ChatSession.resetTranscript()synchronously clears the transcript and sets status to'idle', but an in-flightrunAgent()call'sfinallyblock runs asynchronously in a microtask after the reset. Thefinallyblock then callssetStatus('idle')again and emits aturn-endevent for a turn that no longer exists in the transcript, corrupting session state and potentially causing duplicate background agent streams.Panel verdict: P0, VERIFIED, EXISTING_DEFECT, consensus.
Affected Files
src/server/chat.tsresetTranscript()clears state synchronouslysrc/server/chat.tsrunAgent()finallyblock runs after reset, re-corrupts statesrc/server/chat.tssendMessage()firesthis.runAgent()as a detached void promiseRoot Cause — Code Evidence
resetTranscript()(src/server/chat.ts, lines 157–162):runAgent()finally block (src/server/chat.ts, lines 297–301):sendMessage()fires agent as detached void (src/server/chat.ts, line 150):Race condition timeline:
Impact
turn-endevents referencing turn IDs that no longer exist, causing UI desync.'idle'→'idle'with no corresponding transcript change, causing client re-render thrash.sendMessage()is called quickly after reset, two concurrent agent streams can be active simultaneously (both sharethis.abortCtrl), leading to interleaved output.Steps to Reproduce
turn-endfor a non-existent turn, and/or the new response may contain text from the previous aborted run.Remediation
Option A — Introduce a generation counter (recommended)
Option B — Await the agent before allowing new sends
Change
sendMessagetoawait this.runAgent()and guard the entire run under a mutex/lock, preventing concurrent streams entirely.