Skip to content

[BUG] Agent loop stalls permanently when write_to_file partial streaming hits a filesystem error (EROFS/EACCES) #703

Description

@awschmeder

Problem

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.

Error visible in logs:

Error handling partial write_to_file:
EROFS: read-only file system, mkdir '/scratch'

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.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

  1. Any OS/version where /scratch (or similar) does not exist
  2. Ask the agent to write a file to /scratch/test.ts or any path whose parent directory cannot be created
  3. The model issues a write_to_file tool call
  4. Streaming begins, the UI shows the error EROFS: read-only file system, mkdir '/scratch'
  5. 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 reached
    cline.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 tools handlePartialoverrides 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)

Activity

  1. easonLiangWorldedtech commented on Oct 5, 2026

    @easonLiangWorldedtech
    Contributor

    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) a+d
    U1 Task.saveClineMessages stage semantics 173
    U2 Task.finalizePartialToolAsk() 307
    U3 BaseTool.onParameterParseFailure() boundary + WriteToFile override 258
    U4 WriteToFileTool per-task stream state + cleanup primitives + ClineProvider disposal wiring 346
    U5 handlePartial streaming failure capture + single-error reporting 242
    U6 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.

    Every split PR cites this issue.

  2. easonLiangWorldedtech commented on Oct 5, 2026

    @easonLiangWorldedtech
    Contributor

    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.

    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 sanctioned allowNew test files.

    Deviations from the original plan

    1. U1 + U2 merged into U12 — the stage-semantics tests call finalizePartialToolAsk(), so the test blocks cannot be split without orphaning them (test-block atomicity).
    2. 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.
    3. 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.
    4. 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.
    • 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 in eef03b577; they need a reply/resolve on the original PR.
  3. easonLiangWorldedtech commented on Oct 5, 2026

    @easonLiangWorldedtech
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions