fix(resource-sync): prune removed bundled subdirectory extensions on upgrade (#1972)
* fix(resource-sync): prune removed bundled subdirectory extensions on upgrade
The managed-resources manifest and pruning system only tracked root-level
files, not subdirectory extensions. When a bundled subdirectory extension
like mcporter/ was removed from the bundle in a newer GSD version, the
previously-synced copy in ~/.gsd/agent/extensions/ persisted indefinitely,
causing tool name conflicts with its replacement (mcp-client/).
- Add installedExtensionDirs to the manifest alongside installedExtensionRootFiles,
recording directory names present in the bundled extensions dir at sync time.
- In pruneRemovedBundledExtensions, diff previous installedExtensionDirs against
current bundled dirs and rmSync({ recursive: true }) any that were removed.
- Add mcporter to the hardcoded stale-entry list for pre-manifest upgrades.
- Fix extension conflict error prefix: also match "conflicts with" (not just
"supersedes") so extension-vs-extension conflicts are classified as warnings
rather than hard errors.
Fixes #1955
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(resource-loader): repair mangled lines from conflict resolution
The Python regex used to resolve cherry-pick conflicts stripped trailing
newlines, causing declarations and comments to merge onto the same line.
Replace the file with the upstream/main version which contains all the
installedExtensionDirs logic correctly formatted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(resource-loader): sweep all installed extension dirs not in current bundle
The manifest-based pruner only removed dirs it had previously recorded.
Extensions installed by pre-manifest versions (or manually) were never
tracked, so they survived upgrades. Add a sweep of the actual installed
extensions directory that removes any subdirectory absent from the current
bundle, regardless of manifest history.
Fixes the mcporter stale-dir regression test (#1972).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: check external-state DB path before symlink-resolved handler (#2952)
The external-state handler added in c609d813 was placed after the generic
symlink-resolved handler, which matches the same /.gsd/projects/<hash>/worktrees/
pattern and short-circuits to the wrong result. Move the external-state check
(which uses the more specific hex-hash regex) first so it takes precedence.
Fixes shared-wal test: external-state worktree path resolves to project state DB.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: update db-path-worktree-symlink expectations for external-state (#2952)
/.gsd/projects/<hash>/worktrees/ paths now resolve to <hash>/gsd.db
after the external-state handler from #2952 was placed before the
symlink-resolved handler. On POSIX, getcwd() returns canonical paths so
<proj>/.gsd/projects/<hash>/worktrees/ would in practice appear as
~/.gsd/projects/<hash>/worktrees/ after OS symlink resolution — both
correctly handled by the external-state behavior.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: trek-e <trek-e@users.noreply.github.com>
This commit is contained in:
parent
29517f177d
commit
e9dabdc649
3 changed files with 60 additions and 4 deletions
|
|
@ -470,8 +470,8 @@ if (isPrintMode) {
|
|||
if (extensionsResult.errors.length > 0) {
|
||||
for (const err of extensionsResult.errors) {
|
||||
// Downgrade conflicts with built-in tools to warnings (#1347)
|
||||
const isSuperseded = err.error.includes("supersedes");
|
||||
const prefix = isSuperseded ? "Extension conflict" : "Extension load error";
|
||||
const isConflict = err.error.includes("supersedes") || err.error.includes("conflicts with");
|
||||
const prefix = isConflict ? "Extension conflict" : "Extension load error";
|
||||
process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`)
|
||||
}
|
||||
}
|
||||
|
|
@ -622,8 +622,8 @@ validateConfiguredModel(modelRegistry, settingsManager)
|
|||
|
||||
if (extensionsResult.errors.length > 0) {
|
||||
for (const err of extensionsResult.errors) {
|
||||
const isSuperseded = err.error.includes("supersedes");
|
||||
const prefix = isSuperseded ? "Extension conflict" : "Extension load error";
|
||||
const isConflict = err.error.includes("supersedes") || err.error.includes("conflicts with");
|
||||
const prefix = isConflict ? "Extension conflict" : "Extension load error";
|
||||
process.stderr.write(`[gsd] ${prefix}: ${err.error}\n`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,6 +373,16 @@ function pruneRemovedBundledExtensions(
|
|||
}
|
||||
}
|
||||
|
||||
// Sweep-based: also remove any installed extension subdirectory not in the current bundle,
|
||||
// even if it was never tracked in the manifest (e.g. installed by a pre-manifest version).
|
||||
try {
|
||||
if (existsSync(extensionsDir)) {
|
||||
for (const e of readdirSync(extensionsDir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) removeDirIfStale(e.name)
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
// Always remove known stale files regardless of manifest state.
|
||||
// These were installed by pre-manifest versions so they may not appear in
|
||||
// installedExtensionRootFiles even when a manifest exists.
|
||||
|
|
|
|||
|
|
@ -160,3 +160,49 @@ test("initResources prunes stale top-level extension siblings next to bundled co
|
|||
assert.equal(existsSync(staleSiblingPath), false, "stale top-level sibling should be removed during sync");
|
||||
assert.equal(existsSync(bundledPath), true, "bundled extension should remain after cleanup");
|
||||
});
|
||||
|
||||
test("pruneRemovedBundledExtensions removes stale subdirectory extensions not in current bundle", async () => {
|
||||
const { initResources } = await import("../resource-loader.ts");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-prune-dirs-"));
|
||||
const fakeAgentDir = join(tmp, "agent");
|
||||
|
||||
try {
|
||||
// First sync — seeds the agent dir and writes the manifest.
|
||||
initResources(fakeAgentDir);
|
||||
|
||||
// Simulate a stale subdirectory extension left from a previous GSD version.
|
||||
// This mirrors the mcporter scenario: it was bundled before, synced to
|
||||
// ~/.gsd/agent/extensions/, then removed from the bundle in a newer version.
|
||||
const staleExtDir = join(fakeAgentDir, "extensions", "mcporter");
|
||||
mkdirSync(staleExtDir, { recursive: true });
|
||||
writeFileSync(join(staleExtDir, "index.ts"), 'export default { name: "mcporter" };\n');
|
||||
assert.equal(existsSync(staleExtDir), true, "stale subdir extension should exist before prune");
|
||||
|
||||
// Read the manifest to verify subdirectory extensions are tracked.
|
||||
const manifestPath = join(fakeAgentDir, "managed-resources.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
|
||||
// The manifest must record installed extension directories so the pruner
|
||||
// can detect when one has been removed from the bundle.
|
||||
assert.ok(
|
||||
Array.isArray(manifest.installedExtensionDirs),
|
||||
"manifest should contain installedExtensionDirs array",
|
||||
);
|
||||
|
||||
// Bump the manifest version to force a re-sync (simulates upgrading GSD).
|
||||
manifest.gsdVersion = "0.0.0-force-resync";
|
||||
manifest.contentHash = "0000000000000000";
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
|
||||
// Second sync — the bundle no longer contains mcporter/, so it must be pruned.
|
||||
initResources(fakeAgentDir);
|
||||
|
||||
assert.equal(
|
||||
existsSync(staleExtDir),
|
||||
false,
|
||||
"stale subdirectory extension (mcporter/) should be pruned after upgrade",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue