Skip to content

[P0][BUG] Chat session status corruption: resetTranscript() races with in-flight async runAgent() finally block #3

Description

@pratik-saptarshi

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

  1. Start a chat session and send a message.
  2. While the agent is streaming, immediately click the Reset button.
  3. Immediately send another message.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions