Skip to content

[split-1066] U6 - fix(write-to-file): run the diff cleanup when handleError rejects #1937

Open

Description

@easonLiangWorldedtech

Unit U6 of the PR #1066 split (5/6).

Split plan: #703
PR: #1932
Content source of record: tag pr1066-source = 46d1d218701f0ce2d675b1b315489bacb6b0f77d (easonLiangWorldedtech/Zoo-Code).

Unit contract

The execute() cleanup invariant: a finally around handleError so the diff cleanup runs even when handleError rejects, the writeApproved flag, and the consecutive-mistake-counter order.

Why this unit exists on its own

Single provider group + single gate scope (the execute() error path).

Boundary

Files and budget

  • src/core/tools/WriteToFileTool.ts +45/-11 — content subset of source
  • src/core/tools/__tests__/writeToFileTool.spec.ts +379/-4 — content subset of source
  • budget: 439 a+d / 2 files — SOFT-OVERSHOOT (rationale: single provider group + single gate scope; under the 1000 hard cap)
  • mutation gate: 34 changed executable lines — under the 500 cap; valid mutants for the whole PR are 116 / 400, so no directive was added by this unit.

Verification (must pass by once, binary)

  • zdt split verify --contract U6.json --worktree <wt> --head 9b93a6f882a4 — PASS (every changed file is a content subset of the source of record, or an explicitly sanctioned allowNew file).
  • Tests: 259 passed / 5 skipped, exit 0 (narrowest relevant suites: Task.spec.ts, writeToFileTool.spec.ts, writeToFileTool-partial-state-cleanup.spec.ts, removeClineFromStack-delegation.spec.ts, presentAssistantMessage-custom-tool.spec.ts).
  • Changed-line coverage: 14 covered / 0 uncovered — PASS.
  • ESLint --prune-suppressions --max-warnings=0: clean on every touched file; suppression counts unchanged.
  • No .changeset file, no CHANGELOG edit (AGENTS.md).

Deviations recorded

  • Owns the test reports a filesystem error only once across the streaming and execute phases, originally attributed to U5 (see U5).

Reproduce

git fetch https://github.com/easonLiangWorldedtech/Zoo-Code p1066/u6-execute-error-path-cleanup
node zdt.mjs split verify --contract U6.json --worktree <wt> --head 9b93a6f882a4
node zdt.mjs split measure --worktree <wt> --base e3c10401f760 --head 9b93a6f882a4
pnpm --dir src exec eslint --prune-suppressions --max-warnings=0 <touched file>

Scope re-cut (assigned coding scope)

Additional files defined and assigned to this unit (CodeRabbit Out of Scope Changes on #1932 at 443993e57; full reasoning in #41 (comment)):

Activity

  1. easonLiangWorldedtech commented on Oct 10, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Scope re-cut for U6 (#1932): which extra files are not U6's, and which one is

    Trigger: CodeRabbit Out of Scope Changes check (Error) on #1932 at 443993e57. Row, verbatim:

    Issue #1937 defines U6 as the single execute() error path and lists two files: WriteToFileTool.ts and writeToFileTool.spec.ts. The PR also changes BaseTool.ts, ClineProvider.ts, DiffViewProvider.ts, and their tests.

    The row's own second remedy is the one we are taking: or update issue #1937 to explicitly define and assign the additional coding scope before merge. This comment is that definition; #1937 now carries the same text in its body. It is per file, because the three files have three different answers, and the reason for each is the owning commit, not the fact that the code already exists.

    1. src/core/tools/BaseTool.ts (+36/-3) - NOT U6. Owned by e3c10401f feat(tools): onParameterParseFailure teardown boundary, which is U3 (#1931). git log -m 036245c5e..HEAD -- src/core/tools/BaseTool.ts returns that commit and the org-main merge and nothing else. It appears in #1932's file list only because the split branches live on the fork, so every unit PR in the chain is opened against main and GitHub shows the cumulative diff through this unit - which is stated in the PR body under Chain position. Merge order is fixed at U12 (#1927) -> U4 (#1929) -> U5 -> U3 (#1931) -> U6 (#1932) -> U7/FINAL (#1928); by the time U6 merges, U3 has merged and this hunk is no longer in U6's diff.

    2. src/core/webview/ClineProvider.ts (+6/-0) - NOT U6. Owned by d172c95ae feat(write-to-file): per-task partial stream state + cleanup primitives, which is U4 (#1929): the writeToFileTool.clearTaskState(task) call before task.dispose(), i.e. the history-task disposal the row names. Same cumulative-diff explanation as above.

    3. src/integrations/editor/DiffViewProvider.ts (+121/-10) and src/integrations/editor/__tests__/DiffViewProvider.spec.ts (+213/-1) - U6's own, and it cannot live outside U6. Owned by 443993e57, a U6 commit. The reason is not that it was written here first:

    • U6's contract is a finally around handleError so the diff cleanup runs even when handleError rejects. The cleanup that finally runs is DiffViewProvider.revertChanges(). Before this hunk, for a new file whose openDiffEditor() had rejected there was no activeDiffEditor and revertChanges() returned early, leaving the empty placeholder and the directories open() had created on disk. The invariant U6 asserts was then only vacuously satisfied at U6's own head: the finally ran, called a function, and that function did nothing. A test of the cleanup runs that cannot distinguish called from did something does not constrain the code U6 is about.
    • The same hunk stops revertChanges() from saving a dirty buffer on the rollback path. Saving unapproved streamed content is the state the execute() error path exists to roll back, so the flag writeApproved and the buffer behaviour are the same invariant seen from two sides; splitting them across units would let one side move without the other.
    • The 213 added spec lines are the tests for exactly that behaviour, so they are in scope by the same argument as the code they pin.

    Assigned to U6 explicitly: src/integrations/editor/DiffViewProvider.ts and src/integrations/editor/__tests__/DiffViewProvider.spec.ts, for the diff-cleanup target of the execute() error path only (rollback of an unapproved stream: placeholder, created directories, and the buffer that must not be saved). Anything in that file beyond the rollback target stays out of U6.

    Head this declaration is written against: 443993e57d65b75c8b1a3a2121af7e495104d538. The follow-up commit that fixes the two remaining rows on #1932 (Persistence Integrity: route the rollback through discardUnapprovedStream() as defined by the earliest unit that defines it, U7/#1928 f8f7ce19a; Lifecycle: the two-window + liveness shape from U4/#1929 61dd05a2c) stays inside the file pair declared above.

  2. easonLiangWorldedtech commented on Oct 10, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Migrated from #41 comment 6093028889 - this note belongs to the split-1066 chain (chain map: #1989), not to that unrelated closed bug (fork-issue numbering trap).

    Scope re-cut for U6 (#1932): which extra files are not U6's, and which one is

    Trigger: CodeRabbit Out of Scope Changes check (Error) on #1932 at 443993e57. Row, verbatim:

    Issue #1937 defines U6 as the single execute() error path and lists two files: WriteToFileTool.ts and writeToFileTool.spec.ts. The PR also changes BaseTool.ts, ClineProvider.ts, DiffViewProvider.ts, and their tests.

    The row's own second remedy is the one we are taking: or update issue #1937 to explicitly define and assign the additional coding scope before merge. This comment is that definition; #1937 now carries the same text in its body. It is per file, because the three files have three different answers, and the reason for each is the owning commit, not the fact that the code already exists.

    1. src/core/tools/BaseTool.ts (+36/-3) - NOT U6. Owned by e3c10401f feat(tools): onParameterParseFailure teardown boundary, which is U3 (#1931). git log -m 036245c5e..HEAD -- src/core/tools/BaseTool.ts returns that commit and the org-main merge and nothing else. It appears in #1932's file list only because the split branches live on the fork, so every unit PR in the chain is opened against main and GitHub shows the cumulative diff through this unit - which is stated in the PR body under Chain position. Merge order is fixed at U12 (#1927) -> U4 (#1929) -> U5 -> U3 (#1931) -> U6 (#1932) -> U7/FINAL (#1928); by the time U6 merges, U3 has merged and this hunk is no longer in U6's diff.

    2. src/core/webview/ClineProvider.ts (+6/-0) - NOT U6. Owned by d172c95ae feat(write-to-file): per-task partial stream state + cleanup primitives, which is U4 (#1929): the writeToFileTool.clearTaskState(task) call before task.dispose(), i.e. the history-task disposal the row names. Same cumulative-diff explanation as above.

    3. src/integrations/editor/DiffViewProvider.ts (+121/-10) and src/integrations/editor/__tests__/DiffViewProvider.spec.ts (+213/-1) - U6's own, and it cannot live outside U6. Owned by 443993e57, a U6 commit. The reason is not that it was written here first:

    • U6's contract is a finally around handleError so the diff cleanup runs even when handleError rejects. The cleanup that finally runs is DiffViewProvider.revertChanges(). Before this hunk, for a new file whose openDiffEditor() had rejected there was no activeDiffEditor and revertChanges() returned early, leaving the empty placeholder and the directories open() had created on disk. The invariant U6 asserts was then only vacuously satisfied at U6's own head: the finally ran, called a function, and that function did nothing. A test of the cleanup runs that cannot distinguish called from did something does not constrain the code U6 is about.
    • The same hunk stops revertChanges() from saving a dirty buffer on the rollback path. Saving unapproved streamed content is the state the execute() error path exists to roll back, so the flag writeApproved and the buffer behaviour are the same invariant seen from two sides; splitting them across units would let one side move without the other.
    • The 213 added spec lines are the tests for exactly that behaviour, so they are in scope by the same argument as the code they pin.

    Assigned to U6 explicitly: src/integrations/editor/DiffViewProvider.ts and src/integrations/editor/__tests__/DiffViewProvider.spec.ts, for the diff-cleanup target of the execute() error path only (rollback of an unapproved stream: placeholder, created directories, and the buffer that must not be saved). Anything in that file beyond the rollback target stays out of U6.

    Head this declaration is written against: 443993e57d65b75c8b1a3a2121af7e495104d538. The follow-up commit that fixes the two remaining rows on #1932 (Persistence Integrity: route the rollback through discardUnapprovedStream() as defined by the earliest unit that defines it, U7/#1928 f8f7ce19a; Lifecycle: the two-window + liveness shape from U4/#1929 61dd05a2c) stays inside the file pair declared above.

  3. easonLiangWorldedtech commented on Oct 10, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Migrated from #41 comment 6094009717 - this note belongs to the split-1066 chain (chain map: #1989), not to that unrelated closed bug (fork-issue numbering trap).
    Port sources recorded for #1932 (p1066/u6-execute-error-path-cleanup) - the unit's rollback fixes are ports, and the chain has to converge on one shape of each primitive:

    hunk ported from why
    discardFileTab() restore-then-save before close(tab), saveBufferClean() #1931 5c0f21219 same primitive, one shape; close()'s 2nd arg is preserveFocus, so the forced close never discarded a dirty tab
    saveChanges(onCommit) commit point + writeCommitted stand-down #1931 2a9bfabfb the durable-write fact is what the rollback must obey, not the approval fact
    placeholderPath released after the approved save #1929 8084eab2e #1929's Persistence Integrity fix; #1932 carries the same saveChanges()

    Deliberate deviation: the Critical thread also asks to gate revertChanges() on an active edit session. Not taken in #1932 - that guard is #1931's shape of the same method (already filed as a follow-up for #1931's next push). #1932 takes the other half (reset() clears relPath/newContent), which removes the stale-path trigger the thread's reproductions depend on, and its tests record the current guard rather than the desired one.

    Correction to the Major rooignore thread: the two missing-parameter early returns did already call reset(); what none of the three branches did was discard the content an earlier streaming delta had put in the diff view. All three now run the shared discard-then-reset teardown.

  4. easonLiangWorldedtech commented on Oct 10, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Port note: bring the commit-point semantics with the DiffViewProvider half

    The DiffViewProvider content U6 ports from 5c0f21219 (restore buffers before closing) sits in
    the same method as the commit-point signal added in U3 2a9bfabfb:
    saveChanges(diagnosticsEnabled, writeDelayMs, onCommit?) fires onCommit immediately after the
    document write (including the clean-document case, where disk already matches), and
    WriteToFileTool keeps a writeCommitted flag so its catch path does not roll back a write that
    already landed.

    Why it matters here: U6 owns the execute() catch cleanup. If it rolls the diff view back
    without checking the commit point, it undoes a write the user approved. Two further facts about
    the catch path, both verified at U3 head 5ff7bea73:

    • revertChanges() early-returns when !isEditing, and isEditing is set only by open(), so
      on the saveDirectly (prevent-focus-disruption) branch the cleanup is a no-op while the
      caller still reads "reverted". A test that runs only the default branch passes and misses it.
    • reset() clears createdDirs / adoptedCreatedDirs without touching disk, so anything not
      rolled back before the reset is unreachable.

    Port source (per-hunk ownership = the earliest commit defining the hunk): U3 / #1931 commits
    5c0f21219 and 2a9bfabfb. Carry onCommit and writeCommitted in the same positions, or the
    two branches will disagree when they merge.

    Ported from the retired fork tracking item 6093538947 / the U3 note above.

  5. easonLiangWorldedtech commented on Nov 12, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Re-split plan for U6 (#1932): two units, posted before anything opens or moves

    Carrier verified rather than assumed. This issue's title is [split-1066] U6 - fix(write-to-file): run the diff cleanup when handleError rejects, and its body names the pull twice: the line PR: https://github.com/Zoo-Code-Org/Zoo-Code/pull/1932, and in the scope re-cut: "NOT this unit's, listed only to close the row: BaseTool.ts belongs to U3 (#1931, e3c10401f) and ClineProvider.ts to U4 (#1929, d172c95ae); both show in #1932's file list because the chain is opened against main and the diff is cumulative through this unit." The chain map itself lives on #1989 ([Tracking] split-1066 chain: rules, port sources, chain-level rows), whose body carries the row | U6 | #1937 | #1932 | p1066/u6-execute-error-path-cleanup | open | and the sentence "Declared merge order: U3 #1931 -> U4 #1929 -> U5 #1930 -> U6 #1932 -> U7 #1928 (final integration, base main)." A chain-level copy of the unit-list change goes to #1989; everything unit-scope stays here.

    What splits, and into what

    Figures, every one with its head attached

    Units are counted executable lines (instrument cap 500), selectors from the same path:startLine-endLine list, and instrumented mutants produced by Stryker run --dryRunOnly on that identical selector list (gate cap 400 valid mutants). Instrument: scripts/stryker-diff.mjs blob 7dfe0006d2d5, selector logic run in process at caps 500 / 400, extension leg only. Test, mock, fixture and test-utils paths are excluded from both counts by the instrument itself, so moving specs is free in width.

    shape head counted executable lines (cap 500) selectors instrumented mutants (cap 400) outcome against the caps
    parent: main tip 53cb5b5e7c8014d3226e5636bbca8999ed3b8526 merged with pull head 1eabeca9abbbbac031cc69643c4bcb7462db8363, candidate fe447fa7a766eb36f76d3361d2f4331817e97d61, tree 84711aad7d7940c9c787fe460b1c6efad6d92d7d both, held constant 450 163 410 50 lines under, 10 mutants over
    U6a, commit 210b94a981bd34d343d662e132370b4d22760491, tree 089b78a5ac60ae50a3e7cffc442057c34730db88, parent main tip 210b94a981bd 207 68 183 293 lines and 217 mutants under
    U6b delta over U6a, commit 28c086751c16b76b1e7086fa85d96e641d147578, tree = the parent tree 28c086751c16 243 95 227 257 lines and 173 mutants under

    Additivity is exact, so nothing is estimated: 207 + 243 = 450 lines, 68 + 95 = 163 selectors, 183 + 227 = 410 mutants. Per file at the parent shape, from four separate dry runs on the same selector list: DiffViewProvider.ts 207 lines / 68 selectors / 183 mutants; WriteToFileTool.ts 228 / 89 / 217; BaseTool.ts 14 / 5 / 9; ClineProvider.ts 1 / 1 / 1 -- each column sums to the parent total exactly.

    Density is what makes this pull answer differently from #2385: 2.52 instrumented mutants per selector here, against 3.55 measured on #2385's safeWriteText.ts (527 lines / 150 selectors / 533 mutants), where every defensible seam put one half back over a cap. Here the files split along a real call-graph boundary and both halves keep 173-217 mutants of headroom, so no carve and no gate exclusion is needed.

    What these numbers are

    • Every mutant figure is an instrumented count produced on my Windows host, where the initial test run aborts with Vitest failed to find test files related to mutated files and then No tests were executed. It is a count of mutants produced, never a verdict, a score, a kill rate or a pass, and no unit is called green on it.
    • The line figures are stock-selector outcomes at the caps as committed; the stock selector throws on neither half and reports the same counted-line totals as a caps-raised copy.
    • The CI facts quoted below are from a campaign posture read held on disk by lane u910-11 (2026-11-11T23:21:22Z, GraphQL statusCheckRollup for 22 contexts at head 1eabeca9abbb), not from my own API call, and I have not re-read the ruleset.

    The eight type errors U6a carries, and what CI actually reports for this pull

    Measured on a worktree whose install matches the repository lockfile (tsc --noEmit from src, build-info file redirected so no shared cache was written): main tip 53cb5b5e7c80 gives 0 errors; the pull head alone 1eabeca9abbb gives 10 errors, none of them TS2540; the merge of the two gives exactly 8, all TS2540: Cannot assign to 'isDirty' because it is a read-only property at src/integrations/editor/__tests__/DiffViewProvider.spec.ts lines 1414, 1447, 1475, 1500, 1548, 1674, 1745 and 1805.

    Mechanism, measured rather than assumed: upstream rewrote that spec to build its doubles with the typed helpers in src/test-utils/vscode.ts (makeTextDocument / makeTextEditor, whose returns are as unknown as vscode.TextDocument / TextEditor), and vscode.TextDocument.isDirty is read-only. The pull's isDirty assignments survive the merge, and eight of them bind to that read-only property. U6a repairs them with a locally widened structural type -- type MutableTextDocument = Omit<vscode.TextDocument, "isDirty"> & { isDirty: boolean } plus a typed accessor -- which is assignment-compatible without a cast, so no as unknown as and no any is added. That repair is new content in U6a and is listed as such: it is not in the pinned content source.

    This is not sold as a CI blocker. In the posture read above, the only FAILURE at this head is webview-visual (check run 122373017189, 2026-10-27T18:13:47Z -> 2026-10-27T18:17:38Z), which is one of the advisory legs; compile (122373016641), knip (122373015993), e2e-mock (122373016536), platform-unit-test on ubuntu and windows (122373016581, 122373016878), Build test VSIX (122373016513) and dependency-review (122373016496) are COMPLETED/SUCCESS, as are both mutation-diff rows (122373016368, 128583563671). A red compile leg is not what CI reported for this pull today; the eight errors are real at the shape this re-cut lands on, and they are repaired because U6a is rebased onto today's main.

    Cross-unit overlap I am naming instead of hiding

    Meter and limits for this plan post

    This comment is one REST core (POST issue comment). Reading this issue, PR #1932 and #1989 was three unauthenticated GETs on the 60/hour unauthenticated pool, which draws no campaign token. All shape work was local git plumbing (ls-remote, merge-tree, commit-tree, rev-list, cat-file, show, grep) plus local Stryker dry runs and local tsc, none of which is metered. Nothing is pushed, opened, retitled or reassigned by this comment, and no CI result is claimed.

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

Metadata

Metadata

Assignees

No one assigned

    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