fix(runtime): reuse cached worktree rows when the admin probe cannot answer (#27445)

* fix(runtime): reuse cached worktree rows when the admin probe cannot answer

* test(runtime): wait for the fingerprint probe to settle instead of a fixed delay

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 03:55:21 -07:00
committed by GitHub
co-authored by m4air
parent e2339f27a5
commit ff5be7f7b6
3 changed files with 119 additions and 23 deletions
@@ -20,6 +20,25 @@ import { listStoredWorktreeRowsForRepo } from './repo-worktree-row-resolution'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../shared/execution-host'
const lastFingerprintUnknownWarnAt = new Map<string, number>()
// Why once per reconcile window: a wedged mount answers "unknown" on every poll.
function warnFingerprintUnknownOnce(repoId: string, reason: string): void {
const now = Date.now()
const last = lastFingerprintUnknownWarnAt.get(repoId)
if (last !== undefined && now - last < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS) {
return
}
lastFingerprintUnknownWarnAt.set(repoId, now)
console.warn(
'[worktree-scan] admin fingerprint unknown; reusing cached rows until the reconcile ceiling',
{
repoId,
reason
}
)
}
export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget {
/**
* Refresh one repo's worktree rows, skipping the `git worktree list` subprocess when a cheap
@@ -48,28 +67,61 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
scannedAt - cached.scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS
? cached
: null
if (probe && reusable) {
// Why reuse on "cannot tell": a probe that is still pending or timed out says nothing about
// the repo, and turning that into a full scan is the feedback loop of #19312 (slow mount →
// timeouts → scans → more load). The original `scannedAt` is kept, so the reconcile ceiling
// still forces a real scan on schedule.
const reuseCached = (): RuntimeWorktreeScanRefresh | null =>
reusable
? {
result: reusable.result,
adminFingerprint: reusable.adminFingerprint,
adminFingerprintProbe: null,
scannedAt: reusable.scannedAt
}
: null
if (probe === 'in-flight') {
const reused = reuseCached()
if (reused) {
warnFingerprintUnknownOnce(repo.id, 'previous probe still pending')
return reused
}
} else if (probe && reusable) {
// Why await only here: this is the one branch whose decision needs the probe. A scan-bound
// caller must never wait on it, or every cold read pays filesystem latency it cannot use.
const probed = await withTimeoutResult(probe, WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS)
if (!probed.ok) {
// Why log: expiry and "fingerprint unavailable" both surface as `null`, so a wedged mount is
// otherwise indistinguishable from a repo that simply cannot be fingerprinted.
console.warn('[worktree-scan] admin fingerprint probe expired; running a full scan', {
repoId: repo.id,
timeoutMs: WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS
})
warnFingerprintUnknownOnce(repo.id, 'probe expired')
return (
reuseCached() ??
(await this.scanRepoWorktreesForRefresh(repo, projectRuntime, null, scannedAt))
)
}
const current = probed.ok ? probed.value : null
if (current !== null && current === reusable.adminFingerprint) {
// A known fingerprint that matches proves nothing changed; null means the repo cannot be
// fingerprinted, which (like a mismatch) needs a real scan.
if (probed.value !== null && probed.value === reusable.adminFingerprint) {
return {
result: reusable.result,
adminFingerprint: current,
adminFingerprint: probed.value,
adminFingerprintProbe: null,
scannedAt: reusable.scannedAt
}
}
}
return await this.scanRepoWorktreesForRefresh(
repo,
projectRuntime,
probe === 'in-flight' ? null : probe,
scannedAt
)
}
private async scanRepoWorktreesForRefresh(
repo: Repo,
projectRuntime: ProjectExecutionRuntimeResolution | undefined,
probe: Promise<string | null> | null,
scannedAt: number
): Promise<RuntimeWorktreeScanRefresh> {
const result = dropRepeatedWorktreeRows(
await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime)
)
@@ -81,9 +133,11 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
* Why the gate: `withTimeout` abandons a probe without cancelling it, and readdir/stat take no
* AbortSignal — on a wedged mount a fresh probe per refresh would pin every libuv fs thread.
*/
protected startRepoWorktreeAdminFingerprintProbe(repo: Repo): Promise<string | null> | null {
protected startRepoWorktreeAdminFingerprintProbe(
repo: Repo
): Promise<string | null> | 'in-flight' {
if (this.worktreeAdminFingerprintProbes.has(repo.id)) {
return null
return 'in-flight'
}
this.worktreeAdminFingerprintProbes.add(repo.id)
return readRepoWorktreeAdminFingerprint(repo.path)
@@ -46,7 +46,15 @@ describe('resolveWorktreeScanCacheTtlMs', () => {
it('keeps a scratch repo scan cached past the base TTL while normal repos rescan', async () => {
// Why: the whole fix lives in the cache-stamp call site; pin the wiring so
// a revert to the flat TTL fails CI, not just the pure-function tests.
vi.useFakeTimers()
// Why only Date: each refresh starts a real admin-fingerprint read, which must settle on the
// real event loop; a probe still pending at the next refresh reuses the cache by design.
vi.useFakeTimers({ toFake: ['Date'] })
let runtimeProbes: { size: number } = { size: 0 }
const settleFingerprintProbes = async (): Promise<void> => {
for (let attempt = 0; attempt < 400 && runtimeProbes.size > 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
// Why: the shared listWorktrees stub keeps call history across this file's
// tests; absolute counts need a clean baseline.
vi.mocked(listWorktrees).mockClear()
@@ -66,10 +74,13 @@ describe('resolveWorktreeScanCacheTtlMs', () => {
]
} as never)
const internals = runtime as unknown as { listResolvedWorktrees: () => Promise<unknown> }
// Why bracket access: the probe set is protected runtime state this test only reads.
runtimeProbes = runtime['worktreeAdminFingerprintProbes']
const scanCallsFor = (path: string): number =>
vi.mocked(listWorktrees).mock.calls.filter((call) => call[0] === path).length
await internals.listResolvedWorktrees()
await settleFingerprintProbes()
expect(scanCallsFor('/tmp/repo')).toBe(1)
expect(scanCallsFor(scratchPath)).toBe(1)
@@ -403,8 +403,9 @@ describe('worktree scan admin-fingerprint gate', () => {
const secondSettled = trackSettled(second)
await drainMicrotasks()
expect(secondSettled()).toBe(true)
await second
expect(scanCount()).toBe(2)
// #19312: a pending probe says nothing, so it reuses the scanned rows instead of rescanning.
expect(heads(await second)).toEqual(['abc', 'def'])
expect(scanCount()).toBe(1)
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
const third = list()
@@ -412,7 +413,7 @@ describe('worktree scan admin-fingerprint gate', () => {
await drainMicrotasks()
expect(thirdSettled()).toBe(true)
await third
expect(scanCount()).toBe(3)
expect(scanCount()).toBe(1)
} finally {
vi.useRealTimers()
}
@@ -427,6 +428,7 @@ describe('worktree scan admin-fingerprint gate', () => {
await list()
expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1)
const startedAt = Date.now()
for (let refresh = 0; refresh < 20; refresh += 1) {
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
await list()
@@ -434,7 +436,12 @@ describe('worktree scan admin-fingerprint gate', () => {
// Each abandoned probe pins an fs work item on the 4-slot libuv pool forever.
expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1)
expect(scanCount()).toBe(21)
// Only the reconcile ceiling rescans while the probe stays pending, never every refresh.
const elapsed = Date.now() - startedAt
expect(scanCount()).toBeGreaterThan(1)
expect(scanCount()).toBeLessThanOrEqual(
1 + Math.ceil(elapsed / WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS)
)
} finally {
vi.useRealTimers()
}
@@ -449,24 +456,24 @@ describe('worktree scan admin-fingerprint gate', () => {
await list()
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
await list()
expect(scanCount()).toBe(2)
expect(scanCount()).toBe(1)
// The mount unwedges; its stale answer must not gate anything, but the repo must probe again.
releaseStalledProbe('fp-stale')
await drainMicrotasks()
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
await list()
expect(scanCount()).toBe(3)
expect(scanCount()).toBe(2)
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
await list()
expect(scanCount()).toBe(3)
expect(scanCount()).toBe(2)
} finally {
vi.useRealTimers()
}
})
it('scans when the awaited probe outlives its deadline', async () => {
it('reuses the scanned rows when the awaited probe outlives its deadline', async () => {
vi.useFakeTimers()
try {
const { list } = makeRuntime()
@@ -485,8 +492,32 @@ describe('worktree scan admin-fingerprint gate', () => {
await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS)
await drainMicrotasks()
// #19312: a timeout is "unknown", never a mismatch, so it must not start a full scan.
expect(scanCount()).toBe(1)
expect(heads(await second)).toEqual(['abc', 'def'])
} finally {
vi.useRealTimers()
}
})
it('still reconciles on schedule while every probe times out', async () => {
vi.useFakeTimers()
try {
readRepoWorktreeAdminFingerprintMock.mockImplementation(() => new Promise(() => {}))
const { list } = makeRuntime()
const scannedAt = Date.now()
await list()
expect(scanCount()).toBe(1)
while (Date.now() - scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS - SCAN_TTL_MS) {
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
await list()
}
expect(scanCount()).toBe(1)
vi.advanceTimersByTime(2 * SCAN_TTL_MS)
await list()
expect(scanCount()).toBe(2)
await second
} finally {
vi.useRealTimers()
}
@@ -537,7 +568,7 @@ describe('worktree scan admin-fingerprint gate', () => {
await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS)
await drainMicrotasks()
expect(secondSettled()).toBe(true)
expect(scanCount()).toBe(2)
expect(scanCount()).toBe(1)
expect(heads(await second)).toEqual(['abc', 'def'])
} finally {
vi.useRealTimers()