mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +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.
74 lines
2.4 KiB
TypeScript
74 lines
2.4 KiB
TypeScript
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
|
|
|
export type ResolvedWorktreeSnapshot = {
|
|
worktrees: ResolvedWorktree[]
|
|
platformByRepoId: ReadonlyMap<string, NodeJS.Platform>
|
|
}
|
|
|
|
type ResolvedCache = ResolvedWorktreeSnapshot & { expiresAt: number; inventoryRevision: number }
|
|
type ResolvedInFlight = {
|
|
generation: number
|
|
inventoryRevision: number
|
|
promise: Promise<ResolvedWorktreeSnapshot>
|
|
}
|
|
export class RuntimeResolvedWorktreeCache {
|
|
private resolved: ResolvedCache | null = null
|
|
private resolvedInFlight: ResolvedInFlight | null = null
|
|
private resolvedGeneration = 0
|
|
|
|
peek(): ResolvedCache | null {
|
|
return this.resolved
|
|
}
|
|
|
|
/**
|
|
* Why the revision and not the TTL alone: a snapshot only answers for the repos that were
|
|
* registered when it ran. A repo added afterwards — a remote host the user just connected —
|
|
* is missing from it for reasons that have nothing to do with what exists on that host, and
|
|
* callers read the gap as a verdict that the worktree does not exist.
|
|
*/
|
|
isFresh(inventoryRevision: number, now = Date.now()): boolean {
|
|
return Boolean(
|
|
this.resolved &&
|
|
this.resolved.inventoryRevision === inventoryRevision &&
|
|
this.resolved.expiresAt > now
|
|
)
|
|
}
|
|
|
|
async getSnapshot(
|
|
compute: () => Promise<ResolvedWorktreeSnapshot>,
|
|
ttlMs: number,
|
|
inventoryRevision: number
|
|
): Promise<ResolvedWorktreeSnapshot> {
|
|
if (this.resolved && this.isFresh(inventoryRevision)) {
|
|
return this.resolved
|
|
}
|
|
const generation = this.resolvedGeneration
|
|
if (
|
|
this.resolvedInFlight?.generation === generation &&
|
|
this.resolvedInFlight.inventoryRevision === inventoryRevision
|
|
) {
|
|
return this.resolvedInFlight.promise
|
|
}
|
|
const promise = compute()
|
|
this.resolvedInFlight = { generation, inventoryRevision, promise }
|
|
try {
|
|
const result = await promise
|
|
if (generation === this.resolvedGeneration) {
|
|
// Why stamped on completion, not entry: a compute that spent longer than the TTL would
|
|
// otherwise publish an already-expired entry, so the next poll recomputes the same slow path.
|
|
this.resolved = { ...result, inventoryRevision, expiresAt: Date.now() + ttlMs }
|
|
}
|
|
return result
|
|
} finally {
|
|
if (this.resolvedInFlight?.promise === promise) {
|
|
this.resolvedInFlight = null
|
|
}
|
|
}
|
|
}
|
|
|
|
invalidateResolved(): void {
|
|
this.resolvedGeneration += 1
|
|
this.resolved = null
|
|
}
|
|
}
|