Skip to content

fix: retry npm ERESOLVE with the smallest re-resolve, blocked report otherwise (UPT-107) - #224

Merged
lucasbraum merged 2 commits into
mainfrom
lucasbraum/upt-107-eresolve-staged-retry
Oct 18, 2026
Merged

lucasbraum merged 2 commits into
mainfrom
lucasbraum/upt-107-eresolve-staged-retry

Conversation

@lucasbraum

Copy link
Copy Markdown
Collaborator

Closes UPT-107. Refs UPT-102 (not closed: enki delivered no draft and did not end blocked; see below).

What and why

On enki (UpgradeBench, #169), npm install --package-lock-only failed with ERESOLVE from the parent's lockfile, although the same manifest resolves fresh. fix stopped with exit 2, and fix --json printed nothing.

Staged retry (managers/upgrade.ts). For npm only, and only on ERESOLVE, the lock-only install is retried from smaller lockfiles, smallest re-resolve first:

  1. moved entries: the old lockfile without the entries of the moved packages (lead and companions), every locked package that declares one of them as a peer, and everything installed below those. Links are kept.
  2. whole lockfile: every installed entry dropped, so npm resolves everything fresh.

--legacy-peer-deps and --force are never used (the tests assert it on every install call).

  • The step that worked is recorded as lockfile.eresolveRetry ({ step, dropped, conflicts }). It shows in the text summary and the PR body ("Lockfile re-resolved").
  • The parent-commit comparison (parentLock) starts from the same reduced lockfile. So the lock guard reports churn as usual: what the fresh resolve moves at the parent too shows as parentChurn, not as the upgrade's.
  • If every step fails, fix ends with INSTALL_BLOCKED (InstallBlockedError). Its message lists the package lines from npm's ERESOLVE report (While resolving / Found / Could not resolve dependency / Conflicting peer dependency / peer … from …). It crosses the worker boundary intact.

fix --json on every exit. Exit 0 and 1 already printed the report. On a failure (exit 2, and exit 1 from a CliError), fix --json now prints { error: { code, message } }. A blocked install adds blocked: { attempts, conflicts }. This is scoped to fix: pr already writes its own error JSON and then rethrows.

Second commit, found by the rerun. The paid enki run got past ERESOLVE and ran the agent. It then stopped at "Required migration evidence exceeds the PR body budget". That refusal is thrown after report.json is written in the clone, and the clone is then removed. So the JSON had the error but no report: no cost, no lockfile. The refusal is now a ReportedError (PR_BODY_TOO_LARGE) carrying the report, and fix --json prints the report with error on top. Whether the run should stop there at all is unchanged here (follow-up).

Tests

  • managers/eresolve.test.ts uses a stand-in npm: a small registry, keeps locked versions, and fails with npm's real ERESOLVE text (synthetic names, fixtures/lockfiles/eresolve/npm-11.txt) while a conflict holds.
    • moved entries: the retry starts without toggle, ui, ui-dom and keeps unrelated locked; toggle re-resolves to 1.1.0.
    • whole lockfile: used only when step 1 still conflicts; the fresh resolve's churn lands in parentChurn, and outOfScope is empty.
    • all fail: INSTALL_BLOCKED after exactly 3 attempts, with the conflict lines.
  • cli.test.ts: fix --json exit 2 prints JSON for a blocked install (with blocked), for any other failure, and for a ReportedError (with the report). The text output carries the same message.
  • format-fix.test.ts: the summary names the retry step.
  • pnpm lint, pnpm typecheck, pnpm test: green locally (core 2271, cli 515). pnpm public:check with the denylist: clean.

enki rerun (#169 harness, Uptide arm only, claude-sonnet-5-5, $1 cap)

Run a8ff50ca, from this branch's first commit (fd3037f).

Exit 2: Required migration evidence exceeds the PR body budget (after install, agent and verification)
--json printed { error } (before the second commit it carried no report)
Install step moved entries (see the $0 replay below)
Lockfile churn 0 out of scope; 85 parent churn; 5 moved subtrees
Clean install (#220) npm ci --ignore-scripts passed in the replay; the paid run's result was lost with its report
Checks install/typecheck/lint/test 0, but on the parent commit: no commits were delivered
Cost unknown, at most the $1 cap. The agent estimated $0.54 for 9 sites; the harness read $0 because no report came back
Wall time 193 s
Fork CI none: no commits, so no fork pull request

$0 replay. The same CLI and flags were run on a copy of the run's clone, with --keep-temp and an invalid API key so every agent call fails. It takes the same agent-led plan: react, react-dom 19.2.0, @types/react, @types/react-dom, @entur/react-component-toggle 1.0.0, react-leaflet 5.0.0, unsafe @reach/* overrides left alone. The lock-only install hit ERESOLVE (Found: @entur/react-component-toggle@0.0.3 / Conflicting peer dependency: react@19.2.0). Step 1 resolved it after dropping the entries of 82 react-peer packages; step 2 was not needed. It ended at exit 1 with a 23 KB PR body. In the paid run, the agent's edits pushed the required (never-trimmed) evidence past GitHub's 65,536-character limit.

UPT-102 stays open: enki now installs, but its draft is refused at the PR body.

🤖 Generated with Claude Code

lucasbraum and others added 2 commits October 17, 2026 18:57
…otherwise (UPT-107)

npm's lock-only install could ERESOLVE from the old lockfile although the
same manifest resolves fresh (enki: react 19 with
@entur/react-component-toggle), and fix stopped with exit 2 while
fix --json printed nothing.

On ERESOLVE, upgradeInstall now retries lock-only without the lockfile
entries of the moved packages, their companions and every locked package
that declares one of them as a peer; only then with the whole lockfile
re-resolved. The step that worked is recorded (lockfile.eresolveRetry,
shown in the summary and the PR body), the parent-commit comparison
starts from the same lockfile, and the lock guard reports churn as usual.
It never uses --legacy-peer-deps or --force. When every step fails, fix
ends with INSTALL_BLOCKED, naming the packages from npm's ERESOLVE report.
fix --json now prints JSON on every exit: { error, blocked? } on a failure.

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

The enki rerun got past ERESOLVE, ran the agent, then stopped at "Required
migration evidence exceeds the PR body budget". That refusal is thrown
after report.json is written in the clone, and the clone is removed, so
fix --json printed only the error: no cost, no lockfile, no install step.

The refusal is now a ReportedError (PR_BODY_TOO_LARGE) that carries the
report across the worker boundary; fix --json prints the report with the
error on top. Whether the run should stop there at all is left as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
@lucasbraum
lucasbraum merged commit 09f68df into main Oct 18, 2026
28 checks passed
@lucasbraum
lucasbraum deleted the lucasbraum/upt-107-eresolve-staged-retry branch October 18, 2026 02:30
@uptide-release uptide-release Bot mentioned this pull request Oct 18, 2026
lucasbraum added a commit that referenced this pull request Oct 18, 2026
)

Closes UPT-106. Closes UPT-102: enki now delivers an agent-led draft,
uptide-bench/entur--enki#5.

## Problem

`prBody()` threw "Required migration evidence exceeds the PR body
budget" when the rank-0 sections alone were over the budget. That threw
away the whole finished run. It happened on enki (a8ff50ca, after #224)
and on dms-saas (#221).

## Change

- **No section is exempt.** `fitPieces` returns `undefined` instead of
throwing. Required lists that can grow without bound get a compact form,
which is used only when the whole list does not fit. These lists still
go before anything droppable.
- Lockfile churn (outside the upgrade, and parent churn): counts per
kind of change (added, removed, version changed, re-resolved at the same
version), then the first 10 packages.
- Manual sites under "Worth a look": grouped by rule, with a count and
the first 5 locations each.
  - Remaining type errors: the first 10, then a count.
- **Full lists** stay in report.json. The Action's job summary now lists
every breaking site (it was capped at 50). Compact forms link to it
through a new `runUrl` field, set from
`GITHUB_SERVER_URL`/`GITHUB_REPOSITORY`/`GITHUB_RUN_ID` when the run is
in Actions.
- **Minimal body.** If the compact form still does not fit, the body is
a fixed-size summary: the verdict, the checks (types, tests, builds,
lint, clean install), the counts (sites by outcome, lockfile, files,
decisions), the branch and commit, and where the full report is. It
opens with a "Shortened description" notice.
- **The run is kept.** `fix()` and the isolated run's `finish()` write
report.json before the body, and nothing throws for size any more, so a
finished run always keeps its commits and report.json. `uptide pr` sends
the shortened body instead of refusing.
- **Decision lines** (second commit, found by the enki rerun). An agent
decision named every file in its task's scope. The verification-feedback
task was scoped to 317 files, so its 2 decisions came to 46 KB. A
decision now names the files the task changed, five at most, and counts
the rest.

## Fixtures

Written first; both failed on main with the throw.
- 300 manual sites (3 rules) and 100 + 100 churned lockfile entries. At
the 60 k budget every section is present: lockfile sections, risk table,
What changed, Worth a look, Decisions, Verification details, Run
details. Manual sites are compact and the lockfile lists are whole. At 8
k the lockfile lists are compact too: "100 entries: 25 added, 25
removed, 25 re-resolved at the same version, 25 version changed", with
the first 10 packages.
- The same report with an oversized decision renders the minimal body.
- A 317-file task with one changed file: the decision names only that
file. Without diffs it names 5 files and "312 more files".

## enki rerun (#169 harness)

Uptide arm only, claude-sonnet-5-5, default $1 cap, one run: 60949226.

| | |
|---|---|
| Exit | 0, draft delivered (branch `uptide/react-19.2.0`, verified
commit `3f05f25`) |
| PR body | 804 chars: minimal fallback (on 06bab33). With 16b2abe the
same stored report renders whole at 34,718 chars (77,681 before), no
shortening |
| Checks (harness) | install 0 · typecheck 0 · lint 0 · test 0 |
| #220 clean install | `npm ci --ignore-scripts --no-audit --no-fund`
passed |
| Sites | 3 by rule, 8 by the agent, 1 manual; verification PASS, 0 new
type errors |
| Cost / wall time | $0.17 (estimate $0.54) · 160 s |
| Fork CI | uptide-bench/entur--enki#5: npm ci, `npm run check` and
tests passed. The job fails at SonarQube Scan (no `SONAR_TOKEN` in the
fork), so build is skipped. Fork PRs #1–#4 fail the same way |

The run used the build of 06bab33. The fork PR's body is therefore the
minimal form; the 34.7 KB figure is a $0 re-render of the stored
report.json with 16b2abe. The report records `uptideDirty` because the
harness files under `bench/` were copied in untracked, as in earlier
reruns.

## Left as is

- Lockfile churn is not in the job summary. The run-report schema
(`uptide.run/v1`) has no lockfile field, so the full churn list is only
in report.json.
- `ReportedError` and `PR_BODY_TOO_LARGE` stay. Nothing throws
`PR_BODY_TOO_LARGE` any more, but `fix --json` still uses
`ReportedError` for reporting any failure after report.json is written.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
lucasbraum added a commit that referenced this pull request Oct 18, 2026
…LVE retry (UPT-109) (#234)

Closes UPT-109 once the enki check below is done. Refs UPT-107 (#224).

## What and why

In the 0.15.1 pilot (#169), enki's Uptide PR installed and passed tests,
but the type-check failed: 3 files import `@react-types/datepicker`,
which enki uses without declaring it, and the new lockfile no longer had
it.

**Cause.** The UPT-107 "moved entries" retry drops every package that
declares `react` as a peer, and that includes `@react-aria/datepicker`.
It re-resolved from 3.15.2 to 3.17.1, and 3.17.1 no longer depends on
`@react-types/datepicker`. Nothing required that package any more, so
npm pruned it.

**Fix** (`managers/upgrade.ts`, npm only):
- After a retry step succeeds, compare the top level of the old and new
lockfile (root and workspaces). Then scan tracked `.[cm]?[jt]sx?` files
for bare `from` / `import` / `require` specifiers. A package that left
the top level and is imported is a dropped import.
- If there is one, the same step runs again. It keeps the dropped
package and every locked package that requires it, transitively, at
their locked versions. Moved packages are never kept.
- If that retry hits ERESOLVE, the first retry's lockfile stands.
- `lockfile.undeclared` (`{ name, version, kept }`) lists each one. The
CLI summary and the PR body show it under "Imported but not declared":
the package is imported but not declared in package.json, and it was
kept at a version or the new lockfile no longer has it. Both suggest
adding it to package.json.
- The same report also covers a normal install with no retry, where the
only possible outcome is reporting it.
- `package.json` is never changed.

On enki this should keep `@react-aria/datepicker` (its react peer range
already accepts 19) and its requirers at their old versions, and so keep
`@react-types/datepicker` 3.13.2.

## Tests

- `eresolve.test.ts`, stand-in npm (now resolves dependencies
transitively and prunes unrequired entries, like npm):
- **kept**: `picker` peers on the moved `ui`, so the first retry drops
it. `picker` 1.1.0 no longer brings `picker-types`, which `src/date.ts`
imports. The second retry starts with `picker` and `picker-types`
locked, and the lockfile keeps both at 1.0.0. The report has
`undeclared: [{ picker-types, 1.0.0, kept: true }]`, and `package.json`
is unchanged.
- **cannot keep**: `picker` 1.0.0 conflicts too, so the second retry
hits ERESOLVE. The first retry's lockfile stands (`picker` 1.1.0, no
`picker-types`), with `kept: false`.
  - Both failed on main before the fix.
- `format-fix.test.ts`, `report.test.ts`: the summary and PR body lines
for both kept and not kept.
- Locally: `pnpm lint` (only the existing warning/info from main), `pnpm
typecheck`, core 2296 passed, cli 517 passed (after `pnpm build`, which
the notices and telemetry tests need).

## Not done

- **enki not re-run.** No bench runs for this PR, so the type-check
result on enki is still unconfirmed. Running the #169 harness's Uptide
arm on enki would confirm it.
- pnpm and yarn are not covered. The retry is npm only, and their strict
layouts already break undeclared imports.
- The import scan is a regex over JS/TS files. It does not read `.vue` /
`.svelte` files or `/// <reference types>`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.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