Conversation
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]>
|
🤖 Handoff: benchmark results, revised plan, and next steps
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 1. Benchmark resultsSetup: 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
Where the time goes:
2. Revised plan (supersedes the current diff)
Open questions and risks:
3. Next session, step 1: a wcprof feedback loop for tests and benchmarksToday: engine-lab's Suggested shape:
4. Reproducing the current benchmarkEach branch carries the same harness:
Then grep the test output for |
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]>
592a560 to
6d46383
Compare
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]>
Problem
MCP.applyChangeset→normalizeChangesetToPatch(#14441) rewrites a tool's changeset asbefore.withPatchFile(blob(patch), onConflict: LEAVE_CONFLICT_MARKERS)…changes(from: before), wherebeforeis the changeset's ownBefore, and records it withWorkspace.withChanges.Beforeis whatever the producer chose, and it can be expensive. In trace9d6121d1e6278a650780535d1e204b10,editor_generatemerged 7 SDK generator changesets, so the normalized patch was applied on top ofdirectory.withDirectory("", source: <before_1>)…withDirectory("", source: <before_7>). For the Python SDK generator (python-client-dev),Beforeiscontainer…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:editor.edit(…)or a generator's module function;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:
TestChangesetToolPatchesWorkspacerestores the LLM from its recipe in a fresh session. A cache-volume sentinel fails if the generator's command runs again.withExec. That covers generators, commands, pure edits,mvand 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):
mainwithChangesetsThe benchmark's generators are a single
alpineexec each. In the motivating trace, each generator'sBeforecarried 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.applyChangesetnow records a tool's changeset according to where the workspace lives and what the changeset's recipe contains.ClientLocalBase): the raw changeset is applied withWorkspace.withChanges.PathCountExceeds(0)) stays, so a successful command that changed nothing still doesn't advance state.Before.Changeset.RenderPatchOnto(core/changeset_onto.go) stages only the changed paths, from the workspace root and fromAfter, and runsgit diff --binarybetween them.Workspace.__withPatchapplies the patch with a plaingit applyon the workspace root. Nothing else is built or compared.Beforelacked, such as a gitignored generator output on its second run. So there's no check and no fallback.directories/removedDirectories. A new non-empty directory's mode isn't kept.After.changes(from: Before), chosen by a structural walk of the recipe, to drop just the tool call. WithBefore = 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:
withChanges.MaxFileContentsSizefalls back to the raw changeset.cwd: vito/editor measures its changesets from
source.directory("."), which is relative to the workspace cwd, butWorkspace.withChangesapplies 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 ofsub/notes.txtrewrote the rootnotes.txt.applyChangesetnow tracesBefore'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, theBeforefallback,rebasePatchOntoBeforeandreconcileDirsAfterPatch.Tooling
engine-dev testProfile: runs liketest, 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.engineTest(wcprofCapture: "<name>"): keeps that dump as a named capture, sowcprofReport(includingcompare) works on test runs.TestLLM/TestBenchChangesetApply: skipped unless_DAGGER_BENCHis set.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.Container.directory). Measured with wcprof.Directory.entries, which forces the overlay.Columns, apply / read:
mainAfter.changes(from: Before).mainwithChangesetsApply 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.withChangeson large trees: it still diffs the full trees even when the changeset's paths are already known. Host-backed workspaces and otherwithChangescallers 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 tocore/changeset_onto_test.gofor a changed helper signature.git applyof the 4 MB patch (~175ms). Its recipe is about 12 MB because blob contents are stored inline in the call, which is also separate.directory("/")plus the cwd, so the engine wouldn't have to infer the directory.Tests
TestRenderPatchOntoruns a realgit apply. It covers overwrites, a staleBefore, empty, removed and pruned directories, a cwd prefix, symlink escapes and the size limit.TestWorkspaceTreePrefix.TestChangesetToolRegeneratesIgnoredFile: a generator that copies the workspace withgitignore: true, run twice.TestChangesetToolPatchesDirectories,TestChangesetToolRefusesMountsandTestChangesetToolAppliesAtCwd(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 replacesTestChangesetToolRebasesOntoWorkspace.TestChangesetToolKeepsEmptyDirectories,TestLargeChangesetToolSkipsPatchWorkandTestChangesetToolPrunesExecution.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.