Repository navigation
Description
Summary
On Windows, Snapshot.capture can fail with
EPERM: operation not permitted, uv_spawn 'C:\Program Files\Git\cmd\git.EXE'. The snapshot is
documented as best-effort and has an error handler, but this failure escapes it and fails the
whole step: the session ends the turn with an idle message and the user sees no error.
Environment
- opencode version: 2.0.26 (observed); source compared against 2.1.4
- OS: Windows 10.0.26300 (win32 x64)
- Terminal: unavailable:
TERM_PROGRAM/WT_SESSIONare not set (TERM=xterm-256color) - Shell:
powershell.exe(ComSpec=C:\WINDOWS\system32\cmd.exe) - Install/channel: latest
- Active plugins:
@cortexkit/opencode-magic-context@0.48.0
Reproduction
Not reproduced deterministically — EPERM on spawn comes from the OS (Windows security
policy, antivirus/filesystem filter, or process-creation limits). The path is:
- Work in a git repository on Windows, keep the snapshot feature enabled.
- Trigger a step boundary at a moment when spawning
git.exeis refused for the server
process (EPERM). - The step fails; no tool call is interrupted, no message is produced.
A unit-level repro would be a spawner that throws from spawn() rather than failing with a
typed AppProcess.AppProcessError.
Expected Behavior
A snapshot failure is best-effort by contract (packages/core/src/snapshot.ts:45-47):
log a warning, return undefined, and let the step continue without a snapshot.
Actual Behavior
The error propagates out of Snapshot.capture → SessionStep.attempt → SessionRunner.drain
and ends the step:
timestamp=2026-10-23T11:49:10.570Z level=ERROR run=... message="Failed to drain Session"
cause="Error: EPERM: operation not permitted, uv_spawn 'C:\Program Files\Git\cmd\git.EXE'
at spawn (unknown)
at spawn (node:child_process:730:35)
at spawn (node:child_process:22:39)
at Cr (chunk-3kgnsp08.js:2:6060)
at AppProcess.run (chunk-bs2ghvry.js:21:1897)
at Git.tree.capture (chunk-b7pgr6s2.js:116:2552)
at Snapshot.capture (chunk-gfcrb6b.js:361:11390)
at SessionStep.attempt (chunk-gfcr6bw6.js:376:3409)
at SessionRunner.runStep (chunk-gfcr6bw6.js:376:2507)"
Three milliseconds later the session recorded an idle message (time_created 11:49:10.573Z)
and nothing else. There is no user-visible error and no indication that the turn ended
abnormally.
Additional Context
Why the handler does not catch it
packages/core/src/snapshot.ts:141:
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))),
)In effect v4 Effect.catch is catchAll: it recovers typed failures (E) only. A spawn
error thrown by child_process arrives as a defect, so the handler never runs. The stack
above starts at spawn (node:child_process:730:35) and the error is a plain Error, not
AppProcess.AppProcessError — consistent with a defect.
2.1.4 does not cover it
snapshot.tsin 2.1.4 keeps the sameEffect.catch(...)handler at the same place.packages/core/src/git.ts:370-374(2.1.4) states explicitly: "Only a Git exit is retried,
never a failure to start Git."
Impact seen
The step ends silently in the middle of a user request; the conversation appears stuck until
the user sends another message.
Frequency in the log of one machine: 42 Failed to drain Session lines, 30 of them in a
single hour (2026-10-24T04:16Z, 30 consecutive entries within one minute — one session hitting
a local obstacle, not 30 independent cases). After upgrading to 2.1.4: 0 occurrences during
~35 minutes of observation. The sample after the upgrade is too short to call it fixed, and the
relevant code path is unchanged, so this stays open as an observation.
Platform scope
The defect is in the harness code path, not in the OS: any thrown error from spawning git
takes the same route out of the best-effort capture. EPERM is what a Windows endpoint
protection / filesystem filter produces; on Linux the same path is reachable through EACCES
from a noexec mount, SELinux/AppArmor denials, or cgroup limits. So the fix matters on every
platform, while the trigger frequency is far higher on Windows. All measurements below are from
Windows.
Suggested fix direction
Catch defects (Effect.catchCause / Effect.catchAllCause, or Effect.orElseSucceed)
around the snapshot body so the documented best-effort contract holds for thrown spawn
failures too, and surface the failure as a warning in the session log.
Activity
opencode-agent commentedon Oct 24, 2026
Thanks for the detailed analysis. I reproduced it on the v2.0.26 source and on the latest
dev(f20760b) using a test, without a real Windows machine.The script below makes spawning
gitthrow synchronously withEPERM: operation not permitted, uv_spawn 'git', which matches the error in your stack trace. The firstSnapshot.capture()returns a tree ID. The next one, with spawn throwing, does not returnundefinedas the best-effort contract says. It fails with theEPERMerror coming out ofGit.tree.capture, which gets past theEffect.catchhandler.The throw seems to come from
launchProcessinpackages/util/src/cross-spawn-spawner.ts. It callscross-spawnwithout a try/catch, and only asyncerrorevents become typed errors. On Linux, Bun reports spawn errors such asEACCESasynchronously, so they are handled there. The synchronous throw seems specific to how Bun spawns processes on Windows.I didn't run a full session step here. That this then ends the turn silently comes from your log.
Reproduction script (snapshot-spawn-throw.test.ts)
Run it:
bun snapshot-spawn-throw.test.tsDopetaiga commentedon Oct 24, 2026
I'd like to take this. Plan: the best-effort handler at
snapshot.tsusesEffect.catch, which in Effect v4 recovers typed failures only — a spawn failure fromchild_processarrives as a defect and sails past it into the step. AddEffect.catchDefectalongside so the documented contract (log a warning, returnundefined, keep the step alive) holds for both channels; interruption still propagates. Will verify with a defect-injection repro of the exact handler shape.fix(util): type a synchronous spawn refusal through the PlatformError…
fix(util): type a synchronous spawn refusal through the PlatformError…