fix(push,pull): recanonicalize stale UUID-suffixed state keys — root-cause duplicate generation#32
Merged
Merged
Conversation
Generic, resource-type-agnostic pass that collapses engine-generated
`<base>-<uuid8>` state keys back to canonical `<base>` when the
underlying name-collision has resolved. Root-cause fix for the
recurring duplicate-generation pattern that 10+ prior symptom-fixes
had addressed.
Pull rewrites state keys to UUID-suffixed form when name-collision
adoption is refused (src/pull.ts:findExistingResourceId), but never
undoes that rekey when the conflict resolves. Next push sees the
canonical-slug local file as orphan → bypasses the orphan-YAML gate
with --allow-new-files → silently creates a third dashboard duplicate.
Safety preconditions (every rekey must satisfy ALL 5):
1. Key matches `^(.+)-([0-9a-f]{8})$` (engine-generated shape)
2. Captured suffix matches the entry's UUID prefix
3. Canonical slug is unclaimed in state
4. Local file exists at canonical slug (any VALID_EXTENSIONS shape)
5. NO local file at UUID-suffixed slug (avoid silent data loss)
Same-UUID self-aliases auto-resolve. Different-UUID twins are refused
with a clear conflict reason. `touched` is plumbed through so scoped
pushes flush the rename via `mergeScoped`.
Runs at end of pull (gated on `!bootstrap && !resourceIds`, honors
typeFilter) and start of push after maybeBootstrapState, before the
orphan-YAML gate.
VALID_EXTENSIONS is imported from src/resources.ts so precondition 5
stays in lockstep with the loader (.yml/.yaml/.ts/.md).
Tests: 208/208 pass (17 new cases covering happy path, every-type
uniformity, all 5 preconditions, multi-segment slugs, .ts extension
support, H1 mergeScoped regression, H2 same-UUID auto-resolve,
credentials safety, typeFilter wiring, formatter).
This was referenced May 15, 2026
This was referenced May 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Generic, resource-type-agnostic state-key recanonicalization pass that root-causes the recurring duplicate-generation pattern. 10+ prior PRs (#23 tool dedup, #30 orphan-YAML gate, etc.) addressed this symptomatically; this fixes the underlying state-file asymmetry.
The bug class. Pull rewrites state keys to UUID-suffixed form (
<base>-<uuid8>) when name-collision adoption is refused — that's intentional. But nothing ever undoes that rekey when the conflict resolves (e.g. the conflicting dashboard twin is deleted). Next push sees the canonical-slug local file as an orphan-YAML, the operator passes--allow-new-filesto bypass the gate, and the engine silently creates a third dashboard duplicate. The cycle repeats indefinitely.The fix. A single generic pass walks every section of
StateFileuniformly, with 5 safety preconditions for each rekey:^(.+)-([0-9a-f]{8})$VALID_EXTENSIONSshape (.yml/.yaml/.ts/.md)Same-UUID self-aliases auto-resolve (the redundant entry is dropped). Different-UUID twins are refused with
canonical-slug-claimed-by-different-uuid. Theboth-local-files-existcase is surfaced for manual resolution — we never silently pick a winner.Where it runs.
runPull, gated on!bootstrap && !resourceIds, honorstypeFilterrunPushaftermaybeBootstrapState, before the orphan-YAML gatetouchedis plumbed through on the push side so scoped pushes flush the rename viamergeScoped(H1 from the in-branch code review)Why it's resource-type-agnostic. Iterates
VALID_RESOURCE_TYPESand usesFOLDER_MAP+VALID_EXTENSIONSfromsrc/resources.ts. Adding a newResourceTyperequires zero changes to this file.Files changed
src/recanonicalize.tstests/recanonicalize.test.tssrc/pull.tsrunPullsrc/push.tstouchedsrc/resources.tsVALID_EXTENSIONS(was private) so precondition 5 stays in lockstep with the loaderTest plan
npm run build(tsc --noEmit) — cleannpm test— 208/208 pass (191 prior + 17 new)npx @biomejs/biome check --write— cleanmy-tool-deadbeef), 3 (different-UUID twin), 4 (canonical local file missing), 5 (both files exist — allVALID_EXTENSIONS)touchedplumbing exercised throughmergeScopedend-to-endstate.credentialsnever touched (not inVALID_RESOURCE_TYPES)iform-voicemail-triage-squad-llm-only-vmd-004c5108shape from the 2026-05-14 incident.tsextension regression guard (post-merge review surfaced this gap before merge)Code review
Two rounds of in-branch review:
Round 1 (pre-push). Code-reviewer surfaced:
mergeScopedbecausetouchedwasn't plumbed. Fixed by addingtouchedparameter and marking both old + new keys. Regression test added.--bootstrapskips it).typeFilternow restricts the pass to refreshed types on scoped pulls.Round 2 (post-merge audit). Reviewer surfaced:
RESOURCE_EXTENSIONSwas a 3-entry local constant, omitting.ts. Fixed by importingVALID_EXTENSIONSfromsrc/resources.ts— the loader's canonical list — so any future loader extension is automatically respected. Two regression tests added (precondition 4 + precondition 5 on.tspairs).Related
iform-voicemail-triage-squad-llm-onlytriplication chain