Skip to content

mcp: record tool changesets as workspace patches - #14460

Open
vito wants to merge 12 commits into
mainfrom
vito/normalize-onto-workspace
Open

vito wants to merge 12 commits into
mainfrom
vito/normalize-onto-workspace

Conversation

@vito

@vito vito commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

MCP.applyChangeset → normalizeChangesetToPatch (#14441) rewrites a tool's changeset as before.withPatchFile(blob(patch), onConflict: LEAVE_CONFLICT_MARKERS)…changes(from: before), where before is the changeset's own Before, and records it with Workspace.withChanges.

Before is whatever the producer chose, and it can be expensive. In trace 9d6121d1e6278a650780535d1e204b10, editor_generate merged 7 SDK generator changesets, so the normalized patch was applied on top of directory.withDirectory("", source: <before_1>)…withDirectory("", source: <before_7>). For the Python SDK generator (python-client-dev), Before is container…withExec(["uv","sync"]).withServiceBinding("dagger-engine", <dev engine Container.asService>)…directory("/src"), a container output.

Every later workspace kept that recipe, and so did every commit, recompose and module load built from those workspaces. Each of them depended on building the dev engine and running that container: about 100 execs, plus dockerBuild/asTarball. Restoring that conversation spent minutes rebuilding it, even though the overlay was only ever meant to be "workspace + patch".

Normalizing also cost every tool call a full-tree diff (main) or a sparse read plus a check (this PR's first design). A benchmark (below) showed that both were avoidable.

Result

After a tool call on an in-engine workspace, the workspace's recipe is the prior workspace plus a patch blob (Workspace.__withPatch). It no longer depends on:

  • the tool call itself, such as editor.edit(…) or a generator's module function;
  • the changeset's Before/After, or any container exec, service or dev engine behind them.

So restoring the conversation, and every commit, recompose and module load built from that workspace, replays none of the tool's work. The tests check this directly:

  • TestChangesetToolPatchesWorkspace restores the LLM from its recipe in a fresh session. A cache-volume sentinel fails if the generator's command runs again.
  • The changeset tests assert that the recipe has no tool call and no withExec. That covers generators, commands, pure edits, mv and the cwd cases.

Execs a single tool call adds to the workspace's recipe, from the benchmark's recipe stats (the workspace's own setup exec isn't counted):

Scenario main this PR
edit (1 file) 0 0
codegen (1 generator) 1 0
codegen ×4 via withChangesets 4 0
vendor (1,000 files) 1 0

The benchmark's generators are a single alpine exec each. In the motivating trace, each generator's Before carried about 100 execs and a dev engine build, and every later workspace in the conversation inherited them.

Host-backed workspaces are the exception. They apply the raw changeset, so their recipe keeps the tool call, but a conversation over the client's checkout can't be reproduced from its recipe anyway.

Change

MCP.applyChangeset now records a tool's changeset according to where the workspace lives and what the changeset's recipe contains.

  • Host-backed workspace (ClientLocalBase): the raw changeset is applied with Workspace.withChanges.
    • A conversation over the client's checkout can't be reproduced from its recipe anyway. Normalizing only added a patch render, a sparse host read and a check to every call.
    • The no-op check (PathCountExceeds(0)) stays, so a successful command that changed nothing still doesn't advance state.
  • In-engine workspace (edits, generators, commands, module functions, fetches): the changeset is applied as a patch rendered against the workspace, not Before.
    • Changeset.RenderPatchOnto (core/changeset_onto.go) stages only the changed paths, from the workspace root and from After, and runs git diff --binary between them.
    • The new internal Workspace.__withPatch applies the patch with a plain git apply on the workspace root. Nothing else is built or compared.
    • The patch starts from the workspace's own content, so applying it reproduces the raw changeset by construction. That includes overwriting a file Before lacked, such as a gitignored generator output on its second run. So there's no check and no fallback.
    • The recorded overlay is the prior workspace plus a blob. The model is shown this same patch, so it's rendered once.
    • Empty directories, which a patch can't express, are passed in directories/removedDirectories. A new non-empty directory's mode isn't kept.
    • Pure edits take this path too. An earlier commit recorded them as After.changes(from: Before), chosen by a structural walk of the recipe, to drop just the tool call. With Before = ws.directory("/"), though, the first read after each edit paid a full-tree diff of about 150ms on the benchmark workspace, while the patch is rendered for the tool result anyway. Patching every edit cut that read to 9ms, and on a quiet machine apply didn't get slower either (151 against 171ms).

Errors and limits:

  • A changed path under a workspace mount is an error, as it is for withChanges.
  • A patch over MaxFileContentsSize falls back to the raw changeset.

cwd: vito/editor measures its changesets from source.directory("."), which is relative to the workspace cwd, but Workspace.withChanges applies at the root. With a non-root cwd, edits landed at the wrong path on all three paths above; for example, a host-backed edit of sub/notes.txt rewrote the root notes.txt. applyChangeset now traces Before's recipe back to the workspace read to find the directory it was measured from, and applies the changeset there. When that can't be traced (a tree read out of a container), it applies at the root, which is where generators measure from.

Removed: the sparse workspace read, the base.withChanges(raw) check, the Before fallback, rebasePatchOntoBefore and reconcileDirsAfterPatch.

Tooling

  • engine-dev testProfile: runs like test, but the test engine records wcprof from startup (_DAGGER_WCPROF=1). It returns the dump, the exit code and the output tail even when tests fail, and sets _DAGGER_BENCH=1.
  • engine-lab engineTest(wcprofCapture: "<name>"): keeps that dump as a named capture, so wcprofReport (including compare) works on test runs.
  • TestLLM/TestBenchChangesetApply: skipped unless _DAGGER_BENCH is set.
    • Setup: a value workspace of 5,000 files (22 MB) plus a 2.4 MB generated file.
    • Each run makes one tool call with its own client and unique content, and ends with one read of the workspace.
    • Logs per-rep timings and recipe stats.

Benchmark

All figures come from engineTest(pkg: "./core/integration", run: "TestLLM/TestBenchChangesetApply", wcprofCapture: …), run back to back on a quiet machine. They're medians of reps 1–3, in ms.

  • apply: from the tool call's return to the end of the LLM loop step, minus the generator output that apply forces (Container.directory). Measured with wcprof.
  • read: the final Directory.entries, which forces the overlay.
  • no-op row: the per-turn cost that falls in the same window.

Columns, apply / read:

Scenario main previous design pure edits unwrapped this PR + #14466
no-op 130 / 4 143 / 4 81 / 4 83 / 4 78 / 4
edit (1 file) 213 / 151 336 / 7 171 / 156 151 / 9 43 / 9
codegen (1 generator) 306 / 154 360 / 8 170 / 15 180 / 18 73 / 15
codegen ×4 via withChangesets 369 / 14 371 / 11 219 / 21 222 / 20 240 / 25
vendor (1,000 files, 4 MB patch) 1117 / 206 1124 / 63 504 / 223 519 / 191 403 / 194

Apply plus read, against main: edit goes from 364 to 160, codegen from 460 to 198, ×4 from 383 to 242, and vendor from 1323 to 710. With #14466, edit is 52 and codegen 88, mostly because computing a changeset's paths walks only the new overlay layer. The ×4 scenario merges several generators' layers, so #14466's fast path doesn't apply to it; its difference there is within noise.

Recipes: see Result above. Every in-engine overlay is the prior workspace plus __withPatch, and no scenario's recipe gains an exec. The PR's first design already removed those execs; the redesign keeps that and costs less.

Known and left for follow-ups:

  • Directory.withChanges on large trees: it still diffs the full trees even when the changeset's paths are already known. Host-backed workspaces and other withChanges callers pay for that. core: apply and diff small changesets cheaply #14466 fixes it separately, along with walking only the new layer when computing a changeset's paths (see the last benchmark column). Whichever PR merges second needs a one-line update to core/changeset_onto_test.go for a changed helper signature.
  • Vendor: the read is mostly git apply of the 4 MB patch (~175ms). Its recipe is about 12 MB because blob contents are stored inline in the call, which is also separate.
  • Restoring host-backed conversations: the raw edits are replayed against the host as it is at restore time. There's no tolerant patch restore there anymore.
  • Paths shown to the model: after a patch, it sees root-relative paths; after a host-backed edit, it sees cwd-relative ones. vito/editor could also build its changesets from directory("/") plus the cwd, so the engine wouldn't have to infer the directory.

Tests

  • New unit tests:
    • TestRenderPatchOnto runs a real git apply. It covers overwrites, a stale Before, empty, removed and pruned directories, a cwd prefix, symlink escapes and the size limit.
    • TestWorkspaceTreePrefix.
  • New integration tests:
    • TestChangesetToolRegeneratesIgnoredFile: a generator that copies the workspace with gitignore: true, run twice.
    • TestChangesetToolPatchesDirectories, TestChangesetToolRefusesMounts and TestChangesetToolAppliesAtCwd (pure, patched, root-measured and host cases).
    • TestChangesetToolPatchesPureEdits, which includes a restore and checks that the patch is rendered once and paths computed once per call.
    • TestChangesetToolPatchesWorkspace, which replaces TestChangesetToolRebasesOntoWorkspace.
  • Adapted: TestChangesetToolKeepsEmptyDirectories, TestLargeChangesetToolSkipsPatchWork and TestChangesetToolPrunesExecution.
  • Results: TestLLM/, TestAgents/, TestAgentRestore/ and golangci-lint pass. After the last two commits (empty-directory listing, patching pure edits), TestLLM/TestChangesetTool, TestAgentRestore/, the unit tests and lint were re-run and pass.

A tool's changeset was normalized to Before.withPatchFile(blob), so the
recorded workspace overlay kept the changeset's Before: whatever the producer
chose, e.g. a generator's source tree read out of a container after running
codegen against a dev engine. Every later workspace, and every commit,
recompose and module load built from one, then rebuilt it.

Apply the patch to a sparse read of the bound workspace instead (just the
changeset's paths), so the overlay's recipe is the prior workspace plus a
blob. The result is checked against applying the raw changeset to the same
paths; directory residue is reconciled as before, and any file-level
difference (Before did not match the workspace) or a changeset touching a
workspace mount falls back to rebasing onto Before.

Signed-off-by: Alex Suraci <[email protected]>
@vito

vito commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 Handoff: benchmark results, revised plan, and next steps

Update (session 2), read this first. The original handoff below is still the reference. This update records decisions and what's left to do.

Decisions

  1. Take over this PR and branch, and force-push the redesign.
  2. Bundle the tooling (wcprof test capture and the benchmark harness) into this PR.
  3. Parallelize with two staff workers, tooling and redesign.
  4. Make no changes to engine/wcprof. This replaces section 3's phase-op idea:
    • no new op kind, and no wcprof.BeginOp phase markers;
    • every dagql call, including the internal srv.Select calls from core/mcp.go, is already recorded as a call op named after its field (Changeset.asPatch, Directory.withPatchFile, Workspace.withChanges, and later Workspace.__withPatch);
    • lazy work shows up where it's forced, as lazy/call_exec;
    • root reports (critpath/tree/breakdown) at the tool's call op;
    • drop the bench.* spans and the forced evaluation, and end each run with one read of the resulting workspace.

State

  • Nothing is implemented or pushed yet. The head is still 592a560, 74 commits behind main.
  • A trial rebase onto main (6dd48d4) had no conflicts. It was never pushed, so it needs to be redone.
  • rebase needs a clean tree. Put any local dagger.toml/dagger.lock tool pins in a temporary commit, rebase, then soft-reset the temporary commit away. Never push those pins.

Staff breakage after reloading onto vito/agents#27

  • Symptom: every staff call failed with convert field members: expected module type, got *hm.FunctionType.
  • Cause: the old staff module stored the roster in a private map named members. Question about the UX for shell evaluation and strings #27 renames that map to agents and reuses members for the collection function. Reload carried the old key over, because overlayModuleObjectState in core/object_reload.go only warns about private keys, and the dang SDK then tried to decode it against the function.
  • Workaround: start a fresh session.
  • Fix for Question about the UX for shell evaluation and strings #27: in Staff.agent, use .withTools(tools, except: plumbing, version: 1).
  • Possible engine follow-up: drop a carried private key whose name is now a function rather than a field.

Next steps

  1. Check out vito/normalize-onto-workspace and rebase it onto main. Optionally, have a worker push the version: 1 fix to Address agent git history as workspace artifacts vito/agents#27.

  2. Worker tooling:

    • engine-dev: add TestProfile next to Test. It starts the test engine with _DAGGER_WCPROF=1, runs go test, fetches http://daggerengine:6060/debug/wcprof/dump, and returns the dump even if the tests fail, along with the exit status and the tail of the output.
    • engine-lab: add engineTest(..., wcprofCapture: String! = ""), which saves the dump as a named capture so wcprofReport/compare work on test runs. Update the SKILL.md.
    • Harness: port TestBenchChangesetApply from 0149c9f, without core/mcp_bench.go, without core/mcp.go changes, and without forced evaluation. Give each scenario its own client where practical, keep the recipe stats, and gate the test out of CI.
    • Baselines: capture "pr" and "main". For "main", temporarily restore core/mcp.go from main.
  3. Worker redesign: implement section 2, one commit per step, including the new tests:

    • a generator that copies the workspace with gitignore: true, run twice;
    • a workspace with a non-root cwd;
    • empty directories;
    • an error for paths under a mount.

    Then run TestLLM/, TestAgents/, TestAgentRestore/ and lint.

  4. Harvest: cherry-pick both workers' commits from dag://staff/members/head?member=<name>. Capture "new" and compare it against "pr" and "main". Measure findRejectFiles on a larger tree, and settle the cwd question.

  5. Publish: force-push with a lease on 592a560, unless the branch has moved since. Then rewrite the PR description.

From: https://dagger.cloud/dagger/traces/f307bb0061e85d1cff3f6b798016f9ef#bdcd2737767003df

This PR currently rebases a tool's changeset patch onto a sparse read of the workspace, checks the result against applying the raw changeset to that read, and falls back to patching onto Before. We benchmarked it and reworked the design in discussion. The plan below replaces most of what this PR does now. Nothing below is implemented on this branch yet.

1. Benchmark results

Setup: a value workspace of 5,000 files (22 MB) plus a 2.4 MB generated file. One tool call per run on a fresh workspace, with unique content per run so nothing hits the cache. Medians of 3 runs after a warmup, in ms per MCP.applyChangeset (normalization, no-op check and overlay, with each step forced to evaluate inside its span).

Scenario main this PR PR + native check (prototype)
edit: rewrite 1 file (Before = ws.directory("/")) 283 238 87
codegen: 1 container generator (21 modified, 5 added) 361 260 105
codegen ×4 merged via withChangesets 235 238 133
vendor: 1,000 new files (4 MB patch) 967 845 624
no-op command 53 54 57

Where the time goes:

  • Patch rendering (asPatch + blob): 55–85ms, or 460ms for vendor, in every variant. For small text changes the tool-result summary renders this same patch anyway (summarizePatch; the second render is a cache hit), so it isn't really normalization's cost.
  • main: the overlay step (Workspace.withChanges of a changeset whose two sides are full trees) costs 215–306ms. Directory.withChanges walks both trees (ComputePaths) and builds a structural diff.
  • This PR: the overlay drops to 5–12ms because both sides are sparse. The sparse read costs 11–17ms (45ms for vendor). But the check (base.withChanges(raw)) costs 100–180ms even for a one-file edit, because it needs a structural diff of the producer's full Before and After.
  • Prototype: Changeset.PlanPatchRebase compares Before with the workspace at the changed paths straight from mounts, in 1–4ms.
  • Recipe: both codegen scenarios on main keep the producer's container commands in the recorded recipe; this PR keeps only the workspace's own setup command.
  • Recipe size: the vendor recipe is about 12 MB in every variant, because blob contents are stored inline in the call.

2. Revised plan (supersedes the current diff)

  1. Host-backed workspaces: no normalization. The session isn't reproducible anyway, so normalizing only adds cost. Apply the raw changeset and keep only the cheap no-op check (PathCountExceeds(0)), so a successful no-op command still doesn't advance state. This drops the sparse host reads and every host special case.
  2. Non-host workspaces: everything is already in the engine. No sparse reads, no extra layer just to select a subset of files.
  3. Pure recipes: unwrap only. If Before/After contain only workspace reads and pure Directory/File operations (vito/editor's edit/mv/cp, withReplaced, withNewFile, …), record After.changes(from: Before). That's one call, just to drop the tool call (editor.edit(…)) from the recipe.
    • Decide this with a structural walk of the recipe, no evaluation. dagql/recipe_classification.go already does this kind of walk for non-replayable fields.
    • Use an allowlist: anything unrecognized (withExec, services, module functions, git/http fetches) takes the next path.
  4. Expensive recipes (generators, commands): workspace + patch.
    • Render the patch against the workspace, not Before. A small core helper copies only the changed paths out of the in-engine workspace root and out of After, then runs git diff --binary (same approach as the staging in computeChangesetPathsDelta). Because the workspace's own content is the patch's starting point, applying it reproduces the raw changeset by construction, including overwrites where Before lacked the file. Example: a generator that copies the workspace with gitignore: true, run twice; a patch from Before would say "create file" and git apply hard-errors because the file exists.
    • Apply it with a new internal Workspace.__withPatch(patch: File!) that runs git apply directly on the workspace root. No changeset is built and no trees are compared. Empty-directory adds and removals go through the existing Workspace.withNewDirectory/withoutDirectory, or as arguments to the op. The recipe is then the prior workspace plus the blob.
    • No check and no fallbacks. Error only when a path is under a mount, as withChanges does today.
    • Show the model this patch in the tool result. It's what actually changed in its workspace, and it replaces the raw asPatch render, so there's no second render.
  5. Remove from this PR: the sparse read, the target step, the extra-diff check, the Before fallback, and the PlanPatchRebase prototype.

Open questions and risks:

  • git apply on the full root: with conflict markers enabled, applyGitPatch runs findRejectFiles, which walks the whole tree. It took 8–57ms on main's full Before. Measure it on a larger tree.
  • Pure tools whose Before is the full root (e.g. ws.directory("/")) still pay the full-tree withChanges cost: about 215ms in the "edit" scenario on main. That's an engine issue: diffing a child snapshot against its parent shouldn't need a full walk. Fix it separately; don't route pure edits through the patch path.
  • Recipe size from inline blob contents (12 MB for the vendor patch). Separate from this PR.
  • cwd: vito/editor builds Before from source.directory("."), which is relative to the workspace cwd, while Workspace.withChanges applies at the workspace root. Check workspaces with a non-root cwd.
  • Metadata: permission bits beyond the executable bit, and modes of new non-empty directories, don't survive a patch. Accepted as fine.

3. Next session, step 1: a wcprof feedback loop for tests and benchmarks

Today: engine-lab's wcprofEnable/wcprofCapture/wcprofReport only work against the engine started with start. engineTest (engine-dev's test, .dagger/modules/engine-dev/test.go, testContainer) starts its own engine. That engine exposes --debugaddr 0.0.0.0:6060, but nothing captures from it. Integration tests and benchmarks therefore can't feed wcprof, and the numbers above come from temporary OTel spans with forced evaluation, which distorts how lazy work is attributed.

Suggested shape:

  • engine-dev: an opt-in to record wcprof on the test engine. Set _DAGGER_WCPROF=1 on the engine container, or POST on to /debug/wcprof/enabled before the tests. After go test, fetch http://<engine>:6060/debug/wcprof/dump from the test container and return it as a File, e.g. a testProfile(...) File next to test.
  • engine-lab: something like engineTest(..., wcprofCapture: String = "") that adds the dump to wcprofCaptureNames/wcprofDumps under that name. wcprofReport (including view: "compare") then works on test runs, and captures survive across source edits, so a single session can capture main, this PR and the new design.
  • Mark the code phases with wcprof ops (wcprof.BeginOp) instead of the bench.* OTel spans: applyChangeset, normalize, patch render, __withPatch, overlay. They cost nothing when profiling is off, so they can stay in the tree. A kind for code phases probably needs adding; the existing kinds are call/lazy/exec/session_phase/io/internal.
  • Run benchmarks alone, since the recording is engine-wide; use the client filter or the phase ops to scope reports.

4. Reproducing the current benchmark

Each branch carries the same harness: core/mcp_bench.go (benchStep/benchEval helpers) and core/integration/llm_changeset_bench_test.go (TestLLM/TestBenchChangesetApply).

Variant Branch
main vito/normalize-bench-main
this PR vito/normalize-onto-workspace-bench at 0149c9f
PR + native check the same branch at its head, 25c3d9e (adds core/changeset_rebase.go)
dagger api call engine-dev test --pkg ./core/integration \
  --run TestLLM/TestBenchChangesetApply --test-verbose

Then grep the test output for BENCH-MEDIAN (per-step medians) and BENCH scenario=… rep=3 (also prints recipe_bytes, withExec and withPatchFile counts). The full run takes about a minute after the first image pulls. Treat run-to-run noise as roughly ±10%. The withExec=1 in every recipe is the workspace's own setup command.

vito added 10 commits October 2, 2026 17:29
Run engine tests against a test engine that records wcprof from startup
(_DAGGER_WCPROF=1), then fetch its dump from the debug endpoint. The dump is
returned with the exit code and the output tail even when the tests fail, so
integration tests and benchmarks can feed wcprof reports instead of ad-hoc
OTel spans. Profiled runs set _DAGGER_BENCH=1 to opt benchmark tests in.

Signed-off-by: Alex Suraci <[email protected]>
engineTest(wcprofCapture: "<name>") runs engine-dev's testProfile and keeps
the test engine's dump as a named capture, so wcprofReport (compare
included) works on integration tests and benchmarks, not only on the
start engine. The result reports the test verdict, output tail and capture
summary; a failing run still keeps its capture.

Signed-off-by: Alex Suraci <[email protected]>
TestLLM/TestBenchChangesetApply makes one tool call per run on a fresh
5,000-file value workspace for five changeset shapes (1-file edit, codegen,
4 merged generators, 1,000-file vendor, no-op), each run in its own client,
ending with one read of the resulting workspace. It forces nothing and adds
no spans: run it under engine-dev testProfile and report from wcprof at the
tool's call op. Recipe stats are logged on the last rep. Skipped unless
_DAGGER_BENCH is set, which keeps it out of CI.

Signed-off-by: Alex Suraci <[email protected]>
A host-backed workspace reads the client's checkout, so a conversation built
on one cannot be reproduced from its recipe anyway. Normalizing its changesets
only cost a patch render, a sparse host read and a check per tool call. Apply
the raw changeset; the no-op check still keeps a successful command that
changed nothing from advancing the workspace.

Signed-off-by: Alex Suraci <[email protected]>
An agent's expensive changesets (a generator's, a command's) are to be
recorded as the prior workspace plus a patch. Changeset.RenderPatchOnto renders
that patch against the tree it will apply to rather than the changeset's
Before: it stages only the changed paths out of both trees and runs `git diff
--binary` between them, so applying it reproduces what Directory.withChanges
would by construction, even where Before and the tree differ (e.g. a file a
gitignore-filtered generator re-adds). It also lists the empty directories a
patch cannot express, and those `git apply` prunes.

Workspace.__withPatch applies such a patch at the workspace root, with those
directories, without building a changeset to compare trees. It is internal, so
it stays out of the SDKs and the published schema.

Signed-off-by: Alex Suraci <[email protected]>
A tool's changeset was normalized onto a sparse read of the workspace, checked
against the raw changeset and rebased onto Before when the two disagreed. The
check alone needed a structural diff of the producer's full trees, and the
fallback kept Before's recipe, e.g. a generator's container.

On an in-engine workspace, render the patch against the workspace root
instead (Changeset.RenderPatchOnto) and apply it with Workspace.__withPatch.
Starting from the workspace's own content, the patch reproduces the changeset
by construction, so there is nothing to check or fall back to, and the
recorded overlay is the prior workspace plus the patch. It is also the patch
the model is shown, so it is not rendered twice. A changeset touching a mount
is refused, as Workspace.withChanges refuses it; only a patch too large to
embed is applied raw.

Signed-off-by: Alex Suraci <[email protected]>
A file edit's changeset is built from reads of the workspace and pure file
operations (withFile, withReplaced, withoutFile, ...). Replaying those is
cheap, so rendering a patch for one only costs time; what the recorded overlay
must not keep is the tool call that returned it.

Decide this structurally, without evaluating anything: walk the recipes of
Before and After against an allowlist of pure Directory, File and Changeset
fields, stopping at the workspace's own state (the workspace, its root tree,
its mounts). A pure changeset is recorded as After.changes(from: Before);
anything else (an exec, a fetch, a module function) still takes the patch path.

Signed-off-by: Alex Suraci <[email protected]>
vito/editor's tools read the workspace at ".", which resolves from the
workspace cwd, so their changesets are measured from it. Workspace.withChanges
and the workspace patch apply at the root, so on a workspace whose cwd is not
its root, an edit of sub/notes.txt landed on notes.txt at the root, on every
path: host-backed, pure and patched.

Place a changeset at the directory its Before reads the workspace from, found
structurally by following Before's recipe through subdirectory reads and
layout-preserving Directory operations down to the workspace, its root tree,
or a host read of it. A changeset that cannot be placed, e.g. a tree read back
out of a container, still applies at the root, which is how generators measure
theirs.

Signed-off-by: Alex Suraci <[email protected]>
gocyclo flagged it; the Directory and host-read cases read better as helpers
of their own anyway.

Signed-off-by: Alex Suraci <[email protected]>
RenderPatchOnto listed every directory a changeset adds, so a vendored tree
of 1,000 files in 20 new directories recorded 20 directories on
Workspace.__withPatch, and every read of the workspace replayed a
withNewDirectory per directory. git apply already creates a directory along
with the files in it; only list an added directory that no written file lies
beneath. The mode of a new non-empty directory is lost, which was already
accepted for patches.

Signed-off-by: Alex Suraci <[email protected]>
@vito
vito force-pushed the vito/normalize-onto-workspace branch from 592a560 to 6d46383 Compare October 2, 2026 18:50
@vito vito changed the title mcp: rebase normalized changesets onto the workspace mcp: record tool changesets as workspace patches Oct 2, 2026
A file edit's changeset was recorded as After.changes(from: Before) to drop
the tool call while keeping a recipe that is cheap to replay. But with Before
= ws.directory("/") that recipe makes the first read of the workspace diff
two full trees, about 150ms per edit on a 5,000-file workspace, while the
patch is rendered for the tool result anyway. Apply every in-engine changeset
as a workspace patch, and drop the structural purity check that chose the
other path; placing a cwd-measured changeset (workspaceTreePrefix) stays.

Signed-off-by: Alex Suraci <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant