Skip to content

perf(fix): cut install copies and the agent's test check, count commands per phase (UPT-103) - #232

Merged
lucasbraum merged 4 commits into
mainfrom
upt-103-wall-time
Oct 18, 2026
Merged

lucasbraum merged 4 commits into
mainfrom
upt-103-wall-time

Conversation

@lucasbraum

Copy link
Copy Markdown
Collaborator

Closes UPT-103.

What and why

After UPT-100/101/104/106, Uptide's cost is at or below Claude Code's on the pilot repositories (#169). Wall time is the gap. This PR measures where the time goes and makes two cuts. Neither one changes the gate after the agent's edits or the final verification.

1. Per-phase commands in the fix report (d0a77c3). Each phases[] entry now has runs: the repository's commands the phase ran (install, generate, build, types, tests, lint, format), each with a count and its summed wall time. An agent-led run's clean install (#220) gets its own clean-install phase, so the phases add up to the run. The tables below come from this field.

What the baseline showed. These are the costs I could act on, across the three repos:

  • node_modules copies around the install. isolatedUpgrade installs in a temporary worktree. It then cpSyncs the new node_modules into the clone, file by file and not even copy-on-write, and deletes the worktree. In a $0 replay with a CPU profile (invalid API key, no model spend), that copy was 7.5 s on enki and 5.1 s on dms-saas. npm itself took only about 10.5 s of enki's 31 s install.
  • Test runs in the agent phase on dms-saas: 2 runs, 42.6 s. It is the largest line item there apart from the model. The agent's run_tests asks for the task's related tests. But dms-saas's test script is pnpm run build && … && pnpm run test:unit, so related scoping found no bare runner and ran the whole script, build included.

The model's own time is the largest item everywhere (about 31 s, 71 s and 50 s), but I found no deterministic cut for it (see "What did not work").

2. The agent's own test check runs its runner's related tests (26574a9). This is a new agentCheck test option, used only by the agent's own run_tests. When a test script ends by running a script that is just the runner, the check runs that runner's related tests. Everything else is unchanged:

  • The gate after the agent's edits still runs its own related plan (the whole script on dms-saas).
  • The final verification runs the full suite.
  • A check result never stands in for a gate result: separate cache entries.
  • Where the narrowing changes no command (calmcp, enki: the script already is the runner), the check stays the gate's own run, and the gate reuses its result on the same tree, as before.

3. The installed tree is moved, not copied (049023c). isolatedUpgrade renames the new node_modules out of the worktree, which is removed anyway. The tree is the same, links into the worktree are relocated as before, and it falls back to a copy across filesystems (EXDEV).

Results

The #169 harness (bench/run.sh), Uptide arm only, claude-sonnet-5-5 at medium effort, default $1 limit, once per repository. Before is origin/main plus the counting commit d0a77c3 (no behavior change). After is 049023c; the last commit only adds a test. bench/ is untracked in both checkouts, which is why both reports say uptideDirty. Claude Code's numbers are from the #169 rerun (same harness and model).

repo Claude Code (#169) Uptide before Uptide after
calmcp, zod 3.25.76→4.6.5 $0.11, 47 s $0.094, 60 s $0.085, 53 s
dms-saas, stripe 18.5.0→22.6.2 $0.31, 149 s $0.265, 224 s $0.279, 210 s
enki, react 18.3.1→19.2.0 $0.11, 67 s (its diff fails npm ci) $0.176, 147 s $0.161, 136 s

Same checks, same verdicts, same diff shape. On every repository, before and after match on all of these:

  • verification.passed (true)
  • the site outcomes: calmcp 1 mechanical + 5 agent, dms-saas 2 + 10, enki 3 + 8 + 1 manual
  • the changed file list: 4, 11 and 12 files, identical
  • test, lint and build statuses, and no new type errors
  • feedback rounds: 0, 0 and 1
  • enki's clean install, which passed

One run each: the model's own time moves by ±5 s between runs, which is the size of calmcp's whole change.

calmcp phases
line item before after
wall (harness) 60 s 53 s
cost $0.094 $0.085
outside fix(): preflight, clone with node_modules copy, delivery, clone removal 3 s 3 s
analyze (check; workspace prep builds) 5.7 s 5.5 s
baseline phase 3.3 s 3.2 s
↳ baseline type-check 1× 0.8 s 1× 0.8 s
↳ baseline tests 1× 1.8 s 1× 1.8 s
install phase (bump install, consumed rebuild, target types) 6.1 s 4 s
↳ upgrade install 1× 4.8 s 1× 2.8 s
agent phase 38.2 s ($0.094) 33.5 s ($0.085)
↳ checks inside it (types, lint, format) 0.9 s 1 s
↳ tests inside it (agent check + gate) 2× 1.8 s 2× 1.9 s
↳ model and tool turns (the rest) 35.5 s 30.6 s
feedback rounds 0: – 0: –
final verify (build, format, full tests, types, lint) 2.6 s 2.7 s
clean install (agent-led, #220) – –
installs, all phases 1× 4.8 s 1× 2.8 s
tests, all phases 4× 5.1 s 4× 5.3 s
type-checks, all phases 8× 2.7 s 8× 2.7 s
builds (buildRuns) 2× 0.7 s 2× 0.6 s
generators 2× 0 s 2× 0 s
publish not run (no --pr in the harness) not run
agent turns 19 17
dms-saas phases
line item before after
wall (harness) 224 s 210 s
cost $0.265 $0.279
outside fix(): preflight, clone with node_modules copy, delivery, clone removal 9 s 10 s
analyze (check; workspace prep builds) 23 s 22.7 s
baseline phase 25.8 s 25.5 s
↳ baseline type-check 1× 3.6 s 1× 3.7 s
↳ baseline tests 1× 21.6 s 1× 21.5 s
install phase (bump install, consumed rebuild, target types) 22 s 14.7 s
↳ upgrade install 1× 12.4 s 1× 5.3 s
agent phase 112.4 s ($0.265) 107.3 s ($0.279)
↳ checks inside it (types, lint, format) 1.5 s 1.3 s
↳ tests inside it (agent check + gate) 2× 42.6 s 2× 35.3 s
↳ model and tool turns (the rest) 68.4 s 70.7 s
feedback rounds 0: – 0: –
final verify (build, format, full tests, types, lint) 31.1 s 29.1 s
clean install (agent-led, #220) – –
installs, all phases 1× 12.4 s 1× 5.3 s
tests, all phases 4× 85.6 s 4× 76.6 s
type-checks, all phases 8× 11.4 s 8× 11.2 s
builds (buildRuns) 3× 13.5 s 3× 13.1 s
generators 2× 0 s 2× 0 s
publish not run (no --pr in the harness) not run
agent turns 34 34
enki phases
line item before after
wall (harness) 147 s 136 s
cost $0.176 $0.161
outside fix(): preflight, clone with node_modules copy, delivery, clone removal 11 s 11 s
analyze (check; workspace prep builds) 12.8 s 12.7 s
baseline phase 7 s 7 s
↳ baseline type-check 1× 2.8 s 1× 2.6 s
↳ baseline tests 1× 3.9 s 1× 4.1 s
install phase (bump install, consumed rebuild, target types) 32.9 s 23 s
↳ upgrade install 1× 30.9 s 1× 21.1 s
agent phase 26.6 s ($0.085) 27.4 s ($0.085)
↳ checks inside it (types, lint, format) 1.1 s 1 s
↳ tests inside it (agent check + gate) 1× 1.1 s 1× 1.2 s
↳ model and tool turns (the rest) 24.4 s 25.2 s
feedback rounds 1: 44 s ($0.092) 1: 40.8 s ($0.076)
final verify (build, format, full tests, types, lint) 5.7 s 5.8 s
clean install (agent-led, #220) 6 s 7.7 s
installs, all phases 2× 36.9 s 2× 28.7 s
tests, all phases 5× 15.5 s 5× 16.3 s
type-checks, all phases 11× 15.3 s 11× 15 s
builds (buildRuns) 0× 0 s 0× 0 s
generators 2× 0 s 2× 0 s
publish not run (no --pr in the harness) not run
agent turns 17 + 19 17 + 13

Where the cuts show up:

  • upgrade install: calmcp 4.8 → 2.8 s, dms-saas 12.4 → 5.3 s, enki 30.9 → 21.1 s.
  • agent-phase tests on dms-saas: 42.6 → 35.3 s. The narrowed check took about 14 s instead of 21 s.

Tests on calmcp and enki: same counts and times as before.

What is left versus Claude Code

  • calmcp, 53 s vs 47 s. About 31 s is the model: 17 turns, one tool call each. The other ~19 s is work Claude Code does not do in this form: analysis 5.5 s, install 4 s, baseline 3.2 s, final verify 2.7 s, clone and delivery 3 s.
  • dms-saas, 210 s vs 149 s. Tests are the gap: four runs of the test script family take 76.6 s (baseline 21.5, agent check ≈14, gate ≈21, final ≈20).
    • Every full run builds before running vitest. vitest alone is about 17 s.
    • Related tests barely help here: every route imports the Stripe client, so "related" is most of the suite.
    • The gate and the final run are the verification this PR must not trade away.
    • The rest: analysis 22.7 s (the stripe 18→22 type diff and call-site compile) and the model, about 71 s for 34 turns.
  • enki, 136 s vs 67 s. Claude Code's diff does not install (npm ci ERESOLVE), so it skipped what costs Uptide here: the upgrade install (21 s), the clean install (7.7 s) and the agent-led feedback round (41 s). The round is forced by the peer-blocker and root-pin fallback.

What did not work (reverted, not in this PR)

An intermediate build also (a) asked the agent to batch reads and edits as parallel tool calls in one message, and (b) narrowed the agent's check in feedback rounds. It ran on all three repos ("after (reverted)" runs 8e0f068f, d48a70c0, 72b2bc57):

  • (a) changed nothing: dms-saas made the same 34 turns with the same tool counts.
  • (b) made enki slower, 147 → 163 s. vitest related over the feedback task's files cost more than the full suite, and splitting the check from the gate lost the gate's cache hit.

Both are reverted. The final change keeps the check on the gate's run wherever narrowing changes nothing.

How it was verified

Fixture tests:

  • generic.test.ts, "narrows only the agent's own check, …" (3 cases) — the final verify still runs the full gate. It covers a chained script, a plain runner, and an agent-led run. It records every test call and its options and checks:
  • fix.test.ts, "scopes only the agent's own check to the runner a test script chain ends with": agentCheck gives vitest related …, while related alone and fullSuite still give the whole pnpm run build && pnpm run test:unit.
  • sdk-agent.test.ts: the agent's run_tests uses checkTests, repeats reuse its own result, and the gate on the same tree still runs tests.
  • isolated-install.test.ts: the clone's node_modules holds the same inode the install wrote (a move, not a copy). Relative links are kept and absolute links into the worktree are relocated.
  • generic.test.ts phases test: the baseline phase's runs counts its type-check and test run.

pnpm lint && pnpm typecheck && pnpm test pass (core 2,292 tests, cli 516). The bench runs needed no fork PRs: nothing was pushed anywhere but this branch.

Checklist

  • Tests for the change
  • pnpm lint && pnpm typecheck && pnpm test pass
  • README and docs updated if behavior changed (no CLI flag or documented output changed; the report field is described in types.ts)
  • Changeset added (agent-phase-wall-time.md)
  • No new dependency
  • No private code, names or paths in fixtures, tests or docs
  • The rules in CONTRIBUTING.md ("Rules that do not bend") still hold

🤖 Generated with Claude Code

lucasbraum and others added 4 commits October 17, 2026 23:48
…t (UPT-103)

Each phase records the installs, generators, builds, type-checks, tests,
lint and format runs it made, with their summed wall time. An agent-led
run's clean install gets its own phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
… (UPT-103)

The agent's run_tests ran the full test script, build included, when the
script chains a build before the runner (dms-saas: about 21 s a call),
although it asks for the task's related tests. It now runs the related
tests of the runner the chain ends with. The gate after the agent's edits
and the final verification are unchanged, and the check never stands in
for the gate's result. Where the narrowing changes nothing, the check is
the gate's run, and the gate reuses its result on the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
…e (UPT-103)

isolatedUpgrade copied the new node_modules from its temporary worktree
into the clone, file by file, then removed the worktree. A rename gives
the same tree (enki: 7.5 s of copying, dms-saas: 5 s). Across filesystems
it still copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
…rowed check (UPT-103)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
@lucasbraum
lucasbraum merged commit e4a2885 into main Oct 18, 2026
12 checks passed
@lucasbraum
lucasbraum deleted the upt-103-wall-time branch October 18, 2026 16:40
@uptide-release uptide-release Bot mentioned this pull request Oct 18, 2026
lucasbraum pushed a commit that referenced this pull request Oct 18, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## uptide@0.15.1

### Patch Changes

- [#220](#220)
[`cf86d65`](cf86d65)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - With an assisting
agent, a root pin Uptide cannot update safely (a nested override such as
`overrides > pkg > peerDependencies > react`) no longer stops `fix` with
exit 2: the run continues as an agent-led draft that leaves the pin as
written and names it, with its exact path and value, at the top of the
PR body. The agent-led peer reason now names each blocker's first
release that accepts the target, or says none does. An agent-led draft
must pass the repository's clean install (`npm ci` or the manager's
frozen install) before delivery; if it does not, the PR says so at the
top. Without an agent, with `--no-llm`, or on a safety refusal, `fix`
still exits 2.

- [#232](#232)
[`e4a2885`](e4a2885)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide fix`
spends less wall time on test runs and dependency copies:
  
- The agent's own `run_tests` check runs only its runner's related tests
when the `test` script builds first and then runs a script that is just
the runner (`pnpm run build && pnpm run test:unit`). The gate after the
agent's edits and the final verification run exactly what they ran
before.
- The upgrade's install moves the installed `node_modules` out of its
temporary worktree instead of copying it.
- Each phase of the fix report lists the repository's commands it ran
(installs, generators, builds, type-checks, tests, lint, format) with
their count and summed wall time. An agent-led run's clean install has
its own phase.

- [#223](#223)
[`2dc2162`](2dc2162)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `fix` no longer
stops with "baseline tests modified the working tree" when the test run
only writes new untracked files, such as a junit `test-report.xml` that
is not gitignored. Those files are recorded as test artifacts
(`testArtifacts` in the report), kept out of every commit, the agent's
diff and the pending-changes check, and removed before each later test
run, `uptide verify` included. A change to a tracked file during
baseline tests still refuses the run.

- [#215](#215)
[`abddcb3`](abddcb3)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `check` no longer
lists possible impact (type changes nothing confirmed as breaking) on
its first screen: each package says it in one line, `+N possible impact
in M files, not counted as breaking`, and `--details` and `--json` still
have every site. A package whose types were partly verified no longer
repeats its coverage line in the heading, and a monorepo's unbuilt
workspace chains are one count on that line.

- [#213](#213)
[`2de3a18`](2de3a18)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - A companion pack
that moves with the lead's fix (react beside next) now gets its own scan
context and inventory, and the PR body carries a section with its review
notes.

- [#212](#212)
[`733cfbe`](733cfbe)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `pack test
--write` records how many breaking sites a finding landed on at any
severity and as breaking, out of all breaking sites it scored, and the
pack tables show both ("Found, any severity", "Found as breaking") in
place of the single recall column. Packs recorded before it show `—`.

- [#224](#224)
[`09f68df`](09f68df)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `fix` on npm no
longer stops when the lock-only install hits ERESOLVE from the old
lockfile. It retries without the lockfile entries of the moved packages,
their companions and the packages whose peers they satisfy, and only
then with the whole lockfile re-resolved; the report says which step
worked, and the lockfile check reports the churn as usual. It never uses
`--legacy-peer-deps` or `--force`. When every step fails, `fix` ends
blocked, naming the conflicting packages from npm's ERESOLVE report.
`fix --json` now prints JSON on every exit, a failure included; when the
run already has its report (a PR body over GitHub's size limit), that
report comes with the error.

- [#221](#221)
[`ff8de8e`](ff8de8e)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `fix` no longer
rejects an agent patch for test files the parent commit already failed,
whichever test scope ran them: when the test script builds first and the
build failed on the error being fixed, the start had no test results to
compare with, so a correct patch was reverted every round. The patch's
tests also run with the pack's follow-ups in place (a test asserting the
old Stripe API version), which `fix` still applies and reports after the
agent. When a PR body cannot fit, the stored report.json the error
points to is now written first. `check` and `fix` now also analyze files
that reach a package's wrapper through a barrel (`import {
getStripeClient } from '../stripe'` with `export * from './client'`).
Edited files are formatted with oxfmt and dprint as well as biome and
prettier, from the workspace that holds them, with its own
configuration, else the root's. The stripe pack adds `request-options`:
request options passed in the params slot (`subscriptions.cancel(id, {
idempotencyKey })`), which SDK 22 rejects, move to the third argument.
It runs assisted until a ground-truth repository exercises it.

- [#226](#226)
[`448c3cb`](448c3cb)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide fix`
judges consumed workspace builds on the final head, not on the tree
right after the bump. A build that failed after the bump and passes once
the edits land no longer starts a feedback round, adds an "Agent-led
draft" reason or shows "Build failed" in the PR body. The builds run
again only when the tree changed since their last result, and not at all
when the verification build already built those workspaces. The fix
report's `buildRuns` gives the number of builds the run executed and
their wall time.

- [#218](#218)
[`13e343b`](13e343b)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `check` folds a
compile error into the breaking site it comes from when the compiler
traces its value there (an `errors.map(e => e.message)` under a
`result.error.errors` that zod 4 no longer types, and the narrowing that
depended on it). The rule's line shows `+N downstream errors`,
`--details` lists them under their site, and `--json` carries them as
that finding's `downstream`. They are no longer separate breaking sites.
`fix` still verifies against every error.

- [#214](#214)
[`195ae10`](195ae10)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - A rewrite rule
below 80% precision over at least 5 scored ground-truth sites now runs
as assisted in `fix` (react-router `dom-imports`); smaller samples (next
`middleware-proxy`) stay mechanical and are flagged in the compatibility
table. Pack status is unchanged.

- [#218](#218)
[`13e343b`](13e343b)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - pack test runs a
ground-truth repository's code generators (prisma generate, local
graphql-codegen) after its install, as fix does. It fetches the Prisma
engines the locked version needs itself, so generators stay off the
network. A repository whose generator still fails is incomplete and
fails the pack, unless its ground truth declares `withoutGenerated`

- [#227](#227)
[`ae175a9`](ae175a9)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - A migration whose
evidence is larger than GitHub's PR description limit no longer stops
`fix` with "Required migration evidence exceeds the PR body budget" and
loses the run. Long required lists get a compact form when they do not
fit. Lockfile churn shows counts per kind of change and the first
packages. Manual sites are grouped by rule, with counts and the first
locations. Remaining type errors show the first entries. Every full list
stays in report.json and, in the Uptide Action, in the job summary,
which the description links to. If even the compact form does not fit,
the description is a short summary that says so at the top: the verdict,
the checks, the counts and where the full report is. The branch and
report.json are always kept.
  
An agent's decisions under "Decisions for you" name the files the agent
changed, five at most, and count the rest, instead of every file in the
task's scope. A verification-feedback task scoped to the whole
repository made each of its decisions a list of hundreds of files.

- [#219](#219)
[`12dc612`](12dc612)
Thanks [@lucasbraum](https://github.com/lucasbraum)! - `fix zod`
rewrites `z.string().uuid()` to `z.guid()` by rule without
`--include-deprecated`: zod 4's `uuid()` also checks the RFC version and
variant, so ids zod 3 accepted stop validating, and `check` now counts
the site as breaking. `fix` no longer reports false pre-existing type
errors on a TypeScript 7 repository whose `lib` names a library newer
than its `target` (`lib: ["ES2023"]` under `ES2022`).

Co-authored-by: uptide-release[bot] <338861287+uptide-release[bot]@users.noreply.github.com>
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