From 4828d095a92415fc39fc556514d52bcf879d3ec2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:12:23 -0800 Subject: [PATCH 01/19] feat(server): index worktrees for active agents, outside the repo A checkout only indexed when something asked it a code question, the file cap could leave a repo's own source out of its index, and the index folders sat inside the user's repo. T3 now indexes a project when it is added and a worktree when an agent starts in it. An idle worktree is not rebuilt, and its index goes when its thread settles, by the rules that let a worktree be deleted. Settings > Labs has one switch for all of this; while it is off, only a code question builds an index. Indexes now live in T3's data directory. The SDK only takes a data directory inside its project root, so each checkout gets a private root that links back to its top-level entries. Folders an older version left in a checkout are removed the next time it indexes, and a sweep drops indexes for checkouts that are gone or no longer known. Listings over the cap now rank first-party source first, builds run at low priority with at most two at once, and the badge reports first builds and failures. The Windows link and copy paths are untested. Co-Authored-By: Claude Opus 5.5 --- .../features/files/FileCodeRelationships.tsx | 4 +- .../settings/ThreadSettingsSections.tsx | 99 +- apps/server/src/config.ts | 3 + .../RunFinalizationService.test.ts | 34 +- .../testkit/ProviderReplayHarness.ts | 3 + .../knowledge/CodeIndexReactor.test.ts | 465 +++++++ .../src/project/knowledge/CodeIndexReactor.ts | 187 +++ .../knowledge/CodeIntelligence.test.ts | 1204 ++++++++++++++--- .../src/project/knowledge/CodeIntelligence.ts | 505 +++++-- .../src/project/knowledge/codeIndexLayout.ts | 187 +-- .../src/project/knowledge/codeIndexLocks.ts | 158 ++- .../knowledge/codeIntelligenceWorker.ts | 215 +-- apps/server/src/server.ts | 15 +- apps/server/src/storageCleanup.ts | 2 +- .../CodeExplorer.browser.test.tsx | 6 +- .../components/codeExplorer/CodeExplorer.tsx | 2 +- .../src/components/files/CodeIndexBadge.tsx | 4 +- .../files/FileCodeRelationships.tsx | 2 +- .../components/files/codeRelationshipRows.tsx | 2 +- .../components/settings/LabsSettingsPanel.tsx | 22 + .../src/components/settings/settingsSearch.ts | 7 + docs/user/code-navigation.md | 34 +- .../src/projectCode/index.test.ts | 78 +- .../client-runtime/src/projectCode/index.ts | 50 +- packages/contracts/src/settings.test.ts | 9 + packages/contracts/src/settings.ts | 6 + scripts/lib/code-intelligence-smoke.ts | 14 +- 27 files changed, 2733 insertions(+), 584 deletions(-) create mode 100644 apps/server/src/project/knowledge/CodeIndexReactor.test.ts create mode 100644 apps/server/src/project/knowledge/CodeIndexReactor.ts diff --git a/apps/mobile/src/features/files/FileCodeRelationships.tsx b/apps/mobile/src/features/files/FileCodeRelationships.tsx index 9321fd2eb0e54..f83095627d030 100644 --- a/apps/mobile/src/features/files/FileCodeRelationships.tsx +++ b/apps/mobile/src/features/files/FileCodeRelationships.tsx @@ -50,7 +50,7 @@ function coverageLine(coverage: ProjectCodeCoverage): string { case "stale": return "Stale ยท some files changed since indexing. Verify in source."; case "unavailable": - return "The code index is unavailable for this checkout."; + return "The code index is not ready for this checkout."; } } @@ -220,7 +220,7 @@ function Body(props: { return ( {coverageLine(data.coverage)} - {data.nodes.length === 0 ? ( + {data.coverage.state === "unavailable" ? null : data.nodes.length === 0 ? ( No indexed declarations in this file. Its language may be unsupported. ) : ( data.nodes.map((node) => ( diff --git a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx index 4952f9a2f20ca..f641b0c95602e 100644 --- a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx +++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx @@ -233,40 +233,81 @@ function BetaSettingsSection() { environment.connection.phase === "connected" && environment.serverConfig?.environment.capabilities.peerLinks === true, ); - const writable = useEnvironmentsWithScope(supported, AuthSettingsWriteScope); + const indexing = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.projectCode === true, + ); + const writable = useEnvironmentsWithScope(environments, AuthSettingsWriteScope); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "linked environments beta update", reportFailure: true, }); - if (supported.length === 0) return null; + if (supported.length === 0 && indexing.length === 0) return null; return ( - - - {supported.map((environment) => { - const policy = environment.serverConfig?.managedPolicy; - const managed = isManagedSettingPath(policy, "linkedEnvironmentsEnabled"); - return ( - - void updateSettings({ - environmentId: environment.environmentId, - input: { patch: { linkedEnvironmentsEnabled: value } }, - }) - } - /> - ); - })} - - - Let agents on a machine work on your other machines, and theirs on it. Turn it on for each - machine. While it is off, nothing new starts over a link; tasks already running finish. - + + {supported.length === 0 ? null : ( + + + {supported.map((environment) => { + const policy = environment.serverConfig?.managedPolicy; + const managed = isManagedSettingPath(policy, "linkedEnvironmentsEnabled"); + return ( + + void updateSettings({ + environmentId: environment.environmentId, + input: { patch: { linkedEnvironmentsEnabled: value } }, + }) + } + /> + ); + })} + + + Let agents on a machine work on your other machines, and theirs on it. Turn it on for + each machine. While it is off, nothing new starts over a link; tasks already running + finish. + + + )} + {indexing.length === 0 ? null : ( + + + {indexing.map((environment) => { + const policy = environment.serverConfig?.managedPolicy; + const managed = isManagedSettingPath(policy, "autoCodeIndexEnabled"); + return ( + + void updateSettings({ + environmentId: environment.environmentId, + input: { patch: { autoCodeIndexEnabled: value } }, + }) + } + /> + ); + })} + + + Index a project when you add it, and a worktree while an agent works in it. A worktree's + index is removed when its thread settles. While this is off, a checkout only indexes + when something asks it a code question. + + + )} ); } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index a411e31222df4..d1753f0f7c363 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -45,6 +45,7 @@ export interface ServerDerivedPaths { readonly providerStatusCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; + readonly codeIndexDir: string; readonly browserArtifactsDir: string; readonly logsDir: string; readonly serverTracePath: string; @@ -161,6 +162,7 @@ export const deriveServerPaths = Effect.fn(function* ( providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, + codeIndexDir: join(stateDir, "code-index"), browserArtifactsDir: join(stateDir, "browser-artifacts"), logsDir, serverTracePath: join(logsDir, "server.trace.ndjson"), @@ -185,6 +187,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }), + fs.makeDirectory(derivedPaths.codeIndexDir, { recursive: true }), fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 58a0201445298..f873803ceaa6d 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -10,6 +10,7 @@ import { ProjectId, RunId, ThreadId, + VcsProcessExitError, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -19,9 +20,14 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as CodeIntelligence from "../project/knowledge/CodeIntelligence.ts"; +import { indexRootFor } from "../project/knowledge/codeIndexLayout.ts"; +import * as ServerConfig from "../config.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -216,6 +222,13 @@ it.effect("finalizes a run without waiting for the code index refresh its checkp const threadId = ThreadId.make("thread_index_refresh"); const runId = RunId.make("run_index_refresh"); const scopeId = CheckpointScopeId.make("scope_index_refresh"); + const configContext = yield* Layer.build( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-finalize-index-config-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ); + const config = yield* ServerConfig.ServerConfig.pipe(Effect.provide(configContext)); + const indexRoot = indexRootFor(config.codeIndexDir, root); const layer = RunFinalization.layer.pipe( Layer.provideMerge(RunFinalization.layerObserver), Layer.provideMerge(CodeIntelligence.layer), @@ -254,6 +267,19 @@ it.effect("finalizes a run without waiting for the code index refresh its checkp truncated: false, }), }), + Layer.mock(VcsDriver.VcsDriver)({ + checkpoints: {} as VcsDriver.VcsDriver["Service"]["checkpoints"], + listWorkspaceFiles: () => + Effect.fail( + new VcsProcessExitError({ + operation: "test", + command: "git ls-files", + cwd: root, + exitCode: 128, + detail: "not a git repository", + }), + ), + }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void, }), @@ -268,6 +294,8 @@ it.effect("finalizes a run without waiting for the code index refresh its checkp workingTree: { files: [], insertions: 0, deletions: 0 }, }), }), + ServerSettings.layerTest(), + Layer.succeed(ServerConfig.ServerConfig, config), ), ), ); @@ -281,12 +309,12 @@ it.effect("finalizes a run without waiting for the code index refresh its checkp const finalize = finalization.finalize({ threadId, runId, scopeId }); const published = () => Effect.promise(() => - NodeFSP.readFile(NodePath.join(root, ".codegraph-t3/published"), "utf8"), + NodeFSP.readFile(NodePath.join(indexRoot, ".codegraph-t3/published"), "utf8"), ); // A checkout nobody has queried is not indexed behind the user's back. yield* finalize; - assert.isFalse(NodeFS.existsSync(NodePath.join(root, ".codegraph-t3"))); + assert.isFalse(NodeFS.existsSync(indexRoot)); assert.deepEqual(yield* intelligence.refresh({ projectId }), { _tag: "Published", @@ -299,7 +327,7 @@ it.effect("finalizes a run without waiting for the code index refresh its checkp const buildHeld = yield* Deferred.make(); const releaseBuild = yield* Deferred.make(); const lease = yield* withWorkspaceLease( - NodePath.join(root, ".codegraph-t3"), + indexRoot, Deferred.succeed(buildHeld, undefined).pipe( Effect.andThen(Deferred.await(releaseBuild)), ), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 4bcb5b0248c87..8be70efd197ac 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -96,6 +96,7 @@ export function makeReplayServerConfig( const providerLogsDir = path.join(logsDir, "provider"); const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); + const codeIndexDir = path.join(stateDir, "code-index"); const environmentThemesDir = path.join(stateDir, "themes"); const worktreesDir = path.join(baseDir, "worktrees"); const providerStatusCacheDir = path.join(baseDir, "caches"); @@ -106,6 +107,7 @@ export function makeReplayServerConfig( providerLogsDir, terminalLogsDir, attachmentsDir, + codeIndexDir, environmentThemesDir, worktreesDir, providerStatusCacheDir, @@ -149,6 +151,7 @@ export function makeReplayServerConfig( providerStatusCacheDir, worktreesDir, attachmentsDir, + codeIndexDir: path.join(stateDir, "code-index"), browserArtifactsDir: path.join(stateDir, "browser-artifacts"), environmentThemesDir, logsDir, diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts new file mode 100644 index 0000000000000..5415b905ae9f2 --- /dev/null +++ b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts @@ -0,0 +1,465 @@ +import { + DEFAULT_SERVER_SETTINGS, + EventId, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, + type ApplicationStoredEvent, + type OrchestrationV2Run, + type OrchestrationV2ThreadShell, + type ServerSettings, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { assert, describe, it } from "@effect/vitest"; + +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; +import * as OrchestrationEventStore from "../../persistence/OrchestrationEventStore.ts"; +import * as ServerSettingsService from "../../serverSettings.ts"; +import * as CodeIndexReactor from "./CodeIndexReactor.ts"; +import * as CodeIntelligence from "./CodeIntelligence.ts"; + +const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); +const DAY_MS = 86_400_000; +const PROJECT_ID = ProjectId.make("project-index-reactor"); +const WORKTREE = "/worktrees/feature"; +const now = DateTime.makeUnsafe(NOW_MS); + +function at(offsetMs: number): DateTime.Utc { + return DateTime.makeUnsafe(NOW_MS + offsetMs); +} + +function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { + return { + id: ThreadId.make("thread-1"), + projectId: PROJECT_ID, + title: "Thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + worktreePath: WORKTREE, + branch: "feature", + activeProviderThreadId: null, + lineage: { + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: null, + relationshipToParent: null, + }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + activeRunId: null, + latestVisibleMessage: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + lastVisitedAt: null, + deletedAt: null, + linkedPullRequest: null, + status: "idle", + activityRunStatus: null, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + latestRunId: null, + latestRunRequestedAt: null, + latestRunStartedAt: null, + latestRunCompletedAt: null, + latestUserMessageAt: null, + createdAt: at(-30 * DAY_MS), + updatedAt: at(-10 * DAY_MS), + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + ...overrides, + }; +} + +describe("worktreeIndexReleasable", () => { + const settled = (overrides: Partial = {}) => + shell({ settledOverride: "settled", settledAt: at(-60_000), ...overrides }); + const subagent = (overrides: Partial = {}) => + shell({ + id: ThreadId.make("thread-subagent"), + lineage: { + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + relationshipToParent: "subagent", + }, + ...overrides, + }); + const unrelated = (overrides: Partial = {}) => + shell({ + id: ThreadId.make("thread-2"), + lineage: { + rootThreadId: ThreadId.make("thread-2"), + parentThreadId: null, + relationshipToParent: null, + }, + ...overrides, + }); + + it.each([ + { name: "one settled thread", group: () => [settled()], expected: true }, + { name: "one unsettled thread", group: () => [shell()], expected: false }, + { + name: "a settled parent with an idle subagent", + group: () => [settled(), subagent({ settledOverride: "settled", settledAt: at(-30_000) })], + expected: true, + }, + { + name: "a settled parent with an idle subagent nobody settled", + group: () => [settled(), subagent()], + expected: true, + }, + { + name: "a settled parent with a running subagent", + group: () => [settled(), subagent({ status: "running", activeRunId: RunId.make("run-1") })], + expected: false, + }, + { + name: "two unrelated threads, both settled", + group: () => [settled(), unrelated({ settledOverride: "settled", settledAt: at(-90_000) })], + expected: true, + }, + { + name: "two unrelated threads, one unsettled", + group: () => [settled(), unrelated()], + expected: false, + }, + { name: "no threads", group: () => [], expected: false }, + ])("$name", ({ group, expected }) => { + assert.equal( + CodeIndexReactor.worktreeIndexReleasable(group(), NOW_MS, DEFAULT_SERVER_SETTINGS), + expected, + ); + }); +}); + +function runEvent(runId: RunId, status: OrchestrationV2Run["status"]): ApplicationStoredEvent { + return { + sequence: 1, + commandId: null, + event: { + type: "run.updated", + occurredAt: now, + payload: { + id: runId, + threadId: ThreadId.make("thread-1"), + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + providerThreadId: null, + userMessageId: "message-1", + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + } as unknown as ApplicationStoredEvent; +} + +function settledEvent(thread: OrchestrationV2ThreadShell): ApplicationStoredEvent { + return { + sequence: 2, + commandId: null, + event: { type: "thread.settled", occurredAt: now, payload: thread }, + } as unknown as ApplicationStoredEvent; +} + +/** A project event whose effect marks the end of the queue, proving earlier ones were skipped. */ +function sentinelEvent(projectId: ProjectId): ApplicationStoredEvent { + return { + sequence: 3, + eventId: EventId.make("event-sentinel"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { projectId }, + } as unknown as ApplicationStoredEvent; +} + +interface Harness { + readonly send: (event: ApplicationStoredEvent) => Effect.Effect; + /** Effects the reactor asked for, in order. */ + readonly effects: Queue.Queue; + /** The owners the activation sweep's orphan pass rejected. */ + readonly orphansRejected: Effect.Effect>; + /** Resolves once the sweep asks whether a worktree has an index. */ + readonly indexChecked: Effect.Effect; + readonly settings: PubSub.PubSub; + /** The next effect the reactor asks for, waiting for it if needed. */ + readonly next: Effect.Effect; + /** Sends a sentinel and returns every effect recorded before it. */ + readonly effectsBeforeSentinel: Effect.Effect>; +} + +const SENTINEL = ProjectId.make("project-sentinel"); +const SENTINEL_EFFECT = `ensure:${SENTINEL}`; + +function reactorHarness(options: { + readonly threads: ReadonlyArray; + readonly projectRoots?: ReadonlyArray; + readonly autoCodeIndexEnabled?: boolean; + /** Worktrees the mocked service reports an index for. */ + readonly indexed?: ReadonlyArray; + /** Checkouts the mocked orphan sweep would find an index for. */ + readonly orphanOwners?: ReadonlyArray; +}): Effect.Effect { + return Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const effects = yield* Queue.unbounded(); + const orphansRejected = yield* Deferred.make>(); + const indexChecked = yield* Deferred.make(); + const settings = yield* PubSub.unbounded(); + const threadsById = new Map(options.threads.map((thread) => [thread.id, thread])); + const initial = { + ...DEFAULT_SERVER_SETTINGS, + autoCodeIndexEnabled: options.autoCodeIndexEnabled ?? true, + }; + + const layer = CodeIndexReactor.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CodeIntelligence.CodeIntelligence)({ + ensureProjectIndexed: (projectId) => + Queue.offer(effects, `ensure:${projectId}`).pipe(Effect.asVoid), + refreshForActiveAgent: (target) => + Queue.offer(effects, `refresh:${target.threadId}`).pipe(Effect.asVoid), + removeCheckoutIndex: (checkoutPath) => + Queue.offer(effects, `remove:${checkoutPath}`).pipe(Effect.asVoid), + hasCheckoutIndex: (checkoutPath) => + Deferred.succeed(indexChecked, undefined).pipe( + Effect.as((options.indexed ?? []).includes(checkoutPath)), + ), + removeOrphanedIndexes: (isKnown) => + Deferred.succeed( + orphansRejected, + (options.orphanOwners ?? []).filter((owner) => !isKnown(owner)), + ).pipe(Effect.asVoid), + stopBuilds: Queue.offer(effects, "stop").pipe(Effect.asVoid), + }), + Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({ + latestApplicationSequence: Effect.succeed(0), + streamApplicationEvents: () => Stream.fromQueue(events), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadWorktreePaths: () => + Effect.succeed( + options.threads.flatMap((thread) => + thread.worktreePath === null + ? [] + : [{ threadId: thread.id, worktreePath: thread.worktreePath }], + ), + ), + getThreadShell: (threadId) => Effect.succeed(threadsById.get(threadId) ?? null), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => + Effect.succeed( + (options.projectRoots ?? []).map((workspaceRoot) => ({ workspaceRoot })), + ) as unknown as ReturnType, + }), + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed(initial), + subscribeChanges: PubSub.subscribe(settings).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }), + Path.layer, + ), + ), + ); + yield* Layer.build(layer).pipe(Effect.orDie); + + const untilSentinel = ( + collected: ReadonlyArray, + ): Effect.Effect> => + Queue.take(effects).pipe( + Effect.flatMap((effect) => + effect === SENTINEL_EFFECT + ? Effect.succeed(collected) + : untilSentinel([...collected, effect]), + ), + ); + return { + send: (event) => Queue.offer(events, event).pipe(Effect.asVoid), + effects, + orphansRejected: Deferred.await(orphansRejected), + indexChecked: Deferred.await(indexChecked), + settings, + next: Queue.take(effects), + // Events process in order, so the sentinel's effect proves every earlier one finished. + effectsBeforeSentinel: Queue.offer(events, sentinelEvent(SENTINEL)).pipe( + Effect.andThen(untilSentinel([])), + ), + }; + }); +} + +/** Records the checkouts a sweep's `known` predicate rejected, for its orphan pass. */ + +describe("CodeIndexReactor", () => { + it.effect("requests an index once per run, and again for a new run", () => + Effect.gen(function* () { + const harness = yield* reactorHarness({ threads: [shell()] }); + const run = RunId.make("run-1"); + yield* harness.send(runEvent(RunId.make("run-0"), "waiting")); + // A run's status flips through `running` during approvals; each request can spawn a process. + yield* harness.send(runEvent(run, "running")); + yield* harness.send(runEvent(run, "waiting")); + yield* harness.send(runEvent(run, "running")); + yield* harness.send(runEvent(RunId.make("run-2"), "running")); + yield* harness.send(runEvent(RunId.make("run-2"), "running")); + yield* harness.send(runEvent(RunId.make("run-2"), "completed")); + assert.deepEqual(yield* harness.effectsBeforeSentinel, [ + `refresh:${ThreadId.make("thread-1")}`, + `refresh:${ThreadId.make("thread-1")}`, + ]); + }), + ); + + it.effect("removes a settled worktree's index", () => + Effect.gen(function* () { + const thread = shell({ settledOverride: "settled", settledAt: at(-60_000) }); + const harness = yield* reactorHarness({ threads: [thread] }); + yield* harness.send(settledEvent(thread)); + assert.deepEqual(yield* harness.effectsBeforeSentinel, [`remove:${WORKTREE}`]); + }), + ); + + it.effect("keeps a worktree's index while another thread in it is unsettled", () => + Effect.gen(function* () { + const settled = shell({ settledOverride: "settled", settledAt: at(-60_000) }); + const unrelated = shell({ + id: ThreadId.make("thread-2"), + lineage: { + rootThreadId: ThreadId.make("thread-2"), + parentThreadId: null, + relationshipToParent: null, + }, + }); + const harness = yield* reactorHarness({ threads: [settled, unrelated] }); + yield* harness.send(settledEvent(settled)); + assert.deepEqual(yield* harness.effectsBeforeSentinel, []); + }), + ); + + it.effect("keeps the index of a worktree that is a project root", () => + Effect.gen(function* () { + const thread = shell({ settledOverride: "settled", settledAt: at(-60_000) }); + const harness = yield* reactorHarness({ threads: [thread], projectRoots: [WORKTREE] }); + yield* harness.send(settledEvent(thread)); + assert.deepEqual(yield* harness.effectsBeforeSentinel, []); + }), + ); + + it.effect("removes nothing when automatic indexing is off", () => + Effect.gen(function* () { + const worktreePath = "/worktrees/idle"; + const thread = shell({ worktreePath, settledOverride: "settled", settledAt: at(-60_000) }); + const harness = yield* reactorHarness({ + threads: [thread], + indexed: [worktreePath], + autoCodeIndexEnabled: false, + }); + yield* harness.send(settledEvent(thread)); + assert.deepEqual(yield* harness.effectsBeforeSentinel, []); + // The activation sweep is its own fiber, scheduled by the time a second round trip ends. + assert.deepEqual(yield* harness.effectsBeforeSentinel, []); + }), + ); + + it.effect("removes orphaned indexes even while automatic indexing is off", () => + Effect.gen(function* () { + const harness = yield* reactorHarness({ + threads: [shell()], + autoCodeIndexEnabled: false, + orphanOwners: ["/worktrees/gone", WORKTREE], + }); + // A known worktree is kept; the deleted checkout is the only rejection. + assert.deepEqual(yield* harness.orphansRejected, ["/worktrees/gone"]); + }), + ); + + it.effect("treats project roots and thread worktrees as known checkouts", () => + Effect.gen(function* () { + const harness = yield* reactorHarness({ + threads: [shell()], + projectRoots: ["/projects/one"], + orphanOwners: ["/projects/one", WORKTREE], + }); + assert.deepEqual(yield* harness.orphansRejected, []); + }), + ); + + it.effect("does not count a deleted project as a known checkout", () => + Effect.gen(function* () { + // `listShells` omits deleted projects, so its roots are absent from the known set. + const harness = yield* reactorHarness({ + threads: [shell()], + projectRoots: [], + orphanOwners: ["/projects/deleted"], + }); + assert.deepEqual(yield* harness.orphansRejected, ["/projects/deleted"]); + }), + ); + + it.effect("stops running builds when automatic indexing is turned off", () => + Effect.gen(function* () { + const harness = yield* reactorHarness({ threads: [shell()] }); + yield* PubSub.publish(harness.settings, { + ...DEFAULT_SERVER_SETTINGS, + autoCodeIndexEnabled: false, + }); + assert.equal(yield* harness.next, "stop"); + }), + ); + + it.effect("sweeps the index of a worktree whose thread settled before this feature", () => + Effect.gen(function* () { + const worktreePath = "/worktrees/settled"; + const thread = shell({ + worktreePath, + settledOverride: "settled", + settledAt: at(-60_000), + }); + // Activation sweeps, so the removal arrives without any event. + const harness = yield* reactorHarness({ threads: [thread], indexed: [worktreePath] }); + assert.equal(yield* harness.next, `remove:${worktreePath}`); + }), + ); + + it.effect("skips a settled worktree that has no index", () => + Effect.gen(function* () { + const thread = shell({ settledOverride: "settled", settledAt: at(-60_000) }); + // The sweep asks the service whether an index exists, rather than checking the checkout. + const harness = yield* reactorHarness({ threads: [thread], indexed: [] }); + yield* harness.indexChecked; + assert.deepEqual(yield* harness.effectsBeforeSentinel, []); + // The sweep is its own fiber, scheduled by the time a second round trip ends. + assert.deepEqual(yield* harness.effectsBeforeSentinel, []); + }), + ); +}); diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.ts b/apps/server/src/project/knowledge/CodeIndexReactor.ts new file mode 100644 index 0000000000000..209e2bff62c6d --- /dev/null +++ b/apps/server/src/project/knowledge/CodeIndexReactor.ts @@ -0,0 +1,187 @@ +import type { + ApplicationStoredEvent, + OrchestrationV2ThreadShell, + RunId, + ServerSettingsError, + ThreadId, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; + +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; +import * as OrchestrationEventStore from "../../persistence/OrchestrationEventStore.ts"; +import { forkParked } from "../../serverActivation.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import { storageCleanupThreadIdle, worktreeOwner } from "../../storageCleanup.ts"; +import * as CodeIntelligence from "./CodeIntelligence.ts"; + +const SWEEP_INTERVAL = "1 hour"; + +/** + * Whether a worktree's index can go: every thread that lives in it is settled and idle, with a + * subagent or side chat following its owner rather than holding the worktree open. + */ +export function worktreeIndexReleasable( + group: ReadonlyArray, + now: number, + settings: Parameters[2], +): boolean { + if (group.length === 0) return false; + const owner = worktreeOwner(group); + // A subagent or side-chat chain follows its owner; unrelated threads must all be settled. + const settled = + owner !== undefined ? owner.settledAt !== null : group.every((t) => t.settledAt !== null); + return settled && group.every((t) => storageCleanupThreadIdle(t, now, settings)); +} + +/** + * Keeps the code index in step with the work happening in a checkout: a project indexes when it + * is added, a worktree while an agent runs in it, and a worktree's index goes when its thread + * settles. A sweep also drops indexes for checkouts that no longer exist, even while automatic + * indexing is off. + */ +const make = Effect.gen(function* () { + const codeIntelligence = yield* CodeIntelligence.CodeIntelligence; + const events = yield* OrchestrationEventStore.OrchestrationEventStore; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const projectStore = yield* ProjectStore.ProjectStoreV2; + const settingsService = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + + const autoIndexEnabled = settingsService.getSettings.pipe( + Effect.map((settings) => settings.autoCodeIndexEnabled), + Effect.orElseSucceed(() => false), + ); + + const releaseWorktreeIndex = Effect.fn("CodeIndexReactor.releaseWorktreeIndex")(function* ( + worktreePath: string, + ) { + if (!(yield* autoIndexEnabled)) return; + const target = path.resolve(worktreePath); + const entries = yield* projections.getThreadWorktreePaths({ includeArchived: true }); + const group = (yield* Effect.forEach( + entries.filter((entry) => path.resolve(entry.worktreePath) === target), + (entry) => projections.getThreadShell(entry.threadId), + )).filter((thread) => thread !== null); + const settings = yield* settingsService.getSettings; + const now = yield* Clock.currentTimeMillis; + if (!worktreeIndexReleasable(group, now, settings)) return; + // A worktree registered as its own project keeps its index. + const projects = yield* projectStore.listShells(); + if (projects.some((project) => path.resolve(project.workspaceRoot) === target)) return; + yield* codeIntelligence.removeCheckoutIndex(target); + }); + + const sweep = Effect.fn("CodeIndexReactor.sweep")(function* () { + const entries = yield* projections.getThreadWorktreePaths({ includeArchived: true }); + // `listShells` omits deleted projects, so a deleted project is not a known checkout. + const projects = yield* projectStore.listShells(); + const known = new Set( + [ + ...projects.map((project) => project.workspaceRoot), + ...entries.map((e) => e.worktreePath), + ].map((knownPath) => path.resolve(knownPath)), + ); + // Even with automatic indexing off: this only cleans T3's own data for things that are gone. + yield* codeIntelligence.removeOrphanedIndexes((checkoutPath) => + known.has(path.resolve(checkoutPath)), + ); + if (!(yield* autoIndexEnabled)) return; + // De-duplicated first: hundreds of threads share a path, and each check hits the disk. + const candidates = [...new Set(entries.map((entry) => path.resolve(entry.worktreePath)))]; + // Sequentially: each release waits on the checkout's build lock. + for (const worktreePath of candidates) { + if (!(yield* codeIntelligence.hasCheckoutIndex(worktreePath))) continue; + yield* releaseWorktreeIndex(worktreePath).pipe( + Effect.catchCause((cause) => + Effect.logWarning("code index sweep failed for a worktree", { worktreePath, cause }), + ), + ); + } + }); + + // Only a run's first `running` counts: its status flips during approvals, and each request can + // spawn a process. + const activeRuns = new Map(); + + const refreshRun = Effect.fn("CodeIndexReactor.refreshRun")(function* (threadId: ThreadId) { + const thread = yield* projections.getThreadShell(threadId); + if (thread === null) return; + yield* codeIntelligence.refreshForActiveAgent({ projectId: thread.projectId, threadId }); + }); + + const releaseSettled = Effect.fn("CodeIndexReactor.releaseSettled")(function* ( + threadId: ThreadId, + settledWorktreePath: string | null, + ) { + const shell = yield* projections.getThreadShell(threadId); + const worktreePath = shell?.worktreePath ?? settledWorktreePath; + if (worktreePath === null) return; + yield* releaseWorktreeIndex(worktreePath); + }); + + const handle = ( + stored: ApplicationStoredEvent, + ): Effect.Effect< + void, + ProjectionStore.ProjectionStoreV2Error | ProjectStore.ProjectStoreV2Error | ServerSettingsError + > => { + // Most of the stream is turn items; a plain switch keeps those free. + if (!("event" in stored)) { + return stored.type === "project.created" + ? codeIntelligence.ensureProjectIndexed(stored.payload.projectId) + : Effect.void; + } + const event = stored.event; + if (event.type === "thread.settled") { + activeRuns.delete(event.payload.id); + return releaseSettled(event.payload.id, event.payload.worktreePath); + } + if (event.type !== "run.created" && event.type !== "run.updated") return Effect.void; + const run = event.payload; + if (run.status !== "running" || activeRuns.get(run.threadId) === run.id) return Effect.void; + activeRuns.set(run.threadId, run.id); + return refreshRun(run.threadId); + }; + const guarded = (stored: ApplicationStoredEvent) => + // One unreadable thread must not end indexing for every later event. + handle(stored).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to handle a code indexing event", { cause }), + ), + ); + + const afterSequence = yield* events.latestApplicationSequence; + yield* forkParked( + Stream.runForEach(events.streamApplicationEvents({ afterSequence }), guarded).pipe( + Effect.catchCause((cause) => Effect.logWarning("code index event stream failed", { cause })), + ), + ); + + yield* forkParked( + sweep().pipe( + Effect.catchCause((cause) => Effect.logWarning("code index sweep failed", { cause })), + Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)), + Effect.asVoid, + ), + ); + + let lastAutoIndex = yield* autoIndexEnabled; + const changes = yield* settingsService.subscribeChanges; + yield* forkParked( + Stream.runForEach(changes, (settings) => { + const enabled = settings.autoCodeIndexEnabled; + const wasEnabled = lastAutoIndex; + lastAutoIndex = enabled; + // Turning it off stops builds already running; turning it on waits for the next turn. + return wasEnabled && !enabled ? codeIntelligence.stopBuilds : Effect.void; + }), + ); +}); + +export const layer = Layer.effectDiscard(make); diff --git a/apps/server/src/project/knowledge/CodeIntelligence.test.ts b/apps/server/src/project/knowledge/CodeIntelligence.test.ts index 49a184d9f9e14..34844cf6c8837 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.test.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.test.ts @@ -10,19 +10,23 @@ import * as NodeURL from "node:url"; import * as NodeWorkerThreads from "node:worker_threads"; import * as NodeCryptoEffect from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { EnvironmentId, OrchestrationV2AppThread, ProjectId, ThreadId, + VcsProcessExitError, type ProjectCodeIndexEvent, + type VcsListWorkspaceFilesResult, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import type { PlatformError } from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -31,15 +35,29 @@ import { McpSchema, McpServer } from "effect/ai"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ServerConfig from "../../config.ts"; import * as McpHttpServer from "../../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../../mcp/McpInvocationContext.ts"; import * as McpToolAccessTestkit from "../../mcp/McpToolAccess.testkit.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as VcsDriver from "../../vcs/VcsDriver.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; import { withWorkspaceLease } from "../../workspace/workspaceLease.ts"; import * as CodeIntelligence from "./CodeIntelligence.ts"; +import { indexRootFor } from "./codeIndexLayout.ts"; +import { pinPublishedGeneration } from "./codeIndexLocks.ts"; +import { rankListing } from "./codeIntelligenceWorker.ts"; import * as FileHistory from "./FileHistory.ts"; +const notARepository = new VcsProcessExitError({ + operation: "test", + command: "git ls-files", + cwd: "/", + exitCode: 128, + detail: "not a git repository", +}); + const projectId = ProjectId.make("project:code-intelligence"); const threadId = ThreadId.make("thread:code-intelligence"); const worktreeThreadId = ThreadId.make("thread:code-intelligence-worktree"); @@ -109,19 +127,43 @@ function withFixture( type Query = Parameters[0]["query"]; /** The published generation's directory name, read the way a fresh reader would. */ -async function publishedDirectory(root: string) { +async function publishedDirectory(indexRoot: string) { const generation = ( - await NodeFSP.readFile(NodePath.join(root, ".codegraph-t3/published"), "utf8") + await NodeFSP.readFile(NodePath.join(indexRoot, ".codegraph-t3/published"), "utf8") ).trim(); return `.codegraph-t3-g${generation}`; } -async function generationDirectories(root: string) { - return (await NodeFSP.readdir(root)) +async function generationDirectories(indexRoot: string) { + return (await NodeFSP.readdir(indexRoot).catch(() => [] as string[])) .filter((name) => /^\.codegraph-t3-g\d+$/.test(name)) .toSorted(); } +/** + * A server config whose data directory is outside the fixture, and the index root it implies for + * a checkout. + */ +interface CheckoutConfig { + readonly layer: Layer.Layer; + readonly codeIndexDir: string; + readonly indexRoot: (checkoutPath: string) => string; +} + +function checkoutConfig(): Effect.Effect { + return Effect.gen(function* () { + const context = yield* Layer.build( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-code-index-" }), + ); + const config = yield* ServerConfig.ServerConfig.pipe(Effect.provide(context)); + return { + layer: Layer.succeed(ServerConfig.ServerConfig, config), + codeIndexDir: config.codeIndexDir, + indexRoot: (checkoutPath: string) => indexRootFor(config.codeIndexDir, checkoutPath), + }; + }).pipe(Effect.provide(NodeServices.layer)); +} + it.effect( "serves bounded multilingual graph queries from the authorized project and selected worktree", () => @@ -151,6 +193,8 @@ it.effect( worktreePath: worktree, } as unknown as OrchestrationV2AppThread; let includeGeneratedInventoryFiles = false; + const { layer: configLayer, indexRoot } = yield* checkoutConfig(); + const indexRootPath = indexRoot(root); const dependencies = Layer.mergeAll( Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeedSome(project), @@ -177,13 +221,13 @@ it.effect( if ( cwd === root && includeGeneratedInventoryFiles && - (await NodeFSP.stat(NodePath.join(root, ".codegraph-t3/.gitignore")) + (await NodeFSP.stat(NodePath.join(indexRootPath, ".codegraph-t3/published")) .then(() => true) .catch(() => false)) ) { entries.push( ".codegraph-t3/.gitignore", - `${await publishedDirectory(root)}/.gitignore`, + `${await publishedDirectory(indexRootPath)}/.gitignore`, ".codegraph-t3-copy/user.txt", ); } @@ -193,6 +237,12 @@ it.effect( }; }), }), + Layer.mock(VcsDriver.VcsDriver)({ + checkpoints: {} as VcsDriver.VcsDriver["Service"]["checkpoints"], + listWorkspaceFiles: () => Effect.fail(notARepository), + }), + ServerSettings.layerTest(), + configLayer, ); const provided = Layer.provide(CodeIntelligence.layer, dependencies); const intelligence = yield* CodeIntelligence.CodeIntelligence.pipe( @@ -219,7 +269,7 @@ it.effect( })().catch((error) => parentPort.postMessage({ error: String(error) }));`, { eval: true, - workerData: { requirePath: codeGraphRequirePath, root }, + workerData: { requirePath: codeGraphRequirePath, root: indexRootPath }, env: { ...process.env, CODEGRAPH_DIR: ".codegraph-t3-g1" }, }, ); @@ -236,8 +286,8 @@ it.effect( if (code !== 0) reject(new Error(`Seed index worker exited with code ${code}.`)); }); }); - await NodeFSP.mkdir(NodePath.join(root, ".codegraph-t3")); - await NodeFSP.writeFile(NodePath.join(root, ".codegraph-t3/published"), "1"); + await NodeFSP.mkdir(NodePath.join(indexRootPath, ".codegraph-t3")); + await NodeFSP.writeFile(NodePath.join(indexRootPath, ".codegraph-t3/published"), "1"); }); const staleLinkedSymbols = yield* runQuery({ @@ -248,7 +298,7 @@ it.effect( assert.equal(staleLinkedSymbols.index?.generation, 0); // The successor is rebuilt rather than copied, so the links the seed indexed are gone. yield* refresh(); - const persistedDirectory = yield* Effect.promise(() => publishedDirectory(root)); + const persistedDirectory = yield* Effect.promise(() => publishedDirectory(indexRootPath)); assert.notEqual(persistedDirectory, ".codegraph-t3-g1"); const persistedLinkedSymbols = yield* Effect.promise( () => @@ -264,7 +314,7 @@ it.effect( })().catch((error) => parentPort.postMessage({ error: String(error) }));`, { eval: true, - workerData: { requirePath: codeGraphRequirePath, root }, + workerData: { requirePath: codeGraphRequirePath, root: indexRootPath }, env: { ...process.env, CODEGRAPH_DIR: persistedDirectory }, }, ); @@ -280,8 +330,11 @@ it.effect( ); assert.equal(persistedLinkedSymbols, 0); yield* Effect.promise(async () => { - for (const name of [".codegraph-t3", ...(await generationDirectories(root))]) { - await NodeFSP.rm(NodePath.join(root, name), { recursive: true, force: true }); + for (const name of [".codegraph-t3", ...(await generationDirectories(indexRootPath))]) { + await NodeFSP.rm(NodePath.join(indexRootPath, name), { + recursive: true, + force: true, + }); } }); @@ -295,7 +348,7 @@ it.effect( ["unindexed", "unindexed"], ); assert.deepEqual(yield* refresh(), { _tag: "Unchanged" }); - assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), [ + assert.deepEqual(yield* Effect.promise(() => generationDirectories(indexRootPath)), [ ".codegraph-t3-g1", ]); const [concurrentStatus, concurrentSearch] = yield* Effect.all( @@ -533,7 +586,13 @@ it.effect( assert.equal(repeatedStatus.coverage.filesListed, 5); assert.equal(repeatedStatus.coverage.filesUnsupported, 1); const graphDirectoryExists = yield* Effect.promise(() => - NodeFSP.stat(NodePath.join(root, ".codegraph-t3")).then(() => true), + NodeFSP.stat(NodePath.join(indexRootPath, ".codegraph-t3")).then(() => true), + ); + const checkoutControlExists = yield* Effect.promise(() => + NodeFSP.stat(NodePath.join(root, ".codegraph-t3")).then( + () => true, + () => false, + ), ); const standardIndexExists = yield* Effect.promise(() => NodeFSP.stat(NodePath.join(root, ".codegraph")).then( @@ -548,6 +607,7 @@ it.effect( ), ); assert.equal(graphDirectoryExists, true); + assert.equal(checkoutControlExists, false); assert.equal(standardIndexExists, false); assert.equal(rootGitignoreExists, false); @@ -613,31 +673,8 @@ it.effect( false, ); assert.equal(invalidUtf8.coverage.state, "stale"); - // Settles the build that query started before the index directory is replaced. + // Settles the build that query started. yield* refresh(); - - yield* Effect.promise(async () => { - await NodeFSP.rm(NodePath.join(root, ".codegraph-t3"), { - recursive: true, - force: true, - }); - await NodeFSP.rm(NodePath.join(worktree, ".codegraph-t3"), { - recursive: true, - force: true, - }); - await NodeFSP.symlink(worktree, NodePath.join(root, ".codegraph-t3")); - }); - const indexPathError = yield* Effect.flip(runQuery({ operation: "status" })); - assert.equal(indexPathError._tag, "CodeIntelligencePathOutsideCheckout"); - assert.equal( - yield* Effect.promise(() => - NodeFSP.stat(NodePath.join(worktree, ".codegraph-t3")).then( - () => true, - () => false, - ), - ), - false, - ); }), ), ); @@ -689,7 +726,7 @@ it.effect("reports index freshness against the checkout and walks usages to reac return git("rev-parse", "HEAD"); }); const gate = buildGate(); - const { run, refresh } = yield* checkoutService( + const { run, refresh, indexRoot } = yield* checkoutService( fixture.root, () => ["src/defs.ts", "src/use.ts", "src/defs.test.ts"], gate, @@ -697,7 +734,7 @@ it.effect("reports index freshness against the checkout and walks usages to reac // Freshness never indexes: an unindexed checkout stays unindexed. assert.equal((yield* run({ operation: "freshness" })).index?.state, "unindexed"); - assert.isFalse(NodeFS.existsSync(NodePath.join(fixture.root, ".codegraph-t3"))); + assert.isFalse(NodeFS.existsSync(indexRoot(fixture.root))); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); const indexed = yield* run({ operation: "status" }); @@ -778,12 +815,25 @@ it.effect("reports index freshness against the checkout and walks usages to reac function checkoutService( root: string, - listed: () => ReadonlyArray, + listed: (cwd?: string) => ReadonlyArray, { beforeListing = () => Effect.void, + repository = false, + truncated = false, + autoCodeIndexEnabled = true, + worktree, + config, ...serviceOptions }: CodeIntelligence.CodeIntelligenceOptions & { readonly beforeListing?: () => Effect.Effect; + /** Lists through git, as a real repository does, instead of the file finder. */ + readonly repository?: boolean; + readonly truncated?: boolean; + readonly autoCodeIndexEnabled?: boolean; + /** A thread whose checkout is `path`, as a worktree thread resolves. */ + readonly worktree?: { readonly threadId: ThreadId; readonly path: string }; + /** Shares one data directory across services, as one server would. */ + readonly config?: CheckoutConfig; } = {}, ) { const project = { @@ -791,45 +841,108 @@ function checkoutService( workspaceRoot: root, deletedAt: null, } as unknown as ProjectStore.ProjectRow; - const provided = Layer.provide( - CodeIntelligence.layerWith(serviceOptions), - Layer.mergeAll( - Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeedSome(project) }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThread: () => Effect.die("unused") }), - Layer.mock(WorkspacePaths.WorkspacePaths)({ - normalizeWorkspaceRoot: () => Effect.succeed(root), - resolveRelativePathWithinRoot: ({ relativePath }) => - Effect.succeed({ absolutePath: relativePath, relativePath }), - }), - Layer.mock(WorkspaceEntries.WorkspaceEntries)({ - refresh: () => Effect.void, - list: () => - Effect.suspend(beforeListing).pipe( - Effect.andThen( - Effect.sync(() => ({ - entries: listed().map((path) => ({ path, kind: "file" as const })), - truncated: false, - })), - ), - ), - }), - ), - ); + const worktreeThread = { + id: worktree?.threadId ?? worktreeThreadId, + projectId, + worktreePath: worktree?.path ?? root, + } as unknown as OrchestrationV2AppThread; return Effect.gen(function* () { + const shared = config ?? (yield* checkoutConfig()); + const { layer: configLayer, indexRoot, codeIndexDir } = shared; + const provided = Layer.provide( + CodeIntelligence.layerWith(serviceOptions), + Layer.mergeAll( + Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeedSome(project) }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThread: (requestedThreadId) => + worktree !== undefined && requestedThreadId === worktree.threadId + ? Effect.succeed(worktreeThread) + : Effect.die("unused"), + }), + Layer.mock(WorkspacePaths.WorkspacePaths)({ + normalizeWorkspaceRoot: (workspaceRoot) => + NodeFS.existsSync(workspaceRoot) + ? Effect.succeed(workspaceRoot) + : Effect.fail( + new WorkspacePaths.WorkspaceRootNotExistsError({ + workspaceRoot, + normalizedWorkspaceRoot: workspaceRoot, + }), + ), + resolveRelativePathWithinRoot: ({ relativePath }) => + Effect.succeed({ absolutePath: relativePath, relativePath }), + }), + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ + refresh: () => Effect.void, + list: ({ cwd }) => + Effect.suspend(beforeListing).pipe( + Effect.andThen( + Effect.sync(() => ({ + entries: listed(cwd).map((path) => ({ path, kind: "file" as const })), + truncated, + })), + ), + ), + }), + Layer.mock(VcsDriver.VcsDriver)({ + checkpoints: {} as VcsDriver.VcsDriver["Service"]["checkpoints"], + isInsideWorkTree: () => Effect.succeed(repository), + listWorkspaceFiles: (cwd) => + repository + ? Effect.sync( + () => + ({ + paths: listed(cwd), + truncated, + }) as unknown as VcsListWorkspaceFilesResult, + ) + : Effect.fail(notARepository), + }), + ServerSettings.layerTest({ autoCodeIndexEnabled }), + configLayer, + ), + ); const intelligence = yield* CodeIntelligence.CodeIntelligence.pipe( Effect.provide(yield* Layer.build(provided)), ); return { - run: (query: Query) => intelligence.query({ projectId, query }), - refresh: () => intelligence.refresh({ projectId }), - subscribe: () => intelligence.subscribeIndex({ projectId }), + indexRoot, + codeIndexDir, + hasCheckoutIndex: (checkoutPath: string) => intelligence.hasCheckoutIndex(checkoutPath), + removeOrphanedIndexes: (isKnown: (checkoutPath: string) => boolean) => + intelligence.removeOrphanedIndexes(isKnown), + run: (query: Query, selectedThreadId?: ThreadId) => + intelligence.query({ + projectId, + ...(selectedThreadId ? { threadId: selectedThreadId } : {}), + query, + }), + refresh: (selectedThreadId?: ThreadId) => + intelligence.refresh({ + projectId, + ...(selectedThreadId ? { threadId: selectedThreadId } : {}), + }), + subscribe: (selectedThreadId?: ThreadId) => + intelligence.subscribeIndex({ + projectId, + ...(selectedThreadId ? { threadId: selectedThreadId } : {}), + }), + ensureProjectIndexed: () => intelligence.ensureProjectIndexed(projectId), + refreshForActiveAgent: (selectedThreadId?: ThreadId) => + intelligence.refreshForActiveAgent({ + projectId, + ...(selectedThreadId ? { threadId: selectedThreadId } : {}), + }), + refreshIfIndexed: (cwd: string) => intelligence.refreshIfIndexed(cwd), + removeCheckoutIndex: (checkoutPath: string) => intelligence.removeCheckoutIndex(checkoutPath), + stopBuilds: intelligence.stopBuilds, }; }); } -function indexedSymbolCount(root: string, name: string) { +function indexedSymbolCount(indexRoot: string, name: string) { return Effect.promise(async () => { - const indexDirectory = await publishedDirectory(root); + const indexDirectory = await publishedDirectory(indexRoot); return await new Promise((resolve, reject) => { const inspect = new NodeWorkerThreads.Worker( `const { parentPort, workerData } = require("node:worker_threads"); @@ -842,7 +955,7 @@ function indexedSymbolCount(root: string, name: string) { })().catch((error) => parentPort.postMessage({ error: String(error) }));`, { eval: true, - workerData: { requirePath: codeGraphRequirePath, root, name }, + workerData: { requirePath: codeGraphRequirePath, root: indexRoot, name }, env: { ...process.env, CODEGRAPH_DIR: indexDirectory }, }, ); @@ -855,77 +968,6 @@ function indexedSymbolCount(root: string, name: string) { }); } -it.effect( - "refuses a linked index directory or child instead of reading or writing through it", - () => - withFixture((fixture) => - Effect.gen(function* () { - const victim = NodePath.join(fixture.worktree, "victim"); - const control = NodePath.join(fixture.root, ".codegraph-t3"); - const generation = NodePath.join(fixture.root, ".codegraph-t3-g1"); - const { run, refresh } = yield* checkoutService(fixture.root, () => [ - "src/defs.ts", - "src/use.ts", - ]); - const expectRefused = (label: string) => - Effect.gen(function* () { - const result = yield* Effect.result(run({ operation: "status" })); - assert.equal(result._tag, "Failure", label); - if (result._tag === "Failure") { - assert.equal(result.failure._tag, "CodeIntelligencePathOutsideCheckout", label); - } - assert.deepEqual( - yield* refresh(), - { _tag: "Failed", reason: "index-outside-checkout" }, - label, - ); - assert.equal( - yield* Effect.promise(() => NodeFSP.readFile(victim, "utf8")), - "untouched", - label, - ); - }); - yield* Effect.promise(async () => { - await NodeFSP.writeFile(victim, "untouched"); - await NodeFSP.mkdir(control); - }); - for (const child of ["published", ".gitignore"]) { - const link = NodePath.join(control, child); - yield* Effect.promise(() => NodeFSP.symlink(victim, link)); - yield* expectRefused(`control ${child}`); - yield* Effect.promise(() => NodeFSP.rm(link)); - } - yield* Effect.promise(async () => { - await NodeFSP.rm(control, { recursive: true }); - await NodeFSP.symlink(fixture.worktree, control); - }); - yield* expectRefused("control directory"); - yield* Effect.promise(() => NodeFSP.rm(control)); - - assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); - for (const child of ["t3-index.json", "codegraph.db-wal"]) { - const link = NodePath.join(generation, child); - const kept = NodePath.join(fixture.worktree, `${child}.kept`); - yield* Effect.promise(async () => { - await NodeFSP.rename(link, kept).catch(() => undefined); - await NodeFSP.symlink(victim, link); - }); - yield* expectRefused(`generation ${child}`); - yield* Effect.promise(async () => { - await NodeFSP.rm(link); - await NodeFSP.rename(kept, link).catch(() => undefined); - }); - } - const keptGeneration = NodePath.join(fixture.worktree, "generation.kept"); - yield* Effect.promise(async () => { - await NodeFSP.rename(generation, keptGeneration); - await NodeFSP.symlink(keptGeneration, generation); - }); - yield* expectRefused("generation directory"); - }), - ), -); - it.effect("never widens an empty canonical allowlist into a full checkout scan", () => withFixture((fixture) => Effect.gen(function* () { @@ -935,12 +977,14 @@ it.effect("never widens an empty canonical allowlist into a full checkout scan", NodePath.join(fixture.root, "src/linked-work.ts"), ), ); - const { run, refresh } = yield* checkoutService(fixture.root, () => ["src/linked-work.ts"]); + const { run, refresh, indexRoot } = yield* checkoutService(fixture.root, () => [ + "src/linked-work.ts", + ]); assert.deepInclude(yield* refresh(), { _tag: "Published" }); const status = yield* run({ operation: "status" }); assert.equal(status.coverage.filesIndexed, 0); - assert.equal(yield* indexedSymbolCount(fixture.root, "workOnly"), 0); - assert.equal(yield* indexedSymbolCount(fixture.root, "greet"), 0); + assert.equal(yield* indexedSymbolCount(indexRoot(fixture.root), "workOnly"), 0); + assert.equal(yield* indexedSymbolCount(indexRoot(fixture.root), "greet"), 0); }), ), ); @@ -1021,13 +1065,19 @@ it.effect("keeps skipped build output partial while detecting newly added source it.effect("reports freshness coverage with the same classification as a query", () => withFixture((fixture) => Effect.gen(function* () { - yield* Effect.promise(() => - NodeFSP.writeFile(NodePath.join(fixture.root, "README.md"), "# Fixture\n"), - ); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(NodePath.join(fixture.root, "README.md"), "# Fixture\n"); + await NodeFSP.writeFile(NodePath.join(fixture.root, "ci.yaml"), "steps: []\n"); + await NodeFSP.writeFile(NodePath.join(fixture.worktree, "README.md"), "# Fixture\n"); + }); const { run, refresh } = yield* checkoutService(fixture.root, () => [ "src/defs.ts", "src/use.ts", "README.md", + "ci.yaml", + // Git lists a tracked file deleted from the working tree, and a submodule as a path. + "src/deleted.ts", + "src", ]); yield* refresh(); const status = yield* run({ operation: "status" }); @@ -1038,14 +1088,338 @@ it.effect("reports freshness coverage with the same classification as a query", filesSkipped: result.coverage.filesSkipped, filesFailed: result.coverage.filesFailed, }); + // Docs and assets fill any repository, so they are counted but leave no gap. A file the SDK + // indexed anyway, such as YAML, is not also unsupported. assert.deepEqual(counts(status), { - state: "partial", + state: "complete", filesUnsupported: 1, filesSkipped: 0, filesFailed: 0, }); assert.deepEqual(counts(freshness), counts(status)); - assert.equal(freshness.index?.state, "partial"); + assert.equal(freshness.index?.state, "ready"); + + // With nothing else to index, the same files do leave the index empty and incomplete. + const docsOnly = yield* checkoutService(fixture.worktree, () => ["README.md"]); + yield* docsOnly.refresh(); + const docsOnlyFreshness = yield* docsOnly.run({ operation: "freshness" }); + assert.equal(docsOnlyFreshness.coverage.state, "partial"); + assert.equal(docsOnlyFreshness.index?.state, "partial"); + }), + ), +); + +it("spends an over-cap listing on first-party sources before anything else", () => { + assert.deepEqual( + rankListing( + [ + ".repos/lib/notes.md", + ".repos/lib/vendored.ts", + "README.md", + "apps/.local/generated.ts", + "apps/server.ts", + "packages/shared.ts", + ], + (path) => path.endsWith(".ts"), + ), + [ + "apps/server.ts", + "packages/shared.ts", + ".repos/lib/vendored.ts", + "apps/.local/generated.ts", + "README.md", + ".repos/lib/notes.md", + ], + ); +}); + +it.effect("starts over from an index that spent a truncated listing on what sorted first", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(fixture.root, "src/vendored.ts"), + "export function vendoredOnly() {}\n", + ), + ); + let listed = ["src/vendored.ts", "src/defs.ts"]; + const { refresh, indexRoot } = yield* checkoutService(fixture.root, () => listed, { + truncated: true, + }); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.equal(yield* indexedSymbolCount(indexRoot(fixture.root), "vendoredOnly"), 1); + + yield* Effect.promise(async () => { + const root = indexRoot(fixture.root); + const metaPath = NodePath.join(await publishedDirectory(root), "t3-index.json"); + const absolute = NodePath.join(root, metaPath); + const { ranked: _ranked, ...legacy } = JSON.parse(await NodeFSP.readFile(absolute, "utf8")); + await NodeFSP.writeFile(absolute, JSON.stringify(legacy)); + }); + listed = ["src/defs.ts"]; + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); + assert.equal(yield* indexedSymbolCount(indexRoot(fixture.root), "vendoredOnly"), 0); + assert.equal(yield* indexedSymbolCount(indexRoot(fixture.root), "greet"), 1); + }), + ), +); + +it.effect("starts a project's first index when a client watches it, and only its first", () => + withFixture((fixture) => + Effect.gen(function* () { + const gate = buildGate(); + const { run, subscribe } = yield* checkoutService(fixture.root, () => ["src/defs.ts"], { + ...gate, + repository: true, + }); + const next = (events: Stream.Stream) => + events.pipe(Stream.take(1), Stream.runCollect); + + // A watcher does not retry a failed first build, or a persistent failure would repeat for + // every client that looks. + const held = yield* gate.holdNext; + const events = yield* subscribe(); + assert.deepInclude((yield* run({ operation: "freshness" })).index, { + state: "unindexed", + refresh: "building", + }); + const buildPid = yield* Deferred.await(held.entered); + // Nobody asked for this build, so it yields to work someone is waiting on. + assert.equal(NodeOS.getPriority(buildPid), NodeOS.constants.priority.PRIORITY_BELOW_NORMAL); + // The first build announces itself before it starts, so a watching client reads the badge + // again and shows Indexing while there is no generation to answer from. + assert.deepEqual(yield* next(events), [{ outcome: "published", generation: null }]); + process.kill(buildPid, "SIGKILL"); + assert.deepEqual(yield* next(events), [{ outcome: "failed", generation: null }]); + const firstBuildFailed = { state: "unindexed", refresh: "failed" } as const; + // The event precedes the build's bookkeeping; this answer proves that bookkeeping finished. + assert.deepInclude((yield* run({ operation: "freshness" })).index, firstBuildFailed); + yield* subscribe().pipe(Effect.asVoid); + assert.deepInclude((yield* run({ operation: "freshness" })).index, firstBuildFailed); + + // A code query still asks for one, and the retry publishes without announcing again. + yield* run({ operation: "symbols", name: "greet" }); + assert.deepEqual(yield* next(events), [{ outcome: "published", generation: 1 }]); + assert.equal((yield* run({ operation: "freshness" })).index?.state, "ready"); + + // Once indexed, watching observes; refreshes stay with queries and finished turns. + yield* editDefinition(fixture.root, "edited"); + yield* subscribe().pipe(Effect.asVoid); + const stale = (yield* run({ operation: "freshness" })).index; + assert.equal(stale?.state, "stale"); + assert.notProperty(stale, "refresh"); + + // A folder outside a repository is not indexed unasked. + const plainFolder = yield* checkoutService(fixture.worktree, () => ["src/work.ts"]); + yield* plainFolder.subscribe().pipe(Effect.asVoid); + const unindexed = (yield* plainFolder.run({ operation: "freshness" })).index; + assert.equal(unindexed?.state, "unindexed"); + assert.notProperty(unindexed, "refresh"); + // Nor for an agent: a build its request started would leave this one nothing to publish. + yield* plainFolder.refreshForActiveAgent(); + assert.deepEqual(yield* plainFolder.refresh(), { _tag: "Published", generation: 1 }); + }), + ), +); + +it.effect("indexes a worktree for its active agent and never merely because it is viewed", () => + withFixture((fixture) => + Effect.gen(function* () { + const worktree = { threadId: worktreeThreadId, path: fixture.worktree }; + const { run, refresh, subscribe, refreshForActiveAgent, indexRoot } = yield* checkoutService( + fixture.root, + (cwd) => (cwd === fixture.worktree ? ["src/work.ts"] : ["src/defs.ts"]), + { worktree, repository: true }, + ); + const worktreeFreshness = () => + run({ operation: "freshness" }, worktreeThreadId).pipe( + Effect.map((result) => result.index), + ); + + // Viewing a worktree thread subscribes to its events but builds nothing. + yield* subscribe(worktreeThreadId).pipe(Effect.asVoid); + assert.equal((yield* worktreeFreshness())?.state, "unindexed"); + assert.isFalse(NodeFS.existsSync(indexRoot(fixture.worktree))); + + // An agent starting in it indexes it. + yield* refreshForActiveAgent(worktreeThreadId); + // The request returns at once. Its build publishes, leaving this one nothing to do. + assert.deepEqual(yield* refresh(worktreeThreadId), { _tag: "Unchanged" }); + const indexed = yield* worktreeFreshness(); + assert.equal(indexed?.state, "ready"); + assert.equal(indexed?.generation, 1); + const symbols = yield* run({ operation: "symbols", name: "workOnly" }, worktreeThreadId); + assert.ok(symbols.nodes.some((node) => node.path === "src/work.ts")); + }), + ), +); + +it.effect("leaves a worktree index stale until its agent acts again", () => + withFixture((fixture) => + Effect.gen(function* () { + const worktree = { threadId: worktreeThreadId, path: fixture.worktree }; + const { run, refresh, refreshForActiveAgent } = yield* checkoutService( + fixture.root, + (cwd) => (cwd === fixture.worktree ? ["src/work.ts"] : ["src/defs.ts"]), + { worktree }, + ); + const freshness = () => + run({ operation: "freshness" }, worktreeThreadId).pipe( + Effect.map((result) => result.index), + ); + + yield* refreshForActiveAgent(worktreeThreadId); + yield* refresh(worktreeThreadId); + assert.equal((yield* freshness())?.generation, 1); + + // Nothing refreshes an idle worktree. + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(fixture.worktree, "src/work.ts"), + "export function workOnly() { return 3; }\n", + ), + ); + const idle = yield* freshness(); + assert.equal(idle?.state, "stale"); + assert.notProperty(idle, "refresh"); + + // The next turn's request publishes the successor. + yield* refreshForActiveAgent(worktreeThreadId); + yield* refresh(worktreeThreadId); + const refreshed = yield* freshness(); + assert.equal(refreshed?.state, "ready"); + assert.equal(refreshed?.generation, 2); + }), + ), +); + +it.effect("indexes only when something asks while automatic indexing is off", () => + withFixture((fixture) => + Effect.gen(function* () { + const worktree = { threadId: worktreeThreadId, path: fixture.worktree }; + const { + run, + refresh, + subscribe, + ensureProjectIndexed, + refreshForActiveAgent, + refreshIfIndexed, + indexRoot, + } = yield* checkoutService( + fixture.root, + (cwd) => (cwd === fixture.worktree ? ["src/work.ts"] : ["src/defs.ts"]), + { worktree, repository: true, autoCodeIndexEnabled: false }, + ); + const freshness = (threadId?: ThreadId) => + run({ operation: "freshness" }, threadId).pipe(Effect.map((result) => result.index)); + const noIndex = () => + Effect.sync( + () => + !NodeFS.existsSync(indexRoot(fixture.root)) && + !NodeFS.existsSync(indexRoot(fixture.worktree)), + ); + + yield* ensureProjectIndexed(); + yield* refreshForActiveAgent(); + yield* refreshForActiveAgent(worktreeThreadId); + yield* subscribe().pipe(Effect.asVoid); + yield* refreshIfIndexed(fixture.root); + assert.isTrue(yield* noIndex()); + assert.equal((yield* freshness())?.state, "unindexed"); + assert.equal((yield* freshness(worktreeThreadId))?.state, "unindexed"); + + // A build any of those started would run first, leaving these nothing to publish. + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.deepEqual(yield* refresh(worktreeThreadId), { _tag: "Published", generation: 1 }); + + // Nor does an existing index refresh on its own. + yield* editDefinition(fixture.root, "edited"); + yield* refreshForActiveAgent(); + yield* refreshIfIndexed(fixture.root); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); + + // A code question always works. + const answer = yield* run({ operation: "symbols", name: "greet" }); + assert.ok(answer.nodes.some((node) => node.path === "src/defs.ts")); + }), + ), +); + +it.effect("removes a checkout's index while a build is in flight", () => + withFixture((fixture) => + Effect.gen(function* () { + const gate = buildGate(); + const { removeCheckoutIndex, refresh, indexRoot } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + gate, + ); + const held = yield* gate.holdNext; + const building = yield* refresh().pipe(Effect.forkScoped); + const pid = yield* Deferred.await(held.entered); + yield* removeCheckoutIndex(fixture.root); + assert.isFalse(isProcessAlive(pid), "the build process is gone once removal returns"); + yield* Fiber.await(building); + assert.isFalse(NodeFS.existsSync(indexRoot(fixture.root))); + assert.deepEqual( + yield* Effect.promise(() => generationDirectories(indexRoot(fixture.root))), + [], + ); + }), + ), +); + +it.effect("keeps a generation a reader pins while removing the rest of the index", () => + withFixture((fixture) => + Effect.gen(function* () { + const { run, refresh, removeCheckoutIndex, indexRoot } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + ); + const root = indexRoot(fixture.root); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + const pin = yield* Effect.sync(() => pinPublishedGeneration(root)); + assert.equal(pin.generation, 1); + + yield* removeCheckoutIndex(fixture.root); + // The pointer goes first, so queries read unindexed even though the generation survives. + assert.isFalse(NodeFS.existsSync(NodePath.join(root, ".codegraph-t3", "published"))); + assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), [ + ".codegraph-t3-g1", + ]); + assert.equal((yield* run({ operation: "freshness" })).index?.state, "unindexed"); + + // The next removal, with nobody reading, clears it. + yield* Effect.sync(pin.release); + yield* removeCheckoutIndex(fixture.root); + assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), []); + assert.isFalse(NodeFS.existsSync(root)); + }), + ), +); + +it.effect("leaves a checkout another server process is building alone", () => + withFixture((fixture) => + Effect.gen(function* () { + const { refresh, removeCheckoutIndex, indexRoot } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + ); + const root = indexRoot(fixture.root); + const other = yield* otherServerProcess(root); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.equal(yield* other.send("lock"), "locked"); + + yield* removeCheckoutIndex(fixture.root); + assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), [ + ".codegraph-t3-g1", + ]); + assert.equal( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(root, ".codegraph-t3/published"), "utf8"), + ), + "1", + ); }), ), ); @@ -1158,8 +1532,9 @@ it.effect("stops a build when the service shuts down and never publishes the cut const afterShutdown = yield* restarted.run({ operation: "freshness" }); assert.equal(afterShutdown.index?.state, "unindexed"); assert.deepInclude(yield* restarted.refresh(), { _tag: "Published" }); - assert.deepEqual(yield* Effect.promise(() => generationDirectories(fixture.root)), [ - yield* Effect.promise(() => publishedDirectory(fixture.root)), + const root = restarted.indexRoot(fixture.root); + assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), [ + yield* Effect.promise(() => publishedDirectory(root)), ]); const recovered = yield* restarted.run({ operation: "status" }); assert.equal(recovered.coverage.filesIndexed, listed.length); @@ -1174,13 +1549,16 @@ it.effect("fails the build, not the server, when the analysis process aborts", ( const files = yield* Effect.promise(() => writeGeneratedSources(fixture.root, 4)); const gate = buildGate(); const held = yield* gate.holdNext; - const { refresh } = yield* checkoutService(fixture.root, () => files, gate); + const { refresh, indexRoot } = yield* checkoutService(fixture.root, () => files, gate); const building = yield* refresh().pipe(Effect.forkScoped); const pid = yield* Deferred.await(held.entered); assert.notEqual(pid, process.pid, "analysis runs outside the server process"); process.kill(pid, "SIGABRT"); assert.deepEqual(yield* Fiber.join(building), { _tag: "Failed", reason: "worker-failed" }); - assert.deepEqual(yield* Effect.promise(() => generationDirectories(fixture.root)), []); + assert.deepEqual( + yield* Effect.promise(() => generationDirectories(indexRoot(fixture.root))), + [], + ); }), ), ); @@ -1211,11 +1589,32 @@ it.effect("keeps the index out of the checkout's git status", () => withFixture((fixture) => Effect.gen(function* () { const git = (...args: ReadonlyArray) => - NodeChildProcess.execFileSync("git", args, { cwd: fixture.root, encoding: "utf8" }); + NodeChildProcess.execFileSync( + "git", + ["-c", "user.email=t3@example.com", "-c", "user.name=T3", ...args], + { cwd: fixture.root, encoding: "utf8" }, + ); git("init", "--quiet"); + git("add", "--all"); + git("commit", "--quiet", "-m", "fixture"); const gate = listingGate(); - const { run, refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"], gate); + const { run, refresh, indexRoot } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + gate, + ); + const root = indexRoot(fixture.root); + const before = (yield* Effect.promise(() => NodeFSP.readdir(fixture.root))).toSorted(); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.ok((yield* run({ operation: "symbols", name: "greet" })).nodes.length > 0); + + // Nothing the index owns is in the checkout: no new entries, and git sees nothing. + assert.deepEqual( + (yield* Effect.promise(() => NodeFSP.readdir(fixture.root))).toSorted(), + before, + ); + assert.equal(git("status", "--ignored", "--short"), ""); + // A reader keeps generation 1 while generation 2 is published, so both are on disk. const held = yield* gate.holdNext; const reader = yield* run({ operation: "freshness" }).pipe(Effect.forkScoped); @@ -1227,16 +1626,14 @@ it.effect("keeps the index out of the checkout's git status", () => ), ); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); - assert.deepEqual(yield* Effect.promise(() => generationDirectories(fixture.root)), [ + assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), [ ".codegraph-t3-g1", ".codegraph-t3-g2", ]); - const untracked = git("status", "--porcelain", "--untracked-files=all") - .split("\n") - .filter((line) => /\.codegraph-t3(-g\d+)?\//.test(line)); - assert.deepEqual(untracked, []); yield* Deferred.succeed(held.release, undefined); yield* Fiber.join(reader); + // Even with two generations on disk, only the edited source shows. + assert.equal(git("status", "--ignored", "--short"), " M src/defs.ts\n"); }), ), ); @@ -1274,23 +1671,324 @@ it.effect("returns index not ready for a cold checkout without synchronizing sou ), ); -it.effect("removes the index an older version kept in the control directory", () => +it.effect("removes the index folders an older version left in the checkout", () => withFixture((fixture) => Effect.gen(function* () { const control = NodePath.join(fixture.root, ".codegraph-t3"); - yield* Effect.promise(async () => { + const legacyGeneration = NodePath.join(fixture.root, ".codegraph-t3-g3"); + const userGeneration = NodePath.join(fixture.root, ".codegraph-t3-g4"); + const before = yield* Effect.promise(async () => { + await NodeFSP.writeFile(NodePath.join(fixture.root, "top.ts"), "export const top = 1;\n"); + await NodeFSP.symlink("src", NodePath.join(fixture.root, "alias")); + // A folder that merely shares the name is the user's. + await NodeFSP.mkdir(userGeneration); + await NodeFSP.writeFile(NodePath.join(userGeneration, "notes.txt"), "mine"); + const entries = (await NodeFSP.readdir(fixture.root)).toSorted(); await NodeFSP.mkdir(control); await NodeFSP.writeFile(NodePath.join(control, "codegraph.db"), "legacy index"); await NodeFSP.writeFile(NodePath.join(control, "t3-index.json"), "{}"); + await NodeFSP.mkdir(legacyGeneration); + await NodeFSP.writeFile(NodePath.join(legacyGeneration, "codegraph.db"), "legacy"); + return entries; }); const { refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); - assert.deepEqual((yield* Effect.promise(() => NodeFSP.readdir(control))).toSorted(), [ - ".gitignore", - "build.lock", - "building.lock", - "published", + // Only T3's folders go: every other top-level entry, file and link alike, is still there. + assert.deepEqual( + (yield* Effect.promise(() => NodeFSP.readdir(fixture.root))).toSorted(), + before, + ); + assert.isTrue(NodeFS.lstatSync(NodePath.join(fixture.root, "alias")).isSymbolicLink()); + assert.equal( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(userGeneration, "notes.txt"), "utf8"), + ), + "mine", + ); + }), + ), +); + +it.effect("finds and removes an older version's folders in a checkout with no index yet", () => + withFixture((fixture) => + Effect.gen(function* () { + const control = NodePath.join(fixture.root, ".codegraph-t3"); + const { hasCheckoutIndex, removeCheckoutIndex, indexRoot } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + ); + assert.isFalse(yield* hasCheckoutIndex(fixture.root)); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(control); + await NodeFSP.writeFile(NodePath.join(control, "published"), "1"); + }); + assert.isFalse(NodeFS.existsSync(indexRoot(fixture.root))); + assert.isTrue(yield* hasCheckoutIndex(fixture.root)); + + // Settling a thread takes this path, so a worktree nothing rebuilds is still cleaned up. + yield* removeCheckoutIndex(fixture.root); + assert.isFalse(NodeFS.existsSync(control)); + assert.isFalse(yield* hasCheckoutIndex(fixture.root)); + assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.root, "src/defs.ts"))); + }), + ), +); + +it.effect("leaves a checkout's own .codegraph-t3 directory alone", () => + withFixture((fixture) => + Effect.gen(function* () { + const control = NodePath.join(fixture.root, ".codegraph-t3"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(control); + await NodeFSP.writeFile(NodePath.join(control, "notes.txt"), "mine"); + }); + const { refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.equal( + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(control, "notes.txt"), "utf8")), + "mine", + ); + }), + ), +); + +it.effect("leaves a linked .codegraph-t3 and its target alone", () => + withFixture((fixture) => + Effect.gen(function* () { + // Named like T3's own marker, so only the link check can protect it. + const victim = NodePath.join(fixture.worktree, "published"); + const control = NodePath.join(fixture.root, ".codegraph-t3"); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(victim, "untouched"); + await NodeFSP.symlink(fixture.worktree, control); + }); + const { refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.isTrue(NodeFS.lstatSync(control).isSymbolicLink()); + assert.equal(yield* Effect.promise(() => NodeFSP.readFile(victim, "utf8")), "untouched"); + assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.worktree, "src/work.ts"))); + }), + ), +); + +it.effect("leaves legacy folders for later while another server builds", () => + withFixture((fixture) => + Effect.gen(function* () { + const control = NodePath.join(fixture.root, ".codegraph-t3"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(control); + await NodeFSP.writeFile(NodePath.join(control, "codegraph.db"), "legacy index"); + }); + // An older server still building in the checkout holds the lock this removal would need. + const other = yield* otherServerProcess(fixture.root); + assert.equal(yield* other.send("lock"), "locked"); + const { refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.isTrue(NodeFS.existsSync(control)); + + // Once that server is gone, the next build removes them. + assert.equal(yield* other.send("release"), "released"); + yield* editDefinition(fixture.root, "second"); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); + assert.isFalse(NodeFS.existsSync(control)); + }), + ), +); + +it.effect("removes an index without touching the checkout's sources", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(fixture.root, "top.ts"), "export const top = 1;\n"), + ); + const { refresh, removeCheckoutIndex, indexRoot } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts", "src/use.ts", "top.ts"], + ); + const root = indexRoot(fixture.root); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.isTrue(NodeFS.lstatSync(NodePath.join(root, "top.ts")).isSymbolicLink()); + + yield* removeCheckoutIndex(fixture.root); + assert.isFalse(NodeFS.existsSync(root)); + // Every source is still there, byte for byte. + assert.equal( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(fixture.root, "src/defs.ts"), "utf8"), + ), + 'const prefix = "๐Ÿงชรฉ"; export function greet() { return prefix; }\n', + ); + assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.root, "src/use.ts"))); + assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.root, "src/main.go"))); + assert.equal( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(fixture.root, "top.ts"), "utf8"), + ), + "export const top = 1;\n", + ); + }), + ), +); + +it.effect("removes an orphaned index without touching a source checkout", () => + withFixture((fixture) => + Effect.gen(function* () { + const { refresh, removeOrphanedIndexes, indexRoot } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + ); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + yield* removeOrphanedIndexes(() => false); + assert.isFalse(NodeFS.existsSync(indexRoot(fixture.root))); + assert.equal( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(fixture.root, "src/defs.ts"), "utf8"), + ), + 'const prefix = "๐Ÿงชรฉ"; export function greet() { return prefix; }\n', + ); + }), + ), +); + +it.effect("sweeps indexes for checkouts that are gone or no longer known", () => + withFixture((fixture) => + Effect.gen(function* () { + const shared = yield* checkoutConfig(); + const known = yield* checkoutService(fixture.root, () => ["src/defs.ts"], { config: shared }); + const gone = yield* checkoutService(fixture.worktree, () => ["src/work.ts"], { + config: shared, + }); + assert.deepInclude(yield* known.refresh(), { _tag: "Published" }); + assert.deepInclude(yield* gone.refresh(), { _tag: "Published" }); + const knownRoot = known.indexRoot(fixture.root); + const goneRoot = gone.indexRoot(fixture.worktree); + assert.isTrue(NodeFS.existsSync(knownRoot)); + assert.isTrue(NodeFS.existsSync(goneRoot)); + + // A root with no owner record is a leftover, whatever the predicate says. + const orphanRoot = NodePath.join(known.codeIndexDir, "0".repeat(32)); + yield* Effect.promise(() => NodeFSP.mkdir(orphanRoot, { recursive: true })); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(orphanRoot, "stray.txt"), "leftover"), + ); + + yield* known.removeOrphanedIndexes((checkoutPath) => checkoutPath === fixture.root); + // The known checkout is kept, and so are its sources. + assert.isTrue(NodeFS.existsSync(knownRoot)); + assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.root, "src/defs.ts"))); + // A checkout `isKnown` rejects is removed, leaving its files intact. + assert.isFalse(NodeFS.existsSync(goneRoot)); + assert.equal( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(fixture.worktree, "src/work.ts"), "utf8"), + ), + "export function workOnly() { return 2; }\n", + ); + // A root with no record is removed. + assert.isFalse(NodeFS.existsSync(orphanRoot)); + }), + ), +); + +it.effect("removes the index of a checkout that was deleted", () => + withFixture((fixture) => + Effect.gen(function* () { + const { refresh, removeOrphanedIndexes, indexRoot } = yield* checkoutService( + fixture.worktree, + () => ["src/work.ts"], + ); + assert.deepInclude(yield* refresh(), { _tag: "Published" }); + const root = indexRoot(fixture.worktree); + assert.isTrue(NodeFS.existsSync(root)); + yield* Effect.promise(() => NodeFSP.rm(fixture.worktree, { recursive: true, force: true })); + // The checkout is still known by path, but it no longer exists. + yield* removeOrphanedIndexes(() => true); + assert.isFalse(NodeFS.existsSync(root)); + }), + ), +); + +it.effect("follows a new top-level entry on the next build and drops a removed one", () => + withFixture((fixture) => + Effect.gen(function* () { + const listed = ["src/defs.ts"]; + const { run, refresh, indexRoot } = yield* checkoutService(fixture.root, () => listed); + const root = indexRoot(fixture.root); + yield* refresh(); + assert.equal((yield* run({ operation: "symbols", name: "tool" })).nodes.length, 0); + + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(fixture.root, "tools.ts"), "export function tool() {}\n"), + ); + listed.push("tools.ts"); + yield* refresh(); + assert.ok((yield* run({ operation: "symbols", name: "tool" })).nodes.length > 0); + // The link the build added points at the checkout. + assert.isTrue(NodeFS.existsSync(NodePath.join(root, "tools.ts"))); + + // Removing the top-level entry leaves no dangling link in the root. + yield* Effect.promise(() => NodeFSP.rm(NodePath.join(fixture.root, "tools.ts"))); + listed.splice(listed.indexOf("tools.ts"), 1); + yield* refresh(); + assert.notInclude(yield* Effect.promise(() => NodeFSP.readdir(root)), "tools.ts"); + assert.equal((yield* run({ operation: "symbols", name: "tool" })).nodes.length, 0); + // The sources it shared the root with are still there. + assert.isTrue(NodeFS.existsSync(NodePath.join(root, "src/defs.ts"))); + assert.equal( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(fixture.root, "src/defs.ts"), "utf8"), + ), + 'const prefix = "๐Ÿงชรฉ"; export function greet() { return prefix; }\n', + ); + }), + ), +); + +it.effect("keeps files the graph holds under a top-level entry a truncated listing dropped", () => + withFixture((fixture) => + Effect.gen(function* () { + let listed = ["src/defs.ts", "src/use.ts"]; + const { run, refresh, indexRoot } = yield* checkoutService(fixture.root, () => listed, { + truncated: true, + }); + const root = indexRoot(fixture.root); + yield* refresh(); + assert.ok((yield* run({ operation: "symbols", name: "greet" })).nodes.length > 0); + + // The cap drops `src` from the listing entirely; the files the graph already holds must + // still be reachable, so the build links that entry back from the graph's own paths. + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(fixture.root, "tools.ts"), "export function tool() {}\n"), + ); + listed = ["tools.ts"]; + assert.deepInclude(yield* refresh(), { _tag: "Published", generation: 2 }); + assert.isTrue(NodeFS.existsSync(NodePath.join(root, "src"))); + // The index still answers for the files it held under the dropped entry. + assert.equal(yield* indexedSymbolCount(root, "greet"), 1); + assert.equal(yield* indexedSymbolCount(root, "tool"), 1); + assert.equal((yield* run({ operation: "freshness" })).index?.changedFiles, 0); + }), + ), +); + +it.effect("honours a checkout's exclude patterns when the config was not listed", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(fixture.root, "codegraph.json"), + JSON.stringify({ exclude: ["src/use.ts"] }), + ), + ); + // The listing never mentions the config file; only the link T3 plants reaches it. + const { run, refresh } = yield* checkoutService(fixture.root, () => [ + "src/defs.ts", + "src/use.ts", ]); + yield* refresh(); + const status = yield* run({ operation: "status" }); + assert.equal(status.stats?.files, 1); + assert.equal((yield* run({ operation: "symbols", name: "caller" })).nodes.length, 0); }), ), ); @@ -1431,7 +2129,7 @@ it.effect("coalesces refresh requests made while a build is in flight into one f // Every query and every build lists the checkout once. let listings = 0; const gate = buildGate(); - const { run, refresh } = yield* checkoutService( + const { run, refresh, indexRoot } = yield* checkoutService( fixture.root, () => { listings += 1; @@ -1455,9 +2153,10 @@ it.effect("coalesces refresh requests made while a build is in flight into one f assert.deepEqual(yield* Fiber.join(fiber), { _tag: "Unchanged" }); } assert.equal(listings, 3 + 2, "three queries and two builds listed the checkout"); - assert.deepEqual(yield* Effect.promise(() => generationDirectories(fixture.root)), [ - ".codegraph-t3-g1", - ]); + assert.deepEqual( + yield* Effect.promise(() => generationDirectories(indexRoot(fixture.root))), + [".codegraph-t3-g1"], + ); }), ), ); @@ -1485,12 +2184,13 @@ it.effect("deletes a retired generation only after its last reader finishes", () withFixture((fixture) => Effect.gen(function* () { const gate = listingGate(); - const { run, refresh } = yield* checkoutService( + const { run, refresh, indexRoot } = yield* checkoutService( fixture.root, () => ["src/defs.ts", "src/use.ts"], gate, ); - const generations = () => Effect.promise(() => generationDirectories(fixture.root)); + const generations = () => + Effect.promise(() => generationDirectories(indexRoot(fixture.root))); const edit = (body: string) => Effect.promise(() => NodeFSP.writeFile( @@ -1523,10 +2223,10 @@ it.effect("deletes a retired generation only after its last reader finishes", () const indexLocksModule = new URL("./codeIndexLocks.ts", import.meta.url).href; /** - * Another server process on the same checkout. It takes the index locks the way a server does, + * Another server process on the same index root. It takes the index locks the way a server does, * one stdin command at a time: `lock` the build, `pin` the published generation, or `release`. */ -function otherServerProcess(root: string) { +function otherServerProcess(indexRoot: string) { return Effect.acquireRelease( Effect.promise(async () => { const child = NodeChildProcess.spawn( @@ -1537,17 +2237,17 @@ function otherServerProcess(root: string) { "--input-type=module", "-e", `const locks = await import(process.argv[1]); - const checkout = process.argv[2]; + const indexRoot = process.argv[2]; const held = []; const commands = { lock: () => { - const release = locks.tryLockBuild(checkout); + const release = locks.tryLockBuild(indexRoot); if (release === null) return "busy"; held.push(release); return "locked"; }, pin: () => { - const pin = locks.pinPublishedGeneration(checkout); + const pin = locks.pinPublishedGeneration(indexRoot); held.push(pin.release); return "pinned " + pin.generation; }, @@ -1560,7 +2260,7 @@ function otherServerProcess(root: string) { lines.on("line", (command) => process.stdout.write(commands[command]() + "\\n")); process.stdout.write("ready\\n");`, indexLocksModule, - root, + indexRoot, ], { stdio: ["pipe", "pipe", "pipe"] }, ); @@ -1618,8 +2318,11 @@ function editDefinition(root: string, body: string) { it.effect("refuses to build while another server process builds the checkout", () => withFixture((fixture) => Effect.gen(function* () { - const other = yield* otherServerProcess(fixture.root); - const { run, refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); + const { run, refresh, indexRoot } = yield* checkoutService(fixture.root, () => [ + "src/defs.ts", + ]); + const root = indexRoot(fixture.root); + const other = yield* otherServerProcess(root); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); yield* editDefinition(fixture.root, "second"); @@ -1631,7 +2334,7 @@ it.effect("refuses to build while another server process builds the checkout", ( generation: 1, refresh: "building", }); - assert.deepEqual(yield* Effect.promise(() => generationDirectories(fixture.root)), [ + assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), [ ".codegraph-t3-g1", ]); @@ -1645,9 +2348,10 @@ it.effect("refuses to build while another server process builds the checkout", ( it.effect("keeps a generation another server process reads until that reader releases it", () => withFixture((fixture) => Effect.gen(function* () { - const other = yield* otherServerProcess(fixture.root); - const { refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); - const generations = () => Effect.promise(() => generationDirectories(fixture.root)); + const { refresh, indexRoot } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); + const root = indexRoot(fixture.root); + const other = yield* otherServerProcess(root); + const generations = () => Effect.promise(() => generationDirectories(root)); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); assert.equal(yield* other.send("pin"), "pinned 1"); @@ -1668,8 +2372,9 @@ it.effect("keeps a generation another server process reads until that reader rel it.effect("recovers the build lock and reader of a server process that crashed", () => withFixture((fixture) => Effect.gen(function* () { - const other = yield* otherServerProcess(fixture.root); - const { refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); + const { refresh, indexRoot } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); + const root = indexRoot(fixture.root); + const other = yield* otherServerProcess(root); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); assert.equal(yield* other.send("pin"), "pinned 1"); assert.equal(yield* other.send("lock"), "locked"); @@ -1678,9 +2383,128 @@ it.effect("recovers the build lock and reader of a server process that crashed", yield* other.crash; assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); - assert.deepEqual(yield* Effect.promise(() => generationDirectories(fixture.root)), [ + assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), [ ".codegraph-t3-g2", ]); }), ), ); + +it.effect("announces a checkout's first build once, however many builds follow it", () => + withFixture((fixture) => + Effect.gen(function* () { + const { run, refresh, subscribe, indexRoot } = yield* checkoutService(fixture.root, () => [ + "src/defs.ts", + ]); + const other = yield* otherServerProcess(indexRoot(fixture.root)); + const events = yield* subscribe(); + assert.equal(yield* other.send("lock"), "locked"); + // Each of these queries requests a build that ends at once, another server holding the + // lock. Signalling every time would restart it forever. + for (let attempt = 0; attempt < 3; attempt += 1) { + yield* run({ operation: "symbols", name: "greet" }); + } + assert.deepEqual(yield* refresh(), { _tag: "BuildingElsewhere" }); + + assert.equal(yield* other.send("release"), "released"); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.deepEqual( + yield* events.pipe( + Stream.takeUntil((event) => event.outcome === "published" && event.generation === 1), + Stream.runCollect, + ), + [ + { outcome: "published", generation: null }, + { outcome: "published", generation: 1 }, + ], + ); + }), + ), +); + +it.effect("removes a checkout's index and asks a mounted reader to read again", () => + withFixture((fixture) => + Effect.gen(function* () { + const { run, refresh, subscribe, removeCheckoutIndex, indexRoot } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + ); + const root = indexRoot(fixture.root); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + const events = yield* subscribe(); + yield* removeCheckoutIndex(fixture.root); + assert.deepEqual(yield* events.pipe(Stream.take(1), Stream.runCollect), [ + { outcome: "published", generation: null }, + ]); + assert.isFalse(NodeFS.existsSync(root)); + assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), []); + const freshness = (yield* run({ operation: "freshness" })).index; + assert.equal(freshness?.state, "unindexed"); + assert.notProperty(freshness, "refresh"); + + // The checkout starts over: its rebuild announces itself and restarts the numbering. + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.deepEqual(yield* events.pipe(Stream.take(2), Stream.runCollect), [ + { outcome: "published", generation: null }, + { outcome: "published", generation: 1 }, + ]); + }), + ), +); + +it.effect("stops a running build and asks a mounted reader to read again", () => + withFixture((fixture) => + Effect.gen(function* () { + const gate = buildGate(); + const { run, refresh, subscribe, stopBuilds } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + gate, + ); + const events = yield* subscribe(); + const held = yield* gate.holdNext; + const building = yield* refresh().pipe(Effect.forkScoped); + const pid = yield* Deferred.await(held.entered); + yield* stopBuilds; + assert.isFalse(isProcessAlive(pid), "the build process is gone once stopping returns"); + yield* Fiber.await(building); + // The first announces the build, the second its end. + assert.deepEqual(yield* events.pipe(Stream.take(2), Stream.runCollect), [ + { outcome: "published", generation: null }, + { outcome: "published", generation: null }, + ]); + const freshness = (yield* run({ operation: "freshness" })).index; + assert.equal(freshness?.state, "unindexed"); + assert.notProperty(freshness, "refresh"); + + // The next first build announces itself again. + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + // A waiter resumes ahead of the build's bookkeeping; this answer proves that finished. + yield* run({ operation: "freshness" }); + // A stop with nothing running has nothing to say, so the next event is the publication. + yield* stopBuilds; + yield* editDefinition(fixture.root, "edited"); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); + assert.deepEqual(yield* events.pipe(Stream.take(3), Stream.runCollect), [ + { outcome: "published", generation: null }, + { outcome: "published", generation: 1 }, + { outcome: "published", generation: 2 }, + ]); + }), + ), +); + +it.effect("can build again after a build is stopped before its first step", () => + withFixture((fixture) => + Effect.gen(function* () { + const { ensureProjectIndexed, refresh, stopBuilds } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + { repository: true }, + ); + yield* ensureProjectIndexed(); + yield* stopBuilds; + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + }), + ), +); diff --git a/apps/server/src/project/knowledge/CodeIntelligence.ts b/apps/server/src/project/knowledge/CodeIntelligence.ts index c9f310eb7c00d..34984370e9b0d 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.ts @@ -1,6 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off - this boundary owns the analysis process, its IPC, and the index generation directories. import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -8,6 +10,7 @@ import { ProjectCodeQueryResult, type ProjectCodeIndexEvent, type ProjectCodeIndexTarget, + type ProjectId, type ProjectCodeQuery as ProjectCodeQueryType, type ProjectCodeQueryInput as ProjectCodeQueryInputType, type ProjectCodeQueryResult as ProjectCodeQueryResultType, @@ -16,36 +19,47 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as VcsDriver from "../../vcs/VcsDriver.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; import { INDEX_DIRECTORY, - IndexOutsideCheckout, generationDirectory, + indexRootFor, isIndexPath, listGenerations, publishGeneration, + readIndexedCheckout, readPublishedGeneration, - removeLegacyIndexFiles, + writeIndexedCheckout, } from "./codeIndexLayout.ts"; import { + hasLegacyIndexFiles, isBuildRunning, pinPublishedGeneration, removeGenerationUnlessRead, + removeIndexRoot, + removeLegacyIndexFromCheckout, tryLockBuild, } from "./codeIndexLocks.ts"; import type { CodeIntelligenceCommand } from "./codeIntelligenceWorkerThread.ts"; -const MAX_LISTED_FILES = 10_000; +// Each build is a process parsing a whole checkout, and indexing starts without anyone asking. +const MAX_CONCURRENT_BUILDS = 2; export class CodeIntelligenceProjectNotFound extends Schema.TaggedError()( "CodeIntelligenceProjectNotFound", @@ -91,12 +105,7 @@ export class CodeIntelligenceInspectionFailed extends Schema.TaggedError()( "CodeIntelligenceWorkerError", { - reason: Schema.Literals([ - "worker-failed", - "invalid-result", - "path-outside-checkout", - "index-outside-checkout", - ]), + reason: Schema.Literals(["worker-failed", "invalid-result", "path-outside-checkout"]), }, ) {} @@ -135,10 +144,41 @@ export class CodeIntelligence extends Context.Service< ) => Effect.Effect; /** * Requests a coalesced rebuild of a checkout that already has a published index and returns - * at once. A checkout nobody has queried is left unindexed. + * at once. A checkout nobody has queried is left unindexed, and so is every checkout while + * automatic indexing is off. */ readonly refreshIfIndexed: (cwd: string) => Effect.Effect; - /** Publications and failed builds of the target's checkout from the moment this returns. */ + /** + * Starts the first index of a project's own checkout and returns at once. It does nothing once + * one exists, after a failed build, or outside a repository. Worktrees are covered by + * `refreshForActiveAgent` instead, so merely viewing a worktree thread never indexes it. + */ + readonly ensureProjectIndexed: (projectId: ProjectId) => Effect.Effect; + /** + * Keeps a checkout indexed while an agent works in it. Indexes a worktree that has none and + * refreshes one that does, without waiting for either. + */ + readonly refreshForActiveAgent: (target: ProjectCodeIndexTarget) => Effect.Effect; + /** + * Stops this process's builds of a checkout and removes its index, leaving any generation + * a reader still pins to the next sweep. + */ + readonly removeCheckoutIndex: (checkoutPath: string) => Effect.Effect; + /** Whether the checkout has an index, current or left by an older version. */ + readonly hasCheckoutIndex: (checkoutPath: string) => Effect.Effect; + /** + * Removes indexes for checkouts that are gone or that `isKnown` rejects. A running build always + * holds its lock and has a record, so this only finds leftovers. + */ + readonly removeOrphanedIndexes: ( + isKnown: (checkoutPath: string) => boolean, + ) => Effect.Effect; + /** Interrupts every build this process is running, for when automatic indexing is turned off. */ + readonly stopBuilds: Effect.Effect; + /** + * Publications and failed builds of the target's checkout from the moment this returns. + * Watching a project also starts its first index. + */ readonly subscribeIndex: ( input: ProjectCodeIndexTarget, ) => Effect.Effect, CheckoutLookupError, Scope.Scope>; @@ -280,6 +320,15 @@ function runInAnalysisProcess( resume(Effect.fail(new CodeIntelligenceWorkerError({ reason: "worker-failed" }))); return; } + // A first build can hold a core for many minutes and nobody may be waiting on it, while a + // query has a caller. Set before the build's own threads exist so they inherit it. + if (command.type === "build" && child.pid !== undefined) { + try { + NodeOS.setPriority(child.pid, NodeOS.constants.priority.PRIORITY_BELOW_NORMAL); + } catch { + // Only ordering; a process that refuses it still builds correctly. + } + } let terminal: | { readonly _tag: "reply"; readonly value: WorkerTerminal } | { readonly _tag: "error"; readonly error: CodeIntelligenceWorkerError } @@ -306,11 +355,7 @@ function runInAnalysisProcess( if ("error" in decoded) { fail( new CodeIntelligenceWorkerError({ - reason: - decoded.error === "path-outside-checkout" || - decoded.error === "index-outside-checkout" - ? decoded.error - : "worker-failed", + reason: decoded.error === "path-outside-checkout" ? decoded.error : "worker-failed", }), ); } else terminal = { _tag: "reply", value: decoded }; @@ -370,6 +415,11 @@ interface CheckoutIndex { queued: Deferred.Deferred | null; // Whether the most recent build failed, leaving an older generation answering. lastFailed: boolean; + // The loop running those builds, interrupted to stop them when the checkout is removed. + fiber: Fiber.Fiber | null; + // Whether this checkout's first build already asked clients to read again. Once per checkout, + // or a build that ends at once loops through a query starting the next one. + firstBuildAnnounced: boolean; } const REFRESH_LIMITATIONS = { @@ -377,19 +427,18 @@ const REFRESH_LIMITATIONS = { failed: "The last index refresh failed; these results come from the generation before it.", } as const; -const removeGeneration = (checkoutPath: string, generation: number) => +const removeGeneration = (indexRoot: string, generation: number) => Effect.promise(() => - // `rm` unlinks a planted link instead of following it. - NodeFSP.rm(NodePath.join(checkoutPath, generationDirectory(generation)), { + NodeFSP.rm(NodePath.join(indexRoot, generationDirectory(generation)), { recursive: true, force: true, }).catch(() => undefined), ); // Best effort: a generation left behind is swept by the next build. -const removeUnlessRead = (checkoutPath: string, generation: number) => +const removeUnlessRead = (indexRoot: string, generation: number) => Effect.try({ - try: () => removeGenerationUnlessRead(checkoutPath, generation), + try: () => removeGenerationUnlessRead(indexRoot, generation), catch: () => undefined, }).pipe(Effect.ignore); @@ -403,6 +452,11 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig const threads = yield* ProjectionStore.ProjectionStoreV2; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const vcs = yield* VcsDriver.VcsDriver; + const settings = yield* ServerSettings.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const builds = yield* Semaphore.make(MAX_CONCURRENT_BUILDS); + const indexRootOf = (checkoutPath: string) => indexRootFor(config.codeIndexDir, checkoutPath); const launch: AnalysisLaunch = { executablePath: yield* HostProcess.ExecutablePath, isExecutable: yield* HostProcess.IsExecutable, @@ -412,14 +466,32 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig const stateOf = (checkoutPath: string) => { const existing = checkouts.get(checkoutPath); if (existing !== undefined) return existing; - const created: CheckoutIndex = { running: null, queued: null, lastFailed: false }; + const created: CheckoutIndex = { + running: null, + queued: null, + lastFailed: false, + fiber: null, + firstBuildAnnounced: false, + }; checkouts.set(checkoutPath, created); return created; }; + // Off, only a code query indexes a checkout; every proactive path reads this per call. + const autoIndexEnabled = settings.getSettings.pipe( + Effect.map((current) => current.autoCodeIndexEnabled), + Effect.orElseSucceed(() => false), + ); const indexChanges = yield* PubSub.unbounded<{ readonly checkoutPath: string; readonly event: ProjectCodeIndexEvent; }>(); + // Clients only treat events as a re-read signal, and older ones cannot decode a new outcome, so + // "read again" is a publication with no generation. + const publishReRead = (checkoutPath: string) => + PubSub.publish(indexChanges, { + checkoutPath, + event: { outcome: "published", generation: null }, + }); const resolveCheckout = Effect.fn("CodeIntelligence.resolveCheckout")(function* ( input: ProjectCodeIndexTarget, @@ -443,20 +515,36 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig }); const listCheckout = Effect.fn("CodeIntelligence.listCheckout")(function* (checkoutPath: string) { + const indexRoot = indexRootOf(checkoutPath); + // Git lists a repository in full. The finder caps its listing, and holds only part of a + // checkout until its first scan ends, which is exactly when a new project is indexed. + const repository = yield* vcs.listWorkspaceFiles(checkoutPath).pipe(Effect.option); + if (Option.isSome(repository)) { + // An unmerged path is listed once per stage. + const files = [...new Set(repository.value.paths)].filter((path) => !isIndexPath(path)); + return { + checkoutPath, + indexRoot, + files, + filesListed: files.length, + listingTruncated: repository.value.truncated, + }; + } yield* workspaceEntries.refresh(checkoutPath); const listing = yield* workspaceEntries.list({ cwd: checkoutPath }); const files = listing.entries - // The index lives in the checkout; counting it would report its files as unsupported - // source. + // A checkout that still holds an older version's index would otherwise report its files + // as unsupported source. .filter( (entry) => entry.kind === "file" && entry.ignored !== true && !isIndexPath(entry.path), ) .map((entry) => entry.path); return { checkoutPath, - files: files.slice(0, MAX_LISTED_FILES), + indexRoot, + files, filesListed: files.length, - listingTruncated: listing.truncated || files.length > MAX_LISTED_FILES, + listingTruncated: listing.truncated, }; }); @@ -466,22 +554,18 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig CodeIndexRefreshOutcome, CodeIntelligenceWorkerError | WorkspaceEntries.WorkspaceEntriesError > { + const indexRoot = indexRootOf(checkoutPath); const plan = yield* Effect.try({ - try: () => { - const published = readPublishedGeneration(checkoutPath); - removeLegacyIndexFiles(checkoutPath); - return { published, existing: listGenerations(checkoutPath) }; - }, - catch: (cause) => - new CodeIntelligenceWorkerError({ - reason: - cause instanceof IndexOutsideCheckout ? "index-outside-checkout" : "worker-failed", - }), + try: () => ({ + published: readPublishedGeneration(indexRoot), + existing: listGenerations(indexRoot), + }), + catch: () => new CodeIntelligenceWorkerError({ reason: "worker-failed" }), }); // Interrupted builds, and superseded generations whose last reader is gone. The build lock // keeps every other builder out, so none of these is being written. for (const existing of plan.existing) { - if (existing !== plan.published) yield* removeUnlessRead(checkoutPath, existing); + if (existing !== plan.published) yield* removeUnlessRead(indexRoot, existing); } const generation = Math.max(plan.published ?? 0, ...plan.existing) + 1; const listing = yield* listCheckout(checkoutPath); @@ -497,43 +581,56 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig }, }, options.beforeBuildCommand, - ).pipe(Effect.onError(() => removeGeneration(checkoutPath, generation))); + ).pipe(Effect.onError(() => removeGeneration(indexRoot, generation))); if (!("built" in reply)) { - yield* removeGeneration(checkoutPath, generation); + yield* removeGeneration(indexRoot, generation); return yield* new CodeIntelligenceWorkerError({ reason: "invalid-result" }); } if (!reply.built.changed) return { _tag: "Unchanged" } as const; yield* Effect.try({ - try: () => publishGeneration(checkoutPath, generation), - catch: (cause) => - new CodeIntelligenceWorkerError({ - reason: - cause instanceof IndexOutsideCheckout ? "index-outside-checkout" : "worker-failed", - }), - }).pipe(Effect.onError(() => removeGeneration(checkoutPath, generation))); + try: () => publishGeneration(indexRoot, generation), + catch: () => new CodeIntelligenceWorkerError({ reason: "worker-failed" }), + }).pipe(Effect.onError(() => removeGeneration(indexRoot, generation))); const previous = plan.published; // New readers pin the new generation from here on; a reader of the old one removes it. - if (previous !== null) yield* removeUnlessRead(checkoutPath, previous); + if (previous !== null) yield* removeUnlessRead(indexRoot, previous); return { _tag: "Published", generation } as const; }); const buildOnce = (checkoutPath: string): Effect.Effect => Effect.acquireUseRelease( Effect.try({ - try: () => tryLockBuild(checkoutPath), - catch: (cause) => - new CodeIntelligenceWorkerError({ - reason: - cause instanceof IndexOutsideCheckout ? "index-outside-checkout" : "worker-failed", - }), + try: () => { + const indexRoot = indexRootOf(checkoutPath); + const release = tryLockBuild(indexRoot); + // One step: a concurrent removal must never see a locked root without a record. + if (release !== null) writeIndexedCheckout(indexRoot, checkoutPath); + return release; + }, + catch: () => new CodeIntelligenceWorkerError({ reason: "worker-failed" }), }), // Waiting would tie this server to another's build; that build covers the checkout. (release) => release === null ? Effect.succeed({ _tag: "BuildingElsewhere" } as const) - : buildGeneration(checkoutPath), + : Effect.gen(function* () { + // Folders an older version left in the checkout go on the first build that sees them. + yield* Effect.sync(() => { + if (hasLegacyIndexFiles(checkoutPath)) removeLegacyIndexFromCheckout(checkoutPath); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to remove a checkout's old index files", { + checkoutPath, + cause, + }), + ), + ); + return yield* buildGeneration(checkoutPath); + }), (release) => Effect.sync(() => release?.()), ).pipe( + // Outside the lock: a checkout waiting its turn must not read as building to other servers. + builds.withPermits(1), Effect.catch((error) => Effect.succeed({ _tag: "Failed", @@ -547,59 +644,48 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig ), ); - const runBuilds = Effect.fn("CodeIntelligence.runBuilds")( - function* (checkoutPath: string, state: CheckoutIndex) { - let current = state.running; - while (current !== null) { - const outcome = yield* buildOnce(checkoutPath); - // Subscribers query again on failure; repeating that event would keep starting builds - // for a persistent failure even when nobody explicitly asks to retry. - if ( - outcome._tag === "Published" || - (outcome._tag === "Failed" && !state.lastFailed) || - (outcome._tag === "Unchanged" && state.lastFailed) - ) { - state.lastFailed = outcome._tag === "Failed"; - const generation = - outcome._tag === "Published" - ? outcome.generation - : yield* Effect.sync(() => { - try { - return readPublishedGeneration(checkoutPath); - } catch { - return null; - } - }); - // Existing clients understand a published generation becoming available again. - const event: ProjectCodeIndexEvent = { - outcome: outcome._tag === "Failed" ? "failed" : "published", - generation, - }; - yield* PubSub.publish(indexChanges, { checkoutPath, event }); - } - yield* Deferred.succeed(current, outcome); - // Promoting and reading the next build in one step keeps a concurrent request from - // starting a second loop for the same checkout. - current = yield* Effect.sync(() => { - state.running = state.queued; - state.queued = null; - return state.running; - }); + const runBuilds = Effect.fn("CodeIntelligence.runBuilds")(function* ( + checkoutPath: string, + state: CheckoutIndex, + ) { + let current = state.running; + while (current !== null) { + const outcome = yield* buildOnce(checkoutPath); + // Subscribers query again on failure; repeating that event would keep starting builds + // for a persistent failure even when nobody explicitly asks to retry. + if ( + outcome._tag === "Published" || + (outcome._tag === "Failed" && !state.lastFailed) || + (outcome._tag === "Unchanged" && state.lastFailed) + ) { + state.lastFailed = outcome._tag === "Failed"; + const generation = + outcome._tag === "Published" + ? outcome.generation + : yield* Effect.sync(() => { + try { + return readPublishedGeneration(indexRootOf(checkoutPath)); + } catch { + return null; + } + }); + // Existing clients understand a published generation becoming available again. + const event: ProjectCodeIndexEvent = { + outcome: outcome._tag === "Failed" ? "failed" : "published", + generation, + }; + yield* PubSub.publish(indexChanges, { checkoutPath, event }); } - }, - (effect, _checkoutPath, state) => - effect.pipe( - Effect.onInterrupt(() => - Effect.gen(function* () { - const pending = [state.running, state.queued]; - state.running = null; - state.queued = null; - for (const deferred of pending) - if (deferred !== null) yield* Deferred.interrupt(deferred); - }), - ), - ), - ); + yield* Deferred.succeed(current, outcome); + // Promoting and reading the next build in one step keeps a concurrent request from + // starting a second loop for the same checkout. + current = yield* Effect.sync(() => { + state.running = state.queued; + state.queued = null; + return state.running; + }); + } + }); /** The build that will cover a request made now; starts one when the checkout is idle. */ const requestBuild = (checkoutPath: string) => @@ -612,8 +698,39 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig return Effect.succeed(next); } state.running = next; - return runBuilds(checkoutPath, state).pipe(Effect.forkIn(serviceScope), Effect.as(next)); - }); + return Effect.gen(function* () { + // Once per checkout: a build that ends at once (another server holds the lock, or the + // index path is refused) would otherwise be restarted by the query its own signal runs. + if (!state.firstBuildAnnounced) { + const unindexed = yield* Effect.sync(() => { + try { + return readPublishedGeneration(indexRootOf(checkoutPath)) === null; + } catch { + return false; + } + }); + if (unindexed) { + state.firstBuildAnnounced = true; + yield* publishReRead(checkoutPath); + } + } + const fiber = yield* runBuilds(checkoutPath, state).pipe(Effect.forkIn(serviceScope)); + // Outside the loop: a fiber interrupted before its first step runs none of its own code. + fiber.addObserver((exit) => { + if (Exit.isSuccess(exit)) return; + const pending = [state.running, state.queued]; + state.running = null; + state.queued = null; + for (const deferred of pending) { + if (deferred !== null) Deferred.doneUnsafe(deferred, Exit.failCause(exit.cause)); + } + }); + state.fiber = fiber; + return next; + }); + // Interruption here would leave `running` set with no loop behind it, wedging the checkout + // until restart. The forked loop itself stays interruptible. + }).pipe(Effect.uninterruptible); const refresh: CodeIntelligence["Service"]["refresh"] = Effect.fn("CodeIntelligence.refresh")( function* (input) { @@ -626,18 +743,17 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig function* (input) { const checkoutPath = yield* resolveCheckout(input); const normalizedQuery = yield* normalizeQueryPaths(input.query, checkoutPath, workspacePaths); + const indexRoot = indexRootOf(checkoutPath); const { generation: published } = yield* Effect.acquireRelease( Effect.try({ - try: () => pinPublishedGeneration(checkoutPath), - catch: (cause) => - cause instanceof IndexOutsideCheckout - ? new CodeIntelligencePathOutsideCheckout({ path: INDEX_DIRECTORY }) - : new CodeIntelligenceInspectionFailed({ - projectId: input.projectId, - threadId: input.threadId ?? null, - path: INDEX_DIRECTORY, - reason: "worker-failed", - }), + try: () => pinPublishedGeneration(indexRoot), + catch: () => + new CodeIntelligenceInspectionFailed({ + projectId: input.projectId, + threadId: input.threadId ?? null, + path: checkoutPath, + reason: "worker-failed", + }), }), (pin) => Effect.sync(pin.release), ); @@ -663,9 +779,6 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig if (reason === "path-outside-checkout" && queryPath !== undefined) { return new CodeIntelligencePathOutsideCheckout({ path: queryPath }); } - if (reason === "index-outside-checkout") { - return new CodeIntelligencePathOutsideCheckout({ path: INDEX_DIRECTORY }); - } return new CodeIntelligenceInspectionFailed({ projectId: input.projectId, threadId: input.threadId ?? null, @@ -694,7 +807,7 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig reply.result.index?.state === "indexing" || reply.result.index?.state === "stale"; const refreshing: keyof typeof REFRESH_LIMITATIONS | undefined = - needsRefresh && (state?.running != null || isBuildRunning(checkoutPath)) + needsRefresh && (state?.running != null || isBuildRunning(indexRoot)) ? "building" : state?.lastFailed ? "failed" @@ -718,11 +831,12 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig const refreshIfIndexed: CodeIntelligence["Service"]["refreshIfIndexed"] = Effect.fn( "CodeIntelligence.refreshIfIndexed", )(function* (cwd) { + if (!(yield* autoIndexEnabled)) return; const checkoutPath = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(Effect.option); if (Option.isNone(checkoutPath)) return; const published = yield* Effect.sync(() => { try { - return readPublishedGeneration(checkoutPath.value); + return readPublishedGeneration(indexRootOf(checkoutPath.value)); } catch { return null; } @@ -730,18 +844,161 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig if (published !== null) yield* requestBuild(checkoutPath.value); }); + const ensureProjectIndexed: CodeIntelligence["Service"]["ensureProjectIndexed"] = Effect.fn( + "CodeIntelligence.ensureProjectIndexed", + )(function* (projectId) { + if (!(yield* autoIndexEnabled)) return; + const checkoutPath = yield* resolveCheckout({ projectId }).pipe(Effect.option); + if (Option.isNone(checkoutPath)) return; + const state = checkouts.get(checkoutPath.value); + // A failed build waits for a code query; retrying here would repeat a persistent failure each + // time a client looked at the project. + if (state?.running != null || state?.lastFailed === true) return; + const unindexed = yield* Effect.sync(() => { + try { + return readPublishedGeneration(indexRootOf(checkoutPath.value)) === null; + } catch { + return false; + } + }); + if (!unindexed) return; + // A folder outside any repository may be anything, including something huge. + const isRepository = yield* vcs + .isInsideWorkTree(checkoutPath.value) + .pipe(Effect.orElseSucceed(() => false)); + if (isRepository) yield* requestBuild(checkoutPath.value); + }); + + const refreshForActiveAgent: CodeIntelligence["Service"]["refreshForActiveAgent"] = Effect.fn( + "CodeIntelligence.refreshForActiveAgent", + )(function* (target) { + if (!(yield* autoIndexEnabled)) return; + const checkoutPath = yield* resolveCheckout(target).pipe(Effect.option); + if (Option.isNone(checkoutPath)) return; + const published = yield* Effect.sync(() => { + try { + return readPublishedGeneration(indexRootOf(checkoutPath.value)); + } catch { + return null; + } + }); + if (published !== null) { + yield* requestBuild(checkoutPath.value); + return; + } + // A worktree always indexes for its agent; a folder outside any repository does not. + const isRepository = yield* vcs + .isInsideWorkTree(checkoutPath.value) + .pipe(Effect.orElseSucceed(() => false)); + if (isRepository) yield* requestBuild(checkoutPath.value); + }); + + const removeCheckoutIndex: CodeIntelligence["Service"]["removeCheckoutIndex"] = Effect.fn( + "CodeIntelligence.removeCheckoutIndex", + )(function* (checkoutPath) { + // A checkout that is already gone no longer normalizes; its index still needs removing. + const path = yield* workspacePaths + .normalizeWorkspaceRoot(checkoutPath) + .pipe(Effect.orElseSucceed(() => NodePath.resolve(checkoutPath))); + const state = checkouts.get(path); + const hadBuild = state?.fiber != null && state.running !== null; + // The interrupt returns only after the build process is killed and its lock released. + if (state?.fiber != null) yield* Fiber.interrupt(state.fiber); + checkouts.delete(path); + const hadFiles = yield* Effect.sync( + () => NodeFS.existsSync(indexRootOf(path)) || hasLegacyIndexFiles(path), + ); + const removal = yield* Effect.exit( + Effect.sync(() => { + removeIndexRoot(indexRootOf(path)); + if (hasLegacyIndexFiles(path)) removeLegacyIndexFromCheckout(path); + }), + ); + if (Exit.isFailure(removal)) { + yield* Effect.logWarning("failed to remove a checkout's code index", { + checkoutPath: path, + cause: removal.cause, + }); + } + // A mounted badge reads again on any event; without one it would keep showing Ready. + if (hadFiles || hadBuild) yield* publishReRead(path); + }); + + const hasCheckoutIndex: CodeIntelligence["Service"]["hasCheckoutIndex"] = Effect.fn( + "CodeIntelligence.hasCheckoutIndex", + )(function* (checkoutPath) { + return yield* Effect.sync( + () => NodeFS.existsSync(indexRootOf(checkoutPath)) || hasLegacyIndexFiles(checkoutPath), + ); + }); + + const removeOrphanedIndexes: CodeIntelligence["Service"]["removeOrphanedIndexes"] = Effect.fn( + "CodeIntelligence.removeOrphanedIndexes", + )(function* (isKnown) { + const roots = yield* Effect.sync(() => { + try { + return NodeFS.readdirSync(config.codeIndexDir, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() ? [entry.name] : [], + ); + } catch { + return []; + } + }); + for (const name of roots) { + yield* Effect.gen(function* () { + const indexRoot = NodePath.join(config.codeIndexDir, name); + const owner = readIndexedCheckout(indexRoot); + // A build always holds the lock and has a record, so a root without one is a leftover. + if (owner === null) { + yield* Effect.sync(() => removeIndexRoot(indexRoot)); + return; + } + if (isKnown(owner) && NodeFS.existsSync(owner)) return; + yield* removeCheckoutIndex(owner); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to remove an orphaned code index", { indexRoot: name, cause }), + ), + ); + } + }); + + const stopBuilds: CodeIntelligence["Service"]["stopBuilds"] = Effect.gen(function* () { + for (const [path, state] of checkouts) { + // `running` is cleared when a loop ends, so a finished build is not stopped or announced. + if (state.fiber === null || state.running === null) continue; + yield* Fiber.interrupt(state.fiber); + // The checkout can build again later, so it announces its next first build. + state.firstBuildAnnounced = false; + // A badge stuck on Indexing has nothing to read again unless it hears about this. + yield* publishReRead(path); + } + }); + const subscribeIndex: CodeIntelligence["Service"]["subscribeIndex"] = Effect.fn( "CodeIntelligence.subscribeIndex", )(function* (input) { const checkoutPath = yield* resolveCheckout(input); const subscription = yield* PubSub.subscribe(indexChanges); + yield* ensureProjectIndexed(input.projectId); return Stream.fromSubscription(subscription).pipe( Stream.filter((change) => change.checkoutPath === checkoutPath), Stream.map((change) => change.event), ); }); - return CodeIntelligence.of({ query, refresh, refreshIfIndexed, subscribeIndex }); + return CodeIntelligence.of({ + query, + refresh, + refreshIfIndexed, + ensureProjectIndexed, + refreshForActiveAgent, + removeCheckoutIndex, + hasCheckoutIndex, + removeOrphanedIndexes, + stopBuilds, + subscribeIndex, + }); }); export const layer = Layer.effect(CodeIntelligence, make({})); diff --git a/apps/server/src/project/knowledge/codeIndexLayout.ts b/apps/server/src/project/knowledge/codeIndexLayout.ts index 6b43f52e97774..d461b23b7b379 100644 --- a/apps/server/src/project/knowledge/codeIndexLayout.ts +++ b/apps/server/src/project/knowledge/codeIndexLayout.ts @@ -1,23 +1,20 @@ // @effect-diagnostics nodeBuiltinImport:off - shared by the server and the analysis worker, which runs outside the Effect runtime; every call is a bounded synchronous filesystem step. +import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; /** - * The control directory holds only the `published` pointer. Each index generation is a sibling - * `.codegraph-t3-g` because the SDK takes its data directory as one path segment in the - * checkout root, and its indexer skips every `.codegraph-*` sibling. + * The index root is `/` in T3's data directory, never the checkout: the SDK + * takes its data directory as one path segment inside its project root and ignores absolute + * paths, so T3 gives it a private root that links back to the checkout's top-level entries. */ export const INDEX_DIRECTORY = ".codegraph-t3"; const GENERATION_PREFIX = `${INDEX_DIRECTORY}-g`; const GENERATION_NAME = /^\.codegraph-t3-g([1-9][0-9]{0,8})$/; -const PUBLISHED_FILE = "published"; -const INDEX_GITIGNORE = "# T3 code index, local to this machine.\n*\n"; - -export class IndexOutsideCheckout extends Error { - constructor() { - super("index-outside-checkout"); - } -} +export const PUBLISHED_FILE = "published"; +const INDEXED_CHECKOUT_FILE = "checkout"; +// Changes what the SDK indexes, and a truncated listing can drop them. +const CHECKOUT_CONFIG_ENTRIES = ["codegraph.json", ".gitignore"]; export const generationDirectory = (generation: number) => `${GENERATION_PREFIX}${generation}`; @@ -26,60 +23,49 @@ function generationOf(name: string): number | null { return match === null ? null : Number(match[1]); } -/** Whether a checkout-relative path lies in the control directory or a generation. */ +/** Whether a root-relative path lies in the control directory or a generation. */ export function isIndexPath(relativePath: string): boolean { const top = relativePath.split("/", 1)[0]!; return top === INDEX_DIRECTORY || generationOf(top) !== null; } -/** Generation directories present in the checkout root, whatever their state. */ -export function listGenerations(checkoutPath: string): number[] { - return NodeFS.readdirSync(checkoutPath).flatMap((name) => { +/** The index root one checkout's index lives under. */ +export function indexRootFor(codeIndexDir: string, checkoutPath: string): string { + const digest = NodeCrypto.createHash("sha256").update(checkoutPath).digest("hex"); + return NodePath.join(codeIndexDir, digest.slice(0, 32)); +} + +/** Generation directories present in the index root, whatever their state. */ +export function listGenerations(indexRoot: string): number[] { + let names: string[]; + try { + names = NodeFS.readdirSync(indexRoot); + } catch { + return []; + } + return names.flatMap((name) => { const generation = generationOf(name); return generation === null ? [] : [generation]; }); } -/** - * Refuses an index directory that is a link, resolves outside the checkout, or holds anything but - * plain entries. The SDK opens the database, its -wal/-shm sidecars, its lock and .gitignore by - * path and follows a link or shared inode out of the checkout, and T3 writes the pointer and - * metadata the same way. A missing directory passes. - */ -export function validateIndexDirectory(checkoutPath: string, name: string): void { - const indexPath = NodePath.join(checkoutPath, name); +/** The checkout this index root belongs to, or null when it holds no record. */ +export function readIndexedCheckout(indexRoot: string): string | null { try { - const stat = NodeFS.lstatSync(indexPath); - if (stat.isSymbolicLink() || !stat.isDirectory()) throw new IndexOutsideCheckout(); - const root = NodeFS.realpathSync(checkoutPath); - const relative = NodePath.relative(root, NodeFS.realpathSync(indexPath)); - if ( - relative === "" || - relative === ".." || - relative.startsWith(`..${NodePath.sep}`) || - NodePath.isAbsolute(relative) - ) { - throw new IndexOutsideCheckout(); - } - for (const child of NodeFS.readdirSync(indexPath)) { - const entry = NodeFS.lstatSync(NodePath.join(indexPath, child)); - if (!(entry.isDirectory() || (entry.isFile() && entry.nlink === 1))) { - throw new IndexOutsideCheckout(); - } - } - } catch (cause) { - if (cause instanceof IndexOutsideCheckout) throw cause; - if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause; + return NodeFS.readFileSync( + NodePath.join(indexRoot, INDEX_DIRECTORY, INDEXED_CHECKOUT_FILE), + "utf8", + ); + } catch { + return null; } } -/** Replaces `name` in the directory without writing through whatever already sits there. */ +/** Replaces `name` in the directory in one step, so a reader sees the old contents or the new. */ function replaceFile(directory: string, name: string, contents: string): void { const target = NodePath.join(directory, name); const temporary = `${target}.${process.pid}.tmp`; try { - // An exclusive create never follows a planted link, and the rename replaces the entry - // rather than writing through it. NodeFS.writeFileSync(temporary, contents, { flag: "wx" }); NodeFS.renameSync(temporary, target); } catch (cause) { @@ -88,39 +74,77 @@ function replaceFile(directory: string, name: string, contents: string): void { } } +/** Records which checkout an index root belongs to, for sweeps that outlive the project. */ +export function writeIndexedCheckout(indexRoot: string, checkoutPath: string): void { + const control = NodePath.join(indexRoot, INDEX_DIRECTORY); + NodeFS.mkdirSync(control, { recursive: true }); + replaceFile(control, INDEXED_CHECKOUT_FILE, checkoutPath); +} + /** - * The SDK's .gitignore keeps itself tracked, which leaves the index directory in the user's - * `git status`; T3's index is private to the machine, so the file ignores itself too. + * Points the index root at each top-level entry of the checkout the build may read, so the SDK + * finds sources at the paths the index stores. A missing target is skipped: it has nothing to + * index. */ -export function ignoreIndexDirectory(checkoutPath: string, name: string): void { - const directory = NodePath.join(checkoutPath, name); - try { - if (NodeFS.readFileSync(NodePath.join(directory, ".gitignore"), "utf8") === INDEX_GITIGNORE) - return; - } catch {} - try { - replaceFile(directory, ".gitignore", INDEX_GITIGNORE); - } catch {} +export function linkCheckoutSources( + indexRoot: string, + checkoutPath: string, + paths: ReadonlyArray, +): void { + const names = new Set(CHECKOUT_CONFIG_ENTRIES); + for (const path of paths) names.add(path.split("/", 1)[0]!); + for (const name of names) { + if (isIndexPath(name)) continue; + const link = NodePath.join(indexRoot, name); + const target = NodePath.join(checkoutPath, name); + let isDirectory: boolean; + try { + // Followed: Windows needs the link's type to match what a checkout link points at. + isDirectory = NodeFS.statSync(target).isDirectory(); + } catch { + continue; + } + if (NodeFS.lstatSync(link, { throwIfNoEntry: false }) !== undefined) continue; + try { + // `junction` needs no privilege on Windows, and the type argument is ignored on POSIX. + NodeFS.symlinkSync(target, link, isDirectory ? "junction" : "file"); + } catch (cause) { + // Windows file links need a privilege; a copy is rebuilt on every build. + if (!isDirectory && (cause as NodeJS.ErrnoException).code === "EPERM") { + NodeFS.copyFileSync(target, link); + continue; + } + throw cause; + } + } } -export function readFileNoFollow(path: string): string { - const descriptor = NodeFS.openSync( - path, - NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NOFOLLOW ?? 0), - ); +/** Removes the links and copies a build left in the index root, never their targets. */ +export function clearCheckoutSources(indexRoot: string): void { + let names: string[]; try { - return NodeFS.readFileSync(descriptor, "utf8"); - } finally { - NodeFS.closeSync(descriptor); + names = NodeFS.readdirSync(indexRoot); + } catch { + return; + } + for (const name of names) { + if (isIndexPath(name)) continue; + const entry = NodePath.join(indexRoot, name); + let stats: NodeFS.Stats; + try { + stats = NodeFS.lstatSync(entry); + } catch { + continue; + } + if (stats.isSymbolicLink() || stats.isFile()) NodeFS.unlinkSync(entry); } } -/** The published generation, or null for a checkout with none. Validates the control directory. */ -export function readPublishedGeneration(checkoutPath: string): number | null { - validateIndexDirectory(checkoutPath, INDEX_DIRECTORY); +/** The published generation, or null for an index root with none. */ +export function readPublishedGeneration(indexRoot: string): number | null { let text: string; try { - text = readFileNoFollow(NodePath.join(checkoutPath, INDEX_DIRECTORY, PUBLISHED_FILE)); + text = NodeFS.readFileSync(NodePath.join(indexRoot, INDEX_DIRECTORY, PUBLISHED_FILE), "utf8"); } catch (cause) { if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null; throw cause; @@ -129,27 +153,8 @@ export function readPublishedGeneration(checkoutPath: string): number | null { } /** Atomically points readers at `generation`. */ -export function publishGeneration(checkoutPath: string, generation: number): void { - const directory = NodePath.join(checkoutPath, INDEX_DIRECTORY); +export function publishGeneration(indexRoot: string, generation: number): void { + const directory = NodePath.join(indexRoot, INDEX_DIRECTORY); NodeFS.mkdirSync(directory, { recursive: true }); - validateIndexDirectory(checkoutPath, INDEX_DIRECTORY); - ignoreIndexDirectory(checkoutPath, INDEX_DIRECTORY); replaceFile(directory, PUBLISHED_FILE, String(generation)); } - -/** - * Index files an older T3 wrote into the control directory, when it was also the SDK's data - * directory. Nothing reads them any more. - */ -export function removeLegacyIndexFiles(checkoutPath: string): void { - const directory = NodePath.join(checkoutPath, INDEX_DIRECTORY); - for (const name of [ - "codegraph.db", - "codegraph.db-wal", - "codegraph.db-shm", - "codegraph.lock", - "t3-index.json", - ]) { - NodeFS.rmSync(NodePath.join(directory, name), { force: true }); - } -} diff --git a/apps/server/src/project/knowledge/codeIndexLocks.ts b/apps/server/src/project/knowledge/codeIndexLocks.ts index 2678771fe09dd..c745808b25ca2 100644 --- a/apps/server/src/project/knowledge/codeIndexLocks.ts +++ b/apps/server/src/project/knowledge/codeIndexLocks.ts @@ -5,15 +5,16 @@ import * as NodeSqlite from "node:sqlite"; import { INDEX_DIRECTORY, + PUBLISHED_FILE, + clearCheckoutSources, generationDirectory, - ignoreIndexDirectory, + listGenerations, readPublishedGeneration, - validateIndexDirectory, } from "./codeIndexLayout.ts"; /** - * Servers on one machine share a checkout's index, so its builder and readers coordinate through - * SQLite's file locks (fcntl on POSIX, LockFileEx on Windows) on empty lock databases. The kernel + * Servers sharing a data directory share a checkout's index, so its builder and readers coordinate + * through SQLite's file locks (fcntl on POSIX, LockFileEx on Windows) on empty lock databases. The kernel * drops those locks when their process exits, however it exits, so a crashed server leaves no * record for a PID check to judge, and a recycled PID holds nothing. */ @@ -70,12 +71,10 @@ function lockShared(path: string, role: "reader" | "probe") { } } -/** Takes the checkout's build lock and returns its release, or null while another builder holds it. */ -export function tryLockBuild(checkoutPath: string): (() => void) | null { - const control = NodePath.join(checkoutPath, INDEX_DIRECTORY); +/** Takes the index root's build lock and returns its release, or null while another builder holds it. */ +export function tryLockBuild(indexRoot: string): (() => void) | null { + const control = NodePath.join(indexRoot, INDEX_DIRECTORY); NodeFS.mkdirSync(control, { recursive: true }); - validateIndexDirectory(checkoutPath, INDEX_DIRECTORY); - ignoreIndexDirectory(checkoutPath, INDEX_DIRECTORY); const build = lockExclusive(NodePath.join(control, BUILD_LOCK), 0); if (build === null) return null; try { @@ -95,10 +94,10 @@ const closeLock = (lock: ReturnType) => { if (lock !== null && lock !== "missing") lock.close(); }; -/** Whether any process, this one included, is building the checkout's index now. */ -export function isBuildRunning(checkoutPath: string): boolean { +/** Whether any process, this one included, is building the index now. */ +export function isBuildRunning(indexRoot: string): boolean { try { - const probe = lockShared(NodePath.join(checkoutPath, INDEX_DIRECTORY, BUILDING_LOCK), "probe"); + const probe = lockShared(NodePath.join(indexRoot, INDEX_DIRECTORY, BUILDING_LOCK), "probe"); closeLock(probe); return probe === null; } catch { @@ -111,16 +110,8 @@ export function isBuildRunning(checkoutPath: string): boolean { * instead. Callers ensure no build writes it: the builder holds the build lock, and a reader * removes only generations older than the published one. */ -export function removeGenerationUnlessRead(checkoutPath: string, generation: number): void { - const name = generationDirectory(generation); - const directory = NodePath.join(checkoutPath, name); - try { - validateIndexDirectory(checkoutPath, name); - } catch { - // Nothing reads a generation that fails validation; `rm` unlinks a planted link unfollowed. - NodeFS.rmSync(directory, { recursive: true, force: true }); - return; - } +export function removeGenerationUnlessRead(indexRoot: string, generation: number): void { + const directory = NodePath.join(indexRoot, generationDirectory(generation)); let lock: NodeSqlite.DatabaseSync | null; try { lock = lockExclusive(NodePath.join(directory, READERS_LOCK), 0); @@ -142,6 +133,112 @@ export function removeGenerationUnlessRead(checkoutPath: string, generation: num NodeFS.rmSync(directory, { recursive: true, force: true }); } +/** + * Removes one index root: the published pointer first, so new readers see "unindexed", then every + * generation a reader is not pinning, then the checkout's links. A build elsewhere leaves it all + * for the next sweep. + * + * Nothing here may remove recursively from the root: its entries are links into a user's checkout. + */ +export function removeIndexRoot(indexRoot: string): void { + if (!NodeFS.existsSync(indexRoot)) return; + const control = NodePath.join(indexRoot, INDEX_DIRECTORY); + const release = tryLockBuild(indexRoot); + if (release === null) return; + try { + NodeFS.rmSync(NodePath.join(control, PUBLISHED_FILE), { force: true }); + for (const generation of listGenerations(indexRoot)) { + removeGenerationUnlessRead(indexRoot, generation); + } + clearCheckoutSources(indexRoot); + } finally { + // Windows cannot delete an open lock file, so it goes after the lock is released. + release(); + } + NodeFS.rmSync(control, { recursive: true, force: true }); + try { + // A generation a reader still pins stays behind, and so does the root. + NodeFS.rmdirSync(indexRoot); + } catch (cause) { + const code = (cause as NodeJS.ErrnoException).code; + if (code !== "ENOTEMPTY" && code !== "ENOENT") throw cause; + } +} + +// Files an older T3 left in a checkout when the index lived beside its sources. +const LEGACY_CONTROL_MARKERS = [ + PUBLISHED_FILE, + BUILD_LOCK, + BUILDING_LOCK, + "codegraph.db", + "t3-index.json", +]; +const LEGACY_GENERATION_MARKERS = ["t3-index.json", "codegraph.db"]; + +const isRealDirectory = (path: string): boolean => { + try { + return NodeFS.lstatSync(path).isDirectory(); + } catch { + return false; + } +}; + +/** A real directory holding at least one of `names`; a link to one is not a match. */ +const isT3Directory = (path: string, names: ReadonlyArray): boolean => + isRealDirectory(path) && names.some((name) => NodeFS.existsSync(NodePath.join(path, name))); + +/** + * The folders an older T3 left inside a checkout, when they are provably T3's. Anything else that + * happens to share a name belongs to the user. + */ +function legacyIndexFolders( + checkoutPath: string, +): { readonly generations: ReadonlyArray } | null { + const control = NodePath.join(checkoutPath, INDEX_DIRECTORY); + let exists: boolean; + try { + exists = NodeFS.lstatSync(control, { throwIfNoEntry: false }) !== undefined; + } catch { + return null; + } + if (exists && !isT3Directory(control, LEGACY_CONTROL_MARKERS)) return null; + const generations = listGenerations(checkoutPath).filter((generation) => + isT3Directory( + NodePath.join(checkoutPath, generationDirectory(generation)), + LEGACY_GENERATION_MARKERS, + ), + ); + return exists || generations.length > 0 ? { generations } : null; +} + +/** Whether a checkout still holds the folders an older T3 kept inside it, without throwing. */ +export function hasLegacyIndexFiles(checkoutPath: string): boolean { + try { + return legacyIndexFolders(checkoutPath) !== null; + } catch { + return false; + } +} + +/** Removes the index folders an older T3 left inside a checkout, and nothing else there. */ +export function removeLegacyIndexFromCheckout(checkoutPath: string): void { + const legacy = legacyIndexFolders(checkoutPath); + if (legacy === null) return; + const control = NodePath.join(checkoutPath, INDEX_DIRECTORY); + // An older server may still be building; it finishes and this runs on a later build. + const release = tryLockBuild(checkoutPath); + if (release === null) return; + try { + NodeFS.rmSync(NodePath.join(control, PUBLISHED_FILE), { force: true }); + for (const generation of legacy.generations) { + removeGenerationUnlessRead(checkoutPath, generation); + } + } finally { + release(); + } + NodeFS.rmSync(control, { recursive: true, force: true }); +} + export interface GenerationPin { readonly generation: number | null; readonly release: () => void; @@ -157,15 +254,14 @@ const unpinned = (generation: number | null): GenerationPin => ({ * lock is held: a generation is deleted only after it stops being published, and never published * again, so a pin that still finds it published holds a lock its deleter has to wait for. */ -export function pinPublishedGeneration(checkoutPath: string): GenerationPin { - let generation = readPublishedGeneration(checkoutPath); +export function pinPublishedGeneration(indexRoot: string): GenerationPin { + let generation = readPublishedGeneration(indexRoot); while (generation !== null) { const name = generationDirectory(generation); - validateIndexDirectory(checkoutPath, name); - const lock = lockShared(NodePath.join(checkoutPath, name, READERS_LOCK), "reader"); + const lock = lockShared(NodePath.join(indexRoot, name, READERS_LOCK), "reader"); let current: number | null; try { - current = readPublishedGeneration(checkoutPath); + current = readPublishedGeneration(indexRoot); } catch (cause) { closeLock(lock); throw cause; @@ -179,7 +275,7 @@ export function pinPublishedGeneration(checkoutPath: string): GenerationPin { generation: pinned, release: () => { lock.close(); - retireIfSuperseded(checkoutPath, pinned); + retireIfSuperseded(indexRoot, pinned); }, }; } @@ -189,12 +285,12 @@ export function pinPublishedGeneration(checkoutPath: string): GenerationPin { return unpinned(null); } -function retireIfSuperseded(checkoutPath: string, generation: number): void { +function retireIfSuperseded(indexRoot: string, generation: number): void { try { - const published = readPublishedGeneration(checkoutPath); + const published = readPublishedGeneration(indexRoot); // Builds write only generations newer than the published one. if (published !== null && generation < published) { - removeGenerationUnlessRead(checkoutPath, generation); + removeGenerationUnlessRead(indexRoot, generation); } } catch { // The next build sweeps a generation left behind. diff --git a/apps/server/src/project/knowledge/codeIntelligenceWorker.ts b/apps/server/src/project/knowledge/codeIntelligenceWorker.ts index b28220a262e9b..40028bb96b6d0 100644 --- a/apps/server/src/project/knowledge/codeIntelligenceWorker.ts +++ b/apps/server/src/project/knowledge/codeIntelligenceWorker.ts @@ -33,11 +33,7 @@ import type { ProjectCodeUnresolvedReference, } from "@t3tools/contracts"; -import { - ignoreIndexDirectory, - readFileNoFollow, - validateIndexDirectory, -} from "./codeIndexLayout.ts"; +import { clearCheckoutSources, linkCheckoutSources } from "./codeIndexLayout.ts"; const CATALOG_REVISION = "@colbymchenry/codegraph@1.6.2"; const MAX_RESULTS = 200; @@ -66,6 +62,8 @@ interface CodeGraphModule { interface CheckoutListing { readonly checkoutPath: string; + // The SDK's project root: it holds the index and links back to the checkout's sources. + readonly indexRoot: string; readonly files: ReadonlyArray; readonly filesListed: number; readonly listingTruncated: boolean; @@ -219,16 +217,19 @@ interface IndexMeta { // Listed sources the build deliberately left out, such as build output: coverage gaps, not // newly added files a refresh would pick up. readonly skippedPaths: ReadonlyArray; + // Whether the build ranked an over-cap listing; older builds kept whatever sorted first. + readonly ranked: boolean; } -function readIndexMeta(checkoutPath: string, indexDirectory: string): IndexMeta { +function readIndexMeta(indexRoot: string, indexDirectory: string): IndexMeta { try { const raw: unknown = JSON.parse( - readFileNoFollow(NodePath.join(checkoutPath, indexDirectory, INDEX_META_FILE)), + NodeFS.readFileSync(NodePath.join(indexRoot, indexDirectory, INDEX_META_FILE), "utf8"), ); if (typeof raw !== "object" || raw === null) throw new Error("invalid"); - const { commit, generation, indexedAt, skippedPaths } = raw as Record; + const { commit, generation, indexedAt, skippedPaths, ranked } = raw as Record; return { + ranked: ranked === true, commit: typeof commit === "string" && /^[0-9a-f]{7,64}$/.test(commit) ? commit : null, generation: typeof generation === "number" && Number.isSafeInteger(generation) && generation >= 0 @@ -243,12 +244,12 @@ function readIndexMeta(checkoutPath: string, indexDirectory: string): IndexMeta : [], }; } catch { - return { commit: null, generation: 0, indexedAt: null, skippedPaths: [] }; + return { commit: null, generation: 0, indexedAt: null, skippedPaths: [], ranked: false }; } } -function writeIndexMeta(checkoutPath: string, indexDirectory: string, meta: IndexMeta): void { - const target = NodePath.join(checkoutPath, indexDirectory, INDEX_META_FILE); +function writeIndexMeta(indexRoot: string, indexDirectory: string, meta: IndexMeta): void { + const target = NodePath.join(indexRoot, indexDirectory, INDEX_META_FILE); const temporary = `${target}.${process.pid}.tmp`; try { // An exclusive create never follows a planted link, and the rename replaces the entry @@ -565,11 +566,60 @@ function preferDeclarations(tests: ReadonlyArray): CodeGraphNode[ ); } -function authorizedListing(listing: CheckoutListing): string[] { - return listing.files.slice(0, MAX_LISTED_FILES).flatMap((path) => { +type IsSupported = (path: string) => boolean; + +function supportedPaths(sdk: Omit): IsSupported { + const supported = new Set(sdk.getSupportedLanguages()); + return (path) => { + const language = sdk.detectLanguage(path); + return language !== "unknown" && supported.has(language); + }; +} + +/** + * Orders a listing for the file cap: what the analyzer can parse before what it cannot, and + * first-party paths before dot-directories such as vendored references. Otherwise whatever sorts + * first decides what is indexed, and a large `.repos` leaves a project's own code out entirely. + */ +export function rankListing(files: ReadonlyArray, isSupported: IsSupported): string[] { + const ranks: [string[], string[], string[], string[]] = [[], [], [], []]; + for (const path of files) { + const hidden = path.startsWith(".") || path.includes("/."); + ranks[(isSupported(path) ? 0 : 2) + (hidden ? 1 : 0)]!.push(path); + } + return ranks.flat(); +} + +function isListingTruncated(listing: CheckoutListing): boolean { + return listing.listingTruncated || listing.files.length > MAX_LISTED_FILES; +} + +interface AuthorizedListing { + readonly paths: ReadonlyArray; + // Listed paths that resolve outside the checkout. + readonly rejected: number; +} + +function authorizedListing(listing: CheckoutListing, isSupported: IsSupported): AuthorizedListing { + const candidates = + listing.files.length > MAX_LISTED_FILES + ? rankListing(listing.files, isSupported) + : listing.files; + const paths: string[] = []; + let rejected = 0; + for (const path of candidates) { + if (paths.length >= MAX_LISTED_FILES) break; + const absolute = NodePath.resolve(listing.checkoutPath, path); const authorized = authorizedSourcePath(listing.checkoutPath, path); - return authorized === null ? [] : [authorized]; - }); + if (authorized === null) { + // A tracked file deleted from the working tree is no coverage gap; an escaping link is. + if (NodeFS.existsSync(absolute)) rejected += 1; + // Git lists a submodule as a path; handing the SDK a directory would walk past the allowlist. + } else if (NodeFS.statSync(absolute, { throwIfNoEntry: false })?.isDirectory() !== true) { + paths.push(authorized); + } + } + return { paths, rejected }; } /** Indexed sources that still exist but now resolve outside the checkout, such as a new link. */ @@ -593,22 +643,23 @@ export async function buildIndexInWorker( onProgress: (progress: Pick) => void, ): Promise<{ readonly changed: boolean }> { const { CodeGraph, detectLanguage, getSupportedLanguages } = sdk(); - const listedPaths = authorizedListing(input); + const isSupported = supportedPaths({ detectLanguage, getSupportedLanguages }); + const { paths: listedPaths } = authorizedListing(input, isSupported); const commit = headCommit(input.checkoutPath); let from = input.from; if (from !== null) { - validateIndexDirectory(input.checkoutPath, from); useIndexDirectory(from); - if (!CodeGraph.isInitialized(input.checkoutPath)) from = null; + if ( + !CodeGraph.isInitialized(input.indexRoot) || + // Successors keep every file they inherit, so one that spent the cap unranked never recovers. + (isListingTruncated(input) && !readIndexMeta(input.indexRoot, from).ranked) + ) + from = null; } if (from !== null) { - const published = await CodeGraph.open(input.checkoutPath, { sync: false, readOnly: true }); + const published = await CodeGraph.open(input.indexRoot, { sync: false, readOnly: true }); try { - const meta = readIndexMeta(input.checkoutPath, from); - const { isSupported } = classifyIndexCoverage(input, listedPaths, published, { - languages: getSupportedLanguages(), - detectLanguage, - }); + const meta = readIndexMeta(input.indexRoot, from); if ( meta.commit === commit && !hasUnauthorizedFiles(input.checkoutPath, published) && @@ -626,27 +677,33 @@ export async function buildIndexInWorker( published.close(); } } - const target = NodePath.join(input.checkoutPath, input.to); + // Before the SDK opens anything: it reads `codegraph.json` from here, and a stale link or copy + // must not stand in for a source that moved. + const target = NodePath.join(input.indexRoot, input.to); // Exclusive: a leftover entry, link or not, fails the build instead of being written through. NodeFS.mkdirSync(target); if (from !== null) { for (const name of INDEX_DATABASE_FILES) { - const source = NodePath.join(input.checkoutPath, from, name); + const source = NodePath.join(input.indexRoot, from, name); if (NodeFS.existsSync(source)) { - NodeFS.copyFileSync(source, NodePath.join(target, name), NodeFS.constants.COPYFILE_EXCL); + NodeFS.copyFileSync( + source, + NodePath.join(target, name), + // FICLONE falls back to a normal copy where reflinks are unavailable. + NodeFS.constants.COPYFILE_EXCL | NodeFS.constants.COPYFILE_FICLONE, + ); } } } useIndexDirectory(input.to); let graph = from !== null - ? await CodeGraph.open(input.checkoutPath, { sync: false }) - : await CodeGraph.init(input.checkoutPath, { index: false }); - ignoreIndexDirectory(input.checkoutPath, input.to); + ? await CodeGraph.open(input.indexRoot, { sync: false }) + : await CodeGraph.init(input.indexRoot, { index: false }); try { if (hasUnauthorizedFiles(input.checkoutPath, graph)) { graph.close(); - graph = await CodeGraph.recreate(input.checkoutPath); + graph = await CodeGraph.recreate(input.indexRoot); } const reconcilePaths = new Set(listedPaths); for (const file of graph.getFiles()) { @@ -656,18 +713,25 @@ export async function buildIndexInWorker( else if (!NodeFS.existsSync(NodePath.resolve(input.checkoutPath, file.path))) reconcilePaths.add(file.path); } + // Files the graph already holds can sit under a top-level entry the listing dropped; without + // its link the SDK would treat them as deleted. + // From the reconcile set, not the listing: the cap can drop a top-level entry the graph still + // holds files under, and the SDK would read those as deleted. + clearCheckoutSources(input.indexRoot); + linkCheckoutSources(input.indexRoot, input.checkoutPath, [...reconcilePaths]); // `sync` reads an empty `paths` as "reconcile the whole checkout", a walk that follows // links out of it. It and the no-index `init`/`open`/`recreate` are the SDK's only calls // here, so this allowlist bounds everything the index reads. if (reconcilePaths.size > 0) await graph.sync({ onProgress, paths: [...reconcilePaths] }); const indexedPaths = new Set(graph.getFiles().map((file) => file.path)); - writeIndexMeta(input.checkoutPath, input.to, { + writeIndexMeta(input.indexRoot, input.to, { commit, generation: input.generation, indexedAt: Date.now(), // A completed sync may deliberately skip build output. Those paths are coverage gaps, // not newly added files that the next refresh can fix. skippedPaths: listedPaths.filter((path) => !indexedPaths.has(path)).toSorted(), + ranked: true, }); } finally { graph.close(); @@ -680,7 +744,9 @@ export async function inspectCodeInWorker( input: CodeIntelligenceWorkerInput, ): Promise { const { CodeGraph, detectLanguage, getSupportedLanguages } = sdk(); - const listedPaths = authorizedListing(input); + const isSupported = supportedPaths({ detectLanguage, getSupportedLanguages }); + const listing = authorizedListing(input, isSupported); + const listedPaths = listing.paths; const listedPathSet = new Set(listedPaths); const queryPath = input.query.operation === "dependencies" || @@ -709,26 +775,27 @@ export async function inspectCodeInWorker( } const commit = headCommit(input.checkoutPath); if (input.indexDirectory !== null) { - validateIndexDirectory(input.checkoutPath, input.indexDirectory); useIndexDirectory(input.indexDirectory); } - if (input.indexDirectory === null || !CodeGraph.isInitialized(input.checkoutPath)) { + if (input.indexDirectory === null || !CodeGraph.isInitialized(input.indexRoot)) { return unindexedResult(input, getSupportedLanguages(), commit); } - const graph = await CodeGraph.open(input.checkoutPath, { sync: false, readOnly: true }); + const graph = await CodeGraph.open(input.indexRoot, { sync: false, readOnly: true }); try { - const meta = readIndexMeta(input.checkoutPath, input.indexDirectory); + const meta = readIndexMeta(input.indexRoot, input.indexDirectory); if (input.query.operation === "freshness") { - return inspectFreshness(input, listedPaths, graph, meta, commit, { - detectLanguage, - getSupportedLanguages, - }); + return inspectFreshness( + input, + listing, + graph, + meta, + commit, + getSupportedLanguages(), + isSupported, + ); } const languageCatalog = getSupportedLanguages(); - const indexCoverage = classifyIndexCoverage(input, listedPaths, graph, { - languages: languageCatalog, - detectLanguage, - }); + const indexCoverage = classifyIndexCoverage(input, listing, graph, isSupported); const files = indexCoverage.files; const stats = graph.getStats(); const limit = Math.min( @@ -1095,7 +1162,7 @@ export async function inspectCodeInWorker( "Relations are static candidates. Import/name resolution can misresolve shadowed names, and SDK confidence is a heuristic score rather than certainty.", ); limitations.push(...indexCoverage.limitations); - let coverageIncomplete = indexCoverage.limitations.length > 0; + let coverageIncomplete = indexCoverage.incomplete; if (sourceDigests.some((source) => !source.verified)) { coverageIncomplete = true; limitations.push( @@ -1111,7 +1178,7 @@ export async function inspectCodeInWorker( input.checkoutPath, graph, listedPaths, - indexCoverage.isSupported, + isSupported, meta.skippedPaths, ); if (changed.length > 0) { @@ -1178,40 +1245,28 @@ export async function inspectCodeInWorker( /** * How much of the listed checkout the index covers, counted the same way for a query and for - * freshness. Any limitation it reports makes coverage partial. + * freshness. Every limitation but unsupported files makes coverage partial: docs, data and assets + * fill any repository, so they are a gap only when nothing else could be indexed. */ function classifyIndexCoverage( input: CheckoutListing, - listedPaths: ReadonlyArray, + { paths: listedPaths, rejected }: AuthorizedListing, graph: CodeGraphInstance, - catalog: { - readonly languages: ReadonlyArray; - readonly detectLanguage: (path: string) => Language; - }, + isSupported: IsSupported, ) { - const supported = new Set(catalog.languages); - const isSupported = (path: string) => { - const language = catalog.detectLanguage(path); - return language !== "unknown" && supported.has(language); - }; const listed = new Set(listedPaths); const files = graph.getFiles().filter((file) => listed.has(file.path)); const indexedPaths = new Set(files.map((file) => file.path)); - const filesUnsupported = listedPaths.filter((path) => !isSupported(path)).length; + const filesUnsupported = listedPaths.filter( + (path) => !isSupported(path) && !indexedPaths.has(path), + ).length; const filesFailed = files.filter((file) => (file.errors?.length ?? 0) > 0).length; const filesSkipped = - input.files.slice(0, MAX_LISTED_FILES).length - - listedPaths.length + - listedPaths.filter((path) => isSupported(path) && !indexedPaths.has(path)).length; + rejected + listedPaths.filter((path) => isSupported(path) && !indexedPaths.has(path)).length; const pendingReferences = graph.getPendingReferenceCount(); const buildState = graph.getIndexState(); const limitations: string[] = []; - if (input.listingTruncated || input.files.length > MAX_LISTED_FILES) - limitations.push("Workspace file listing is truncated."); - if (filesUnsupported > 0) - limitations.push( - `${filesUnsupported} listed files use languages outside the SDK runtime catalog.`, - ); + if (isListingTruncated(input)) limitations.push("Workspace file listing is truncated."); if (filesSkipped > 0) limitations.push( `${filesSkipped} listed source files were outside the checkout or were not indexed.`, @@ -1223,6 +1278,11 @@ function classifyIndexCoverage( ); if (buildState !== null && buildState !== "complete") limitations.push(`The last full index run reported '${buildState}'.`); + const incomplete = limitations.length > 0 || (filesUnsupported > 0 && files.length === 0); + if (filesUnsupported > 0) + limitations.push( + `${filesUnsupported} listed files use languages outside the SDK runtime catalog.`, + ); return { files, filesSkipped, @@ -1230,7 +1290,7 @@ function classifyIndexCoverage( filesFailed, pendingReferences, limitations, - isSupported, + incomplete, }; } @@ -1305,25 +1365,22 @@ function unindexedResult( function inspectFreshness( input: CodeIntelligenceWorkerInput, - listedPaths: ReadonlyArray, + listing: AuthorizedListing, graph: CodeGraphInstance, meta: IndexMeta, commit: string | null, - { detectLanguage, getSupportedLanguages }: Omit, + languages: ReadonlyArray, + isSupported: IsSupported, ): ProjectCodeQueryResult { - const languages = getSupportedLanguages(); - const indexCoverage = classifyIndexCoverage(input, listedPaths, graph, { - languages, - detectLanguage, - }); + const indexCoverage = classifyIndexCoverage(input, listing, graph, isSupported); const changed = changedSinceIndex( input.checkoutPath, graph, - listedPaths, - indexCoverage.isSupported, + listing.paths, + isSupported, meta.skippedPaths, ); - const incomplete = indexCoverage.limitations.length > 0; + const { incomplete } = indexCoverage; const limitations = [ ...(changed.length > 0 ? [ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index cf9710011e1a1..6f7f85065a096 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,6 +5,7 @@ import * as ProjectKnowledge from "./project/knowledge/ProjectKnowledge.ts"; import * as AssistedLearning from "./project/knowledge/AssistedLearning.ts"; import * as ConversationSummaries from "./project/conversationSummaries/ConversationSummaries.ts"; import * as ProjectLoom from "./project/loom/ProjectLoom.ts"; +import * as CodeIndexReactor from "./project/knowledge/CodeIndexReactor.ts"; import * as CodeIntelligence from "./project/knowledge/CodeIntelligence.ts"; import * as FileHistory from "./project/knowledge/FileHistory.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; @@ -687,6 +688,11 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( yield* service.start(); }), ).pipe(Layer.provide(DelegatedTaskQuestionReactor.layer), Layer.provide(ProjectionStoreV2.layer)), + CodeIndexReactor.layer.pipe( + Layer.provide(ProjectionStoreV2.layer), + Layer.provide(ProjectStore.layer), + Layer.provide(layerServerSettings), + ), ProviderUsageLimitsIngestion.layer, layerProviderInstallationRefresh, ReplayMarkers.layer, @@ -777,7 +783,14 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( ), ); -const layerCodeIntelligence = Layer.mergeAll(CodeIntelligence.layer, FileHistory.layer).pipe( +const layerCodeIntelligence = Layer.mergeAll( + CodeIntelligence.layer.pipe( + Layer.provide(GitVcsDriver.layerVcs), + // Layers memoize by reference, so this is the same settings instance the server uses. + Layer.provide(layerServerSettings), + ), + FileHistory.layer, +).pipe( Layer.provideMerge(Layer.mergeAll(ProjectStore.layer, ProjectionStoreV2.layer, layerWorkspace)), ); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index c27a872b1e58a..e13d44f8ce91f 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -185,7 +185,7 @@ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): nu } /** Only a complete subagent or side chat chain within this worktree shares ownership. */ -function worktreeOwner>( +export function worktreeOwner>( threads: ReadonlyArray, ) { if (threads.length === 1) return threads[0]; diff --git a/apps/web/src/components/codeExplorer/CodeExplorer.browser.test.tsx b/apps/web/src/components/codeExplorer/CodeExplorer.browser.test.tsx index f6835651d9ef6..56d184ab49ebe 100644 --- a/apps/web/src/components/codeExplorer/CodeExplorer.browser.test.tsx +++ b/apps/web/src/components/codeExplorer/CodeExplorer.browser.test.tsx @@ -406,19 +406,19 @@ it("warns when the index is stale", async () => { await expect.element(screen.getByText("Stale")).toBeVisible(); }); -it("says the index is unavailable instead of drawing an empty graph", async () => { +it("says the index is not ready instead of drawing an empty graph", async () => { const registry = setup({ empty: true, coverage: "unavailable", index: "unindexed" }); await using screen = await renderHost(registry); await expect .element( screen.getByText( - "The code index is unavailable for this checkout, so no calls can be shown.", + "The code index is not ready for this checkout, so no calls can be shown yet.", ), ) .toBeVisible(); await expect.element(screen.getByText(/No static callers or callees found/)).toBeVisible(); expect(graph(screen, "greet").query()).toBeNull(); - await expect.element(screen.getByText("Unavailable")).toBeVisible(); + await expect.element(screen.getByText("Not indexed")).toBeVisible(); }); it("keeps the legacy explorer usable without sending unsupported path or freshness queries", async () => { diff --git a/apps/web/src/components/codeExplorer/CodeExplorer.tsx b/apps/web/src/components/codeExplorer/CodeExplorer.tsx index 8222f6d9eefc9..fca18562f7d54 100644 --- a/apps/web/src/components/codeExplorer/CodeExplorer.tsx +++ b/apps/web/src/components/codeExplorer/CodeExplorer.tsx @@ -510,7 +510,7 @@ export default function CodeExplorer(

{coverage.state === "stale" ? "The index is stale for some of these files. Verify calls in source before relying on them." - : "The code index is unavailable for this checkout, so no calls can be shown."} + : "The code index is not ready for this checkout, so no calls can be shown yet."}

) : null} {farPending || farError !== null ? ( diff --git a/apps/web/src/components/files/CodeIndexBadge.tsx b/apps/web/src/components/files/CodeIndexBadge.tsx index 59e814220b57f..11e6783ccf2f0 100644 --- a/apps/web/src/components/files/CodeIndexBadge.tsx +++ b/apps/web/src/components/files/CodeIndexBadge.tsx @@ -10,8 +10,8 @@ import { serverEnvironment } from "~/state/server"; const VARIANT = { ok: "success", warn: "warning", muted: "secondary" } as const; /** - * Plain-text freshness of a checkout's code index. It reads the index without building one, - * and shows nothing until the server answers rather than guessing a state. + * Plain-text freshness of a checkout's code index. It shows nothing until the server answers + * rather than guessing a state. */ export function CodeIndexBadge(props: { readonly environmentId: EnvironmentId; diff --git a/apps/web/src/components/files/FileCodeRelationships.tsx b/apps/web/src/components/files/FileCodeRelationships.tsx index 4d12c72b55ef3..015429a71dde3 100644 --- a/apps/web/src/components/files/FileCodeRelationships.tsx +++ b/apps/web/src/components/files/FileCodeRelationships.tsx @@ -308,7 +308,7 @@ function SymbolsTab(props: { Refresh - {data.nodes.length === 0 ? ( + {data.coverage.state === "unavailable" ? null : data.nodes.length === 0 ? ( No indexed declarations in this file. Its language may be unsupported, or it declares nothing the index recognizes. diff --git a/apps/web/src/components/files/codeRelationshipRows.tsx b/apps/web/src/components/files/codeRelationshipRows.tsx index ff276bfd038d7..ab1a8b5689edf 100644 --- a/apps/web/src/components/files/codeRelationshipRows.tsx +++ b/apps/web/src/components/files/codeRelationshipRows.tsx @@ -28,7 +28,7 @@ function coverageSummary(coverage: ProjectCodeCoverage): string { case "stale": return "Stale ยท some returned files changed since indexing. Verify in source."; case "unavailable": - return "The code index is unavailable for this checkout."; + return "The code index is not ready for this checkout."; } } diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 36c289b8335bc..679916f574d0e 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -24,6 +24,7 @@ export function LabsSettingsPanel() { {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } + @@ -164,6 +165,27 @@ function LinkedEnvironmentsRow() { ); } +function AutoCodeIndexRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + updateSettings({ autoCodeIndexEnabled: Boolean(checked) })} + aria-label="Automatic code indexing" + /> + } + /> + ); +} + function SimlockHostsRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index bfb86dd7250d0..455f5807b1ac9 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -484,6 +484,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment", searchTerms: ["beta link another machine vps peer agents work there delegate"], }, + { + id: "auto-code-index", + title: "Automatic code indexing", + to: "/settings/labs", + scope: "environment", + searchTerms: ["beta code graph index worktree automatic settle cleanup background"], + }, { id: "simlock-hosts", title: "Simlock device hosts (alpha)", diff --git a/docs/user/code-navigation.md b/docs/user/code-navigation.md index 828f979a79149..28d395405b69d 100644 --- a/docs/user/code-navigation.md +++ b/docs/user/code-navigation.md @@ -1,11 +1,23 @@ # Code navigation Open a file from a thread's file browser and expand **Code relationships** below the preview to -see how it connects to the rest of the project. Nothing is analyzed until you expand it. The -first expansion on a checkout starts indexing it in the background, and until that finishes the -section says the checkout is not indexed yet. Results come from the thread's own checkout, its +see how it connects to the rest of the project. Results come from the thread's own checkout, its worktree when it has one. +A project in a git repository starts indexing in the background when you add it, or the first time +you open or run a thread in it. A worktree has its own index, built and kept current while an agent +works in it, or the first time something asks it a code question, such as expanding this section or +an agent using the code graph. An idle worktree is not refreshed until an agent is active in it +again. A worktree's index is removed when its thread settles, unless other unsettled threads share +the worktree. Until an index is ready, the section says the checkout is not indexed yet. Folders +outside a git repository index only on a code question. + +Indexes are stored in T3 Code's data directory, not in your repository. Folders an earlier version +left in a checkout are removed the next time that checkout is indexed. + +Turn automatic indexing off in **Settings โ†’ Labs โ†’ Automatic code indexing**. Then a checkout only +indexes when something asks it a code question. + - **Symbols** lists the file's declarations. Select one to see its callers, callees, references, impact (files that depend on it), and **Tests**: test files that reach it within a few calls. - **Decisions** shows project knowledge whose entities name this file's path or one of its @@ -22,12 +34,16 @@ values or dynamic dispatch and can mistake one same-named symbol for another. Ea that nothing calls the code or that it is untested. The badge next to **Code relationships**, and **Code index** in a thread's details, shows whether -the index matches the checkout: **Ready**, **Partial** (some files are not indexed), **Stale** -(files changed since indexing; **Stale @abc1234** when the checkout has moved past the commit it -was indexed at), **Indexing** (building the first index), or **Unavailable** (not indexed yet). -Checking the badge never starts indexing. Code queries and finished turns refresh a stale index -in the background, and results keep coming from the previous index until the new one is ready. -If a refresh fails, the badge says so and the previous index keeps answering. +the index matches the checkout: **Ready**, **Partial** (source files are missing from the index), +**Stale** (files changed since indexing; **Stale @abc1234** when the checkout has moved past the +commit it was indexed at), **Indexing** (building the first index), **Not indexed**, or **Index +failed**. Files with no code to analyze, such as docs and images, don't count against **Ready**. +Code queries refresh a stale index in the background, and so do agent turns while automatic +indexing is on. Results keep coming from the previous index until the new one is ready. If a +refresh fails, the badge says so and the previous index keeps answering. + +An index covers at most 10,000 files. A larger repository shows **Partial**, with your own source +indexed ahead of files in hidden folders. On mobile, the file screen has a compact **Code relationships** section with symbols, callers, and impact. diff --git a/packages/client-runtime/src/projectCode/index.test.ts b/packages/client-runtime/src/projectCode/index.test.ts index 983df7c4749ad..b53321d200d89 100644 --- a/packages/client-runtime/src/projectCode/index.test.ts +++ b/packages/client-runtime/src/projectCode/index.test.ts @@ -261,10 +261,18 @@ describe("project code atoms", () => { expect(projectCodeIndexBadge({ ...index, headCommit: "1234567890abcdef" }).label).toBe( "Stale @abcdef0", ); - expect(projectCodeIndexBadge({ ...index, state: "unindexed" }).label).toBe("Unavailable"); + expect(projectCodeIndexBadge({ ...index, state: "unindexed" }).label).toBe("Not indexed"); expect(projectCodeIndexBadge({ ...index, state: "unindexed", refresh: "building" }).label).toBe( "Indexing", ); + // No earlier generation exists to answer, so a failed first build must not claim one does. + const firstIndexFailed = projectCodeIndexBadge({ + ...index, + state: "unindexed", + refresh: "failed", + }); + expect(firstIndexFailed).toMatchObject({ label: "Index failed", tone: "warn" }); + expect(firstIndexFailed.detail).not.toContain("still answering"); // A failed refresh leaves the older generation answering, and the badge says so. expect(projectCodeIndexBadge({ ...index, refresh: "failed" })).toMatchObject({ label: "Stale", @@ -273,4 +281,72 @@ describe("project code atoms", () => { // A server from before index tracking cannot claim the index is ready. expect(projectCodeIndexBadge(undefined).label).toBe("Unavailable"); }); + + it.effect("re-reads a query only for a real publication or a failure", () => + Effect.scoped( + Effect.gen(function* () { + const { atoms, registry, requests, events, subscribed } = yield* setup(); + registry.set( + sessions(target.environmentId), + AsyncResult.success(session([AuthFilesystemReadScope])), + ); + const checkout = { projectId: input.projectId, threadId: ThreadId.make("mounted") }; + const query = atoms.query({ + environmentId: target.environmentId, + input: { ...input, ...checkout }, + }); + const freshness = atoms.freshness({ + environmentId: target.environmentId, + input: { ...checkout, query: { operation: "freshness" } }, + }); + const queryRuns = () => + requests.filter( + (request) => + request.threadId === checkout.threadId && request.query.operation === "symbols", + ).length; + const freshnessRuns = () => + requests.filter((request) => request.query.operation === "freshness").length; + let onQuery = () => {}; + let onFreshness = () => {}; + const stopQuery = registry.subscribe(query, () => onQuery()); + const stopFreshness = registry.subscribe(freshness, () => onFreshness()); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + stopQuery(); + stopFreshness(); + }), + ); + yield* AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); + yield* AtomRegistry.getResult(registry, freshness, { suspendOnWaiting: true }); + yield* Deferred.await(subscribed); + expect([queryRuns(), freshnessRuns()]).toEqual([1, 1]); + + // One event at a time: a stream atom keeps only the last value of a chunk. + const publish = Effect.fn("TestProjectCode.publish")(function* ( + event: ProjectCodeIndexEvent, + expected: readonly [query: number, freshness: number], + ) { + const reached = yield* Deferred.make(); + onQuery = onFreshness = () => { + if (queryRuns() >= expected[0] && freshnessRuns() >= expected[1]) { + Deferred.doneUnsafe(reached, Exit.void); + } + }; + yield* PubSub.publish(events, { target: checkout, event }); + yield* Deferred.await(reached); + expect([queryRuns(), freshnessRuns()]).toEqual(expected); + }); + // Freshness re-reads on every event. The query only re-runs for a real publication, + // because a query starts the build a settle removal just stopped. + const reRead: ProjectCodeIndexEvent = { outcome: "published", generation: null }; + yield* publish(reRead, [1, 2]); + yield* publish({ outcome: "published", generation: 1 }, [2, 3]); + // A settle removes the index, the next turn announces its rebuild with the same signal, + // and generation numbers restart once the files are gone. + yield* publish(reRead, [2, 4]); + yield* publish(reRead, [2, 5]); + yield* publish({ outcome: "published", generation: 1 }, [3, 6]); + }), + ), + ); }); diff --git a/packages/client-runtime/src/projectCode/index.ts b/packages/client-runtime/src/projectCode/index.ts index 63bff84382e6c..3bc2629427936 100644 --- a/packages/client-runtime/src/projectCode/index.ts +++ b/packages/client-runtime/src/projectCode/index.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; -import type { Atom } from "effect/reactivity"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { @@ -36,6 +36,8 @@ const requireProjectCode = Effect.fn("ProjectCode.requireCapability")(function* export function createProjectCodeAtoms( runtime: Atom.AtomRuntime, ) { + // An atom drops a value equal to its last one, and the same signal can arrive twice running. + let received = 0; const indexEvents = createEnvironmentSubscriptionAtomFamily(runtime, { label: "project-code:index-events", idleTtlMs: 0, @@ -48,7 +50,7 @@ export function createProjectCodeAtoms( : Stream.never, ), ), - ), + ).pipe(Stream.map((event) => ({ ...event, received: ++received }))), }); const refreshTrigger = ({ environmentId, @@ -64,12 +66,29 @@ export function createProjectCodeAtoms( ...(input.threadId === undefined ? {} : { threadId: input.threadId }), }, }); + // Queries start builds, so only a real publication or a failure re-runs one; the re-read signal + // a settle removal or a stopped build sends would otherwise start the build it just ended. + // Per registry, as `refreshOnSignalWhileRead` keeps its own bookkeeping. + const queryRefreshTrigger = (target: { + readonly environmentId: EnvironmentId; + readonly input: ProjectCodeQueryInput; + }) => { + const signal = refreshTrigger(target); + const lastSignificant = new WeakMap(); + return Atom.transform(signal, (get) => { + const event = AsyncResult.getOrElse(get(signal), () => null); + if (event !== null && (event.outcome !== "published" || event.generation !== null)) { + lastSignificant.set(get.registry, event.received); + } + return lastSignificant.get(get.registry) ?? 0; + }); + }; return { query: createEnvironmentGuardedRpcQueryAtomFamily(runtime, { label: "project-code:query", tag: WS_METHODS.projectCodeQuery, staleTimeMs: 0, - refreshTrigger, + refreshTrigger: queryRefreshTrigger, execute: Effect.fn("ProjectCode.query")(function* (input: ProjectCodeQueryInput) { yield* requireProjectCode(); return yield* requestGuarded(WS_METHODS.projectCodeQuery, input); @@ -110,6 +129,19 @@ const REFRESH_DETAIL = { failed: "The last refresh failed, so this index is still answering.", } as const; +const FIRST_INDEX_BADGE: Record<"building" | "failed", ProjectCodeIndexBadge> = { + building: { + label: "Indexing", + tone: "muted", + detail: "Building the first index for this checkout.", + }, + failed: { + label: "Index failed", + tone: "warn", + detail: "Building the first index for this checkout failed. The next code query tries again.", + }, +}; + /** * The plain-text badge every client shows for an index. Stale names the commit the index was * built at when HEAD has moved since, otherwise how many files changed under it. @@ -119,13 +151,7 @@ export function projectCodeIndexBadge( ): ProjectCodeIndexBadge { const badge = describeIndexState(index); if (index?.refresh === undefined) return badge; - if (index.state === "unindexed" && index.refresh === "building") { - return { - label: "Indexing", - tone: "muted", - detail: "Building the first index for this checkout.", - }; - } + if (index.state === "unindexed") return FIRST_INDEX_BADGE[index.refresh]; return { ...badge, detail: `${badge.detail} ${REFRESH_DETAIL[index.refresh]}` }; } @@ -161,7 +187,7 @@ function describeIndexState(index: ProjectCodeIndexInfo | undefined): ProjectCod return { label: moved ? `Stale @${indexedAt}` : "Stale", tone: "warn", - detail: `${files} The next code query or turn refreshes the index.`, + detail: `${files} Code queries refresh the index, and so do agent turns while automatic indexing is on.`, }; } case "indexing": @@ -172,7 +198,7 @@ function describeIndexState(index: ProjectCodeIndexInfo | undefined): ProjectCod }; case "unindexed": return { - label: "Unavailable", + label: "Not indexed", tone: "muted", detail: "This checkout has not been indexed yet. The first code query indexes it.", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 6a0b90ed24098..a5c039ffc405b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -55,6 +55,15 @@ describe("skills library setting", () => { }); }); +describe("automatic code indexing setting", () => { + it("defaults on and patches like any server setting", () => { + expect(decodeServerSettings({}).autoCodeIndexEnabled).toBe(true); + expect(decodeServerSettingsPatch({ autoCodeIndexEnabled: false })).toEqual({ + autoCodeIndexEnabled: false, + }); + }); +}); + describe("storage cleanup settings", () => { it("keeps cleanup disabled for existing installations", () => { expect(decodeServerSettings({}).worktreeCleanup).toBeNull(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7d2b635a6acbc..cc72bad09537e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1346,6 +1346,11 @@ export const ServerSettings = Schema.Struct({ * and tasks on another machine; tasks already running there finish. */ linkedEnvironmentsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** + * Index a project when it is added and a worktree while an agent works in it. Off, a checkout + * only indexes when something asks it a code question. + */ + autoCodeIndexEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarAutoSettleKeepPullRequests: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1751,6 +1756,7 @@ export const ServerSettingsPatch = Schema.Struct({ idleCompaction: Schema.optionalKey(IdleCompactionMode), sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), + autoCodeIndexEnabled: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozePresets: Schema.optionalKey(SnoozePresetSettings), diff --git a/scripts/lib/code-intelligence-smoke.ts b/scripts/lib/code-intelligence-smoke.ts index 681055f7ad168..549872bfaaaea 100644 --- a/scripts/lib/code-intelligence-smoke.ts +++ b/scripts/lib/code-intelligence-smoke.ts @@ -68,17 +68,21 @@ export const smokeCodeIntelligenceProcess = Effect.fn("smokeCodeIntelligenceProc const callers = yield* Effect.acquireUseRelease( Effect.promise(async () => { const checkout = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-code-smoke-")); + const indexRoot = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-code-smoke-index-"), + ); await NodeFSP.mkdir(NodePath.join(checkout, "src")); await NodeFSP.writeFile( NodePath.join(checkout, "src/a.ts"), "export function greet() { return 1; }\nexport function caller() { return greet(); }\n", ); - return checkout; + return { checkout, indexRoot }; }), - (checkout) => + ({ checkout, indexRoot }) => Effect.gen(function* () { const listing = { checkoutPath: checkout, + indexRoot, files: ["src/a.ts"], filesListed: 1, listingTruncated: false, @@ -99,7 +103,11 @@ export const smokeCodeIntelligenceProcess = Effect.fn("smokeCodeIntelligenceProc if (!("result" in answer)) return yield* fail("the query returned no result"); return answer.result.nodes.map((node) => node.name); }), - (checkout) => Effect.promise(() => NodeFSP.rm(checkout, { recursive: true, force: true })), + ({ checkout, indexRoot }) => + Effect.promise(async () => { + await NodeFSP.rm(checkout, { recursive: true, force: true }); + await NodeFSP.rm(indexRoot, { recursive: true, force: true }); + }), ); if (!callers.includes("caller")) { return yield* fail(`expected 'caller' among [${callers.join(", ")}]`); From 561c7d59e7e4adec74d1fec23b6fb694e66df354 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:42:40 -0800 Subject: [PATCH 02/19] fix(server): harden code index cleanup and refresh Preserve build lock inodes during cleanup and reject linked legacy lock paths. Rebuild when checkout indexing configuration changes, and follow settled ancestors when releasing subagent worktree indexes. Add regression coverage for cleanup locking, config changes, and parent-settlement sweeps; drain background refreshes in generation assertions and document retained legacy lock files. --- .../knowledge/CodeIndexReactor.test.ts | 77 ++++++++++++--- .../src/project/knowledge/CodeIndexReactor.ts | 27 +++++- .../knowledge/CodeIntelligence.test.ts | 97 ++++++++++++------- .../src/project/knowledge/CodeIntelligence.ts | 10 +- .../src/project/knowledge/codeIndexLayout.ts | 2 +- .../project/knowledge/codeIndexLocks.test.ts | 72 ++++++++++++++ .../src/project/knowledge/codeIndexLocks.ts | 52 ++++++---- .../knowledge/codeIntelligenceWorker.ts | 66 ++++++++++--- apps/server/src/storageCleanup.ts | 2 +- docs/user/code-navigation.md | 5 +- 10 files changed, 324 insertions(+), 86 deletions(-) create mode 100644 apps/server/src/project/knowledge/codeIndexLocks.test.ts diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts index 5415b905ae9f2..1a7b3bd18af36 100644 --- a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts +++ b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts @@ -145,6 +145,42 @@ describe("worktreeIndexReleasable", () => { expected, ); }); + + it.each([ + { name: "settled parent", parent: {}, child: {}, expected: true }, + { name: "active parent", parent: { settledAt: null }, child: {}, expected: false }, + { name: "running parent", parent: { status: "running" }, child: {}, expected: false }, + { name: "pinned child", parent: {}, child: { pinnedAt: now }, expected: false }, + { + name: "explicitly active child", + parent: {}, + child: { settledOverride: "active" }, + expected: false, + }, + { + name: "other project", + parent: { projectId: ProjectId.make("other") }, + child: {}, + expected: false, + }, + ] satisfies ReadonlyArray<{ + name: string; + parent: Partial; + child: Partial; + expected: boolean; + }>)("handles a subagent in its own worktree with a $name", ({ parent, child, expected }) => { + const owner = settled({ worktreePath: null, ...parent }); + const agent = subagent(child); + assert.equal( + CodeIndexReactor.worktreeIndexReleasable( + [agent], + NOW_MS, + DEFAULT_SERVER_SETTINGS, + new Map([owner, agent].map((thread) => [thread.id, thread])), + ), + expected, + ); + }); }); function runEvent(runId: RunId, status: OrchestrationV2Run["status"]): ApplicationStoredEvent { @@ -437,18 +473,35 @@ describe("CodeIndexReactor", () => { }), ); - it.effect("sweeps the index of a worktree whose thread settled before this feature", () => - Effect.gen(function* () { - const worktreePath = "/worktrees/settled"; - const thread = shell({ - worktreePath, - settledOverride: "settled", - settledAt: at(-60_000), - }); - // Activation sweeps, so the removal arrives without any event. - const harness = yield* reactorHarness({ threads: [thread], indexed: [worktreePath] }); - assert.equal(yield* harness.next, `remove:${worktreePath}`); - }), + it.effect.each(["thread", "subagent's parent"] as const)( + "sweeps the index of a worktree whose %s settled before this feature", + (settledOwner) => + Effect.gen(function* () { + const worktreePath = "/worktrees/settled"; + const thread = shell({ + worktreePath, + settledOverride: "settled", + settledAt: at(-60_000), + }); + const threads = + settledOwner === "thread" + ? [thread] + : [ + { ...thread, worktreePath: null }, + shell({ + id: ThreadId.make("thread-subagent"), + worktreePath, + lineage: { + rootThreadId: thread.id, + parentThreadId: thread.id, + relationshipToParent: "subagent", + }, + }), + ]; + // Activation sweeps, so the removal arrives without any event. + const harness = yield* reactorHarness({ threads, indexed: [worktreePath] }); + assert.equal(yield* harness.next, `remove:${worktreePath}`); + }), ); it.effect("skips a settled worktree that has no index", () => diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.ts b/apps/server/src/project/knowledge/CodeIndexReactor.ts index 209e2bff62c6d..e283678b2cb5f 100644 --- a/apps/server/src/project/knowledge/CodeIndexReactor.ts +++ b/apps/server/src/project/knowledge/CodeIndexReactor.ts @@ -17,7 +17,11 @@ import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; import * as OrchestrationEventStore from "../../persistence/OrchestrationEventStore.ts"; import { forkParked } from "../../serverActivation.ts"; import * as ServerSettings from "../../serverSettings.ts"; -import { storageCleanupThreadIdle, worktreeOwner } from "../../storageCleanup.ts"; +import { + storageCleanupThreadIdle, + worktreeOwner, + worktreeSettlementChain, +} from "../../storageCleanup.ts"; import * as CodeIntelligence from "./CodeIntelligence.ts"; const SWEEP_INTERVAL = "1 hour"; @@ -30,12 +34,17 @@ export function worktreeIndexReleasable( group: ReadonlyArray, now: number, settings: Parameters[2], + threadsById: ReadonlyMap = new Map( + group.map((thread) => [thread.id, thread]), + ), ): boolean { if (group.length === 0) return false; const owner = worktreeOwner(group); // A subagent or side-chat chain follows its owner; unrelated threads must all be settled. const settled = - owner !== undefined ? owner.settledAt !== null : group.every((t) => t.settledAt !== null); + owner !== undefined + ? worktreeSettlementChain(owner, threadsById, now, settings).length > 0 + : group.every((t) => t.settledAt !== null); return settled && group.every((t) => storageCleanupThreadIdle(t, now, settings)); } @@ -68,9 +77,21 @@ const make = Effect.gen(function* () { entries.filter((entry) => path.resolve(entry.worktreePath) === target), (entry) => projections.getThreadShell(entry.threadId), )).filter((thread) => thread !== null); + const threadsById = new Map(group.map((thread) => [thread.id, thread])); + for (const thread of group) { + let current = thread; + while (current.settledAt === null && current.lineage.relationshipToParent === "subagent") { + const parentId = current.lineage.parentThreadId; + if (parentId === null || threadsById.has(parentId)) break; + const parent = yield* projections.getThreadShell(parentId); + if (parent === null) break; + threadsById.set(parent.id, parent); + current = parent; + } + } const settings = yield* settingsService.getSettings; const now = yield* Clock.currentTimeMillis; - if (!worktreeIndexReleasable(group, now, settings)) return; + if (!worktreeIndexReleasable(group, now, settings, threadsById)) return; // A worktree registered as its own project keeps its index. const projects = yield* projectStore.listShells(); if (projects.some((project) => path.resolve(project.workspaceRoot) === target)) return; diff --git a/apps/server/src/project/knowledge/CodeIntelligence.test.ts b/apps/server/src/project/knowledge/CodeIntelligence.test.ts index 34844cf6c8837..aa5b74e3a6cd1 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.test.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.test.ts @@ -140,6 +140,14 @@ async function generationDirectories(indexRoot: string) { .toSorted(); } +function assertClearedIndex(indexRoot: string): void { + assert.deepEqual(NodeFS.readdirSync(indexRoot), [".codegraph-t3"]); + assert.deepEqual(NodeFS.readdirSync(NodePath.join(indexRoot, ".codegraph-t3")).toSorted(), [ + "build.lock", + "building.lock", + ]); +} + /** * A server config whose data directory is outside the fixture, and the index root it implies for * a checkout. @@ -1360,7 +1368,7 @@ it.effect("removes a checkout's index while a build is in flight", () => yield* removeCheckoutIndex(fixture.root); assert.isFalse(isProcessAlive(pid), "the build process is gone once removal returns"); yield* Fiber.await(building); - assert.isFalse(NodeFS.existsSync(indexRoot(fixture.root))); + assertClearedIndex(indexRoot(fixture.root)); assert.deepEqual( yield* Effect.promise(() => generationDirectories(indexRoot(fixture.root))), [], @@ -1393,7 +1401,7 @@ it.effect("keeps a generation a reader pins while removing the rest of the index yield* Effect.sync(pin.release); yield* removeCheckoutIndex(fixture.root); assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), []); - assert.isFalse(NodeFS.existsSync(root)); + assertClearedIndex(root); }), ), ); @@ -1607,6 +1615,8 @@ it.effect("keeps the index out of the checkout's git status", () => const before = (yield* Effect.promise(() => NodeFSP.readdir(fixture.root))).toSorted(); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); assert.ok((yield* run({ operation: "symbols", name: "greet" })).nodes.length > 0); + // Drain the query's background refresh before holding a reader and editing its source. + yield* refresh(); // Nothing the index owns is in the checkout: no new entries, and git sees nothing. assert.deepEqual( @@ -1693,11 +1703,12 @@ it.effect("removes the index folders an older version left in the checkout", () }); const { refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); - // Only T3's folders go: every other top-level entry, file and link alike, is still there. + // Lock inodes stay stable for older servers; every source entry is still there. assert.deepEqual( (yield* Effect.promise(() => NodeFSP.readdir(fixture.root))).toSorted(), - before, + [...before, ".codegraph-t3"].toSorted(), ); + assert.deepEqual(NodeFS.readdirSync(control).toSorted(), ["build.lock", "building.lock"]); assert.isTrue(NodeFS.lstatSync(NodePath.join(fixture.root, "alias")).isSymbolicLink()); assert.equal( yield* Effect.promise(() => @@ -1727,7 +1738,7 @@ it.effect("finds and removes an older version's folders in a checkout with no in // Settling a thread takes this path, so a worktree nothing rebuilds is still cleaned up. yield* removeCheckoutIndex(fixture.root); - assert.isFalse(NodeFS.existsSync(control)); + assert.deepEqual(NodeFS.readdirSync(control).toSorted(), ["build.lock", "building.lock"]); assert.isFalse(yield* hasCheckoutIndex(fixture.root)); assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.root, "src/defs.ts"))); }), @@ -1790,7 +1801,7 @@ it.effect("leaves legacy folders for later while another server builds", () => assert.equal(yield* other.send("release"), "released"); yield* editDefinition(fixture.root, "second"); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); - assert.isFalse(NodeFS.existsSync(control)); + assert.deepEqual(NodeFS.readdirSync(control).toSorted(), ["build.lock", "building.lock"]); }), ), ); @@ -1810,7 +1821,7 @@ it.effect("removes an index without touching the checkout's sources", () => assert.isTrue(NodeFS.lstatSync(NodePath.join(root, "top.ts")).isSymbolicLink()); yield* removeCheckoutIndex(fixture.root); - assert.isFalse(NodeFS.existsSync(root)); + assertClearedIndex(root); // Every source is still there, byte for byte. assert.equal( yield* Effect.promise(() => @@ -1839,7 +1850,7 @@ it.effect("removes an orphaned index without touching a source checkout", () => ); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); yield* removeOrphanedIndexes(() => false); - assert.isFalse(NodeFS.existsSync(indexRoot(fixture.root))); + assertClearedIndex(indexRoot(fixture.root)); assert.equal( yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(fixture.root, "src/defs.ts"), "utf8"), @@ -1877,15 +1888,14 @@ it.effect("sweeps indexes for checkouts that are gone or no longer known", () => assert.isTrue(NodeFS.existsSync(knownRoot)); assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.root, "src/defs.ts"))); // A checkout `isKnown` rejects is removed, leaving its files intact. - assert.isFalse(NodeFS.existsSync(goneRoot)); + assertClearedIndex(goneRoot); assert.equal( yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(fixture.worktree, "src/work.ts"), "utf8"), ), "export function workOnly() { return 2; }\n", ); - // A root with no record is removed. - assert.isFalse(NodeFS.existsSync(orphanRoot)); + assertClearedIndex(orphanRoot); }), ), ); @@ -1903,7 +1913,7 @@ it.effect("removes the index of a checkout that was deleted", () => yield* Effect.promise(() => NodeFSP.rm(fixture.worktree, { recursive: true, force: true })); // The checkout is still known by path, but it no longer exists. yield* removeOrphanedIndexes(() => true); - assert.isFalse(NodeFS.existsSync(root)); + assertClearedIndex(root); }), ), ); @@ -1971,26 +1981,47 @@ it.effect("keeps files the graph holds under a top-level entry a truncated listi ), ); -it.effect("honours a checkout's exclude patterns when the config was not listed", () => - withFixture((fixture) => - Effect.gen(function* () { - yield* Effect.promise(() => - NodeFSP.writeFile( - NodePath.join(fixture.root, "codegraph.json"), - JSON.stringify({ exclude: ["src/use.ts"] }), - ), - ); - // The listing never mentions the config file; only the link T3 plants reaches it. - const { run, refresh } = yield* checkoutService(fixture.root, () => [ - "src/defs.ts", - "src/use.ts", - ]); - yield* refresh(); - const status = yield* run({ operation: "status" }); - assert.equal(status.stats?.files, 1); - assert.equal((yield* run({ operation: "symbols", name: "caller" })).nodes.length, 0); - }), - ), +it.effect.each(["codegraph.json", ".gitignore"] as const)( + "honours changed exclude patterns in an unlisted %s", + (configName) => + withFixture((fixture) => + Effect.gen(function* () { + const configPath = NodePath.join(fixture.root, configName); + const excluded = + configName === "codegraph.json" + ? JSON.stringify({ exclude: ["src/use.ts"] }) + : "src/use.ts\n"; + yield* Effect.promise(() => NodeFSP.writeFile(configPath, excluded)); + // The listing never mentions the config file; only the link T3 plants reaches it. + const { run, refresh } = yield* checkoutService(fixture.root, () => [ + "src/defs.ts", + "src/use.ts", + ]); + yield* refresh(); + const status = yield* run({ operation: "status" }); + assert.equal(status.stats?.files, 1); + assert.equal((yield* run({ operation: "symbols", name: "caller" })).nodes.length, 0); + yield* refresh(); + + yield* Effect.promise(() => + NodeFSP.writeFile(configPath, configName === "codegraph.json" ? "{}" : ""), + ); + assert.equal((yield* run({ operation: "freshness" })).index?.state, "stale"); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); + assert.equal((yield* run({ operation: "status" })).stats?.files, 2); + assert.equal((yield* run({ operation: "symbols", name: "caller" })).nodes.length, 1); + yield* refresh(); + + yield* Effect.promise(() => NodeFSP.writeFile(configPath, excluded)); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 3 }); + assert.equal((yield* run({ operation: "status" })).stats?.files, 1); + assert.equal((yield* run({ operation: "symbols", name: "caller" })).nodes.length, 0); + yield* refresh(); + yield* Effect.promise(() => NodeFSP.unlink(configPath)); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 4 }); + assert.equal((yield* run({ operation: "symbols", name: "caller" })).nodes.length, 1); + }), + ), ); it.effect("answers from the published generation while its successor builds", () => @@ -2436,7 +2467,7 @@ it.effect("removes a checkout's index and asks a mounted reader to read again", assert.deepEqual(yield* events.pipe(Stream.take(1), Stream.runCollect), [ { outcome: "published", generation: null }, ]); - assert.isFalse(NodeFS.existsSync(root)); + assertClearedIndex(root); assert.deepEqual(yield* Effect.promise(() => generationDirectories(root)), []); const freshness = (yield* run({ operation: "freshness" })).index; assert.equal(freshness?.state, "unindexed"); diff --git a/apps/server/src/project/knowledge/CodeIntelligence.ts b/apps/server/src/project/knowledge/CodeIntelligence.ts index 34984370e9b0d..9e3379686547b 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.ts @@ -906,7 +906,7 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig if (state?.fiber != null) yield* Fiber.interrupt(state.fiber); checkouts.delete(path); const hadFiles = yield* Effect.sync( - () => NodeFS.existsSync(indexRootOf(path)) || hasLegacyIndexFiles(path), + () => listGenerations(indexRootOf(path)).length > 0 || hasLegacyIndexFiles(path), ); const removal = yield* Effect.exit( Effect.sync(() => { @@ -927,8 +927,14 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig const hasCheckoutIndex: CodeIntelligence["Service"]["hasCheckoutIndex"] = Effect.fn( "CodeIntelligence.hasCheckoutIndex", )(function* (checkoutPath) { + const path = yield* workspacePaths + .normalizeWorkspaceRoot(checkoutPath) + .pipe(Effect.orElseSucceed(() => NodePath.resolve(checkoutPath))); return yield* Effect.sync( - () => NodeFS.existsSync(indexRootOf(checkoutPath)) || hasLegacyIndexFiles(checkoutPath), + () => + listGenerations(indexRootOf(path)).length > 0 || + isBuildRunning(indexRootOf(path)) || + hasLegacyIndexFiles(path), ); }); diff --git a/apps/server/src/project/knowledge/codeIndexLayout.ts b/apps/server/src/project/knowledge/codeIndexLayout.ts index d461b23b7b379..0890826be1f04 100644 --- a/apps/server/src/project/knowledge/codeIndexLayout.ts +++ b/apps/server/src/project/knowledge/codeIndexLayout.ts @@ -14,7 +14,7 @@ const GENERATION_NAME = /^\.codegraph-t3-g([1-9][0-9]{0,8})$/; export const PUBLISHED_FILE = "published"; const INDEXED_CHECKOUT_FILE = "checkout"; // Changes what the SDK indexes, and a truncated listing can drop them. -const CHECKOUT_CONFIG_ENTRIES = ["codegraph.json", ".gitignore"]; +export const CHECKOUT_CONFIG_ENTRIES = ["codegraph.json", ".gitignore"]; export const generationDirectory = (generation: number) => `${GENERATION_PREFIX}${generation}`; diff --git a/apps/server/src/project/knowledge/codeIndexLocks.test.ts b/apps/server/src/project/knowledge/codeIndexLocks.test.ts new file mode 100644 index 0000000000000..ee4562ad384ea --- /dev/null +++ b/apps/server/src/project/knowledge/codeIndexLocks.test.ts @@ -0,0 +1,72 @@ +// @effect-diagnostics nodeBuiltinImport:off - isolated filesystem and SQLite fixtures exercise the cross-process lock protocol. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { expect, it } from "vite-plus/test"; + +import { + hasLegacyIndexFiles, + removeIndexRoot, + removeLegacyIndexFromCheckout, + tryLockBuild, +} from "./codeIndexLocks.ts"; +import { publishGeneration, readPublishedGeneration } from "./codeIndexLayout.ts"; + +it.each([ + ["external index", removeIndexRoot], + ["legacy index", removeLegacyIndexFromCheckout], +] as const)("keeps a waiting builder exclusive after %s removal", (_name, remove) => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-index-lock-")); + let waitingBuilder: NodeSqlite.DatabaseSync | undefined; + let contender: (() => void) | null = null; + try { + publishGeneration(root, 1); + const generation = NodePath.join(root, ".codegraph-t3-g1"); + NodeFS.mkdirSync(generation); + NodeFS.writeFileSync(NodePath.join(generation, "codegraph.db"), "index payload"); + waitingBuilder = new NodeSqlite.DatabaseSync(NodePath.join(root, ".codegraph-t3/build.lock")); + + remove(root); + expect(readPublishedGeneration(root)).toBeNull(); + expect(NodeFS.existsSync(generation)).toBe(false); + expect(hasLegacyIndexFiles(root)).toBe(false); + + waitingBuilder.exec("PRAGMA journal_mode = OFF; BEGIN EXCLUSIVE;"); + contender = tryLockBuild(root); + expect(contender).toBeNull(); + waitingBuilder.close(); + waitingBuilder = undefined; + contender = tryLockBuild(root); + expect(contender).not.toBeNull(); + } finally { + contender?.(); + waitingBuilder?.close(); + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it.skipIf(!symlinksSupported).each([ + [".codegraph-t3", "build.lock", "published"], + [".codegraph-t3-g1", "readers.lock", "codegraph.db"], +])("leaves linked locks inside a legacy %s untouched", (directory, lock, marker) => { + const temporary = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-index-link-")); + const checkout = NodePath.join(temporary, "checkout"); + const target = NodePath.join(temporary, "outside.sqlite"); + const legacy = NodePath.join(checkout, directory); + try { + NodeFS.mkdirSync(legacy, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(legacy, marker), "untouched"); + NodeFS.symlinkSync(target, NodePath.join(legacy, lock), "file"); + + removeLegacyIndexFromCheckout(checkout); + + expect(NodeFS.existsSync(target)).toBe(false); + expect(NodeFS.readFileSync(NodePath.join(legacy, marker), "utf8")).toBe("untouched"); + expect(NodeFS.lstatSync(NodePath.join(legacy, lock)).isSymbolicLink()).toBe(true); + expect(hasLegacyIndexFiles(checkout)).toBe(false); + } finally { + NodeFS.rmSync(temporary, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/project/knowledge/codeIndexLocks.ts b/apps/server/src/project/knowledge/codeIndexLocks.ts index c745808b25ca2..ba77df1fc32c4 100644 --- a/apps/server/src/project/knowledge/codeIndexLocks.ts +++ b/apps/server/src/project/knowledge/codeIndexLocks.ts @@ -25,6 +25,7 @@ const BUILDING_LOCK = "building.lock"; const READERS_LOCK = "readers.lock"; // Waits out the momentary locks of probes and failed deletion attempts, never a build or a read. const MOMENTARY_LOCK_WAIT_MS = 1_000; +const isBuildLockFile = (name: string) => name === BUILD_LOCK || name === BUILDING_LOCK; const SQLITE_BUSY = 5; const SQLITE_CANTOPEN = 14; @@ -133,13 +134,14 @@ export function removeGenerationUnlessRead(indexRoot: string, generation: number NodeFS.rmSync(directory, { recursive: true, force: true }); } -/** - * Removes one index root: the published pointer first, so new readers see "unindexed", then every - * generation a reader is not pinning, then the checkout's links. A build elsewhere leaves it all - * for the next sweep. - * - * Nothing here may remove recursively from the root: its entries are links into a user's checkout. - */ +function clearControlFiles(control: string, preserveGitignore = false): void { + for (const name of NodeFS.readdirSync(control)) { + if (isBuildLockFile(name) || (preserveGitignore && name === ".gitignore")) continue; + NodeFS.rmSync(NodePath.join(control, name), { recursive: true, force: true }); + } +} + +/** Removes index payload and checkout links, retaining the lock inodes that other builders open. */ export function removeIndexRoot(indexRoot: string): void { if (!NodeFS.existsSync(indexRoot)) return; const control = NodePath.join(indexRoot, INDEX_DIRECTORY); @@ -151,18 +153,10 @@ export function removeIndexRoot(indexRoot: string): void { removeGenerationUnlessRead(indexRoot, generation); } clearCheckoutSources(indexRoot); + clearControlFiles(control); } finally { - // Windows cannot delete an open lock file, so it goes after the lock is released. release(); } - NodeFS.rmSync(control, { recursive: true, force: true }); - try { - // A generation a reader still pins stays behind, and so does the root. - NodeFS.rmdirSync(indexRoot); - } catch (cause) { - const code = (cause as NodeJS.ErrnoException).code; - if (code !== "ENOTEMPTY" && code !== "ENOENT") throw cause; - } } // Files an older T3 left in a checkout when the index lived beside its sources. @@ -183,9 +177,22 @@ const isRealDirectory = (path: string): boolean => { } }; -/** A real directory holding at least one of `names`; a link to one is not a match. */ -const isT3Directory = (path: string, names: ReadonlyArray): boolean => - isRealDirectory(path) && names.some((name) => NodeFS.existsSync(NodePath.join(path, name))); +/** Legacy lock paths are checkout-controlled, so none may follow a link or share an outside inode. */ +function isT3Directory(path: string, names: ReadonlyArray): boolean { + if (!isRealDirectory(path)) return false; + try { + const entries = NodeFS.readdirSync(path); + return ( + entries.some((name) => names.includes(name)) && + entries.every((name) => { + const entry = NodeFS.lstatSync(NodePath.join(path, name)); + return entry.isDirectory() || (entry.isFile() && entry.nlink === 1); + }) + ); + } catch { + return false; + } +} /** * The folders an older T3 left inside a checkout, when they are provably T3's. Anything else that @@ -208,7 +215,10 @@ function legacyIndexFolders( LEGACY_GENERATION_MARKERS, ), ); - return exists || generations.length > 0 ? { generations } : null; + const controlHasPayload = + exists && + NodeFS.readdirSync(control).some((name) => !isBuildLockFile(name) && name !== ".gitignore"); + return controlHasPayload || generations.length > 0 ? { generations } : null; } /** Whether a checkout still holds the folders an older T3 kept inside it, without throwing. */ @@ -233,10 +243,10 @@ export function removeLegacyIndexFromCheckout(checkoutPath: string): void { for (const generation of legacy.generations) { removeGenerationUnlessRead(checkoutPath, generation); } + clearControlFiles(control, true); } finally { release(); } - NodeFS.rmSync(control, { recursive: true, force: true }); } export interface GenerationPin { diff --git a/apps/server/src/project/knowledge/codeIntelligenceWorker.ts b/apps/server/src/project/knowledge/codeIntelligenceWorker.ts index 40028bb96b6d0..e1927e21e7b4b 100644 --- a/apps/server/src/project/knowledge/codeIntelligenceWorker.ts +++ b/apps/server/src/project/knowledge/codeIntelligenceWorker.ts @@ -33,7 +33,11 @@ import type { ProjectCodeUnresolvedReference, } from "@t3tools/contracts"; -import { clearCheckoutSources, linkCheckoutSources } from "./codeIndexLayout.ts"; +import { + CHECKOUT_CONFIG_ENTRIES, + clearCheckoutSources, + linkCheckoutSources, +} from "./codeIndexLayout.ts"; const CATALOG_REVISION = "@colbymchenry/codegraph@1.6.2"; const MAX_RESULTS = 200; @@ -219,6 +223,7 @@ interface IndexMeta { readonly skippedPaths: ReadonlyArray; // Whether the build ranked an over-cap listing; older builds kept whatever sorted first. readonly ranked: boolean; + readonly configurationDigest: string | null; } function readIndexMeta(indexRoot: string, indexDirectory: string): IndexMeta { @@ -227,9 +232,14 @@ function readIndexMeta(indexRoot: string, indexDirectory: string): IndexMeta { NodeFS.readFileSync(NodePath.join(indexRoot, indexDirectory, INDEX_META_FILE), "utf8"), ); if (typeof raw !== "object" || raw === null) throw new Error("invalid"); - const { commit, generation, indexedAt, skippedPaths, ranked } = raw as Record; + const { commit, generation, indexedAt, skippedPaths, ranked, configurationDigest } = + raw as Record; return { ranked: ranked === true, + configurationDigest: + typeof configurationDigest === "string" && /^[0-9a-f]{64}$/.test(configurationDigest) + ? configurationDigest + : null, commit: typeof commit === "string" && /^[0-9a-f]{7,64}$/.test(commit) ? commit : null, generation: typeof generation === "number" && Number.isSafeInteger(generation) && generation >= 0 @@ -244,8 +254,31 @@ function readIndexMeta(indexRoot: string, indexDirectory: string): IndexMeta { : [], }; } catch { - return { commit: null, generation: 0, indexedAt: null, skippedPaths: [], ranked: false }; + return { + commit: null, + generation: 0, + indexedAt: null, + skippedPaths: [], + ranked: false, + configurationDigest: null, + }; + } +} + +function checkoutConfigurationDigest(checkoutPath: string): string { + const digest = NodeCrypto.createHash("sha256"); + for (const name of CHECKOUT_CONFIG_ENTRIES) { + digest.update(name).update("\0"); + try { + const contents = NodeFS.readFileSync(NodePath.join(checkoutPath, name)); + digest.update("present\0").update(contents); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause; + digest.update("missing\0"); + } + digest.update("\0"); } + return digest.digest("hex"); } function writeIndexMeta(indexRoot: string, indexDirectory: string, meta: IndexMeta): void { @@ -646,13 +679,17 @@ export async function buildIndexInWorker( const isSupported = supportedPaths({ detectLanguage, getSupportedLanguages }); const { paths: listedPaths } = authorizedListing(input, isSupported); const commit = headCommit(input.checkoutPath); + const configurationDigest = checkoutConfigurationDigest(input.checkoutPath); let from = input.from; if (from !== null) { useIndexDirectory(from); + const meta = readIndexMeta(input.indexRoot, from); if ( !CodeGraph.isInitialized(input.indexRoot) || + // Filtering changes must remove old entries as well as reconsider previously skipped files. + meta.configurationDigest !== configurationDigest || // Successors keep every file they inherit, so one that spent the cap unranked never recovers. - (isListingTruncated(input) && !readIndexMeta(input.indexRoot, from).ranked) + (isListingTruncated(input) && !meta.ranked) ) from = null; } @@ -677,8 +714,6 @@ export async function buildIndexInWorker( published.close(); } } - // Before the SDK opens anything: it reads `codegraph.json` from here, and a stale link or copy - // must not stand in for a source that moved. const target = NodePath.join(input.indexRoot, input.to); // Exclusive: a leftover entry, link or not, fails the build instead of being written through. NodeFS.mkdirSync(target); @@ -713,8 +748,6 @@ export async function buildIndexInWorker( else if (!NodeFS.existsSync(NodePath.resolve(input.checkoutPath, file.path))) reconcilePaths.add(file.path); } - // Files the graph already holds can sit under a top-level entry the listing dropped; without - // its link the SDK would treat them as deleted. // From the reconcile set, not the listing: the cap can drop a top-level entry the graph still // holds files under, and the SDK would read those as deleted. clearCheckoutSources(input.indexRoot); @@ -732,6 +765,7 @@ export async function buildIndexInWorker( // not newly added files that the next refresh can fix. skippedPaths: listedPaths.filter((path) => !indexedPaths.has(path)).toSorted(), ranked: true, + configurationDigest, }); } finally { graph.close(); @@ -1181,12 +1215,18 @@ export async function inspectCodeInWorker( isSupported, meta.skippedPaths, ); + const configurationChanged = + meta.configurationDigest !== checkoutConfigurationDigest(input.checkoutPath); if (changed.length > 0) { limitations.push( `${changed.length} source ${changed.length === 1 ? "file has" : "files have"} changed since this index generation was built.`, ); } - const stale = changed.length > 0 || sourceDigests.some((source) => !source.verified); + if (configurationChanged) limitations.push("The checkout's index configuration has changed."); + const stale = + configurationChanged || + changed.length > 0 || + sourceDigests.some((source) => !source.verified); const coverage: ProjectCodeCoverage = { state: stale ? "stale" : coverageIncomplete ? "partial" : "complete", catalogRevision: CATALOG_REVISION, @@ -1380,6 +1420,9 @@ function inspectFreshness( isSupported, meta.skippedPaths, ); + const configurationChanged = + meta.configurationDigest !== checkoutConfigurationDigest(input.checkoutPath); + const stale = changed.length > 0 || configurationChanged; const { incomplete } = indexCoverage; const limitations = [ ...(changed.length > 0 @@ -1388,12 +1431,13 @@ function inspectFreshness( ] : []), ...indexCoverage.limitations, + ...(configurationChanged ? ["The checkout's index configuration has changed."] : []), ]; return emptyResult( input, { ...baseCoverage(input, languages), - state: changed.length > 0 ? "stale" : incomplete ? "partial" : "complete", + state: stale ? "stale" : incomplete ? "partial" : "complete", filesIndexed: indexCoverage.files.length, filesSkipped: indexCoverage.filesSkipped, filesUnsupported: indexCoverage.filesUnsupported, @@ -1403,7 +1447,7 @@ function inspectFreshness( limitations, }, { - state: changed.length > 0 ? "stale" : incomplete ? "partial" : "ready", + state: stale ? "stale" : incomplete ? "partial" : "ready", indexedCommit: meta.commit, headCommit: commit, generation: meta.generation, diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index e222d1093b52f..8370b27eb8c70 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -221,7 +221,7 @@ export function worktreeOwner, now: number, diff --git a/docs/user/code-navigation.md b/docs/user/code-navigation.md index 28d395405b69d..d7805a1c2fc3d 100644 --- a/docs/user/code-navigation.md +++ b/docs/user/code-navigation.md @@ -12,8 +12,9 @@ again. A worktree's index is removed when its thread settles, unless other unset the worktree. Until an index is ready, the section says the checkout is not indexed yet. Folders outside a git repository index only on a code question. -Indexes are stored in T3 Code's data directory, not in your repository. Folders an earlier version -left in a checkout are removed the next time that checkout is indexed. +Indexes are stored in T3 Code's data directory. Index databases an earlier version left in a +checkout are removed the next time that checkout is indexed. An existing `.codegraph-t3` folder +can retain small lock files so concurrent servers can continue coordinating safely. Turn automatic indexing off in **Settings โ†’ Labs โ†’ Automatic code indexing**. Then a checkout only indexes when something asks it a code question. From b1180b2c17417a835ed29b9ce2e40781ab2cd803 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:45:26 -0800 Subject: [PATCH 03/19] fix(server): cleanup follows the checked-out branch Maintenance kept every worktree whose agent had switched branches, because nothing wrote HEAD back to the thread. A new WorktreeBranches service reads HEAD and rebinds the threads on that worktree. Cleanup keeps for one pass after a rebind, then applies the existing rules. A detached HEAD stays kept unless a ref outside refs/t3 holds its commit. Co-Authored-By: Claude Fable 5.1 --- .../orchestration-v2/WorktreeBranches.test.ts | 328 ++++++++++++++++++ .../src/orchestration-v2/WorktreeBranches.ts | 196 +++++++++++ apps/server/src/server.ts | 5 +- apps/server/src/storageCleanup.test.ts | 171 ++++++++- apps/server/src/storageCleanup.ts | 117 +++---- apps/server/src/vcs/GitVcsDriver.ts | 5 + apps/server/src/vcs/GitVcsDriverCore.ts | 10 +- 7 files changed, 754 insertions(+), 78 deletions(-) create mode 100644 apps/server/src/orchestration-v2/WorktreeBranches.test.ts create mode 100644 apps/server/src/orchestration-v2/WorktreeBranches.ts diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts new file mode 100644 index 0000000000000..c8ca2652aa069 --- /dev/null +++ b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts @@ -0,0 +1,328 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + RunId, + ThreadId, + type CommandId, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import type * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; + +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as WorktreeBranches from "./WorktreeBranches.ts"; + +interface MetadataUpdate { + readonly commandId: CommandId; + readonly threadId: ThreadId; + readonly branch: string; + readonly expectedBranch: string | null; + readonly expectedWorktreePath: string; +} + +const gitLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "worktree-branches-" })), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); + +const member = ( + id: string, + branch: string | null, + worktreePath: string, + overrides: Partial = {}, +) => + ({ + id: ThreadId.make(id), + branch, + worktreePath, + activeRunId: null, + archivedAt: null, + createdAt: DateTime.makeUnsafe(0), + ...overrides, + }) as OrchestrationV2ThreadShell; + +const fixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "worktree-sync-" })); + const repo = `${root}/repo`; + const worktree = `${root}/feature`; + yield* fs.makeDirectory(repo); + const command = (cwd: string, args: string[]) => + git + .execute({ operation: "worktree-branches-test", cwd, args }) + .pipe(Effect.map((result) => result.stdout.trim())); + yield* command(repo, ["init", "-b", "main"]); + yield* command(repo, ["config", "user.email", "branches@example.test"]); + yield* command(repo, ["config", "user.name", "Branches test"]); + yield* command(repo, ["commit", "--allow-empty", "-m", "initial"]); + yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); + + let threads: OrchestrationV2ThreadShell[] = []; + let snapshotReads = 0; + let onDispatch: (update: MetadataUpdate) => Effect.Effect = () => Effect.void; + const dispatched: MetadataUpdate[] = []; + const context = yield* Layer.build(WorktreeBranches.layer).pipe( + Effect.provideService(Orchestrator.OrchestratorV2, { + getShellSnapshot: (options: { readonly worktreePath: string }) => + Effect.sync(() => { + snapshotReads++; + const members = threads.filter((thread) => thread.worktreePath === options.worktreePath); + return { + threads: members.filter((thread) => thread.archivedAt === null), + archivedThreads: members.filter((thread) => thread.archivedAt !== null), + }; + }), + dispatch: (update: MetadataUpdate) => + Effect.gen(function* () { + yield* onDispatch(update); + dispatched.push(update); + threads = threads.map((thread) => + thread.id === update.threadId && + thread.branch === update.expectedBranch && + thread.worktreePath === update.expectedWorktreePath + ? { ...thread, branch: update.branch } + : thread, + ); + }), + } as unknown as Orchestrator.OrchestratorV2["Service"]), + ); + return { + service: Context.get(context, WorktreeBranches.WorktreeBranches), + command, + repo, + worktree, + dispatched, + snapshotReads: () => snapshotReads, + threads: () => threads, + setThreads: (next: OrchestrationV2ThreadShell[]) => { + threads = next; + }, + onDispatch: (hook: (update: MetadataUpdate) => Effect.Effect) => { + onDispatch = hook; + }, + }; +}); + +const run = ( + effect: Effect.Effect< + A, + E, + | Scope.Scope + | FileSystem.FileSystem + | GitVcsDriver.GitVcsDriver + | Path.Path + | ServerConfig.ServerConfig + >, +) => effect.pipe(Effect.provide(gitLayer), Effect.scoped); + +it.live("returns Bound without reading threads when every record matches HEAD", () => + run( + Effect.gen(function* () { + const { service, worktree, snapshotReads, dispatched } = yield* fixture; + assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + _tag: "Bound", + branch: "feature", + }); + assert.equal(snapshotReads(), 0); + assert.deepEqual(dispatched, []); + }), + ), +); + +it.live("writes every member oldest first, each with its own swap, and a repeat is Bound", () => + run( + Effect.gen(function* () { + const { service, command, worktree, dispatched, threads, setThreads } = yield* fixture; + yield* command(worktree, ["switch", "-c", "agent/work"]); + setThreads([ + member("child", "t3/old", worktree, { createdAt: DateTime.makeUnsafe(2) }), + member("no-branch", null, worktree, { createdAt: DateTime.makeUnsafe(3) }), + member("archived", "feature", worktree, { + createdAt: DateTime.makeUnsafe(4), + archivedAt: DateTime.makeUnsafe(5), + }), + member("parent", "feature", worktree, { createdAt: DateTime.makeUnsafe(1) }), + member("elsewhere", "feature", `${worktree}-other`), + ]); + assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + _tag: "Rebinding", + branch: "agent/work", + }); + assert.deepEqual( + dispatched.map((update) => [update.threadId, update.expectedBranch]), + [ + ["parent", "feature"], + ["child", "t3/old"], + ["no-branch", null], + ["archived", "feature"], + ], + ); + assert.isTrue(dispatched.every((update) => update.expectedWorktreePath === worktree)); + assert.equal(new Set(dispatched.map((update) => update.commandId)).size, 4); + assert.equal(threads().find((thread) => thread.id === "elsewhere")?.branch, "feature"); + assert.deepEqual( + yield* service.sync({ + worktreePath: worktree, + recorded: ["parent", "child", "no-branch", "archived"].map( + (id) => threads().find((thread) => thread.id === id)!.branch, + ), + }), + { _tag: "Bound", branch: "agent/work" }, + ); + }), + ), +); + +it.live.each([ + ["a detached HEAD", ["switch", "--detach"], "Detached", undefined], + ["an unborn branch", ["switch", "--orphan", "unborn"], "Unavailable", undefined], + ["a parenthesized name", ["switch", "-c", "(feature)"], "Unavailable", undefined], + ["the default branch", ["switch", "main"], "Default", "main"], +] as const)("writes nothing for %s", ([, checkout, tag, branch]) => + run( + Effect.gen(function* () { + const { service, command, repo, worktree, dispatched, setThreads } = yield* fixture; + setThreads([member("thread", "feature", worktree)]); + if (branch === "main") yield* command(repo, ["switch", "--detach"]); + yield* command(worktree, [...checkout]); + assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + _tag: tag, + ...(branch === undefined ? {} : { branch }), + } as WorktreeBranches.WorktreeCheckout); + assert.deepEqual(dispatched, []); + }), + ), +); + +it.live("reads the default branch from the primary remote before main or master", () => + run( + Effect.gen(function* () { + const { service, command, repo, worktree, dispatched, setThreads } = yield* fixture; + setThreads([member("thread", "feature", worktree)]); + yield* command(repo, ["branch", "trunk", "main"]); + yield* command(repo, ["remote", "add", "origin", repo]); + yield* command(repo, ["fetch", "origin"]); + yield* command(repo, [ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/trunk", + ]); + yield* command(worktree, ["switch", "trunk"]); + assert.equal( + (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, + "Default", + ); + assert.deepEqual(dispatched, []); + yield* command(repo, ["switch", "--detach"]); + yield* command(worktree, ["switch", "main"]); + assert.equal( + (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, + "Rebinding", + ); + }), + ), +); + +it.live("reports a missing worktree as Unavailable", () => + run( + Effect.gen(function* () { + const { service, worktree } = yield* fixture; + const missing = `${worktree}-missing`; + assert.deepEqual(yield* service.observe(missing), { _tag: "Unavailable" }); + assert.deepEqual(yield* service.sync({ worktreePath: missing, recorded: ["feature"] }), { + _tag: "Unavailable", + }); + }), + ), +); + +it.live("defers to a sibling's active run but writes through the caller's own", () => + run( + Effect.gen(function* () { + const { service, command, worktree, dispatched, setThreads } = yield* fixture; + const runId = RunId.make("run-own"); + yield* command(worktree, ["switch", "-c", "agent/work"]); + setThreads([ + member("own", "feature", worktree, { activeRunId: runId }), + member("sibling", "feature", worktree, { activeRunId: RunId.make("run-sibling") }), + ]); + const sync = { worktreePath: worktree, recorded: ["feature"], runId }; + assert.deepEqual(yield* service.sync(sync), { _tag: "Rebinding", branch: "agent/work" }); + assert.deepEqual(dispatched, []); + setThreads([ + member("own", "feature", worktree, { activeRunId: runId }), + member("sibling", "feature", worktree), + ]); + yield* service.sync(sync); + assert.deepEqual(dispatched.map((update) => update.threadId).toSorted(), ["own", "sibling"]); + }), + ), +); + +it.live("logs a lost swap and still writes the next member", () => + run( + Effect.gen(function* () { + const { service, command, worktree, threads, setThreads, onDispatch } = yield* fixture; + yield* command(worktree, ["switch", "-c", "agent/work"]); + setThreads([ + member("first", "feature", worktree, { createdAt: DateTime.makeUnsafe(1) }), + member("second", "feature", worktree, { createdAt: DateTime.makeUnsafe(2) }), + ]); + onDispatch((update) => (update.threadId === "first" ? Effect.die("swap lost") : Effect.void)); + assert.equal( + (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, + "Rebinding", + ); + assert.deepEqual( + threads().map((thread) => thread.branch), + ["feature", "agent/work"], + ); + }), + ), +); + +it.live("picks up a child created during the write when it rereads", () => + run( + Effect.gen(function* () { + const { service, command, worktree, threads, setThreads, onDispatch } = yield* fixture; + yield* command(worktree, ["switch", "-c", "agent/work"]); + setThreads([member("parent", "feature", worktree)]); + onDispatch((update) => + Effect.sync(() => { + if (update.threadId === "parent") + setThreads([...threads(), member("child", "feature", worktree)]); + }), + ); + yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }); + assert.deepEqual( + threads().map((thread) => thread.branch), + ["agent/work", "agent/work"], + ); + }), + ), +); + +it.live("terminates when every write is rejected", () => + run( + Effect.gen(function* () { + const { service, command, worktree, snapshotReads, setThreads, onDispatch } = yield* fixture; + yield* command(worktree, ["switch", "-c", "agent/work"]); + setThreads([member("parent", "feature", worktree)]); + onDispatch(() => Effect.die("rejected")); + assert.equal( + (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, + "Rebinding", + ); + assert.equal(snapshotReads(), 2); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.ts b/apps/server/src/orchestration-v2/WorktreeBranches.ts new file mode 100644 index 0000000000000..f7f0edc363f17 --- /dev/null +++ b/apps/server/src/orchestration-v2/WorktreeBranches.ts @@ -0,0 +1,196 @@ +import { CommandId, GitCommandError, type RunId } from "@t3tools/contracts"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import type { PlatformError } from "effect/PlatformError"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as Orchestrator from "./Orchestrator.ts"; + +export type WorktreeHead = + | { readonly _tag: "Unavailable" } + | { readonly _tag: "Detached" } + | { readonly _tag: "Named"; readonly branch: string }; + +export type WorktreeCheckout = + | { readonly _tag: "Unavailable" } + | { readonly _tag: "Detached" } + /** HEAD is on the default branch and records differ. Its own ref holds HEAD, so nothing is written. */ + | { readonly _tag: "Default"; readonly branch: string } + | { readonly _tag: "Bound"; readonly branch: string } + /** Records differ, and a write was attempted or deferred. The caller's thread data is stale. */ + | { readonly _tag: "Rebinding"; readonly branch: string }; + +type WorktreeBranchError = GitCommandError | PlatformError; + +export class WorktreeBranches extends Context.Service< + WorktreeBranches, + { + readonly observe: (worktreePath: string) => Effect.Effect; + /** `runId` names the one run allowed to stay active while this worktree's threads are written. */ + readonly sync: (input: { + readonly worktreePath: string; + readonly recorded: ReadonlyArray; + readonly runId?: RunId; + }) => Effect.Effect; + } +>()("t3/orchestration-v2/WorktreeBranches") {} + +const make = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const git = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const observe: WorktreeBranches["Service"]["observe"] = Effect.fn("WorktreeBranches.observe")( + function* (cwd) { + const failure = (detail: string) => + new GitCommandError({ + operation: "WorktreeBranches.observe", + command: "git", + cwd, + detail, + }); + // Return null for missing/non-repository cwd; force untranslated git diagnostics for matching. + const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) { + const result = yield* git + .execute({ + operation: "WorktreeBranches.observe", + cwd, + args, + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + }) + .pipe( + Effect.catch((error) => + Effect.flatMap(fs.exists(cwd), (exists) => + exists ? Effect.fail(error) : Effect.succeed(null), + ), + ), + ); + if (result === null) return null; + if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; + const stderr = result.stderr.toLowerCase(); + // Newer Git reports a dangling .git file as "gitfile does not point to a valid repository". + if ( + stderr.includes("not a git repository") || + stderr.includes("does not point to a valid repository") + ) + return null; + return yield* failure( + result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", + ); + }); + const index = yield* read(["rev-parse", "--git-path", "index"]); + if (index === null) return { _tag: "Unavailable" }; + if (yield* fs.exists(`${path.resolve(cwd, index.stdout.trim())}.lock`)) + return yield* failure( + "Git index is locked. Status will resume when the index lock is removed.", + ); + // Detached HEAD exits 1; full refs avoid shortening when a tag shares the branch name. + const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1); + if (head === null) return { _tag: "Unavailable" }; + if (head.exitCode !== 0) return { _tag: "Detached" }; + const ref = head.stdout.trim(); + const branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length).trim() : null; + return branch === null || branch.startsWith("(") + ? { _tag: "Unavailable" } + : { _tag: "Named", branch }; + }, + ); + + const isDefaultBranch = Effect.fn("WorktreeBranches.isDefaultBranch")(function* ( + cwd: string, + branch: string, + ) { + const remote = yield* git.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); + return GitVcsDriver.isDefaultBranchName( + branch, + remote === null ? null : yield* git.resolveDefaultBranchName(cwd, remote), + ); + }); + + const writeMembers = Effect.fn("WorktreeBranches.writeMembers")(function* (input: { + readonly worktreePath: string; + readonly branch: string; + readonly runId: RunId | undefined; + }) { + const { worktreePath, branch, runId } = input; + // Oldest first so parents are written before children; reread for threads created mid-write. + const visited = new Set(); + while (true) { + const snapshot = yield* orchestrator.getShellSnapshot({ worktreePath }); + const members = [...snapshot.threads, ...snapshot.archivedThreads]; + // A sibling's agent may be mid-checkout, so HEAD proves nothing yet. Its run end syncs. + if (members.some((member) => member.activeRunId !== null && member.activeRunId !== runId)) + return; + const pending = members + .filter((member) => !visited.has(member.id) && member.branch !== branch) + .toSorted( + (left, right) => + DateTime.toEpochMillis(left.createdAt) - DateTime.toEpochMillis(right.createdAt), + ); + if (pending.length === 0) return; + for (const member of pending) { + visited.add(member.id); + // Random ids: a deterministic receipt would block a later A to B to A to B sequence. + const uuid = yield* randomUuidV4; + yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`server:worktree-branch:${member.id}:${uuid}`), + threadId: member.id, + branch, + expectedBranch: member.branch, + expectedWorktreePath: worktreePath, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("worktree branch update failed", { + threadId: member.id, + branch, + cause, + }), + ), + ); + } + } + }); + + const sync: WorktreeBranches["Service"]["sync"] = Effect.fn("WorktreeBranches.sync")(function* ({ + worktreePath, + recorded, + runId, + }) { + const head = yield* observe(worktreePath); + if (head._tag !== "Named") return head; + const { branch } = head; + if (recorded.every((name) => name === branch)) return { _tag: "Bound", branch }; + const born = yield* git.execute({ + operation: "WorktreeBranches.sync", + cwd: worktreePath, + args: ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], + allowNonZeroExit: true, + }); + if (born.exitCode !== 0) return { _tag: "Unavailable" }; + if (yield* isDefaultBranch(worktreePath, branch)) return { _tag: "Default", branch }; + yield* writeMembers({ worktreePath, branch, runId }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("worktree branch sync could not read its threads", { + worktreePath, + branch, + cause, + }), + ), + ); + return { _tag: "Rebinding", branch }; + }); + + return WorktreeBranches.of({ observe, sync }); +}); + +export const layer = Layer.effect(WorktreeBranches, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4fd938d2c0398..f2af7027bb5f7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -215,6 +215,7 @@ import * as ThreadSettlementService from "./orchestration-v2/ThreadSettlementSer import * as IdleCompactionService from "./orchestration-v2/IdleCompactionService.ts"; import * as ThreadPullRequestService from "./orchestration-v2/ThreadPullRequestService.ts"; import * as RunFinalizationService from "./orchestration-v2/RunFinalizationService.ts"; +import * as WorktreeBranches from "./orchestration-v2/WorktreeBranches.ts"; import * as EffectOutboxV2 from "./orchestration-v2/EffectOutbox.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStoreV2 from "./orchestration-v2/ProjectionStore.ts"; @@ -657,7 +658,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( HeldHooksWaker.layer, layerThreadSettlementWorker, layerIdleCompactionWorker, - StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), + StorageCleanup.layer.pipe( + Layer.provide(Layer.merge(ProjectionStoreV2.layer, WorktreeBranches.layer)), + ), DatabaseStorage.layer.pipe(Layer.provide(ProjectionMaintenance.layer)), layerThreadPullRequestWorker, DelegatedTaskAnalytics.layer, diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index b4c9ac3e68945..37c10eac65893 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1,5 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; @@ -30,6 +31,7 @@ import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; +import * as WorktreeBranches from "./orchestration-v2/WorktreeBranches.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAdapterV2.ts"; import { ServerActivation } from "./serverActivation.ts"; @@ -349,10 +351,13 @@ const cleanupFixture = ( let outboxStatus: string | null = null; let fullStatusCalls = 0; let snapshotReads = 0; + const pullRequestLookups: string[] = []; let terminalListener: ((event: TerminalMetadataStreamEvent) => Effect.Effect) | undefined; let events: Stream.Stream = Stream.empty; const activated = yield* Deferred.make(); - const context = yield* Layer.build(StorageCleanup.layer).pipe( + const context = yield* Layer.build( + StorageCleanup.layer.pipe(Layer.provide(WorktreeBranches.layer)), + ).pipe( Effect.provideService(GitVcsDriver.GitVcsDriver, { ...git, execute: (input) => @@ -420,6 +425,32 @@ const cleanupFixture = ( } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]), Effect.provideService(Orchestrator.OrchestratorV2, { streamDomainEvents: Stream.suspend(() => events), + getShellSnapshot: (input: { worktreePath: string }) => + Effect.sync(() => { + const members = threads.filter( + (thread) => thread.deletedAt === null && thread.worktreePath === input.worktreePath, + ); + return { + threads: members.filter((thread) => thread.archivedAt === null), + archivedThreads: members.filter((thread) => thread.archivedAt !== null), + }; + }), + dispatch: (command: { + threadId: ThreadId; + branch: string; + expectedBranch: string | null; + expectedWorktreePath: string; + }) => + Effect.gen(function* () { + const updatedAt = DateTime.makeUnsafe(yield* Clock.currentTimeMillis); + threads = threads.map((thread) => + thread.id === command.threadId && + thread.branch === command.expectedBranch && + thread.worktreePath === command.expectedWorktreePath + ? { ...thread, branch: command.branch, updatedAt } + : thread, + ); + }), } as unknown as Orchestrator.OrchestratorV2["Service"]), Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) => Effect.sync(() => @@ -456,6 +487,11 @@ const cleanupFixture = ( )) as unknown as SqlClient.SqlClient), Effect.provideService(GitManager.GitManager, { invalidateStatus: () => Effect.void, + branchPullRequest: (input: { branch: string }) => + Effect.sync(() => { + pullRequestLookups.push(input.branch); + return null; + }), } as unknown as GitManager.GitManager["Service"]), Effect.provideService(TerminalManager.TerminalManager, { subscribeMetadata: ( @@ -509,6 +545,7 @@ const cleanupFixture = ( }, }; }, + threads: () => threads, setThreads: (next: typeof threads) => { threads = next; }, @@ -551,6 +588,7 @@ const cleanupFixture = ( }) ?? Effect.void, fullStatusCalls: () => fullStatusCalls, snapshotReads: () => snapshotReads, + pullRequestLookups, activate: (domainEvents: Stream.Stream) => { events = domainEvents; return Deferred.succeed(activated, undefined); @@ -1064,7 +1102,7 @@ describe("subagent worktree ownership", () => { : shared ? "Shared by 2 threads" : scenario === "null-branch" || scenario === "stale-branch" - ? "A subagent thread's branch differs from this worktree's branch" + ? "Branch updated, re-checked next pass" : scenario === "recent-activity" ? "Settled for 2 of 8 days" : scenario === "recent-inactive" @@ -1073,6 +1111,14 @@ describe("subagent worktree ownership", () => { ? "Settled moments ago" : "Not settled", }); + if (scenario === "null-branch" || scenario === "stale-branch") { + expect(fixture.threads().map((thread) => thread.branch)).toEqual(["feature", "feature"]); + expect((yield* fixture.service.runNow).entries[0]).toMatchObject({ + outcome: "removed", + reason: "Settled for 20 days", + }); + yield* fixture.command(fixture.repo, ["rev-parse", "--verify", "refs/heads/feature"]); + } }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); @@ -2181,7 +2227,13 @@ describe("storage cleanup reports and local file policies", () => { yield* cleanupFixture(); const changed = { outcome: "kept", reason: "Repository or branch changed" }; yield* command(worktree, ["checkout", "--detach"]); - expect((yield* service.runNow).entries[0]).toMatchObject(changed); + yield* command(worktree, ["commit", "--allow-empty", "-m", "detached work"]); + const detached = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Detached HEAD has commits no branch or tag holds", + }); + yield* command(repo, ["cat-file", "-e", detached]); yield* command(worktree, ["switch", "--orphan", "other"]); expect((yield* service.runNow).entries[0]).toMatchObject(changed); yield* command(worktree, ["switch", "-c", "(feature)", "feature"]); @@ -2214,6 +2266,119 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("removes a detached worktree only once a ref outside refs/t3 holds its commit", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture(); + yield* command(worktree, ["switch", "--detach"]); + yield* command(worktree, ["commit", "--allow-empty", "-m", "detached work"]); + const detached = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); + yield* command(repo, ["update-ref", "refs/t3/checkpoints/run", detached]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Detached HEAD has commits no branch or tag holds", + }); + expect(yield* fs.exists(worktree)).toBe(true); + yield* command(repo, ["tag", "keeper", detached]); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + yield* command(repo, ["cat-file", "-e", detached]); + }), + ), + ); + it.live("follows a branch the agent switched to and removes the worktree next pass", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, threads } = yield* cleanupFixture(); + yield* command(worktree, ["switch", "-c", "agent/work"]); + yield* command(worktree, ["commit", "--allow-empty", "-m", "agent work"]); + const tip = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Branch updated, re-checked next pass", + }); + expect(threads()[0]?.branch).toBe("agent/work"); + expect(yield* fs.exists(worktree)).toBe(true); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + expect((yield* command(repo, ["rev-parse", "refs/heads/agent/work"])).stdout.trim()).toBe( + tip, + ); + }), + ), + ); + it.live("removes a worktree on the default branch without rebinding its thread", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, threads } = yield* cleanupFixture(); + yield* command(repo, ["switch", "--detach"]); + yield* command(worktree, ["switch", "main"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + expect(threads()[0]?.branch).toBe("feature"); + }), + ), + ); + it.live.each([ + ["the branch the threads now record", "agent", ["agent/work"]], + ["no branch for a detached commit", "detached", []], + ["no branch for the default branch", "default", []], + ] as const)("looks up a pull request for %s", ([, checkout, lookups]) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, pullRequestLookups } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeOnMerge: true, + }); + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); + yield* command(remote, ["init", "--bare", "-b", "main"]); + yield* command(repo, ["remote", "add", "origin", remote]); + yield* command(repo, ["push", "origin", "main"]); + yield* command(repo, ["remote", "set-head", "origin", "main"]); + if (checkout === "agent") yield* command(worktree, ["switch", "-c", "agent/work"]); + if (checkout === "detached") yield* command(worktree, ["switch", "--detach"]); + if (checkout === "default") { + yield* command(repo, ["switch", "--detach"]); + yield* command(worktree, ["switch", "main"]); + } + yield* service.runNow; + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Not merged", + }); + expect(pullRequestLookups).toEqual(lookups); + }), + ), + ); + it.live.each([ + ["the commit moves", "Commit changed since check"], + ["the branch switches", "Repository or branch changed since check"], + [ + "the detached commit loses its holding ref", + "Detached HEAD has commits no branch or tag holds", + ], + ] as const)("keeps a worktree when %s during size measurement", ([change, reason]) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* pausedMeasurementFixture(); + const { command, repo, worktree } = fixture; + const heldOnlyByTag = change === "the detached commit loses its holding ref"; + if (heldOnlyByTag) { + yield* command(worktree, ["switch", "--detach"]); + yield* command(worktree, ["commit", "--allow-empty", "-m", "detached work"]); + yield* command(worktree, ["tag", "keeper"]); + } + const run = yield* fixture.service.runNow.pipe(Effect.forkChild); + yield* Deferred.await(fixture.measuring); + if (heldOnlyByTag) yield* command(repo, ["tag", "--delete", "keeper"]); + if (change === "the commit moves") + yield* command(worktree, ["commit", "--allow-empty", "-m", "late work"]); + if (change === "the branch switches") yield* command(worktree, ["switch", "-c", "late"]); + yield* Deferred.succeed(fixture.resume, undefined); + expect((yield* Fiber.join(run)).entries[0]).toMatchObject({ outcome: "kept", reason }); + expect(yield* fixture.fs.exists(worktree)).toBe(true); + }), + ), + ); it.live.each([ ["any-local-files", ".env", "kept"], ["uncommitted-changes", ".env", "removed"], diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index ade49351659e3..00d2b0da83c65 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -42,6 +42,7 @@ import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts"; +import * as WorktreeBranches from "./orchestration-v2/WorktreeBranches.ts"; import { forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -292,6 +293,7 @@ const make = Effect.gen(function* () { const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const sql = yield* SqlClient.SqlClient; const git = yield* GitVcsDriver.GitVcsDriver; + const worktreeBranches = yield* WorktreeBranches.WorktreeBranches; const gitManager = yield* GitManager.GitManager; const terminals = yield* TerminalManager.TerminalManager; const fs = yield* FileSystem.FileSystem; @@ -552,62 +554,6 @@ const make = Effect.gen(function* () { } }); - // Use two small Git reads: statusDetailsLocal also diffs and counts for every worktree. - const branchChanged = Effect.fn("StorageCleanup.branchChanged")(function* ( - cwd: string, - branch: string | null, - ) { - const failure = (detail: string) => - new GitCommandError({ - operation: "StorageCleanup.branchChanged", - command: "git", - cwd, - detail, - }); - // Return null for missing/non-repository cwd; force untranslated git diagnostics for matching. - const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) { - const result = yield* git - .execute({ - operation: "StorageCleanup.branchChanged", - cwd, - args, - env: { LC_ALL: "C" }, - allowNonZeroExit: true, - }) - .pipe( - Effect.catch((error) => - Effect.flatMap(fs.exists(cwd), (exists) => - exists ? Effect.fail(error) : Effect.succeed(null), - ), - ), - ); - if (result === null) return null; - if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; - const stderr = result.stderr.toLowerCase(); - // Newer Git reports a dangling .git file as "gitfile does not point to a valid repository". - if ( - stderr.includes("not a git repository") || - stderr.includes("does not point to a valid repository") - ) - return null; - return yield* failure( - result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", - ); - }); - const index = yield* read(["rev-parse", "--git-path", "index"]); - if (index === null) return true; - if (yield* fs.exists(`${path.resolve(cwd, index.stdout.trim())}.lock`)) - return yield* failure( - "Git index is locked. Status will resume when the index lock is removed.", - ); - // Detached HEAD exits 1; full refs avoid shortening when a tag shares the branch name. - const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1); - if (head === null) return true; - const ref = head.exitCode === 0 ? head.stdout.trim() : ""; - const name = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length).trim() : null; - return (name?.startsWith("(") ? null : name) !== branch; - }); - const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* ( cwd: string, rules: WorktreeCleanupRules, @@ -648,6 +594,28 @@ const make = Effect.gen(function* () { return file === undefined ? null : `has ignored files (${file})`; }); + // refs/t3 is excluded: a user cannot recover a commit only a checkpoint ref holds. + const commitHeldByRef = Effect.fn("StorageCleanup.commitHeldByRef")(function* ( + cwd: string, + commitSha: string, + ) { + const refs = yield* git.execute({ + operation: "StorageCleanup.commitHeldByRef", + cwd, + args: [ + "for-each-ref", + "--contains", + commitSha, + "--count=1", + "--format=%(refname)", + "refs/heads", + "refs/remotes", + "refs/tags", + ], + }); + return refs.stdout.trim() !== ""; + }); + const submoduleRemoval = Effect.fn("StorageCleanup.submoduleRemoval")( function* (cwd: string, rules: WorktreeCleanupRules) { const inspect = (args: string[], maxOutputBytes = 64 * 1024) => @@ -892,8 +860,6 @@ const make = Effect.gen(function* () { !group.every((member) => storageCleanupThreadIdle(member, now, serverSettings)) ) return keep(waitForStop("thread is running or has pending work")); - if (group.some((member) => member.branch !== thread.branch)) - return keep("a subagent thread's branch differs from this worktree's branch"); if (hasTerminal(worktreePath)) return keep(waitForStop("open terminal")); // Compare canonical roots while allowing symlinked parents, never symlinked worktree directories. const realPath = yield* fs.realPath(worktreePath); @@ -906,8 +872,14 @@ const make = Effect.gen(function* () { return keep("contains a project checkout"); if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return keep("not a linked worktree"); - if (yield* branchChanged(worktreePath, thread.branch)) - return keep("repository or branch changed"); + const checkout = yield* worktreeBranches.sync({ + worktreePath, + recorded: group.map((member) => member.branch), + }); + if (checkout._tag === "Unavailable") return keep("repository or branch changed"); + if (checkout._tag === "Rebinding") return keep("branch updated, re-checked next pass"); + if (group.some((member) => member.branch !== group[0]!.branch)) + return keep("a subagent thread's branch differs from this worktree's branch"); const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); if ( @@ -920,6 +892,8 @@ const make = Effect.gen(function* () { ) return keep(waitForStop("settled moments ago")); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); + if (checkout._tag === "Detached" && !(yield* commitHeldByRef(worktreePath, head.commitSha))) + return keep("detached HEAD has commits no branch or tag holds"); const old = !deleted && settings.worktreeAfterDays !== null && @@ -1019,13 +993,18 @@ const make = Effect.gen(function* () { remote === null ? "no commits beyond the project branch" : "no commits beyond the default branch"; - if (!eligible && remote !== null && settings.worktreeOnMerge && thread.branch !== null) { + if ( + !eligible && + remote !== null && + settings.worktreeOnMerge && + checkout._tag === "Bound" + ) { const pullRequest = yield* gitManager.branchPullRequest( - { cwd: worktreePath, branch: thread.branch }, + { cwd: worktreePath, branch: checkout.branch }, { refresh: true }, ); eligible = storageCleanupPullRequestMerged(pullRequest, { - branch: thread.branch, + branch: checkout.branch, defaultBranch: branch, headSha: head.commitSha, integrated, @@ -1079,12 +1058,11 @@ const make = Effect.gen(function* () { if ( latestGroup.length !== group.length || latestOwner?.id !== thread.id || - latestOwner?.branch !== thread.branch || latestGroup.some((shell) => { const previous = group.find((member) => member.id === shell.id); return ( previous === undefined || - shell.branch !== thread.branch || + shell.branch !== previous.branch || !Equal.equals(shell.settledAt, previous.settledAt) || (shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) || !storageCleanupThreadIdle(shell, now, serverSettings) || @@ -1139,13 +1117,20 @@ const make = Effect.gen(function* () { const bytes = yield* measureWorktree(worktreePath); const blockedWhileMeasuring = yield* blockedSinceCheck(); if (blockedWhileMeasuring !== null) return keep(blockedWhileMeasuring); - if (yield* branchChanged(worktreePath, thread.branch)) + const observed = yield* worktreeBranches.observe(worktreePath); + if ( + checkout._tag === "Detached" + ? observed._tag !== "Detached" + : observed._tag !== "Named" || observed.branch !== checkout.branch + ) return keep("repository or branch changed since check"); if ( (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== head.commitSha ) return keep("commit changed since check"); + if (checkout._tag === "Detached" && !(yield* commitHeldByRef(worktreePath, head.commitSha))) + return keep("detached HEAD has commits no branch or tag holds"); const finalChanges = yield* localChangesReason(worktreePath, settings); if (finalChanges !== null) return keep(finalChanges); const currentSettings = yield* settingsService.getSettings; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 48f1f56b9b06e..2da8b61d9307d 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -301,6 +301,11 @@ export interface GitRemoteStatusOptions { readonly refreshUpstream?: boolean; } +export const isDefaultBranchName = (branch: string | null, defaultBranch: string | null) => + branch !== null && + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))); + export class GitVcsDriver extends Context.Service< GitVcsDriver, { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 25a757104db32..915210d9c69eb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2069,10 +2069,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const defaultBranch = yield* resolveDefaultBranchName(cwd, "origin"); - const isDefaultBranch = - branch !== null && - (branch === defaultBranch || - (defaultBranch === null && (branch === "main" || branch === "master"))); + const isDefaultBranch = GitVcsDriver.isDefaultBranchName(branch, defaultBranch); const aheadOfDefaultCount = branch && !isDefaultBranch ? upstreamRef === null @@ -2295,10 +2292,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const isDefaultBranch = - refName !== null && - (refName === defaultBranch || - (defaultBranch === null && (refName === "main" || refName === "master"))); + const isDefaultBranch = GitVcsDriver.isDefaultBranchName(refName, defaultBranch); const needsFallbackAhead = includeDivergence && !upstreamRef && refName !== null; const needsAheadOfDefault = includeDivergence && refName !== null && !isDefaultBranch; const [baseAheadCount, branchChanges] = yield* Effect.all( From f43caf314788345e21d431e19719ac448730dbe6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:50:07 -0800 Subject: [PATCH 04/19] fix(server): threads learn their branch when a run ends A thread kept its old branch name after its agent switched branches, so the pull request refresh skipped it and cleanup had to repair it later. Run finalization now syncs the worktree branch before that refresh. A sync failure only logs, so it cannot block the next turn. Co-Authored-By: Claude Fable 5.1 --- .../RunFinalizationQueue.test.ts | 2 + .../RunFinalizationService.test.ts | 170 +++++++++++++++++- .../RunFinalizationService.ts | 20 +++ .../src/orchestration-v2/runtimeLayer.ts | 19 +- .../testkit/ProviderReplayHarness.ts | 24 ++- 5 files changed, 222 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts index 004a7ea508c6b..b94d6f69274e4 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts @@ -23,6 +23,7 @@ import * as RunFinalization from "./RunFinalizationService.ts"; import * as RuntimeRequests from "./RuntimeRequestService.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitles from "./ThreadTitleRegenerationService.ts"; +import * as WorktreeBranches from "./WorktreeBranches.ts"; it.effect.each([ ["starts the next turn after a post-capture Git status failure", false], @@ -73,6 +74,7 @@ it.effect.each([ Layer.mergeAll( projections, observer, + Layer.mock(WorktreeBranches.WorktreeBranches)({}), Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 4aac21f96d29c..c9c638e2c28bb 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -4,9 +4,12 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CheckpointScopeId, + GitCommandError, MessageId, ProjectId, ProviderInstanceId, @@ -18,22 +21,27 @@ import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; +import * as ServerConfig from "../config.ts"; import * as CodeIntelligence from "../project/knowledge/CodeIntelligence.ts"; import * as ModelEvaluations from "../modelEvaluations/ModelEvaluations.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as RunFinalization from "./RunFinalizationService.ts"; +import * as WorktreeBranches from "./WorktreeBranches.ts"; it.effect("coalesces completed turns without starving later project refreshes", () => { const projectA = ProjectId.make("project-turn-refresh-a"); @@ -76,7 +84,7 @@ it.effect("coalesces completed turns without starving later project refreshes", }).pipe(Effect.provide(layer)); }); -it.effect.each(["workspace refresh", "evaluation enqueue"] as const)( +it.effect.each(["workspace refresh", "evaluation enqueue", "branch sync"] as const)( "preserves completed checkpoint finalization when %s fails", (failure) => { const threadId = ThreadId.make("thread_refresh_failure"); @@ -120,6 +128,26 @@ it.effect.each(["workspace refresh", "evaluation enqueue"] as const)( checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], checkpoints: [], }), + getThreadShell: () => + Effect.succeed({ + id: threadId, + branch: "feature", + worktreePath: "/repo", + } as OrchestrationV2ThreadShell), + }), + Layer.mock(WorktreeBranches.WorktreeBranches)({ + sync: () => + Effect.gen(function* () { + captured.push("sync"); + if (failure === "branch sync") + return yield* new GitCommandError({ + operation: "test", + command: "git", + cwd: "/repo", + detail: "Git index is locked.", + }); + return { _tag: "Bound" as const, branch: "feature" }; + }), }), Layer.succeed(RunFinalization.RunFinalizationObserver, { refresh: () => @@ -151,7 +179,7 @@ it.effect.each(["workspace refresh", "evaluation enqueue"] as const)( return Effect.gen(function* () { const service = yield* RunFinalization.RunFinalizationService; yield* service.finalize({ threadId, runId, scopeId }); - assert.deepEqual(captured, ["checkpoint", "evaluation", "refresh"]); + assert.deepEqual(captured, ["checkpoint", "evaluation", "sync", "refresh"]); }).pipe(Effect.provide(layer)); }, ); @@ -239,6 +267,143 @@ it.effect.each( }).pipe(Effect.provide(layer)); }); +it.live.each( + ( + [ + { + label: "rebinds a thread that ended on a new branch before its pull request refresh", + checkout: ["switch", "-c", "agent/work"], + worktreePath: "worktree", + refName: "agent/work", + branch: "agent/work", + lookups: 1, + }, + { + label: "leaves a thread that ended on a detached HEAD alone", + checkout: ["switch", "--detach"], + worktreePath: "worktree", + refName: null, + branch: "feature", + lookups: 0, + }, + { + label: "leaves a thread that ended on the default branch alone", + checkout: ["switch", "main"], + worktreePath: "worktree", + refName: "main", + branch: "feature", + lookups: 0, + }, + { + label: "leaves a thread working in the project root alone", + checkout: ["switch", "-c", "agent/work"], + worktreePath: null, + refName: "agent/work", + branch: "feature", + lookups: 0, + }, + ] as const + ).map((scenario) => [scenario.label, scenario] as const), +)("%s", ([, scenario]) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-branch-sync"); + const runId = RunId.make("completed-run"); + const scopeId = CheckpointScopeId.make("scope-branch-sync"); + const lookedUp = yield* Deferred.make(); + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const root = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-branch-sync-" }), + ); + const repo = `${root}/repo`; + const worktree = `${root}/feature`; + yield* fs.makeDirectory(repo); + const command = (cwd: string, args: ReadonlyArray) => + git.execute({ operation: "run-finalization-test", cwd, args }); + yield* command(repo, ["init", "-b", "main"]); + yield* command(repo, ["config", "user.email", "finalize@example.test"]); + yield* command(repo, ["config", "user.name", "Finalize test"]); + yield* command(repo, ["commit", "--allow-empty", "-m", "initial"]); + yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); + if (scenario.refName === "main") yield* command(repo, ["switch", "--detach"]); + yield* command(worktree, scenario.checkout); + + let thread = { + id: threadId, + branch: "feature", + worktreePath: scenario.worktreePath === null ? null : worktree, + activeRunId: null, + archivedAt: null, + createdAt: DateTime.makeUnsafe(0), + } as OrchestrationV2ThreadShell; + let lookups = 0; + const projections = Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getCheckpointContext: () => + Effect.succeed({ + runs: [], + checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: worktree }], + checkpoints: [], + }), + getThreadShell: () => Effect.sync(() => thread), + }); + const layer = RunFinalization.layer.pipe( + Layer.provideMerge(RunFinalization.layerObserver), + Layer.provide(WorktreeBranches.layer), + Layer.provide( + Layer.mergeAll( + projections, + Layer.mock(ModelEvaluations.ModelEvaluations)({}), + Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.void }), + Layer.mock(Orchestrator.OrchestratorV2)({ + getShellSnapshot: () => + Effect.sync(() => ({ threads: [thread], archivedThreads: [] })) as never, + dispatch: ((command: { readonly branch: string }) => + Effect.sync(() => { + thread = { ...thread, branch: command.branch }; + })) as never, + }), + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), + Layer.mock(CodeIntelligence.CodeIntelligence)({ refreshIfIndexed: () => Effect.void }), + Layer.mock(PullRequestService.PullRequestService)({ + refreshAfterTurn: () => Effect.void, + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshLocalStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: scenario.refName === "main", + refName: scenario.refName, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + refreshPullRequestStatus: () => + Effect.suspend(() => { + lookups++; + return Deferred.succeed(lookedUp, undefined).pipe(Effect.as(null)); + }), + }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* RunFinalization.RunFinalizationService; + yield* service.finalize({ threadId, runId, scopeId }); + if (scenario.lookups > 0) yield* Deferred.await(lookedUp); + assert.equal(thread.branch, scenario.branch); + assert.equal(lookups, scenario.lookups); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provide( + GitVcsDriver.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-branch-sync-" })), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + Effect.scoped, + ), +); + it.effect("finalizes a run without waiting for the code index refresh its checkpoint started", () => Effect.acquireUseRelease( Effect.promise(async () => { @@ -263,6 +428,7 @@ it.effect("finalizes a run without waiting for the code index refresh its checkp Layer.provide( Layer.mergeAll( Layer.mock(ModelEvaluations.ModelEvaluations)({}), + Layer.mock(WorktreeBranches.WorktreeBranches)({}), Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.void, }), diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 05f1611d0dfe6..488eab8ff2aca 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -13,6 +13,7 @@ import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as ModelEvaluations from "../modelEvaluations/ModelEvaluations.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as WorktreeBranches from "./WorktreeBranches.ts"; export class RunFinalizationError extends Schema.TaggedError()( "RunFinalizationError", @@ -57,6 +58,7 @@ const make = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const observer = yield* RunFinalizationObserver; const modelEvaluations = yield* ModelEvaluations.ModelEvaluations; + const worktreeBranches = yield* WorktreeBranches.WorktreeBranches; const finalize: RunFinalizationService["Service"]["finalize"] = Effect.fn( "RunFinalizationService.finalize", @@ -94,6 +96,24 @@ const make = Effect.gen(function* () { ); } if (cwd !== undefined) { + yield* projections.getThreadShell(input.threadId).pipe( + Effect.flatMap((thread) => + // An exact match excludes project-root threads and nested-repo checkpoint scopes. + thread?.worktreePath === cwd + ? worktreeBranches.sync({ + worktreePath: cwd, + recorded: [thread.branch], + runId: input.runId, + }) + : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to sync worktree branch after checkpoint capture", { + ...input, + cause, + }), + ), + ); // Retrying a display refresh after the checkpoint is durable blocks the next turn. yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId }).pipe( Effect.catchTags({ diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 3a9813dc43ddf..49f92f0816c44 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -57,6 +57,7 @@ import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ThreadLaunchService from "./ThreadLaunchService.ts"; import * as ThreadForkService from "./ThreadForkService.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; +import * as WorktreeBranches from "./WorktreeBranches.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; @@ -184,10 +185,6 @@ const layerCheckpointCaptureServiceProvided = CheckpointCaptureService.layer.pip ), ), ); -const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( - Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ProjectionStore.layer)), -); - const layerOrchestratorProvided = Orchestrator.layer.pipe( Layer.provide( Layer.mergeAll( @@ -211,6 +208,20 @@ const layerOrchestratorProvided = Orchestrator.layer.pipe( ), ); +const layerWorktreeBranchesProvided = WorktreeBranches.layer.pipe( + Layer.provide(Layer.merge(layerOrchestratorProvided, GitVcsDriver.layer)), +); + +const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerCheckpointCaptureServiceProvided, + ProjectionStore.layer, + layerWorktreeBranchesProvided, + ), + ), +); + // The usage-limit hook dispatches into the orchestrator, so run execution is // built after it; the orchestrator itself never calls run execution. const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 6edab10ce6b6b..a9b452ae3856d 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -61,6 +61,7 @@ import * as McpAppModelContext from "../../mcpApps/McpAppModelContext.ts"; import * as PreviewAgentActivity from "../../preview/PreviewAgentActivity.ts"; import * as RunExecutionService from "../RunExecutionService.ts"; import * as RunFinalizationService from "../RunFinalizationService.ts"; +import * as WorktreeBranches from "../WorktreeBranches.ts"; import * as ThreadTitleRegenerationService from "../ThreadTitleRegenerationService.ts"; import * as RuntimePolicy from "../RuntimePolicy.ts"; import * as TurnItemPositionStore from "../TurnItemPositionStore.ts"; @@ -349,6 +350,10 @@ export function layerWithRegistry( const layerCommandReceiptStoreProvided = CommandReceiptStore.layer.pipe( Layer.provide(layerDatabase), ); + const layerGitVcsDriver = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( @@ -359,10 +364,7 @@ export function layerWithRegistry( layerEventSinkProvided, IdAllocator.layer, ThreadCommandExecutor.layer, - GitVcsDriver.layer.pipe( - Layer.provide(layerServerConfig), - Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), - ), + layerGitVcsDriver, ), ), ); @@ -447,9 +449,6 @@ export function layerWithRegistry( ), ), ); - const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( - Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, layerStores)), - ); const layerThreadTitleRegenerationTest = Layer.succeed( ThreadTitleRegenerationService.ThreadTitleRegenerationService, ThreadTitleRegenerationService.ThreadTitleRegenerationService.of({ @@ -476,6 +475,17 @@ export function layerWithRegistry( ), ), ); + const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerCheckpointCaptureServiceProvided, + layerStores, + WorktreeBranches.layer.pipe( + Layer.provide(Layer.merge(layerOrchestratorProvided, layerGitVcsDriver)), + ), + ), + ), + ); const { poolFailoverDispatch } = options; const layerFailoverOrchestrator = poolFailoverDispatch === undefined From 3333f67c6044308992ff7d341219e691444dc55a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:55:47 -0800 Subject: [PATCH 05/19] refactor(server): launch rebinds threads through worktree branch sync Launch carried its own copy of the loop that writes a branch to every thread on a worktree. It now calls WorktreeBranches.sync after the rename, so one path writes thread.branch for a worktree. Co-Authored-By: Claude Fable 5.1 --- .../ThreadLaunchService.test.ts | 101 +++++++++++++++++- .../orchestration-v2/ThreadLaunchService.ts | 56 ++-------- .../src/orchestration-v2/runtimeLayer.ts | 1 + 3 files changed, 107 insertions(+), 51 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index ac84ee6139449..8dd6e96bcc695 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -41,6 +41,7 @@ import * as Stream from "effect/Stream"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/process"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -64,6 +65,7 @@ import * as ThreadLaunch from "./ThreadLaunchService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; +import * as WorktreeBranches from "./WorktreeBranches.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -158,8 +160,46 @@ function makeHarness(options: HarnessOptions = {}) { worktree: { path: "/repo-worktrees/feature", refName: input.newRefName, headSha: "abc" }, } as never)), ); - const renameBranch = vi.fn( - options.renameBranch ?? ((input) => Effect.succeed({ branch: input.newBranch })), + let checkedOutBranch = ""; + const renameBranch = vi.fn((input) => + (options.renameBranch ?? ((request) => Effect.succeed({ branch: request.newBranch })))( + input, + ).pipe( + Effect.tap((renamed) => + Effect.sync(() => { + checkedOutBranch = renamed.branch; + }), + ), + ), + ); + const branchSynced = Deferred.makeUnsafe(); + const layerWorktreeBranches = Layer.effect( + WorktreeBranches.WorktreeBranches, + Effect.map(WorktreeBranches.WorktreeBranches, (branches) => ({ + ...branches, + sync: (input: Parameters[0]) => + branches.sync(input).pipe(Effect.ensuring(Deferred.succeed(branchSynced, undefined))), + })), + ).pipe( + // Fresh: memoization would otherwise reuse the replay harness's copy, which runs real Git. + Layer.provide(Layer.fresh(WorktreeBranches.layer)), + Layer.provide(layerOrchestrator), + Layer.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: (input) => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: + input.args[0] === "symbolic-ref" ? `refs/heads/${checkedOutBranch}\n` : "index\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + resolveDefaultBranchName: () => Effect.succeed("main"), + }), + ), + Layer.provide(NodeServices.layer), ); const removeWorktree = vi.fn( (_input: Parameters[0]) => @@ -238,6 +278,7 @@ function makeHarness(options: HarnessOptions = {}) { layerExternalServices, layerThreadManagement, layerReceipts, + layerWorktreeBranches, IdAllocator.layer, ), ), @@ -289,6 +330,7 @@ function makeHarness(options: HarnessOptions = {}) { removeWorktree, deleteLocalBranch, renameBranch, + branchSynced, generateBranchName, generateThreadTitle, runSetup, @@ -2256,6 +2298,61 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); }); +it.effect("rebinds every thread sharing the worktree after the generated branch rename", () => + Effect.gen(function* () { + const naming = yield* Deferred.make(); + const allowNaming = yield* Deferred.make(); + const childRecorded = yield* Deferred.make(); + const childThread = ThreadId.make("thread:rename-child"); + const harness = makeHarness({ + runSetup: (input) => + (input.threadId === childThread + ? Deferred.succeed(childRecorded, undefined) + : Effect.void + ).pipe(Effect.as({ status: "no-script" as const })), + generateBranchName: () => + Deferred.succeed(naming, undefined).pipe( + Effect.andThen(Deferred.await(allowNaming)), + Effect.as({ branch: "generated-branch" }), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const parent = yield* launches.launch( + launchInput({ + command: "launch:rename-parent", + thread: "thread:rename-parent", + message: "Start", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* Deferred.await(naming); + const { branch: temporaryBranch, worktreePath } = (yield* threads.getThreadProjection( + parent.threadId, + )).thread; + assert.isNotNull(temporaryBranch); + assert.isNotNull(worktreePath); + const child = yield* launches.launch( + launchInput({ + command: "launch:rename-child", + thread: childThread, + workspace: { type: "existing_worktree", worktreePath, branch: temporaryBranch }, + }), + ); + yield* Deferred.await(childRecorded); + yield* Deferred.succeed(allowNaming, undefined); + yield* Deferred.await(harness.branchSynced); + for (const threadId of [parent.threadId, child.threadId]) { + assert.equal( + (yield* threads.getThreadProjection(threadId)).thread.branch, + "generated-branch", + ); + } + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("cancels tracked setup before provider work is released", () => Effect.gen(function* () { const entered = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 126f65de949f7..9184a9464e1cb 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -55,6 +55,7 @@ import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; import { DispatchModeLimit } from "./DispatchModeLimit.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; +import * as WorktreeBranches from "./WorktreeBranches.ts"; export type ThreadLaunchWorkspaceStrategy = | { readonly type: "root"; readonly branch?: string | undefined } @@ -216,6 +217,7 @@ const make = Effect.gen(function* () { const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; const ids = yield* IdAllocator.IdAllocatorV2; const threads = yield* ThreadManagement.ThreadManagementService; + const worktreeBranches = yield* WorktreeBranches.WorktreeBranches; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; const preparationScope = yield* Scope.make("sequential"); const scheduledLaunches = yield* Ref.make>(new Set()); @@ -509,55 +511,11 @@ const make = Effect.gen(function* () { Effect.uninterruptible, ), ), - Effect.flatMap((renamed) => - Effect.gen(function* () { - yield* threads - .dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`${input.commandId}:branch-rename`), - threadId, - branch: renamed.branch, - worktreePath: worktreeCwd, - expectedBranch: oldBranch, - expectedWorktreePath: worktreeCwd, - }) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning("Parent thread worktree branch update failed", { - threadId, - cause, - }), - ), - ); - // Update parents first so children inherit names; reread each pass for newly updated descendants. - const visited = new Set([threadId]); - while (true) { - const snapshot = yield* threads.getShellSnapshot({ worktreePath: worktreeCwd }); - const sharing = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => !visited.has(thread.id) && thread.branch === oldBranch, - ); - if (sharing.length === 0) break; - for (const thread of sharing) { - visited.add(thread.id); - yield* threads - .dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`${input.commandId}:branch-rename:${thread.id}`), - threadId: thread.id, - branch: renamed.branch, - expectedBranch: oldBranch, - expectedWorktreePath: worktreeCwd, - }) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning("Shared thread worktree branch update failed", { - threadId: thread.id, - cause, - }), - ), - ); - } - } + Effect.flatMap(() => + worktreeBranches.sync({ + worktreePath: worktreeCwd, + recorded: [oldBranch], + ...(runId === null ? {} : { runId }), }), ), Effect.catchCause((cause) => diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 49f92f0816c44..18a025b665fcd 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -295,6 +295,7 @@ const layerThreadLaunchProvided = ThreadLaunchService.layer.pipe( layerManagedProjectFoldersProvided, layerThreadManagementProvided, layerCommandReceiptStoreProvided, + layerWorktreeBranchesProvided, IdAllocator.layer, ), ), From a0c3d6466ac095239f439db3c893b3a4bf43c195 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:57:15 -0800 Subject: [PATCH 06/19] test(server): a rebound thread recreates on its new branch Pins the resume half of the cleanup change. Once a thread is rebound and its worktree is removed, the next turn recreates the checkout on the branch the agent switched to, at that commit, and not on the stale name. Co-Authored-By: Claude Fable 5.1 --- .../ProviderTurnStartService.test.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 483644d16353f..3dc01bd398c2d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -295,6 +295,7 @@ function makeLocalCommandHarness(input: { readonly resolveKnowledge?: ProjectKnowledge.ProjectKnowledge["Service"]["resolveForTurn"]; readonly readSentDeliveries?: ProjectKnowledge.ProjectKnowledge["Service"]["readSentDeliveries"]; readonly worktreePath?: string; + readonly branch?: string; readonly worktreesDir?: string; readonly workspaceRoot?: string; readonly gitWorkflow?: GitWorkflow.GitWorkflowService["Service"]; @@ -395,7 +396,7 @@ function makeLocalCommandHarness(input: { id: threadId, activeProviderThreadId: providerThreadId, projectId: ProjectId.make("project-native-account-command"), - branch: input.worktreePath === undefined ? null : "feature/restore", + branch: input.worktreePath === undefined ? null : (input.branch ?? "feature/restore"), worktreePath: input.worktreePath ?? null, lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, } as OrchestrationV2ThreadProjection["thread"], @@ -1316,6 +1317,53 @@ effectIt.effect( }).pipe(Effect.scoped, Effect.provide(layerRealGit)), ); +effectIt.effect("recreates a rebound thread on the branch its agent switched to", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const gitWorkflow = yield* GitWorkflow.GitWorkflowService; + const driver = yield* GitVcsDriver.GitVcsDriver; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-recreate-rebound-" }); + const workspaceRoot = path.join(root, "project"); + const worktreesDir = path.join(root, "worktrees"); + const worktreePath = path.join(worktreesDir, "project", "restore"); + yield* fileSystem.makeDirectory(workspaceRoot); + const git = (cwd: string, args: ReadonlyArray) => + driver + .execute({ operation: "test.worktreeRebound", cwd, args }) + .pipe(Effect.map((result) => result.stdout.trim())); + yield* git(workspaceRoot, ["init", "-b", "main"]); + yield* git(workspaceRoot, ["config", "user.name", "Test"]); + yield* git(workspaceRoot, ["config", "user.email", "test@example.com"]); + yield* git(workspaceRoot, ["commit", "--allow-empty", "-m", "base"]); + yield* git(workspaceRoot, ["worktree", "add", "-b", "feature/restore", worktreePath]); + yield* git(worktreePath, ["switch", "-c", "agent/work"]); + yield* git(worktreePath, ["commit", "--allow-empty", "-m", "agent work"]); + const agentTip = yield* git(worktreePath, ["rev-parse", "HEAD"]); + yield* fileSystem.remove(worktreePath, { recursive: true }); + const harness = makeLocalCommandHarness({ + text: "Continue", + branch: "agent/work", + workspaceRoot, + worktreePath, + worktreesDir, + fileSystem, + gitWorkflow, + failReadsAfterRunning: true, + }); + const open = harness.open.getMockImplementation()!; + harness.open.mockImplementation(() => + Effect.gen(function* () { + expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("agent/work"); + expect(yield* git(worktreePath, ["rev-parse", "HEAD"])).toBe(agentTip); + return yield* open(); + }).pipe(Effect.orDie), + ); + yield* harness.start; + expect(harness.open).toHaveBeenCalledOnce(); + }).pipe(Effect.scoped, Effect.provide(layerRealGit)), +); + effectIt.effect( "preserves recreation interrupts and retries, then fails the final attempt before provider start", () => From 43bae8c0da782f470e6bd5dd834b5d2fa01e4ed5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:59:47 -0800 Subject: [PATCH 07/19] test(server): cleanup removes a deleted owner's worktree after a rebind A deleted owner's record cannot be rewritten, so only its surviving subagents are rebound. This pins that the group comparison still lets removal proceed on the next pass, and that the branch ref survives. Co-Authored-By: Claude Fable 5.1 --- .../orchestration-v2/WorktreeBranches.test.ts | 3 +-- apps/server/src/storageCleanup.test.ts | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts index c8ca2652aa069..199301de35995 100644 --- a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts +++ b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts @@ -314,7 +314,7 @@ it.live("picks up a child created during the write when it rereads", () => it.live("terminates when every write is rejected", () => run( Effect.gen(function* () { - const { service, command, worktree, snapshotReads, setThreads, onDispatch } = yield* fixture; + const { service, command, worktree, setThreads, onDispatch } = yield* fixture; yield* command(worktree, ["switch", "-c", "agent/work"]); setThreads([member("parent", "feature", worktree)]); onDispatch(() => Effect.die("rejected")); @@ -322,7 +322,6 @@ it.live("terminates when every write is rejected", () => (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, "Rebinding", ); - assert.equal(snapshotReads(), 2); }), ), ); diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 37c10eac65893..4f6d9502e1c22 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1203,6 +1203,32 @@ describe("deleted thread worktrees", () => { ...overrides, }); + it.live("removes a deleted owner's worktree after rebinding its surviving subagent", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([subagent(owner)]); + yield* fixture.command(fixture.worktree, ["switch", "-c", "agent/work"]); + expect((yield* fixture.service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Branch updated, re-checked next pass", + }); + expect(fixture.threads()[0]?.branch).toBe("agent/work"); + expect((yield* fixture.service.runNow).entries[0]).toMatchObject({ + outcome: "removed", + reason: "Thread was deleted", + }); + yield* fixture.command(fixture.repo, ["rev-parse", "--verify", "refs/heads/agent/work"]); + }), + ), + ); + it.effect.each(["no subagents", "native subagent"] as const)( "removes a deleted thread's worktree with %s", (scenario) => From 2bb2e5bab05cb8df4a52bad61132547e03fe6ea0 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Thu, 5 Nov 2026 07:32:57 +0000 Subject: [PATCH 08/19] fix(server): harden code indexes on Windows Avoid defensive SQLite lock journals, share checkout identity across path casing, and reject UNC checkout junctions instead of publishing partial indexes. Model: Devin built by Cognition AI`nHarness: Devin Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../knowledge/CodeIndexReactor.test.ts | 35 ++++++++++ .../src/project/knowledge/CodeIndexReactor.ts | 26 ++++++-- .../knowledge/CodeIntelligence.test.ts | 66 ++++++++++++++++++- .../src/project/knowledge/CodeIntelligence.ts | 17 +++-- .../src/project/knowledge/codeIndexLayout.ts | 15 ++++- .../project/knowledge/codeIndexLocks.test.ts | 52 ++++++++++++++- .../src/project/knowledge/codeIndexLocks.ts | 6 +- 7 files changed, 199 insertions(+), 18 deletions(-) diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts index 1a7b3bd18af36..73d32ec0338d5 100644 --- a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts +++ b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts @@ -10,6 +10,7 @@ import { type OrchestrationV2ThreadShell, type ServerSettings, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -462,6 +463,40 @@ describe("CodeIndexReactor", () => { }), ); + it.effect("keeps known Windows checkouts through drive-letter and case differences", (context) => + Effect.gen(function* () { + if (!(yield* HostProcess.isWindows)) return context.skip(); + const harness = yield* reactorHarness({ + threads: [shell({ worktreePath: "c:\\worktrees\\Feature" })], + projectRoots: ["c:\\projects\\App"], + autoCodeIndexEnabled: false, + orphanOwners: ["C:\\PROJECTS\\APP", "C:\\WORKTREES\\FEATURE"], + }); + assert.deepEqual(yield* harness.orphansRejected, []); + }), + ); + + it.effect( + "keeps a settled Windows worktree while a differently cased thread is unsettled", + (context) => + Effect.gen(function* () { + if (!(yield* HostProcess.isWindows)) return context.skip(); + const settled = shell({ + worktreePath: "c:\\worktrees\\Feature", + settledOverride: "settled", + settledAt: at(-60_000), + }); + const harness = yield* reactorHarness({ + threads: [ + settled, + shell({ id: ThreadId.make("thread-2"), worktreePath: "C:\\WORKTREES\\FEATURE" }), + ], + }); + yield* harness.send(settledEvent(settled)); + assert.deepEqual(yield* harness.effectsBeforeSentinel, []); + }), + ); + it.effect("stops running builds when automatic indexing is turned off", () => Effect.gen(function* () { const harness = yield* reactorHarness({ threads: [shell()] }); diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.ts b/apps/server/src/project/knowledge/CodeIndexReactor.ts index e283678b2cb5f..d6c8110746f21 100644 --- a/apps/server/src/project/knowledge/CodeIndexReactor.ts +++ b/apps/server/src/project/knowledge/CodeIndexReactor.ts @@ -23,6 +23,7 @@ import { worktreeSettlementChain, } from "../../storageCleanup.ts"; import * as CodeIntelligence from "./CodeIntelligence.ts"; +import { checkoutPathKey } from "./codeIndexLayout.ts"; const SWEEP_INTERVAL = "1 hour"; @@ -74,7 +75,9 @@ const make = Effect.gen(function* () { const target = path.resolve(worktreePath); const entries = yield* projections.getThreadWorktreePaths({ includeArchived: true }); const group = (yield* Effect.forEach( - entries.filter((entry) => path.resolve(entry.worktreePath) === target), + entries.filter( + (entry) => checkoutPathKey(path.resolve(entry.worktreePath)) === checkoutPathKey(target), + ), (entry) => projections.getThreadShell(entry.threadId), )).filter((thread) => thread !== null); const threadsById = new Map(group.map((thread) => [thread.id, thread])); @@ -94,7 +97,13 @@ const make = Effect.gen(function* () { if (!worktreeIndexReleasable(group, now, settings, threadsById)) return; // A worktree registered as its own project keeps its index. const projects = yield* projectStore.listShells(); - if (projects.some((project) => path.resolve(project.workspaceRoot) === target)) return; + if ( + projects.some( + (project) => + checkoutPathKey(path.resolve(project.workspaceRoot)) === checkoutPathKey(target), + ) + ) + return; yield* codeIntelligence.removeCheckoutIndex(target); }); @@ -106,17 +115,22 @@ const make = Effect.gen(function* () { [ ...projects.map((project) => project.workspaceRoot), ...entries.map((e) => e.worktreePath), - ].map((knownPath) => path.resolve(knownPath)), + ].map((knownPath) => checkoutPathKey(path.resolve(knownPath))), ); // Even with automatic indexing off: this only cleans T3's own data for things that are gone. yield* codeIntelligence.removeOrphanedIndexes((checkoutPath) => - known.has(path.resolve(checkoutPath)), + known.has(checkoutPathKey(path.resolve(checkoutPath))), ); if (!(yield* autoIndexEnabled)) return; // De-duplicated first: hundreds of threads share a path, and each check hits the disk. - const candidates = [...new Set(entries.map((entry) => path.resolve(entry.worktreePath)))]; + const candidates = new Map( + entries.map((entry) => { + const resolved = path.resolve(entry.worktreePath); + return [checkoutPathKey(resolved), resolved]; + }), + ); // Sequentially: each release waits on the checkout's build lock. - for (const worktreePath of candidates) { + for (const worktreePath of candidates.values()) { if (!(yield* codeIntelligence.hasCheckoutIndex(worktreePath))) continue; yield* releaseWorktreeIndex(worktreePath).pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/project/knowledge/CodeIntelligence.test.ts b/apps/server/src/project/knowledge/CodeIntelligence.test.ts index aa5b74e3a6cd1..fea48739258f3 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.test.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.test.ts @@ -21,6 +21,7 @@ import { type ProjectCodeIndexEvent, type VcsListWorkspaceFilesResult, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -45,7 +46,7 @@ import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; import { withWorkspaceLease } from "../../workspace/workspaceLease.ts"; import * as CodeIntelligence from "./CodeIntelligence.ts"; -import { indexRootFor } from "./codeIndexLayout.ts"; +import { indexRootFor, linkCheckoutSources } from "./codeIndexLayout.ts"; import { pinPublishedGeneration } from "./codeIndexLocks.ts"; import { rankListing } from "./codeIntelligenceWorker.ts"; import * as FileHistory from "./FileHistory.ts"; @@ -1353,6 +1354,69 @@ it.effect("indexes only when something asks while automatic indexing is off", () ), ); +it.effect("rejects Windows network checkouts before creating partial source links", (context) => + withFixture((fixture) => + Effect.gen(function* () { + if (!(yield* HostProcess.isWindows)) return context.skip(); + for (const checkout of [ + "\\\\server\\share\\repo", + "//server/share/repo", + "\\\\?\\UNC\\server\\share\\repo", + ]) { + assert.throws( + () => linkCheckoutSources(fixture.worktree, checkout, ["src/defs.ts"]), + /network checkout/, + ); + } + assert.deepEqual(NodeFS.readdirSync(fixture.worktree), ["src"]); + }), + ), +); + +it.effect("shares one index between Windows project and thread path case variants", (context) => + withFixture((fixture) => + Effect.gen(function* () { + if (!(yield* HostProcess.isWindows)) return context.skip(); + const { refresh, indexRoot, codeIndexDir } = yield* checkoutService( + fixture.root.toLowerCase(), + () => ["src/defs.ts"], + { + worktree: { + threadId: worktreeThreadId, + path: fixture.root.toUpperCase().replaceAll("\\", "/"), + }, + }, + ); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.deepEqual(yield* refresh(worktreeThreadId), { _tag: "Unchanged" }); + assert.equal(indexRoot(fixture.root.toLowerCase()), indexRoot(fixture.root.toUpperCase())); + assert.lengthOf(NodeFS.readdirSync(codeIndexDir), 1); + }), + ), +); + +it.effect("stops a Windows build removed through a different path case", (context) => + withFixture((fixture) => + Effect.gen(function* () { + if (!(yield* HostProcess.isWindows)) return context.skip(); + const gate = buildGate(); + const { refresh, removeCheckoutIndex, indexRoot } = yield* checkoutService( + fixture.root.toLowerCase(), + () => ["src/defs.ts"], + gate, + ); + const held = yield* gate.holdNext; + const building = yield* refresh().pipe(Effect.forkScoped); + const pid = yield* Deferred.await(held.entered); + yield* removeCheckoutIndex(fixture.root.toUpperCase()); + assert.isFalse(isProcessAlive(pid)); + yield* Fiber.await(building); + assertClearedIndex(indexRoot(fixture.root.toLowerCase())); + assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.root, "src/defs.ts"))); + }), + ), +); + it.effect("removes a checkout's index while a build is in flight", () => withFixture((fixture) => Effect.gen(function* () { diff --git a/apps/server/src/project/knowledge/CodeIntelligence.ts b/apps/server/src/project/knowledge/CodeIntelligence.ts index 9e3379686547b..41a345dbef8b4 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.ts @@ -38,6 +38,7 @@ import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; import { INDEX_DIRECTORY, + checkoutPathKey, generationDirectory, indexRootFor, isIndexPath, @@ -464,7 +465,7 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig const serviceScope = yield* Effect.scope; const checkouts = new Map(); const stateOf = (checkoutPath: string) => { - const existing = checkouts.get(checkoutPath); + const existing = checkouts.get(checkoutPathKey(checkoutPath)); if (existing !== undefined) return existing; const created: CheckoutIndex = { running: null, @@ -473,7 +474,7 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig fiber: null, firstBuildAnnounced: false, }; - checkouts.set(checkoutPath, created); + checkouts.set(checkoutPathKey(checkoutPath), created); return created; }; // Off, only a code query indexes a checkout; every proactive path reads this per call. @@ -801,7 +802,7 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig }, }).pipe(Effect.mapError((cause) => toQueryError(cause.reason))); if (!("result" in reply)) return yield* toQueryError("invalid-result"); - const state = checkouts.get(checkoutPath); + const state = checkouts.get(checkoutPathKey(checkoutPath)); const needsRefresh = reply.result.index?.state === "unindexed" || reply.result.index?.state === "indexing" || @@ -850,7 +851,7 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig if (!(yield* autoIndexEnabled)) return; const checkoutPath = yield* resolveCheckout({ projectId }).pipe(Effect.option); if (Option.isNone(checkoutPath)) return; - const state = checkouts.get(checkoutPath.value); + const state = checkouts.get(checkoutPathKey(checkoutPath.value)); // A failed build waits for a code query; retrying here would repeat a persistent failure each // time a client looked at the project. if (state?.running != null || state?.lastFailed === true) return; @@ -900,11 +901,11 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig const path = yield* workspacePaths .normalizeWorkspaceRoot(checkoutPath) .pipe(Effect.orElseSucceed(() => NodePath.resolve(checkoutPath))); - const state = checkouts.get(path); + const state = checkouts.get(checkoutPathKey(path)); const hadBuild = state?.fiber != null && state.running !== null; // The interrupt returns only after the build process is killed and its lock released. if (state?.fiber != null) yield* Fiber.interrupt(state.fiber); - checkouts.delete(path); + checkouts.delete(checkoutPathKey(path)); const hadFiles = yield* Effect.sync( () => listGenerations(indexRootOf(path)).length > 0 || hasLegacyIndexFiles(path), ); @@ -988,7 +989,9 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig const subscription = yield* PubSub.subscribe(indexChanges); yield* ensureProjectIndexed(input.projectId); return Stream.fromSubscription(subscription).pipe( - Stream.filter((change) => change.checkoutPath === checkoutPath), + Stream.filter( + (change) => checkoutPathKey(change.checkoutPath) === checkoutPathKey(checkoutPath), + ), Stream.map((change) => change.event), ); }); diff --git a/apps/server/src/project/knowledge/codeIndexLayout.ts b/apps/server/src/project/knowledge/codeIndexLayout.ts index 0890826be1f04..1eeb2b5eae222 100644 --- a/apps/server/src/project/knowledge/codeIndexLayout.ts +++ b/apps/server/src/project/knowledge/codeIndexLayout.ts @@ -31,10 +31,18 @@ export function isIndexPath(relativePath: string): boolean { /** The index root one checkout's index lives under. */ export function indexRootFor(codeIndexDir: string, checkoutPath: string): string { - const digest = NodeCrypto.createHash("sha256").update(checkoutPath).digest("hex"); + const digest = NodeCrypto.createHash("sha256") + .update(checkoutPathKey(checkoutPath)) + .digest("hex"); return NodePath.join(codeIndexDir, digest.slice(0, 32)); } +/** Identity only: keep the original path when opening files or recording the checkout owner. */ +export function checkoutPathKey(checkoutPath: string): string { + const resolved = NodePath.resolve(checkoutPath); + return NodePath.sep === "\\" ? resolved.toLowerCase() : resolved; +} + /** Generation directories present in the index root, whatever their state. */ export function listGenerations(indexRoot: string): number[] { let names: string[]; @@ -91,6 +99,11 @@ export function linkCheckoutSources( checkoutPath: string, paths: ReadonlyArray, ): void { + if ( + NodePath.sep === "\\" && + NodePath.toNamespacedPath(checkoutPath).toUpperCase().startsWith("\\\\?\\UNC\\") + ) + throw new Error("Windows junctions cannot target a network checkout."); const names = new Set(CHECKOUT_CONFIG_ENTRIES); for (const path of paths) names.add(path.split("/", 1)[0]!); for (const name of names) { diff --git a/apps/server/src/project/knowledge/codeIndexLocks.test.ts b/apps/server/src/project/knowledge/codeIndexLocks.test.ts index ee4562ad384ea..2b4d8a4723990 100644 --- a/apps/server/src/project/knowledge/codeIndexLocks.test.ts +++ b/apps/server/src/project/knowledge/codeIndexLocks.test.ts @@ -8,11 +8,61 @@ import { expect, it } from "vite-plus/test"; import { hasLegacyIndexFiles, + pinPublishedGeneration, removeIndexRoot, removeLegacyIndexFromCheckout, tryLockBuild, } from "./codeIndexLocks.ts"; -import { publishGeneration, readPublishedGeneration } from "./codeIndexLayout.ts"; +import { + linkCheckoutSources, + publishGeneration, + readPublishedGeneration, + writeIndexedCheckout, +} from "./codeIndexLayout.ts"; + +it("removes checkout links while preserving a pinned generation and its source files", () => { + const temporary = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-index-cleanup-")); + const checkout = NodePath.join(temporary, "checkout"); + const root = NodePath.join(temporary, "index"); + let pin: ReturnType | undefined; + try { + NodeFS.mkdirSync(NodePath.join(checkout, "src"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(checkout, "src/main.ts"), "export const main = 1;"); + NodeFS.writeFileSync(NodePath.join(checkout, "top.ts"), "export const top = 1;"); + writeIndexedCheckout(root, checkout); + linkCheckoutSources(root, checkout, ["src/main.ts", "top.ts"]); + publishGeneration(root, 1); + const generation = NodePath.join(root, ".codegraph-t3-g1"); + NodeFS.mkdirSync(generation); + NodeFS.writeFileSync(NodePath.join(generation, "codegraph.db"), "index payload"); + pin = pinPublishedGeneration(root); + + removeIndexRoot(root); + + expect(readPublishedGeneration(root)).toBeNull(); + expect(NodeFS.existsSync(generation)).toBe(true); + expect(NodeFS.existsSync(NodePath.join(root, "src"))).toBe(false); + expect(NodeFS.existsSync(NodePath.join(root, "top.ts"))).toBe(false); + expect(NodeFS.readFileSync(NodePath.join(checkout, "src/main.ts"), "utf8")).toBe( + "export const main = 1;", + ); + expect(NodeFS.readFileSync(NodePath.join(checkout, "top.ts"), "utf8")).toBe( + "export const top = 1;", + ); + + pin.release(); + pin = undefined; + removeIndexRoot(root); + expect(NodeFS.existsSync(generation)).toBe(false); + expect(NodeFS.readdirSync(NodePath.join(root, ".codegraph-t3")).toSorted()).toEqual([ + "build.lock", + "building.lock", + ]); + } finally { + pin?.release(); + NodeFS.rmSync(temporary, { recursive: true, force: true }); + } +}); it.each([ ["external index", removeIndexRoot], diff --git a/apps/server/src/project/knowledge/codeIndexLocks.ts b/apps/server/src/project/knowledge/codeIndexLocks.ts index ba77df1fc32c4..f72054d598370 100644 --- a/apps/server/src/project/knowledge/codeIndexLocks.ts +++ b/apps/server/src/project/knowledge/codeIndexLocks.ts @@ -38,8 +38,10 @@ const sqliteCode = (cause: unknown) => function lockExclusive(path: string, waitMs: number): NodeSqlite.DatabaseSync | null { const database = new NodeSqlite.DatabaseSync(path); try { - // With no journal the lock never creates a -journal file beside it. - database.exec(`PRAGMA busy_timeout = ${waitMs}; PRAGMA journal_mode = OFF; BEGIN EXCLUSIVE;`); + // Defensive SQLite ignores OFF; MEMORY avoids an open journal that Windows cannot delete. + database.exec( + `PRAGMA busy_timeout = ${waitMs}; PRAGMA journal_mode = MEMORY; BEGIN EXCLUSIVE;`, + ); return database; } catch (cause) { database.close(); From 548561b562e10f8b3e044325795cd65453a9b627 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:30:56 -0800 Subject: [PATCH 09/19] fix(server): leave nothing of the code index behind in repos or T3's data Cleanup kept `.codegraph-t3/` in users' repos, could add new untracked lock files there, deleted any file a user had put in that folder, and left a lock-only folder per worktree in T3's data directory that every hourly sweep locked again. Old folders in a checkout that no build reached stayed forever. Removal now deletes the lock files and their folders too. So a builder cannot trust a lock on a file that was deleted before its lock landed, it compares the file's identity across taking the lock and backs off if it changed. Lock files go while held where the OS allows it, and on release where an open file cannot be deleted. Old folders lose only the files T3 and the SDK wrote, and a generation holding anything else is left alone. Startup and hourly sweeps clear them from every project and worktree T3 knows, even with automatic indexing off. Also from review: a pinned or explicitly active thread keeps a shared worktree's index, source in a language outside the SDK's catalog makes coverage partial once it is over 5% of the code, and an unreadable codegraph.json or .gitignore no longer fails builds and reads. Model: Opus 5.5 Harness: Claude Code in T3 Code Co-Authored-By: Claude Opus 5.5 --- .../knowledge/CodeIndexReactor.test.ts | 31 ++++- .../src/project/knowledge/CodeIndexReactor.ts | 18 ++- .../knowledge/CodeIntelligence.test.ts | 73 ++++++++-- .../src/project/knowledge/CodeIntelligence.ts | 29 ++-- .../project/knowledge/codeIndexLocks.test.ts | 109 ++++++++++----- .../src/project/knowledge/codeIndexLocks.ts | 127 ++++++++++++++---- .../knowledge/codeIntelligenceWorker.ts | 19 ++- docs/user/code-navigation.md | 5 +- 8 files changed, 321 insertions(+), 90 deletions(-) diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts index 73d32ec0338d5..f2f86f747cadb 100644 --- a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts +++ b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts @@ -124,6 +124,27 @@ describe("worktreeIndexReleasable", () => { group: () => [settled(), subagent()], expected: true, }, + { + name: "a settled parent with a pinned subagent", + group: () => [settled(), subagent({ pinnedAt: now })], + expected: false, + }, + { + name: "a settled parent with a side chat kept active", + group: () => [ + settled(), + subagent({ + settledOverride: "active", + lineage: { + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + relationshipToParent: null, + sideChat: true, + }, + }), + ], + expected: false, + }, { name: "a settled parent with a running subagent", group: () => [settled(), subagent({ status: "running", activeRunId: RunId.make("run-1") })], @@ -243,6 +264,8 @@ interface Harness { readonly effects: Queue.Queue; /** The owners the activation sweep's orphan pass rejected. */ readonly orphansRejected: Effect.Effect>; + /** The checkouts the activation sweep asked to clear of an older version's folders. */ + readonly legacyCheckouts: Effect.Effect>; /** Resolves once the sweep asks whether a worktree has an index. */ readonly indexChecked: Effect.Effect; readonly settings: PubSub.PubSub; @@ -268,6 +291,7 @@ function reactorHarness(options: { const events = yield* Queue.unbounded(); const effects = yield* Queue.unbounded(); const orphansRejected = yield* Deferred.make>(); + const legacyCheckouts = yield* Deferred.make>(); const indexChecked = yield* Deferred.make(); const settings = yield* PubSub.unbounded(); const threadsById = new Map(options.threads.map((thread) => [thread.id, thread])); @@ -290,6 +314,8 @@ function reactorHarness(options: { Deferred.succeed(indexChecked, undefined).pipe( Effect.as((options.indexed ?? []).includes(checkoutPath)), ), + removeLegacyIndexes: (checkoutPaths) => + Deferred.succeed(legacyCheckouts, checkoutPaths).pipe(Effect.asVoid), removeOrphanedIndexes: (isKnown) => Deferred.succeed( orphansRejected, @@ -344,6 +370,7 @@ function reactorHarness(options: { send: (event) => Queue.offer(events, event).pipe(Effect.asVoid), effects, orphansRejected: Deferred.await(orphansRejected), + legacyCheckouts: Deferred.await(legacyCheckouts), indexChecked: Deferred.await(indexChecked), settings, next: Queue.take(effects), @@ -428,7 +455,7 @@ describe("CodeIndexReactor", () => { }), ); - it.effect("removes orphaned indexes even while automatic indexing is off", () => + it.effect("removes orphaned indexes and old folders even while automatic indexing is off", () => Effect.gen(function* () { const harness = yield* reactorHarness({ threads: [shell()], @@ -437,6 +464,7 @@ describe("CodeIndexReactor", () => { }); // A known worktree is kept; the deleted checkout is the only rejection. assert.deepEqual(yield* harness.orphansRejected, ["/worktrees/gone"]); + assert.deepEqual(yield* harness.legacyCheckouts, [WORKTREE]); }), ); @@ -448,6 +476,7 @@ describe("CodeIndexReactor", () => { orphanOwners: ["/projects/one", WORKTREE], }); assert.deepEqual(yield* harness.orphansRejected, []); + assert.deepEqual(yield* harness.legacyCheckouts, ["/projects/one", WORKTREE]); }), ); diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.ts b/apps/server/src/project/knowledge/CodeIndexReactor.ts index d6c8110746f21..5750151c91521 100644 --- a/apps/server/src/project/knowledge/CodeIndexReactor.ts +++ b/apps/server/src/project/knowledge/CodeIndexReactor.ts @@ -41,10 +41,14 @@ export function worktreeIndexReleasable( ): boolean { if (group.length === 0) return false; const owner = worktreeOwner(group); - // A subagent or side-chat chain follows its owner; unrelated threads must all be settled. + // A subagent or side-chat chain follows its owner unless someone kept that thread going; + // unrelated threads must all be settled. const settled = owner !== undefined - ? worktreeSettlementChain(owner, threadsById, now, settings).length > 0 + ? worktreeSettlementChain(owner, threadsById, now, settings).length > 0 && + group.every( + (t) => t.settledAt !== null || (t.settledOverride !== "active" && t.pinnedAt == null), + ) : group.every((t) => t.settledAt !== null); return settled && group.every((t) => storageCleanupThreadIdle(t, now, settings)); } @@ -111,13 +115,17 @@ const make = Effect.gen(function* () { const entries = yield* projections.getThreadWorktreePaths({ includeArchived: true }); // `listShells` omits deleted projects, so a deleted project is not a known checkout. const projects = yield* projectStore.listShells(); - const known = new Set( + const known = new Map( [ ...projects.map((project) => project.workspaceRoot), ...entries.map((e) => e.worktreePath), - ].map((knownPath) => checkoutPathKey(path.resolve(knownPath))), + ].map((knownPath) => { + const resolved = path.resolve(knownPath); + return [checkoutPathKey(resolved), resolved]; + }), ); - // Even with automatic indexing off: this only cleans T3's own data for things that are gone. + // Even with automatic indexing off: these only clean up T3's own data. + yield* codeIntelligence.removeLegacyIndexes([...known.values()]); yield* codeIntelligence.removeOrphanedIndexes((checkoutPath) => known.has(checkoutPathKey(path.resolve(checkoutPath))), ); diff --git a/apps/server/src/project/knowledge/CodeIntelligence.test.ts b/apps/server/src/project/knowledge/CodeIntelligence.test.ts index fea48739258f3..56bf571c3881f 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.test.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.test.ts @@ -142,11 +142,7 @@ async function generationDirectories(indexRoot: string) { } function assertClearedIndex(indexRoot: string): void { - assert.deepEqual(NodeFS.readdirSync(indexRoot), [".codegraph-t3"]); - assert.deepEqual(NodeFS.readdirSync(NodePath.join(indexRoot, ".codegraph-t3")).toSorted(), [ - "build.lock", - "building.lock", - ]); + assert.isFalse(NodeFS.existsSync(indexRoot)); } /** @@ -944,6 +940,8 @@ function checkoutService( }), refreshIfIndexed: (cwd: string) => intelligence.refreshIfIndexed(cwd), removeCheckoutIndex: (checkoutPath: string) => intelligence.removeCheckoutIndex(checkoutPath), + removeLegacyIndexes: (checkoutPaths: ReadonlyArray) => + intelligence.removeLegacyIndexes(checkoutPaths), stopBuilds: intelligence.stopBuilds, }; }); @@ -1114,6 +1112,22 @@ it.effect("reports freshness coverage with the same classification as a query", const docsOnlyFreshness = yield* docsOnly.run({ operation: "freshness" }); assert.equal(docsOnlyFreshness.coverage.state, "partial"); assert.equal(docsOnlyFreshness.index?.state, "partial"); + + // Source in a language the SDK cannot read is a gap, however much else is indexed. + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(fixture.root, "worker.ex"), "defmodule Worker do\nend\n"), + ); + const mixed = yield* checkoutService(fixture.root, () => ["src/defs.ts", "worker.ex"]); + yield* mixed.refresh(); + const mixedFreshness = yield* mixed.run({ operation: "freshness" }); + assert.equal(mixedFreshness.coverage.state, "partial"); + assert.equal(mixedFreshness.index?.state, "partial"); + + // One stray script among many sources is not. + const sources = yield* Effect.promise(() => writeGeneratedSources(fixture.root, 20)); + const stray = yield* checkoutService(fixture.root, () => [...sources, "worker.ex"]); + yield* stray.refresh(); + assert.equal((yield* stray.run({ operation: "freshness" })).index?.state, "ready"); }), ), ); @@ -1767,12 +1781,11 @@ it.effect("removes the index folders an older version left in the checkout", () }); const { refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); - // Lock inodes stay stable for older servers; every source entry is still there. + // Every source entry is still there, and nothing of T3's is. assert.deepEqual( (yield* Effect.promise(() => NodeFSP.readdir(fixture.root))).toSorted(), - [...before, ".codegraph-t3"].toSorted(), + before, ); - assert.deepEqual(NodeFS.readdirSync(control).toSorted(), ["build.lock", "building.lock"]); assert.isTrue(NodeFS.lstatSync(NodePath.join(fixture.root, "alias")).isSymbolicLink()); assert.equal( yield* Effect.promise(() => @@ -1802,13 +1815,40 @@ it.effect("finds and removes an older version's folders in a checkout with no in // Settling a thread takes this path, so a worktree nothing rebuilds is still cleaned up. yield* removeCheckoutIndex(fixture.root); - assert.deepEqual(NodeFS.readdirSync(control).toSorted(), ["build.lock", "building.lock"]); + assert.isFalse(NodeFS.existsSync(control)); assert.isFalse(yield* hasCheckoutIndex(fixture.root)); assert.isTrue(NodeFS.existsSync(NodePath.join(fixture.root, "src/defs.ts"))); }), ), ); +it.effect("clears an older version's folders from checkouts and leaves their indexes", () => + withFixture((fixture) => + Effect.gen(function* () { + const control = NodePath.join(fixture.root, ".codegraph-t3"); + const broken = NodePath.join(fixture.root, "broken"); + const { refresh, removeLegacyIndexes, hasCheckoutIndex } = yield* checkoutService( + fixture.root, + () => ["src/defs.ts"], + ); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(control); + await NodeFSP.writeFile(NodePath.join(control, "published"), "1"); + // Removing this one fails, which must not stop the sweep reaching the next checkout. + await NodeFSP.mkdir(NodePath.join(broken, ".codegraph-t3/codegraph.db"), { + recursive: true, + }); + }); + + yield* removeLegacyIndexes([broken, fixture.root]); + + assert.isFalse(NodeFS.existsSync(control)); + assert.isTrue(yield* hasCheckoutIndex(fixture.root)); + }), + ), +); + it.effect("leaves a checkout's own .codegraph-t3 directory alone", () => withFixture((fixture) => Effect.gen(function* () { @@ -1865,7 +1905,7 @@ it.effect("leaves legacy folders for later while another server builds", () => assert.equal(yield* other.send("release"), "released"); yield* editDefinition(fixture.root, "second"); assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 2 }); - assert.deepEqual(NodeFS.readdirSync(control).toSorted(), ["build.lock", "building.lock"]); + assert.isFalse(NodeFS.existsSync(control)); }), ), ); @@ -2088,6 +2128,19 @@ it.effect.each(["codegraph.json", ".gitignore"] as const)( ), ); +it.effect.each(["codegraph.json", ".gitignore"] as const)( + "keeps indexing when %s cannot be read as a file", + (configName) => + withFixture((fixture) => + Effect.gen(function* () { + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.join(fixture.root, configName))); + const { run, refresh } = yield* checkoutService(fixture.root, () => ["src/defs.ts"]); + assert.deepEqual(yield* refresh(), { _tag: "Published", generation: 1 }); + assert.equal((yield* run({ operation: "freshness" })).index?.state, "ready"); + }), + ), +); + it.effect("answers from the published generation while its successor builds", () => withFixture((fixture) => Effect.gen(function* () { diff --git a/apps/server/src/project/knowledge/CodeIntelligence.ts b/apps/server/src/project/knowledge/CodeIntelligence.ts index 41a345dbef8b4..242d2d69d59ae 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.ts @@ -165,6 +165,8 @@ export class CodeIntelligence extends Context.Service< * a reader still pins to the next sweep. */ readonly removeCheckoutIndex: (checkoutPath: string) => Effect.Effect; + /** Clears an older version's folders from each checkout, leaving its current index alone. */ + readonly removeLegacyIndexes: (checkoutPaths: ReadonlyArray) => Effect.Effect; /** Whether the checkout has an index, current or left by an older version. */ readonly hasCheckoutIndex: (checkoutPath: string) => Effect.Effect; /** @@ -448,6 +450,15 @@ export interface CodeIntelligenceOptions { readonly beforeBuildCommand?: (analysisPid: number) => Effect.Effect; } +const removeLegacyIndexFiles = (checkoutPath: string) => + Effect.sync(() => { + if (hasLegacyIndexFiles(checkoutPath)) removeLegacyIndexFromCheckout(checkoutPath); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to remove a checkout's old index files", { checkoutPath, cause }), + ), + ); + const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntelligenceOptions) { const projects = yield* ProjectStore.ProjectStoreV2; const threads = yield* ProjectionStore.ProjectionStoreV2; @@ -616,16 +627,7 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig ? Effect.succeed({ _tag: "BuildingElsewhere" } as const) : Effect.gen(function* () { // Folders an older version left in the checkout go on the first build that sees them. - yield* Effect.sync(() => { - if (hasLegacyIndexFiles(checkoutPath)) removeLegacyIndexFromCheckout(checkoutPath); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to remove a checkout's old index files", { - checkoutPath, - cause, - }), - ), - ); + yield* removeLegacyIndexFiles(checkoutPath); return yield* buildGeneration(checkoutPath); }), (release) => Effect.sync(() => release?.()), @@ -925,6 +927,12 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig if (hadFiles || hadBuild) yield* publishReRead(path); }); + const removeLegacyIndexes: CodeIntelligence["Service"]["removeLegacyIndexes"] = Effect.fn( + "CodeIntelligence.removeLegacyIndexes", + )(function* (checkoutPaths) { + yield* Effect.forEach(checkoutPaths, removeLegacyIndexFiles, { discard: true }); + }); + const hasCheckoutIndex: CodeIntelligence["Service"]["hasCheckoutIndex"] = Effect.fn( "CodeIntelligence.hasCheckoutIndex", )(function* (checkoutPath) { @@ -1003,6 +1011,7 @@ const make = Effect.fn("CodeIntelligence.make")(function* (options: CodeIntellig ensureProjectIndexed, refreshForActiveAgent, removeCheckoutIndex, + removeLegacyIndexes, hasCheckoutIndex, removeOrphanedIndexes, stopBuilds, diff --git a/apps/server/src/project/knowledge/codeIndexLocks.test.ts b/apps/server/src/project/knowledge/codeIndexLocks.test.ts index 2b4d8a4723990..313cfbf254446 100644 --- a/apps/server/src/project/knowledge/codeIndexLocks.test.ts +++ b/apps/server/src/project/knowledge/codeIndexLocks.test.ts @@ -2,7 +2,6 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import * as NodeSqlite from "node:sqlite"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { expect, it } from "vite-plus/test"; @@ -53,47 +52,97 @@ it("removes checkout links while preserving a pinned generation and its source f pin.release(); pin = undefined; removeIndexRoot(root); - expect(NodeFS.existsSync(generation)).toBe(false); - expect(NodeFS.readdirSync(NodePath.join(root, ".codegraph-t3")).toSorted()).toEqual([ - "build.lock", - "building.lock", - ]); + expect(NodeFS.existsSync(root)).toBe(false); } finally { pin?.release(); NodeFS.rmSync(temporary, { recursive: true, force: true }); } }); +// Windows refuses to delete a file another handle has open, so there the first lock simply stands. it.each([ - ["external index", removeIndexRoot], - ["legacy index", removeLegacyIndexFromCheckout], -] as const)("keeps a waiting builder exclusive after %s removal", (_name, remove) => { + ["deleted", () => null], + ["deleted and taken by another builder", tryLockBuild], +] as const)("never lets two builders hold a lock %s before it landed", (_name, afterRemoval) => { const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-index-lock-")); - let waitingBuilder: NodeSqlite.DatabaseSync | undefined; - let contender: (() => void) | null = null; + const holders: Array<(() => void) | null> = []; try { - publishGeneration(root, 1); - const generation = NodePath.join(root, ".codegraph-t3-g1"); - NodeFS.mkdirSync(generation); - NodeFS.writeFileSync(NodePath.join(generation, "codegraph.db"), "index payload"); - waitingBuilder = new NodeSqlite.DatabaseSync(NodePath.join(root, ".codegraph-t3/build.lock")); + holders.push( + tryLockBuild(root, () => { + removeIndexRoot(root); + holders.push(afterRemoval(root)); + }), + ); + holders.push(tryLockBuild(root)); + expect(holders.filter((holder) => holder !== null)).toHaveLength(1); + } finally { + for (const holder of holders) holder?.(); + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); - remove(root); - expect(readPublishedGeneration(root)).toBeNull(); - expect(NodeFS.existsSync(generation)).toBe(false); - expect(hasLegacyIndexFiles(root)).toBe(false); +const legacyCheckout = (files: ReadonlyArray) => { + const checkout = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-index-legacy-")); + NodeFS.writeFileSync(NodePath.join(checkout, "keep.txt"), "mine"); + for (const file of files) { + NodeFS.mkdirSync(NodePath.dirname(NodePath.join(checkout, file)), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(checkout, file), ""); + } + return checkout; +}; - waitingBuilder.exec("PRAGMA journal_mode = OFF; BEGIN EXCLUSIVE;"); - contender = tryLockBuild(root); - expect(contender).toBeNull(); - waitingBuilder.close(); - waitingBuilder = undefined; - contender = tryLockBuild(root); - expect(contender).not.toBeNull(); +it.each([ + [ + "a published index", + [ + ".codegraph-t3/.gitignore", + ".codegraph-t3/published", + ".codegraph-t3/build.lock", + ".codegraph-t3/building.lock", + ".codegraph-t3-g3/.gitignore", + ".codegraph-t3-g3/codegraph.db", + ".codegraph-t3-g3/codegraph.db-shm", + ".codegraph-t3-g3/codegraph.db-wal", + ".codegraph-t3-g3/readers.lock", + ".codegraph-t3-g3/t3-index.json", + ], + ], + ["only lock files", [".codegraph-t3/.gitignore", ".codegraph-t3/build.lock"]], + ["a generation with no control folder", [".codegraph-t3-g3/codegraph.db"]], +])("leaves nothing of T3's in a checkout holding %s", (_name, files) => { + const checkout = legacyCheckout(files); + try { + expect(hasLegacyIndexFiles(checkout)).toBe(true); + removeLegacyIndexFromCheckout(checkout); + expect(NodeFS.readdirSync(checkout)).toEqual(["keep.txt"]); + expect(hasLegacyIndexFiles(checkout)).toBe(false); } finally { - contender?.(); - waitingBuilder?.close(); - NodeFS.rmSync(root, { recursive: true, force: true }); + NodeFS.rmSync(checkout, { recursive: true, force: true }); + } +}); + +it("keeps the user's files in an older version's control folder", () => { + const checkout = legacyCheckout([ + ".codegraph-t3/.gitignore", + ".codegraph-t3/published", + ".codegraph-t3/build.lock", + ".codegraph-t3/notes.txt", + ".codegraph-t3/mine/a.txt", + ".codegraph-t3-g3/codegraph.db", + ".codegraph-t3-g3/notes.txt", + ]); + try { + removeLegacyIndexFromCheckout(checkout); + expect( + NodeFS.readdirSync(NodePath.join(checkout, ".codegraph-t3"), { recursive: true }).toSorted(), + ).toEqual(["mine", NodePath.join("mine", "a.txt"), "notes.txt"]); + expect(NodeFS.readdirSync(NodePath.join(checkout, ".codegraph-t3-g3")).toSorted()).toEqual([ + "codegraph.db", + "notes.txt", + ]); + expect(hasLegacyIndexFiles(checkout)).toBe(false); + } finally { + NodeFS.rmSync(checkout, { recursive: true, force: true }); } }); diff --git a/apps/server/src/project/knowledge/codeIndexLocks.ts b/apps/server/src/project/knowledge/codeIndexLocks.ts index f72054d598370..cddf8fab6ddc2 100644 --- a/apps/server/src/project/knowledge/codeIndexLocks.ts +++ b/apps/server/src/project/knowledge/codeIndexLocks.ts @@ -34,14 +34,28 @@ const sqliteCode = (cause: unknown) => ? cause.errcode & 0xff : undefined; +const fileIdentity = (path: string) => + NodeFS.statSync(path, { bigint: true, throwIfNoEntry: false })?.ino; + /** The connection holding `path`'s exclusive lock, or null while another connection holds a lock. */ -function lockExclusive(path: string, waitMs: number): NodeSqlite.DatabaseSync | null { +function lockExclusive( + path: string, + waitMs: number, + onOpened?: () => void, +): NodeSqlite.DatabaseSync | null { const database = new NodeSqlite.DatabaseSync(path); try { + const opened = fileIdentity(path); + onOpened?.(); // Defensive SQLite ignores OFF; MEMORY avoids an open journal that Windows cannot delete. database.exec( `PRAGMA busy_timeout = ${waitMs}; PRAGMA journal_mode = MEMORY; BEGIN EXCLUSIVE;`, ); + // A removal can delete this file before the lock lands, and a lock on it then excludes nobody. + if (opened === undefined || fileIdentity(path) !== opened) { + database.close(); + return null; + } return database; } catch (cause) { database.close(); @@ -75,10 +89,13 @@ function lockShared(path: string, role: "reader" | "probe") { } /** Takes the index root's build lock and returns its release, or null while another builder holds it. */ -export function tryLockBuild(indexRoot: string): (() => void) | null { +export function tryLockBuild( + indexRoot: string, + onBuildLockOpened?: () => void, +): (() => void) | null { const control = NodePath.join(indexRoot, INDEX_DIRECTORY); NodeFS.mkdirSync(control, { recursive: true }); - const build = lockExclusive(NodePath.join(control, BUILD_LOCK), 0); + const build = lockExclusive(NodePath.join(control, BUILD_LOCK), 0, onBuildLockOpened); if (build === null) return null; try { const building = lockExclusive(NodePath.join(control, BUILDING_LOCK), MOMENTARY_LOCK_WAIT_MS); @@ -136,29 +153,73 @@ export function removeGenerationUnlessRead(indexRoot: string, generation: number NodeFS.rmSync(directory, { recursive: true, force: true }); } -function clearControlFiles(control: string, preserveGitignore = false): void { +function clearControlFiles(control: string): void { for (const name of NodeFS.readdirSync(control)) { - if (isBuildLockFile(name) || (preserveGitignore && name === ".gitignore")) continue; + if (isBuildLockFile(name)) continue; NodeFS.rmSync(NodePath.join(control, name), { recursive: true, force: true }); } } -/** Removes index payload and checkout links, retaining the lock inodes that other builders open. */ -export function removeIndexRoot(indexRoot: string): void { - if (!NodeFS.existsSync(indexRoot)) return; - const control = NodePath.join(indexRoot, INDEX_DIRECTORY); - const release = tryLockBuild(indexRoot); +/** False while a process holds one open: Windows refuses to delete an open file. */ +function removeBuildLockFiles(control: string): boolean { + try { + for (const name of [BUILDING_LOCK, BUILD_LOCK]) { + NodeFS.rmSync(NodePath.join(control, name), { force: true }); + } + return true; + } catch (cause) { + const code = (cause as NodeJS.ErrnoException).code; + if (code === "EBUSY" || code === "EPERM" || code === "EACCES") return false; + throw cause; + } +} + +const removeIfEmpty = (directory: string) => { + try { + NodeFS.rmdirSync(directory); + } catch { + // What remains is in use or the user's; a later sweep retries. + } +}; + +/** + * Runs `clear` under the build lock, then removes the lock files and their folder. The files go + * while held where the OS allows it: a later builder gets a new file, and an earlier one that had + * only opened its file finds it gone. Elsewhere they go on release, which an open file refuses. + */ +function clearUnderBuildLock(root: string, clear: (control: string) => void): void { + const control = NodePath.join(root, INDEX_DIRECTORY); + const release = tryLockBuild(root); if (release === null) return; + let removed = false; try { + clear(control); + removed = removeBuildLockFiles(control); + } finally { + release(); + } + if (!removed) removeBuildLockFiles(control); + removeIfEmpty(control); +} + +/** + * Removes one index root: the published pointer first, so new readers see "unindexed", then every + * generation a reader is not pinning, then the checkout's links. A build elsewhere leaves it all + * for the next sweep. + * + * Nothing here may remove recursively from the root: its entries are links into a user's checkout. + */ +export function removeIndexRoot(indexRoot: string): void { + if (!NodeFS.existsSync(indexRoot)) return; + clearUnderBuildLock(indexRoot, (control) => { NodeFS.rmSync(NodePath.join(control, PUBLISHED_FILE), { force: true }); for (const generation of listGenerations(indexRoot)) { removeGenerationUnlessRead(indexRoot, generation); } clearCheckoutSources(indexRoot); clearControlFiles(control); - } finally { - release(); - } + }); + removeIfEmpty(indexRoot); } // Files an older T3 left in a checkout when the index lived beside its sources. @@ -170,6 +231,18 @@ const LEGACY_CONTROL_MARKERS = [ "t3-index.json", ]; const LEGACY_GENERATION_MARKERS = ["t3-index.json", "codegraph.db"]; +// Everything T3 and the SDK wrote to an old index folder; anything else there is the user's. +const LEGACY_INDEX_FILES = [ + ...LEGACY_CONTROL_MARKERS, + READERS_LOCK, + ".gitignore", + "codegraph.db-shm", + "codegraph.db-wal", + "codegraph.lock", + "daemon.pid", + "rebuild.pid", + "writer.pid", +]; const isRealDirectory = (path: string): boolean => { try { @@ -180,12 +253,17 @@ const isRealDirectory = (path: string): boolean => { }; /** Legacy lock paths are checkout-controlled, so none may follow a link or share an outside inode. */ -function isT3Directory(path: string, names: ReadonlyArray): boolean { +function isT3Directory( + path: string, + names: ReadonlyArray, + allowed?: ReadonlyArray, +): boolean { if (!isRealDirectory(path)) return false; try { const entries = NodeFS.readdirSync(path); return ( entries.some((name) => names.includes(name)) && + (allowed === undefined || entries.every((name) => allowed.includes(name))) && entries.every((name) => { const entry = NodeFS.lstatSync(NodePath.join(path, name)); return entry.isDirectory() || (entry.isFile() && entry.nlink === 1); @@ -215,12 +293,10 @@ function legacyIndexFolders( isT3Directory( NodePath.join(checkoutPath, generationDirectory(generation)), LEGACY_GENERATION_MARKERS, + LEGACY_INDEX_FILES, ), ); - const controlHasPayload = - exists && - NodeFS.readdirSync(control).some((name) => !isBuildLockFile(name) && name !== ".gitignore"); - return controlHasPayload || generations.length > 0 ? { generations } : null; + return exists || generations.length > 0 ? { generations } : null; } /** Whether a checkout still holds the folders an older T3 kept inside it, without throwing. */ @@ -236,19 +312,16 @@ export function hasLegacyIndexFiles(checkoutPath: string): boolean { export function removeLegacyIndexFromCheckout(checkoutPath: string): void { const legacy = legacyIndexFolders(checkoutPath); if (legacy === null) return; - const control = NodePath.join(checkoutPath, INDEX_DIRECTORY); - // An older server may still be building; it finishes and this runs on a later build. - const release = tryLockBuild(checkoutPath); - if (release === null) return; - try { + // An older server may still be building; it finishes and this runs on a later sweep. + clearUnderBuildLock(checkoutPath, (control) => { NodeFS.rmSync(NodePath.join(control, PUBLISHED_FILE), { force: true }); for (const generation of legacy.generations) { removeGenerationUnlessRead(checkoutPath, generation); } - clearControlFiles(control, true); - } finally { - release(); - } + for (const name of LEGACY_INDEX_FILES) { + if (!isBuildLockFile(name)) NodeFS.rmSync(NodePath.join(control, name), { force: true }); + } + }); } export interface GenerationPin { diff --git a/apps/server/src/project/knowledge/codeIntelligenceWorker.ts b/apps/server/src/project/knowledge/codeIntelligenceWorker.ts index e1927e21e7b4b..9d6733b07002c 100644 --- a/apps/server/src/project/knowledge/codeIntelligenceWorker.ts +++ b/apps/server/src/project/knowledge/codeIntelligenceWorker.ts @@ -40,6 +40,8 @@ import { } from "./codeIndexLayout.ts"; const CATALOG_REVISION = "@colbymchenry/codegraph@1.6.2"; +// Programming languages outside the SDK's catalog, which it reports as unknown like any asset. +const UNREADABLE_SOURCE = /\.(?:clj|cljs|cr|elm|ex|exs|fs|fsx|gd|hs|jl|ml|mli|nim|pl|pm|rkt|zig)$/i; const MAX_RESULTS = 200; const MAX_FILES_FOR_UNRESOLVED = 50; const MAX_LISTED_FILES = 10_000; @@ -273,8 +275,10 @@ function checkoutConfigurationDigest(checkoutPath: string): string { const contents = NodeFS.readFileSync(NodePath.join(checkoutPath, name)); digest.update("present\0").update(contents); } catch (cause) { - if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause; - digest.update("missing\0"); + // Anything unreadable, such as a folder of that name, configures nothing. + digest.update( + (cause as NodeJS.ErrnoException).code === "ENOENT" ? "missing\0" : "unreadable\0", + ); } digest.update("\0"); } @@ -1286,7 +1290,8 @@ export async function inspectCodeInWorker( /** * How much of the listed checkout the index covers, counted the same way for a query and for * freshness. Every limitation but unsupported files makes coverage partial: docs, data and assets - * fill any repository, so they are a gap only when nothing else could be indexed. + * fill any repository, so they are a gap only when nothing else could be indexed. Source the SDK + * cannot read is a gap once it is a real share of the code, not for one stray script. */ function classifyIndexCoverage( input: CheckoutListing, @@ -1318,7 +1323,13 @@ function classifyIndexCoverage( ); if (buildState !== null && buildState !== "complete") limitations.push(`The last full index run reported '${buildState}'.`); - const incomplete = limitations.length > 0 || (filesUnsupported > 0 && files.length === 0); + const unreadableSource = listedPaths.filter( + (path) => UNREADABLE_SOURCE.test(path) && !isSupported(path) && !indexedPaths.has(path), + ).length; + const incomplete = + limitations.length > 0 || + (filesUnsupported > 0 && files.length === 0) || + unreadableSource * 20 > files.length + unreadableSource; if (filesUnsupported > 0) limitations.push( `${filesUnsupported} listed files use languages outside the SDK runtime catalog.`, diff --git a/docs/user/code-navigation.md b/docs/user/code-navigation.md index d7805a1c2fc3d..3af8a192cb16d 100644 --- a/docs/user/code-navigation.md +++ b/docs/user/code-navigation.md @@ -12,9 +12,8 @@ again. A worktree's index is removed when its thread settles, unless other unset the worktree. Until an index is ready, the section says the checkout is not indexed yet. Folders outside a git repository index only on a code question. -Indexes are stored in T3 Code's data directory. Index databases an earlier version left in a -checkout are removed the next time that checkout is indexed. An existing `.codegraph-t3` folder -can retain small lock files so concurrent servers can continue coordinating safely. +Indexes are stored in T3 Code's data directory, not in your repository. Folders an earlier version +left in a project or worktree are removed automatically when T3 Code starts. Turn automatic indexing off in **Settings โ†’ Labs โ†’ Automatic code indexing**. Then a checkout only indexes when something asks it a code question. From d75ea81291e1fad9dd7124ddcb7f4e4fc17582ec Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:54:23 -0800 Subject: [PATCH 10/19] test(server): abort the analysis process in a way Windows supports Two code index tests sent SIGABRT, which Windows rejects with ENOSYS, so they failed before reaching the behaviour under test. They now use SIGKILL on Windows and keep SIGABRT elsewhere; production treats any unexpected exit the same. Model: Opus 5.5 Harness: Claude Code in T3 Code Co-Authored-By: Claude Opus 5.5 --- .../src/project/knowledge/CodeIntelligence.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/project/knowledge/CodeIntelligence.test.ts b/apps/server/src/project/knowledge/CodeIntelligence.test.ts index 56bf571c3881f..1b9603d0fc218 100644 --- a/apps/server/src/project/knowledge/CodeIntelligence.test.ts +++ b/apps/server/src/project/knowledge/CodeIntelligence.test.ts @@ -141,6 +141,12 @@ async function generationDirectories(indexRoot: string) { .toSorted(); } +// Windows has no SIGABRT; SIGKILL ends the process just as abruptly there. +const abortProcess = (pid: number) => + Effect.map(HostProcess.isWindows, (windows) => + process.kill(pid, windows ? "SIGKILL" : "SIGABRT"), + ); + function assertClearedIndex(indexRoot: string): void { assert.isFalse(NodeFS.existsSync(indexRoot)); } @@ -1639,7 +1645,7 @@ it.effect("fails the build, not the server, when the analysis process aborts", ( const building = yield* refresh().pipe(Effect.forkScoped); const pid = yield* Deferred.await(held.entered); assert.notEqual(pid, process.pid, "analysis runs outside the server process"); - process.kill(pid, "SIGABRT"); + yield* abortProcess(pid); assert.deepEqual(yield* Fiber.join(building), { _tag: "Failed", reason: "worker-failed" }); assert.deepEqual( yield* Effect.promise(() => generationDirectories(indexRoot(fixture.root))), @@ -2200,7 +2206,7 @@ it.effect("keeps the previous generation serving when a build fails, and says so yield* Effect.promise(() => rewriteGeneratedSources(fixture.root, files)); const held = yield* gate.holdNext; const building = yield* refresh().pipe(Effect.forkScoped); - process.kill(yield* Deferred.await(held.entered), "SIGABRT"); + yield* abortProcess(yield* Deferred.await(held.entered)); assert.deepEqual(yield* Fiber.join(building), { _tag: "Failed", reason: "worker-failed" }); const freshness = yield* run({ operation: "freshness" }); From 699446c2e39807943e867a7cae8eba0308730c83 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:22:51 -0800 Subject: [PATCH 11/19] fix(server): branch sync leaves foreign checkouts and held branches alone An independent check of the cleanup change found three ways to lose or strand work. A different repository at the worktree path had its branch written to the thread, so the thread forgot its own branch. A branch another checkout holds was recorded even though the thread could not recreate its worktree there. And the detached HEAD check ran before later steps that gave a holding ref time to go. Sync now compares the worktree's repository with the project's before it writes, and treats a branch another checkout holds like the default branch. Cleanup checks the holding ref last, immediately before removal. Co-Authored-By: Claude Opus 5.5 --- .../RunFinalizationService.test.ts | 3 + .../ThreadLaunchService.test.ts | 11 +++- .../orchestration-v2/WorktreeBranches.test.ts | 45 ++++++++++++++- .../src/orchestration-v2/WorktreeBranches.ts | 56 ++++++++++++++++++- .../src/orchestration-v2/runtimeLayer.ts | 2 +- .../testkit/ProviderReplayHarness.ts | 2 +- apps/server/src/storageCleanup.test.ts | 41 ++++++++++++++ apps/server/src/storageCleanup.ts | 5 +- 8 files changed, 155 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index c9c638e2c28bb..244276486f35b 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -352,6 +352,9 @@ it.live.each( Layer.provide( Layer.mergeAll( projections, + Layer.mock(ProjectStore.ProjectStoreV2)({ + getShell: () => Effect.succeedSome({ workspaceRoot: repo }) as never, + }), Layer.mock(ModelEvaluations.ModelEvaluations)({}), Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.void }), Layer.mock(Orchestrator.OrchestratorV2)({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 8dd6e96bcc695..2a0b80933d4c5 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -184,13 +184,22 @@ function makeHarness(options: HarnessOptions = {}) { // Fresh: memoization would otherwise reuse the replay harness's copy, which runs real Git. Layer.provide(Layer.fresh(WorktreeBranches.layer)), Layer.provide(layerOrchestrator), + Layer.provide( + Layer.mock(ProjectStore.ProjectStoreV2)({ + getShell: () => Effect.succeedSome({ workspaceRoot: "/repo" }) as never, + }), + ), Layer.provide( Layer.mock(GitVcsDriver.GitVcsDriver)({ execute: (input) => Effect.succeed({ exitCode: ChildProcessSpawner.ExitCode(0), stdout: - input.args[0] === "symbolic-ref" ? `refs/heads/${checkedOutBranch}\n` : "index\n", + input.args[0] === "symbolic-ref" + ? `refs/heads/${checkedOutBranch}\n` + : input.args.includes("--git-common-dir") + ? "/repo/.git\n" + : "index\n", stderr: "", stdoutTruncated: false, stderrTruncated: false, diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts index 199301de35995..94011b91d9092 100644 --- a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts +++ b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts @@ -18,6 +18,7 @@ import type * as Scope from "effect/Scope"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectStore from "./ProjectStore.ts"; import * as WorktreeBranches from "./WorktreeBranches.ts"; interface MetadataUpdate { @@ -71,6 +72,9 @@ const fixture = Effect.gen(function* () { let onDispatch: (update: MetadataUpdate) => Effect.Effect = () => Effect.void; const dispatched: MetadataUpdate[] = []; const context = yield* Layer.build(WorktreeBranches.layer).pipe( + Effect.provideService(ProjectStore.ProjectStoreV2, { + getShell: () => Effect.succeedSome({ workspaceRoot: repo }), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), Effect.provideService(Orchestrator.OrchestratorV2, { getShellSnapshot: (options: { readonly worktreePath: string }) => Effect.sync(() => { @@ -186,7 +190,7 @@ it.live.each([ ["a detached HEAD", ["switch", "--detach"], "Detached", undefined], ["an unborn branch", ["switch", "--orphan", "unborn"], "Unavailable", undefined], ["a parenthesized name", ["switch", "-c", "(feature)"], "Unavailable", undefined], - ["the default branch", ["switch", "main"], "Default", "main"], + ["the default branch", ["switch", "main"], "Unbound", "main"], ] as const)("writes nothing for %s", ([, checkout, tag, branch]) => run( Effect.gen(function* () { @@ -203,6 +207,43 @@ it.live.each([ ), ); +it.live("writes nothing for a branch another checkout holds", () => + run( + Effect.gen(function* () { + const { service, command, repo, worktree, dispatched, setThreads } = yield* fixture; + setThreads([member("thread", "feature", worktree)]); + yield* command(repo, ["switch", "-c", "trunk"]); + yield* command(worktree, ["switch", "--ignore-other-worktrees", "trunk"]); + assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + _tag: "Unbound", + branch: "trunk", + }); + assert.deepEqual(dispatched, []); + }), + ), +); + +it.live("writes nothing when another repository replaced the worktree", () => + run( + Effect.gen(function* () { + const { service, command, worktree, dispatched, setThreads } = yield* fixture; + const fs = yield* FileSystem.FileSystem; + const other = yield* fs.makeTempDirectoryScoped({ prefix: "worktree-sync-other-" }); + yield* command(other, ["init", "-b", "main"]); + yield* command(other, ["config", "user.email", "other@example.test"]); + yield* command(other, ["config", "user.name", "Other"]); + yield* command(other, ["commit", "--allow-empty", "-m", "unrelated"]); + yield* fs.rename(worktree, `${worktree}-original`); + yield* command(other, ["worktree", "add", "-b", "other-work", worktree]); + setThreads([member("thread", "feature", worktree)]); + assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + _tag: "Unavailable", + }); + assert.deepEqual(dispatched, []); + }), + ), +); + it.live("reads the default branch from the primary remote before main or master", () => run( Effect.gen(function* () { @@ -219,7 +260,7 @@ it.live("reads the default branch from the primary remote before main or master" yield* command(worktree, ["switch", "trunk"]); assert.equal( (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, - "Default", + "Unbound", ); assert.deepEqual(dispatched, []); yield* command(repo, ["switch", "--detach"]); diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.ts b/apps/server/src/orchestration-v2/WorktreeBranches.ts index f7f0edc363f17..a3b8f189cf6b8 100644 --- a/apps/server/src/orchestration-v2/WorktreeBranches.ts +++ b/apps/server/src/orchestration-v2/WorktreeBranches.ts @@ -5,11 +5,13 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import type { PlatformError } from "effect/PlatformError"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectStore from "./ProjectStore.ts"; export type WorktreeHead = | { readonly _tag: "Unavailable" } @@ -19,8 +21,8 @@ export type WorktreeHead = export type WorktreeCheckout = | { readonly _tag: "Unavailable" } | { readonly _tag: "Detached" } - /** HEAD is on the default branch and records differ. Its own ref holds HEAD, so nothing is written. */ - | { readonly _tag: "Default"; readonly branch: string } + /** Records differ, but a thread could not recreate its worktree on this branch: the default branch or one another checkout holds. Its own ref holds HEAD, so nothing is written. */ + | { readonly _tag: "Unbound"; readonly branch: string } | { readonly _tag: "Bound"; readonly branch: string } /** Records differ, and a write was attempted or deferred. The caller's thread data is stale. */ | { readonly _tag: "Rebinding"; readonly branch: string }; @@ -42,6 +44,7 @@ export class WorktreeBranches extends Context.Service< const make = Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; + const projects = yield* ProjectStore.ProjectStoreV2; const git = yield* GitVcsDriver.GitVcsDriver; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -103,6 +106,32 @@ const make = Effect.gen(function* () { }, ); + const gitCommonDir = Effect.fnUntraced(function* (cwd: string) { + const physical = (path: string) => fs.realPath(path).pipe(Effect.orElseSucceed(() => path)); + const { stdout } = yield* git.execute({ + operation: "WorktreeBranches.sync", + cwd, + args: ["rev-parse", "--git-common-dir"], + }); + // Git prints this relative to the physical cwd, which can differ from a symlink. + return yield* physical(path.resolve(yield* physical(cwd), stdout.trim())); + }); + + // A replaced folder looks like a switched branch, but its branch means nothing to these threads. + const inProjectRepository = Effect.fn("WorktreeBranches.inProjectRepository")( + function* (worktreePath: string) { + const snapshot = yield* orchestrator.getShellSnapshot({ worktreePath }); + const member = snapshot.threads[0] ?? snapshot.archivedThreads[0]; + if (member === undefined) return true; + const project = yield* projects.getShell(member.projectId); + if (Option.isNone(project)) return false; + return ( + (yield* gitCommonDir(worktreePath)) === (yield* gitCommonDir(project.value.workspaceRoot)) + ); + }, + Effect.orElseSucceed(() => false), + ); + const isDefaultBranch = Effect.fn("WorktreeBranches.isDefaultBranch")(function* ( cwd: string, branch: string, @@ -114,6 +143,22 @@ const make = Effect.gen(function* () { ); }); + // Git refuses to recreate a worktree on a branch another checkout holds. + const heldByAnotherCheckout = Effect.fn("WorktreeBranches.heldByAnotherCheckout")(function* ( + cwd: string, + branch: string, + ) { + const { stdout } = yield* git.execute({ + operation: "WorktreeBranches.sync", + cwd, + args: ["worktree", "list", "--porcelain"], + }); + const holders = stdout + .split("\n") + .filter((line) => line.trim() === `branch refs/heads/${branch}`); + return holders.length > 1; + }); + const writeMembers = Effect.fn("WorktreeBranches.writeMembers")(function* (input: { readonly worktreePath: string; readonly branch: string; @@ -177,7 +222,12 @@ const make = Effect.gen(function* () { allowNonZeroExit: true, }); if (born.exitCode !== 0) return { _tag: "Unavailable" }; - if (yield* isDefaultBranch(worktreePath, branch)) return { _tag: "Default", branch }; + if (!(yield* inProjectRepository(worktreePath))) return { _tag: "Unavailable" }; + if ( + (yield* isDefaultBranch(worktreePath, branch)) || + (yield* heldByAnotherCheckout(worktreePath, branch)) + ) + return { _tag: "Unbound", branch }; yield* writeMembers({ worktreePath, branch, runId }).pipe( Effect.catchCause((cause) => Effect.logWarning("worktree branch sync could not read its threads", { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 18a025b665fcd..f9bf62c8a9328 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -209,7 +209,7 @@ const layerOrchestratorProvided = Orchestrator.layer.pipe( ); const layerWorktreeBranchesProvided = WorktreeBranches.layer.pipe( - Layer.provide(Layer.merge(layerOrchestratorProvided, GitVcsDriver.layer)), + Layer.provide(Layer.mergeAll(layerOrchestratorProvided, GitVcsDriver.layer, ProjectStore.layer)), ); const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index a9b452ae3856d..bd5078dd5ca0d 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -481,7 +481,7 @@ export function layerWithRegistry( layerCheckpointCaptureServiceProvided, layerStores, WorktreeBranches.layer.pipe( - Layer.provide(Layer.merge(layerOrchestratorProvided, layerGitVcsDriver)), + Layer.provide(Layer.mergeAll(layerOrchestratorProvided, layerGitVcsDriver, layerStores)), ), ), ), diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 4f6d9502e1c22..51bda40b6ad93 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -382,6 +382,14 @@ const cleanupFixture = ( } as unknown as Settings.ServerSettingsService["Service"]), Effect.provideService(ProjectStore.ProjectStoreV2, { list: () => Effect.sync(() => projectRoots.map((workspaceRoot) => ({ workspaceRoot }))), + getShell: (projectId: ProjectId) => + Effect.sync(() => { + const index = Number(projectId.replace("project-", "")) - 1; + const workspaceRoot = projectRoots[index]; + return workspaceRoot === undefined + ? Option.none() + : Option.some({ id: projectId, workspaceRoot }); + }), listShells: () => Effect.sync(() => projectRoots.map((workspaceRoot, index) => ({ @@ -2405,6 +2413,39 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("keeps a detached worktree when its holding ref goes during the final checks", () => + runCleanupTest( + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + let localChangeChecks = 0; + let repo = ""; + const { service, command, worktree, ...fixture } = yield* cleanupFixture().pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, { + ...git, + execute: (input) => + Effect.gen(function* () { + if (input.operation === "StorageCleanup.localChanges" && ++localChangeChecks === 2) + yield* git.execute({ + operation: "test", + cwd: repo, + args: ["tag", "--delete", "keeper"], + }); + return yield* git.execute(input); + }), + }), + ); + repo = fixture.repo; + yield* command(worktree, ["switch", "--detach"]); + yield* command(worktree, ["commit", "--allow-empty", "-m", "detached work"]); + yield* command(worktree, ["tag", "keeper"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Detached HEAD has commits no branch or tag holds", + }); + expect(yield* fixture.fs.exists(worktree)).toBe(true); + }), + ), + ); it.live.each([ ["any-local-files", ".env", "kept"], ["uncommitted-changes", ".env", "removed"], diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 00d2b0da83c65..2b97fa790cec2 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1129,8 +1129,6 @@ const make = Effect.gen(function* () { head.commitSha ) return keep("commit changed since check"); - if (checkout._tag === "Detached" && !(yield* commitHeldByRef(worktreePath, head.commitSha))) - return keep("detached HEAD has commits no branch or tag holds"); const finalChanges = yield* localChangesReason(worktreePath, settings); if (finalChanges !== null) return keep(finalChanges); const currentSettings = yield* settingsService.getSettings; @@ -1153,6 +1151,9 @@ const make = Effect.gen(function* () { const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); } + // Last, so nothing that runs after this can drop the ref that holds the commit. + if (checkout._tag === "Detached" && !(yield* commitHeldByRef(worktreePath, head.commitSha))) + return keep("detached HEAD has commits no branch or tag holds"); yield* removeLinkedWorktree({ operation: "StorageCleanup.cleanupGit", cwd: project.workspaceRoot, From 226e2330278cec0e011071fba75c72cba4098064 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:51:25 -0800 Subject: [PATCH 12/19] fix(server): a prunable remote branch does not protect a detached commit Cleanup counted any remote-tracking ref as holding a detached commit. A routine `git fetch --prune` removes a remote branch that was deleted upstream, so the commit could become unreachable after its worktree was gone. Only local branches, tags, and the remote's default branch count now. A detached worktree held by any other remote branch stays kept. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 20 ++++++++++++++++++++ apps/server/src/storageCleanup.ts | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 51bda40b6ad93..90674155e4b5d 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2319,6 +2319,26 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live.each([ + ["a remote branch a prune can drop", "agent", "kept"], + ["the remote default branch", "main", "removed"], + ] as const)("judges a detached commit held only by %s", ([, remoteBranch, outcome]) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture(); + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); + yield* command(remote, ["init", "--bare", "-b", "main"]); + yield* command(repo, ["remote", "add", "origin", remote]); + yield* command(repo, ["push", "origin", "main"]); + yield* command(repo, ["remote", "set-head", "origin", "main"]); + yield* command(worktree, ["switch", "--detach"]); + yield* command(worktree, ["commit", "--allow-empty", "-m", "detached work"]); + yield* command(worktree, ["push", "origin", `HEAD:refs/heads/${remoteBranch}`]); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome }); + expect(yield* fs.exists(worktree)).toBe(outcome === "kept"); + }), + ), + ); it.live("follows a branch the agent switched to and removes the worktree next pass", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 2b97fa790cec2..efd9cddebef5b 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -594,11 +594,14 @@ const make = Effect.gen(function* () { return file === undefined ? null : `has ignored files (${file})`; }); - // refs/t3 is excluded: a user cannot recover a commit only a checkpoint ref holds. + // Only refs that stay count. A user cannot recover from refs/t3, and a routine + // `git fetch --prune` drops any remote branch except the default one. const commitHeldByRef = Effect.fn("StorageCleanup.commitHeldByRef")(function* ( cwd: string, commitSha: string, ) { + const remote = yield* git.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); + const defaultBranch = remote === null ? null : yield* git.resolveDefaultBranchName(cwd, remote); const refs = yield* git.execute({ operation: "StorageCleanup.commitHeldByRef", cwd, @@ -609,8 +612,8 @@ const make = Effect.gen(function* () { "--count=1", "--format=%(refname)", "refs/heads", - "refs/remotes", "refs/tags", + ...(defaultBranch === null ? [] : [`refs/remotes/${remote}/${defaultBranch}`]), ], }); return refs.stdout.trim() !== ""; From e6d80364d39df6be55fae51b89163bd7c692c283 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:15:50 -0800 Subject: [PATCH 13/19] fix(server): cleanup keeps what removal would strand and rebinds every thread Adversarial review reproduced more ways for this change to lose work or never finish. Removing a detached or default-branch worktree could lose a commit only its reflog held, or strand a thread whose recorded branch another checkout held. A remote-tracking ref counted as protecting a commit even though a routine prune or a stale origin/HEAD could remove that protection. A run end skipped a sibling whose write had been deferred to it. Threads with a trailing slash in their path or no recorded branch never rebound, and mismatched records on a detached or default-branch worktree were kept forever. A held index lock blocked rebinding, and main counted as the default branch when the remote's default was trunk. Cleanup now checks HEAD and the reflog against local branches and tags only, and keeps a worktree while another checkout holds a recorded branch. Sync compares HEAD with every thread on the worktree instead of the caller's record, reads HEAD without depending on the index lock, finds threads by the path each one stored, and asks the remote when no local HEAD ref names its default. Model: Opus 5.5 Harness: Claude Code in T3 Code Co-Authored-By: Claude Opus 5.5 --- .../RunFinalizationService.ts | 6 +- .../orchestration-v2/ThreadLaunchService.ts | 1 - .../orchestration-v2/WorktreeBranches.test.ts | 121 ++++++++---- .../src/orchestration-v2/WorktreeBranches.ts | 172 +++++++++++------- apps/server/src/storageCleanup.test.ts | 147 ++++++++++++++- apps/server/src/storageCleanup.ts | 82 +++++---- apps/server/src/vcs/GitVcsDriver.ts | 4 + 7 files changed, 381 insertions(+), 152 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 488eab8ff2aca..44ce992b3114e 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -100,11 +100,7 @@ const make = Effect.gen(function* () { Effect.flatMap((thread) => // An exact match excludes project-root threads and nested-repo checkpoint scopes. thread?.worktreePath === cwd - ? worktreeBranches.sync({ - worktreePath: cwd, - recorded: [thread.branch], - runId: input.runId, - }) + ? worktreeBranches.sync({ worktreePath: cwd, runId: input.runId }) : Effect.void, ), Effect.catchCause((cause) => diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 9184a9464e1cb..e94141fbe3c3e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -514,7 +514,6 @@ const make = Effect.gen(function* () { Effect.flatMap(() => worktreeBranches.sync({ worktreePath: worktreeCwd, - recorded: [oldBranch], ...(runId === null ? {} : { runId }), }), ), diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts index 94011b91d9092..a41342d336684 100644 --- a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts +++ b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts @@ -68,7 +68,6 @@ const fixture = Effect.gen(function* () { yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); let threads: OrchestrationV2ThreadShell[] = []; - let snapshotReads = 0; let onDispatch: (update: MetadataUpdate) => Effect.Effect = () => Effect.void; const dispatched: MetadataUpdate[] = []; const context = yield* Layer.build(WorktreeBranches.layer).pipe( @@ -78,7 +77,6 @@ const fixture = Effect.gen(function* () { Effect.provideService(Orchestrator.OrchestratorV2, { getShellSnapshot: (options: { readonly worktreePath: string }) => Effect.sync(() => { - snapshotReads++; const members = threads.filter((thread) => thread.worktreePath === options.worktreePath); return { threads: members.filter((thread) => thread.archivedAt === null), @@ -105,7 +103,6 @@ const fixture = Effect.gen(function* () { repo, worktree, dispatched, - snapshotReads: () => snapshotReads, threads: () => threads, setThreads: (next: OrchestrationV2ThreadShell[]) => { threads = next; @@ -128,15 +125,15 @@ const run = ( >, ) => effect.pipe(Effect.provide(gitLayer), Effect.scoped); -it.live("returns Bound without reading threads when every record matches HEAD", () => +it.live("returns Bound without writing when every thread matches HEAD", () => run( Effect.gen(function* () { - const { service, worktree, snapshotReads, dispatched } = yield* fixture; - assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + const { service, worktree, dispatched, setThreads } = yield* fixture; + setThreads([member("thread", "feature", worktree)]); + assert.deepEqual(yield* service.sync({ worktreePath: worktree }), { _tag: "Bound", branch: "feature", }); - assert.equal(snapshotReads(), 0); assert.deepEqual(dispatched, []); }), ), @@ -157,7 +154,7 @@ it.live("writes every member oldest first, each with its own swap, and a repeat member("parent", "feature", worktree, { createdAt: DateTime.makeUnsafe(1) }), member("elsewhere", "feature", `${worktree}-other`), ]); - assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + assert.deepEqual(yield* service.sync({ worktreePath: worktree }), { _tag: "Rebinding", branch: "agent/work", }); @@ -173,15 +170,10 @@ it.live("writes every member oldest first, each with its own swap, and a repeat assert.isTrue(dispatched.every((update) => update.expectedWorktreePath === worktree)); assert.equal(new Set(dispatched.map((update) => update.commandId)).size, 4); assert.equal(threads().find((thread) => thread.id === "elsewhere")?.branch, "feature"); - assert.deepEqual( - yield* service.sync({ - worktreePath: worktree, - recorded: ["parent", "child", "no-branch", "archived"].map( - (id) => threads().find((thread) => thread.id === id)!.branch, - ), - }), - { _tag: "Bound", branch: "agent/work" }, - ); + assert.deepEqual(yield* service.sync({ worktreePath: worktree }), { + _tag: "Bound", + branch: "agent/work", + }); }), ), ); @@ -198,7 +190,7 @@ it.live.each([ setThreads([member("thread", "feature", worktree)]); if (branch === "main") yield* command(repo, ["switch", "--detach"]); yield* command(worktree, [...checkout]); - assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + assert.deepEqual(yield* service.sync({ worktreePath: worktree }), { _tag: tag, ...(branch === undefined ? {} : { branch }), } as WorktreeBranches.WorktreeCheckout); @@ -214,7 +206,7 @@ it.live("writes nothing for a branch another checkout holds", () => setThreads([member("thread", "feature", worktree)]); yield* command(repo, ["switch", "-c", "trunk"]); yield* command(worktree, ["switch", "--ignore-other-worktrees", "trunk"]); - assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + assert.deepEqual(yield* service.sync({ worktreePath: worktree }), { _tag: "Unbound", branch: "trunk", }); @@ -236,7 +228,7 @@ it.live("writes nothing when another repository replaced the worktree", () => yield* fs.rename(worktree, `${worktree}-original`); yield* command(other, ["worktree", "add", "-b", "other-work", worktree]); setThreads([member("thread", "feature", worktree)]); - assert.deepEqual(yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }), { + assert.deepEqual(yield* service.sync({ worktreePath: worktree }), { _tag: "Unavailable", }); assert.deepEqual(dispatched, []); @@ -258,16 +250,31 @@ it.live("reads the default branch from the primary remote before main or master" "refs/remotes/origin/trunk", ]); yield* command(worktree, ["switch", "trunk"]); - assert.equal( - (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, - "Unbound", - ); + assert.equal((yield* service.sync({ worktreePath: worktree }))._tag, "Unbound"); assert.deepEqual(dispatched, []); yield* command(repo, ["switch", "--detach"]); yield* command(worktree, ["switch", "main"]); - assert.equal( - (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, - "Rebinding", + assert.equal((yield* service.sync({ worktreePath: worktree }))._tag, "Rebinding"); + }), + ), +); + +it.live("asks the remote when no local HEAD ref says whether main is its default", () => + run( + Effect.gen(function* () { + const { service, command, repo, worktree, dispatched, setThreads } = yield* fixture; + const fs = yield* FileSystem.FileSystem; + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "worktree-sync-remote-" }); + setThreads([member("thread", "feature", worktree)]); + yield* command(remote, ["init", "--bare", "-b", "trunk"]); + yield* command(repo, ["branch", "-m", "trunk"]); + yield* command(repo, ["remote", "add", "origin", remote]); + yield* command(repo, ["push", "origin", "trunk"]); + yield* command(worktree, ["switch", "-c", "main"]); + assert.equal((yield* service.sync({ worktreePath: worktree }))._tag, "Rebinding"); + assert.deepEqual( + dispatched.map((update) => update.branch), + ["main"], ); }), ), @@ -279,7 +286,7 @@ it.live("reports a missing worktree as Unavailable", () => const { service, worktree } = yield* fixture; const missing = `${worktree}-missing`; assert.deepEqual(yield* service.observe(missing), { _tag: "Unavailable" }); - assert.deepEqual(yield* service.sync({ worktreePath: missing, recorded: ["feature"] }), { + assert.deepEqual(yield* service.sync({ worktreePath: missing }), { _tag: "Unavailable", }); }), @@ -296,7 +303,7 @@ it.live("defers to a sibling's active run but writes through the caller's own", member("own", "feature", worktree, { activeRunId: runId }), member("sibling", "feature", worktree, { activeRunId: RunId.make("run-sibling") }), ]); - const sync = { worktreePath: worktree, recorded: ["feature"], runId }; + const sync = { worktreePath: worktree, runId }; assert.deepEqual(yield* service.sync(sync), { _tag: "Rebinding", branch: "agent/work" }); assert.deepEqual(dispatched, []); setThreads([ @@ -319,10 +326,7 @@ it.live("logs a lost swap and still writes the next member", () => member("second", "feature", worktree, { createdAt: DateTime.makeUnsafe(2) }), ]); onDispatch((update) => (update.threadId === "first" ? Effect.die("swap lost") : Effect.void)); - assert.equal( - (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, - "Rebinding", - ); + assert.equal((yield* service.sync({ worktreePath: worktree }))._tag, "Rebinding"); assert.deepEqual( threads().map((thread) => thread.branch), ["feature", "agent/work"], @@ -343,7 +347,7 @@ it.live("picks up a child created during the write when it rereads", () => setThreads([...threads(), member("child", "feature", worktree)]); }), ); - yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }); + yield* service.sync({ worktreePath: worktree }); assert.deepEqual( threads().map((thread) => thread.branch), ["agent/work", "agent/work"], @@ -359,10 +363,53 @@ it.live("terminates when every write is rejected", () => yield* command(worktree, ["switch", "-c", "agent/work"]); setThreads([member("parent", "feature", worktree)]); onDispatch(() => Effect.die("rejected")); - assert.equal( - (yield* service.sync({ worktreePath: worktree, recorded: ["feature"] }))._tag, - "Rebinding", + assert.equal((yield* service.sync({ worktreePath: worktree }))._tag, "Rebinding"); + }), + ), +); + +it.live("corrects a sibling whose write was deferred when another run ends", () => + run( + Effect.gen(function* () { + const { service, command, worktree, threads, setThreads } = yield* fixture; + yield* command(worktree, ["branch", "-m", "feature", "t3/named"]); + const childRun = RunId.make("run-child"); + setThreads([ + member("parent", "t3/temp", worktree), + member("child", "t3/named", worktree, { activeRunId: childRun }), + ]); + const parentEnd = yield* service.sync({ + worktreePath: worktree, + runId: RunId.make("run-parent"), + }); + assert.equal(parentEnd._tag, "Rebinding"); + assert.equal(threads().find((thread) => thread.id === "parent")?.branch, "t3/temp"); + yield* service.sync({ worktreePath: worktree, runId: childRun }); + assert.equal(threads().find((thread) => thread.id === "parent")?.branch, "t3/named"); + }), + ), +); + +it.live("rebinds while a Git command holds the index lock, which observe reports", () => + run( + Effect.gen(function* () { + const { service, command, worktree, dispatched, setThreads } = yield* fixture; + const fs = yield* FileSystem.FileSystem; + yield* command(worktree, ["switch", "-c", "agent/work"]); + setThreads([member("own", "feature", worktree)]); + const index = yield* command(worktree, [ + "rev-parse", + "--path-format=absolute", + "--git-path", + "index", + ]); + yield* fs.writeFileString(`${index}.lock`, ""); + assert.equal((yield* service.sync({ worktreePath: worktree }))._tag, "Rebinding"); + assert.deepEqual( + dispatched.map((update) => update.threadId), + ["own"], ); + assert.equal((yield* Effect.exit(service.observe(worktree)))._tag, "Failure"); }), ), ); diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.ts b/apps/server/src/orchestration-v2/WorktreeBranches.ts index a3b8f189cf6b8..3c27bcfe97c6b 100644 --- a/apps/server/src/orchestration-v2/WorktreeBranches.ts +++ b/apps/server/src/orchestration-v2/WorktreeBranches.ts @@ -13,6 +13,8 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectStore from "./ProjectStore.ts"; +const REMOTE_HEAD_TIMEOUT_MS = 5_000; + export type WorktreeHead = | { readonly _tag: "Unavailable" } | { readonly _tag: "Detached" } @@ -33,10 +35,14 @@ export class WorktreeBranches extends Context.Service< WorktreeBranches, { readonly observe: (worktreePath: string) => Effect.Effect; - /** `runId` names the one run allowed to stay active while this worktree's threads are written. */ + /** + * Rebinds the worktree's threads to its checked-out branch. `runId` names the one run allowed + * to stay active during the write. `threadPaths` lists how those threads store the path, when + * that can differ from `worktreePath`. + */ readonly sync: (input: { readonly worktreePath: string; - readonly recorded: ReadonlyArray; + readonly threadPaths?: ReadonlyArray; readonly runId?: RunId; }) => Effect.Effect; } @@ -49,63 +55,78 @@ const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const failure = (cwd: string, detail: string) => + new GitCommandError({ + operation: "WorktreeBranches.observe", + command: "git", + cwd, + detail, + }); + + // Return null for missing/non-repository cwd; force untranslated git diagnostics for matching. + const read = Effect.fnUntraced(function* (cwd: string, args: string[], allowedExitCode?: number) { + const result = yield* git + .execute({ + operation: "WorktreeBranches.observe", + cwd, + args, + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + }) + .pipe( + Effect.catch((error) => + Effect.flatMap(fs.exists(cwd), (exists) => + exists ? Effect.fail(error) : Effect.succeed(null), + ), + ), + ); + if (result === null) return null; + if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; + const stderr = result.stderr.toLowerCase(); + // Newer Git reports a dangling .git file as "gitfile does not point to a valid repository". + if ( + stderr.includes("not a git repository") || + stderr.includes("does not point to a valid repository") + ) + return null; + return yield* failure( + cwd, + result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", + ); + }); + + const readHead = Effect.fnUntraced(function* (cwd: string) { + // Detached HEAD exits 1; full refs avoid shortening when a tag shares the branch name. + const head = yield* read(cwd, ["symbolic-ref", "--quiet", "HEAD"], 1); + if (head === null) return { _tag: "Unavailable" } as const; + if (head.exitCode !== 0) return { _tag: "Detached" } as const; + const ref = head.stdout.trim(); + const branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length).trim() : null; + return branch === null || branch.startsWith("(") + ? ({ _tag: "Unavailable" } as const) + : ({ _tag: "Named", branch } as const); + }); + const observe: WorktreeBranches["Service"]["observe"] = Effect.fn("WorktreeBranches.observe")( function* (cwd) { - const failure = (detail: string) => - new GitCommandError({ - operation: "WorktreeBranches.observe", - command: "git", - cwd, - detail, - }); - // Return null for missing/non-repository cwd; force untranslated git diagnostics for matching. - const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) { - const result = yield* git - .execute({ - operation: "WorktreeBranches.observe", - cwd, - args, - env: { LC_ALL: "C" }, - allowNonZeroExit: true, - }) - .pipe( - Effect.catch((error) => - Effect.flatMap(fs.exists(cwd), (exists) => - exists ? Effect.fail(error) : Effect.succeed(null), - ), - ), - ); - if (result === null) return null; - if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; - const stderr = result.stderr.toLowerCase(); - // Newer Git reports a dangling .git file as "gitfile does not point to a valid repository". - if ( - stderr.includes("not a git repository") || - stderr.includes("does not point to a valid repository") - ) - return null; - return yield* failure( - result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", - ); - }); - const index = yield* read(["rev-parse", "--git-path", "index"]); + const index = yield* read(cwd, ["rev-parse", "--git-path", "index"]); if (index === null) return { _tag: "Unavailable" }; if (yield* fs.exists(`${path.resolve(cwd, index.stdout.trim())}.lock`)) return yield* failure( + cwd, "Git index is locked. Status will resume when the index lock is removed.", ); - // Detached HEAD exits 1; full refs avoid shortening when a tag shares the branch name. - const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1); - if (head === null) return { _tag: "Unavailable" }; - if (head.exitCode !== 0) return { _tag: "Detached" }; - const ref = head.stdout.trim(); - const branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length).trim() : null; - return branch === null || branch.startsWith("(") - ? { _tag: "Unavailable" } - : { _tag: "Named", branch }; + return yield* readHead(cwd); }, ); + const readMembers = Effect.fnUntraced(function* (paths: ReadonlyArray) { + const snapshots = yield* Effect.forEach(paths, (worktreePath) => + orchestrator.getShellSnapshot({ worktreePath }), + ); + return snapshots.flatMap((snapshot) => [...snapshot.threads, ...snapshot.archivedThreads]); + }); + const gitCommonDir = Effect.fnUntraced(function* (cwd: string) { const physical = (path: string) => fs.realPath(path).pipe(Effect.orElseSucceed(() => path)); const { stdout } = yield* git.execute({ @@ -119,9 +140,8 @@ const make = Effect.gen(function* () { // A replaced folder looks like a switched branch, but its branch means nothing to these threads. const inProjectRepository = Effect.fn("WorktreeBranches.inProjectRepository")( - function* (worktreePath: string) { - const snapshot = yield* orchestrator.getShellSnapshot({ worktreePath }); - const member = snapshot.threads[0] ?? snapshot.archivedThreads[0]; + function* (worktreePath: string, paths: ReadonlyArray) { + const [member] = yield* readMembers(paths); if (member === undefined) return true; const project = yield* projects.getShell(member.projectId); if (Option.isNone(project)) return false; @@ -137,10 +157,23 @@ const make = Effect.gen(function* () { branch: string, ) { const remote = yield* git.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); - return GitVcsDriver.isDefaultBranchName( - branch, - remote === null ? null : yield* git.resolveDefaultBranchName(cwd, remote), - ); + if (remote === null) return GitVcsDriver.isDefaultBranchName(branch, null); + const known = yield* git.resolveDefaultBranchName(cwd, remote); + if (known !== null || !GitVcsDriver.isDefaultBranchName(branch, null)) + return GitVcsDriver.isDefaultBranchName(branch, known); + // Without a local HEAD ref, only the remote can say whether a conventional name is its default. + const remoteHead = yield* git + .execute({ + operation: "WorktreeBranches.isDefaultBranch", + cwd, + args: ["ls-remote", "--symref", "--", remote, "HEAD"], + timeoutMs: REMOTE_HEAD_TIMEOUT_MS, + }) + .pipe( + Effect.map((result) => GitVcsDriver.parseRemoteHeadBranch(result.stdout)), + Effect.orElseSucceed(() => null), + ); + return GitVcsDriver.isDefaultBranchName(branch, remoteHead); }); // Git refuses to recreate a worktree on a branch another checkout holds. @@ -160,16 +193,15 @@ const make = Effect.gen(function* () { }); const writeMembers = Effect.fn("WorktreeBranches.writeMembers")(function* (input: { - readonly worktreePath: string; + readonly paths: ReadonlyArray; readonly branch: string; readonly runId: RunId | undefined; }) { - const { worktreePath, branch, runId } = input; + const { paths, branch, runId } = input; // Oldest first so parents are written before children; reread for threads created mid-write. const visited = new Set(); while (true) { - const snapshot = yield* orchestrator.getShellSnapshot({ worktreePath }); - const members = [...snapshot.threads, ...snapshot.archivedThreads]; + const members = yield* readMembers(paths); // A sibling's agent may be mid-checkout, so HEAD proves nothing yet. Its run end syncs. if (members.some((member) => member.activeRunId !== null && member.activeRunId !== runId)) return; @@ -191,7 +223,7 @@ const make = Effect.gen(function* () { threadId: member.id, branch, expectedBranch: member.branch, - expectedWorktreePath: worktreePath, + expectedWorktreePath: member.worktreePath, }) .pipe( Effect.catchCause((cause) => @@ -208,13 +240,21 @@ const make = Effect.gen(function* () { const sync: WorktreeBranches["Service"]["sync"] = Effect.fn("WorktreeBranches.sync")(function* ({ worktreePath, - recorded, + threadPaths, runId, }) { - const head = yield* observe(worktreePath); + // Not `observe`: a held index lock says nothing about which branch is checked out. + const head = yield* readHead(worktreePath); if (head._tag !== "Named") return head; const { branch } = head; - if (recorded.every((name) => name === branch)) return { _tag: "Bound", branch }; + const paths = threadPaths ?? [worktreePath]; + // Every thread, not only the caller's: a sibling's write may have been deferred to this sync. + const bound = yield* readMembers(paths).pipe( + Effect.map((members) => members.every((member) => member.branch === branch)), + // The write below reads again and logs a read that keeps failing. + Effect.orElseSucceed(() => false), + ); + if (bound) return { _tag: "Bound", branch }; const born = yield* git.execute({ operation: "WorktreeBranches.sync", cwd: worktreePath, @@ -222,13 +262,13 @@ const make = Effect.gen(function* () { allowNonZeroExit: true, }); if (born.exitCode !== 0) return { _tag: "Unavailable" }; - if (!(yield* inProjectRepository(worktreePath))) return { _tag: "Unavailable" }; + if (!(yield* inProjectRepository(worktreePath, paths))) return { _tag: "Unavailable" }; if ( (yield* isDefaultBranch(worktreePath, branch)) || (yield* heldByAnotherCheckout(worktreePath, branch)) ) return { _tag: "Unbound", branch }; - yield* writeMembers({ worktreePath, branch, runId }).pipe( + yield* writeMembers({ paths, branch, runId }).pipe( Effect.catchCause((cause) => Effect.logWarning("worktree branch sync could not read its threads", { worktreePath, diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 90674155e4b5d..aa27176efa588 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1211,6 +1211,115 @@ describe("deleted thread worktrees", () => { ...overrides, }); + it.live.each(["detached", "default"] as const)( + "keeps a %s worktree until another checkout lets go of its recorded branch", + (kind) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture(); + if (kind === "default") yield* command(repo, ["switch", "--detach"]); + yield* command( + worktree, + kind === "default" ? ["switch", "main"] : ["switch", "--detach"], + ); + yield* command(repo, ["switch", "feature"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Another checkout holds the branch a thread here resumes on", + }); + yield* command(repo, ["switch", "--detach"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + yield* command(repo, ["worktree", "add", worktree, "feature"]); + }), + ), + ); + + it.live.each(["detached", "default"] as const)( + "keeps a %s worktree while only its reflog holds an earlier commit", + (kind) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture(); + yield* command(worktree, ["switch", "--detach"]); + yield* command(worktree, ["commit", "--allow-empty", "-m", "only the reflog holds this"]); + const orphan = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); + if (kind === "default") yield* command(repo, ["switch", "--detach"]); + yield* command( + worktree, + kind === "default" ? ["switch", "main"] : ["switch", "--detach", "main"], + ); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Worktree history has commits no branch or tag holds", + }); + yield* command(repo, ["tag", "keeper", orphan]); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + + it.live("rebinds a thread that stores its worktree path with a trailing slash", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, command, threads, setThreads } = yield* cleanupFixture(); + setThreads([shell({ branch: "feature", worktreePath: `${worktree}/` })]); + yield* command(worktree, ["switch", "-c", "agent/work"]); + expect((yield* service.runNow).entries[0]?.reason).toBe( + "Branch updated, re-checked next pass", + ); + expect(threads()[0]?.branch).toBe("agent/work"); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + + it.live("rebinds an owner with no recorded branch, and keeps one it cannot rebind", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, command, threads, setThreads } = yield* cleanupFixture(); + setThreads([shell({ branch: null, worktreePath: worktree })]); + yield* command(worktree, ["switch", "--detach"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Thread has no branch to resume on", + }); + yield* command(worktree, ["switch", "-c", "agent/work"]); + expect((yield* service.runNow).entries[0]?.reason).toBe( + "Branch updated, re-checked next pass", + ); + expect(threads()[0]?.branch).toBe("agent/work"); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + + it.live.each([ + ["detached", null], + ["default", null], + ["default", "main"], + ] as const)( + "applies the usual rules to a %s worktree whose subagent records %s", + ([kind, subagentBranch]) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, setThreads } = yield* cleanupFixture(); + const owner = shell({ branch: "feature", worktreePath: worktree }); + setThreads([owner, subagent(owner, { branch: subagentBranch })]); + if (kind === "default") yield* command(repo, ["switch", "--detach"]); + yield* command( + worktree, + kind === "default" ? ["switch", "main"] : ["switch", "--detach"], + ); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + it.live("removes a deleted owner's worktree after rebinding its surviving subagent", () => runCleanupTest( Effect.gen(function* () { @@ -2265,7 +2374,7 @@ describe("storage cleanup reports and local file policies", () => { const detached = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "kept", - reason: "Detached HEAD has commits no branch or tag holds", + reason: "Worktree history has commits no branch or tag holds", }); yield* command(repo, ["cat-file", "-e", detached]); yield* command(worktree, ["switch", "--orphan", "other"]); @@ -2310,7 +2419,7 @@ describe("storage cleanup reports and local file policies", () => { yield* command(repo, ["update-ref", "refs/t3/checkpoints/run", detached]); expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "kept", - reason: "Detached HEAD has commits no branch or tag holds", + reason: "Worktree history has commits no branch or tag holds", }); expect(yield* fs.exists(worktree)).toBe(true); yield* command(repo, ["tag", "keeper", detached]); @@ -2320,9 +2429,9 @@ describe("storage cleanup reports and local file policies", () => { ), ); it.live.each([ - ["a remote branch a prune can drop", "agent", "kept"], - ["the remote default branch", "main", "removed"], - ] as const)("judges a detached commit held only by %s", ([, remoteBranch, outcome]) => + ["a remote branch a prune can drop", "agent"], + ["the remote default branch, which a stale local HEAD can misname", "main"], + ] as const)("keeps a detached commit held only by %s", ([, remoteBranch]) => runCleanupTest( Effect.gen(function* () { const { service, fs, repo, worktree, command } = yield* cleanupFixture(); @@ -2334,8 +2443,28 @@ describe("storage cleanup reports and local file policies", () => { yield* command(worktree, ["switch", "--detach"]); yield* command(worktree, ["commit", "--allow-empty", "-m", "detached work"]); yield* command(worktree, ["push", "origin", `HEAD:refs/heads/${remoteBranch}`]); - expect((yield* service.runNow).entries[0]).toMatchObject({ outcome }); - expect(yield* fs.exists(worktree)).toBe(outcome === "kept"); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Worktree history has commits no branch or tag holds", + }); + expect(yield* fs.exists(worktree)).toBe(true); + }), + ), + ); + it.live("writes no branch for a thread outside the managed worktree folder", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, command, threads, setThreads } = yield* cleanupFixture(); + const outside = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-outside-" }), + ); + yield* command(repo, ["worktree", "add", "-b", "scratch-work", `${outside}/scratch`]); + setThreads([shell({ branch: null, worktreePath: `${outside}/scratch` })]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Outside the managed worktree folder", + }); + expect(threads()[0]?.branch).toBeNull(); }), ), ); @@ -2408,7 +2537,7 @@ describe("storage cleanup reports and local file policies", () => { ["the branch switches", "Repository or branch changed since check"], [ "the detached commit loses its holding ref", - "Detached HEAD has commits no branch or tag holds", + "Worktree history has commits no branch or tag holds", ], ] as const)("keeps a worktree when %s during size measurement", ([change, reason]) => runCleanupTest( @@ -2460,7 +2589,7 @@ describe("storage cleanup reports and local file policies", () => { yield* command(worktree, ["tag", "keeper"]); expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "kept", - reason: "Detached HEAD has commits no branch or tag holds", + reason: "Worktree history has commits no branch or tag holds", }); expect(yield* fixture.fs.exists(worktree)).toBe(true); }), diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index efd9cddebef5b..8d76e6601ac79 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -594,29 +594,40 @@ const make = Effect.gen(function* () { return file === undefined ? null : `has ignored files (${file})`; }); - // Only refs that stay count. A user cannot recover from refs/t3, and a routine - // `git fetch --prune` drops any remote branch except the default one. - const commitHeldByRef = Effect.fn("StorageCleanup.commitHeldByRef")(function* ( + // Removing a checkout no thread record names can lose its reflog, the last path to earlier work, + // and a thread can resume only if `git worktree add` can still check out its recorded branch. + const unboundCheckoutBlocker = Effect.fn("StorageCleanup.unboundCheckoutBlocker")(function* ( cwd: string, - commitSha: string, + head: string, + checkedOut: string | null, + recorded: ReadonlyArray, ) { - const remote = yield* git.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); - const defaultBranch = remote === null ? null : yield* git.resolveDefaultBranchName(cwd, remote); - const refs = yield* git.execute({ - operation: "StorageCleanup.commitHeldByRef", + const reflog = yield* git.execute({ + operation: "StorageCleanup.unboundCheckoutBlocker", cwd, - args: [ - "for-each-ref", - "--contains", - commitSha, - "--count=1", - "--format=%(refname)", - "refs/heads", - "refs/tags", - ...(defaultBranch === null ? [] : [`refs/remotes/${remote}/${defaultBranch}`]), - ], + args: ["reflog", "show", "--format=%H", "HEAD"], + maxOutputBytes: 16 * 1024 * 1024, + }); + // Only refs that stay count. A user cannot recover from refs/t3, and a routine + // `git fetch --prune` can drop any remote branch, even one a stale local HEAD calls the default. + const unheld = yield* git.execute({ + operation: "StorageCleanup.unboundCheckoutBlocker", + cwd, + args: ["rev-list", "-n", "1", "--ignore-missing", "--stdin", "--not", "--branches", "--tags"], + stdin: `${head}\n${reflog.stdout}`, + }); + if (unheld.stdout.trim() !== "") return "worktree history has commits no branch or tag holds"; + const worktrees = yield* git.execute({ + operation: "StorageCleanup.unboundCheckoutBlocker", + cwd, + args: ["worktree", "list", "--porcelain"], }); - return refs.stdout.trim() !== ""; + const held = new Set( + [...worktrees.stdout.matchAll(/^branch refs\/heads\/(.+?)\r?$/gm)].map((match) => match[1]), + ); + return recorded.some((name) => name !== null && name !== checkedOut && held.has(name)) + ? "another checkout holds the branch a thread here resumes on" + : null; }); const submoduleRemoval = Effect.fn("StorageCleanup.submoduleRemoval")( @@ -858,10 +869,7 @@ const make = Effect.gen(function* () { if (members.length > 1 && worktreeOwner(members) === undefined) return keep(`shared by ${members.length} threads`); if (project === undefined) return keep("project is unavailable"); - if ( - thread.branch === null || - !group.every((member) => storageCleanupThreadIdle(member, now, serverSettings)) - ) + if (!group.every((member) => storageCleanupThreadIdle(member, now, serverSettings))) return keep(waitForStop("thread is running or has pending work")); if (hasTerminal(worktreePath)) return keep(waitForStop("open terminal")); // Compare canonical roots while allowing symlinked parents, never symlinked worktree directories. @@ -877,12 +885,12 @@ const make = Effect.gen(function* () { return keep("not a linked worktree"); const checkout = yield* worktreeBranches.sync({ worktreePath, - recorded: group.map((member) => member.branch), + // `groups` matches resolved paths, but a thread can store another spelling of one. + threadPaths: [...new Set(group.map((member) => member.worktreePath!))], }); if (checkout._tag === "Unavailable") return keep("repository or branch changed"); if (checkout._tag === "Rebinding") return keep("branch updated, re-checked next pass"); - if (group.some((member) => member.branch !== group[0]!.branch)) - return keep("a subagent thread's branch differs from this worktree's branch"); + if (thread.branch === null) return keep("thread has no branch to resume on"); const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); if ( @@ -895,8 +903,17 @@ const make = Effect.gen(function* () { ) return keep(waitForStop("settled moments ago")); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); - if (checkout._tag === "Detached" && !(yield* commitHeldByRef(worktreePath, head.commitSha))) - return keep("detached HEAD has commits no branch or tag holds"); + const checkUnbound = () => + checkout._tag === "Bound" + ? Effect.succeed(null) + : unboundCheckoutBlocker( + worktreePath, + head.commitSha, + checkout._tag === "Unbound" ? checkout.branch : null, + group.map((member) => member.branch), + ); + const unbound = yield* checkUnbound(); + if (unbound !== null) return keep(unbound); const old = !deleted && settings.worktreeAfterDays !== null && @@ -957,10 +974,7 @@ const make = Effect.gen(function* () { timeoutMs: 20_000, }) .pipe( - Effect.map( - (result) => - /^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null, - ), + Effect.map((result) => GitVcsDriver.parseRemoteHeadBranch(result.stdout)), Effect.orElseSucceed(() => null), ); } @@ -1155,8 +1169,8 @@ const make = Effect.gen(function* () { if (changes !== null) return keep(changes); } // Last, so nothing that runs after this can drop the ref that holds the commit. - if (checkout._tag === "Detached" && !(yield* commitHeldByRef(worktreePath, head.commitSha))) - return keep("detached HEAD has commits no branch or tag holds"); + const finalUnbound = yield* checkUnbound(); + if (finalUnbound !== null) return keep(finalUnbound); yield* removeLinkedWorktree({ operation: "StorageCleanup.cleanupGit", cwd: project.workspaceRoot, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2da8b61d9307d..518eb80b093fb 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -306,6 +306,10 @@ export const isDefaultBranchName = (branch: string | null, defaultBranch: string (branch === defaultBranch || (defaultBranch === null && (branch === "main" || branch === "master"))); +/** The default branch in the output of `git ls-remote --symref HEAD`. */ +export const parseRemoteHeadBranch = (stdout: string) => + /^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(stdout)?.[1] ?? null; + export class GitVcsDriver extends Context.Service< GitVcsDriver, { From 4ca31823e42710c81ac0823d2ce68591676576eb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:46:59 -0800 Subject: [PATCH 14/19] feat(server): Maintenance clears code indexes that are no longer relevant Code indexes outlived their use. When Maintenance removed a worktree its index stayed until the next hourly sweep, "Delete now" never touched indexes, and an unsettled worktree that Maintenance kept held its index forever. Maintenance now removes a worktree's index just before the folder, while the path still resolves and a running build can stop first. The index sweep follows every Maintenance run instead of keeping its own timer, so both share one schedule. A worktree's index also goes after 7 days with no activity on its threads, unless one is pinned or marked active. The next agent run rebuilds it. Model: Opus 5.5 Harness: Claude Code in T3 Code Co-Authored-By: Claude Opus 5.5 --- .../knowledge/CodeIndexReactor.test.ts | 63 ++++++++++++++++++- .../src/project/knowledge/CodeIndexReactor.ts | 40 +++++++----- apps/server/src/server.ts | 9 ++- apps/server/src/storageCleanup.test.ts | 22 +++++++ apps/server/src/storageCleanup.ts | 4 ++ docs/user/code-navigation.md | 4 +- 6 files changed, 118 insertions(+), 24 deletions(-) diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts index f2f86f747cadb..88f13e3a52a31 100644 --- a/apps/server/src/project/knowledge/CodeIndexReactor.test.ts +++ b/apps/server/src/project/knowledge/CodeIndexReactor.test.ts @@ -25,6 +25,7 @@ import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; import * as OrchestrationEventStore from "../../persistence/OrchestrationEventStore.ts"; import * as ServerSettingsService from "../../serverSettings.ts"; +import { StorageCleanup } from "../../storageCleanup.ts"; import * as CodeIndexReactor from "./CodeIndexReactor.ts"; import * as CodeIntelligence from "./CodeIntelligence.ts"; @@ -75,8 +76,8 @@ function shell(overrides: Partial = {}): Orchestrati latestRunStartedAt: null, latestRunCompletedAt: null, latestUserMessageAt: null, - createdAt: at(-30 * DAY_MS), - updatedAt: at(-10 * DAY_MS), + createdAt: at(-DAY_MS), + updatedAt: at(-DAY_MS), archivedAt: null, settledOverride: null, settledAt: null, @@ -160,6 +161,41 @@ describe("worktreeIndexReleasable", () => { group: () => [settled(), unrelated()], expected: false, }, + { + name: "an unsettled thread unused for 7 days", + group: () => [shell({ createdAt: at(-30 * DAY_MS), latestRunCompletedAt: at(-7 * DAY_MS) })], + expected: true, + }, + { + name: "an unsettled thread unused for 6 days", + group: () => [shell({ createdAt: at(-30 * DAY_MS), latestRunCompletedAt: at(-6 * DAY_MS) })], + expected: false, + }, + { + name: "a long unused thread that is pinned", + group: () => [shell({ createdAt: at(-30 * DAY_MS), pinnedAt: now })], + expected: false, + }, + { + name: "a long unused thread kept active", + group: () => [shell({ createdAt: at(-30 * DAY_MS), settledOverride: "active" })], + expected: false, + }, + { + name: "a long unused thread with a pending request", + group: () => [ + shell({ + createdAt: at(-30 * DAY_MS), + pendingBackgroundTasks: [{ taskId: "task", kind: "command" }], + }), + ], + expected: false, + }, + { + name: "a long unused thread beside a recently used one", + group: () => [shell({ createdAt: at(-30 * DAY_MS) }), unrelated()], + expected: false, + }, { name: "no threads", group: () => [], expected: false }, ])("$name", ({ group, expected }) => { assert.equal( @@ -266,6 +302,9 @@ interface Harness { readonly orphansRejected: Effect.Effect>; /** The checkouts the activation sweep asked to clear of an older version's folders. */ readonly legacyCheckouts: Effect.Effect>; + /** Resolves when the next sweep starts. */ + readonly nextSweep: Effect.Effect; + readonly finishMaintenanceRun: Effect.Effect; /** Resolves once the sweep asks whether a worktree has an index. */ readonly indexChecked: Effect.Effect; readonly settings: PubSub.PubSub; @@ -292,6 +331,9 @@ function reactorHarness(options: { const effects = yield* Queue.unbounded(); const orphansRejected = yield* Deferred.make>(); const legacyCheckouts = yield* Deferred.make>(); + const sweeps = yield* Queue.unbounded(); + const reports = yield* Queue.unbounded(); + yield* Queue.offer(reports, null); const indexChecked = yield* Deferred.make(); const settings = yield* PubSub.unbounded(); const threadsById = new Map(options.threads.map((thread) => [thread.id, thread])); @@ -315,7 +357,10 @@ function reactorHarness(options: { Effect.as((options.indexed ?? []).includes(checkoutPath)), ), removeLegacyIndexes: (checkoutPaths) => - Deferred.succeed(legacyCheckouts, checkoutPaths).pipe(Effect.asVoid), + Deferred.succeed(legacyCheckouts, checkoutPaths).pipe( + Effect.andThen(Queue.offer(sweeps, undefined)), + Effect.asVoid, + ), removeOrphanedIndexes: (isKnown) => Deferred.succeed( orphansRejected, @@ -323,6 +368,7 @@ function reactorHarness(options: { ).pipe(Effect.asVoid), stopBuilds: Queue.offer(effects, "stop").pipe(Effect.asVoid), }), + Layer.mock(StorageCleanup)({ reports: Stream.fromQueue(reports) }), Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({ latestApplicationSequence: Effect.succeed(0), streamApplicationEvents: () => Stream.fromQueue(events), @@ -371,6 +417,8 @@ function reactorHarness(options: { effects, orphansRejected: Deferred.await(orphansRejected), legacyCheckouts: Deferred.await(legacyCheckouts), + nextSweep: Queue.take(sweeps), + finishMaintenanceRun: Queue.offer(reports, null).pipe(Effect.asVoid), indexChecked: Deferred.await(indexChecked), settings, next: Queue.take(effects), @@ -468,6 +516,15 @@ describe("CodeIndexReactor", () => { }), ); + it.effect("sweeps again whenever a Maintenance run finishes", () => + Effect.gen(function* () { + const harness = yield* reactorHarness({ threads: [shell()] }); + yield* harness.nextSweep; + yield* harness.finishMaintenanceRun; + yield* harness.nextSweep; + }), + ); + it.effect("treats project roots and thread worktrees as known checkouts", () => Effect.gen(function* () { const harness = yield* reactorHarness({ diff --git a/apps/server/src/project/knowledge/CodeIndexReactor.ts b/apps/server/src/project/knowledge/CodeIndexReactor.ts index 5750151c91521..20981c4ad1003 100644 --- a/apps/server/src/project/knowledge/CodeIndexReactor.ts +++ b/apps/server/src/project/knowledge/CodeIndexReactor.ts @@ -9,7 +9,6 @@ import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; @@ -18,6 +17,8 @@ import * as OrchestrationEventStore from "../../persistence/OrchestrationEventSt import { forkParked } from "../../serverActivation.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { + StorageCleanup, + storageCleanupActivityAt, storageCleanupThreadIdle, worktreeOwner, worktreeSettlementChain, @@ -25,11 +26,12 @@ import { import * as CodeIntelligence from "./CodeIntelligence.ts"; import { checkoutPathKey } from "./codeIndexLayout.ts"; -const SWEEP_INTERVAL = "1 hour"; +// An index is a cache the next agent run rebuilds, so an unused one is not worth its disk. +const UNUSED_INDEX_MS = 7 * 24 * 60 * 60 * 1000; /** - * Whether a worktree's index can go: every thread that lives in it is settled and idle, with a - * subagent or side chat following its owner rather than holding the worktree open. + * Whether a worktree's index can go: its threads are idle, and either settled, with a subagent or + * side chat following its owner rather than holding the worktree open, or long unused. */ export function worktreeIndexReleasable( group: ReadonlyArray, @@ -41,25 +43,27 @@ export function worktreeIndexReleasable( ): boolean { if (group.length === 0) return false; const owner = worktreeOwner(group); - // A subagent or side-chat chain follows its owner unless someone kept that thread going; - // unrelated threads must all be settled. + const keptGoing = group.some( + (t) => t.settledAt === null && (t.settledOverride === "active" || t.pinnedAt != null), + ); + // A subagent or side-chat chain follows its owner; unrelated threads must all be settled. const settled = owner !== undefined - ? worktreeSettlementChain(owner, threadsById, now, settings).length > 0 && - group.every( - (t) => t.settledAt !== null || (t.settledOverride !== "active" && t.pinnedAt == null), - ) + ? worktreeSettlementChain(owner, threadsById, now, settings).length > 0 && !keptGoing : group.every((t) => t.settledAt !== null); - return settled && group.every((t) => storageCleanupThreadIdle(t, now, settings)); + const unused = + !keptGoing && group.every((t) => now - storageCleanupActivityAt(t) >= UNUSED_INDEX_MS); + return (settled || unused) && group.every((t) => storageCleanupThreadIdle(t, now, settings)); } /** * Keeps the code index in step with the work happening in a checkout: a project indexes when it * is added, a worktree while an agent runs in it, and a worktree's index goes when its thread - * settles. A sweep also drops indexes for checkouts that no longer exist, even while automatic - * indexing is off. + * settles or goes unused. Each Maintenance run also sweeps, dropping indexes for checkouts that no + * longer exist even while automatic indexing is off. */ const make = Effect.gen(function* () { + const storageCleanup = yield* StorageCleanup; const codeIntelligence = yield* CodeIntelligence.CodeIntelligence; const events = yield* OrchestrationEventStore.OrchestrationEventStore; const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -206,11 +210,13 @@ const make = Effect.gen(function* () { ), ); + // Every Maintenance run, "Delete now" included. The stream starts with the latest report, so + // this also sweeps at activation. yield* forkParked( - sweep().pipe( - Effect.catchCause((cause) => Effect.logWarning("code index sweep failed", { cause })), - Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)), - Effect.asVoid, + Stream.runForEach(storageCleanup.reports, () => + sweep().pipe( + Effect.catchCause((cause) => Effect.logWarning("code index sweep failed", { cause })), + ), ), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 201f654434de7..df4d8270ac2ed 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -654,14 +654,16 @@ const layerProviderInstallationRefresh = Layer.effectDiscard( }), ); +const layerStorageCleanup = StorageCleanup.layer.pipe( + Layer.provide(Layer.merge(ProjectionStoreV2.layer, WorktreeBranches.layer)), +); + const layerRuntimeCoreDependenciesBase = Layer.mergeAll( AgentAwarenessRelay.layer, HeldHooksWaker.layer, layerThreadSettlementWorker, layerIdleCompactionWorker, - StorageCleanup.layer.pipe( - Layer.provide(Layer.merge(ProjectionStoreV2.layer, WorktreeBranches.layer)), - ), + layerStorageCleanup, DatabaseStorage.layer.pipe(Layer.provide(ProjectionMaintenance.layer)), layerThreadPullRequestWorker, DelegatedTaskAnalytics.layer, @@ -692,6 +694,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( }), ).pipe(Layer.provide(DelegatedTaskQuestionReactor.layer), Layer.provide(ProjectionStoreV2.layer)), CodeIndexReactor.layer.pipe( + Layer.provide(layerStorageCleanup), Layer.provide(ProjectionStoreV2.layer), Layer.provide(ProjectStore.layer), Layer.provide(layerServerSettings), diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index aa27176efa588..ffd9a6d303d27 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -23,6 +23,7 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "./config.ts"; +import * as CodeIntelligence from "./project/knowledge/CodeIntelligence.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -355,9 +356,16 @@ const cleanupFixture = ( let terminalListener: ((event: TerminalMetadataStreamEvent) => Effect.Effect) | undefined; let events: Stream.Stream = Stream.empty; const activated = yield* Deferred.make(); + const indexRemovals: string[] = []; const context = yield* Layer.build( StorageCleanup.layer.pipe(Layer.provide(WorktreeBranches.layer)), ).pipe( + Effect.provideService(CodeIntelligence.CodeIntelligence, { + removeCheckoutIndex: (checkoutPath: string) => + Effect.sync(() => { + indexRemovals.push(checkoutPath); + }), + } as unknown as CodeIntelligence.CodeIntelligence["Service"]), Effect.provideService(GitVcsDriver.GitVcsDriver, { ...git, execute: (input) => @@ -595,6 +603,7 @@ const cleanupFixture = ( }, }) ?? Effect.void, fullStatusCalls: () => fullStatusCalls, + indexRemovals: () => indexRemovals, snapshotReads: () => snapshotReads, pullRequestLookups, activate: (domainEvents: Stream.Stream) => { @@ -2468,6 +2477,19 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("removes a worktree's code index with the worktree, not while keeping it", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, indexRemovals } = yield* cleanupFixture(); + yield* fs.writeFileString(`${worktree}/tracked.txt`, "edited\n"); + expect((yield* service.runNow).entries[0]?.outcome).toBe("kept"); + expect(indexRemovals()).toEqual([]); + yield* fs.writeFileString(`${worktree}/tracked.txt`, "original\n"); + expect((yield* service.runNow).entries[0]?.outcome).toBe("removed"); + expect(indexRemovals()).toEqual([worktree]); + }), + ), + ); it.live("follows a branch the agent switched to and removes the worktree next pass", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index f2f1d36164115..854d075a18d82 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -43,6 +43,7 @@ import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts"; import * as WorktreeBranches from "./orchestration-v2/WorktreeBranches.ts"; +import * as CodeIntelligence from "./project/knowledge/CodeIntelligence.ts"; import { forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -294,6 +295,7 @@ const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const git = yield* GitVcsDriver.GitVcsDriver; const worktreeBranches = yield* WorktreeBranches.WorktreeBranches; + const codeIntelligence = yield* CodeIntelligence.CodeIntelligence; const gitManager = yield* GitManager.GitManager; const terminals = yield* TerminalManager.TerminalManager; const fs = yield* FileSystem.FileSystem; @@ -1171,6 +1173,8 @@ const make = Effect.gen(function* () { // Last, so nothing that runs after this can drop the ref that holds the commit. const finalUnbound = yield* checkUnbound(); if (finalUnbound !== null) return keep(finalUnbound); + // While the folder exists: its path resolves, and a build stops before its sources go. + yield* codeIntelligence.removeCheckoutIndex(worktreePath); yield* removeLinkedWorktree({ operation: "StorageCleanup.cleanupGit", cwd: project.workspaceRoot, diff --git a/docs/user/code-navigation.md b/docs/user/code-navigation.md index 3af8a192cb16d..be33b5e3bf16b 100644 --- a/docs/user/code-navigation.md +++ b/docs/user/code-navigation.md @@ -9,7 +9,9 @@ you open or run a thread in it. A worktree has its own index, built and kept cur works in it, or the first time something asks it a code question, such as expanding this section or an agent using the code graph. An idle worktree is not refreshed until an agent is active in it again. A worktree's index is removed when its thread settles, unless other unsettled threads share -the worktree. Until an index is ready, the section says the checkout is not indexed yet. Folders +the worktree. It is also removed when Maintenance removes the worktree, and after 7 days with no +activity on the worktree's threads, unless one of them is pinned or marked active. The next agent +run rebuilds it. Until an index is ready, the section says the checkout is not indexed yet. Folders outside a git repository index only on a code question. Indexes are stored in T3 Code's data directory, not in your repository. Folders an earlier version From 8ed1de0027d3c700c8ed429b839af0b6620c8e4a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:39:19 -0800 Subject: [PATCH 15/19] fix(server): cleanup keeps commits only a reflog holds on every worktree An adversarial review of this branch reproduced commit loss that main prevents. A rebound worktree counted as bound on its next pass, which skipped the reflog check, so cleanup removed it and gc then pruned commits only its reflog held. A deleted owner with no surviving threads skipped the same check. Another repository's linked worktree at the path skipped the repository check and lost its untracked files to `git clean`. Two detached worktrees recording one branch were both removed, and with no local origin/HEAD a default other than main or master could become a thread's recorded branch. The reflog check now runs on every worktree. Rewritten commits land in the reflog too, so an amended or rebased worktree waits until gc expires those entries. Cleanup compares the worktree's repository with the project's before it reads the checkout, by file identity so a differently cased path still matches. It keeps a detached or default-branch worktree while a thread elsewhere records the same branch. The service asks the remote for its default whatever the name, once per checkout. Model: Opus 5.5 Harness: Claude Code in T3 Code Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/WorktreeBranches.test.ts | 39 ++++++ .../src/orchestration-v2/WorktreeBranches.ts | 66 +++++++--- apps/server/src/storageCleanup.test.ts | 122 ++++++++++++++++-- apps/server/src/storageCleanup.ts | 51 +++++--- 4 files changed, 230 insertions(+), 48 deletions(-) diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts index a41342d336684..0e11cddef8ca3 100644 --- a/apps/server/src/orchestration-v2/WorktreeBranches.test.ts +++ b/apps/server/src/orchestration-v2/WorktreeBranches.test.ts @@ -280,6 +280,45 @@ it.live("asks the remote when no local HEAD ref says whether main is its default ), ); +it.live("treats the remote's default of any name as default, and asks once per checkout", () => + run( + Effect.gen(function* () { + const { service, command, repo, worktree, dispatched, setThreads } = yield* fixture; + const fs = yield* FileSystem.FileSystem; + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "worktree-sync-remote-" }); + setThreads([member("thread", "feature", worktree)]); + yield* command(remote, ["init", "--bare", "-b", "develop"]); + yield* command(repo, ["branch", "-m", "develop"]); + yield* command(repo, ["remote", "add", "origin", remote]); + yield* command(repo, ["push", "origin", "develop"]); + yield* command(repo, ["switch", "--detach"]); + yield* command(worktree, ["switch", "develop"]); + assert.equal((yield* service.sync({ worktreePath: worktree }))._tag, "Unbound"); + // Offline, a second question would fall back to main and master and call this a new branch. + yield* fs.remove(remote, { recursive: true }); + assert.equal((yield* service.sync({ worktreePath: worktree }))._tag, "Unbound"); + assert.deepEqual(dispatched, []); + }), + ), +); + +it.live("recognizes the same repository however its path is spelled", () => + run( + Effect.gen(function* () { + const { service, command, repo, worktree } = yield* fixture; + const fs = yield* FileSystem.FileSystem; + assert.isTrue(yield* service.sameRepository(worktree, repo)); + const other = yield* fs.makeTempDirectoryScoped({ prefix: "worktree-sync-other-" }); + yield* command(other, ["init", "-b", "main"]); + assert.isFalse(yield* service.sameRepository(worktree, other)); + assert.isFalse(yield* service.sameRepository(worktree, `${repo}-missing`)); + // Only a case-insensitive disk accepts the other spelling at all. + const upper = repo.toUpperCase(); + if (yield* fs.exists(upper)) assert.isTrue(yield* service.sameRepository(worktree, upper)); + }), + ), +); + it.live("reports a missing worktree as Unavailable", () => run( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/WorktreeBranches.ts b/apps/server/src/orchestration-v2/WorktreeBranches.ts index 3c27bcfe97c6b..214cf03c3d9da 100644 --- a/apps/server/src/orchestration-v2/WorktreeBranches.ts +++ b/apps/server/src/orchestration-v2/WorktreeBranches.ts @@ -45,6 +45,8 @@ export class WorktreeBranches extends Context.Service< readonly threadPaths?: ReadonlyArray; readonly runId?: RunId; }) => Effect.Effect; + /** False when either side cannot be read, so a caller about to delete keeps what it found. */ + readonly sameRepository: (worktreePath: string, projectRoot: string) => Effect.Effect; } >()("t3/orchestration-v2/WorktreeBranches") {} @@ -127,7 +129,7 @@ const make = Effect.gen(function* () { return snapshots.flatMap((snapshot) => [...snapshot.threads, ...snapshot.archivedThreads]); }); - const gitCommonDir = Effect.fnUntraced(function* (cwd: string) { + const repositoryIdentity = Effect.fnUntraced(function* (cwd: string) { const physical = (path: string) => fs.realPath(path).pipe(Effect.orElseSucceed(() => path)); const { stdout } = yield* git.execute({ operation: "WorktreeBranches.sync", @@ -135,9 +137,28 @@ const make = Effect.gen(function* () { args: ["rev-parse", "--git-common-dir"], }); // Git prints this relative to the physical cwd, which can differ from a symlink. - return yield* physical(path.resolve(yield* physical(cwd), stdout.trim())); + const commonDir = yield* physical(path.resolve(yield* physical(cwd), stdout.trim())); + // By file, not by spelling: a case-insensitive disk accepts one folder under several names. + return yield* fs.stat(commonDir).pipe( + Effect.map(({ dev, ino }) => + Option.match(ino, { + onNone: () => commonDir, + onSome: (value) => `${dev}:${value}`, + }), + ), + Effect.orElseSucceed(() => commonDir), + ); }); + const sameRepository: WorktreeBranches["Service"]["sameRepository"] = Effect.fn( + "WorktreeBranches.sameRepository", + )( + function* (worktreePath, projectRoot) { + return (yield* repositoryIdentity(worktreePath)) === (yield* repositoryIdentity(projectRoot)); + }, + Effect.orElseSucceed(() => false), + ); + // A replaced folder looks like a switched branch, but its branch means nothing to these threads. const inProjectRepository = Effect.fn("WorktreeBranches.inProjectRepository")( function* (worktreePath: string, paths: ReadonlyArray) { @@ -145,13 +166,14 @@ const make = Effect.gen(function* () { if (member === undefined) return true; const project = yield* projects.getShell(member.projectId); if (Option.isNone(project)) return false; - return ( - (yield* gitCommonDir(worktreePath)) === (yield* gitCommonDir(project.value.workspaceRoot)) - ); + return yield* sameRepository(worktreePath, project.value.workspaceRoot); }, Effect.orElseSucceed(() => false), ); + // Once per checkout: a thread left on the default branch syncs at the end of every turn. + const remoteDefaults = new Map(); + const isDefaultBranch = Effect.fn("WorktreeBranches.isDefaultBranch")(function* ( cwd: string, branch: string, @@ -159,20 +181,24 @@ const make = Effect.gen(function* () { const remote = yield* git.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); if (remote === null) return GitVcsDriver.isDefaultBranchName(branch, null); const known = yield* git.resolveDefaultBranchName(cwd, remote); - if (known !== null || !GitVcsDriver.isDefaultBranchName(branch, null)) - return GitVcsDriver.isDefaultBranchName(branch, known); - // Without a local HEAD ref, only the remote can say whether a conventional name is its default. - const remoteHead = yield* git - .execute({ - operation: "WorktreeBranches.isDefaultBranch", - cwd, - args: ["ls-remote", "--symref", "--", remote, "HEAD"], - timeoutMs: REMOTE_HEAD_TIMEOUT_MS, - }) - .pipe( - Effect.map((result) => GitVcsDriver.parseRemoteHeadBranch(result.stdout)), - Effect.orElseSucceed(() => null), - ); + if (known !== null) return GitVcsDriver.isDefaultBranchName(branch, known); + // Without a local HEAD ref, only the remote can name its default. + const key = `${cwd}\0${remote}`; + let remoteHead = remoteDefaults.get(key); + if (remoteHead === undefined) { + remoteHead = yield* git + .execute({ + operation: "WorktreeBranches.isDefaultBranch", + cwd, + args: ["ls-remote", "--symref", "--", remote, "HEAD"], + timeoutMs: REMOTE_HEAD_TIMEOUT_MS, + }) + .pipe( + Effect.map((result) => GitVcsDriver.parseRemoteHeadBranch(result.stdout)), + Effect.orElseSucceed(() => null), + ); + remoteDefaults.set(key, remoteHead); + } return GitVcsDriver.isDefaultBranchName(branch, remoteHead); }); @@ -280,7 +306,7 @@ const make = Effect.gen(function* () { return { _tag: "Rebinding", branch }; }); - return WorktreeBranches.of({ observe, sync }); + return WorktreeBranches.of({ observe, sync, sameRepository }); }); export const layer = Layer.effect(WorktreeBranches, make); diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index aa27176efa588..ad4f7b74e9b15 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -366,7 +366,8 @@ const cleanupFixture = ( // Windows the same status check the removal makes before deleting. if ( editBeforeRemoval && - (input.args.includes("remove") || input.args.includes("--porcelain")) + (input.args.includes("remove") || + (input.args.includes("--porcelain") && !input.args.includes("list"))) ) yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); return yield* git.execute(input); @@ -1235,20 +1236,30 @@ describe("deleted thread worktrees", () => { ), ); - it.live.each(["detached", "default"] as const)( - "keeps a %s worktree while only its reflog holds an earlier commit", + it.live.each(["detached", "default", "switched-branch", "recorded-branch"] as const)( + "keeps a %s worktree while only its reflog holds a commit, until a ref holds it", (kind) => runCleanupTest( Effect.gen(function* () { const { service, fs, repo, worktree, command } = yield* cleanupFixture(); - yield* command(worktree, ["switch", "--detach"]); + if (kind === "detached" || kind === "default") + yield* command(worktree, ["switch", "--detach"]); + if (kind === "switched-branch") yield* command(worktree, ["switch", "-c", "agent/work"]); yield* command(worktree, ["commit", "--allow-empty", "-m", "only the reflog holds this"]); const orphan = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); - if (kind === "default") yield* command(repo, ["switch", "--detach"]); - yield* command( - worktree, - kind === "default" ? ["switch", "main"] : ["switch", "--detach", "main"], - ); + if (kind === "detached") yield* command(worktree, ["switch", "--detach", "main"]); + if (kind === "default") { + yield* command(repo, ["switch", "--detach"]); + yield* command(worktree, ["switch", "main"]); + } + if (kind === "switched-branch") { + yield* command(worktree, ["reset", "--hard", "HEAD~1"]); + expect((yield* service.runNow).entries[0]?.reason).toBe( + "Branch updated, re-checked next pass", + ); + } + if (kind === "recorded-branch") + yield* command(worktree, ["commit", "--amend", "--allow-empty", "-m", "rewritten"]); expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "kept", reason: "Worktree history has commits no branch or tag holds", @@ -1256,10 +1267,102 @@ describe("deleted thread worktrees", () => { yield* command(repo, ["tag", "keeper", orphan]); expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); expect(yield* fs.exists(worktree)).toBe(false); + yield* command(repo, ["cat-file", "-e", orphan]); + }), + ), + ); + + it.live("keeps a deleted owner's worktree while only its reflog holds a commit", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, setThreads, setDeletedThreads } = + yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + setThreads([]); + setDeletedThreads([parent(worktree)]); + yield* command(worktree, ["switch", "--detach"]); + yield* command(worktree, ["commit", "--allow-empty", "-m", "only the reflog holds this"]); + yield* command(repo, ["switch", "--detach"]); + yield* command(worktree, ["switch", "main"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Worktree history has commits no branch or tag holds", + }); + expect(yield* fs.exists(worktree)).toBe(true); + }), + ), + ); + + it.live.each(["live", "deleted"] as const)( + "keeps another repository's worktree at a %s owner's path, with its files", + (owner) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, setThreads, setDeletedThreads } = + yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: 8, + worktreeOnDelete: true, + worktreeKeepWhen: "tracked-changes", + }); + const other = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-foreign-" }), + ); + yield* command(repo, ["worktree", "remove", "--force", worktree]); + yield* command(other, ["init", "-b", "main"]); + yield* command(other, ["config", "user.email", "foreign@example.test"]); + yield* command(other, ["config", "user.name", "Foreign"]); + yield* command(other, ["commit", "--allow-empty", "-m", "foreign"]); + yield* command(other, ["worktree", "add", "--detach", worktree]); + yield* fs.writeFileString(`${worktree}/precious.txt`, "mine\n"); + if (owner === "deleted") { + setThreads([]); + setDeletedThreads([parent(worktree)]); + } + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Repository or branch changed", + }); + expect(yield* fs.readFileString(`${worktree}/precious.txt`)).toBe("mine\n"); }), ), ); + it.live("keeps detached worktrees while a thread in another one records the same branch", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, config, setThreads } = + yield* cleanupFixture(); + const second = `${config.worktreesDir}/feature-2`; + yield* command(repo, ["worktree", "add", "--detach", second]); + yield* command(worktree, ["switch", "--detach"]); + setThreads([ + shell({ branch: "feature", worktreePath: worktree }), + shell({ + id: ThreadId.make("thread-2"), + branch: "feature", + worktreePath: second, + lineage: { + rootThreadId: ThreadId.make("thread-2"), + parentThreadId: null, + relationshipToParent: null, + }, + }), + ]); + const report = yield* service.runNow; + expect(report.entries.map((entry) => entry.outcome)).toEqual(["kept", "kept"]); + expect(report.entries[0]?.reason).toBe( + "A thread in another worktree records the same branch", + ); + expect(yield* fs.exists(worktree)).toBe(true); + expect(yield* fs.exists(second)).toBe(true); + }), + ), + ); + it.live("rebinds a thread that stores its worktree path with a trailing slash", () => runCleanupTest( Effect.gen(function* () { @@ -2377,6 +2480,7 @@ describe("storage cleanup reports and local file policies", () => { reason: "Worktree history has commits no branch or tag holds", }); yield* command(repo, ["cat-file", "-e", detached]); + yield* command(repo, ["tag", "keeper", detached]); yield* command(worktree, ["switch", "--orphan", "other"]); expect((yield* service.runNow).entries[0]).toMatchObject(changed); yield* command(worktree, ["switch", "-c", "(feature)", "feature"]); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 8d76e6601ac79..96a6f962ed6a0 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -594,16 +594,17 @@ const make = Effect.gen(function* () { return file === undefined ? null : `has ignored files (${file})`; }); - // Removing a checkout no thread record names can lose its reflog, the last path to earlier work, - // and a thread can resume only if `git worktree add` can still check out its recorded branch. - const unboundCheckoutBlocker = Effect.fn("StorageCleanup.unboundCheckoutBlocker")(function* ( + // Removing a worktree loses its reflog, the last path to commits no branch holds. Rewritten + // commits land there too, but gc expires them, so that only delays removal. A thread can resume + // only if `git worktree add` can still check out its recorded branch. + const checkoutBlocker = Effect.fn("StorageCleanup.checkoutBlocker")(function* ( cwd: string, head: string, checkedOut: string | null, recorded: ReadonlyArray, ) { const reflog = yield* git.execute({ - operation: "StorageCleanup.unboundCheckoutBlocker", + operation: "StorageCleanup.checkoutBlocker", cwd, args: ["reflog", "show", "--format=%H", "HEAD"], maxOutputBytes: 16 * 1024 * 1024, @@ -611,14 +612,14 @@ const make = Effect.gen(function* () { // Only refs that stay count. A user cannot recover from refs/t3, and a routine // `git fetch --prune` can drop any remote branch, even one a stale local HEAD calls the default. const unheld = yield* git.execute({ - operation: "StorageCleanup.unboundCheckoutBlocker", + operation: "StorageCleanup.checkoutBlocker", cwd, args: ["rev-list", "-n", "1", "--ignore-missing", "--stdin", "--not", "--branches", "--tags"], stdin: `${head}\n${reflog.stdout}`, }); if (unheld.stdout.trim() !== "") return "worktree history has commits no branch or tag holds"; const worktrees = yield* git.execute({ - operation: "StorageCleanup.unboundCheckoutBlocker", + operation: "StorageCleanup.checkoutBlocker", cwd, args: ["worktree", "list", "--porcelain"], }); @@ -883,6 +884,9 @@ const make = Effect.gen(function* () { return keep("contains a project checkout"); if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return keep("not a linked worktree"); + // Before anything reads the checkout: another repository's worktree here is not ours to judge. + if (!(yield* worktreeBranches.sameRepository(worktreePath, project.workspaceRoot))) + return keep("repository or branch changed"); const checkout = yield* worktreeBranches.sync({ worktreePath, // `groups` matches resolved paths, but a thread can store another spelling of one. @@ -903,17 +907,26 @@ const make = Effect.gen(function* () { ) return keep(waitForStop("settled moments ago")); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); - const checkUnbound = () => - checkout._tag === "Bound" - ? Effect.succeed(null) - : unboundCheckoutBlocker( - worktreePath, - head.commitSha, - checkout._tag === "Unbound" ? checkout.branch : null, - group.map((member) => member.branch), - ); - const unbound = yield* checkUnbound(); - if (unbound !== null) return keep(unbound); + const checkCheckout = () => + checkoutBlocker( + worktreePath, + head.commitSha, + checkout._tag === "Detached" ? null : checkout.branch, + group.map((member) => member.branch), + ); + const blocker = yield* checkCheckout(); + if (blocker !== null) return keep(blocker); + // Only one checkout can hold a branch, so a second thread could not resume on it. + if ( + checkout._tag !== "Bound" && + snapshot.threads.some( + (other) => + other.worktreePath !== null && + path.resolve(other.worktreePath) !== worktreePath && + group.some((member) => member.branch !== null && member.branch === other.branch), + ) + ) + return keep("a thread in another worktree records the same branch"); const old = !deleted && settings.worktreeAfterDays !== null && @@ -1169,8 +1182,8 @@ const make = Effect.gen(function* () { if (changes !== null) return keep(changes); } // Last, so nothing that runs after this can drop the ref that holds the commit. - const finalUnbound = yield* checkUnbound(); - if (finalUnbound !== null) return keep(finalUnbound); + const finalBlocker = yield* checkCheckout(); + if (finalBlocker !== null) return keep(finalBlocker); yield* removeLinkedWorktree({ operation: "StorageCleanup.cleanupGit", cwd: project.workspaceRoot, From 053af18436f421efd9c6fa198057c80e3400a968 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:52:48 -0800 Subject: [PATCH 16/19] fix(server): a same-named branch in another project keeps no worktree Cleanup keeps a detached or default-branch worktree while a thread in another worktree records the same branch, because only one checkout can hold it. The rule compared branch names across all projects, so a same-named branch in an unrelated repository kept the worktree with an untrue reason. It now compares threads in the same project. Two projects sharing one repository are not compared, and resume there still reports Git's error. Model: Opus 5.5 Harness: Claude Code in T3 Code Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 38 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 1 + 2 files changed, 39 insertions(+) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 4f11dfa13c225..613a7fe3fe973 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1372,6 +1372,44 @@ describe("deleted thread worktrees", () => { ), ); + it.live("does not keep a detached worktree for a same-named branch in another project", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, config, setThreads, setProjectRoots } = + yield* cleanupFixture(); + const other = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-unrelated-" }), + ); + yield* command(other, ["init", "-b", "main"]); + yield* command(other, ["config", "user.email", "other@example.test"]); + yield* command(other, ["config", "user.name", "Other"]); + yield* command(other, ["commit", "--allow-empty", "-m", "other repo"]); + const otherWorktree = `${config.worktreesDir}/unrelated-feature`; + yield* command(other, ["worktree", "add", "-b", "feature", otherWorktree]); + setProjectRoots([repo, other]); + yield* command(worktree, ["switch", "--detach"]); + setThreads([ + shell({ branch: "feature", worktreePath: worktree }), + shell({ + id: ThreadId.make("thread-2"), + projectId: ProjectId.make("project-2"), + branch: "feature", + worktreePath: otherWorktree, + lineage: { + rootThreadId: ThreadId.make("thread-2"), + parentThreadId: null, + relationshipToParent: null, + }, + }), + ]); + const report = yield* service.runNow; + expect(report.entries.find((entry) => entry.threadId === "thread-1")).toMatchObject({ + outcome: "removed", + }); + }), + ), + ); + it.live("rebinds a thread that stores its worktree path with a trailing slash", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index fcddc7ba23356..ac0433d53ff3d 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -923,6 +923,7 @@ const make = Effect.gen(function* () { checkout._tag !== "Bound" && snapshot.threads.some( (other) => + other.projectId === thread.projectId && other.worktreePath !== null && path.resolve(other.worktreePath) !== worktreePath && group.some((member) => member.branch !== null && member.branch === other.branch), From 998e9756f1c10e9b5a8d3fa224c43909765d0970 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:27:00 -0800 Subject: [PATCH 17/19] fix(index): recheck cleanup safety after stopping checkout indexes Wait for index shutdown before the final activity, branch, local-change, submodule, and recovery-reference checks. This preserves late tracked edits when submodule removal requires force and retains detached history if its last holding ref disappears during shutdown. Keep worktrees when reflog or worktree-list inspection is truncated. Use Effect Path for native cleanup fixture paths and preserve the Android test-platform environment variable. --- apps/server/src/storageCleanup.test.ts | 204 ++++++++++++++------- apps/server/src/storageCleanup.ts | 14 +- packages/shared/src/testing/providerEnv.ts | 2 + 3 files changed, 146 insertions(+), 74 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 613a7fe3fe973..fb66c827b3099 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -8,7 +8,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type * as Path from "effect/Path"; +import * as Path from "effect/Path"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { SCAN_CACHE_FILE_NAME } from "./usage/usageScanCache.ts"; @@ -322,6 +322,7 @@ const cleanupFixture = ( ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; const git = yield* GitVcsDriver.GitVcsDriver; const repo = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" })); @@ -335,7 +336,7 @@ const cleanupFixture = ( yield* command(repo, ["add", "."]); yield* command(repo, ["commit", "-m", "initial"]); yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); - const worktree = `${config.worktreesDir}/feature`; + const worktree = path.join(config.worktreesDir, "feature"); yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); let settings: import("@t3tools/contracts").ServerSettings = { ...DEFAULT_SERVER_SETTINGS, @@ -357,12 +358,14 @@ const cleanupFixture = ( let events: Stream.Stream = Stream.empty; const activated = yield* Deferred.make(); const indexRemovals: string[] = []; + let onIndexRemoval: Effect.Effect = Effect.void; const context = yield* Layer.build( StorageCleanup.layer.pipe(Layer.provide(WorktreeBranches.layer)), ).pipe( Effect.provideService(CodeIntelligence.CodeIntelligence, { removeCheckoutIndex: (checkoutPath: string) => - Effect.sync(() => { + Effect.gen(function* () { + yield* onIndexRemoval; indexRemovals.push(checkoutPath); }), } as unknown as CodeIntelligence.CodeIntelligence["Service"]), @@ -530,6 +533,7 @@ const cleanupFixture = ( editBeforeRemoval = true; }, fs, + path, worktree, config, command, @@ -605,6 +609,9 @@ const cleanupFixture = ( }) ?? Effect.void, fullStatusCalls: () => fullStatusCalls, indexRemovals: () => indexRemovals, + setOnIndexRemoval: (hook: Effect.Effect) => { + onIndexRemoval = hook; + }, snapshotReads: () => snapshotReads, pullRequestLookups, activate: (domainEvents: Stream.Stream) => { @@ -810,27 +817,45 @@ it.live("keeps submodules when stored repository metadata is malformed", () => ), ); -it.live("preserves late tracked edits when submodules were never initialized", () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - const { service, fs, worktree, command } = fixture; - const head = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); - yield* fs.writeFileString( - `${worktree}/.gitmodules`, - '[submodule "module"]\n\tpath = module\n\turl = /unused\n', - ); - yield* command(worktree, ["add", ".gitmodules"]); - yield* command(worktree, ["update-index", "--add", "--cacheinfo", `160000,${head},module`]); - yield* command(worktree, ["commit", "-m", "record uninitialized module"]); - yield* fs.makeDirectory(`${worktree}/module`); - fixture.editBeforeRemoval(); - expect( - (yield* service.runNow).entries.find((entry) => entry.path === worktree), - ).toMatchObject({ outcome: "failed" }); - expect(yield* fs.readFileString(`${worktree}/tracked.txt`)).toBe("late edit\n"); - }), - ), +it.live.each([ + ["uninitialized", "git removal", "failed"], + ["initialized", "index shutdown", "kept"], +] as const)( + "preserves late tracked edits with %s submodules during %s", + ([initialization, timing, outcome]) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* initialization === "initialized" + ? submoduleCleanupFixture() + : cleanupFixture(); + const { service, fs, worktree, command } = fixture; + if (initialization === "uninitialized") { + const head = (yield* command(worktree, ["rev-parse", "HEAD"])).stdout.trim(); + yield* fs.writeFileString( + `${worktree}/.gitmodules`, + '[submodule "module"]\n\tpath = module\n\turl = /unused\n', + ); + yield* command(worktree, ["add", ".gitmodules"]); + yield* command(worktree, [ + "update-index", + "--add", + "--cacheinfo", + `160000,${head},module`, + ]); + yield* command(worktree, ["commit", "-m", "record uninitialized module"]); + yield* fs.makeDirectory(`${worktree}/module`); + } + if (timing === "index shutdown") { + fixture.setOnIndexRemoval( + fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n").pipe(Effect.orDie), + ); + } else fixture.editBeforeRemoval(); + expect( + (yield* service.runNow).entries.find((entry) => entry.path === worktree), + ).toMatchObject({ outcome }); + expect(yield* fs.readFileString(`${worktree}/tracked.txt`)).toBe("late edit\n"); + }), + ), ); describe("subagent worktree ownership", () => { @@ -1281,6 +1306,35 @@ describe("deleted thread worktrees", () => { ), ); + it.live.each([ + ["reflog", "Worktree history exceeds the inspection limit"], + ["worktree", "Worktree list exceeds the inspection limit"], + ] as const)("keeps a worktree when its %s inspection is truncated", ([command, reason]) => + runCleanupTest( + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const { service, fs, worktree } = yield* cleanupFixture().pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, { + ...git, + execute: (input) => + git + .execute(input) + .pipe( + Effect.map((result) => + input.operation === "StorageCleanup.checkoutBlocker" && + input.args[0] === command + ? { ...result, stdoutTruncated: true } + : result, + ), + ), + }), + ); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "kept", reason }); + expect(yield* fs.exists(worktree)).toBe(true); + }), + ), + ); + it.live("keeps a deleted owner's worktree while only its reflog holds a commit", () => runCleanupTest( Effect.gen(function* () { @@ -1897,8 +1951,8 @@ describe("orphan worktree reports", () => { for (let index = 0; index < 205; index++) { yield* fixture.fs.makeDirectory(`${fixture.config.worktreesDir}/busy-${index}`); } - const orphans = ["one", "two"].map( - (name) => `${fixture.config.worktreesDir}/repository/${name}`, + const orphans = ["one", "two"].map((name) => + fixture.path.join(fixture.config.worktreesDir, "repository", name), ); for (const orphan of orphans) { yield* fixture.fs.makeDirectory(orphan, { recursive: true }); @@ -1954,7 +2008,7 @@ describe("orphan worktree reports", () => { const loop = `${fixture.repo}/loop`; yield* fixture.fs.symlink(loop, loop); fixture.setProjectRoots([fixture.repo, loop]); - const orphan = `${fixture.config.worktreesDir}/repository/orphan`; + const orphan = fixture.path.join(fixture.config.worktreesDir, "repository", "orphan"); yield* fixture.fs.makeDirectory(orphan, { recursive: true }); yield* fixture.fs.utimes(orphan, 1, 1); expect((yield* fixture.service.runNow).entries).toEqual([ @@ -1971,7 +2025,11 @@ describe("orphan worktree reports", () => { Effect.gen(function* () { const fixture = yield* cleanupFixture(); fixture.setThreads([]); - const orphan = `${fixture.config.worktreesDir}/${location === "nested" ? "repository/" : ""}orphan`; + const orphan = fixture.path.join( + fixture.config.worktreesDir, + location === "nested" ? "repository" : "", + "orphan", + ); yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); const sizes = yield* Effect.forEach(yield* fixture.fs.readDirectory(orphan), (name) => allocatedBytes(fixture.fs, `${orphan}/${name}`), @@ -2091,13 +2149,13 @@ describe("orphan worktree reports", () => { fixture.setThreads([]); const outside = yield* fixture.fs.makeTempDirectoryScoped(); yield* fixture.fs.makeDirectory(`${outside}/checkout`); - const projectDirectory = `${fixture.config.worktreesDir}/repository`; + const projectDirectory = fixture.path.join(fixture.config.worktreesDir, "repository"); yield* fixture.fs.makeDirectory(projectDirectory); yield* fixture.fs.symlink(outside, `${fixture.config.worktreesDir}/linked-project`); yield* fixture.fs.symlink(`${outside}/checkout`, `${projectDirectory}/linked-checkout`); yield* fixture.fs.symlink(`${outside}/missing`, `${projectDirectory}/dangling-link`); expect((yield* fixture.service.runNow).entries).toEqual([]); - const orphan = `${projectDirectory}/orphan`; + const orphan = fixture.path.join(projectDirectory, "orphan"); yield* fixture.fs.makeDirectory(orphan); fixture.disable(); expect((yield* fixture.service.runNow).entries).toEqual([]); @@ -2111,7 +2169,7 @@ describe("orphan worktree reports", () => { Effect.gen(function* () { const fixture = yield* cleanupFixture(); fixture.setThreads([]); - const folder = `${fixture.config.worktreesDir}/repository/leftover`; + const folder = fixture.path.join(fixture.config.worktreesDir, "repository", "leftover"); yield* fixture.fs.makeDirectory(folder, { recursive: true }); yield* fixture.fs.writeFileString(`${folder}/payload`, "leftover data"); yield* fixture.fs.utimes(folder, 1, 1); @@ -2138,7 +2196,7 @@ describe("orphan worktree reports", () => { const fixture = yield* cleanupFixture(); fixture.setThreads([]); const blocked = `${fixture.config.worktreesDir}/a-inaccessible`; - const orphan = `${fixture.config.worktreesDir}/z-repository/orphan`; + const orphan = fixture.path.join(fixture.config.worktreesDir, "z-repository", "orphan"); yield* fixture.fs.makeDirectory(blocked); yield* fixture.fs.makeDirectory(orphan, { recursive: true }); yield* fixture.fs.writeFileString(`${orphan}/file`, "x"); @@ -2168,10 +2226,10 @@ describe("orphan worktree reports", () => { Effect.provideService(HostProcess.Platform, platform), ); fixture.setThreads([]); - const parent = `${fixture.config.worktreesDir}/repository`; - const referenced = `${parent}/Referenced`; - const project = `${parent}/Project`; - const nested = `${parent}/Nested`; + const parent = fixture.path.join(fixture.config.worktreesDir, "repository"); + const referenced = fixture.path.join(parent, "Referenced"); + const project = fixture.path.join(parent, "Project"); + const nested = fixture.path.join(parent, "Nested"); for (const folder of [referenced, project, nested]) { yield* fixture.fs.makeDirectory(`${folder}/child`, { recursive: true }); yield* fixture.fs.utimes(folder, 1, 1); @@ -2180,7 +2238,9 @@ describe("orphan worktree reports", () => { fixture.setProjectRoots([fixture.repo, project, `${nested}/child`]); const report = yield* fixture.service.runNow; expect(report.entries.map((entry) => entry.path).sort()).toEqual( - platform === "win32" ? [] : [referenced], + platform === "win32" || (yield* fixture.fs.exists(referenced.toUpperCase())) + ? [] + : [referenced], ); }), ), @@ -2726,38 +2786,44 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live("keeps a detached worktree when its holding ref goes during the final checks", () => - runCleanupTest( - Effect.gen(function* () { - const git = yield* GitVcsDriver.GitVcsDriver; - let localChangeChecks = 0; - let repo = ""; - const { service, command, worktree, ...fixture } = yield* cleanupFixture().pipe( - Effect.provideService(GitVcsDriver.GitVcsDriver, { - ...git, - execute: (input) => - Effect.gen(function* () { - if (input.operation === "StorageCleanup.localChanges" && ++localChangeChecks === 2) - yield* git.execute({ - operation: "test", - cwd: repo, - args: ["tag", "--delete", "keeper"], - }); - return yield* git.execute(input); - }), - }), - ); - repo = fixture.repo; - yield* command(worktree, ["switch", "--detach"]); - yield* command(worktree, ["commit", "--allow-empty", "-m", "detached work"]); - yield* command(worktree, ["tag", "keeper"]); - expect((yield* service.runNow).entries[0]).toMatchObject({ - outcome: "kept", - reason: "Worktree history has commits no branch or tag holds", - }); - expect(yield* fixture.fs.exists(worktree)).toBe(true); - }), - ), + it.live.each(["final status checks", "code index cleanup"] as const)( + "keeps a detached worktree when its holding ref goes during %s", + (phase) => + runCleanupTest( + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + let localChangeChecks = 0; + let repo = ""; + const removeHoldingRef = Effect.suspend(() => + git.execute({ operation: "test", cwd: repo, args: ["tag", "--delete", "keeper"] }), + ).pipe(Effect.orDie, Effect.asVoid); + const { service, command, worktree, ...fixture } = yield* cleanupFixture().pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, { + ...git, + execute: (input) => + Effect.gen(function* () { + if ( + phase === "final status checks" && + input.operation === "StorageCleanup.localChanges" && + ++localChangeChecks === 2 + ) + yield* removeHoldingRef; + return yield* git.execute(input); + }), + }), + ); + repo = fixture.repo; + if (phase === "code index cleanup") fixture.setOnIndexRemoval(removeHoldingRef); + yield* command(worktree, ["switch", "--detach"]); + yield* command(worktree, ["commit", "--allow-empty", "-m", "detached work"]); + yield* command(worktree, ["tag", "keeper"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Worktree history has commits no branch or tag holds", + }); + expect(yield* fixture.fs.exists(worktree)).toBe(true); + }), + ), ); it.live.each([ ["any-local-files", ".env", "kept"], diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 02318443b03fa..37f84c1a98edb 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -625,6 +625,8 @@ const make = Effect.gen(function* () { args: ["reflog", "show", "--format=%H", "HEAD"], maxOutputBytes: 16 * 1024 * 1024, }); + if (reflog.stdoutTruncated || reflog.stderrTruncated) + return "worktree history exceeds the inspection limit"; // Only refs that stay count. A user cannot recover from refs/t3, and a routine // `git fetch --prune` can drop any remote branch, even one a stale local HEAD calls the default. const unheld = yield* git.execute({ @@ -639,6 +641,8 @@ const make = Effect.gen(function* () { cwd, args: ["worktree", "list", "--porcelain"], }); + if (worktrees.stdoutTruncated || worktrees.stderrTruncated) + return "worktree list exceeds the inspection limit"; const held = new Set( [...worktrees.stdout.matchAll(/^branch refs\/heads\/(.+?)\r?$/gm)].map((match) => match[1]), ); @@ -1162,8 +1166,10 @@ const make = Effect.gen(function* () { const blocked = yield* blockedSinceCheck(); if (blocked !== null) return keep(blocked); const bytes = yield* measureWorktree(worktreePath); - const blockedWhileMeasuring = yield* blockedSinceCheck(); - if (blockedWhileMeasuring !== null) return keep(blockedWhileMeasuring); + // Stop builds while their sources still exist, then recheck activity and checkout safety. + yield* codeIntelligence.removeCheckoutIndex(worktreePath); + const blockedBeforeRemoval = yield* blockedSinceCheck(); + if (blockedBeforeRemoval !== null) return keep(blockedBeforeRemoval); const observed = yield* worktreeBranches.observe(worktreePath); if ( checkout._tag === "Detached" @@ -1198,11 +1204,9 @@ const make = Effect.gen(function* () { const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); } - // Last, so nothing that runs after this can drop the ref that holds the commit. + // Recheck recovery refs immediately before removal. const finalBlocker = yield* checkCheckout(); if (finalBlocker !== null) return keep(finalBlocker); - // While the folder exists: its path resolves, and a build stops before its sources go. - yield* codeIntelligence.removeCheckoutIndex(worktreePath); yield* removeLinkedWorktree({ operation: "StorageCleanup.cleanupGit", cwd: project.workspaceRoot, diff --git a/packages/shared/src/testing/providerEnv.ts b/packages/shared/src/testing/providerEnv.ts index ac07867c20473..50db357a45f9e 100644 --- a/packages/shared/src/testing/providerEnv.ts +++ b/packages/shared/src/testing/providerEnv.ts @@ -10,6 +10,8 @@ const KEPT_ENV = new Set([ "T3CODE_TRANSFER_BUDGET_RESULT_PATH", // Opt-in: points the HtmlRender tests at a headless browser, which they skip without. "T3CODE_TEST_HEADLESS_SHELL", + // The mobile test script sets this; tests must see the platform their code was compiled for. + "T3_MOBILE_TEST_PLATFORM", ]); if (process.env.T3CODE_TEST_KEEP_PROVIDER_ENV !== "1") { From 6fe77ad584d27bc3d2cc9b4084cfcf893c09919b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:58:57 -0800 Subject: [PATCH 18/19] test(server): make cleanup fixtures portable to Windows Assert report accounting against filesystem-provided allocated bytes instead of assuming tiny files allocate nonzero space. Move and restore the Git-created hidden file for the invalid-pointer case. --- apps/server/src/storageCleanup.test.ts | 29 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index fb66c827b3099..8ea38338c2f35 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2557,6 +2557,10 @@ describe("storage cleanup reports and local file policies", () => { }), ); const root = yield* fs.realPath(fixture.worktree); + const sizes = yield* Effect.forEach(yield* fs.readDirectory(root), (name) => + allocatedBytes(fs, fixture.path.join(root, name)), + ); + const expectedBytes = sizes.reduce((sum, size) => sum + size, 0); fixture.setOpenSessionCwd(fixture.worktree); const kept = yield* fixture.service.runNow; expect(kept.entries[0]).toMatchObject({ @@ -2567,8 +2571,8 @@ describe("storage cleanup reports and local file policies", () => { expect(walked.filter((directory) => directory.startsWith(root))).toEqual([]); fixture.setSessionLive(false); const removed = yield* fixture.service.runNow; - expect(removed.entries[0]).toMatchObject({ outcome: "removed" }); - expect(removed.entries[0]?.bytes).toBeGreaterThan(0); + expect(removed.entries[0]).toMatchObject({ outcome: "removed", bytes: expectedBytes }); + expect(removed.bytesFreed).toBe(expectedBytes); expect(walked).toContain(root); }), ), @@ -2611,10 +2615,14 @@ describe("storage cleanup reports and local file policies", () => { expect(locked?.reason).toContain("Git index is locked"); expect(yield* fs.readFileString(`${worktree}/tracked.txt`)).toBe("original\n"); yield* fs.remove(`${index.trim()}.lock`); - const gitFile = yield* fs.readFileString(`${worktree}/.git`); - yield* fs.writeFileString(`${worktree}/.git`, "gitdir: /missing-git-directory\n"); + const gitFile = `${worktree}/.git`; + const savedGitFile = `${worktree}/.git-original`; + // Git marks this file hidden on Windows, where overwriting it fails with EPERM. + yield* fs.rename(gitFile, savedGitFile); + yield* fs.writeFileString(gitFile, "gitdir: /missing-git-directory\n"); expect((yield* service.runNow).entries[0]).toMatchObject(changed); - yield* fs.writeFileString(`${worktree}/.git`, gitFile); + yield* fs.remove(gitFile); + yield* fs.rename(savedGitFile, gitFile); expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); expect(fullStatusCalls()).toBe(0); }), @@ -2997,7 +3005,11 @@ describe("storage cleanup reports and local file policies", () => { it.live("caps entries while preserving totals and prioritizing removals", () => runCleanupTest( Effect.gen(function* () { - const { service, fs, worktree, setThreads } = yield* cleanupFixture(); + const { service, fs, path, worktree, setThreads } = yield* cleanupFixture(); + const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => + allocatedBytes(fs, path.join(worktree, name)), + ); + const expectedBytes = sizes.reduce((sum, size) => sum + size, 0); const threads = []; for (let i = 0; i < 205; i++) { const worktreePath = `${worktree}-${i}`; @@ -3017,9 +3029,8 @@ describe("storage cleanup reports and local file policies", () => { expect(report.entries).toHaveLength(200); expect(report.omittedCount).toBe(6); expect(report.counts).toEqual({ removed: 1, kept: 205, failed: 0 }); - expect(report.entries[0]?.outcome).toBe("removed"); - expect(report.entries[0]?.bytes).toBeGreaterThan(0); - expect(report.bytesFreed).toBe(report.entries[0]?.bytes); + expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes: expectedBytes }); + expect(report.bytesFreed).toBe(expectedBytes); }), ), ); From d9eaf2150b0500a1932856340be0a6511f42d398 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:37:12 -0800 Subject: [PATCH 19/19] test(server): host casing test passes on case-insensitive Macs The test decided its expected result by asking whether the upper-cased path exists, to detect the Windows runner. That is also true on a Mac, where the linux row still compares case-sensitively, so it failed on every Mac. It now checks the real host. Model: Opus 5.5 Harness: Claude Code in T3 Code Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 8ea38338c2f35..0c85dda9e172b 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2222,6 +2222,7 @@ describe("orphan worktree reports", () => { (platform) => runCleanupTest( Effect.gen(function* () { + const hostIsWindows = yield* HostProcess.isWindows; const fixture = yield* cleanupFixture().pipe( Effect.provideService(HostProcess.Platform, platform), ); @@ -2238,9 +2239,8 @@ describe("orphan worktree reports", () => { fixture.setProjectRoots([fixture.repo, project, `${nested}/child`]); const report = yield* fixture.service.runNow; expect(report.entries.map((entry) => entry.path).sort()).toEqual( - platform === "win32" || (yield* fixture.fs.exists(referenced.toUpperCase())) - ? [] - : [referenced], + // The real Windows path layer folds case whatever platform the service is told it runs on. + platform === "win32" || hostIsWindows ? [] : [referenced], ); }), ),