mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 08:02:33 +00:00
* fix(P1-D): coalesce remote-ref probes, TTL negatives, and bound unsettled keys Keep forge resolution from stampeding git under worktree fan-out, let remotes added mid-session be discovered without a restart, and refuse pathological new-branch waves once the unsettled map is full. * fix(P1-D): stop abandoned probes publishing, and split capacity refusals A coalesced probe abandoned as stale kept running and still wrote its answer to the cache, so a late permanent miss could land over the successor's fresher one. Probes now publish only while they still own the in-flight key. The hosted-review capacity refusal told brand-new branches that an earlier attempt of their own never answered when the refusal was really the unsettled map or the process-wide detached cap; each cap now says what it is. Also caches stable "no such remote" SSH misses under the negative TTL instead of re-spawning the probe on every poll. Co-authored-by: Orca <help@stably.ai> * Bound SSH remote URL probe with deadline to prevent hangs The SSH branch of remote URL probes was unbounded — the relay's bounds are per-phase and reset on every frame, so a relay dribbling output would outlive them. Pass AbortSignal.timeout to the SSH provider's exec call to enforce the same 30s deadline as local probes. Treat AbortError as a transient probe error: it signals unavailable infrastructure (deadline or cancellation), not a negative answer about the remote. --------- Co-authored-by: Orca <help@stably.ai>
144 lines
5.2 KiB
TypeScript
144 lines
5.2 KiB
TypeScript
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
|
import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe'
|
|
import { isTransientGitProbeError, readRemoteUrl } from './remote-url-probe'
|
|
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
|
|
|
|
/**
|
|
* The "is this repo mine?" probe every forge integration runs: read the remote's
|
|
* URL once per repo/runtime, cache what the provider's parser made of it, and
|
|
* never cache an answer a failed probe never gave.
|
|
*/
|
|
|
|
const REPO_REF_CACHE_MAX_ENTRIES = 512
|
|
|
|
/**
|
|
* Why: "not this provider" only holds until someone edits the repo's remotes —
|
|
* and a repo first probed before it had any remote answers that way too.
|
|
* Nothing here watches `.git/config`, and a watcher cannot cover the SSH and WSL
|
|
* runtimes this cache also serves, so negatives expire instead: one probe per
|
|
* repo per interval is what lets a remote added mid-session be picked up without
|
|
* a restart. Positives stay, as they did before.
|
|
*/
|
|
export const NEGATIVE_ENTRY_TTL_MS = 5 * 60_000
|
|
|
|
type CachedRepoRef<Ref> = { value: Ref | null; expiresAt: number }
|
|
|
|
export type RemoteRefLocalGitOptions = {
|
|
wslDistro?: string
|
|
}
|
|
|
|
export type RemoteRefProbeCache<Ref> = {
|
|
get(
|
|
repoPath: string,
|
|
remoteName: string,
|
|
connectionId?: string | null,
|
|
localGitOptions?: RemoteRefLocalGitOptions
|
|
): Promise<Ref | null>
|
|
clear(): void
|
|
size(): number
|
|
}
|
|
|
|
export function createRemoteRefProbeCache<Ref>(
|
|
parseRemoteUrl: (remoteUrl: string) => Ref | null
|
|
): RemoteRefProbeCache<Ref> {
|
|
const repoRefCache = new Map<string, CachedRepoRef<Ref>>()
|
|
const inFlight: CoalescedProbes<Ref | null> = new Map()
|
|
|
|
function remember(cacheKey: string, value: Ref | null): void {
|
|
repoRefCache.set(cacheKey, {
|
|
value,
|
|
expiresAt: value === null ? Date.now() + NEGATIVE_ENTRY_TTL_MS : Number.POSITIVE_INFINITY
|
|
})
|
|
while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) {
|
|
const oldestKey = repoRefCache.keys().next().value
|
|
if (oldestKey === undefined) {
|
|
return
|
|
}
|
|
repoRefCache.delete(oldestKey)
|
|
}
|
|
}
|
|
|
|
async function probe(
|
|
cacheKey: string,
|
|
ownsKey: () => boolean,
|
|
repoPath: string,
|
|
remoteName: string,
|
|
connectionId: string | null | undefined,
|
|
localGitOptions: RemoteRefLocalGitOptions
|
|
): Promise<Ref | null> {
|
|
// Why: a probe abandoned as stale still runs, and its answer describes a repo
|
|
// state older than whatever the successor is about to store — or already has.
|
|
// It may still answer its own callers; it may not publish.
|
|
const publish = (value: Ref | null): void => {
|
|
if (ownsKey()) {
|
|
remember(cacheKey, value)
|
|
}
|
|
}
|
|
try {
|
|
const stdout = await readRemoteUrl(
|
|
{
|
|
repoPath,
|
|
connectionId,
|
|
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
|
},
|
|
remoteName
|
|
)
|
|
// Why: null is the SSH runtime being disconnected, not an answer about the
|
|
// remote — and it costs no `git`, so there is nothing here to spare. It is
|
|
// deliberately the one negative with no TTL floor: flooring it would make a
|
|
// reconnected host wait the interval out for a probe it could serve now.
|
|
if (stdout === null) {
|
|
return null
|
|
}
|
|
const result = parseRemoteUrl(stdout)
|
|
publish(result)
|
|
return result
|
|
} catch (error) {
|
|
// Why: a probe killed on its deadline says nothing about the remote, and an
|
|
// SSH failure is usually a reconnect or tunnel state rather than an answer.
|
|
// Only "no such remote" is the repo itself saying it is not this provider's
|
|
// — anything else cached would poison it, on SSH for the generation's life.
|
|
if (isTransientGitProbeError(error)) {
|
|
return null
|
|
}
|
|
if (connectionId && !isStableMissingGitRemoteError(error)) {
|
|
return null
|
|
}
|
|
publish(null)
|
|
return null
|
|
}
|
|
}
|
|
|
|
return {
|
|
async get(repoPath, remoteName, connectionId, localGitOptions = {}) {
|
|
// Why: a reconnect retires the connection an answer came from, and with it
|
|
// the probe still running on it — stamping the generation stops a caller on
|
|
// the new connection from adopting either.
|
|
const runtimeKey = connectionId
|
|
? `${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
|
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
|
const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}`
|
|
const cached = repoRefCache.get(cacheKey)
|
|
if (cached) {
|
|
if (cached.expiresAt > Date.now()) {
|
|
return cached.value
|
|
}
|
|
repoRefCache.delete(cacheKey)
|
|
}
|
|
// Why: every branch of a repo resolves its forge through this probe, so a
|
|
// poll of the worktree list arrives as a burst of identical lookups. One
|
|
// young probe answers all of them instead of spawning a `git` per branch.
|
|
return runCoalescedProbe(inFlight, cacheKey, (ownsKey) =>
|
|
probe(cacheKey, ownsKey, repoPath, remoteName, connectionId, localGitOptions)
|
|
)
|
|
},
|
|
clear() {
|
|
repoRefCache.clear()
|
|
inFlight.clear()
|
|
},
|
|
size() {
|
|
return repoRefCache.size
|
|
}
|
|
}
|
|
}
|