fix(github): repo cache eviction clears every host's entries, keeping a live twin's (#31312)

* fix(github): repo cache eviction drops every host scope's entries, keeping a twin's own

Eviction and issue-source changes matched only unscoped key prefixes, so
SSH-, runtime- and source-scoped entries survived repo removal. Keys are now
composed by one builder and read back by its inverse, so eviction classifies
each key by host and keeps only a surviving same-id twin's bucket. Hosted
review (GitLab MR) entries are evicted too.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(github): read task-source scopes with empty parts back whole

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(github): an issue-source change evicts only the changed twin's buckets

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-20 19:36:49 -07:00
committed by GitHub
co-authored by Claude
parent 379ad0c5cd
commit 356bda10d1
10 changed files with 311 additions and 72 deletions
+42 -7
View File
@@ -2,8 +2,10 @@ import type { GlobalSettings } from '../global-settings-types'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
toSshExecutionHostId
toSshExecutionHostId,
type ExecutionHostId
} from '../execution-host'
import { taskSourceCacheScopeHostId } from '../task-source-context'
type RuntimeFocusSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
@@ -16,14 +18,47 @@ export function getGitHubRepoCacheKey(
executionHostId?: string | null,
hasRepoOwner = false
): string {
const owner = repoId ?? repoPath
const scope = getGitHubCacheHostScope(settings, connectionId, executionHostId, hasRepoOwner)
// Why: runtime/SSH lookups can observe different remotes than the local repo
// path, so cache keys include the repo's owning execution boundary.
if (scope) {
return `${scope}::${owner}::${suffix}`
return composeRepoCacheKey(
getGitHubCacheHostScope(settings, connectionId, executionHostId, hasRepoOwner),
repoId ?? repoPath,
suffix
)
}
/**
* The one shape of a repo-owned cache key: `[scope::]owner::suffix`, where scope is an execution
* host id or a task-source scope and no scope means this computer. Read back by repoCacheKeyHostId.
*/
export function composeRepoCacheKey(
scope: string | null | undefined,
owner: string,
suffix: string
): string {
return scope ? `${scope}::${owner}::${suffix}` : `${owner}::${suffix}`
}
/** Which host's bucket `key` is when one of `owners` (repo id or path) owns it; null when none does. */
export function repoCacheKeyHostId(key: string, owners: readonly string[]): ExecutionHostId | null {
if (owners.some((owner) => key.startsWith(`${owner}::`))) {
return LOCAL_EXECUTION_HOST_ID
}
return `${owner}::${suffix}`
// Why not the first `::`: a task-source scope with empty parts contains `::` itself.
for (const owner of owners) {
const marker = `::${owner}::`
for (let at = key.indexOf(marker); at !== -1; at = key.indexOf(marker, at + 1)) {
const scope = key.slice(0, at)
// Why the `::` check: a composed host id is encoded, so a candidate spanning `::` is owner + suffix.
const hostId =
(scope.includes('::') ? null : normalizeExecutionHostId(scope)) ??
taskSourceCacheScopeHostId(scope)
if (hostId) {
return hostId
}
}
}
return null
}
function getGitHubCacheHostScope(
@@ -57,7 +92,7 @@ export function getLegacyGitHubRepoCacheKey(
repoId: string | undefined,
suffix: string
): string {
return `${repoId ?? repoPath}::${suffix}`
return composeRepoCacheKey(null, repoId ?? repoPath, suffix)
}
export function getGitHubPRCacheKey(