You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG] Agent loop stalls permanently when write_to_file partial streaming hits a filesystem error (EROFS/EACCES) #703
When the model uses write_to_file to create a file whose parent directory does not exist (or is on a read-only mount), the extension freezes mid-stream and never returns control to the agent loop. The user sees an error flash in the UI but the assistant never continues.
Any user who asks the agent to write a file to a path whose root does not exist or is read-only (e.g. /scratch/foo.ts on macOS where /scratch does not exist) will hit this. The agent appears completely frozen and requires a manual abort or window reload.
Reproduction steps
Any OS/version where /scratch (or similar) does not exist
Ask the agent to write a file to /scratch/test.ts or any path whose parent directory cannot be created
The model issues a write_to_file tool call
Streaming begins, the UI shows the error EROFS: read-only file system, mkdir '/scratch'
The agent never continues -- no further tool calls, no error recovery, no completion
Expected result
The agent should surface the error as a tool result and continue the conversation (retry, use a different path, or report the failure to the user).
Actual result
The agent loop stalls permanently. userMessageContentReady is never set to true, so the task hangs waiting for a condition that can never be satisfied.
Root cause (technical)
WriteToFileTool.handlePartial() calls createDirectoriesForFile(absolutePath) (which calls fs.mkdir) without a .catch() guard. During streaming (block.partial === true), this throws EROFS.
The throw is caught by BaseTool.handle(), which calls handleError (correctly pushing a tool_result to API history) and returns. However, back in presentAssistantMessage(), the advancement gate:
if(!block.partial||cline.didRejectTool||cline.didAlreadyUseTool){cline.userMessageContentReady=true// never reachedcline.currentStreamingContentIndex++// never reached}
is skipped because block.partial is still true and neither flag was set by handleError. The loop stalls.
Fix: Remove the createDirectoriesForFile block from handlePartial -- it is redundant since execute() already calls it before diffViewProvider.open(). All other toolshandlePartialoverrides guard their async I/O with.catch(() => {})`; this one does not.
App Version
Reproducible on current main (confirmed by code inspection).
API Provider
Not Applicable / Other (filesystem error, provider-independent)
Split plan for this issue (recorded before any split PR opens)
PR #1066 is over the size cap: zdt split measure on its standalone delta vs its own base (72143527fd33306e5541116093c2cbf803cce9e0) gives 2025 a+d across 8 files -> HARD-OVERSHOOT (hard cap 1000). Changed executable lines 211/500 and valid mutants 116/400 are not breached and CI is green, so the split is driven by size + reviewability (maintainer p12tic on #1066: "it includes too many things at once"), not by a failing check.
Content source of record: tag pr1066-source = 46d1d218701f0ce2d675b1b315489bacb6b0f77d, base 72143527fd33306e5541116093c2cbf803cce9e0. Every unit contract pins source: { kind: "local", base: "72143527fd", head: "46d1d2187" }; no stacked unit head is used as a source.
Unit
Contract point (one provider group + one gate scope)
execute() error-path cleanup invariant (finally around handleError, writeApproved, mistake-counter order)
417 (soft cap, rationale in body)
U7
Early-return / rooignore-denial cleanup branches
233
Merge order (dependency-forced): U1 -> U2 -> U4 -> U5 -> U3 -> U6 -> U7 -> final integration PR (base = main, head = U7). Mid PRs base on the previous unit's head; the final PR is the sole merge target.
Cross-unit symbol ownership: U4 owns getTaskPartialStreamState, getPartialStreamFailureKey, resetTaskPartialState, clearTaskState, finalizePartialToolAskAfterFailure, revertDiffChangesBeforeReset, resetDiffViewAfterWrite; U3/U5/U6/U7 call them and rebase-and-drop their copies.
Accepted mechanism divergence (recorded decision): U4 keeps per-task keying only in WriteToFileTool while the sibling streaming tools (ApplyDiffTool, EditFileTool, SearchReplaceTool, EditTool) use BaseTool's singleton lastSeenPartialPath/resetPartialState. Lifting it into BaseTool for all streaming tools is a separate follow-up PR.
Sanctioned new content:presentAssistantMessage-custom-tool.spec.ts (+1 mock line, no source change) is listed in U2's allowNew.
Content source of record: tag pr1066-source = 46d1d218701f0ce2d675b1b315489bacb6b0f77d. The final head is byte-identical to it for all 8 original files; the only additions are two sanctioned allowNew test files.
Deviations from the original plan
U1 + U2 merged into U12 — the stage-semantics tests call finalizePartialToolAsk(), so the test blocks cannot be split without orphaning them (test-block atomicity).
reports a filesystem error only once across the streaming and execute phases re-attributed from U5 to U6 — it depends on the execute() error-path restructure.
U4 gained a focused cleanup spec (src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts, +100 lines, allowNew): the primitives' catch arms are only reachable by calling them directly at this layer, so the changed-line coverage item can pass by once on U4 itself.
Chain PRs are opened against main because the split branches live on the fork (no push access upstream). GitHub therefore shows a cumulative diff for each chain PR; the unit's own delta is stated in each body. Merge strictly in order.
Not yet done
Local Stryker preflight cannot run on this Windows worktree (src/node_modules/.bin/vitest is a WSL #!/bin/sh shim, so Stryker's spawnSync gets ENOENT). CI's mutation-diff job is the authoritative gate: 211 changed executable lines / 500 cap, 116 valid mutants / 400 cap — both under the caps, no directive added.
Per-unit issues now carry the full unit record (contract point, why the unit exists on its own, boundary, per-file a+d, budget, mutation-gate numbers, verification, deviations, reproduce commands):
Changed executable lines per unit (mutation gate, extension package, cap 500):
Unit
changed executable lines
U12
41
U4
63 (58 WriteToFileTool + 5 ClineProvider)
U5
24
U3
30 (15 BaseTool + 15 WriteToFileTool)
U6
34
U7
24
Sum 216; the whole PR measures 211 against the same gate, and valid mutants are 116 / 400 — no directive added by any unit. Each PR body now names its own issue, and each issue names its PR.
Activity
Drop the out-of-scope comment from the throttle test
Drop out-of-scope Stryker tooling changes
Restore the Stryker tooling files to the PR base
easonLiangWorldedtech commentedon Oct 5, 2026
Split plan for this issue (recorded before any split PR opens)
PR #1066 is over the size cap:
zdt split measureon its standalone delta vs its own base (72143527fd33306e5541116093c2cbf803cce9e0) gives 2025 a+d across 8 files -> HARD-OVERSHOOT (hard cap 1000). Changed executable lines 211/500 and valid mutants 116/400 are not breached and CI is green, so the split is driven by size + reviewability (maintainer p12tic on #1066: "it includes too many things at once"), not by a failing check.Content source of record: tag
pr1066-source=46d1d218701f0ce2d675b1b315489bacb6b0f77d, base72143527fd33306e5541116093c2cbf803cce9e0. Every unit contract pinssource: { kind: "local", base: "72143527fd", head: "46d1d2187" }; no stacked unit head is used as a source.Task.saveClineMessagesstage semanticsTask.finalizePartialToolAsk()BaseTool.onParameterParseFailure()boundary + WriteToFile overridehandlePartialstreaming failure capture + single-error reportingexecute()error-path cleanup invariant (finallyaroundhandleError,writeApproved, mistake-counter order)Merge order (dependency-forced): U1 -> U2 -> U4 -> U5 -> U3 -> U6 -> U7 -> final integration PR (base = main, head = U7). Mid PRs base on the previous unit's head; the final PR is the sole merge target.
Cross-unit symbol ownership: U4 owns
getTaskPartialStreamState,getPartialStreamFailureKey,resetTaskPartialState,clearTaskState,finalizePartialToolAskAfterFailure,revertDiffChangesBeforeReset,resetDiffViewAfterWrite; U3/U5/U6/U7 call them and rebase-and-drop their copies.Accepted mechanism divergence (recorded decision): U4 keeps per-task keying only in WriteToFileTool while the sibling streaming tools (ApplyDiffTool, EditFileTool, SearchReplaceTool, EditTool) use BaseTool's singleton
lastSeenPartialPath/resetPartialState. Lifting it into BaseTool for all streaming tools is a separate follow-up PR.Sanctioned new content:
presentAssistantMessage-custom-tool.spec.ts(+1 mock line, no source change) is listed in U2'sallowNew.Every split PR cites this issue.
easonLiangWorldedtech commentedon Oct 5, 2026
Split execution status (units opened in upstream, one issue per unit)
Merge order is fixed: U12 -> U4 -> U5 -> U3 -> U6 -> U7/FINAL. The FINAL integration PR is the sole merge target; the chain PRs are review units.
Content source of record: tag
pr1066-source=46d1d218701f0ce2d675b1b315489bacb6b0f77d. The final head is byte-identical to it for all 8 original files; the only additions are two sanctionedallowNewtest files.Deviations from the original plan
finalizePartialToolAsk(), so the test blocks cannot be split without orphaning them (test-block atomicity).reports a filesystem error only once across the streaming and execute phasesre-attributed from U5 to U6 — it depends on the execute() error-path restructure.src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts, +100 lines, allowNew): the primitives' catch arms are only reachable by calling them directly at this layer, so the changed-line coverage item can pass by once on U4 itself.mainbecause the split branches live on the fork (no push access upstream). GitHub therefore shows a cumulative diff for each chain PR; the unit's own delta is stated in each body. Merge strictly in order.Not yet done
src/node_modules/.bin/vitestis a WSL#!/bin/shshim, so Stryker'sspawnSyncgets ENOENT). CI's mutation-diff job is the authoritative gate: 211 changed executable lines / 500 cap, 116 valid mutants / 400 cap — both under the caps, no directive added.src/core/task/__tests__/Task.throttle.test.ts, a file dropped ineef03b577; they need a reply/resolve on the original PR.easonLiangWorldedtech commentedon Oct 5, 2026
Per-unit issues now carry the full unit record (contract point, why the unit exists on its own, boundary, per-file a+d, budget, mutation-gate numbers, verification, deviations, reproduce commands):
Changed executable lines per unit (mutation gate, extension package, cap 500):
Sum 216; the whole PR measures 211 against the same gate, and valid mutants are 116 / 400 — no directive added by any unit. Each PR body now names its own issue, and each issue names its PR.