Skip to content

fix(updates): check_bundle_status walks includes instead of dropping them - #361

Merged
Brian Krabach (bkrabach) merged 2 commits into
mainfrom
lane/fd-72y
Sep 6, 2026
Merged

fix(updates): check_bundle_status walks includes instead of dropping them#361
Brian Krabach (bkrabach) merged 2 commits into
mainfrom
lane/fd-72y

Conversation

@bkrabach

@bkrabach Brian Krabach (bkrabach) commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Defect

check_bundle_status() — a public foundation mechanism every host inherits, not just the CLI — reported All N source(s) up to date over a cache stuck at an old commit.

Measured on a real machine (2026-09-06): ~/.amplifier/cache/amplifier-d1dda27a16518560 (git_url https://github.com/microsoft/amplifier, ref main) sat at c03a88b while upstream main was 28588b9. That cache is reached only through amplifier-foundation's bundle.md include git+https://github.com/microsoft/amplifier@main#subdirectory=behaviors/amplifier-expert.yaml.

Root cause

amplifier_foundation/updates/__init__.py:138-140 (inside _collect_source_uris()):

# Note: Included bundles are now registered as first-class bundles
# and will be checked independently by _check_all_bundle_status().
# No need to collect their URIs here.

The premise is false in exactly the case that matters:

  1. An include is registered (registry._load_single(..., auto_register=True)), but a #subdirectory= include lands with is_root: false (amplifier_foundation/registry.py:520-545), and host enumeration that keeps only is_root: true entries (e.g. amplifier_app_cli/lib/bundle_loader/discovery.py:679-685) never reaches it. A repo whose only registry presence is a non-root sub-bundle entry is checked by nobody.
  2. It compounds: GitSourceHandler.resolve() (amplifier_foundation/sources/git.py:697-709) returns an existing cache verbatim on a hit — no TTL, no fetch, ever. A source the update path does not enumerate is a source that never moves.

A second, non-obvious root cause found while fixing this

Walking the in-memory bundle.includes — the literal reading of the issue — does not work. Bundle.compose() (amplifier_foundation/bundle/_dataclass.py:185) keeps only includes=list(self.includes), and _compose_includes() returns composed_includes.compose(bundle). So after load_bundle() composes a bundle, the returned object's .includes is the first included bundle's list, not its own. Verified live below: foundation's post-compose bundle.includes == [].

The walk therefore seeds from bundle._source_uri and re-reads that bundle file from disk (falling back to bundle.includes only for a hand-constructed, never-loaded Bundle).

Change

File What
amplifier_foundation/updates/__init__.py New collect_transitive_source_uris() — cycle-safe BFS over includes:, reusing BundleRegistry._parse_include / _resolve_include_source / _load_from_path and GitSourceHandler._get_cache_path. New _cached_path_for() reads an already-cached bundle without resolving (resolving downloads — a deleted cache must not look healthy). check_bundle_status() gains keyword-only registry and include_transitive=True. Stale comment replaced with what is actually true.
amplifier_foundation/sources/protocol.py SourceStatus.via: str | None = None — the including bundle's name; None for direct sources. Appended last with a default, so every existing positional/keyword construction is byte-compatible.
tests/test_transitive_include_status_72y.py 5 new tests (below).

Behavioural consequences:

  • Each transitive git source appears as a SourceStatus with cached vs remote commit, has_update, and via.
  • is_pinned refs (@<sha> / @v1.2.3) are reported pinned, never updateable (GitSourceHandler.get_status already short-circuits; the walk just stops hiding them).
  • status.summary can no longer say All N source(s) up to date while any transitive source has_update — transitive rows are in status.sources, so updateable_sources counts them.
  • update_bundle() consequently now refreshes stale transitive caches too, which is the actual remedy for the measured staleness.

What app-cli would delete

amplifier-app-cli PR #317 (merged c120a36) added amplifier_app_cli/utils/include_graph.py to work around this app-side. Semantics here were matched to it deliberately (fragment-stripped URI as the update-target identity; visited-set keyed on the full URI including fragment; read-cache-never-resolve; reuse of the loader's own resolution). With this merged, app-cli can collapse:

  • amplifier_app_cli/utils/include_graph.pycollect_transitive_git_sources(), check_transitive_sources(), transitive_statuses_for(), _local_path_for(), strip_uri_fragment(), TransitiveSource, TransitiveStatus → all superseded by collect_transitive_source_uris() + SourceStatus.via.
  • Its call sites in amplifier update / amplifier bundle update become plain reads of check_bundle_status().sources (filter on via is not None for the "reached via an include" rows).
  • refresh_transitive_source() can stay or become update_bundle(selective=[...]) — it is a thin GitSourceHandler.update() wrapper either way.

One deliberate difference: app-cli's TransitiveSource carries both parent (immediate includer) and root (the registered row it groups under, for table rendering). SourceStatus.via is the honest immediate includer only — grouping is a presentation concern for the host, not a property of the source.

Gates

Gate Result
CI: 6-job pytest matrix (ubuntu/windows x py3.11/3.12/3.13) 6/6 pass
CI: license/cla pass
uv run pytest tests/ -q locally 1793 passed, 1 skipped, 1 failed — the single failure, tests/test_sources.py::TestFileSourceHandler::test_resolve_existing_file, is pre-existing on this host at base 2ef5e12 (verified by stashing all of my changes and re-running on a pristine tree: assert PosixPath('/tmp') == PosixPath('/tmp/tmpXXXX') — a /tmp resolution quirk of this machine, in FileSourceHandler, untouched here). It passes in CI on all six legs, confirming it is host-local and not a regression.
New tests tests/test_transitive_include_status_72y.py6 passed
ruff format --check on changed files clean
ruff check on changed files clean, except one pre-existing F401 (ParsedURI imported but unused) on an untouched import line in updates/__init__.py — left alone deliberately, since it is also a public re-export (amplifier_foundation.updates.ParsedURI) and removing it is an unrelated surface change
CI 6-job pytest matrix + license/cla — see checks below

New tests

  1. test_non_root_include_with_stale_cache_is_reported — fixture repo C (root bundle name deliberately collides with an already-registered name, so C's repo gets no root entry; sub-bundle behaviors/child.yaml registers is_root=False), cache pre-seeded at commit one, fake remote advanced to commit two. Asserts: no root entry for that repo; exactly one transitive row; cached_commit == first, remote_commit == second, has_update is True, via == "bee"; "source(s) up to date" not in summary. Ends with an in-place regression guard — the same bundle through include_transitive=False reproduces the old green lie (has_updates is False, no row).
  2. test_pinned_include_is_reported_pinned_not_updateable@<sha> include → is_pinned is True, has_update is False, status.has_updates is False.
  3. test_include_cycle_terminates — B includes C, C includes B, both cached git repos. Terminates, returns {uri_c: "bee"}.
  4. test_deleted_cache_is_not_resolved_back_into_existence — a missing cache yields None and stays missing; the walk never clones one back into a green row.
  5. test_cached_path_for_round_trips_a_file_uriPath.as_uri() round-trips, including the Windows file:///C:/... form (see below).
  6. test_direct_sources_carry_no_viavia stays None for directly-declared sources.

Second commit: a Windows bug the first commit shipped

The first push failed all three Windows CI legs — every transitive source vanished there. Cause: Path.as_uri() emits file:///C:/Users/x on Windows, whose path component parses to /C:/Users/x — rooted but driveless. Passed to Path() unnormalized it resolves against whatever the current drive happens to be, so the seed bundle file "does not exist", _cached_path_for() returns None, and the walk finds nothing at all — silently, with a green summary. Exactly the class of failure this PR exists to remove.

Fixed by normalizing through the same shared helper FileSourceHandler.resolve() already uses (strip_uri_drive_prefix), and honouring #subdirectory= for file URIs the way that handler does. Guarded by test 5 above.

Note for the app-side collapse: amplifier-app-cli's include_graph.py _local_path_for() has this same latent bug in its file:// branch — worth fixing there if the collapse does not happen promptly.

LIVE proof

Isolated AMPLIFIER_HOME (a copy — the real ~/.amplifier was read, never written; verified untouched afterwards), containing a copy of the 22 caches foundation's includes reach. In that copy: git -C cache/amplifier-d1dda27a16518560 reset --hard c03a88b plus .amplifier_cache_meta.json commit corrected to match, and the is_root: true registry entry for microsoft/amplifier removed — reproducing "reachable only as a non-root include".

Then check_bundle_status() on the foundation bundle loaded from this worktree (PYTHONPATH), with no explicit cache_dir or registry (zero-config path):

AMPLIFIER_HOME = .../lanes/fd-72y/iso-home
ROOT registry entries for microsoft/amplifier: [] (empty => checked by nobody, pre-fix)
loaded bundle  = foundation | _source_uri = file:///.../amplifier-foundation/bundle.md
post-compose bundle.includes[0:2] = []   <-- NOT foundation's own includes; why the walk seeds from _source_uri

=== check_bundle_status(bundle)  [default cache_dir + registry] ===
summary: 11 update(s) available (28 up to date, 2 unknown)

--- the row that did not exist before this change ---
{
  "source_uri": "git+https://github.com/microsoft/amplifier@main",
  "via": "foundation",
  "is_cached": true,
  "cached_commit": "c03a88ba9d3e76bf7bb1509770bfc5d779e03b75",
  "remote_commit": "28588b93886dd4b134f131294ca98e944d71cbfd",
  "has_update": true,
  "is_pinned": false,
  "summary": "Update available (c03a88ba \u2192 28588b93)"
}

--- pre-fix behaviour on the SAME bundle (include_transitive=False) ---
summary: 10 update(s) available (18 up to date, 2 unknown) | has_updates: True | rows for microsoft/amplifier: []

--- all transitive rows (11) ---
  ok      via=foundation                   git+https://github.com/microsoft/amplifier-bundle-amplifier-tester@main
  ok      via=foundation                   git+https://github.com/microsoft/amplifier-bundle-browser-tester@main
  ok      via=foundation                   git+https://github.com/microsoft/amplifier-bundle-design-intelligence@main
  ok      via=amplifier-tester-behavior    git+https://github.com/microsoft/amplifier-bundle-digital-twin-universe@main
  ok      via=foundation                   git+https://github.com/microsoft/amplifier-bundle-evaluation@main
  ok      via=foundation                   git+https://github.com/microsoft/amplifier-bundle-filesystem@main
  ok      via=digital-twin-universe-behavior git+https://github.com/microsoft/amplifier-bundle-gitea@main
  ok      via=foundation                   git+https://github.com/microsoft/amplifier-bundle-llm-wiki@main
  ok      via=foundation                   git+https://github.com/microsoft/amplifier-bundle-superpowers@main
  ok      via=foundation                   git+https://github.com/microsoft/amplifier-core@main
  UPDATE  via=foundation                   git+https://github.com/microsoft/amplifier@main

The measured defect, reproduced and then fixed: microsoft/amplifier had no row at all pre-fix, and post-fix reports c03a88b -> 28588b9, has_update: true, via: foundation. The depth-2 rows (via=amplifier-tester-behavior, via=digital-twin-universe-behavior) show the walk is genuinely transitive, not one-level.

Honest caveat on this live run: the isolated home's direct sources were themselves stale, so the literal string All N source(s) up to date does not appear in either summary here — the pre-fix summary already said 10 update(s) available. What the live run proves is the missing row; the exact All N source(s) up to date regression is covered by assertion in test 1 ("source(s) up to date" not in status.summary).

Closes work item recipes-72y.

Generated with Amplifier

Co-Authored-By: Amplifier 240397093+microsoft-amplifier@users.noreply.github.com

…them

check_bundle_status() reported "All N source(s) up to date" over a cache
stuck at an old commit. _collect_source_uris() dropped bundle.includes on
the premise -- stated in a comment at updates/__init__.py:138-140 -- that
included bundles "are registered as first-class bundles and will be checked
independently". They are registered, but a `#subdirectory=` include lands
with is_root=False, and host enumeration that keeps only root entries never
reaches it. A repo whose only registry presence is a non-root sub-bundle
entry was therefore checked by nobody, and GitSourceHandler.resolve()
returns an existing cache verbatim with no fetch -- so a source the update
path does not enumerate is a source that never moves.

- collect_transitive_source_uris(): cycle-safe BFS over includes, reusing
  BundleRegistry._parse_include / _resolve_include_source / _load_from_path
  for resolution and GitSourceHandler._get_cache_path to read an
  already-cached bundle WITHOUT resolving (resolving downloads, which would
  make a deleted cache look healthy).
- check_bundle_status() reports each transitive git source as a SourceStatus
  with cached vs remote commit, has_update, and a new `via` field naming the
  including bundle. Pinned refs (@sha / @tag) stay pinned, never updateable.
  summary therefore can no longer claim "All N source(s) up to date" while a
  transitive source has an update.
- SourceStatus.via appended last with a default, so every existing
  construction is unchanged.
- Stale comment replaced with what is actually true.

The walk seeds from the bundle's _source_uri and re-reads that file from
disk rather than trusting the in-memory bundle.includes: Bundle.compose()
keeps only self.includes, so after load_bundle() composes, the returned
bundle's .includes is the FIRST INCLUDED bundle's list, not its own.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…s on Windows

CI's three Windows legs failed: every transitive source vanished. A
`file:///C:/Users/x` seed parses to the path component "/C:/Users/x" --
rooted but driveless. Passed to Path() unnormalized it resolves against
whatever the current drive happens to be, so the seed bundle file "does not
exist", _cached_path_for() returns None, and the walk finds nothing at all.

Normalized through the same shared helper FileSourceHandler.resolve() uses
(strip_uri_drive_prefix), and #subdirectory= is now honoured for file URIs
the same way that handler honours it. Adds a round-trip test over
Path.as_uri() -- passes trivially on POSIX, and is a real guard on the
Windows legs.

Note for the app-side collapse: amplifier-app-cli's include_graph.py
_local_path_for() has this same latent bug in its file:// branch.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@bkrabach
Brian Krabach (bkrabach) merged commit 5bc2ed1 into main Sep 6, 2026
7 checks passed
@bkrabach
Brian Krabach (bkrabach) deleted the lane/fd-72y branch September 6, 2026 18:27
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