Skip to content

Lock dependencies with uv.lock + uv sync, and fix the Python floor - #320

Merged
pufit merged 2 commits into
mainfrom
alex-clickhouse/lock-dependencies
Aug 19, 2026
Merged

Lock dependencies with uv.lock + uv sync, and fix the Python floor#320
pufit merged 2 commits into
mainfrom
alex-clickhouse/lock-dependencies

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Makes Nerve's dependency versions reproducible across every install path, and corrects a
Python floor that has been wrong since the beginning.

Depends on #319, so this PR's file list currently includes it. #320 is one commit
(4316b3d, 13 files) sitting on top of #319's port commit. Because it targets main
rather than #319's branch, GitHub shows the union of both — 18 files. Merge #319
first
and this collapses to just the lock work. The base is main deliberately: a PR
based on a non-default branch receives no ci.yml checks at all, and this change is not
one to land unverified. The dependency is real rather than bookkeeping — uv.lock pins
mcp 2.x, which only works with #319's ported code.

The problem

pyproject.toml declared ~15 dependencies with lower bounds only and there was no
lockfile, so what a fresh install resolved changed silently as upstream published. When
mcp 2.0.0 removed an API Nerve imports at module scope, every fresh install broke at
startup (#316).

CI could not see it. It keyed its uv cache on pyproject.toml, so a restored cache
carried stale index metadata and kept resolving mcp==1.29.0:
run 32061993270 was green
on main three weeks after installs had broken. The report came from a user, not from us.

The uncomfortable summary: a green CI run had never been evidence that a fresh install
of Nerve worked.

What this changes

uv.lock (123 packages) is committed, and uv sync becomes how Nerve is installed.

uv sync                          # runtime dependencies
uv sync --extra test             # ...plus the test extra
uv lock                          # after editing pyproject deps — commit both
uv lock --upgrade-package mcp    # move a single pin deliberately

Every install path that Nerve controls now reads the lock:

Path From uv.lock? How
Quick start / server install / install.sh yes uv sync --locked --inexact
nerve upgrade yes uv sync --project <root> --locked --inexact
CI yes uv sync --extra test --locked
Docker yes locked at build time; entrypoint syncs the project
uv pip install -e . / plain pip install -e . no resolves from pyproject.toml bounds

That last row is deliberate and worth knowing: uv's pip-compatible layer has no
lockfile awareness at all.
uv pip install -e . ignores uv.lock entirely, which is
exactly why making it the documented install would have left the lock decorative. It
remains the right command when you want to test against current upstream.

nerve upgrade

#316 called this out specifically — "nerve upgrade reinstalls dependencies and can pull
2.x back in, so the pin has to be reapplied after upgrades."
Upgrades are how existing
users move, so leaving that path unpinned would have missed the group with a working
install to lose. Three flag choices carry weight:

  • --locked rather than --frozen. Both avoid rewriting the lock, but --frozen skips the freshness check, so a checkout whose pyproject.toml had drifted would be upgraded to a lock that doesn't describe it — silently.
  • --inexact. uv sync is exact by default and would uninstall anything absent from the lock, quietly removing optional extras a user added. The command this replaces was purely additive, so exact syncing would be a regression.
  • UV_PROJECT_ENVIRONMENT=sys.prefix. uv otherwise syncs <project>/.venv, which needn't be the venv Nerve is installed in, and VIRTUAL_ENV is unset when the console script runs directly as .venv/bin/nerve upgrade (so --active alone is unreliable). Guarded on sys.prefix != sys.base_prefix, since pointing uv at a system prefix could disturb packages far outside Nerve's environment.

Where uv or the lock is unavailable, the previous unpinned behaviour remains — installs
predating the lockfile depend on it — but it now prints an explicit "this upgrade is not
reproducible"
warning naming the reason, rather than degrading quietly.

Docker

Docker was resolving dependencies twice, both unpinned: the image synthesised a
requirements.txt from project.dependencies and pip-installed it, then the entrypoint
ran pip install -e .. A rebuild of an unchanged commit could therefore pick up a newer,
untested dependency — #316's failure mode on a first-class deployment path.

  • uv enters the image pinned to the same version CI uses.
  • Build layer: uv sync --locked --no-install-project --no-dev from only pyproject.toml + uv.lock, so it stays cached until dependencies actually change and the source can arrive later via the mount.
  • Entrypoint: uv sync --locked --inexact, which normally installs just the project.
  • UV_PROJECT_ENVIRONMENT=/opt/nerve-venv keeps the environment outside /nerve. Load-bearing, not tidiness: /nerve is a bind mount at runtime, so a .venv beneath it would be shadowed by the host's checkout — and a host-created venv may not be Linux-compatible at all. A test pins this so it doesn't get "simplified" back into the project directory.

Python floor: >=3.12>=3.13

uv lock refused to resolve the project at all:

Because the requested Python version (>=3.12) does not satisfy Python>=3.13
and memu-py==1.4.0 depends on Python>=3.13, we can conclude that
memu-py==1.4.0 cannot be used.

This was never only a locking obstacle — 3.12 has never been installable.
uv pip install -e . on 3.12 fails today with exactly that opaque transitive conflict, and
ci.yml's own matrix comment already said "3.13 is the real floor". The declaration was
simply a false promise.

install.sh follows: it stops accepting 3.12, and it now validates the interpreter it
actually provisioned rather than only checking that some python3 exists — the
dnf/zypper fallback chains could previously settle on 3.12 and report success. Because the
script now lets uv sync --python own the environment instead of creating it separately,
an existing 3.12 .venv is recreated at 3.13 rather than reused.

Two things the lock cannot pin for us

  • uv itself is pinned in CI. uv owns the lockfile format (uv.lock records a version and revision) and is the tool asserting the lock is current, so an unpinned uv is a moving part underneath the reproducibility this adds.
  • The build backend is bounded hatchling>=1.27,<2. uv.lock covers runtime dependencies, but build backends resolve fresh in an isolated build environment on every install, so an unpinned backend was a hole in the same claim. A major bound rather than --build-constraint, because the plain-pip path wouldn't honour a uv-specific constraints file.

One CI job, and its limit

ci.yml installs uv sync --extra test --locked. --locked fails when uv.lock
disagrees with pyproject.toml for any reason — dependency added, removed, or re-bounded —
so a dependency change cannot merge without a relock, and CI then tests the relocked set.

The limit worth stating plainly: uv lock is pin-preserving, so relocking after a
widened bound keeps the old pin. Verified — narrowing anthropic to <0.120 forced the
pin to 0.119.0, and then removing the ceiling again left it at 0.119.0 rather than
returning to 0.123.0. So a widened bound does not get its new range exercised. That only
affects consumers resolving from bounds, which after this PR is plain pip install -e .
and downstream packagers — both outside the reproducibility contract. It is the reason
Docker is locked here rather than watched by a monitoring job.

Migration

Existing Docker deployments need one manual step. nerve init does not overwrite
Docker files that already exist, so an install generated before this change keeps its
pip-based Dockerfile and entrypoint:

rm Dockerfile docker-entrypoint.sh
nerve init                       # choose "docker" again; regenerates both
docker compose build --no-cache

docker-compose.yml is unchanged. Check for local edits before deleting.

The first nerve upgrade after this merges is still unpinned, because nerve upgrade
runs the updater already loaded in memory and only then pulls. On Python 3.12 it will fail
after git pull has advanced the checkout, leaving new source against an old
environment. docs/setup.md documents the recovery (uv sync --locked --inexact on 3.13+).
Upgrades from the second onward install from the lock.

Testing

  • uv sync --extra test --locked from clean, then 3314 tests pass
  • Fresh git clone + bare uv sync, exactly as the README reads: working editable install, nerve CLI on PATH, nerve --help runs
  • The lock governs the documented path — with uv.lock pinning jsonschema==4.25.0 while pyproject.toml allows >=4.20: uv sync installs 4.25.0, uv pip install -e . installs 4.26.0
  • --locked catches every kind of dependency change — added, widened, narrowed; and relocking after a narrowed bound moved the pin
  • Real nerve upgrade — uninstalled a locked package from a target venv, ran nerve upgrade --no-pull --no-frontend, watched it invoke uv sync --locked --inexact and restore that venv at the locked version
  • Real containers — build layer installs mcp 2.0.0 / claude-agent-sdk 0.2.140 (exact lock match) into /opt/nerve-venv; mounting a source tree and running the entrypoint's sync installs the project editable in 636ms with which nerve = /opt/nerve-venv/bin/nerve
  • --inexact verified both ways: an extraneous package survives with it, is removed without it
  • Upgrade falls back correctly with no uv, no uv.lock, and outside a venv, warning in the cases where a lock exists but can't be used
  • An existing 3.12 .venv is recreated at 3.13 by uv sync --python 3.13 (3.12.3 → 3.13.14)
  • Python 3.12 install confirmed broken before this change, so the floor change removes a false promise rather than support anyone had
  • bash -n install.sh; both workflows parse

Not verified: a full Docker image build including the unchanged Node/gh/gog layers — only
the layers this PR touches were built and run.

Follow-ups (not here)

  • Automated lock refresh. Pinning trades "broken install" for "silently stale pins", and locking does not update vulnerable ones. Dependabot has supported uv lockfiles since March 2025; each bot PR would then be tested by uv sync --locked.
  • Coverage. requires-python is unbounded above, so uv.lock carries a Python 3.14 resolution that CI never exercises — add 3.14 or cap the declaration. A periodic uv sync --locked --no-cache would catch a pinned artifact that has been yanked. Nothing yet proves a plain-pip/wheel build, which is what downstream packagers do.

🤖 Generated with Claude Code

@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

CI green, and confirming from the log that both new mechanisms actually engaged
rather than just passing — run 32261058580:

Verify requirements.lock is in sync with pyproject.toml    (passed, no diff)
Install    Run uv pip install -e ".[test]" -c requirements.lock
Install    Resolved 119 packages in 90ms
Install     + mcp==2.0.0
Install     + jsonschema==4.26.0
  • Backend tests (Python 3.13) — pass, 2m14s (the extra ~30s vs before is the sync check's recompile)
  • Frontend build (Vite) — pass, 36s

The 90ms resolve is itself the evidence the constraints took effect: with the whole
closure pinned there's nothing left to solve for.

Worth stating plainly what this run does not prove: that the lock protects
against drift. It can't — nothing has drifted since the lock was generated an hour
ago. The evidence for that half is the pin-preservation experiment in the
description (artificially downgrade a pin → it survives recompilation), which is
what makes the staleness check quiet on upstream releases while still catching an
un-regenerated pyproject.toml.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Commit 7c81cdb adds reproducible Python dependency resolution for CI and aligns the supported Python floor with memu-py.

Changes:

  • Adds a universal pinned constraints file and CI staleness check.
  • Raises the Python requirement to 3.13.
  • Documents lock regeneration and reproducible installs.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
.github/workflows/ci.yml Validates and consumes the lock in CI.
docs/setup.md Documents Python 3.13 and lock usage.
install.sh Enforces the new Python floor.
pyproject.toml Declares Python 3.13+.
requirements.lock Pins Python runtime and test dependencies.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/ci.yml Outdated
Comment thread .github/workflows/ci.yml Outdated
Comment thread install.sh
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/lock-dependencies branch from 7c81cdb to 4c8d469 Compare August 19, 2026 14:31
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Added the deps-PR trigger for fresh-deps.yml, per review discussion. Green after
the change: backend 1m56s, fresh-resolve 3m31s, frontend 31s.

The reasoning in one line: pinning ci.yml makes it validate pins but blind to
bounds.
A PR can widen mcp>=2,<3 to mcp>=2,<4, leave the lock pinning 2.0.0,
and ci.yml sails through — while uv pip install -e . (unpinned, and what the
install docs tell users to run) would now pull mcp 3.x. Running the unpinned resolve
on dependency-touching PRs is the only thing that catches that, and it catches it at
the moment the decision is being made rather than in tomorrow's scheduled run.

Two things I deliberately did not do:

  • Not on every PR. That would reinstate the exact noise the pinned/unpinned split exists to remove — an unrelated frontend fix going red because a transitive dependency published something broken. The paths filter is what makes this useful rather than annoying.
  • Not blocking. It's a normal check; whether it gates merges is a branch-protection decision, not mine to make in a workflow file.

And one honest limitation: this PR's own run doesn't prove the new path filters
work.
It edits fresh-deps.yml, which was already a trigger path, so the job would
have run regardless. The YAML is validated and pyproject.toml / requirements.lock
are exact top-level matches, but the first genuine proof will be the next
dependency-touching PR that leaves the workflow file alone. Flagging it rather than
letting a green check imply more than it does.

@alex-clickhouse alex-clickhouse changed the title Lock dependency versions for CI, and fix the Python floor Lock CI dependencies, add an unpinned drift job, and fix the Python floor Aug 19, 2026
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/lock-dependencies branch from 4c8d469 to fcae65d Compare August 19, 2026 14:55
@alex-clickhouse alex-clickhouse changed the title Lock CI dependencies, add an unpinned drift job, and fix the Python floor Lock dependencies with uv.lock + uv sync, add an unpinned drift job, and fix the Python floor Aug 19, 2026
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Reworked to the native uv workflow — uv.lock + uv sync — replacing the constraints
file. Green: backend 1m52s, fresh-resolve 2m21s, frontend 44s.

The log shows the two jobs doing genuinely opposite things, which is the design:

ci.yml       Run uv sync --extra test --locked --python 3.13
             Resolved 123 packages in 0.85ms      ← reading the lock
             Installed 119 packages in 154ms

fresh-deps   Run uv pip install -e ".[test]" --refresh
             Resolved 119 packages in 1.22s       ← actually solving, unpinned

That ~1400× resolve-time gap is the clearest signal they aren't doing the same work:
one reads a decision already made, the other makes it fresh.

Why the rewrite. The constraints revision left uv pip install -e . as the
documented install, and uv pip install has no lockfile awareness — so the lock had no
effect on the command every doc points at. It would have made CI reproducible while
users kept resolving whatever PyPI offered that day, which is precisely how #316
happened. Proven rather than assumed, with the lock pinning jsonschema==4.25.0 while
pyproject.toml allows >=4.20:

Constraints revision Now
documented install uv pip install -e .4.26.0 (lock ignored) uv sync4.25.0 (lock honored)

Two side benefits worth noting:

  • uv sync --locked is a first-class staleness assertion, replacing the copy-the-lock-and-diff trick I'd needed to get "quiet on upstream releases, loud on an un-regenerated pyproject".
  • uv lock is stricter than uv pip compile — it resolves across the whole requires-python range, which is what surfaced the 3.12/3.13 inconsistency in the first place. uv pip compile had silently emitted memu-py with no marker.

Also verified the full user path rather than just CI: fresh git clone, bare uv sync,
working editable install with the nerve CLI on PATH.

Two gaps I'd rather state than leave implied: Docker installs are still unpinned
(the entrypoint uses plain pip in a container with no uv — a Dockerfile change with its
own risks, and docs/setup.md now says so), and this PR's run can't prove the new
pyproject.toml / uv.lock path filters fire on their own
, because it also edits
fresh-deps.yml, which was already a trigger path.

@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Known gap: nerve upgrade bypasses the lock

Flagging this rather than leaving it implied, because it's the same shape of problem
this PR exists to fix, and issue #316 called it out explicitly:

Note that nerve upgrade reinstalls dependencies and can pull 2.x back in, so the
pin has to be reapplied after upgrades.

nerve/cli.py:_pip_install_cmd builds either
uv pip install -e <root> --python <sys.executable> or
python -m pip install -e <root>. Neither reads uv.lock. So after this PR:

Path Locked?
fresh install (uv sync, installer, docs)
CI
nerve upgrade ❌ still resolves unpinned
Docker ❌ (out of scope, documented)

That matters because nerve upgrade is how existing users move forward — the group
most likely to be bitten by drift, since they already have a working install to lose.

The fix looks contained

uv sync --project <source_root> --active --frozen --inexact

when uv is present and <source_root>/uv.lock exists, falling back to the current
behaviour otherwise. Each flag is load-bearing:

  • --project — locate the project regardless of cwd
  • --active — sync into the venv Nerve is actually installed in, rather than uv's default <project>/.venv, which may be somewhere else entirely
  • --frozen — use the lock as-is; never re-resolve or rewrite it during an upgrade
  • --inexactdon't remove packages absent from the lock. uv sync is exact by default, which would strip optional extras a user installed deliberately. That would be a regression against today's additive pip install -e .

tests/test_cli_upgrade.py asserts on _pip_install_cmd directly, so it needs updating
alongside.

Happy to do it either way

I left it out of this PR because it's a user-facing CLI behaviour change with a couple
of non-obvious flag decisions (particularly --inexact) that deserve their own review
focus, and this PR is already carrying the lock, the workflows, the docs and the Python
floor. But it's a genuine hole in the "reproducible installs" claim, so it shouldn't sit
indefinitely — say the word and I'll add it here or open it as a follow-up.

@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/lock-dependencies branch from fcae65d to 10dc456 Compare August 19, 2026 15:07
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Folded in the nerve upgrade fix. Green: backend 3m51s, fresh-resolve 1m47s, frontend 39s.
3312 tests now (+2 for the new fallback cases).

nerve upgrade was the last unlocked path that mattered, and #316 named it directly.
_dep_install_cmd now returns uv sync --project <root> --frozen --inexact when uv is
present and the checkout has a uv.lock.

I tested each flag choice rather than reasoning about it, because two of them are the
kind of default that bites silently:

  • --inexactuv sync is exact by default. Verified both ways: installed an
    extraneous package into a venv, then synced. Without --inexact uv removed it;
    with --inexact it survived. Exact syncing would have made nerve upgrade silently
    uninstall optional extras users added deliberately — a regression against the purely
    additive pip install -e . it replaces.
  • UV_PROJECT_ENVIRONMENT=sys.prefix — uv otherwise syncs <project>/.venv, which
    needn't be the venv Nerve is installed in, and VIRTUAL_ENV is unset when the binary
    runs directly as .venv/bin/nerve upgrade. Guarded on sys.prefix != sys.base_prefix
    so it can never point at a system prefix, where an exact-ish sync could disturb
    packages far outside Nerve's environment. There's a test pinning that refusal.

Proven end to end rather than only in unit tests: uninstalled a locked package
(websockets) from a target venv, ran a real nerve upgrade --no-pull --no-frontend,
and watched it invoke uv sync --frozen --inexact and restore that specific venv at
the locked 17.0.1.

docs/setup.md now carries a table of which paths the lock covers, so nobody has to
infer it:

Path From uv.lock?
uv sync, install.sh yes
nerve upgrade yes
CI yes
uv pip install -e . / plain pip no — resolves from bounds
Docker no — pip in a container with no uv

Docker remains the one uncovered path, deliberately and now documented. Same for the
caveat that this PR's own run can't isolate the new pyproject.toml / uv.lock trigger
paths, since it also edits fresh-deps.yml.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (4)

docs/setup.md:56

  • This Docker setup path invokes nerve init immediately after uv sync, but uv sync does not expose the venv's scripts on PATH. A fresh host therefore cannot run the wizard unless this section activates .venv (or uses uv run nerve init).
uv sync            # Needed to run the wizard on the host

docs/setup.md:10

  • uv sync creates .venv but does not add .venv/bin to the current shell's PATH. As written, the following nerve init/nerve start commands fail with command not found on a fresh clone. Activate the environment here, as the README and server-install section already do.

This issue also appears on line 56 of the same file.

uv sync                # creates .venv from uv.lock

.github/workflows/fresh-deps.yml:78

  • This comment still says pyproject.toml declares >=3.12, but this PR changes it to >=3.13. Update the workflow documentation so it no longer describes the old mismatch.
        # Mirrors ci.yml. memu-py==1.4.0 requires >=3.13, so 3.13 is the real
        # floor despite pyproject's requires-python being >=3.12.

.github/workflows/fresh-deps.yml:149

  • workflow_dispatch can run against a manually selected branch or tag, but it reaches this same else branch and is told that the tree did not change and no PR can be responsible. Handle schedule separately; the manual-dispatch message must remain neutral about whether code or upstream dependencies changed.
              echo "### The tree did not change — an upstream release did"

@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/lock-dependencies branch from 10dc456 to 252833b Compare August 19, 2026 15:24
@alex-clickhouse alex-clickhouse changed the title Lock dependencies with uv.lock + uv sync, add an unpinned drift job, and fix the Python floor Lock dependencies with uv.lock + uv sync, and fix the Python floor Aug 19, 2026
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Removed the separate unpinned "fresh resolve" workflow, and I was wrong to add it.

Its justification was that ci.yml validates pins but is blind to bounds — so a PR could
widen mcp>=2,<3 to mcp>=2,<4, leave the lock pinning 2.0.0, and sail through CI while
unpinned installs pulled mcp 3.x. That premise is false. uv sync --locked fails when
uv.lock disagrees with pyproject.toml for any reason, including a bound that was
merely widened or narrowed. Tested all three:

add a dependency, don't relock   → uv lock --check FAILS, uv sync --locked FAILS
widen  a bound,   don't relock   → uv lock --check FAILS, uv sync --locked FAILS
narrow a bound,   then relock    → pin MOVES (anthropic 0.123.0 → 0.119.0)

So the loop already closes without a second workflow: change a bound → lock invalidated →
forced relock → pin moves → ci.yml tests the moved pin. The extra job was buying a
guarantee the pinned job already provided, at the cost of a workflow that could legitimately
disagree with ci.yml, extra trigger logic, and a failure message that had to explain which
of two causes applied. Deleted, and its commit dropped from the branch so it doesn't ship.

This PR is now a single commit on top of #319, with two CI checks instead of three.

What genuinely remains uncovered, stated plainly rather than papered over with a job that
didn't address it: with everything pinned, nothing tells you the pins have gone stale. The
failure mode is no longer a broken install — it's a year of untouched pins turning the
eventual relock into a big-bang upgrade. The unpinned job was a poor fit for that regardless,
since it exercised an install path this PR stops documenting. Dependabot/Renovate (open PRs
that ci.yml then tests) or a scheduled uv lock --upgrade + test job are the right shape,
and both are small follow-ups rather than something to bundle here.

Docker also remains unpinned, as documented.

@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/lock-dependencies branch from 252833b to 602a485 Compare August 19, 2026 15:45
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Independent architectural review (Codex), and the fixes it produced

Ran an adversarial architecture-level review of this PR with Codex (~12 min). It endorsed
the core direction — uv.lock + uv sync as the canonical workflow, cached pinned CI as
the gate, Python >=3.13, and the upgrade flag choices — and found five concrete defects,
all since verified and fixed in 602a485.

It caught me being wrong about why the drift job could go

I removed the separate unpinned workflow on the grounds that uv sync --locked fails on
any bound change, forcing a relock that CI then tests. The first half is true. The second
half is not: uv lock is pin-preserving.

narrow anthropic to <0.120  → relock forces the pin DOWN to 0.119.0
then remove the ceiling     → relock KEEPS 0.119.0  (does not return to 0.123.0)

So widening a bound produces a relock that still pins the old version — CI tests the old
version, and the newly-permitted range is never exercised. My earlier check only covered
narrowing, where the pin is forced to move, and I generalised from it. The bounds gap is
real; I just had the mechanism wrong.

It still doesn't bring the job back, because the gap only bites consumers that resolve
from bounds — and after this PR that is essentially just Docker (plus plain pip and
downstream packagers, who are explicitly outside the reproducibility contract). Monitoring
that with a permanent canary treats the symptom; locking Docker removes the consumer.
That's filed as the immediate next PR, and this PR's claims are narrowed accordingly.

Fixed here

  1. Broken quick startuv sync followed by a bare nerve init. uv sync creates .venv but doesn't activate it, so nerve wasn't on PATH. A regression I introduced when replacing pip install -e .. Now activates (with uv run documented as the alternative); the Docker host step uses uv run nerve init.
  2. install.sh sync flags — was bare uv sync. It doubles as an upgrade path, so exact syncing could delete user-added packages, and without --locked it could silently re-resolve and rewrite the user's uv.lock. Now --locked --inexact.
  3. nerve upgrade uses --locked, not --frozen — both avoid rewriting the lock, but --frozen skips the freshness check, so a checkout whose pyproject.toml had drifted would be upgraded to a lock that doesn't describe it, silently. Verified the real upgrade path still works.
  4. First-upgrade migration documentednerve upgrade runs the updater already loaded in memory, so the first upgrade across this commit still uses the old unpinned installer, and on Python 3.12 it fails after git pull has advanced the checkout. docs/setup.md now has an "upgrading an installation that predates the lockfile" section with the recovery command.
  5. Over-claim softened — "same dependency tree on any machine" now notes that lock entries are platform/Python-marker dependent and artifacts aren't vendored, so air-gapped installs still need a cache or wheelhouse.

One place I didn't follow it: it wanted a missing uv or venv to fail loudly rather than
fall back to unpinned pip. That would break installs predating the lockfile and any non-uv
environment. Compromise: the fallback stays, but now prints an explicit
this upgrade is not reproducible warning naming the reason — with tests asserting the
warning fires when a lock exists but can't be used, and stays silent when there's no lock
to be reproducible against.

Deferred, with tasks filed

  • Lock Docker — the one remaining unpinned path. Non-trivial: the container bind-mounts the checkout, so the environment must live outside the mount (UV_PROJECT_ENVIRONMENT), plus a migration for already-generated Docker files.
  • Automated lock refresh — pinning trades "broken install" for "silently stale pins". Dependabot has supported uv lockfiles since 2025-03-13; each bot PR then gets tested by uv sync --locked.
  • Coverageuv.lock carries a Python 3.14 resolution (unbounded >=3.13) that CI never exercises; a periodic uv sync --locked --no-cache would catch a yanked pinned artifact; nothing yet proves a plain-pip/wheel build.

3312 tests pass; lock in sync; bash -n install.sh clean.

A fresh install has been failing at startup with

    ImportError: cannot import name 'request_ctx'
    from 'mcp.server.lowlevel.server'

mcp was never declared in pyproject.toml — it arrived transitively via
claude-agent-sdk, whose constraint is `mcp<3.0.0,>=1.23.0`. Nothing else in
the tree capped it, so once mcp 2.0.0 landed on PyPI every fresh resolve
picked it up. mcp 2.0 is an API redesign of the lowlevel server, so this ports
the endpoint and declares `mcp>=2,<3` explicitly. Declaring it is correct
regardless: three modules under nerve/mcp_server/ import mcp directly.

What changed upstream, and what it forced here:

- Handler registration moved from `@server.list_tools()` / `@server.call_tool()`
  decorators to `Server(..., on_list_tools=, on_call_tool=)` constructor
  callbacks. `Server.call_tool` no longer exists.
- The request context is now an argument, not a contextvar. `request_ctx` is
  gone with no replacement, so `_resolve_client_info` and
  `_bound_identity_from_request` take a `ServerRequestContext` explicitly and
  `build_ctx_resolver`'s closure threads it down from the call handler.
- `on_list_tools` returns `ListToolsResult` rather than `list[Tool]`, and
  `on_call_tool` receives `CallToolRequestParams` rather than positional
  `(name, arguments)`.
- Model attributes are snake_case in Python now (the camelCase wire aliases are
  unchanged). Constructing with `isError=` still works via `populate_by_name`,
  but reads had to move: `client_params.clientInfo` -> `.client_info`.

Two things worth a reviewer's attention:

mcp 1.x validated tool arguments against `inputSchema` inside its `call_tool`
decorator (`validate_input=True` by default). The 2.x callback does not, so
porting mechanically would have silently dropped argument validation on an
endpoint external clients can reach. `build_mcp_server` now validates
explicitly with jsonschema — declared as a direct dependency, since we import
it — and returns the same isError shape as the other failure paths. Tests pin
the behaviour, including that a rejected call never reaches the handler.

The `clientInfo` -> `client_info` rename was invisible to the entire existing
suite: every unit test builds a context with `session=None`, so that line never
executed, and all 3303 tests passed with it broken. Over real HTTP it raised
AttributeError inside the resolver, which `_call_tool` turns into "Context
error" — every external MCP tool call would have failed. The two new
`test_mcp_http_integration` cases drive `initialize` -> `tools/call` through the
real transport and assert satellite attribution, which is what caught it. The
module docstring had claimed a tools/call flow that was never actually there.

Verified: 3310 tests pass under mcp 2.0.0 on a fresh `--refresh` resolve;
`import nerve.gateway.server` clean; the SDK's in-process MCP bridge (which
serves every agent tool, not just /mcp/v1) round-trips a real tool call on its
mcp-2.x branch; and forcing mcp 1.29.0 fails at import, confirming the `>=2`
floor is load-bearing.

Fixes #316

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/lock-dependencies branch from 602a485 to e6ef980 Compare August 19, 2026 15:55
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Thanks — three of these were live and are fixed in e6ef980; the rest were against the
superseded constraints-file revision. Going through them honestly:

Fixed — unpinned uv in CI. The byte-for-byte diff check that prompted this is gone
(replaced by uv sync --locked), but the underlying point survives and is arguably
sharper now: uv owns the lockfile format — uv.lock records version = 1, revision = 3
and it is the tool asserting the lock is current. Letting CI float to whatever uv
shipped that morning puts a moving part underneath the reproducibility this PR exists to
provide. setup-uv is now pinned to 0.12.0, with a note to bump it alongside a
uv lock refresh.

Fixed — build backend unpinned. Confirmed: hatchling is absent from uv.lock,
because build backends resolve fresh in an isolated build environment on every install.
So "reproducible install" had a hole in it exactly as you describe. requires is now
hatchling>=1.27,<2. I chose a major bound over --build-constraint because the
plain-pip path wouldn't honour a uv-specific build-constraints file, so the bound helps
every consumer; the residual (a breaking 1.x release) is smaller than the coupling cost.

Already fixed — the two nerve init docs bugs (quick start and Docker host step
running bare nerve after uv sync). You flagged these as suppressed comments and an
independent Codex review caught them too; both are corrected — the examples now activate
.venv, with uv run documented as the alternative. Genuinely my regression, introduced
when replacing pip install -e ..

Not a problem, verified — existing 3.12 .venv reuse. You're right about the old
code: it created the venv separately behind if [ ! -d ".venv" ], so an upgrading 3.12
user would keep a 3.12 environment. This PR removes that step entirely and lets
uv sync --python 3.X own the environment. Tested against a real pre-existing 3.12 venv:

$ uv venv .venv --python 3.12 && .venv/bin/python --version   → Python 3.12.3
$ uv sync --locked --inexact --python 3.13
$ .venv/bin/python --version                                  → Python 3.13.14

uv sync recreates rather than reuses, so the migration is handled. install.sh also
now passes --locked --inexact, since rerunning it is an upgrade path and exact syncing
would have deleted user-added packages.

Moot — the two fresh-deps.yml comments. That workflow has been removed from this
PR. Your point about workflow_dispatch falling into the "the tree did not change" branch
was correct, though, and would have needed fixing had it stayed.

Adds uv.lock (123 packages) and makes `uv sync` the way Nerve is installed and
upgraded, so a given commit resolves to the same versions wherever it is
installed. CI installs the same way, with --locked.

The mcp 2.0.0 breakage (#316) was not really a versioning accident: pyproject
declared ~15 dependencies with lower bounds only and no lock, so what a fresh
install resolved changed silently as upstream published. CI could not see it
either, because it keyed its uv cache on pyproject.toml and a restored cache
carried stale index metadata — three weeks green while every fresh install was
broken.

`uv sync --locked` in CI is what makes a single job sufficient for dependency
changes. It fails if uv.lock disagrees with pyproject.toml for any reason — a
dependency added, removed, or merely re-bounded — so a change cannot merge
without a relock, and CI then installs the relocked set. Note this does NOT mean
a widened bound gets its new range exercised: `uv lock` is pin-preserving, so
relocking after a widening keeps the old pin. Bounds still matter to consumers
that resolve from them (plain pip, downstream packagers, and Docker until it is
locked), which is why locking Docker is the immediate follow-up rather than
carrying a permanent unpinned canary job.

`nerve upgrade` installs from the lock as well, which matters because it was
named in #316 as the reason a pin had to be reapplied after every upgrade.
`_dep_install_cmd` returns `uv sync --project <root> --locked --inexact` when uv
is present and the checkout has a uv.lock. Each flag is load-bearing:

  * --locked: install the lock, and refuse if it no longer matches pyproject.
    `--frozen` would also avoid rewriting the lock but skips that check, so a
    drifted checkout would be upgraded to a lock that doesn't describe it.
  * --inexact: `uv sync` is exact by default and would uninstall anything absent
    from the lock, silently removing optional extras the user added. Verified:
    without --inexact an extraneous package is removed, with it it survives. The
    command this replaces was purely additive, so exact syncing is a regression.
  * UV_PROJECT_ENVIRONMENT=sys.prefix: target the venv Nerve is installed in. uv
    otherwise syncs <project>/.venv, which needn't be the same directory, and
    VIRTUAL_ENV is unset when the binary runs directly as `.venv/bin/nerve`.
    Guarded on sys.prefix != sys.base_prefix, since pointing uv at a system
    prefix could remove or downgrade packages well outside Nerve's environment.

When uv or the lock is unavailable the previous unpinned behaviour remains —
installs predating the lockfile depend on it — but it now warns explicitly that
the upgrade is not reproducible instead of degrading silently.

Fixing requires-python was a prerequisite: `uv lock` refused to resolve at all
because requires-python said >=3.12 while the pinned memu-py==1.4.0 needs
>=3.13. That inconsistency was already user-facing — `uv pip install -e .` on
3.12 fails with an opaque transitive conflict. So the floor moves to >=3.13,
which is what it has always effectively been (ci.yml's own matrix comment said
as much), and install.sh stops accepting 3.12 and now validates the version it
actually provisioned. Because install.sh now lets `uv sync --python` manage the
environment rather than creating it separately, an existing 3.12 .venv is
recreated at 3.13 rather than reused — verified.

Two things pinned that the lock cannot pin for us:

  * setup-uv is pinned to a uv version. uv owns the lockfile format and is also
    the tool asserting the lock is current, so an unpinned uv is a moving part
    underneath the reproducibility this adds.
  * hatchling is bounded `<2`. uv.lock covers runtime dependencies, but build
    backends resolve fresh in an isolated build environment on every install, so
    an unpinned backend was a hole in the same claim.

Install docs updated to `uv sync`: README, docs/setup.md, docs/worker-guide.md,
docs/observability.md, install.sh, and the nerve-dev skill template shipped in
nerve/templates/. `uv sync` creates .venv but does not activate it, so the
examples activate (or use `uv run`) before calling `nerve`. docs/setup.md also
gains a table of which paths install from the lock, an honest note that the lock
is platform/marker dependent and does not vendor artifacts, and a migration
section for installs predating it — the first `nerve upgrade` across this commit
still runs the old in-memory updater, so it installs unpinned, and on Python
3.12 it fails after `git pull` has already advanced the checkout.

Docker installs from the lock too, so no first-class deployment path is left
re-resolving. It was resolving dependencies twice, both unpinned: the image
synthesised a requirements.txt from `project.dependencies` and pip-installed it,
and the entrypoint then ran `pip install -e .`. Both are replaced by uv:

  * uv enters the image pinned to the same version CI uses.
  * The build layer runs `uv sync --locked --no-install-project --no-dev` from
    just pyproject.toml + uv.lock, so it stays cached until deps really change
    and the project itself can arrive later via the mount.
  * The entrypoint runs `uv sync --locked --inexact`, which in the normal case
    only installs the project.
  * UV_PROJECT_ENVIRONMENT=/opt/nerve-venv puts the environment OUTSIDE /nerve.
    That is load-bearing, not tidiness: /nerve is a bind mount at runtime, so a
    .venv beneath it would be shadowed by the host's checkout — and a host venv
    may not be Linux-compatible at all.

Verified in real containers rather than by reading: the build layer installs the
locked versions (mcp 2.0.0, claude-agent-sdk 0.2.140, matching uv.lock), and
mounting a source tree and running the entrypoint's sync installs the project
editable in ~0.6s with `nerve` resolving to /opt/nerve-venv/bin/nerve.

One migration wrinkle, documented in docs/setup.md: `nerve init` does not
overwrite Docker files that already exist, so pre-existing installs keep their
pip-based Dockerfile and entrypoint until the user deletes those two files and
regenerates.

Verified: `uv sync --extra test --locked` from scratch, then 3312 tests pass; a
fresh `git clone` plus bare `uv sync` yields a working editable install with the
nerve CLI on PATH; with uv.lock pinning jsonschema 4.25.0 while pyproject allows
>=4.20, `uv sync` installs 4.25.0 where `uv pip install -e .` installs 4.26.0,
confirming the lock governs the documented path; a real `nerve upgrade` restores
a deliberately broken target venv at the locked version; `bash -n install.sh`.

Fixes the dependency-management half of #316; the code port is #319.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/lock-dependencies branch from e6ef980 to 4316b3d Compare August 19, 2026 16:05
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Folded the Docker locking in (4316b3d) rather than leaving it stranded as a follow-up.

Docker turned out to be resolving dependencies twice, both unpinned — the image
synthesised a requirements.txt from project.dependencies and pip-installed it, and
then the entrypoint ran pip install -e .. Both are now uv, with uv pinned to the same
version CI uses.

The non-obvious constraint, and the reason this wasn't a one-liner:
UV_PROJECT_ENVIRONMENT=/opt/nerve-venv — the environment has to live outside
/nerve
, because that path is a bind mount at runtime. A .venv beneath it would be
shadowed by the host's checkout, and a host-created venv may not be Linux-compatible at
all. There's a test pinning that, so nobody "simplifies" it back into the project
directory.

Verified in real containers, not by reading the template:

build layer  → mcp 2.0.0, claude-agent-sdk 0.2.140     (exact uv.lock match)
             → sys.prefix = /opt/nerve-venv
runtime      → mounted source + `uv sync --locked --inexact`
             → + nerve==0.1.0 (from file:///nerve) in 636ms
             → which nerve = /opt/nerve-venv/bin/nerve

Scope note: I verified the layers I changed (dependency install, runtime project sync) in
a built image; I did not run a full image build including the unchanged Node/gh/gog
layers.

Migration is documented in docs/setup.md and is the one rough edge: nerve init
won't overwrite Docker files that already exist, so a pre-existing install keeps its
pip-based Dockerfile and entrypoint until those two files are deleted and regenerated.
docker-compose.yml is unchanged.

Coverage table is now: uv sync, nerve upgrade, CI, and Docker install from the
lock; plain pip install -e . does not, which is explicit and intended.

3314 tests pass (+2 covering the locked Docker build and the venv-outside-the-mount
invariant).

Also, for the record on the earlier red run: test_review_loops.py::test_pass_first_iteration
failed once and passed on rerun without any change. It's a poll-for-status test and it
passed 6/6 locally; the relock before it moved zero pinned versions — the only diff was
the recorded specifier string >=0.2.82>=0.2.140. Flake, not a regression, but worth
noting in case it recurs.

@alex-clickhouse
alex-clickhouse requested a balanced review from Copilot August 19, 2026 16:06
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review August 19, 2026 16:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated no new comments.

Suppressed comments (3)

docs/setup.md:376

  • The implementation and tests use --locked, not --frozen; --locked also checks that uv.lock is current. This paragraph additionally omits the code's outside-a-virtualenv fallback.
`nerve upgrade` uses `--inexact`, so it won't uninstall optional extras you added
yourself, and `--frozen`, so it never rewrites `uv.lock` as a side effect of
upgrading. If uv is missing, or the checkout has no `uv.lock`, it falls back to the
previous `pip install -e .` behaviour.

.github/workflows/ci.yml:62

  • A normal uv lock preferentially preserves an existing compatible version (as docs/setup.md:327 also states). Widening or otherwise changing a bound can therefore update the lock metadata while retaining the old pin, so this single job does not necessarily test newly admitted versions. Restore a fresh/upgrade resolution check, or enforce an explicit uv lock --upgrade-package policy for changed bounds; otherwise the “one job sufficient” premise leaves those bounds untested.
        # one job sufficient: any dependency change — added, removed, or
        # re-bounded — invalidates the lock, so it cannot be merged without a
        # relock, and the relock moves the pins that this job then tests. It

docs/setup.md:343

  • This contradicts the immediately preceding statement that relocking preserves existing pins. uv lock can retain a still-compatible pin after a bound is widened, so CI will test that old pin rather than necessarily testing a newly admitted version. Document the required --upgrade-package workflow or restore fresh-resolution coverage instead of claiming the pin always moves.

This issue also appears on line 373 of the same file.

- A dependency change cannot merge without a relock. `--locked` fails if `uv.lock` disagrees with `pyproject.toml` for *any* reason: a dependency added, removed, or simply re-bounded. Since the relock then moves the pins, the versions CI tests are always the ones the change actually selects.

That second property is why no separate unpinned CI job is needed. Widen a bound and
the lock is invalidated; relock and the pin moves; CI tests the moved pin.

@pufit
pufit merged commit 67f5e78 into main Aug 19, 2026
3 checks passed
@pufit
pufit deleted the alex-clickhouse/lock-dependencies branch August 19, 2026 18:20
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.

3 participants