Skip to content

fix(agent): close two run-teardown races in ReusableReadableStream and async drain - #126

Open
abhinav-pola wants to merge 3 commits into
mainfrom
devin/1790127772-tla-race-fixes
Open

abhinav-pola wants to merge 3 commits into
mainfrom
devin/1790127772-tla-race-fixes

Conversation

@abhinav-pola

@abhinav-pola abhinav-pola commented Sep 23, 2026 •

Copy link
Copy Markdown

Summary

Fixes two reachable run-teardown races. Each has a Vitest reproduction that fails on main and passes here.

1. ReusableReadableStream.cancel() vs. a late createConsumer() (active-consumers replay)

cancel() is two-phase: drop backlog, await sourceReader.cancel(), drop backlog again. A chunk the pump already read lands in the buffer during the await and the second drop advances trimOffset past it and clears the slot. A consumer created during the await is registered at the pre-drop watermark, so its first next() hits the cleared slot and throws ReusableReadableStream buffer invariant violated: consumed slot was cleared.

 export class ReusableReadableStream<T> {
+  private cancelled = false;

   createConsumer() {
     const consumerId = this.nextConsumerId++;
-    this.consumers.set(consumerId, {...}); startPump();
+    if (!this.cancelled) { this.consumers.set(consumerId, {...}); startPump(); }
     // iterator: a consumer not in the map is done immediately
   }

   async cancel() {
+    this.cancelled = true;
     ...
     if (this.sourceReader) await this.cancelSourceReader(this.sourceReader);
+    else if (!this.pumpStarted) await this.cancelUnstartedSource();

The cancelUnstartedSource() branch covers cancel() before any consumer exists: with the terminal flag, no later consumer can start the pump, so the wrapper cancels the unlocked source itself.

Reachable from ModelResult: any getFullResponsesStream()/getUiStream() call racing a run-level abort or cancel().

2. onRunEnd: 'drain' loses a settlement that lands during the last drain turn

     if (registry.hasInFlight()) {
       registry.abortAll('Async tool drain budget exhausted at run end');
-      await this.dropSettledTasks();
     }
+    await this.dropSettledTasks();

When the loop exits by maxDrainTurns (or the loop-top settledQueue check misses a settlement that arrives during the following model turn), the task is no longer in flight, so the old conditional skipped the final drop. The settlement stayed in settledQueue, no tool.async_settled event was broadcast, and the persisted task record remained working. Now it is broadcast with delivery: 'dropped' and persisted as terminal, matching the documented drain contract.

Verification

  • pnpm run lint, pnpm run typecheck, pnpm run build pass.
  • pnpm run test: agent 1352 tests pass, mcp 12 pass.
  • sentrux gate reports no degradation (createConsumer kept under cc 15 by extracting registerConsumer).
  • With src/lib/reusable-stream.ts and src/lib/model-result.ts reverted to main, the three new tests are the only failures.
  • The e2e-tests failure (toolStream deltas, BadRequestResponseError from the live provider) also fails on main's scheduled run https://github.com/OpenRouterTeam/typescript-agent/actions/runs/35694019034.

Searched open PRs; #122 and #117 touch model-result.ts but not these paths.

Link to Devin session: https://openrouter.devinenterprise.com/sessions/751c0e35dd354e94ba0d8889f6f1cefb
Open in Devin Desktop: https://openrouter.devinenterprise.com/desktop/session/751c0e35dd354e94ba0d8889f6f1cefb?variant=devin
Requested by: @abhinav-pola


Devin Review

ReusableReadableStream.createConsumer() during cancel()'s await of the
source reader registered a consumer at a watermark the second backlog
sweep then cleared, so its first read threw the buffer invariant error.
cancel() now marks the stream cancelled synchronously and later
consumers are done immediately.

handleRunEndAsyncTasks('drain') only called dropSettledTasks() when work
was still in flight, so a task that settled during the last drain turn
was never harvested and stayed persisted as working. The final drop now
runs unconditionally.

Adds specs/tla with the models and TLC configs that found both.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access that start with 'DevinAI' or '@devin'.

  • Disable automatic comment, CI, and merge conflict monitoring

Original prompt from Abhinav

SYSTEM:
<latest_message>
Abhinav Pola (U090K0G7JF3) [ts=1790126362.734519]: @Devin do this to openrouter agents sdk <https://x.com/bcherny/status/2102543349102338309|x.com/bcherny/status/2102543349102338309>
</latest_message>

=== BEGIN THREAD HISTORY (in #brain-abhinav) ===
Abhinav Pola (U090K0G7JF3) [ts=1790126362.734519]: @Devin do this to openrouter agents sdk <https://x.com/bcherny/status/2102543349102338309|x.com/bcherny/status/2102543349102338309>
=== END THREAD HISTORY ===
Channel ID: C09JH4HR3DH
Thread URL: https://openrouter.slack.com/archives/C09JH4HR3DH/p1790126362734519?thread_ts=1790126362.734519&amp;cid=C09JH4HR3DH

The <latest_message> is the message that you should use to guide your goals + task for this session, and you should use the rest of the slack thread as context.
A [ts=...] marker on a Slack message is that message's timestamp. To act on a specific message with the slack tool (e.g. adding an emoji reaction via the reaction command), pass that value as timestamp along with the Channel ID — no extra lookup call is needed.

devin-ai-integration[bot]

This comment was marked as resolved.

…el()

cancel() before the first consumer never acquired a reader, and the
cancelled flag now stops later consumers from starting the pump, so the
source stream would stay open. Cancel it directly in that case.

Also extract registerConsumer() so createConsumer() stays under the
structural gate's complexity limit.
@devin-ai-integration devin-ai-integration Bot changed the title fix(agent): close two teardown races found by TLA+ model checking fix(agent): close two run-teardown races in ReusableReadableStream and async drain Sep 24, 2026

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant