Skip to main content
← Back to list
01Issue
BugTriagedSwamp CLITeam
Assigneesstack72

Relationships

Blocked

#1419 swamp doctor extensions: nondeterministic BundleBuildFailed for local extensions on an unchanged tree

Opened by magistr · 7/26/2026

Three consecutive swamp doctor extensions runs against an unchanged tree return exit 1, 0, 1.

Repro

In a repo with a local extension source that uses export const extension to add methods to a pulled model type:

for i in 1 2 3; do swamp doctor extensions >/dev/null 2>&1; echo "run $i: exit $?"; done
# run 1: exit 1
# run 2: exit 0
# run 3: exit 1

Observed

Failing runs report, for each export const extension source:

<path>.ts   BundleBuildFailed: Method "<name>" already exists on model type "<pulled type>"
<collective>  Indexed: 3 | BundleBuildFailed: 2
2 orphan bundle file(s)
3 passed, 1 failed — OVERALL: FAIL

The passing run logs Extension catalog updated: 2 "entries" repaired and reports all sources Indexed / OVERALL: PASS.

Why the message looks misleading

The pulled upstream does not declare the method names in question — grepping the pulled sources for each colliding name returns zero hits. So it does not appear to be a collision with upstream; the local extension seems to be colliding with itself, having already merged into the persisted catalog. In each case the colliding name is the first method in that extension's map, which reads like double-registration. Only the export const extension sources flap; export const model sources index on every run.

Runtime appears unaffected

swamp model type describe <type> --compact returns exit 0 with byte-identical output across consecutive runs, listing every method the extension contributes. So the resolved type surface is correct; only the indexing/bundling path races.

Impact

swamp doctor extensions is a natural deploy precondition — a bring-up step that runs it first and refuses to start on a load error will block at random. It also cannot be usefully baselined: an allowlist of "expect these N failures" cannot distinguish the expected N from a different N.

Workaround

Assert the property doctor is a proxy for — that the contributed methods resolve on their types via swamp model type describe — which is stable across runs.

Environment

swamp 20260725.011748.0-sha.bb2cb3ef, macOS. ~100 sources across 9 extensions; the affected local collective has 5 sources (3 export const model, 2 export const extension).

02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 2 MOREREVIEW

Triaged

7/26/2026, 10:21:41 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack727/26/2026, 9:50:29 PM
stack72 marked as blocked7/26/2026, 11:13:51 PM
Editable. Press Enter to edit.

stack72 commented 7/26/2026, 11:14:26 PM

Hey @magistr — thanks for the detailed write-up, especially the grep verification that the colliding methods aren't in the pulled upstreams and the observation that only the first method in each extension's map collides (signature of double-registration). That narrowed the investigation significantly.

I spent a good chunk of time tracing through the extension loading paths and built a reproduction scenario to try to trigger the flap:

What I tested:

  • 191 total sources across 12 extensions (exceeding your 106 across 9)
  • Pulled the exact extensions from your report: @magistr/firecracker, @evrardjp/openbao-configurator, plus 9 others from @swamp/aws/* and @webframp/extension-maintenance
  • 9 local sources: 3 export const model + 6 export const extension targeting pulled types
  • Local extensions with transitive Zod dependencies via a shared _lib/ module

Variations tried (all passed consistently):

  • 20 consecutive runs (cold + warm starts)
  • 15 runs specifically with @magistr/firecracker and @evrardjp/openbao-configurator as targets
  • Deleting the catalog DB between sets to force cold starts
  • Deleting local bundle cache to force fresh bundling
  • Touching/modifying shared lib between runs to force fingerprint changes and rebundling
  • Parallel concurrent doctor invocations

None of these reproduced the alternating exit 1, 0, 1 pattern. All runs passed.

What I found in the code: The invalidateAll() → reconcile → buildIndex() flow does have an asymmetry: when reconcile finds 0 transitions (all sources already Indexed), it doesn't re-set the populated flags that invalidateAll() cleared, which pushes buildIndex into the cold path. But in my testing the cold path's processSecondaryExport never hit a collision — the model types were freshly registered with only their base methods.

Could you help narrow this down?

  1. Can you capture swamp doctor extensions --verbose --json from a failing run? The per-source detail and catalog state would show exactly which code path wrote the BundleBuildFailed.
  2. Does your repo have a deno.json or package.json at the root? Those can affect how Deno resolves imports during bundling, which could make the bundle output nondeterministic.
  3. Is the repo on a network filesystem, Docker volume, or case-insensitive mount?
  4. Does running swamp doctor extensions --repair once break the cycle permanently? If so, it points to corrupt catalog state carried forward from a prior swamp version rather than a live code path bug.

magistr commented 7/27/2026, 8:05:07 AM

Thanks for digging in that far — the invalidateAll() → reconcile → buildIndex() asymmetry looks right, and I think I can now give you the missing half of the repro. Three new facts first, then your four questions in order.

1. It is not nondeterministic — it strictly ALTERNATES

My original report said "1, 0, 1" and undersold it. Eight consecutive runs, unchanged tree, current build:

run 1: exit 1
run 2: exit 0
run 3: exit 1
run 4: exit 0
run 5: exit 1
run 6: exit 0
run 7: exit 1
run 8: exit 0

A clean 2-cycle, not a race. That matches your populated-flag finding exactly, and I think the loop closes like this:

  • a FAILING run leaves the catalog in a state the next run repairs — the PASSING run logs Extension catalog updated: 2 "entries" repaired, which is the same line --repair emits;
  • that repair leaves every source Indexed;
  • so the next run's reconcile finds 0 transitions, doesn't re-set the populated flags invalidateAll() cleared, takes the cold path, and collides;
  • fail → repair → all-Indexed → 0 transitions → cold path → fail.

If that is right, the flap is not timing-dependent at all and a repro needs only to enter the failing half once. A 20-run loop that never entered it would pass 20/20, which may be what happened on your side.

One possible differentiator: both of my colliding local sources are export const extension targeting a single-source pulled aggregate@magistr/firecracker has sourceCount: 1, @evrardjp/openbao-configurator has sourceCount: 1. Your reproduction spread 6 extension sources across 11 extensions including @swamp/aws/* (many sources each). If the cold path re-registers a whole aggregate before merging secondary exports, a 1-source aggregate would be the degenerate case that double-registers.

2. --repair does NOT break the cycle

Answering your Q4 directly, because it rules out your "corrupt state from a prior version" theory:

swamp doctor extensions --repair -y
# Extension catalog updated: 2 "entries" repaired
# Repair APPLIED   (exit 0)

# then, immediately:
run 1: exit 1
run 2: exit 0
run 3: exit 1
run 4: exit 0
run 5: exit 1
run 6: exit 0

The repair applies cleanly and the very next run fails again, with the alternation resuming in phase. This is a live code path, not carried-forward corruption. Note also that --repair itself behaves as one "pass" step of the cycle — same repair line, same effect on the next run.

3. Still reproduces on a newer binary

Filed against 20260725.011748.0-sha.bb2cb3ef; the above is all on 20260727.000815.0-sha.db74fcac, unchanged.


Your four questions

Q1 — --verbose --json from a failing run. Captured, but it does not carry what you need, which is worth a separate small bug. On a failing run (overallStatus: "fail") the registry object is only:

{ "registry": "model", "status": "fail" }

No per-source detail, no error strings, and "orphanFiles": [] even though the human-readable run in the same phase reports 2 orphan bundle file(s). The text --verbose does have it:

extension .../extensions/models/marshland-myr.ts   BundleBuildFailed: Error: Method 'build_myr_rootfs' already exists on model type '@magistr/firecracker'      fp:b6adc7fb4106
extension .../extensions/models/mosse-admin.ts     BundleBuildFailed: Error: Method 'ensure_tenant_self_policy' already exists on model type '@evrardjp/openbao-configurator'  fp:e1a5cd8f3ce0
  0 orphan row(s), 2 orphan bundle file(s)
  @local/marshland   Indexed: 3 | BundleBuildFailed: 2
3 passed, 1 failed — OVERALL: FAIL

Both colliding names are still the FIRST method in their extension's map. Happy to attach the full JSON if useful, but the per-source array simply is not in it.

Q2 — root deno.json / package.json. No package.json, ever. There IS a deno.json now, but I added it after filing this issue, and the flap is identical on both sides of that change: the original 1,0,1 was observed with no deno.json in the repo at all, and the 8-run alternation above was observed with one present. So a root Deno config is not the trigger.

Q3 — filesystem. Local APFS (/dev/disk1s1), not a network mount, not a Docker volume. It IS case-insensitive — verified live (touch .casetest_ABC then ls .casetest_abc resolves). If bundle or catalog keys are ever compared case-sensitively while the filesystem folds case, that would be a plausible source of a double-registration.

Q4 — --repair. Covered above: it does not break the cycle.

Workaround still stands

swamp model type describe <type> --compact remains stable across runs and lists every contributed method, so asserting the property doctor proxies for — that the contributed methods resolve on their types — is reliable where doctor is not. I have that committed as a deploy precondition now: 11 extension-contributed methods across the two pulled types, plus all three local model types, anchored on the method separator rather than a bare substring.

Sign in to post a ripple.