Skip to content

fix: own the module cache, and refresh it by resolving refs instead of deleting clones - #142

Merged
Salil Das (sadlilas) merged 11 commits into
mainfrom
fix/own-foundation-cache-root
Aug 24, 2026
Merged

fix: own the module cache, and refresh it by resolving refs instead of deleting clones#142
Salil Das (sadlilas) merged 11 commits into
mainfrom
fix/own-foundation-cache-root

Conversation

@sadlilas

@sadlilas Salil Das (sadlilas) commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two problems with one root, fixed together because fixing either alone leaves the other broken.

  1. amplifier-agent wrote its module clones into ~/.amplifier — a tree owned by amplifier-app-cli.
  2. Those clones were frozen at first install forever, so an upstream module fix never reached an existing user.

Ships as 0.15.0.


Problem 1 — the dependency was invisible to grep

A repo-wide search for .amplifier returns 94 hits and every one is skills/modes discovery or an e2e fixture. Nothing in this repository caused the coupling. It was created by an absent argument at bundle/loader.py:

bundle = await load_bundle(f"file://{target}")
prepared = await bundle.prepare(install_deps=install_deps)

Neither call passes a cache root, so foundation's default applied — get_amplifier_home(), which reads AMPLIFIER_HOME and otherwise falls back to ~/.amplifier. This repo never set it: grep -rn "AMPLIFIER_HOME" . returned 0 matches.

Fix: bind AMPLIFIER_HOME to <agent home>/foundation before amplifier_foundation is imported. Ordering is load-bearing — session/finder.py:36 computes a ~/.amplifier-derived constant at import time — so the bind runs in the package __init__ of both amplifier_agent_lib and amplifier_agent_http.

The bind is unconditional. A user who exported AMPLIFIER_HOME for app-cli would otherwise silently re-couple — the same bug, appearing only for users who customised their setup. Overrides that are honoured: $AMPLIFIER_AGENT_FOUNDATION_HOME (subtree), $AMPLIFIER_AGENT_HOME (whole tree).

Also relocated: recipe session state (the only write into app-cli's tree), the context-intelligence reader root, and provider-anthropic's rate-limit file — the last built from expanduser("~") + a literal ".amplifier", so the bind can't reach it; its rate_limit_state_path config is defaulted at mount time instead.


Problem 2 — the cache key was being lied to

Foundation keys each clone at sha256(git_url@ref)[:16] and returns any directory that already exists — no fetch, no ref comparison, no commit check.

With @main the key is sha256(url@"main"). main is a pointer, not an identity. The string never changes while the commit it names does, so one directory serves every commit that branch will ever have — in practice the first one, for the life of the machine.

Foundation's reuse rule is correct reasoning on a false premise. Every previous remedy — #141, and earlier revisions of this PR — attacked the consequence by deleting the directory. This attacks the premise.

Before prepare(), resolve each floating ref to the commit it currently points at (git ls-remote — refs only, no repository data, no auth, measured at 0.386s) and rewrite the source to that SHA:

Situation Key Result
Branch unmoved same same directory — nothing downloaded
Branch moved new new directory — cloned fresh, automatically
Remote unreachable commit already on disk existing directory reused, run succeeds

This is not pinning. bundle.md still says @main and is never rewritten in the repository. Resolution happens on the user's machine against the branch as it stands at that moment, so a provider fix reaches users with no amplifier-agent release — preserving the non-goal in docs/spec/bundle-and-cache.md. Pinning at release time was considered and rejected precisely because it would gate every module fix on an engine release.

Applied through Bundle.prepare(source_resolver=...) — a documented foundation extension point ("allows app-layer source override policy to be applied before activation") that amplifier-agent had never used, and that amplifier-app-cli already uses for its own settings overrides. Foundation supports the rewritten form natively via _clone_at_commit() for full 40-character SHAs.

No deletion remains anywhere in the refresh path. Directories are immutable and content-addressed: one either is that commit or does not exist, so there is no half-updated state and no interrupted-fetch failure mode.

update no longer ignores modules

Modules move independently of engine releases, but update exited early on a version match — so the only route to a module fix was an engine release that existed solely to move the cache. It now re-resolves refs, compares against the commit foundation recorded beside each clone, names what moved, and re-primes. When nothing moved it costs one refs-only round trip per module and zero downloads.

Pruning

A moved branch leaves its predecessor unreferenced. After a successful prepare, directories for repositories resolved in that pass whose commit is no longer current are removed. Conservative: only repositories resolved this pass are considered, the current commit is always kept, failures are ignored. Disk reclamation, not correctness.


Verification — clean DTU, no Gitea

Clean Incus container. Code fetched from the GitHub remote inside the container, this PR applied as a patch. Baseline: both trees absent.

1. Refs are resolved to commits

{ "ref": "7092d5d96374ee8846162dc7ab3e907c1dbcdaa7",
  "commit": "7092d5d96374ee8846162dc7ab3e907c1dbcdaa7",
  "git_url": "https://github.com/microsoft/amplifier-module-provider-anthropic" }

ref was "main" before this change. 27/27 clones keyed by commit; none left floating.

2. Unchanged branches download nothing

cold prepare, first time : 27.411s   (27 clones)
cold prepare, again      :  1.572s
PASS: all 27 clones byte-identical (same inode, same mtime)

3. Drift is detected precisely

Planted a stale commit in exactly one clone's metadata, then ran update with the engine already current:

1 module(s) have upstream changes:
  amplifier-module-tool-web -> 8bd784e2dca4
Re-priming bundle cache...
Already up to date. Use --force to reinstall.

One module named. The other 26 untouched.

4. Offline degrades correctly — and this caught a real bug

The first implementation left unresolvable refs floating, described as "behaves exactly as it does today". That was true only while no SHA-keyed directory existed. Once one does, the two keys differ:

key for @main     : 5181591dcf06d076
key for @<commit> : f02f889caf572733

so the fallback pointed foundation at a directory that was never created and sent it to clone — precisely when the network is gone. Worse than the old behaviour, not equal to it. Fixed to fall back to the commit recorded in local clone metadata. Re-tested with DNS broken:

fatal: unable to access '...': Could not resolve host: github.com
amplifier-agent: prepared bundle cached at .../prepared/0.15.0/fb14ee933ffc5cf3
real 0m0.157s
[ OK ] foundation isolation ... [ OK ] bundle cache: prepared

5. Isolation

$ amplifier-agent run -y "Reply with exactly the word: FINAL"
FINAL
PASS: /root/.amplifier ABSENT after a real provider-backed turn

skills cache : /root/.amplifier-agent/foundation/cache/skills/...
rate-limit   : /root/.amplifier-agent/state/rate-limit-state.json
module clones: 27

An earlier isolation measurement passed prematurely — it ran before any successful agent turn. A real turn then created ~/.amplifier via two paths outside foundation's AMPLIFIER_HOME contract: provider-anthropic's rate-limit file (fixed here) and tool-skills' hardcoded cache dir (fixed upstream — see dependency below).


Supersedes #141

#141 ships four files. All are accounted for:

#141 ships Here
pyproject.toml version bump Yes, as 0.15.0
uv.lock bump Yes — this branch had missed it; caught by diffing against #141
CHANGELOG.md stale-clone diagnosis Folded in
post_install.py clone-deleting migration Obsolete. Content-addressed directories mean a moved branch lands in a new directory on its own; deleting would only force re-downloads of unchanged code

Clones left in ~/.amplifier/cache are left strictly alone — not deleted, not reported, no flag to remove them. That directory is foundation's default for every Amplifier application, keyed with no per-app namespacing, so on a machine running amplifier-app-cli they are its live clones, indistinguishable from our leftovers. An earlier revision of this PR added a doctor advisory and a cache clear --legacy flag; both were removed. For app-cli's large user base that affordance is a button that breaks a different application, and a caveat in help text documents the damage rather than preventing it. Users who don't run app-cli can rm -rf ~/.amplifier themselves.

#141's open follow-up remains open and is not addressed here: ModuleActivator._install_dependencies() returns on a distribution-name match before the pyproject.toml fingerprint check that would catch changed dependency constraints. Both guards sit behind if not force, and no production caller in either repo ever passes force=True.


Compatibility ledger

Enumerated once from the org and frozen. Three direct consumers; 17 candidates refuted.

Repo How it consumes Verdict Evidence
amplifier-app-opencode starts serve chat-completions, queries /v1/models PASS Full HTTP surface exercised: /v1/modelsclaude-haiku-4-5-20251001; /v1/chat/completionsOPENCODE-SURFACE-OK; ~/.amplifier absent after
amplifier-app-paperclip spawns the CLI per turn with a minimal env allowlist PASS Reproduced with env -i and exactly PATH, HOME, USER, LANG, TERM, TMPDIR: roots resolved correctly. The bind derives from HOME, not an inherited variable
amplifier-app-nanoclaw Docker install; pre-creates /home/node/.amplifier/cache for "legacy bundle install machinery" PASS Pre-created it, ran a real turn, it stayed empty. The mkdir is now dead weight

Propagation is a separate axis from compatibility — none of these breaks, but each receives the fix differently: nanoclaw pins a SHA (ARG AMPLIFIER_AGENT_REF=cef3fb53…) and needs a bump; paperclip installs latest and gets it free; opencode's version-floor self-heal means raising its floor is a policy choice, not a correctness need.


Named intentional exceptions

  1. provider_sources.py:440 — reads ~/.amplifier/openai-chatgpt-oauth.json to report whether a device-code login happened. The module hardcodes that same literal (oauth.py:60), so relocating would desynchronise reader from writer. Read-only; never emits token material.
  2. Skills and modes discovery<cwd>/.amplifier/skills, ~/.amplifier/skills, .amplifier/modes. Deliberate cross-harness interop.

Known residuals (upstream)

Location Reachable?
registry.py:453Path.home()/".amplifier"/"cache" as a walk stop-boundary Yes, but only when resolved.source_root is falsy — not the normal path
session/finder.py:36 — module-level DEFAULT_SESSIONS_ROOT Evaluated at import; never consulted (explicit transcript paths are passed)
configurator/_state_manager.py:756 No — five symbols are imported from foundation; none reach the configurator

Checks

ruff check src/          -> All checks passed!
ruff format --check src/ -> 93 files already formatted
pyright src/             -> 0 errors, 0 warnings, 0 informations

No test file added: tests/ holds e2e DTU suites only, per [tool.pytest.ini_options]. The repo's own e2e profile is Gitea-based and was deliberately not used; verification ran in a clean container fetching from the real remote.

Dependency

Full isolation requires microsoft/amplifier-bundle-skills#61tool-skills hardcodes ~/.amplifier/cache/skills and passes no cache_dir, so it cannot be fixed from this repository. Verified: with #61 applied, ~/.amplifier is never created; without it, only that one subdirectory appears. Everything else in this PR holds either way.

Merge order: #61 first (skills float on @main, so it lands on next prepare), then this, tagged v0.15.0.

Amplifier added 4 commits August 22, 2026 17:53
amplifier-agent's module clones were written into ~/.amplifier/cache, a tree
owned by amplifier-app-cli. Nothing in this repo referenced .amplifier to cause
it: foundation resolves its storage root from AMPLIFIER_HOME and falls back to
~/.amplifier, and this app never set the variable.

Bind AMPLIFIER_HOME to <agent home>/foundation before amplifier_foundation is
imported, so foundation's own resolver writes into a root this app owns.

Also relocate the recipes session_dir out of ~/.amplifier/projects, add a
doctor check that fails if the bind regresses, and generalise PR #141's
one-time stale-clone migration into the permanent cold-cache refresh -- now
safe, because the directory is ours to delete from.
The hook writes to <agent home>/state/workspaces but its readers resolve the
root only from AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH, defaulting to
~/.amplifier/projects. Unset, captures are written to one tree and looked for
in another; the hook warns about exactly this at runtime. Set when absent,
leaving a deliberate user setting alone.
provider-anthropic defaults its cross-process rate-limit state file to
os.path.expanduser("~") + ".amplifier" — built from the raw home directory, so
the AMPLIFIER_HOME bind cannot reach it. It does read config
rate_limit_state_path first, so default that at mount time.

Applied in build_provider_entry rather than bundle.md because provider entries
in the manifest carry no config: both call sites clear mount_plan[providers]
and mount exactly one provider via inject_provider.
The bump is not cosmetic. `update` compares versions and exits early with
"Already up to date" when they match, so without a new release tag the
reinstall never runs and no user receives this fix.

Same coupling PR #141 identified, for a different reason: #141 needed the bump
because post-install would early-return on a warm cache; this needs it because
`update` never gets that far.
Amplifier added 5 commits August 24, 2026 06:46
Completes the absorption of #141 so it can be closed:

- uv.lock version bump, which #141 carried and this branch had missed
- #141's stale-clone diagnosis folded into the 0.14.2 changelog entry
- legacy clones in ~/.amplifier/cache surfaced by doctor and removable via
  cache clear --legacy

#141 deleted those clones unconditionally. That was right while the directory
was the one amplifier-agent used; after the relocation it is foundation's
default for every Amplifier app, keyed with no per-app namespacing, so on a
machine running app-cli they are its live clones and indistinguishable from
our leftovers. Reported and offered rather than deleted.
Removes the doctor advisory and the `cache clear --legacy` flag added in the
previous commit.

amplifier-app-cli has a large user base, and for those users ~/.amplifier/cache
is live, not stranded. From inside amplifier-agent the two populations are
indistinguishable — clones are keyed sha256(git_url@ref)[:16] with no per-app
namespacing — so any cleanup affordance is, for a substantial fraction of the
people who would see it, a button that breaks a different application. A caveat
in the help text does not fix that; it documents the damage.

Asymmetric costs: not offering cleanup wastes some disk for agent-only users,
who can rm -rf it themselves. Offering it breaks app-cli installs on this
tool's suggestion. Reporting is dropped too — a report whose only actionable
follow-up is a dangerous manual delete is the same footgun with extra steps.
Foundation keys each clone on sha256(git_url@ref) and reuses any directory that
exists. With @main the key never changes while the commit it names does, so one
directory serves every commit that branch will ever have.

That is correct reasoning on a false premise: main is a pointer, not an
identity. Previous fixes attacked the consequence by deleting the directory.
This fixes the premise -- resolve the ref to its current commit via ls-remote
before prepare(), so the key identifies the content.

Unmoved branch resolves to the same key and downloads nothing; a moved one gets
a new directory and clones fresh; offline leaves the ref floating and reuses the
existing clone. No deletion anywhere in the refresh path.

Not pinning: bundle.md still says @main and is never rewritten. Resolution
happens on the user's machine, so module fixes still land without an engine
release. Applied through Bundle.prepare(source_resolver=...), a documented
foundation seam this app had never used.

Also makes `update` check module drift when the engine is already current --
previously it exited early, so the only route to a module fix was an engine
release that existed solely to move the cache.
Caught in DTU verification. Leaving the ref floating when a remote is
unreachable was only equivalent to the old behaviour while no SHA-keyed
directory existed. Once one does, @main and @<commit> hash to different cache
keys, so the fallback pointed foundation at a directory that was never created
and sent it to clone -- exactly when the network is gone.

Fall back to the commit recorded in the local clone metadata instead, which
reproduces the key of the directory that is actually there.
@sadlilas Salil Das (sadlilas) changed the title fix: own the foundation module cache instead of amplifier-app-cli's fix: own the module cache, and refresh it by resolving refs instead of deleting clones Aug 24, 2026
@DavidKoleczek

Copy link
Copy Markdown
Collaborator

Review: amplifier-agent#142 (own the module cache, refresh by resolving refs)

Date: 2026-08-24
Branch reviewed: fix/own-foundation-cache-root at b8a4805, version 0.15.0
Companion PR: microsoft/amplifier-bundle-skills#61 (fix/skills-cache-honors-amplifier-home)
Contract under review: docs/spec/foundation-cache-ownership.md

Verdict

The central claim holds. amplifier-agent operates from ~/.amplifier-agent and
does not write into amplifier-app-cli's ~/.amplifier tree. I verified this
empirically with both applications installed side by side, not by inspection.

Two issues should be resolved before merge, one of which is pre-existing and
inherited rather than introduced here.

What was verified, and how

Ran the e2e suite inside a DTU with the skills#61 branch mirrored in, so the
container ran the companion fix rather than upstream tool-skills:

64 passed, 4 skipped, 1 error in 467.10s

The 4 skips are the vllm suite with no server configured. The 1 error is an
unrelated pre-existing defect in the test suites, not a regression from this
change.

Direct observation inside the container:

[ OK ] config home:          /root/.amplifier-agent/config
[ OK ] cache home:           /root/.amplifier-agent/cache
[ OK ] state home:           /root/.amplifier-agent/state
[ OK ] foundation isolation: /root/.amplifier-agent/foundation (<agent home>/foundation)
[ OK ] foundation home:      /root/.amplifier-agent/foundation
[ OK ] bundle cache: prepared (/root/.amplifier-agent/cache/prepared/0.15.0/fb14ee933ffc5cf3)

foundation_home   = /root/.amplifier-agent/foundation   (source: default)
module_cache_root = /root/.amplifier-agent/foundation/cache

The 86 MB module cache, every module clone, and provider rate-limit state all
live under ~/.amplifier-agent. With app-cli installed alongside and its own
tree populated with 28 real clone directories, a before/after comparison of
~/.amplifier across a full amplifier-agent workload came back byte-identical,
and app-cli still ran afterwards.

The companion fix was confirmed to be the code actually executing, read off the
installed module rather than inferred from the harness log:

/root/.amplifier-agent/foundation/cache/amplifier-bundle-skills-193d124ee586fd4a/
  modules/tool-skills/amplifier_module_tool_skills/sources.py
    line  24: def default_skills_cache_dir() -> Path:

With that in place the agent's remote skill clones land at
/root/.amplifier-agent/foundation/cache/skills.

Should be addressed before merge

1. A live read of app-cli's tree, not covered by the guarantee

src/amplifier_agent_cli/provider_sources.py:440
    token_file = Path("~/.amplifier/openai-chatgpt-oauth.json").expanduser()
    data = json.loads(token_file.read_text())

This is pre-existing, introduced by b1488f4 (PR #128, the openai-chatgpt
provider) and present on origin/main. It is not a regression from this PR.

It matters here because this PR is what establishes the guarantee, and the
guarantee is "never read or write app-cli's tree". This is the read half. It is
reachable from auth status and from provider credential resolution, so the
openai-chatgpt provider's credential state is still sourced from another
application's directory.

The spec's "Known residuals" section already does exactly the right thing for
the three foundation hardcodes it cannot fix. This belongs in that same table,
or it should be fixed. What it should not do is stay invisible: the current
document reads as though the only residuals are upstream foundation's, and this
one is ours.

2. Two bundle.md literals silently ignore AMPLIFIER_AGENT_HOME

bundle.md:184   session_dir: ~/.amplifier-agent/state/projects/{project}/recipe-sessions
bundle.md:256   base_path: "~/.amplifier-agent/state/workspaces"

amplifier_agent_home() honours $AMPLIFIER_AGENT_HOME, and config show
advertises the override by reporting amplifier_agent_home.source as
env:AMPLIFIER_AGENT_HOME. But these two values are literal strings in YAML
that nothing expands.

With the variable unset they agree with state_root() and everything works,
which is why this is invisible in normal use and in the DTU. Set it, and:

context-intelligence WRITER -> bundle.md literal -> ~/.amplifier-agent/state/workspaces
context-intelligence READER -> state_root()      -> $AMPLIFIER_AGENT_HOME/state/workspaces

That is the same writer/reader split root this PR just fixed, relocated one
level down. foundation_home.py:140-169 exists precisely because of that class
of bug, and its docstring quotes the hook's own runtime warning for it. Recipe
session state has the same shape.

Severity is lower than issue 1: this is not app-cli pollution, so the headline
guarantee is unaffected. But it defeats a documented override with no error, and
it constrains testing, since relocating the agent home is the obvious way to
make a test hermetic and would land straight in the divergence.

Suggested fix: resolve both through the same accessor as everything else. The
bundle already does this for the vendored skills directory by injecting an
absolute path at runtime, and the comment at bundle.md:156-160 describes that
mechanism.

Amplifier added 2 commits August 24, 2026 13:11
…li's tree

Both raised by @DavidKoleczek on #142.

1. provider_sources.py read ~/.amplifier/openai-chatgpt-oauth.json directly.
   Pre-existing (PR #128), but this PR is what establishes the never-read-app-cli
   guarantee, and that was the read half. The provider accepts a token_file_path
   config key (provider.py:103), the same seam already used for anthropic's
   rate-limit file, so this is fixable rather than merely documentable. Includes
   a copy-forward so an existing login is not silently invalidated into a
   device-code prompt; copies, never moves.

2. bundle.md declared session_dir and base_path as literal YAML strings that
   nothing expands, so both ignored AMPLIFIER_AGENT_HOME. With it unset they
   equalled state_root() and the divergence was invisible -- including to every
   test either of us ran. Set it and the context-intelligence writer and reader
   split apart again, one level below where foundation_home fixed it. Now
   injected at runtime from state_root(), the same technique the vendored
   skills/modes dirs already use.
@sadlilas
Salil Das (sadlilas) marked this pull request as ready for review August 24, 2026 20:24
@sadlilas
Salil Das (sadlilas) merged commit 083c79a into main Aug 24, 2026
4 checks passed
@sadlilas
Salil Das (sadlilas) deleted the fix/own-foundation-cache-root branch August 24, 2026 20:36
Salil Das (sadlilas) added a commit to microsoft/amplifier-app-opencode that referenced this pull request Aug 24, 2026
Raises MIN_AGENT_VERSION to 0.15.0; AGENT_PINNED_REF and AGENT_HARD_FLOOR follow.

0.15.0 is the first agent release that owns its own storage tree: it binds
AMPLIFIER_HOME to ~/.amplifier-agent/foundation before importing
amplifier_foundation, so module clones, provider rate-limit state and the
ChatGPT OAuth token stop landing in ~/.amplifier, a directory owned by
amplifier-app-cli and shared unnamespaced with every other Amplifier
application on the machine.

It also resolves floating @main module refs to concrete commits before
preparing the bundle, so an upstream module fix reaches an existing install.

Upstream: microsoft/amplifier-agent#142, released as v0.15.0.
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.

2 participants