Repository navigation
perf(opencode): overlap the pre-turn snapshot with the provider request - #27
PierrotAWB wants to merge 1 commit into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Hey! Your PR title Please update it to start with one of:
Where See CONTRIBUTING.md for details. |
|
The following comment was made by an LLM, it may be inaccurate: |
There was a problem hiding this comment.
3 issues found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/opencode/src/session/tools.ts">
<violation number="1" location="packages/opencode/src/session/tools.ts:108">
P1: When a generic MCP tool runs before `step-start`, this barrier does not protect it because only registry tools await the snapshot. A local MCP filesystem tool can therefore mutate the worktree before `snapshot.track()` finishes; await `input.processor.awaitSnapshot` in the MCP execute wrapper before the plugin hook and remote call.</violation>
</file>
<file name="packages/opencode/src/session/processor.ts">
<violation number="1" location="packages/opencode/src/session/processor.ts:132">
P2: `Effect.forkIn(scope)` forks the snapshot fiber into the shared SessionProcessor layer scope, not a per-turn scope, so aborting a turn does not cancel that turn's snapshot git work as the PR description claims. The fiber keeps running git subprocesses after the turn ends, and since `snapshot.track()` is globally mutex-locked it can delay the next turn's snapshot. To get the claimed per-turn interruption, capture the current turn's scope (e.g. `yield* Scope.Scope` inside `create`) and fork into that.</violation>
<violation number="2" location="packages/opencode/src/session/processor.ts:135">
P1: After the first provider step finishes, `awaitSnapshot` rejoins the already-completed initial snapshot fiber, so later `step-start` events record a stale pre-step snapshot. Rotate or re-track the barrier when `ctx.snapshot` is cleared so each provider step starts from the previous step's final tree.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| Effect.gen(function* () { | ||
| const ctx = context(args, options) | ||
| // Tools can run before any step event; the pre-turn snapshot must land before a tool mutates files. | ||
| yield* input.processor.awaitSnapshot |
There was a problem hiding this comment.
P1: When a generic MCP tool runs before step-start, this barrier does not protect it because only registry tools await the snapshot. A local MCP filesystem tool can therefore mutate the worktree before snapshot.track() finishes; await input.processor.awaitSnapshot in the MCP execute wrapper before the plugin hook and remote call.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/session/tools.ts, line 108:
<comment>When a generic MCP tool runs before `step-start`, this barrier does not protect it because only registry tools await the snapshot. A local MCP filesystem tool can therefore mutate the worktree before `snapshot.track()` finishes; await `input.processor.awaitSnapshot` in the MCP execute wrapper before the plugin hook and remote call.</comment>
<file context>
@@ -104,6 +104,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
Effect.gen(function* () {
const ctx = context(args, options)
+ // Tools can run before any step event; the pre-turn snapshot must land before a tool mutates files.
+ yield* input.processor.awaitSnapshot
yield* plugin.trigger(
"tool.execute.before",
</file context>
| Effect.forkIn(scope), | ||
| ) | ||
| const awaitSnapshot = Effect.gen(function* () { | ||
| if (ctx.snapshot === undefined) { |
There was a problem hiding this comment.
P1: After the first provider step finishes, awaitSnapshot rejoins the already-completed initial snapshot fiber, so later step-start events record a stale pre-step snapshot. Rotate or re-track the barrier when ctx.snapshot is cleared so each provider step starts from the previous step's final tree.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/session/processor.ts, line 135:
<comment>After the first provider step finishes, `awaitSnapshot` rejoins the already-completed initial snapshot fiber, so later `step-start` events record a stale pre-step snapshot. Rotate or re-track the barrier when `ctx.snapshot` is cleared so each provider step starts from the previous step's final tree.</comment>
<file context>
@@ -127,6 +123,21 @@ const layer = Layer.effect(
+ Effect.forkIn(scope),
+ )
+ const awaitSnapshot = Effect.gen(function* () {
+ if (ctx.snapshot === undefined) {
+ ctx.snapshot = yield* Fiber.join(snapshotFiber)
+ }
</file context>
| const snapshotFiber = yield* snapshot.track().pipe( | ||
| Effect.tap(() => Effect.sync(() => (ctx.snapshotMs = Date.now() - snapshotStart))), | ||
| Effect.orDie, | ||
| Effect.forkIn(scope), |
There was a problem hiding this comment.
P2: Effect.forkIn(scope) forks the snapshot fiber into the shared SessionProcessor layer scope, not a per-turn scope, so aborting a turn does not cancel that turn's snapshot git work as the PR description claims. The fiber keeps running git subprocesses after the turn ends, and since snapshot.track() is globally mutex-locked it can delay the next turn's snapshot. To get the claimed per-turn interruption, capture the current turn's scope (e.g. yield* Scope.Scope inside create) and fork into that.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/session/processor.ts, line 132:
<comment>`Effect.forkIn(scope)` forks the snapshot fiber into the shared SessionProcessor layer scope, not a per-turn scope, so aborting a turn does not cancel that turn's snapshot git work as the PR description claims. The fiber keeps running git subprocesses after the turn ends, and since `snapshot.track()` is globally mutex-locked it can delay the next turn's snapshot. To get the claimed per-turn interruption, capture the current turn's scope (e.g. `yield* Scope.Scope` inside `create`) and fork into that.</comment>
<file context>
@@ -127,6 +123,21 @@ const layer = Layer.effect(
+ const snapshotFiber = yield* snapshot.track().pipe(
+ Effect.tap(() => Effect.sync(() => (ctx.snapshotMs = Date.now() - snapshotStart))),
+ Effect.orDie,
+ Effect.forkIn(scope),
+ )
+ const awaitSnapshot = Effect.gen(function* () {
</file context>
|
Consolidated into #29 (one commit per concern there; review history for the prep_ms retry semantics is on this PR's threads). |
Issue for this PR
Issues are disabled on this fork; motivation below. Stacked on #26; re-target after it merges.
Type of change
What does this PR do?
SessionProcessor.createawaitedsnapshot.track()— a chain of git subprocesses (init/add/write-tree) over the worktree — before the provider stream could start, putting the whole git cost inside time-to-first-token on every step. The original eager await existed because the AI SDK can execute tools before emitting step events, so the snapshot must exist before any tool mutates files — a data-ordering constraint, not a "before the provider request" constraint.Now the track forks (
Effect.forkIn(scope)) and the stream starts immediately; an idempotentawaitSnapshoton the processor handle is the barrier, taken at exactly the consumption points: the shared toolexecutewrapper (before any tool runs — the case the original comment warned about) andstep-start. The provider's TTFT absorbs the git work.snapshot.trackis internally mutex-locked, so the forked fiber serializes safely against thestep-finishre-track;orDiekeeps a git failure failing the turn as before, andforkIn(scope)ties the fiber to the turn's scope so interruption still cancels it.snapshot_ms(#25) now reports the true concurrent git cost rather than serial prep.How did you verify your code works?
snapshot-tool-race.test.ts(the purpose-built test for this exact barrier) passes, plustest/snapshot55/55, andprompt.test.ts+compaction.test.ts+retry.test.ts145/145.bun run typecheckclean,bun run lint0 errors. Fleet-wide effect visible post-release asprep_msdropping whilesnapshot_msholds steady.Checklist
🤖 Generated with Claude Code
Summary by cubic
Overlaps the pre-turn snapshot with the provider request to reduce time-to-first-token while preserving tool ordering. Previously we awaited the snapshot before starting the provider stream; now we track the snapshot concurrently and gate tool execution and step-start behind an idempotent
awaitSnapshot.awaitSnapshottoSessionProcessor.Handle; tracks the snapshot in a forked fiber bound to the turn scope, preserving failure and cancellation semantics.SessionTools.resolveandstep-startonawaitSnapshot, ensuring tools cannot mutate files before the snapshot lands.snapshot_msmeasures the concurrent git cost, andprep_msshould drop accordingly.Review notes
forkIn(scope),Fiber.join, and the cached result on the handle.awaitSnapshot(e.g.,Effect.succeed(undefined)).Written for commit 2f010a4. Summary will update on new commits.