refactor(core): settle steps lock-free by joining tool fibers first#38743
Open
kitlangton wants to merge 1 commit into
Open
refactor(core): settle steps lock-free by joining tool fibers first#38743kitlangton wants to merge 1 commit into
kitlangton wants to merge 1 commit into
Conversation
kitlangton
force-pushed
the
single-writer-step
branch
from
July 24, 2026 20:22
d19acc5 to
6c1a76d
Compare
kitlangton
force-pushed
the
single-writer-step
branch
from
July 25, 2026 01:28
6c1a76d to
978787e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Deletes all step-event locking from the runner — the v2 baseline's 12
serialized()semaphore sites become zero — and restructurescallModelsettlement so that every tool fiber is joined before any settlement write. Settlement becomes ~40 linear, contention-free lines.llm.tslands net-negative againstv2(+105/−110).flowchart LR P["provider loop — publish direct"] -->|tool-call, then fork| F[tool fiber] F -->|"ephemeral progress — direct"| U[UI] F -->|"own completion, masked, direct"| D[(durable history)] P --> D J["join ALL fibers (only interruptible line)"] --> S["settlement (masked, sole writer)"] S -->|"close unsettled calls, terminal event"| DHow
No lock, by invariant instead of mutex. The provider loop and each tool fiber publish durable events concurrently. This is safe for two reasons, now documented as a contract at the top of
publish-llm-event.ts:tool.settled,stepStarted) and its mark, so check-and-mark is atomic under cooperative scheduling.Tool.Called(the fork happens below that publish). Cross-source order is unconstrained; either interleaving is a truthful history of concurrent work.Join-first settlement. All owned fibers are awaited (all exits, not just the first failure) before any settlement logic. Afterwards no fiber is alive, the fold is final, and settlement is the step's only writer. The overflow-recovery, retry-escape, decline, sweep, and terminal-event logic is unchanged in substance — reordered after the join, with the thrown-LLM-failure branch (
llmError) folded into the same linear sequence.Fiber-owned completion, direct. Each tool fiber publishes its own durable completion under its existing uninterruptible mask, so a finished execution always reaches its settlement — including when the step is interrupted.
Tool.ProgressisEvent.ephemeral(advisory UI state, not durable history) and publishes direct from the fiber.Renames for truth.
retryEvidence→outputStarted(it is evidence the model already spoke, which bars retries and overflow recovery, not evidence for them). Three oracle getters (hasRetryEvidence/stepFailure/stepSettlement) collapse into onerecord(): StepRecordsnapshot;needsContinuationis derived from the record at completion instead of accumulated mid-stream. Fixed en route:classifyToolExitstyped its first failure non-optional via[0]indexing — now.at(0).Why no lock (design history)
Two earlier revisions of this PR were built and superseded: a single-writer queue/feed (merged provider events and tool emissions into one ordered stream) and a two-permit semaphore. A requirements walk plus a survey of comparable agents (grok-build, pi) killed both: the queue's termination bookkeeping bought a slogan, and the semaphore protected interleavings between events that have no required order. The concrete tear both were guarding against cannot occur given the publisher's mutate-then-publish shape — so the synchronization became one documented invariant instead of a primitive. What we deliberately did not adopt from those codebases: both fabricate interrupted-tool state per client in the view layer (grok-build's reload silently drops in-flight calls). Eager per-call durable settlement stays; every projection reads the same settled truth.
Testing
From
packages/core:bun typecheck— clean--rerun-each 5on the order-pinning suites — the empirical check that lock-free publication does not destabilize durable orderPluginSupervisor configflake, verified failing identically on cleanv2