From bdd3fe88992ed179119f7270876cbaec286f395b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sat, 7 Feb 2026 15:55:35 +0000 Subject: [PATCH] fix(worktrees): prune stale worktree mappings Fall back to root when a mapped worktree slug is missing and persistently remove missing slugs from the worktree map to prevent proxy 404s. --- packages/ui/src/stores/worktrees.ts | 99 +++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 4aef984c..cc541ddc 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -45,6 +45,11 @@ async function ensureWorktreesLoaded(instanceId: string): Promise { next.set(instanceId, typeof response.isGitRepo === "boolean" ? response.isGitRepo : null) return next }) + + // If we already loaded a worktree mapping, drop stale slugs. + if (worktreeMapByInstance().has(instanceId)) { + void pruneWorktreeMap(instanceId).catch(() => undefined) + } }) .catch((error) => { log.warn("Failed to load worktrees", { instanceId, error }) @@ -86,6 +91,10 @@ async function reloadWorktrees(instanceId: string): Promise { next.set(instanceId, typeof response.isGitRepo === "boolean" ? response.isGitRepo : null) return next }) + + if (worktreeMapByInstance().has(instanceId)) { + void pruneWorktreeMap(instanceId).catch(() => undefined) + } }) .catch((error) => { log.warn("Failed to reload worktrees", { instanceId, error }) @@ -132,6 +141,11 @@ async function ensureWorktreeMapLoaded(instanceId: string): Promise { next.set(instanceId, normalizeMap(map)) return next }) + + // If worktrees are already loaded, prune any mappings that reference missing worktrees. + if (worktreesByInstance().has(instanceId)) { + void pruneWorktreeMap(instanceId).catch(() => undefined) + } }) .catch((error) => { log.warn("Failed to load worktree map", { instanceId, error }) @@ -173,14 +187,50 @@ function getWorktreeMap(instanceId: string): WorktreeMap { return worktreeMapByInstance().get(instanceId) ?? normalizeMap(null) } -function getDefaultWorktreeSlug(instanceId: string): string { - return getWorktreeMap(instanceId).defaultWorktreeSlug || "root" +function isWorktreeSlugAvailable(instanceId: string, slug: string): boolean { + const normalized = (slug ?? "").trim() || "root" + if (normalized === "root") return true + + const list = getWorktrees(instanceId) + // If worktrees aren't loaded yet, don't force root incorrectly. + if (list.length === 0) return true + return list.some((wt) => wt.slug === normalized) } -async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise { - await ensureWorktreeMapLoaded(instanceId) +function normalizeWorktreeSlug(instanceId: string, slug: string): string { + const normalized = (slug ?? "").trim() || "root" + if (normalized === "root") return "root" + return isWorktreeSlugAvailable(instanceId, normalized) ? normalized : "root" +} + +async function pruneWorktreeMap(instanceId: string): Promise { const current = getWorktreeMap(instanceId) - const next: WorktreeMap = { ...current, defaultWorktreeSlug: slug } + const available = new Set(getWorktrees(instanceId).map((wt) => wt.slug)) + available.add("root") + + let changed = false + let nextDefault = current.defaultWorktreeSlug || "root" + if (!available.has(nextDefault)) { + nextDefault = "root" + changed = true + } + + const nextMapping: Record = { ...(current.parentSessionWorktreeSlug ?? {}) } + for (const [sessionId, slug] of Object.entries(nextMapping)) { + if (!available.has(slug)) { + delete nextMapping[sessionId] + changed = true + } + } + + if (!changed) return false + + const next: WorktreeMap = { + version: 1, + defaultWorktreeSlug: nextDefault, + parentSessionWorktreeSlug: nextMapping, + } + setWorktreeMapByInstance((prev) => { const map = new Map(prev) map.set(instanceId, next) @@ -188,7 +238,29 @@ async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise }) await serverApi.writeWorktreeMap(instanceId, next).catch((error) => { - log.warn("Failed to persist default worktree", { instanceId, slug, error }) + log.warn("Failed to persist pruned worktree map", { instanceId, error }) + }) + + return true +} + +function getDefaultWorktreeSlug(instanceId: string): string { + return normalizeWorktreeSlug(instanceId, getWorktreeMap(instanceId).defaultWorktreeSlug || "root") +} + +async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise { + await ensureWorktreeMapLoaded(instanceId) + const current = getWorktreeMap(instanceId) + const nextSlug = normalizeWorktreeSlug(instanceId, slug) + const next: WorktreeMap = { ...current, defaultWorktreeSlug: nextSlug } + setWorktreeMapByInstance((prev) => { + const map = new Map(prev) + map.set(instanceId, next) + return map + }) + + await serverApi.writeWorktreeMap(instanceId, next).catch((error) => { + log.warn("Failed to persist default worktree", { instanceId, slug: nextSlug, error }) }) } @@ -200,7 +272,8 @@ function getParentSessionId(instanceId: string, sessionId: string): string { function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string { const map = getWorktreeMap(instanceId) - return map.parentSessionWorktreeSlug[parentSessionId] ?? map.defaultWorktreeSlug ?? "root" + const candidate = map.parentSessionWorktreeSlug[parentSessionId] ?? map.defaultWorktreeSlug ?? "root" + return normalizeWorktreeSlug(instanceId, candidate) } function getWorktreeSlugForSession(instanceId: string, sessionId: string): string { @@ -211,8 +284,9 @@ function getWorktreeSlugForSession(instanceId: string, sessionId: string): strin async function setWorktreeSlugForParentSession(instanceId: string, parentSessionId: string, slug: string): Promise { await ensureWorktreeMapLoaded(instanceId) const current = getWorktreeMap(instanceId) + const normalizedSlug = normalizeWorktreeSlug(instanceId, slug) const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) } - nextMapping[parentSessionId] = slug + nextMapping[parentSessionId] = normalizedSlug const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping } setWorktreeMapByInstance((prev) => { const map = new Map(prev) @@ -221,7 +295,7 @@ async function setWorktreeSlugForParentSession(instanceId: string, parentSession }) await serverApi.writeWorktreeMap(instanceId, next).catch((error) => { - log.warn("Failed to persist session worktree mapping", { instanceId, parentSessionId, slug, error }) + log.warn("Failed to persist session worktree mapping", { instanceId, parentSessionId, slug: normalizedSlug, error }) }) } @@ -251,13 +325,14 @@ function getWorktreeSlugForDirectory(instanceId: string, directory: string | und } function buildWorktreeProxyPath(instanceId: string, slug: string): string { - const normalizedSlug = slug || "root" + const normalizedSlug = normalizeWorktreeSlug(instanceId, slug || "root") return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance` } function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient { - const proxyPath = buildWorktreeProxyPath(instanceId, slug) - return sdkManager.createClient(instanceId, proxyPath, slug) + const normalized = normalizeWorktreeSlug(instanceId, slug || "root") + const proxyPath = buildWorktreeProxyPath(instanceId, normalized) + return sdkManager.createClient(instanceId, proxyPath, normalized) } function getRootClient(instanceId: string): OpencodeClient {