mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
* fix(worktrees): stop a resolved-worktree snapshot answering for repos it never saw `listResolvedWorktrees` caches one fleet-wide snapshot for RESOLVED_WORKTREE_CACHE_TTL_MS (1s) and reuses it on time alone. Nothing invalidates it when a repo is registered, so for up to a second after a repo row lands, every caller reads a snapshot computed before that repo existed -- and reads the gap as a verdict. The visible failure is the SSH skill install. `resolveSkillSshTarget` resolves a workspace-scope destination through that snapshot, so installing into a worktree on a host connected moments earlier threw `skill-install-workspace-not-found`: the client asserting a remote workspace is absent on the strength of client-side bookkeeping that had never looked at the host. That is the shape `docs/reference/ssh-execution-boundary.md` rules out -- absence from a client-side set is not evidence about the execution host. It made `tests/e2e/ssh-skill-installation.spec.ts:108` fail 3 runs in 4 locally and deterministically in the Docker SSH lane, where connect-then-install lands inside the one-second window every time. The snapshot now carries the repo-registration revision it was computed under and is only reused while that revision still holds. The counter is the one `bumpLocalWorktreeScanGeneration` already advances on every repo add, removal and update, so the check is O(1) and cannot drift from the mutation sites. * fix(worktrees): key the snapshot on repo mutations only, not on generation reads Two things the headless-reattach lane surfaced. The revision I keyed the snapshot on was `generationSequence`, which `getLocalWorktreeScanGeneration` also advances when it mints a key for a repo id nothing has scanned yet. That is a read, not a mutation, so a read path could discard a snapshot that was still perfectly valid -- the mirror image of the staleness this fixes, and a way to make a lookup fail that would otherwise have succeeded. The counter now advances only where the scan generation is actually bumped: repo add, removal, update, and scan-cache invalidation. Separately, `pty-restore-record-seeding.test.ts` primed the cache by writing its private `resolved` field with a literal spelling out `worktrees`, `platformByRepoId` and `expiresAt`. That literal is a second copy of the cache's freshness contract, so adding a field to the real entry left the fake one failing the check: the primed snapshot was rejected, resolution fell through to a real scan, and the headless fixture -- which has no git -- got `selector_not_found`. It now primes through `getSnapshot` so the cache stamps its own entry and the two cannot drift again. The revision never moved during that test (0 before and after), so nothing was being invalidated; the fake entry simply never satisfied the contract.
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { RuntimeResolvedWorktreeCache } from './runtime-resolved-worktree-cache'
|
|
import type { ResolvedWorktreeSnapshot } from './runtime-resolved-worktree-cache'
|
|
import {
|
|
bumpLocalWorktreeScanGeneration,
|
|
getLocalWorktreeScanGeneration,
|
|
getWorktreeScanMutationRevision
|
|
} from '../local-worktree-scan-generation'
|
|
|
|
function snapshotOf(ids: string[]): ResolvedWorktreeSnapshot {
|
|
return {
|
|
worktrees: ids.map((id) => ({ id }) as ResolvedWorktreeSnapshot['worktrees'][number]),
|
|
platformByRepoId: new Map()
|
|
}
|
|
}
|
|
|
|
describe('RuntimeResolvedWorktreeCache', () => {
|
|
it('reuses a snapshot inside the TTL while the repo inventory is unchanged', async () => {
|
|
const cache = new RuntimeResolvedWorktreeCache()
|
|
let computes = 0
|
|
const compute = async (): Promise<ResolvedWorktreeSnapshot> => {
|
|
computes += 1
|
|
return snapshotOf(['repo-1::/a'])
|
|
}
|
|
|
|
await cache.getSnapshot(compute, 60_000, 7)
|
|
const second = await cache.getSnapshot(compute, 60_000, 7)
|
|
|
|
expect(computes).toBe(1)
|
|
expect(second.worktrees.map((worktree) => worktree.id)).toEqual(['repo-1::/a'])
|
|
})
|
|
|
|
it('recomputes when the repo inventory moved, even well inside the TTL', async () => {
|
|
// Why: this is the whole point. A snapshot taken before a repo was registered cannot testify
|
|
// that the repo's worktrees are absent — callers read the gap as "workspace not found".
|
|
const cache = new RuntimeResolvedWorktreeCache()
|
|
const results = [snapshotOf(['repo-1::/a']), snapshotOf(['repo-1::/a', 'repo-2::/b'])]
|
|
let computes = 0
|
|
const compute = async (): Promise<ResolvedWorktreeSnapshot> => results[computes++]
|
|
|
|
await cache.getSnapshot(compute, 60_000, 7)
|
|
const afterRegistration = await cache.getSnapshot(compute, 60_000, 8)
|
|
|
|
expect(computes).toBe(2)
|
|
expect(afterRegistration.worktrees.map((worktree) => worktree.id)).toEqual([
|
|
'repo-1::/a',
|
|
'repo-2::/b'
|
|
])
|
|
})
|
|
|
|
it('does not join an in-flight compute that started under a stale inventory', async () => {
|
|
const cache = new RuntimeResolvedWorktreeCache()
|
|
const computed: number[] = []
|
|
const compute = async (): Promise<ResolvedWorktreeSnapshot> => {
|
|
computed.push(computed.length)
|
|
return snapshotOf([])
|
|
}
|
|
|
|
const first = cache.getSnapshot(compute, 60_000, 7)
|
|
const second = cache.getSnapshot(compute, 60_000, 8)
|
|
await Promise.all([first, second])
|
|
|
|
expect(computed).toHaveLength(2)
|
|
})
|
|
|
|
it('reports freshness against the inventory the snapshot was computed under', async () => {
|
|
const cache = new RuntimeResolvedWorktreeCache()
|
|
await cache.getSnapshot(async () => snapshotOf([]), 60_000, 7)
|
|
|
|
expect(cache.isFresh(7)).toBe(true)
|
|
expect(cache.isFresh(8)).toBe(false)
|
|
cache.invalidateResolved()
|
|
expect(cache.isFresh(7)).toBe(false)
|
|
})
|
|
|
|
it('keeps a primed snapshot servable when nothing mutated', async () => {
|
|
// Why: the headless-reattach fixtures prime this cache once and then resolve a selector off it
|
|
// without any git available. Losing freshness for a reason other than a mutation strands them
|
|
// on a real scan, which is the failure this pairs with — a lookup that finds nothing because
|
|
// the snapshot was dropped, not because the worktree is gone.
|
|
const cache = new RuntimeResolvedWorktreeCache()
|
|
let computes = 0
|
|
const prime = async (): Promise<ResolvedWorktreeSnapshot> => {
|
|
computes += 1
|
|
return snapshotOf(['repo-restore::/tmp/restore-records'])
|
|
}
|
|
await cache.getSnapshot(prime, 60_000, getWorktreeScanMutationRevision())
|
|
|
|
// A read that mints a scan generation for a repo nothing has scanned yet is not a mutation.
|
|
getLocalWorktreeScanGeneration(`repo-never-scanned-${Math.random()}`)
|
|
|
|
expect(cache.isFresh(getWorktreeScanMutationRevision())).toBe(true)
|
|
const served = await cache.getSnapshot(prime, 60_000, getWorktreeScanMutationRevision())
|
|
expect(computes).toBe(1)
|
|
expect(served.worktrees.map((worktree) => worktree.id)).toEqual([
|
|
'repo-restore::/tmp/restore-records'
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('getWorktreeScanMutationRevision', () => {
|
|
it('advances on a repo mutation and not on a first-seen generation read', () => {
|
|
const repoId = `repo-${Math.random()}`
|
|
const before = getWorktreeScanMutationRevision()
|
|
|
|
getLocalWorktreeScanGeneration(repoId)
|
|
expect(getWorktreeScanMutationRevision()).toBe(before)
|
|
|
|
bumpLocalWorktreeScanGeneration(repoId)
|
|
expect(getWorktreeScanMutationRevision()).toBe(before + 1)
|
|
})
|
|
})
|