fix(workspace): preserve basename links for non-colliding workspaces

Separate internal reserved names (bootstrap files, memory dir) from
taken-name tracking. Non-collision basenames are no longer forced through
the dedup path — only basenames that collide with another workspace or
with an internal name get parent-prefixed. This keeps symlink names
stable and predictable (e.g. /repos/api → api, not parent-api).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
voicewitness 2026-03-17 15:57:24 +08:00
parent e580331b92
commit e906f9d6b9

View File

@ -680,8 +680,8 @@ export async function ensureCompositeWorkspace(params: {
nameCount.set(base, (nameCount.get(base) ?? 0) + 1);
}
// Reserve internal workspace names (bootstrap files, memory dir) to prevent symlink collisions.
const reservedNames = new Set<string>([
// Internal workspace names (bootstrap files, memory dir) that must never be used as symlink names.
const internalNames = new Set<string>([
DEFAULT_AGENTS_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
@ -694,11 +694,13 @@ export async function ensureCompositeWorkspace(params: {
"memory",
WORKSPACE_STATE_DIRNAME,
]);
// Also collect all non-collision basenames so collision resolution can avoid them.
// Track all taken names so collision resolution avoids them. Seed with internal names
// and non-collision basenames (which keep their short names).
const takenNames = new Set<string>(internalNames);
for (const ws of workspacePaths) {
const base = path.basename(ws);
if ((nameCount.get(base) ?? 0) === 1) {
reservedNames.add(base);
if ((nameCount.get(base) ?? 0) === 1 && !internalNames.has(base)) {
takenNames.add(base);
}
}
@ -706,14 +708,15 @@ export async function ensureCompositeWorkspace(params: {
for (const ws of workspacePaths) {
const base = path.basename(ws);
let linkName: string;
const needsDedup = (nameCount.get(base) ?? 0) > 1 || reservedNames.has(base);
// Dedup when basename collides with another workspace or with an internal name.
const needsDedup = (nameCount.get(base) ?? 0) > 1 || internalNames.has(base);
if (needsDedup) {
const parentName = path.basename(path.dirname(ws));
const candidate = `${parentName}-${base}`;
let suffix = nameUsed.get(candidate) ?? 0;
linkName = suffix > 0 ? `${candidate}-${suffix}` : candidate;
// Ensure the resolved name doesn't collide with any reserved or already-used name.
while (reservedNames.has(linkName)) {
// Ensure the resolved name doesn't collide with any taken name.
while (takenNames.has(linkName)) {
suffix += 1;
linkName = `${candidate}-${suffix}`;
}
@ -721,7 +724,7 @@ export async function ensureCompositeWorkspace(params: {
} else {
linkName = base;
}
reservedNames.add(linkName);
takenNames.add(linkName);
linkEntries.push({ linkName, target: ws });
}