diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index 828bf0a355e..6ad7d2014d6 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -1,4 +1,7 @@ -import { joinResolvedWorktreeScans } from './workspaces/resolved-worktree-scan-join' +import { + joinResolvedWorktreeScans, + projectResolvedWorktreeScans +} from './workspaces/resolved-worktree-scan-join' import { getRepoHostIdentityForParts } from '../../shared/repo-host-identity' import { WorkspaceGitIdentity } from './sidebar-inputs/workspace-git-identity' import { describe, expect, it, vi } from 'vitest' @@ -455,7 +458,7 @@ it('stamps the scan/sidebar join while cached catalog projection stays pure acro const scans = new Map([ [key, { ok: true as const, worktrees: [gitWorktree('/repo/work')], gitRun: {} }] ]) - joinResolvedWorktreeScans(deps.store, () => deps, scans, true) + projectResolvedWorktreeScans(deps.store, () => deps, scans) expect(writes).not.toHaveBeenCalled() const first = joinResolvedWorktreeScans(deps.store, () => deps, scans) expect(writes).toHaveBeenCalledOnce() @@ -471,6 +474,6 @@ it('stamps the scan/sidebar join while cached catalog projection stays pure acro identity.observeScan(rows(detached), detached.scannedRepoKeys) expect(identity.title(detached.worktrees[0]!)).toBe(first.worktrees[0]!.displayName) expect(writes).toHaveBeenCalledOnce() - joinResolvedWorktreeScans(deps.store, () => deps, scans, true) + projectResolvedWorktreeScans(deps.store, () => deps, scans) expect(writes).toHaveBeenCalledOnce() }) diff --git a/src/main/runtime/runtime-resolved-worktree-cache.ts b/src/main/runtime/runtime-resolved-worktree-cache.ts index ee4dfe3d8ae..a4193cd3c2b 100644 --- a/src/main/runtime/runtime-resolved-worktree-cache.ts +++ b/src/main/runtime/runtime-resolved-worktree-cache.ts @@ -1,4 +1,7 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import type { RuntimeStore } from './runtime-store-contract' +import type { ResolvedWorktreeRowsJoin } from './workspaces/resolved-worktree-scan-join' +import { WORKTREE_METADATA_DOMAINS } from '../persistence/loading-store/worktree-identity-metadata' export type ResolvedWorktreeSnapshot = { worktrees: ResolvedWorktree[] @@ -6,6 +9,8 @@ export type ResolvedWorktreeSnapshot = { } type ResolvedCache = ResolvedWorktreeSnapshot & { expiresAt: number; inventoryRevision: number } +type CachedScanProjection = { inputs: readonly unknown[]; rows: ResolvedWorktreeRowsJoin } +type StoreWrites = { store: RuntimeStore; revision: number } type ResolvedInFlight = { generation: number inventoryRevision: number @@ -15,6 +20,8 @@ export class RuntimeResolvedWorktreeCache { private resolved: ResolvedCache | null = null private resolvedInFlight: ResolvedInFlight | null = null private resolvedGeneration = 0 + private projection: CachedScanProjection | null = null + private storeWrites: StoreWrites | null = null get generation(): number { return this.resolvedGeneration @@ -64,8 +71,54 @@ export class RuntimeResolvedWorktreeCache { } } + /** Rows projected from cached scans, reused until a store write, an invalidation, or a changed scan entry; stores that cannot report writes recompute every read. */ + projectCachedScans( + store: RuntimeStore | null, + scanEntries: readonly unknown[], + project: () => ResolvedWorktreeRowsJoin + ): ResolvedWorktreeRowsJoin { + const revision = this.storeWriteRevision(store) + if (revision === null) { + return project() + } + // Captured before projecting: a metadata migration the read itself saves must miss next time. + const inputs = [revision, ...scanEntries] + const held = this.projection + if ( + held && + held.inputs.length === inputs.length && + held.inputs.every((input, index) => input === inputs[index]) + ) { + return held.rows + } + const generation = this.resolvedGeneration + const rows = project() + if (generation === this.resolvedGeneration) { + this.projection = { inputs, rows } + } + return rows + } + + private storeWriteRevision(store: RuntimeStore | null): number | null { + if (!store?.onStateWritten) { + return null + } + if (this.storeWrites?.store !== store) { + const writes: StoreWrites = { store, revision: 0 } + this.storeWrites = writes + // Rows read metadata plus repos, lineage and folder workspaces, whose saves name no domain. + store.onStateWritten((domains) => { + if (!domains || WORKTREE_METADATA_DOMAINS.some((domain) => domains.includes(domain))) { + writes.revision += 1 + } + }) + } + return this.storeWrites.revision + } + invalidateResolved(): void { this.resolvedGeneration += 1 this.resolved = null + this.projection = null } } diff --git a/src/main/runtime/session-tabs/structured-session-catalog-workspace.test.ts b/src/main/runtime/session-tabs/structured-session-catalog-workspace.test.ts index cf77a9358d9..5153dbada73 100644 --- a/src/main/runtime/session-tabs/structured-session-catalog-workspace.test.ts +++ b/src/main/runtime/session-tabs/structured-session-catalog-workspace.test.ts @@ -41,14 +41,14 @@ import type { AgentModelCatalogReadParams } from '../../native-chat/agent-model- import type { RuntimeWorktreeScanCache, RuntimeWorktreeScanInFlight } from '../orca-runtime-core' import type { RuntimeWorktreeScanResult } from '../repo-worktree-resolution-scan' import type { ResolvedWorktree } from '../runtime-worktree-path-identity' -import type { ResolvedWorktreeScanJoin } from '../workspaces/resolved-worktree-scan-join' +import type { ResolvedWorktreeRowsJoin } from '../workspaces/resolved-worktree-scan-join' import type { OrcaRuntimeWithWorktreePsDependencies } from '../workspaces/workspace-process-listing-dependencies' type ScopeFixture = { store: RuntimeStore structuredAgentModelCatalog: OrcaRuntimeWithWorktreePsDependencies['structuredAgentModelCatalog'] resolvedWorktrees: RuntimeResolvedWorktreeCache - projectCachedWorktrees: () => ResolvedWorktreeScanJoin + projectCachedWorktrees: () => ResolvedWorktreeRowsJoin worktreeScanCache: Map worktreeScanGenerations: Map worktreeScanInFlight: Map @@ -131,6 +131,8 @@ function rig(agent = 'opencode') { getWorktreeMeta: (id: string) => metadata[id], getAllWorktreeLineage: () => ({}), getAllWorkspaceLineage: () => ({}), + // Production stores report writes; this fixture's in-place edits are followed by invalidation. + onStateWritten: () => () => {}, removeWorktreeLineage: vi.fn(), setWorktreeMeta: writes } diff --git a/src/main/runtime/workspaces/resolved-worktree-scan-join.ts b/src/main/runtime/workspaces/resolved-worktree-scan-join.ts index e0669e3ea54..d08e9ef57ee 100644 --- a/src/main/runtime/workspaces/resolved-worktree-scan-join.ts +++ b/src/main/runtime/workspaces/resolved-worktree-scan-join.ts @@ -29,7 +29,7 @@ export type ResolvedWorktreeRepoScans = Map< RepoWorktreeRowsScan & { gitRun: object | null } > -export type ResolvedWorktreeScanJoin = ResolvedWorktreeSnapshot & { +export type ResolvedWorktreeRowsJoin = Pick & { /** Git repos the scans predate, which list no rows until scanned. */ unscannedRepoKeys: ReadonlySet /** Rows a scan that reached a verdict listed, by their git run; stalled scans remain degraded. */ @@ -38,6 +38,8 @@ export type ResolvedWorktreeScanJoin = ResolvedWorktreeSnapshot & { scannedRepoKeys: ReadonlySet } +export type ResolvedWorktreeScanJoin = ResolvedWorktreeSnapshot & ResolvedWorktreeRowsJoin + function resolvedWorktreeRepoScanKey(repo: Repo): HostScopedRepoKey { return getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) } @@ -82,40 +84,50 @@ export async function scanResolvedWorktreeRepos( export function joinResolvedWorktreeScans( store: RuntimeStore | null, rowDeps: () => RepoWorktreeRowDeps, - scans: ResolvedWorktreeRepoScans, - projectOnly = false + scans: ResolvedWorktreeRepoScans ): ResolvedWorktreeScanJoin { + const repos = store?.getRepos() ?? [] + const platformByRepoId = store + ? platformsByRepoId(repos, resolveLocalProjectRuntimesForRepos(store, repos)) + : new Map() + return { ...joinRows(store, rowDeps, scans, joinRepoWorktreeRows), platformByRepoId } +} + +/** The same rows from memory only: no identity stamping and no launch platforms. */ +export function projectResolvedWorktreeScans( + store: RuntimeStore | null, + rowDeps: () => RepoWorktreeRowDeps, + scans: ResolvedWorktreeRepoScans +): ResolvedWorktreeRowsJoin { + return joinRows(store, rowDeps, scans, projectRepoWorktreeRows) +} + +function joinRows( + store: RuntimeStore | null, + rowDeps: () => RepoWorktreeRowDeps, + scans: ResolvedWorktreeRepoScans, + rowsForRepo: typeof joinRepoWorktreeRows +): ResolvedWorktreeRowsJoin { if (!store) { return { worktrees: [], - platformByRepoId: new Map(), unscannedRepoKeys: new Set(), gitRunById: new Map(), scannedRepoKeys: new Set() } } const metaById = store.getAllWorktreeMeta() ?? {} - const repos = store.getRepos() - const platformByRepoId = platformsByRepoId( - repos, - resolveLocalProjectRuntimesForRepos(store, repos) - ) const deps = rowDeps() const unscannedRepoKeys = new Set() const gitRunById = new Map() const scannedRepoKeys = new Set() - const perRepoWorktrees = repos.map((repo) => { + const perRepoWorktrees = store.getRepos().map((repo) => { const key = resolvedWorktreeRepoScanKey(repo) const scan = scans.get(key) ?? null if (!scan && !isFolderRepo(repo)) { unscannedRepoKeys.add(key) } - const rows = (projectOnly ? projectRepoWorktreeRows : joinRepoWorktreeRows)( - deps, - repo, - scan, - metaById - ) + const rows = rowsForRepo(deps, repo, scan, metaById) if (scan?.ok) { scannedRepoKeys.add(key) for (const row of rows) { @@ -128,5 +140,5 @@ export function joinResolvedWorktreeScans( perRepoWorktrees.flat(), store.getAllWorktreeLineage?.() ?? {} ) - return { worktrees, platformByRepoId, unscannedRepoKeys, gitRunById, scannedRepoKeys } + return { worktrees, unscannedRepoKeys, gitRunById, scannedRepoKeys } } diff --git a/src/main/runtime/workspaces/workspace-catalog.ts b/src/main/runtime/workspaces/workspace-catalog.ts index da9321080f2..8d571f148c4 100644 --- a/src/main/runtime/workspaces/workspace-catalog.ts +++ b/src/main/runtime/workspaces/workspace-catalog.ts @@ -15,8 +15,10 @@ import { import { joinResolvedWorktreeScans, platformsByRepoId, + projectResolvedWorktreeScans, scanResolvedWorktreeRepos, type ResolvedWorktreeRepoScans, + type ResolvedWorktreeRowsJoin, type ResolvedWorktreeScanJoin } from './resolved-worktree-scan-join' import { projectResolvedWorktreeLineage } from '../../../shared/resolved-worktree-lineage' @@ -33,7 +35,7 @@ import { withUnregisteredRemovalCheckouts } from '../../workspaces/removal/workt import { withoutInFlightWorktreeCreates } from '../../workspaces/creation/worktree-create-in-flight' import { preserveFolderUpgradeWorktreePath } from '../../workspaces/catalog/folder-upgrade-worktree-path' import type { RuntimeWorktreeScanCache } from '../orca-runtime-core' -import { getRepoHostIdentityForParts } from '../../../shared/repo-host-identity' +import { getRepoHostIdentity } from '../../../shared/repo-host-identity' import { resolveWorktreeScanCacheTtlMs, resolveWorktreeScanRuntime @@ -158,26 +160,35 @@ export abstract class RuntimeWorkspaceCatalog extends OrcaRuntimeWithReadKittyFi ) } - protected projectCachedWorktrees(): ResolvedWorktreeScanJoin { - const scans: ResolvedWorktreeRepoScans = new Map() - for (const repo of this.store?.getRepos() ?? []) { - const cached = this.worktreeScanCache.get(`${repo.id}\0${getRepoExecutionHostId(repo)}`) - if (cached) { - scans.set(getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)), { - ...(cached.listing ?? cached.result), - gitRun: cached - }) - } - } - return joinResolvedWorktreeScans( + /** The fleet from cached scans for synchronous selector matching, reprojected only when its inputs change. */ + protected projectCachedWorktrees(): ResolvedWorktreeRowsJoin { + const cachedScans = (this.store?.getRepos() ?? []).map((repo) => { + const entry = this.worktreeScanCache.get(`${repo.id}\0${getRepoExecutionHostId(repo)}`) + return { repo, entry, scan: entry && (entry.listing ?? entry.result) } + }) + return this.resolvedWorktrees.projectCachedScans( this.store, - () => ({ - ...this.repoWorktreeRowDeps(), - listFolderWorkspaces: (repo, count) => - projectRuntimeFolderWorkspaces(this.requireStore(), repo, count) - }), - scans, - true + cachedScans.flatMap(({ repo, entry, scan }) => [getRepoHostIdentity(repo), entry, scan]), + () => { + const scans: ResolvedWorktreeRepoScans = new Map() + for (const { repo, entry, scan } of cachedScans) { + if (entry && scan) { + scans.set(getRepoHostIdentity(repo), { + ...scan, + gitRun: entry + }) + } + } + return projectResolvedWorktreeScans( + this.store, + () => ({ + ...this.repoWorktreeRowDeps(), + listFolderWorkspaces: (repo, count) => + projectRuntimeFolderWorkspaces(this.requireStore(), repo, count) + }), + scans + ) + } ) }