Repository navigation
[BUG] Agent loop stalls permanently when write_to_file partial streaming hits a filesystem error (EROFS/EACCES) #703
Description
Activity
easonLiangWorldedtech commented
on Oct 5, 2026 ContributorMore actionsSplit 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.Unit Contract point (one provider group + one gate scope) a+d U1 Task.saveClineMessagesstage semantics173 U2 Task.finalizePartialToolAsk()307 U3 BaseTool.onParameterParseFailure()boundary + WriteToFile override258 U4 WriteToFileTool per-task stream state + cleanup primitives + ClineProvider disposal wiring 346 U5 handlePartialstreaming failure capture + single-error reporting242 U6 execute()error-path cleanup invariant (finallyaroundhandleError,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'sallowNew.Every split PR cites this issue.
easonLiangWorldedtech commented
on Oct 5, 2026 ContributorMore actionsSplit 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.
Unit Contract point PR (upstream) Own issue a+d Files zdt split verify Unit tests Changed-line coverage U12 saveClineMessages stage semantics + finalizePartialToolAsk() #1927 #1933 481 3 PASS 218 passed / 5 skipped 18/0 PASS U4 per-task partial stream state + cleanup primitives + provider disposal wiring #1929 #1934 428 5 PASS 230 passed / 5 skipped 34/0 PASS U5 streaming failure captured once, reported once #1930 #1935 283 2 PASS 239 passed / 5 skipped 13/0 PASS U3 BaseTool.onParameterParseFailure() teardown boundary #1931 #1936 253 3 PASS 243 passed / 5 skipped 20/0 PASS U6 execute() error-path cleanup invariant #1932 #1937 439 2 PASS 259 passed / 5 skipped 14/0 PASS U7 / FINAL early-return + rooignore-denial cleanup; integration of the series #1928 #1938 241 2 PASS 264 passed / 5 skipped 12/0 PASS 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
- 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 phasesre-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
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
- Local Stryker preflight cannot run on this Windows worktree (
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. - Two stale CodeRabbit threads on fix(write-to-file): address partial filesystem error review #1066 point at
src/core/task/__tests__/Task.throttle.test.ts, a file dropped ineef03b577; they need a reply/resolve on the original PR.
- U1 + U2 merged into U12 — the stage-semantics tests call
easonLiangWorldedtech commented
on Oct 5, 2026 ContributorMore actionsPer-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):
- U12 -> [split-1066] U12(1+2) - task save-stage semantics + finalize open partial tool ask #1933
- U4 -> [split-1066] U4 - feat(write-to-file): per-task partial stream state + cleanup primitives #1934
- U5 -> [split-1066] U5 - fix(write-to-file): capture the streaming failure once and report it once #1935
- U3 -> [split-1066] U3 - feat(tools): onParameterParseFailure teardown boundary #1936
- U6 -> [split-1066] U6 - fix(write-to-file): run the diff cleanup when handleError rejects #1937
- U7 / FINAL -> [split-1066] U7 - fix(write-to-file): clean partial state on missing-param and rooignore denial #1938
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.
Problem
When the model uses
write_to_fileto 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.Error visible in logs:
Context
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.tson macOS where/scratchdoes not exist) will hit this. The agent appears completely frozen and requires a manual abort or window reload.Reproduction steps
/scratch(or similar) does not exist/scratch/test.tsor any path whose parent directory cannot be createdwrite_to_filetool callEROFS: read-only file system, mkdir '/scratch'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.
userMessageContentReadyis never set totrue, so the task hangs waiting for a condition that can never be satisfied.Root cause (technical)
WriteToFileTool.handlePartial()callscreateDirectoriesForFile(absolutePath)(which callsfs.mkdir) without a.catch()guard. During streaming (block.partial === true), this throwsEROFS.The throw is caught by
BaseTool.handle(), which callshandleError(correctly pushing atool_resultto API history) and returns. However, back inpresentAssistantMessage(), the advancement gate:is skipped because
block.partialis stilltrueand neither flag was set byhandleError. The loop stalls.Fix: Remove the
createDirectoriesForFileblock fromhandlePartial-- it is redundant sinceexecute()already calls it beforediffViewProvider.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)