From 933c7815f81d2b458aa0d7081fd2ff366ce70a4a Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 06:20:18 +0100 Subject: [PATCH 01/31] feat(vcs): add sparse worktree materialization --- .../OrchestrationEngineHarness.integration.ts | 2 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/git/GitWorkflowService.ts | 16 + .../Layers/ProjectionPipeline.test.ts | 108 ++ .../Layers/ProjectionPipeline.ts | 15 + .../Layers/ProjectionSnapshotQuery.test.ts | 3 + .../Layers/ProjectionSnapshotQuery.ts | 12 + .../Layers/ProviderCommandReactor.test.ts | 244 +++- .../Layers/ProviderCommandReactor.ts | 60 +- apps/server/src/orchestration/Schemas.ts | 2 + apps/server/src/orchestration/decider.ts | 22 + .../src/orchestration/projector.test.ts | 63 + apps/server/src/orchestration/projector.ts | 19 + .../Layers/ProjectionRepositories.test.ts | 10 +- .../persistence/Layers/ProjectionThreads.ts | 42 +- apps/server/src/persistence/Migrations.ts | 2 + ...48_ProjectionThreadMaterialization.test.ts | 151 +++ .../048_ProjectionThreadMaterialization.ts | 59 + .../persistence/Services/ProjectionThreads.ts | 7 +- apps/server/src/review/ReviewService.test.ts | 83 +- apps/server/src/review/ReviewService.ts | 5 + apps/server/src/server.test.ts | 284 ++++- apps/server/src/vcs/GitVcsDriver.ts | 8 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 968 +++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 1055 ++++++++++++++++- apps/server/src/ws.ts | 71 ++ .../web/src/components/ChatView.logic.test.ts | 190 +++ apps/web/src/components/ChatView.logic.ts | 153 +++ apps/web/src/components/ChatView.tsx | 230 ++++ packages/client-runtime/src/state/vcs.ts | 7 + packages/contracts/src/git.test.ts | 37 + packages/contracts/src/git.ts | 71 ++ packages/contracts/src/ipc.ts | 5 + packages/contracts/src/orchestration.test.ts | 27 + packages/contracts/src/orchestration.ts | 25 + packages/contracts/src/rpc.ts | 17 + 36 files changed, 4057 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.test.ts create mode 100644 apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b4e04fd44f60..36b260ac87b6 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -5,6 +5,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ApprovalRequestId, CodexSettings, + FULL_WORKTREE_MATERIALIZATION_STATE, ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, @@ -332,6 +333,7 @@ export const makeOrchestrationIntegrationHarness = ( readonly oldBranch: string; readonly newBranch: string; }) => Effect.succeed({ branch: input.newBranch }), + verifyWorktreeMaterialization: () => Effect.succeed(FULL_WORKTREE_MATERIALIZATION_STATE), }); const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 50adf83bfc53..bf16cc7ab792 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -111,6 +111,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsExpandWorktreeMaterialization]: AuthOrchestrationOperateScope, [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c9b4a4cca365..ab95c91de651 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -11,6 +11,7 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type VcsWorktreeMaterializationState, type VcsListRefsInput, type VcsListRefsResult, type GitManagerServiceError, @@ -65,6 +66,13 @@ export class GitWorkflowService extends Context.Service< readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; + readonly verifyWorktreeMaterialization: ( + cwd: string, + ) => Effect.Effect; + readonly expandWorktreeMaterializationFull: ( + cwd: string, + reason: string, + ) => Effect.Effect; readonly fetchRemote: (input: { readonly cwd: string; readonly remoteName: string; @@ -311,6 +319,14 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), ), + verifyWorktreeMaterialization: (cwd) => + ensureGitCommand("GitWorkflowService.verifyWorktreeMaterialization", cwd).pipe( + Effect.andThen(git.verifyWorktreeMaterialization(cwd)), + ), + expandWorktreeMaterializationFull: (cwd, reason) => + ensureGitCommand("GitWorkflowService.expandWorktreeMaterializationFull", cwd).pipe( + Effect.andThen(git.expandWorktreeMaterializationFull(cwd, reason)), + ), fetchRemote: (input) => ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( Effect.andThen(git.fetchRemote(input)), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1b8a451175f3..4405959268d3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -476,6 +476,114 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }, ); +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline materialization", (it) => { + it.effect("persists server-authored requested and effective identities", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + const events: Array[0]> = [ + { + type: "project.created", + eventId: EventId.make("evt-materialization-project"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-materialization"), + occurredAt: now, + commandId: CommandId.make("cmd-materialization-project"), + causationEventId: null, + correlationId: CommandId.make("cmd-materialization-project"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-materialization"), + title: "Project", + workspaceRoot: "/tmp/project-materialization", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }, + { + type: "thread.created", + eventId: EventId.make("evt-materialization-thread"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-materialization"), + occurredAt: now, + commandId: CommandId.make("cmd-materialization-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-materialization-thread"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-materialization"), + projectId: ProjectId.make("project-materialization"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }, + { + type: "thread.materialization-set", + eventId: EventId.make("evt-materialization-set"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-materialization"), + occurredAt: now, + commandId: CommandId.make("cmd-materialization-set"), + causationEventId: null, + correlationId: CommandId.make("cmd-materialization-set"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-materialization"), + materialization: { + requestedProfileId: "governance-review", + effectiveProfileId: "full", + mode: "full", + reason: "required-paths-missing", + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "task", + }, + updatedAt: now, + }, + }, + ]; + yield* Effect.forEach(events, eventStore.append, { concurrency: 1 }); + yield* projectionPipeline.bootstrap; + const rows = yield* sql<{ + readonly requested: string; + readonly effective: string; + readonly reason: string; + }>` + SELECT + materialization_requested_profile_id AS requested, + materialization_effective_profile_id AS effective, + materialization_reason AS reason + FROM projection_threads + WHERE thread_id = 'thread-materialization' + `; + assert.deepEqual(rows, [ + { + requested: "governance-review", + effective: "full", + reason: "required-paths-missing", + }, + ]); + }), + ); +}); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect( "passes explicit empty attachment arrays through the projection pipeline to clear attachments", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1ba1b6afa4f3..888131dc04a2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,5 +1,6 @@ import { ApprovalRequestId, + FULL_WORKTREE_MATERIALIZATION_STATE, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, @@ -636,6 +637,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, linkedPullRequest: null, latestTurnId: null, createdAt: event.payload.createdAt, @@ -842,6 +844,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.materialization-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) return; + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + materialization: event.payload.materialization, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.runtime-mode-set": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 682b280bf4d0..fc8363f88629 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -6,6 +6,7 @@ import { ThreadId, TurnId, ProviderInstanceId, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -309,6 +310,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, linkedPullRequest: { projectId: asProjectId("project-1"), repository: "pingdotgg/t3code", @@ -437,6 +439,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, linkedPullRequest: { projectId: asProjectId("project-1"), repository: "pingdotgg/t3code", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0d064a1d6e9c..f667f50c63b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -28,6 +28,7 @@ import { ProjectId, ThreadLinkedPullRequest, ThreadId, + VcsWorktreeMaterializationState, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; @@ -101,6 +102,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + materialization: Schema.fromJsonString(VcsWorktreeMaterializationState), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -471,6 +473,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -509,6 +512,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -549,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -1011,6 +1016,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -1942,6 +1948,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + materialization: row.materialization, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2155,6 +2162,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + materialization: row.materialization, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2295,6 +2303,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + materialization: row.materialization, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2443,6 +2452,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + materialization: row.materialization, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2737,6 +2747,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + materialization: threadRow.value.materialization, ...(threadRow.value.linkedPullRequest === null ? {} : { linkedPullRequest: threadRow.value.linkedPullRequest }), @@ -2975,6 +2986,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + materialization: threadRow.value.materialization, ...(threadRow.value.linkedPullRequest === null ? {} : { linkedPullRequest: threadRow.value.linkedPullRequest }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 1c5e834ec2d7..cbb8ea2916ef 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -9,7 +9,10 @@ import { ProviderSession, ProviderDriverKind, ProviderInstanceId, + FULL_WORKTREE_MATERIALIZATION_STATE, + GitCommandError, ProviderSetupError, + type VcsWorktreeMaterializationState, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { @@ -181,6 +184,10 @@ describe("ProviderCommandReactor", () => { session: ProviderSession, ) => Effect.Effect; readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"]; + readonly verifyWorktreeMaterializationEffect?: () => Effect.Effect< + VcsWorktreeMaterializationState, + GitCommandError + >; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -302,9 +309,14 @@ describe("ProviderCommandReactor", () => { ); const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); const createWorktree = vi.fn( - (input: { readonly refName: string; readonly path: string | null }) => + (input: Parameters[0]) => Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), ); + const verifyWorktreeMaterialization = vi.fn( + () => + input?.verifyWorktreeMaterializationEffect?.() ?? + Effect.succeed(FULL_WORKTREE_MATERIALIZATION_STATE), + ); const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, @@ -453,6 +465,7 @@ describe("ProviderCommandReactor", () => { renameBranch, pruneWorktrees, createWorktree, + verifyWorktreeMaterialization, } satisfies Partial), ), Layer.provideMerge( @@ -582,6 +595,7 @@ describe("ProviderCommandReactor", () => { renameBranch, pruneWorktrees, createWorktree, + verifyWorktreeMaterialization, refreshStatus, generateBranchName, generateThreadTitle, @@ -2217,6 +2231,234 @@ describe("ProviderCommandReactor", () => { ); }); + it("round-trips persisted sparse identity when recreating a missing worktree", async () => { + const sparseMaterialization: VcsWorktreeMaterializationState = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse", + reason: null, + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "sparse-rehydrate", + taskCardPath: "ops/stef-task/sparse-rehydrate/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + includeResearchTask: true, + }; + const harness = await createHarness({ + verifyWorktreeMaterializationEffect: () => Effect.succeed(sparseMaterialization), + }); + const now = "2026-01-01T00:00:00.000Z"; + const worktreePath = NodePath.join(harness.stateDir, "missing-sparse-worktree"); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-missing-sparse-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "feature/sparse-restore", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.materialization.set", + commandId: CommandId.make("cmd-thread-sparse-materialization"), + threadId: ThreadId.make("thread-1"), + materialization: sparseMaterialization, + createdAt: now, + }), + ); + expect( + (await harness.readModel()).threads.find((entry) => entry.id === ThreadId.make("thread-1")) + ?.materialization, + ).toEqual(sparseMaterialization); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-sparse-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-sparse-worktree"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project", + refName: "feature/sparse-restore", + path: worktreePath, + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "sparse-rehydrate", + taskCardPath: "ops/stef-task/sparse-rehydrate/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + includeResearchTask: true, + }, + }); + expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( + harness.verifyWorktreeMaterialization.mock.invocationCallOrder[0]!, + ); + expect(harness.verifyWorktreeMaterialization.mock.invocationCallOrder[0]).toBeLessThan( + harness.startSession.mock.invocationCallOrder[0]!, + ); + }); + + it("accepts legacy full materialization before a worktree turn", async () => { + const harness = await createHarness(); + const worktreePath = NodePath.join(harness.stateDir, "legacy-full-worktree"); + NodeFS.mkdirSync(worktreePath, { recursive: true }); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-legacy-full"), + threadId: ThreadId.make("thread-1"), + branch: "feature/legacy-full", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-legacy-full"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-legacy-full"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.verifyWorktreeMaterialization).toHaveBeenCalledWith(worktreePath); + }); + + it("surfaces materialization mismatch as a session error and failure activity", async () => { + const harness = await createHarness({ + verifyWorktreeMaterializationEffect: () => + Effect.succeed({ + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse", + reason: null, + }), + }); + const worktreePath = NodePath.join(harness.stateDir, "mismatched-worktree"); + NodeFS.mkdirSync(worktreePath, { recursive: true }); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-mismatched-materialization"), + threadId: ThreadId.make("thread-1"), + branch: "feature/mismatch", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-mismatched-materialization"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-mismatched-materialization"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "error"; + }); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(thread?.session?.lastError).toContain("run expand-full, then reverify"); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { detail: expect.stringContaining("run expand-full, then reverify") }, + }); + }); + + it("surfaces materialization verifier errors before provider start", async () => { + const worktreePath = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-verifier-error-worktree-"), + ); + const harness = await createHarness({ + verifyWorktreeMaterializationEffect: () => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.verifyWorktreeMaterialization", + command: "git", + cwd: worktreePath, + detail: "required paths missing; run expand-full, then reverify", + }), + ), + }); + createdStateDirs.add(worktreePath); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-verifier-error"), + threadId: ThreadId.make("thread-1"), + branch: "feature/verifier-error", + worktreePath, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-verifier-error"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-verifier-error"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "error"; + }); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1beab8ed22bb..238f9f6198fe 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -9,6 +9,8 @@ import { type OrchestrationSession, ThreadId, type ProviderSession, + type OrchestrationThread, + FULL_WORKTREE_MATERIALIZATION_STATE, type RuntimeMode, type TurnId, } from "@t3tools/contracts"; @@ -483,13 +485,15 @@ const make = Effect.gen(function* () { * Recreates a thread's worktree from its branch when the directory has * disappeared. Provider sessions resume into the persisted cwd, so a missing * worktree makes every later turn fail as a bogus "session not found". - * Best-effort: on failure the turn proceeds and reports the real error. + * Recreation is best-effort; the mandatory verifier blocks the turn if the + * restored path is absent or does not match the persisted materialization. */ const ensureThreadWorktree = Effect.fnUntraced(function* (thread: { readonly id: ThreadId; readonly projectId: ProjectId; readonly branch: string | null; readonly worktreePath: string | null; + readonly materialization?: OrchestrationThread["materialization"]; }) { const { worktreePath, branch } = thread; if (!worktreePath || !branch) { @@ -511,8 +515,38 @@ const make = Effect.gen(function* () { }); // A directory deleted without `git worktree remove` leaves an admin entry // that makes `git worktree add` refuse the path; prune clears it. + const persistedMaterialization = thread.materialization ?? FULL_WORKTREE_MATERIALIZATION_STATE; + const taskCardPath = persistedMaterialization.taskCardPath ?? null; + const sparseRehydration = + persistedMaterialization.effectiveProfileId !== "full" && + persistedMaterialization.expectedContractSha256 && + persistedMaterialization.taskId && + persistedMaterialization.taskSlug && + taskCardPath + ? { + requestedProfileId: persistedMaterialization.effectiveProfileId, + expectedContractSha256: persistedMaterialization.expectedContractSha256, + taskId: persistedMaterialization.taskId, + taskSlug: persistedMaterialization.taskSlug, + taskCardPath, + scopePaths: persistedMaterialization.scopePaths ?? [], + ...(persistedMaterialization.taskClasses + ? { taskClasses: persistedMaterialization.taskClasses } + : {}), + ...(persistedMaterialization.includeResearchTask === true + ? { includeResearchTask: true } + : {}), + } + : undefined; yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( - Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.andThen( + gitWorkflow.createWorktree({ + cwd, + refName: branch, + path: worktreePath, + ...(sparseRehydration ? { materialization: sparseRehydration } : {}), + }), + ), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) @@ -1285,6 +1319,28 @@ const make = Effect.gen(function* () { yield* ensureThreadWorktree(thread); + const materializationReady = yield* Effect.gen(function* () { + if (!thread.worktreePath) return true; + const materialization = yield* gitWorkflow.verifyWorktreeMaterialization(thread.worktreePath); + if ( + !Equal.equals( + materialization, + thread.materialization ?? FULL_WORKTREE_MATERIALIZATION_STATE, + ) + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(thread.modelSelection.instanceId), + }), + method: "thread.turn.start", + detail: + "Persisted thread materialization does not match the worktree. Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify.", + }); + } + return true; + }).pipe(Effect.catchCause((cause) => recoverTurnStartFailure(cause).pipe(Effect.as(false)))); + if (!materializationReady) return; + const isCompactCommand = isCompactCommandMessage(message); const nonCompactUserMessageCount = thread.messages.filter( (entry) => entry.role === "user" && !isCompactCommandMessage(entry), diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..9b6a83c7db66 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -3,6 +3,7 @@ import { ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, + ThreadMaterializationSetPayload as ContractsThreadMaterializationSetPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, @@ -35,6 +36,7 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; +export const ThreadMaterializationSetPayload = ContractsThreadMaterializationSetPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index b336053ac9e1..1caa4a1d2172 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -833,6 +833,28 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.materialization.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.materialization-set", + payload: { + threadId: command.threadId, + materialization: command.materialization, + updatedAt: command.createdAt, + }, + }; + } + case "thread.title.regeneration.complete": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index dad3d07370f9..f2c3f948b8cd 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -5,6 +5,7 @@ import { ProviderDriverKind, ThreadId, type OrchestrationEvent, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; @@ -85,6 +86,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, latestTurn: null, createdAt: now, updatedAt: now, @@ -104,6 +106,67 @@ describe("orchestration projector", () => { ]); }); + it("applies server-owned thread materialization events", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const created = await Effect.runPromise( + projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-materialized", + occurredAt: now, + commandId: "cmd-create", + payload: { + threadId: "thread-materialized", + projectId: "project-1", + title: "demo", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + const sparse = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse" as const, + reason: null, + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "demo", + }; + const next = await Effect.runPromise( + projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.materialization-set", + aggregateKind: "thread", + aggregateId: "thread-materialized", + occurredAt: now, + commandId: "cmd-materialization", + payload: { + threadId: "thread-materialized", + materialization: sparse, + updatedAt: now, + }, + }), + ), + ); + expect(next.threads[0]?.materialization).toEqual(sparse); + }); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 3cea194bbb44..330953eb348a 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -4,6 +4,7 @@ import { OrchestrationMessage, OrchestrationSession, OrchestrationThread, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -18,6 +19,7 @@ import { ThreadActivityAppendedPayload, ThreadArchivedPayload, ThreadCreatedPayload, + ThreadMaterializationSetPayload, ThreadDeletedPayload, ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, @@ -326,6 +328,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -353,6 +356,22 @@ export function projectEvent( }; }); + case "thread.materialization-set": + return decodeForEvent( + ThreadMaterializationSetPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + materialization: payload.materialization, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.deleted": return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index adc3ca40cbb5..312a8190f6b4 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,4 +1,9 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { + FULL_WORKTREE_MATERIALIZATION_STATE, + ProjectId, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -89,6 +94,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", @@ -153,6 +159,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-25T00:00:00.000Z", @@ -229,6 +236,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + materialization: FULL_WORKTREE_MATERIALIZATION_STATE, linkedPullRequest, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index d5653a2c8b42..3ad26e5aecd0 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,12 +14,18 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import { + ModelSelection, + FULL_WORKTREE_MATERIALIZATION_STATE, + ThreadLinkedPullRequest, + VcsWorktreeMaterializationState, +} from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + materialization: Schema.fromJsonString(VcsWorktreeMaterializationState), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -29,8 +35,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const upsertProjectionThreadRow = SqlSchema.void({ Request: ProjectionThread, - execute: (row) => - sql` + execute: (row) => { + const materialization = row.materialization ?? FULL_WORKTREE_MATERIALIZATION_STATE; + return sql` INSERT INTO projection_threads ( thread_id, project_id, @@ -40,6 +47,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + materialization_json, + materialization_requested_profile_id, + materialization_effective_profile_id, + materialization_mode, + materialization_expected_contract_sha256, + materialization_contract_sha256, + materialization_manifest_sha256, + materialization_reason, linked_pull_request_json, latest_turn_id, created_at, @@ -69,6 +84,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${JSON.stringify(materialization)}, + ${materialization.requestedProfileId}, + ${materialization.effectiveProfileId}, + ${materialization.mode}, + ${materialization.expectedContractSha256}, + ${materialization.contractSha256}, + ${materialization.manifestSha256}, + ${materialization.reason}, ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, ${row.latestTurnId}, ${row.createdAt}, @@ -98,6 +121,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + materialization_json = excluded.materialization_json, + materialization_requested_profile_id = excluded.materialization_requested_profile_id, + materialization_effective_profile_id = excluded.materialization_effective_profile_id, + materialization_mode = excluded.materialization_mode, + materialization_expected_contract_sha256 = excluded.materialization_expected_contract_sha256, + materialization_contract_sha256 = excluded.materialization_contract_sha256, + materialization_manifest_sha256 = excluded.materialization_manifest_sha256, + materialization_reason = excluded.materialization_reason, linked_pull_request_json = excluded.linked_pull_request_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, @@ -117,7 +148,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_user_input_count = excluded.pending_user_input_count, has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, deleted_at = excluded.deleted_at - `, + `; + }, }); const getProjectionThreadRow = SqlSchema.findOneOption({ @@ -134,6 +166,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", @@ -172,6 +205,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + materialization_json AS "materialization", linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 92dc18291057..106527076a09 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; +import Migration0048 from "./Migrations/048_ProjectionThreadMaterialization.ts"; /** * Migration loader with all migrations defined inline. @@ -118,6 +119,7 @@ export const migrationEntries = [ [45, "ProjectionProjectsAutoPull", Migration0045], [46, "RepairAutomaticSettlementTimestamps", Migration0046], [47, "ProjectionProjectIcon", Migration0047], + [48, "ProjectionThreadMaterialization", Migration0048], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.test.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.test.ts new file mode 100644 index 000000000000..0908e583b3ce --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.test.ts @@ -0,0 +1,151 @@ +import { assert, it } from "@effect/vitest"; +import { + FULL_WORKTREE_MATERIALIZATION_STATE, + ThreadId, + VcsWorktreeMaterializationState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import { ProjectionThreadRepositoryLive } from "../Layers/ProjectionThreads.ts"; +import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const layer = it.layer( + ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(NodeSqliteClient.layerMemory())), +); + +layer("048_ProjectionThreadMaterialization", (it) => { + it.effect("adds explicit identity columns and a full-state backfill default", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 47 }); + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + linked_pull_request_json, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + pinned_at, + pin_order_key, + created_at, + updated_at, + deleted_at + ) VALUES ( + 'thread-pre-48', + 'project-pre-48', + 'Pre-48 thread', + '{"instanceId":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + NULL, + NULL, + '2026-09-01T00:00:00.000Z', + '2026-09-01T00:00:00.000Z', + NULL + ) + `; + yield* runMigrations({ toMigrationInclusive: 48 }); + + const columns = yield* sql<{ + readonly name: string; + readonly dfltValue: string | null; + readonly notNull: number; + }>` + SELECT name, dflt_value AS "dfltValue", "notnull" AS "notNull" + FROM pragma_table_info('projection_threads') + `; + for (const name of [ + "materialization_json", + "materialization_requested_profile_id", + "materialization_effective_profile_id", + "materialization_mode", + "materialization_expected_contract_sha256", + "materialization_contract_sha256", + "materialization_manifest_sha256", + "materialization_reason", + ]) { + assert.ok( + columns.some((column) => column.name === name), + name, + ); + } + const defaultSql = columns.find( + (column) => column.name === "materialization_json", + )?.dfltValue; + assert.ok(defaultSql); + const defaultJson = + defaultSql.startsWith("'") && defaultSql.endsWith("'") + ? defaultSql.slice(1, -1).replaceAll("''", "'") + : defaultSql; + const decodedDefault = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(VcsWorktreeMaterializationState), + )(defaultJson); + assert.deepStrictEqual(decodedDefault, FULL_WORKTREE_MATERIALIZATION_STATE); + + const repository = yield* ProjectionThreadRepository; + const row = yield* repository.getById({ threadId: ThreadId.make("thread-pre-48") }); + assert.ok(Option.isSome(row)); + assert.deepStrictEqual(row.value.materialization, FULL_WORKTREE_MATERIALIZATION_STATE); + + const materializationReason = columns.find( + (column) => column.name === "materialization_reason", + ); + assert.equal(materializationReason?.notNull, 0); + const sparse = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse" as const, + reason: null, + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "sparse-persistence", + taskCardPath: "ops/stef-task/sparse-persistence/stef-task.json", + scopePaths: ["docs/spec.md"], + }; + yield* repository.upsert({ + ...row.value, + materialization: sparse, + updatedAt: "2026-09-01T00:00:01.000Z", + }); + const sparseRow = yield* repository.getById({ + threadId: ThreadId.make("thread-pre-48"), + }); + assert.ok(Option.isSome(sparseRow)); + assert.deepStrictEqual(sparseRow.value.materialization, sparse); + const rawReason = yield* sql<{ readonly reason: string | null }>` + SELECT materialization_reason AS reason + FROM projection_threads + WHERE thread_id = 'thread-pre-48' + `; + assert.deepStrictEqual(rawReason, [{ reason: null }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.ts new file mode 100644 index 000000000000..484aea920595 --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadMaterialization.ts @@ -0,0 +1,59 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const has = (name: string) => columns.some((column) => column.name === name); + + if (!has("materialization_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_json TEXT NOT NULL DEFAULT '{"status":"ready","requestedProfileId":"full","effectiveProfileId":"full","mode":"full","reason":"default-full","expectedContractSha256":null,"contractSha256":null,"manifestSha256":null,"conePaths":[],"requiredPaths":[],"taskId":null,"taskSlug":null,"taskCardPath":null,"baseSha":null}' + `; + } + if (!has("materialization_requested_profile_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_requested_profile_id TEXT NOT NULL DEFAULT 'full' + `; + } + if (!has("materialization_effective_profile_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_effective_profile_id TEXT NOT NULL DEFAULT 'full' + `; + } + if (!has("materialization_mode")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_mode TEXT NOT NULL DEFAULT 'full' + `; + } + if (!has("materialization_expected_contract_sha256")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_expected_contract_sha256 TEXT + `; + } + if (!has("materialization_contract_sha256")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_contract_sha256 TEXT + `; + } + if (!has("materialization_manifest_sha256")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_manifest_sha256 TEXT + `; + } + if (!has("materialization_reason")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN materialization_reason TEXT DEFAULT 'default-full' + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a70548bc110c..69b2527dfddc 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -17,11 +17,13 @@ import { ThreadLinkedPullRequest, ThreadId, TurnId, + VcsWorktreeMaterializationState, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; +import * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -34,6 +36,9 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + materialization: VcsWorktreeMaterializationState.pipe( + Schema.withDecodingDefault(Effect.succeed(FULL_WORKTREE_MATERIALIZATION_STATE)), + ), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 01a4692264e0..0f6d84defe3a 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -1,10 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import { FULL_WORKTREE_MATERIALIZATION_STATE, GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -14,6 +16,8 @@ function makeLayer(input: { readonly workspaceRoot: string; readonly baseDir: string; readonly detectCalls?: Array<{ readonly cwd: string }>; + readonly detectResult?: VcsDriverRegistry.VcsDriverHandle | null; + readonly git?: Partial; }) { return ReviewService.layer.pipe( Layer.provide( @@ -23,11 +27,11 @@ function makeLayer(input: { detect: (request) => Effect.sync(() => { input.detectCalls?.push({ cwd: request.cwd }); - return null; + return input.detectResult ?? null; }), }), ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({ ...input.git })), Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -108,6 +112,81 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("verifies a legacy full Git checkout before review and preserves normal output", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const verifyCalls: Array = []; + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: workspaceRoot }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + detectResult: { + kind: "git", + repository: {} as never, + driver: {} as never, + }, + git: { + verifyWorktreeMaterialization: (cwd) => + Effect.sync(() => { + verifyCalls.push(cwd); + return FULL_WORKTREE_MATERIALIZATION_STATE; + }), + getReviewDiffPreview: (input) => + Effect.gen(function* () { + return { cwd: input.cwd, generatedAt: yield* DateTime.now, sources: [] }; + }), + }, + }), + ), + ); + assert.deepStrictEqual(verifyCalls, [workspaceRoot]); + assert.strictEqual(result.cwd, workspaceRoot); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("blocks review when Git materialization verification fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: workspaceRoot }).pipe(Effect.flip); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + detectResult: { + kind: "git", + repository: {} as never, + driver: {} as never, + }, + git: { + verifyWorktreeMaterialization: () => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.verifyWorktreeMaterialization", + command: "git", + cwd: workspaceRoot, + detail: "required paths missing", + }), + ), + }, + }), + ), + ); + assert.strictEqual(error._tag, "GitCommandError"); + assert.match(error.message, /required paths missing/); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("preserves unexpected path-resolution failures", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 275dcb416610..14fd711c2f82 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -99,6 +99,9 @@ export const make = Effect.gen(function* () { sources: [], }; } + if (handle.kind === "git") { + yield* git.verifyWorktreeMaterialization(input.cwd); + } const getDriverDiffPreview = handle.driver.getDiffPreview; if (!getDriverDiffPreview) { @@ -129,6 +132,8 @@ export const make = Effect.gen(function* () { }); } + yield* git.verifyWorktreeMaterialization(input.cwd); + return yield* git.getReviewDiffFileContents(input); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 85afe00cb52a..8763e090fb53 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -41,6 +41,7 @@ import { WS_METHODS, WsRpcGroup, EditorId, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import { computeDpopAccessTokenHash, @@ -8872,6 +8873,130 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("binds expand-full to an inactive thread path and persists the result", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-expand-materialization"); + const worktreePath = "/tmp/thread-expand-materialization"; + const dispatchedCommands: Array = []; + const expandedMaterialization = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + reason: "operator-recovery", + }; + const expandWorktreeMaterializationFull = vi.fn((cwd: string, reason: string) => + Effect.succeed({ + ...expandedMaterialization, + reason: `${reason}:${cwd}`, + }), + ); + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/expand", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); + let threadShell: OrchestrationThreadShell = makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath, + session: null, + }); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { expandWorktreeMaterializationFull }, + vcsStatusBroadcaster: { refreshStatus }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => Effect.succeed(Option.some(threadShell)), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const pathMismatch = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsExpandWorktreeMaterialization]({ + cwd: "/tmp/not-the-thread-worktree", + threadId, + }), + ).pipe(Effect.result), + ); + assertTrue(pathMismatch._tag === "Failure"); + assertTrue(pathMismatch.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(pathMismatch.failure.message, "exact persisted worktree path"); + + threadShell = makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const activeSession = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsExpandWorktreeMaterialization]({ cwd: worktreePath, threadId }), + ).pipe(Effect.result), + ); + assertTrue(activeSession._tag === "Failure"); + assertTrue(activeSession.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(activeSession.failure.message, "thread session is active"); + assert.equal(expandWorktreeMaterializationFull.mock.calls.length, 0); + assert.equal(dispatchedCommands.length, 0); + + threadShell = makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath, + session: null, + }); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsExpandWorktreeMaterialization]({ + cwd: worktreePath, + threadId, + reason: "operator-recovery", + }), + ), + ); + + assert.deepEqual(expandWorktreeMaterializationFull.mock.calls[0]?.[0], worktreePath); + assert.deepEqual(expandWorktreeMaterializationFull.mock.calls[0]?.[1], "operator-recovery"); + assert.deepEqual(response.materialization, { + ...expandedMaterialization, + reason: `operator-recovery:${worktreePath}`, + }); + const materializationCommand = dispatchedCommands[0]; + assertTrue(materializationCommand?.type === "thread.materialization.set"); + if (materializationCommand?.type === "thread.materialization.set") { + assert.equal(materializationCommand.threadId, threadId); + assert.deepEqual(materializationCommand.materialization, response.materialization); + } + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.materialization.set"], + ); + assert.deepEqual(refreshStatus.mock.calls[0]?.[0], worktreePath); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "bootstraps first-send worktree turns on the server before dispatching turn start", () => @@ -8917,6 +9042,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567"; + const materializationRequest = { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + } as const; + const materializationState = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + ...materializationRequest, + effectiveProfileId: "governance-review", + mode: "sparse" as const, + reason: null, + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + }; const resolveRemoteTrackingCommit = vi.fn( (_: Parameters[0]) => Effect.sync(() => { @@ -8936,9 +9081,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { refName: "t3code/bootstrap-refName", path: "/tmp/bootstrap-worktree", }, + materialization: materializationState, }; }), ); + const verifyWorktreeMaterialization = vi.fn((cwd: string) => + Effect.sync(() => { + bootstrapGitOperations.push("verify-materialization"); + assert.equal(cwd, "/tmp/bootstrap-worktree"); + return materializationState; + }), + ); const runForThread = vi.fn( ( _: Parameters< @@ -8962,6 +9115,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { remoteBranchExists, resolveRemoteTrackingCommit, createWorktree, + verifyWorktreeMaterialization, }, vcsStatusBroadcaster: { refreshStatus, @@ -8970,6 +9124,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dispatch: (command) => Effect.sync(() => { dispatchedCommands.push(command); + if (command.type === "thread.materialization.set") { + bootstrapGitOperations.push("persist-materialization"); + } return { sequence: dispatchedCommands.length }; }), readEvents: () => Stream.empty, @@ -9013,6 +9170,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { baseBranch: "main", branch: "t3code/bootstrap-refName", startFromOrigin: true, + materialization: materializationRequest, }, runSetupScript: true, }, @@ -9021,12 +9179,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 5); + assert.equal(response.sequence, 6); assert.deepEqual( dispatchedCommands.map((command) => command.type), [ "thread.create", "thread.meta.update", + "thread.materialization.set", "thread.activity.append", "thread.activity.append", "thread.turn.start", @@ -9038,6 +9197,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { newRefName: "t3code/bootstrap-refName", baseRefName: "main", path: null, + materialization: materializationRequest, }); assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { cwd: "/tmp/project", @@ -9059,6 +9219,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "remote-branch-exists", "resolve-remote-commit", "create-worktree", + "verify-materialization", + "persist-materialization", ]); assert.deepEqual(runForThread.mock.calls[0]?.[0], { threadId: ThreadId.make("thread-bootstrap"), @@ -9076,7 +9238,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { setupActivities.map((command) => command.activity.kind), ["setup-script.requested", "setup-script.started"], ); - const finalCommand = dispatchedCommands[4]; + const materializationCommand = dispatchedCommands[2]; + assertTrue(materializationCommand?.type === "thread.materialization.set"); + if (materializationCommand?.type === "thread.materialization.set") { + assert.deepEqual(materializationCommand.materialization, materializationState); + } + const finalCommand = dispatchedCommands[5]; assertTrue(finalCommand?.type === "thread.turn.start"); if (finalCommand?.type === "thread.turn.start") { assert.equal(finalCommand.bootstrap, undefined); @@ -9084,6 +9251,119 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("fails bootstrap closed when verified materialization differs from creation", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const materializationRequest = { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + } as const; + const createdMaterialization = { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + ...materializationRequest, + effectiveProfileId: "governance-review", + mode: "sparse" as const, + reason: null, + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + }; + const verifiedMaterialization = { + ...createdMaterialization, + manifestSha256: "c".repeat(64), + }; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-mismatch", + path: "/tmp/bootstrap-mismatch-worktree", + }, + materialization: createdMaterialization, + }), + ); + const verifyWorktreeMaterialization = vi.fn((_: string) => + Effect.succeed(verifiedMaterialization), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { createWorktree, verifyWorktreeMaterialization }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-materialization-mismatch"), + threadId: ThreadId.make("thread-bootstrap-materialization-mismatch"), + message: { + messageId: MessageId.make("msg-bootstrap-materialization-mismatch"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-mismatch", + materialization: materializationRequest, + }, + runSetupScript: false, + }, + createdAt, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "does not match its persisted identity"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.delete"], + ); + assert.equal(verifyWorktreeMaterialization.mock.calls.length, 1); + assert.isFalse( + dispatchedCommands.some( + (command) => + command.type === "thread.materialization.set" || command.type === "thread.turn.start", + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect.each([ { caseName: "the origin remote is missing", hasOrigin: false }, { caseName: "the base branch exists only locally", hasOrigin: true }, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 08b474cf42da..7c22189378a3 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -18,6 +18,7 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type VcsWorktreeMaterializationState, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, @@ -281,6 +282,13 @@ export class GitVcsDriver extends Context.Service< readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; + readonly verifyWorktreeMaterialization: ( + cwd: string, + ) => Effect.Effect; + readonly expandWorktreeMaterializationFull: ( + cwd: string, + reason: string, + ) => Effect.Effect; readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 75f69de952c1..6f501665047a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,10 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeCrypto from "node:crypto"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; @@ -26,6 +28,8 @@ const TestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); +const worktreeMaterializationSha256ForTest = (value: string) => + NodeCrypto.createHash("sha256").update(value).digest("hex"); const makeNonRepositoryHandle = () => ChildProcessSpawner.makeHandle({ @@ -105,6 +109,7 @@ const git = ( args, ...(env ? { env } : {}), timeoutMs: 10_000, + maxOutputBytes: 32 * 1024 * 1024, }); return result.stdout.trim(); }); @@ -128,6 +133,97 @@ const initRepoWithCommit = ( return { initialBranch }; }); +const writeMaterializationFixture = Effect.fn("writeMaterializationFixture")(function* ( + cwd: string, + options: { + readonly requiredOutsideCone?: boolean; + readonly requiredMissingEverywhere?: boolean; + readonly invalidSharedPath?: boolean; + readonly invalidSharedValue?: unknown; + } = {}, +) { + const contract = { + schemaVersion: "clawd.worktree-materialization-profiles.v1", + taskContext: { + taskCardRoot: "ops/stef-task", + buildStateRoot: "ops/build-state", + researchRoot: "ops/research", + }, + sharedConePaths: + options.invalidSharedValue !== undefined + ? [options.invalidSharedValue] + : options.invalidSharedPath + ? ["/absolute-cone"] + : ["config", "ops/stef-task", "ops/build-state"], + sharedRequiredPaths: ["config/worktree-materialization-profiles.json"], + unsupportedTaskClasses: ["unclassified", "multi-domain", "live-runtime"], + profiles: [ + { id: "full", mode: "full", conePaths: [], requiredPaths: [] }, + { + id: "governance-review", + mode: "sparse", + conePaths: ["docs"], + requiredPaths: options.requiredMissingEverywhere + ? ["missing/never.txt"] + : options.requiredOutsideCone + ? ["outside/needed.txt"] + : [], + }, + { + id: "brandt-source", + mode: "sparse", + conePaths: ["brandt-pattern-recognition"], + requiredPaths: ["brandt-pattern-recognition/source.ts"], + }, + { + id: "trading-strategy-source", + mode: "sparse", + conePaths: ["strategies"], + requiredPaths: ["strategies/source.ts"], + }, + ], + } as const; + // @effect-diagnostics-next-line preferSchemaOverJson:off + const raw = `${JSON.stringify(contract, null, 2)}\n`; + yield* writeTextFile(cwd, "config/worktree-materialization-profiles.json", raw); + yield* writeTextFile(cwd, "docs/spec.md", "# sparse\n"); + yield* writeTextFile(cwd, "ops/stef-task/task/stef-task.json", "{}\n"); + yield* writeTextFile(cwd, "ops/build-state/OC-1/proof.json", "{}\n"); + yield* writeTextFile(cwd, "outside/needed.txt", "needed\n"); + yield* writeTextFile(cwd, "brandt-pattern-recognition/source.ts", "export {};\n"); + yield* writeTextFile(cwd, "strategies/source.ts", "export {};\n"); + yield* writeTextFile(cwd, "excluded/large.txt", `${"x".repeat(1_000_000)}\n`); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "materialization fixture"]); + return { + expectedContractSha256: NodeCrypto.createHash("sha256").update(raw).digest("hex"), + }; +}); + +const logicalWorkingTreeBytes = ( + root: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const visit = (candidate: string): Effect.Effect => + Effect.gen(function* () { + const infoOption = yield* fileSystem.stat(candidate).pipe(Effect.option); + if (Option.isNone(infoOption)) return 0; + const info = infoOption.value; + if (info.type === "File") return Number(info.size); + if (info.type !== "Directory") return 0; + const names = yield* fileSystem.readDirectory(candidate); + let total = 0; + for (const name of names) { + if (candidate === root && name === ".git") continue; + total += yield* visit(pathService.join(candidate, name)); + } + return total; + }); + return yield* visit(root); + }); + it.effect("uses stable diagnostics for every parsed non-repository command", () => { const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; const spawner = ChildProcessSpawner.make((command) => @@ -1376,6 +1472,878 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect( + "materializes an explicit hash-bound sparse worktree and expands only while clean", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "sparse-materialized", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/sparse-materialized", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + if (!created.materialization) return assert.fail("expected materialization state"); + assert.equal(created.materialization.effectiveProfileId, "governance-review"); + assert.equal(created.materialization.mode, "sparse"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "docs/spec.md")), + true, + ); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + assert.equal(yield* git(worktreePath, ["config", "--bool", "index.sparse"]), "false"); + assert.deepStrictEqual( + yield* driver.verifyWorktreeMaterialization(worktreePath), + created.materialization, + ); + + yield* writeTextFile(worktreePath, "dirty.txt", "dirty\n"); + const dirtyExpansion = yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "must-refuse"), + ); + assert.equal(dirtyExpansion._tag, "Failure"); + assert.equal( + (yield* driver.verifyWorktreeMaterialization(worktreePath)).effectiveProfileId, + "governance-review", + ); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + yield* fileSystem.remove(pathService.join(worktreePath, "dirty.txt")); + yield* writeTextFile(worktreePath, "docs/spec.md", "# tracked dirty\n"); + assert.equal( + (yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "must-refuse-tracked"), + ))._tag, + "Failure", + ); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + yield* git(worktreePath, ["checkout", "--", "docs/spec.md"]); + + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "test-expand", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + true, + ); + }), + ); + + it.effect("falls back to full before release when sparse required paths are absent", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + requiredOutsideCone: true, + }); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "full-fallback"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/full-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + if (!created.materialization) return assert.fail("expected fallback materialization state"); + assert.equal(created.materialization.requestedProfileId, "governance-review"); + assert.equal(created.materialization.effectiveProfileId, "full"); + assert.equal(created.materialization.reason, "required-paths-missing"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "outside/needed.txt")), + true, + ); + }), + ); + + it.effect("expand-full repairs missing and failed sparse identities", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, [ + "sparse-checkout", + "set", + "--cone", + "--no-sparse-index", + "config", + "docs", + ]); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(cwd)))._tag, + "Failure", + ); + const repairedMissing = yield* driver.expandWorktreeMaterializationFull( + cwd, + "repair-missing-state", + ); + assert.equal(repairedMissing.status, "ready"); + assert.equal(repairedMissing.effectiveProfileId, "full"); + assert.equal((yield* driver.verifyWorktreeMaterialization(cwd)).effectiveProfileId, "full"); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + assert.equal(yield* fileSystem.exists(pathService.join(cwd, "excluded/large.txt")), true); + + const sparsePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "failed-state-repair", + ); + const created = yield* driver.createWorktree({ + cwd, + path: sparsePath, + refName: initialBranch, + newRefName: "feature/failed-state-repair", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + if (!created.materialization) return assert.fail("expected materialization state"); + const gitDir = yield* git(sparsePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(sparsePath, gitDir), + "worktree-materialization.json", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const failedState = JSON.parse(yield* fileSystem.readFileString(statePath)); + failedState.status = "failed"; + failedState.reason = "injected-terminal-failure"; + // @effect-diagnostics-next-line preferSchemaOverJson:off + yield* fileSystem.writeFileString(statePath, `${JSON.stringify(failedState, null, 2)}\n`); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(sparsePath)))._tag, + "Failure", + ); + const repairedFailed = yield* driver.expandWorktreeMaterializationFull( + sparsePath, + "repair-failed-state", + ); + assert.equal(repairedFailed.status, "ready"); + assert.equal(repairedFailed.effectiveProfileId, "full"); + assert.equal( + yield* fileSystem.exists(pathService.join(sparsePath, "excluded/large.txt")), + true, + ); + }), + ); + + it.effect("expand-full repopulates files when sparse config was already disabled", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "disabled-sparse-config", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/disabled-sparse-config", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + yield* git(worktreePath, ["config", "--worktree", "core.sparseCheckout", "false"]); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(worktreePath)))._tag, + "Failure", + ); + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "recover-disabled-sparse-config", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + true, + ); + }), + ); + + it.effect("falls back to full when an explicit sparse request has no task class", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "unclassified-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/unclassified-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/new-file.md"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "unsupported:unclassified"); + }), + ); + + it.effect("falls back to full for an unknown task-class token", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "unknown-class"), + refName: initialBranch, + newRefName: "feature/unknown-class", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["srouce-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "unsupported:srouce-task"); + }), + ); + + it.effect("falls back to full when the exact task card is absent at the pinned commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "missing-task-card"), + refName: initialBranch, + newRefName: "feature/missing-task-card", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/not-at-base/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "task-card-missing-at-base"); + }), + ); + + it.effect("carries a hash-bound generated task card into the sparse worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const taskCardPath = "ops/stef-task/generated/stef-task.json"; + yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-GENERATED"}}\n'); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-task-card", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-task-card", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-GENERATED", + taskSlug: "generated", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.match(created.materialization?.taskCardSha256 ?? "", /^[a-f0-9]{64}$/); + assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, taskCardPath)), true); + assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); + yield* fileSystem.writeFileString( + pathService.join(worktreePath, taskCardPath), + '{"tampered":true}\n', + ); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(worktreePath)))._tag, + "Failure", + ); + assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "rebind-generated-card", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + + it.effect("falls back to full when tracked task-card working bytes differ from the base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* writeTextFile( + cwd, + "ops/stef-task/task/stef-task.json", + '{"issue":{"id":"OC-DIRTY"}}\n', + ); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "dirty-task-card"), + refName: initialBranch, + newRefName: "feature/dirty-task-card", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-DIRTY", + taskSlug: "dirty", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "task-card-source-mismatch"); + }), + ); + + it.effect("hashes contract bytes from the pinned commit instead of working-tree drift", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const contractPath = pathService.join(cwd, "config/worktree-materialization-profiles.json"); + const contractRaw = yield* fileSystem.readFileString(contractPath); + yield* fileSystem.writeFileString(contractPath, `${contractRaw} \n`); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "pinned-contract"), + refName: initialBranch, + newRefName: "feature/pinned-contract", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal(created.materialization?.contractSha256, expectedContractSha256); + }), + ); + + it.effect("forces the schema-valid UI sentinel to full", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "ui-sentinel-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/ui-sentinel-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "invalid-context", + taskSlug: "invalid-context", + taskCardPath: "invalid", + scopePaths: ["invalid"], + taskClasses: ["unclassified"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "unsupported:unclassified"); + }), + ); + + it.effect("falls back to full on a mismatched expected contract hash", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "hash-mismatch-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/hash-mismatch-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "0".repeat(64), + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "hash-mismatch"); + }), + ); + + it.effect("persists a blocking marker when full fallback cannot satisfy required paths", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + requiredMissingEverywhere: true, + }); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "terminal-fallback-failure", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* Effect.result( + driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/terminal-fallback-failure", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }), + ); + assert.equal(result._tag, "Failure"); + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const failedState = JSON.parse(yield* fileSystem.readFileString(statePath)); + assert.equal(failedState.status, "failed"); + assert.equal( + failedState.reason, + "required-paths-missing:full-fallback-required-paths-missing", + ); + const verification = yield* Effect.result( + driver.verifyWorktreeMaterialization(worktreePath), + ); + assert.equal(verification._tag, "Failure"); + }), + ); + + it.effect("falls back to full when the repository contract has an invalid shared path", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + invalidSharedPath: true, + }); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "malformed-contract-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/malformed-contract-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "contract-unavailable"); + }), + ); + + it.effect("falls back to full for noncanonical or non-string contract paths", () => + Effect.gen(function* () { + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const [index, invalidSharedValue] of ["docs/../x", 123].entries()) { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + invalidSharedValue, + }); + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join( + yield* makeTmpDir("git-worktrees-"), + `invalid-contract-path-${index}`, + ), + refName: initialBranch, + newRefName: `feature/invalid-contract-path-${index}`, + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "contract-unavailable"); + } + }), + ); + + it.effect("keeps future scope paths in the cone when the exact task card is pinned", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "future-paths"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/future-paths", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-FUTURE", + taskSlug: "future-task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/future-file.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal(created.materialization?.requiredPaths.includes("docs/future-file.md"), false); + assert.equal( + created.materialization?.declaredDynamicPaths?.includes("docs/future-file.md"), + true, + ); + }), + ); + + it.effect("materialization canary preserves full-index identity for every sparse profile", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const driver = yield* GitVcsDriver.GitVcsDriver; + const profiles = [ + ["governance-review", "docs/spec.md"], + ["brandt-source", "brandt-pattern-recognition/source.ts"], + ["trading-strategy-source", "strategies/source.ts"], + ] as const; + + for (const [index, [profileId, scopePath]] of profiles.entries()) { + const worktreesRoot = yield* makeTmpDir(`git-canary-${index}-`); + const fullPath = pathService.join(worktreesRoot, "full"); + const sparsePath = pathService.join(worktreesRoot, "sparse"); + const full = yield* driver.createWorktree({ + cwd, + path: fullPath, + refName: initialBranch, + newRefName: `canary/${index}/full`, + }); + const sparse = yield* driver.createWorktree({ + cwd, + path: sparsePath, + refName: initialBranch, + newRefName: `canary/${index}/sparse`, + materialization: { + requestedProfileId: profileId, + expectedContractSha256, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: [scopePath], + taskClasses: ["source-task"], + }, + }); + if (!sparse.materialization) return assert.fail("expected sparse canary identity"); + assert.equal(sparse.materialization.effectiveProfileId, profileId); + assert.equal( + yield* git(fullPath, ["rev-parse", "HEAD^{tree}"]), + yield* git(sparsePath, ["rev-parse", "HEAD^{tree}"]), + ); + assert.equal( + worktreeMaterializationSha256ForTest(yield* git(fullPath, ["ls-files", "--stage"])), + worktreeMaterializationSha256ForTest(yield* git(sparsePath, ["ls-files", "--stage"])), + ); + assert.equal( + yield* git(fullPath, ["ls-tree", "-r", "HEAD"]), + yield* git(sparsePath, ["ls-tree", "-r", "HEAD"]), + ); + assert.equal(yield* git(sparsePath, ["status", "--porcelain=v1"]), ""); + const fullBytes = yield* logicalWorkingTreeBytes(fullPath); + const sparseBytes = yield* logicalWorkingTreeBytes(sparsePath); + assert.ok(sparseBytes <= fullBytes * 0.5, `${profileId}: ${sparseBytes}/${fullBytes}`); + + if (index === 0) { + for (const hiddenPath of ["ops/stef-task/task/stef-task.json", scopePath]) { + yield* fileSystem.remove(pathService.join(sparsePath, hiddenPath)); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(sparsePath)))._tag, + "Failure", + ); + yield* git(sparsePath, ["checkout", "--", hiddenPath]); + assert.equal( + (yield* driver.verifyWorktreeMaterialization(sparsePath)).effectiveProfileId, + profileId, + ); + } + } + + const expanded = yield* driver.expandWorktreeMaterializationFull( + sparsePath, + "canary-expand-full", + ); + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal( + yield* fileSystem.exists(pathService.join(sparsePath, "excluded/large.txt")), + true, + ); + assert.equal(full.materialization?.effectiveProfileId, "full"); + } + }), + ); + + it.effect.skipIf(process.env.T3_MATERIALIZATION_CANARY_REPO === undefined)( + "materialization real-repository canary preserves every frozen profile", + () => + Effect.gen(function* () { + const sourceRepo = process.env.T3_MATERIALIZATION_CANARY_REPO!; + const cloneRoot = yield* makeTmpDir("git-real-canary-"); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const repo = pathService.join(cloneRoot, "repo"); + yield* git(cloneRoot, [ + "clone", + "--quiet", + "--shared", + "--no-checkout", + sourceRepo, + repo, + ]); + const pinnedCommit = yield* git(repo, ["rev-parse", "HEAD^{commit}"]); + yield* git(repo, [ + "checkout", + pinnedCommit, + "--", + "config/worktree-materialization-profiles.json", + ]); + yield* git(repo, ["read-tree", pinnedCommit]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const contractResult = yield* driver.execute({ + operation: "GitVcsDriver.test.realCanaryContract", + cwd: repo, + args: ["show", `${pinnedCommit}:config/worktree-materialization-profiles.json`], + maxOutputBytes: 1024 * 1024, + }); + const contractRaw = Buffer.from(contractResult.stdout, "utf8"); + const expectedContractSha256 = NodeCrypto.createHash("sha256") + .update(contractRaw) + .digest("hex"); + const taskCardPath = (yield* git(repo, [ + "ls-tree", + "-r", + "--name-only", + pinnedCommit, + "ops/stef-task", + ])) + .split(/\r?\n/) + .find((candidate) => candidate.endsWith("/stef-task.json")); + if (!taskCardPath) return assert.fail("real repository needs a tracked task card"); + const fullTree = yield* git(repo, ["rev-parse", `${pinnedCommit}^{tree}`]); + const fullIndexHash = worktreeMaterializationSha256ForTest( + yield* git(repo, ["ls-files", "--stage"]), + ); + const fullModesHash = worktreeMaterializationSha256ForTest( + yield* git(repo, ["ls-tree", "-r", pinnedCommit]), + ); + const fullBytes = (yield* git(repo, ["ls-tree", "-r", "-l", pinnedCommit])) + .split(/\r?\n/) + .reduce((total, line) => { + const match = line.match(/^\d+\s+\w+\s+[a-f0-9]+\s+(\d+)\t/); + return total + Number(match?.[1] ?? 0); + }, 0); + const allProfiles = [ + ["governance-review", "builds/task-queue/lib/build-state.js"], + ["brandt-source", "brandt-pattern-recognition/runtime-config.js"], + ["trading-strategy-source", "cot-data/build-latest-from-index.js"], + ] as const; + const requestedProfile = process.env.T3_MATERIALIZATION_CANARY_PROFILE; + const profiles = requestedProfile + ? allProfiles.filter(([profileId]) => profileId === requestedProfile) + : allProfiles; + if (requestedProfile && profiles.length !== 1) { + return assert.fail(`unknown real canary profile: ${requestedProfile}`); + } + + for (const [index, [profileId, scopePath]] of profiles.entries()) { + const sparsePath = pathService.join(cloneRoot, `sparse-${index}`); + const created = yield* driver.createWorktree({ + cwd: repo, + path: sparsePath, + refName: pinnedCommit, + newRefName: `real-canary/${index}/sparse`, + materialization: { + requestedProfileId: profileId, + expectedContractSha256, + taskId: `OC-REAL-CANARY-${index + 1}`, + taskSlug: `${profileId}-canary`, + taskCardPath, + scopePaths: [scopePath], + taskClasses: ["source-task"], + }, + }); + assert.equal(created.materialization?.effectiveProfileId, profileId); + assert.equal(yield* git(sparsePath, ["rev-parse", "HEAD^{tree}"]), fullTree); + assert.equal( + worktreeMaterializationSha256ForTest(yield* git(sparsePath, ["ls-files", "--stage"])), + fullIndexHash, + ); + assert.equal( + worktreeMaterializationSha256ForTest( + yield* git(sparsePath, ["ls-tree", "-r", "HEAD"]), + ), + fullModesHash, + ); + assert.equal(yield* git(sparsePath, ["status", "--porcelain=v1"]), ""); + const sparseBytes = yield* logicalWorkingTreeBytes(sparsePath); + assert.ok(sparseBytes <= fullBytes * 0.5, `${profileId}: ${sparseBytes}/${fullBytes}`); + for (const hiddenPath of [taskCardPath, scopePath]) { + yield* fileSystem.remove(pathService.join(sparsePath, hiddenPath)); + assert.equal( + (yield* Effect.result(driver.verifyWorktreeMaterialization(sparsePath)))._tag, + "Failure", + ); + yield* git(sparsePath, ["checkout", "--", hiddenPath]); + } + assert.equal( + (yield* driver.expandWorktreeMaterializationFull( + sparsePath, + "real-canary-expand-full", + )).effectiveProfileId, + "full", + ); + } + }), + 300_000, + ); + it.effect("preserves newline characters in worktree paths when listing refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 23f8e6f2a995..4fce2097b773 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,4 +1,5 @@ import * as Arr from "effect/Array"; +import * as NodeCrypto from "node:crypto"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; import * as Crypto from "effect/Crypto"; @@ -25,6 +26,10 @@ import { type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, type VcsRef, + FULL_WORKTREE_MATERIALIZATION_STATE, + type VcsWorktreeMaterializationRequest, + VcsWorktreeMaterializationState as VcsWorktreeMaterializationStateSchema, + type VcsWorktreeMaterializationState, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; @@ -74,6 +79,16 @@ const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); +const WORKTREE_MATERIALIZATION_CONTRACT_PATH = "config/worktree-materialization-profiles.json"; +const WORKTREE_MATERIALIZATION_CONTRACT_SCHEMA = "clawd.worktree-materialization-profiles.v1"; +const SUPPORTED_SPARSE_TASK_CLASSES = new Set(["source-task", "task-evidence"]); +const WORKTREE_MATERIALIZATION_STATE_SCHEMA = "clawd.worktree-materialization-state.v1"; +const WORKTREE_MATERIALIZATION_STATE_FILE = "worktree-materialization.json"; +const WORKTREE_MATERIALIZATION_RECOVERY = + "Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify."; +const decodeWorktreeMaterializationState = Schema.decodeUnknownSync( + VcsWorktreeMaterializationStateSchema, +); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -108,6 +123,222 @@ const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze; + readonly sharedRequiredPaths: ReadonlyArray; + readonly unsupportedTaskClasses: ReadonlyArray; + readonly profiles: ReadonlyArray<{ + readonly id: string; + readonly mode: "full" | "sparse"; + readonly conePaths: ReadonlyArray; + readonly requiredPaths: ReadonlyArray; + }>; +} + +function worktreeMaterializationSha256(bytes: Uint8Array | string): string { + return NodeCrypto.createHash("sha256").update(bytes).digest("hex"); +} + +function normalizeMaterializationRepoPath(value: unknown): string | null { + const raw = String(value ?? "") + .trim() + .replaceAll("\\", "/"); + if (raw.length === 0 || raw.startsWith("/") || raw.includes("\0")) return null; + const parts = raw.split("/"); + if ( + parts.some((part) => part.length === 0 || part === "." || part === ".." || part.startsWith("-")) + ) + return null; + return parts.join("/"); +} + +function validMaterializationSegment(value: unknown): string | null { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(text) ? text : null; +} + +function validMaterializationContractPath(value: unknown): value is string { + return typeof value === "string" && normalizeMaterializationRepoPath(value) === value; +} + +function uniqueMaterializationPaths(values: ReadonlyArray): Array { + return [...new Set(values)].toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +} + +function minimalMaterializationConePaths(values: ReadonlyArray): Array { + const sorted = uniqueMaterializationPaths(values); + return sorted.filter( + (candidate) => + !sorted.some((other) => other !== candidate && candidate.startsWith(`${other}/`)), + ); +} + +function fullMaterializationState( + request: VcsWorktreeMaterializationRequest | undefined, + reason: string, + contractSha256: string | null = null, +): VcsWorktreeMaterializationState { + return { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + status: "ready", + requestedProfileId: request?.requestedProfileId ?? "full", + reason, + expectedContractSha256: request?.expectedContractSha256 ?? null, + contractSha256, + taskId: validMaterializationSegment(request?.taskId), + taskSlug: validMaterializationSegment(request?.taskSlug), + taskCardPath: request?.taskCardPath ?? null, + scopePaths: request?.scopePaths ? [...request.scopePaths] : [], + taskClasses: request?.taskClasses ? [...request.taskClasses] : [], + includeResearchTask: request?.includeResearchTask === true, + }; +} + +function parseWorktreeMaterializationContract(raw: Uint8Array): WorktreeMaterializationContract { + const parsed = JSON.parse( + new TextDecoder().decode(raw), + ) as Partial; + if ( + parsed.schemaVersion !== WORKTREE_MATERIALIZATION_CONTRACT_SCHEMA || + !parsed.taskContext || + !Array.isArray(parsed.sharedConePaths) || + !Array.isArray(parsed.sharedRequiredPaths) || + !Array.isArray(parsed.unsupportedTaskClasses) || + !Array.isArray(parsed.profiles) + ) { + throw new Error("invalid worktree materialization contract"); + } + const contract = parsed as WorktreeMaterializationContract; + const profileIds = contract.profiles.map((profile) => profile.id); + const declaredPaths = [ + contract.taskContext.taskCardRoot, + contract.taskContext.buildStateRoot, + contract.taskContext.researchRoot, + ...contract.sharedConePaths, + ...contract.sharedRequiredPaths, + ...contract.profiles.flatMap((profile) => [...profile.conePaths, ...profile.requiredPaths]), + ]; + if ( + JSON.stringify(profileIds) !== + JSON.stringify(["full", "governance-review", "brandt-source", "trading-strategy-source"]) || + contract.profiles[0]?.id !== "full" || + contract.profiles[0]?.mode !== "full" || + contract.profiles.some( + (profile) => + !validMaterializationSegment(profile.id) || + !["full", "sparse"].includes(profile.mode) || + !Array.isArray(profile.conePaths) || + !Array.isArray(profile.requiredPaths), + ) || + declaredPaths.some((candidate) => !validMaterializationContractPath(candidate)) + ) { + throw new Error("invalid worktree materialization profile declaration"); + } + return contract; +} + +function resolveWorktreeMaterialization( + request: VcsWorktreeMaterializationRequest | undefined, + contract: WorktreeMaterializationContract | null, + contractSha256: string | null, +): VcsWorktreeMaterializationState { + if (!request) return fullMaterializationState(undefined, "default-full"); + if (!contract || !contractSha256) { + return fullMaterializationState(request, "contract-unavailable"); + } + if (request.requestedProfileId === "full") { + return fullMaterializationState(request, "explicit-full", contractSha256); + } + if (request.expectedContractSha256 !== contractSha256) { + return fullMaterializationState(request, "hash-mismatch", contractSha256); + } + const profile = contract.profiles.find( + (candidate) => candidate.id === request.requestedProfileId, + ); + if (!profile || profile.mode !== "sparse") { + return fullMaterializationState(request, "unknown-profile", contractSha256); + } + const taskId = validMaterializationSegment(request.taskId); + const taskSlug = validMaterializationSegment(request.taskSlug); + if (!taskId || !taskSlug) { + return fullMaterializationState(request, "missing-task-context", contractSha256); + } + const taskClasses = (request.taskClasses ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + if (taskClasses.length === 0) { + return fullMaterializationState(request, "unsupported:unclassified", contractSha256); + } + const blockedClass = taskClasses.find((value) => contract.unsupportedTaskClasses.includes(value)); + if (blockedClass) { + return fullMaterializationState(request, `unsupported:${blockedClass}`, contractSha256); + } + const unknownClass = taskClasses.find((value) => !SUPPORTED_SPARSE_TASK_CLASSES.has(value)); + if (unknownClass) { + return fullMaterializationState(request, `unsupported:${unknownClass}`, contractSha256); + } + const dynamicConePaths = [ + `${contract.taskContext.buildStateRoot}/${taskId}`, + ...(request.includeResearchTask ? [`${contract.taskContext.researchRoot}/${taskSlug}`] : []), + ]; + const conePaths = minimalMaterializationConePaths([ + ...contract.sharedConePaths, + ...profile.conePaths, + ...dynamicConePaths, + ]); + const normalizedScopePaths = request.scopePaths.map(normalizeMaterializationRepoPath); + if (normalizedScopePaths.some((scopePath) => scopePath === null)) { + return fullMaterializationState(request, "ambiguous", contractSha256); + } + const scopePaths = normalizedScopePaths as Array; + const scopeIsCovered = (scopePath: string) => + conePaths.some((conePath) => scopePath === conePath || scopePath.startsWith(`${conePath}/`)); + if (scopePaths.length === 0 || scopePaths.some((scopePath) => !scopeIsCovered(scopePath))) { + return fullMaterializationState(request, "multi-domain", contractSha256); + } + const taskCardPath = normalizeMaterializationRepoPath(request.taskCardPath); + if (!taskCardPath || !taskCardPath.startsWith(`${contract.taskContext.taskCardRoot}/`)) { + return fullMaterializationState(request, "task-card-outside-repository", contractSha256); + } + const declaredDynamicPaths = uniqueMaterializationPaths([ + ...scopePaths, + taskCardPath, + `${contract.taskContext.buildStateRoot}/${taskId}`, + ]); + const requiredPaths = uniqueMaterializationPaths([ + ...contract.sharedRequiredPaths, + ...profile.requiredPaths, + ]); + const manifestSha256 = worktreeMaterializationSha256( + JSON.stringify({ profileId: profile.id, conePaths, requiredPaths }), + ); + return { + status: "ready", + requestedProfileId: request.requestedProfileId, + effectiveProfileId: profile.id, + mode: "sparse", + reason: null, + expectedContractSha256: request.expectedContractSha256, + contractSha256, + manifestSha256, + conePaths, + requiredPaths, + taskId, + taskSlug, + taskCardPath, + scopePaths: scopePaths as Array, + taskClasses, + includeResearchTask: request.includeResearchTask === true, + declaredDynamicPaths, + }; +} + type TraceTailState = { processedChars: number; remainder: string; @@ -957,6 +1188,582 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + const materializationError = (operation: string, cwd: string, detail: string, cause?: unknown) => + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args: [] }), + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const readMaterializationContract = Effect.fn("readMaterializationContract")(function* ( + repoRoot: string, + pinnedCommit?: string, + ) { + let raw: Uint8Array; + if (pinnedCommit) { + const shown = yield* Effect.exit( + executeGit("GitVcsDriver.materialization.readContractAtBase", repoRoot, [ + "show", + `${pinnedCommit}:${WORKTREE_MATERIALIZATION_CONTRACT_PATH}`, + ]), + ); + if (Exit.isFailure(shown) || shown.value.stdoutTruncated) return null; + raw = new TextEncoder().encode(shown.value.stdout); + } else { + const file = yield* Effect.exit( + fileSystem.readFile(path.join(repoRoot, WORKTREE_MATERIALIZATION_CONTRACT_PATH)), + ); + if (Exit.isFailure(file)) return null; + raw = file.value; + } + const loaded = yield* Effect.exit( + Effect.try({ + try: () => ({ + contract: parseWorktreeMaterializationContract(raw), + sha256: worktreeMaterializationSha256(raw), + }), + catch: (cause) => + materializationError( + "GitVcsDriver.materialization.readContract", + repoRoot, + "Worktree materialization contract is unreadable.", + cause, + ), + }), + ); + return Exit.isSuccess(loaded) ? loaded.value : null; + }); + + const pinMaterializationRequiredPaths = Effect.fn("pinMaterializationRequiredPaths")(function* ( + repoRoot: string, + pinnedCommit: string, + state: VcsWorktreeMaterializationState, + ) { + if (state.mode !== "sparse") return { ...state, baseSha: pinnedCommit }; + const taskCardPath = state.taskCardPath; + if (!taskCardPath) { + return { + ...state, + effectiveProfileId: "full", + mode: "full" as const, + reason: "task-card-missing-at-base", + manifestSha256: null, + conePaths: [], + requiredPaths: [], + baseSha: pinnedCommit, + }; + } + const taskCardAtBase = yield* executeGit( + "GitVcsDriver.materialization.taskCardAtBase", + repoRoot, + ["cat-file", "-e", `${pinnedCommit}:${taskCardPath}`], + { allowNonZeroExit: true }, + ); + let taskCardBytes: Uint8Array | null = null; + let sourceTaskCardBytes: Uint8Array | null = null; + const sourcePath = path.join(repoRoot, taskCardPath); + const sourceLink = yield* Effect.exit(fileSystem.readLink(sourcePath)); + if (Exit.isFailure(sourceLink)) { + const source = yield* Effect.exit(fileSystem.readFile(sourcePath)); + if (Exit.isSuccess(source)) sourceTaskCardBytes = source.value; + } + if (taskCardAtBase.exitCode === 0) { + const shown = yield* Effect.exit( + executeGit("GitVcsDriver.materialization.taskCardBytesAtBase", repoRoot, [ + "show", + `${pinnedCommit}:${taskCardPath}`, + ]), + ); + if (Exit.isSuccess(shown) && !shown.value.stdoutTruncated) { + taskCardBytes = new TextEncoder().encode(shown.value.stdout); + } + if ( + taskCardBytes && + sourceTaskCardBytes && + worktreeMaterializationSha256(taskCardBytes) !== + worktreeMaterializationSha256(sourceTaskCardBytes) + ) { + return { + ...state, + effectiveProfileId: "full", + mode: "full" as const, + reason: "task-card-source-mismatch", + manifestSha256: null, + conePaths: [], + requiredPaths: [], + baseSha: pinnedCommit, + }; + } + } else { + taskCardBytes = sourceTaskCardBytes; + } + if (!taskCardBytes) { + return { + ...state, + effectiveProfileId: "full", + mode: "full" as const, + reason: "task-card-missing-at-base", + manifestSha256: null, + conePaths: [], + requiredPaths: [], + baseSha: pinnedCommit, + }; + } + const presentDynamicPaths: Array = []; + for (const candidate of state.declaredDynamicPaths ?? []) { + const result = yield* executeGit( + "GitVcsDriver.materialization.pathAtBase", + repoRoot, + ["cat-file", "-e", `${pinnedCommit}:${candidate}`], + { allowNonZeroExit: true }, + ); + if (result.exitCode === 0) presentDynamicPaths.push(candidate); + } + const requiredPaths = uniqueMaterializationPaths([ + ...state.requiredPaths, + taskCardPath, + ...presentDynamicPaths, + ]); + return { + ...state, + baseSha: pinnedCommit, + taskCardSha256: worktreeMaterializationSha256(taskCardBytes), + taskCardGenerated: taskCardAtBase.exitCode !== 0, + requiredPaths, + manifestSha256: worktreeMaterializationSha256( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + profileId: state.effectiveProfileId, + conePaths: state.conePaths, + requiredPaths, + }), + ), + }; + }); + + const ensureMaterializedTaskCard = Effect.fn("ensureMaterializedTaskCard")(function* ( + repoRoot: string, + worktreePath: string, + state: VcsWorktreeMaterializationState, + ) { + if (!state.taskCardPath || !state.taskCardSha256) return; + const targetPath = path.join(worktreePath, state.taskCardPath); + const configureIgnore = Effect.fn("configureGeneratedTaskCardIgnore")(function* () { + if (!state.taskCardGenerated) return; + const gitDirRaw = (yield* runGitStdout( + "GitVcsDriver.materialization.taskCardGitDir", + worktreePath, + ["rev-parse", "--git-dir"], + )).trim(); + const gitDir = path.isAbsolute(gitDirRaw) ? gitDirRaw : path.resolve(worktreePath, gitDirRaw); + const excludePath = path.join(gitDir, "worktree-materialization-excludes"); + yield* fileSystem.writeFileString(excludePath, `${state.taskCardPath}\n`); + yield* runGit("GitVcsDriver.materialization.taskCardExclude", worktreePath, [ + "config", + "--worktree", + "core.excludesFile", + excludePath, + ]); + }); + const target = yield* Effect.exit(fileSystem.readFile(targetPath)); + if (Exit.isSuccess(target)) { + if (worktreeMaterializationSha256(target.value) !== state.taskCardSha256) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + worktreePath, + "Materialized task card bytes do not match the persisted identity.", + ); + } + yield* configureIgnore(); + return; + } + const sourcePath = path.join(repoRoot, state.taskCardPath); + const sourceLink = yield* Effect.exit(fileSystem.readLink(sourcePath)); + if (Exit.isSuccess(sourceLink)) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + repoRoot, + "Hash-bound task card source cannot be a symbolic link.", + ); + } + const source = yield* fileSystem + .readFile(sourcePath) + .pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.taskCard", + repoRoot, + "Hash-bound task card source is unavailable.", + cause, + ), + ), + ); + if (worktreeMaterializationSha256(source) !== state.taskCardSha256) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + repoRoot, + "Hash-bound task card source changed before materialization.", + ); + } + yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); + yield* fileSystem.writeFile(targetPath, source); + yield* configureIgnore(); + }); + + const materializationStatePath = Effect.fn("materializationStatePath")(function* (cwd: string) { + const gitDirRaw = (yield* runGitStdout("GitVcsDriver.materialization.gitDir", cwd, [ + "rev-parse", + "--git-dir", + ])).trim(); + const gitDir = path.isAbsolute(gitDirRaw) ? gitDirRaw : path.resolve(cwd, gitDirRaw); + return path.join(gitDir, WORKTREE_MATERIALIZATION_STATE_FILE); + }); + + const writeMaterializationState = Effect.fn("writeMaterializationState")(function* ( + cwd: string, + state: VcsWorktreeMaterializationState, + ) { + const statePath = yield* materializationStatePath(cwd); + const temporaryPath = `${statePath}.${process.pid}.${NodeCrypto.randomUUID()}.tmp`; + // @effect-diagnostics-next-line preferSchemaOverJson:off + const payload = `${JSON.stringify( + { schemaVersion: WORKTREE_MATERIALIZATION_STATE_SCHEMA, ...state }, + null, + 2, + )}\n`; + yield* fileSystem.writeFileString(temporaryPath, payload).pipe( + Effect.andThen(fileSystem.rename(temporaryPath, statePath)), + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.writeState", + cwd, + "Failed to atomically persist worktree materialization state.", + cause, + ), + ), + ); + }); + + const readMaterializationState = Effect.fn("readMaterializationState")(function* (cwd: string) { + const statePath = yield* materializationStatePath(cwd); + const exists = yield* fileSystem + .exists(statePath) + .pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.readState", + cwd, + "Failed to inspect worktree materialization state.", + cause, + ), + ), + ); + if (!exists) return null; + const raw = yield* fileSystem + .readFileString(statePath) + .pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.readState", + cwd, + "Failed to read worktree materialization state.", + cause, + ), + ), + ); + return yield* Effect.try({ + try: () => { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const parsed = JSON.parse(raw) as Record; + if (parsed.schemaVersion !== WORKTREE_MATERIALIZATION_STATE_SCHEMA) { + throw new Error("invalid materialization state schema"); + } + return decodeWorktreeMaterializationState(parsed); + }, + catch: (cause) => + materializationError( + "GitVcsDriver.materialization.readState", + cwd, + "Worktree materialization state is unreadable.", + cause, + ), + }); + }); + + const requiredMaterializationPathsMissing = Effect.fn("requiredMaterializationPathsMissing")( + function* (cwd: string, requiredPaths: ReadonlyArray) { + const existence = yield* Effect.forEach( + requiredPaths, + (relativePath) => + fileSystem + .exists(path.join(cwd, relativePath)) + .pipe( + Effect.mapError((cause) => + materializationError( + "GitVcsDriver.materialization.requiredPaths", + cwd, + `Failed to inspect required path '${relativePath}'.`, + cause, + ), + ), + ), + { concurrency: "unbounded" }, + ); + return requiredPaths.filter((_, index) => existence[index] !== true); + }, + ); + + const taskCardIdentityMatches = Effect.fn("taskCardIdentityMatches")(function* ( + cwd: string, + state: VcsWorktreeMaterializationState, + ) { + if (!state.taskCardPath || !state.taskCardSha256) return true; + const loaded = yield* Effect.exit(fileSystem.readFile(path.join(cwd, state.taskCardPath))); + return ( + Exit.isSuccess(loaded) && worktreeMaterializationSha256(loaded.value) === state.taskCardSha256 + ); + }); + + const generatedTaskCardIsIgnored = Effect.fn("generatedTaskCardIsIgnored")(function* ( + cwd: string, + state: VcsWorktreeMaterializationState, + ) { + if (!state.taskCardGenerated || !state.taskCardPath) return true; + const result = yield* executeGit( + "GitVcsDriver.materialization.taskCardIgnored", + cwd, + ["check-ignore", "--quiet", "--", state.taskCardPath], + { allowNonZeroExit: true }, + ); + return result.exitCode === 0; + }); + + const sparseCheckoutEnabled = Effect.fn("sparseCheckoutEnabled")(function* (cwd: string) { + const result = yield* executeGit( + "GitVcsDriver.materialization.sparseEnabled", + cwd, + ["config", "--bool", "core.sparseCheckout"], + { allowNonZeroExit: true }, + ); + return result.exitCode === 0 && result.stdout.trim() === "true"; + }); + + const verifyWorktreeMaterialization = Effect.fn("verifyWorktreeMaterialization")(function* ( + cwd: string, + ) { + const persisted = yield* readMaterializationState(cwd); + if (!persisted) { + if (yield* sparseCheckoutEnabled(cwd)) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Sparse checkout has no persisted materialization identity. Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify.", + ); + } + return FULL_WORKTREE_MATERIALIZATION_STATE; + } + if (persisted.status === "failed") { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Worktree materialization failed before release: ${persisted.reason ?? "unknown failure"}. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + ); + } + const sparseEnabled = yield* sparseCheckoutEnabled(cwd); + if (persisted.effectiveProfileId === "full") { + if (sparseEnabled) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Effective full materialization still has sparse checkout enabled.", + ); + } + const missing = yield* requiredMaterializationPathsMissing(cwd, persisted.requiredPaths); + if (missing.length > 0) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Full materialization is missing required path(s): ${missing.join(", ")}. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + ); + } + if (!(yield* taskCardIdentityMatches(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Exact task card bytes do not match the persisted identity. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + ); + } + if (!(yield* generatedTaskCardIsIgnored(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Generated task card is visible to Git status.", + ); + } + return persisted; + } + if (!sparseEnabled || persisted.mode !== "sparse") { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Persisted sparse materialization does not match Git sparse-checkout state.", + ); + } + const repoRoot = (yield* runGitStdout("GitVcsDriver.materialization.repoRoot", cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + const contract = yield* readMaterializationContract(repoRoot); + if (!contract || contract.sha256 !== persisted.contractSha256) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Materialization contract bytes do not match the persisted identity. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + ); + } + const conePaths = minimalMaterializationConePaths([...persisted.conePaths]); + const requiredPaths = uniqueMaterializationPaths([...persisted.requiredPaths]); + const manifestSha256 = worktreeMaterializationSha256( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + profileId: persisted.effectiveProfileId, + conePaths, + requiredPaths, + }), + ); + if (manifestSha256 !== persisted.manifestSha256) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Persisted materialization manifest does not match its hash.", + ); + } + const sparseIndex = yield* executeGit( + "GitVcsDriver.materialization.sparseIndex", + cwd, + ["config", "--bool", "index.sparse"], + { allowNonZeroExit: true }, + ); + if (sparseIndex.exitCode === 0 && sparseIndex.stdout.trim() === "true") { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Sparse index is forbidden for materialized worktrees.", + ); + } + const observedCone = uniqueMaterializationPaths( + (yield* runGitStdout("GitVcsDriver.materialization.sparseList", cwd, [ + "sparse-checkout", + "list", + ])) + .split(/\r?\n/) + .map(normalizeMaterializationRepoPath) + .filter((candidate): candidate is string => candidate !== null), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + if (JSON.stringify(observedCone) !== JSON.stringify(conePaths)) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Git sparse cone does not match persisted materialization state.", + ); + } + const missing = yield* requiredMaterializationPathsMissing(cwd, requiredPaths); + if (missing.length > 0) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Sparse materialization is missing required path(s): ${missing.join(", ")}. Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify.`, + ); + } + if (!(yield* taskCardIdentityMatches(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + `Exact task card bytes do not match the persisted identity. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + ); + } + if (!(yield* generatedTaskCardIsIgnored(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Generated task card is visible to Git status.", + ); + } + return persisted; + }); + + const expandWorktreeMaterializationFull = Effect.fn("expandWorktreeMaterializationFull")( + function* (cwd: string, reason: string) { + const status = (yield* runGitStdout("GitVcsDriver.materialization.expandStatus", cwd, [ + "status", + "--porcelain=v1", + "--untracked-files=all", + ])).trim(); + if (status.length > 0) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + "Worktree must be clean before expand-full.", + ); + } + const persisted = yield* readMaterializationState(cwd); + const wasSparse = yield* sparseCheckoutEnabled(cwd); + const needsSparseDisable = + wasSparse || persisted?.mode === "sparse" || persisted?.status === "failed"; + if (needsSparseDisable) { + yield* runGit( + "GitVcsDriver.materialization.expandFull", + cwd, + ["sparse-checkout", "disable"], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ); + if (yield* sparseCheckoutEnabled(cwd)) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + "Sparse checkout remained enabled after expand-full.", + ); + } + } + const baseSha = (yield* runGitStdout("GitVcsDriver.materialization.expandFullHead", cwd, [ + "rev-parse", + "HEAD^{commit}", + ])).trim(); + const baseState = persisted ?? { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + baseSha, + }; + const missing = yield* requiredMaterializationPathsMissing(cwd, baseState.requiredPaths); + if (missing.length > 0) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + `Full expansion is missing required path(s): ${missing.join(", ")}`, + ); + } + const currentTaskCard = baseState.taskCardPath + ? yield* Effect.exit(fileSystem.readFile(path.join(cwd, baseState.taskCardPath))) + : null; + const nextState: VcsWorktreeMaterializationState = { + ...baseState, + status: "ready", + effectiveProfileId: "full", + mode: "full", + reason: reason.trim() || "expand-full", + taskCardSha256: + currentTaskCard && Exit.isSuccess(currentTaskCard) + ? worktreeMaterializationSha256(currentTaskCard.value) + : (baseState.taskCardSha256 ?? null), + baseSha: baseState.baseSha ?? baseSha, + }; + // Preserve the requested sparse manifest as provenance. Only the + // effective profile and mode transition after creation. + yield* writeMaterializationState(cwd, nextState); + return yield* verifyWorktreeMaterialization(cwd); + }, + ); + const branchExists = (cwd: string, refName: string): Effect.Effect => executeGit( "GitVcsDriver.branchExists", @@ -2836,17 +3643,254 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* )(function* (input) { const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); + const repoRoot = (yield* runGitStdout("GitVcsDriver.createWorktree.repoRoot", input.cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + const repoName = path.basename(repoRoot); const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const pinnedCommit = (yield* runGitStdout("GitVcsDriver.createWorktree.pinCommit", repoRoot, [ + "rev-parse", + "--verify", + `${input.refName}^{commit}`, + ])).trim(); + const contract = input.materialization + ? yield* readMaterializationContract(repoRoot, pinnedCommit) + : null; + const requestedMaterialization = yield* pinMaterializationRequiredPaths( + repoRoot, + pinnedCommit, + resolveWorktreeMaterialization( + input.materialization, + contract?.contract ?? null, + contract?.sha256 ?? null, + ), + ); const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] - : ["worktree", "add", worktreePath, input.refName]; + ? ["worktree", "add", "--no-checkout", "-b", input.newRefName, worktreePath, pinnedCommit] + : ["worktree", "add", "--no-checkout", worktreePath, input.refName]; - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { + yield* executeGit("GitVcsDriver.createWorktree", repoRoot, args, { fallbackErrorDetail: "git worktree add failed", timeoutMs: WORKTREE_ADD_TIMEOUT_MS, }); + let materialization: VcsWorktreeMaterializationState = requestedMaterialization; + if (requestedMaterialization.mode === "sparse") { + let fallbackReason: string | null = null; + const sparseSet = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.sparseSet", + worktreePath, + [ + "sparse-checkout", + "set", + "--cone", + "--no-sparse-index", + ...requestedMaterialization.conePaths, + ], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(sparseSet)) { + fallbackReason = "sparse-setup-failed"; + } else { + const checkout = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.checkoutSparse", + worktreePath, + ["checkout", "--force", targetBranch], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(checkout)) { + fallbackReason = "sparse-checkout-failed"; + } else { + const taskCard = yield* Effect.exit( + ensureMaterializedTaskCard(repoRoot, worktreePath, requestedMaterialization), + ); + if (Exit.isFailure(taskCard)) { + fallbackReason = "task-card-materialization-failed"; + } else { + const missing = yield* requiredMaterializationPathsMissing( + worktreePath, + requestedMaterialization.requiredPaths, + ); + if (missing.length > 0) fallbackReason = "required-paths-missing"; + } + } + } + + if (fallbackReason) { + if (yield* sparseCheckoutEnabled(worktreePath)) { + const disabled = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.disableSparseFallback", + worktreePath, + ["sparse-checkout", "disable"], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(disabled)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + reason: `${fallbackReason}:full-fallback-disable-failed`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback could not disable sparse checkout. The never-released worktree was preserved for diagnosis.`, + ); + } + } + const fullCheckout = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.checkoutFullFallback", + worktreePath, + ["checkout", "--force", targetBranch], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(fullCheckout)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + effectiveProfileId: "full", + mode: "full", + reason: `${fallbackReason}:full-fallback-checkout-failed`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback checkout failed. The never-released worktree was preserved for diagnosis.`, + ); + } + const fullTaskCard = yield* Effect.exit( + ensureMaterializedTaskCard(repoRoot, worktreePath, requestedMaterialization), + ); + if (Exit.isFailure(fullTaskCard)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + effectiveProfileId: "full", + mode: "full", + reason: `${fallbackReason}:full-fallback-task-card-failed`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback could not materialize the exact task card. The never-released worktree was preserved for diagnosis.`, + ); + } + const missing = yield* requiredMaterializationPathsMissing( + worktreePath, + requestedMaterialization.requiredPaths, + ); + if (missing.length > 0) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + effectiveProfileId: "full", + mode: "full", + reason: `${fallbackReason}:full-fallback-required-paths-missing`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback is missing required path(s): ${missing.join(", ")}. The never-released worktree was preserved for diagnosis.`, + ); + } + materialization = { + ...requestedMaterialization, + effectiveProfileId: "full", + mode: "full", + reason: fallbackReason, + }; + } + } else { + const fullCheckout = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.checkoutFull", + worktreePath, + ["checkout", "--force", targetBranch], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), + ); + if (Exit.isFailure(fullCheckout)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + reason: "full-checkout-failed", + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Full worktree checkout failed. The never-released worktree was preserved for diagnosis.", + ); + } + const fullTaskCard = yield* Effect.exit( + ensureMaterializedTaskCard(repoRoot, worktreePath, requestedMaterialization), + ); + if (Exit.isFailure(fullTaskCard)) { + const failedState: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + status: "failed", + reason: "full-task-card-materialization-failed", + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Full worktree could not materialize the exact task card. The never-released worktree was preserved for diagnosis.", + ); + } + } + + const observedHead = (yield* runGitStdout( + "GitVcsDriver.createWorktree.verifyHead", + worktreePath, + ["rev-parse", "--verify", "HEAD^{commit}"], + )).trim(); + if (observedHead !== pinnedCommit) { + yield* writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "materialized-head-mismatch", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Materialized worktree HEAD does not match the pinned source commit. The never-released worktree was preserved for diagnosis.", + ); + } + if (input.newRefName) { + const observedBranch = (yield* runGitStdout( + "GitVcsDriver.createWorktree.verifyBranch", + worktreePath, + ["branch", "--show-current"], + )).trim(); + if (observedBranch !== input.newRefName) { + yield* writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "materialized-branch-mismatch", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Materialized worktree branch does not match the requested branch. The never-released worktree was preserved for diagnosis.", + ); + } + } + yield* writeMaterializationState(worktreePath, materialization); + materialization = yield* verifyWorktreeMaterialization(worktreePath); + // `git worktree add` leaves submodules empty, so a repo that keeps agent // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's @@ -2890,6 +3934,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* path: worktreePath, refName: targetBranch, }, + materialization, }; }); @@ -3320,6 +4365,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readConfigValue, listRefs, createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), + verifyWorktreeMaterialization, + expandWorktreeMaterializationFull, fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), fetchPullRequestHeadCommit, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0a9b5b64389d..ceb701d1efb1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3,6 +3,7 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -1134,6 +1135,9 @@ const makeWsRpcLayer = ( newRefName: bootstrap.prepareWorktree.branch, baseRefName: bootstrap.prepareWorktree.baseBranch, path: null, + ...(bootstrap.prepareWorktree.materialization + ? { materialization: bootstrap.prepareWorktree.materialization } + : {}), }); targetWorktreePath = worktree.worktree.path; yield* dispatchFromClient({ @@ -1143,6 +1147,24 @@ const makeWsRpcLayer = ( branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); + const materialization = worktree.materialization; + if (materialization) { + const verifiedMaterialization = + yield* gitWorkflow.verifyWorktreeMaterialization(targetWorktreePath); + if (!Equal.equals(verifiedMaterialization, materialization)) { + return yield* new OrchestrationDispatchCommandError({ + message: + "Created worktree materialization does not match its persisted identity.", + }); + } + yield* dispatchFromClient({ + type: "thread.materialization.set", + commandId: yield* serverCommandId("bootstrap-thread-materialization-set"), + threadId: command.threadId, + materialization, + createdAt: finalTurnStartCommand.createdAt, + }); + } yield* refreshGitStatus(targetWorktreePath); } @@ -2405,6 +2427,55 @@ const makeWsRpcLayer = ( gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsExpandWorktreeMaterialization]: (input) => + observeRpcEffect( + WS_METHODS.vcsExpandWorktreeMaterialization, + Effect.gen(function* () { + const thread = Option.getOrUndefined( + yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to read thread state for expand-full.", + cause, + }), + ), + ), + ); + if (!thread || thread.worktreePath !== input.cwd) { + return yield* new OrchestrationDispatchCommandError({ + message: + "expand-full requires the exact persisted worktree path for the named thread.", + }); + } + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* new OrchestrationDispatchCommandError({ + message: "expand-full is unavailable while the thread session is active.", + }); + } + const materialization = yield* gitWorkflow.expandWorktreeMaterializationFull( + input.cwd, + input.reason ?? "operator-expand-full", + ); + yield* dispatchFromClient({ + type: "thread.materialization.set", + commandId: yield* serverCommandId("thread-materialization-expand-full"), + threadId: input.threadId, + materialization, + createdAt: yield* nowIso, + }).pipe( + Effect.mapError((cause) => + toDispatchCommandError( + cause, + "Failed to persist expanded worktree materialization", + ), + ), + ); + yield* refreshGitStatus(input.cwd); + return { materialization }; + }), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsRemoveWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsRemoveWorktree, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8e9c80641f2b..e30217b1cd43 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -42,6 +42,10 @@ import { resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + buildUiWorktreeMaterializationRequest, + parseWorktreeMaterializationUiContract, + resolveUiWorktreeMaterializationRequest, + worktreeMaterializationPresentation, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, @@ -1169,6 +1173,192 @@ describe("resolveSendEnvMode", () => { }); }); +describe("worktree materialization selection", () => { + it("discovers profile IDs from the repository contract rather than UI constants", () => { + const parsed = parseWorktreeMaterializationUiContract( + JSON.stringify({ + schemaVersion: "clawd.worktree-materialization-profiles.v1", + profiles: [ + { id: "full", mode: "full" }, + { id: "repository-profile", mode: "sparse" }, + ], + }), + ); + expect(parsed?.profiles.map((profile) => profile.id)).toEqual(["full", "repository-profile"]); + }); + + it("uses only matching structured task-card metadata and never title text", () => { + const sha = "a".repeat(64); + const request = resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + title: "brandt trading governance words are irrelevant", + issue: { id: "OC-1" }, + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + verification: { + status: "declared", + args: { paths: ["scripts/test/materialization.test.js"] }, + }, + }), + }); + expect(request?.requestedProfileId).toBe("governance-review"); + expect(request?.taskCardPath).toBe("ops/stef-task/task/stef-task.json"); + expect(request?.scopePaths).toEqual(["docs/spec.md", "scripts/test/materialization.test.js"]); + expect( + resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + issue: { id: "OC-OTHER" }, + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }), + }), + ).toBeUndefined(); + expect( + resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "brandt-source", + contractSha256: sha, + taskCardPath: "ops/stef-task/other/stef-task.json", + taskCardContents: JSON.stringify({ title: "Brandt source task" }), + }), + ).toBeUndefined(); + }); + + it("rejects card fields that would fail the wire schema", () => { + const sha = "a".repeat(64); + expect( + resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "", + taskSlug: "task", + scopePaths: [123], + }, + }), + }), + ).toBeUndefined(); + expect( + resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "OC-1", + taskSlug: "task", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + verification: { status: "declared", args: { paths: [123] } }, + }), + }), + ).toBeUndefined(); + }); + + it("builds a schema-valid unclassified sentinel that must fall back full", () => { + const sha = "a".repeat(64); + expect( + buildUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "", + taskCardContents: null, + }), + ).toEqual({ + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "invalid-context", + taskSlug: "invalid-context", + taskCardPath: "invalid", + scopePaths: ["invalid"], + taskClasses: ["unclassified"], + }); + expect( + buildUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "b".repeat(64), + taskId: "OC-1", + taskSlug: "task", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }), + })?.taskClasses, + ).toEqual(["unclassified"]); + }); + + it("surfaces sparse and requested-to-full effective states", () => { + const fullFallback = worktreeMaterializationPresentation({ + requestedProfileId: "governance-review", + effectiveProfileId: "full", + mode: "full", + reason: "hash-mismatch", + expectedContractSha256: null, + contractSha256: null, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskId: null, + taskSlug: null, + }); + expect(fullFallback).toEqual({ + label: "Requested governance-review → full (hash-mismatch)", + canExpand: false, + fellBack: true, + }); + expect( + worktreeMaterializationPresentation({ + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse", + reason: null, + expectedContractSha256: null, + contractSha256: null, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskId: null, + taskSlug: null, + }), + ).toEqual({ + label: "Sparse profile: governance-review", + canExpand: true, + fellBack: false, + }); + }); +}); + describe("resolveBackgroundDraftWorkspaceOptions", () => { it("keeps New worktree selected without reusing the launched worktree", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1a6b1b775f41..113826522296 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -16,6 +16,8 @@ import { type ScopedThreadRef, type ThreadId, type TurnId, + type VcsWorktreeMaterializationRequest, + type VcsWorktreeMaterializationState, } from "@t3tools/contracts"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { @@ -61,6 +63,157 @@ export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +export interface WorktreeMaterializationUiContract { + readonly schemaVersion: string; + readonly profiles: ReadonlyArray<{ readonly id: string; readonly mode: "full" | "sparse" }>; +} + +export function parseWorktreeMaterializationUiContract( + contents: string, +): WorktreeMaterializationUiContract | null { + try { + const parsed = JSON.parse(contents) as Partial; + if ( + parsed.schemaVersion !== "clawd.worktree-materialization-profiles.v1" || + !Array.isArray(parsed.profiles) || + parsed.profiles.length === 0 || + parsed.profiles[0]?.id !== "full" || + parsed.profiles.some( + (profile) => + typeof profile?.id !== "string" || !["full", "sparse"].includes(profile.mode ?? ""), + ) + ) { + return null; + } + return parsed as WorktreeMaterializationUiContract; + } catch { + return null; + } +} + +export function resolveUiWorktreeMaterializationRequest(input: { + readonly requestedProfileId: string; + readonly contractSha256: string | null; + readonly taskCardContents: string | null; + readonly taskCardPath: string; +}): VcsWorktreeMaterializationRequest | undefined { + if (input.requestedProfileId === "full") return undefined; + try { + const card = JSON.parse(input.taskCardContents ?? "") as { + readonly issue?: { readonly id?: string }; + readonly issueId?: string; + readonly materialization?: Partial; + readonly verification?: { + readonly status?: string; + readonly args?: Readonly>; + }; + }; + const declared = card.materialization; + const verificationArgs = card.verification?.status === "declared" ? card.verification.args : {}; + const verificationArgsValid = + verificationArgs !== undefined && + verificationArgs !== null && + typeof verificationArgs === "object" && + !Array.isArray(verificationArgs) && + Object.values(verificationArgs).every( + (value) => + Array.isArray(value) && + value.every((candidate) => typeof candidate === "string" && candidate.trim().length > 0), + ); + const verifierPaths = verificationArgsValid + ? Object.values(verificationArgs ?? {}).flatMap((value) => value as ReadonlyArray) + : []; + const normalizeIssueId = (value: unknown) => + String(value ?? "") + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9-]+/g, "-") + .replaceAll(/^-+|-+$/g, ""); + const selectedCardPath = input.taskCardPath.trim().replaceAll("\\", "/"); + const cardPathParts = selectedCardPath.split("/"); + const expectedTaskSlug = cardPathParts.length >= 2 ? cardPathParts.at(-2) : undefined; + if ( + !declared || + !verificationArgsValid || + declared.requestedProfileId !== input.requestedProfileId || + input.contractSha256 === null || + typeof declared.expectedContractSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(declared.expectedContractSha256) || + declared.expectedContractSha256 !== input.contractSha256 || + typeof declared.taskId !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(declared.taskId.trim()) || + typeof declared.taskSlug !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(declared.taskSlug.trim()) || + input.taskCardPath.trim().length === 0 || + normalizeIssueId(card.issue?.id ?? card.issueId) !== normalizeIssueId(declared.taskId) || + declared.taskSlug !== expectedTaskSlug || + declared.taskCardPath !== selectedCardPath || + !Array.isArray(declared.scopePaths) || + declared.scopePaths.length === 0 || + declared.scopePaths.some( + (scopePath) => typeof scopePath !== "string" || scopePath.trim().length === 0, + ) || + (declared.taskClasses !== undefined && + (!Array.isArray(declared.taskClasses) || + declared.taskClasses.some( + (taskClass) => typeof taskClass !== "string" || taskClass.trim().length === 0, + ))) + ) { + return undefined; + } + return { + requestedProfileId: declared.requestedProfileId, + expectedContractSha256: declared.expectedContractSha256, + taskId: declared.taskId, + taskSlug: declared.taskSlug, + taskCardPath: selectedCardPath, + scopePaths: [...new Set([...declared.scopePaths, ...verifierPaths])], + ...(Array.isArray(declared.taskClasses) ? { taskClasses: declared.taskClasses } : {}), + ...(declared.includeResearchTask === true ? { includeResearchTask: true } : {}), + }; + } catch { + return undefined; + } +} + +export function buildUiWorktreeMaterializationRequest(input: { + readonly requestedProfileId: string; + readonly contractSha256: string | null; + readonly taskCardContents: string | null; + readonly taskCardPath: string; +}): VcsWorktreeMaterializationRequest | undefined { + const fromCard = resolveUiWorktreeMaterializationRequest(input); + if (fromCard || input.requestedProfileId === "full") return fromCard; + if (!input.contractSha256) return undefined; + return { + requestedProfileId: input.requestedProfileId, + expectedContractSha256: input.contractSha256, + taskId: "invalid-context", + taskSlug: "invalid-context", + taskCardPath: input.taskCardPath.trim() || "invalid", + scopePaths: ["invalid"], + taskClasses: ["unclassified"], + }; +} + +export function worktreeMaterializationPresentation( + state: VcsWorktreeMaterializationState, +): { readonly label: string; readonly canExpand: boolean; readonly fellBack: boolean } | null { + if (state.requestedProfileId === "full" && state.effectiveProfileId === "full") return null; + if (state.mode === "full") { + return { + label: `Requested ${state.requestedProfileId} → full${state.reason ? ` (${state.reason})` : ""}`, + canExpand: false, + fellBack: true, + }; + } + return { + label: `Sparse profile: ${state.effectiveProfileId}`, + canExpand: true, + fellBack: false, + }; +} + export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function agentControlledBrowserCloseConfirmation( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1b9c106fe523..3aef192e5efb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,6 +24,7 @@ import { resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, + FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; @@ -385,6 +386,9 @@ import { resolveDraftHeroState, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + buildUiWorktreeMaterializationRequest, + parseWorktreeMaterializationUiContract, + worktreeMaterializationPresentation, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, @@ -1383,6 +1387,10 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); + const expandWorktreeMaterialization = useAtomCommand( + vcsEnvironment.expandWorktreeMaterialization, + { reportFailure: false }, + ); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); @@ -4935,6 +4943,146 @@ function ChatViewContent(props: ChatViewProps) { requestedEnvMode: envMode, isGitRepo, }); + const [requestedMaterializationProfileId, setRequestedMaterializationProfileId] = + useState("full"); + const [materializationTaskCardPath, setMaterializationTaskCardPath] = useState(""); + const [materializationExpandPending, setMaterializationExpandPending] = useState(false); + useEffect(() => { + setRequestedMaterializationProfileId("full"); + setMaterializationTaskCardPath(""); + setMaterializationExpandPending(false); + }, [activeProject?.id, activeThread?.id]); + const materializationContractQuery = useEnvironmentQuery( + activeProject && envMode === "worktree" + ? projectEnvironment.readFile({ + environmentId, + input: { + cwd: activeProject.workspaceRoot, + relativePath: "config/worktree-materialization-profiles.json", + }, + }) + : null, + ); + const materializationTaskCardQuery = useEnvironmentQuery( + activeProject && envMode === "worktree" && materializationTaskCardPath.trim().length > 0 + ? projectEnvironment.readFile({ + environmentId, + input: { + cwd: activeProject.workspaceRoot, + relativePath: materializationTaskCardPath.trim(), + }, + }) + : null, + ); + const materializationContract = useMemo( + () => + materializationContractQuery.data?.truncated === false + ? parseWorktreeMaterializationUiContract(materializationContractQuery.data.contents) + : null, + [materializationContractQuery.data], + ); + const [materializationContractSha256, setMaterializationContractSha256] = useState( + null, + ); + useEffect(() => { + let cancelled = false; + const source = materializationContractQuery.data; + if (!source || source.truncated || materializationContract === null) { + setMaterializationContractSha256(null); + return () => { + cancelled = true; + }; + } + const bytes = new TextEncoder().encode(source.contents); + if (bytes.byteLength !== source.byteLength) { + setMaterializationContractSha256(null); + return () => { + cancelled = true; + }; + } + const subtle = globalThis.crypto?.subtle; + if (!subtle) { + setMaterializationContractSha256(null); + return () => { + cancelled = true; + }; + } + void subtle.digest("SHA-256", bytes).then( + (digest) => { + if (cancelled) return; + setMaterializationContractSha256( + [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""), + ); + }, + () => { + if (!cancelled) setMaterializationContractSha256(null); + }, + ); + return () => { + cancelled = true; + }; + }, [materializationContract, materializationContractQuery.data]); + useEffect(() => { + if ( + requestedMaterializationProfileId !== "full" && + !materializationContract?.profiles.some( + (profile) => profile.id === requestedMaterializationProfileId, + ) + ) { + setRequestedMaterializationProfileId("full"); + } + }, [materializationContract, requestedMaterializationProfileId]); + const requestedWorktreeMaterialization = useMemo(() => { + return buildUiWorktreeMaterializationRequest({ + requestedProfileId: requestedMaterializationProfileId, + contractSha256: materializationContractSha256, + taskCardContents: materializationTaskCardQuery.data?.contents ?? null, + taskCardPath: materializationTaskCardPath, + }); + }, [ + materializationContractSha256, + materializationTaskCardPath, + materializationTaskCardQuery.data?.contents, + requestedMaterializationProfileId, + ]); + const activeMaterializationPresentation = useMemo( + () => + activeThread + ? worktreeMaterializationPresentation( + activeThread.materialization ?? FULL_WORKTREE_MATERIALIZATION_STATE, + ) + : null, + [activeThread], + ); + const handleExpandWorktreeMaterialization = useCallback(async () => { + if (!activeThread?.worktreePath || materializationExpandPending) return; + setMaterializationExpandPending(true); + try { + const result = await expandWorktreeMaterialization({ + environmentId, + input: { + cwd: activeThread.worktreePath, + threadId: activeThread.id, + reason: "user-expand-full", + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not expand worktree", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } else if (result._tag === "Success") { + toastManager.add( + stackedThreadToast({ type: "success", title: "Worktree expanded to full" }), + ); + } + } finally { + setMaterializationExpandPending(false); + } + }, [activeThread, environmentId, expandWorktreeMaterialization, materializationExpandPending]); const localCheckoutBranchMismatch = useMemo( () => isServerThread @@ -6658,6 +6806,9 @@ function ChatViewContent(props: ChatViewProps) { baseBranch: baseBranchForWorktree, branch: buildTemporaryWorktreeBranchName(randomHex), ...(startFromOrigin ? { startFromOrigin: true } : {}), + ...(requestedWorktreeMaterialization + ? { materialization: requestedWorktreeMaterialization } + : {}), }, runSetupScript: true, } @@ -7997,6 +8148,85 @@ function ChatViewContent(props: ChatViewProps) { > {mountComposerContextStrip && (
+ {envMode === "worktree" && + (isLocalDraftThread || canOverrideServerThreadEnvMode) && + materializationContract && + materializationContractSha256 ? ( +
+ + {requestedMaterializationProfileId !== "full" ? ( + + ) : null} + {requestedMaterializationProfileId !== "full" && + requestedWorktreeMaterialization?.taskClasses?.includes( + "unclassified", + ) ? ( + + A missing or mismatched task card will safely materialize + full. + + ) : null} +
+ ) : null} + {isServerThread && + activeThread.worktreePath && + activeMaterializationPresentation ? ( +
+ + {activeMaterializationPresentation.label} + + {activeMaterializationPresentation.canExpand ? ( + + ) : null} +
+ ) : null} ( concurrency: vcsCommandConcurrency, onSettled: invalidateRefs, }), + expandWorktreeMaterialization: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:expand-worktree-materialization", + tag: WS_METHODS.vcsExpandWorktreeMaterialization, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, + }), removeWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:remove-worktree", tag: WS_METHODS.vcsRemoveWorktree, diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 5dda491b009b..65587cdf5c00 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -44,6 +44,43 @@ describe("VcsCreateWorktreeInput", () => { expect(parsed.baseRefName).toBe("origin/main"); }); + + it("accepts an explicit hash-bound materialization request", () => { + const parsed = decodeCreateWorktreeInput({ + cwd: "/repo", + refName: "main", + path: "/tmp/worktree", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "governance-task", + taskCardPath: "ops/stef-task/governance-task/stef-task.json", + scopePaths: ["docs/spec.md"], + }, + }); + + expect(parsed.materialization?.requestedProfileId).toBe("governance-review"); + expect(parsed.materialization?.expectedContractSha256).toBe("a".repeat(64)); + }); + + it("rejects a non-SHA contract identity", () => { + expect(() => + decodeCreateWorktreeInput({ + cwd: "/repo", + refName: "main", + path: "/tmp/worktree", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256: "not-a-sha", + taskId: "OC-1", + taskSlug: "governance-task", + taskCardPath: "ops/stef-task/governance-task/stef-task.json", + scopePaths: ["docs/spec.md"], + }, + }), + ).toThrow(); + }); }); describe("GitPreparePullRequestThreadInput", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index d39be34bf6e9..ba4958459d3e 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -47,6 +47,61 @@ const VcsStatusChangeRequestState = Schema.Literals(["open", "closed", "merged"] const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +const Sha256Hex = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); +const GitCommitHex = Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)); + +export const VcsWorktreeMaterializationRequest = Schema.Struct({ + requestedProfileId: TrimmedNonEmptyStringSchema, + expectedContractSha256: Sha256Hex, + taskId: TrimmedNonEmptyStringSchema, + taskSlug: TrimmedNonEmptyStringSchema, + taskCardPath: TrimmedNonEmptyStringSchema, + scopePaths: Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), + taskClasses: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), + includeResearchTask: Schema.optional(Schema.Boolean), +}); +export type VcsWorktreeMaterializationRequest = typeof VcsWorktreeMaterializationRequest.Type; + +export const VcsWorktreeMaterializationState = Schema.Struct({ + status: Schema.optional(Schema.Literals(["ready", "failed"])), + requestedProfileId: TrimmedNonEmptyStringSchema, + effectiveProfileId: TrimmedNonEmptyStringSchema, + mode: Schema.Literals(["full", "sparse"]), + reason: Schema.NullOr(Schema.String), + expectedContractSha256: Schema.NullOr(Sha256Hex), + contractSha256: Schema.NullOr(Sha256Hex), + manifestSha256: Schema.NullOr(Sha256Hex), + conePaths: Schema.Array(TrimmedNonEmptyStringSchema), + requiredPaths: Schema.Array(TrimmedNonEmptyStringSchema), + taskId: Schema.NullOr(TrimmedNonEmptyStringSchema), + taskSlug: Schema.NullOr(TrimmedNonEmptyStringSchema), + taskCardPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), + taskCardSha256: Schema.optional(Schema.NullOr(Sha256Hex)), + taskCardGenerated: Schema.optional(Schema.Boolean), + baseSha: Schema.optional(Schema.NullOr(GitCommitHex)), + scopePaths: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), + taskClasses: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), + includeResearchTask: Schema.optional(Schema.Boolean), + declaredDynamicPaths: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), +}); +export type VcsWorktreeMaterializationState = typeof VcsWorktreeMaterializationState.Type; + +export const FULL_WORKTREE_MATERIALIZATION_STATE: VcsWorktreeMaterializationState = { + status: "ready", + requestedProfileId: "full", + effectiveProfileId: "full", + mode: "full", + reason: "default-full", + expectedContractSha256: null, + contractSha256: null, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskId: null, + taskSlug: null, + taskCardPath: null, + baseSha: null, +}; export const GitRunStackedActionToastRunAction = Schema.Struct({ kind: GitStackedAction, }); @@ -141,9 +196,18 @@ export const VcsCreateWorktreeInput = Schema.Struct({ newRefName: Schema.optional(TrimmedNonEmptyStringSchema), baseRefName: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), + materialization: Schema.optional(VcsWorktreeMaterializationRequest), }); export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; +export const VcsExpandWorktreeMaterializationInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + threadId: ThreadId, + reason: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type VcsExpandWorktreeMaterializationInput = + typeof VcsExpandWorktreeMaterializationInput.Type; + export const GitPullRequestRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, @@ -273,9 +337,16 @@ export type VcsListRefsResult = typeof VcsListRefsResult.Type; export const VcsCreateWorktreeResult = Schema.Struct({ worktree: VcsWorktree, + materialization: Schema.optional(VcsWorktreeMaterializationState), }); export type VcsCreateWorktreeResult = typeof VcsCreateWorktreeResult.Type; +export const VcsExpandWorktreeMaterializationResult = Schema.Struct({ + materialization: VcsWorktreeMaterializationState, +}); +export type VcsExpandWorktreeMaterializationResult = + typeof VcsExpandWorktreeMaterializationResult.Type; + export const GitResolvePullRequestResult = Schema.Struct({ pullRequest: GitResolvedPullRequest, }); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 798d5a777d5d..05bf24457526 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -3,6 +3,8 @@ import type { VcsCreateRefResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, + VcsExpandWorktreeMaterializationInput, + VcsExpandWorktreeMaterializationResult, VcsInitInput, VcsListRefsInput, VcsListRefsResult, @@ -1338,6 +1340,9 @@ export interface EnvironmentApi { vcs: { listRefs: (input: VcsListRefsInput) => Promise; createWorktree: (input: VcsCreateWorktreeInput) => Promise; + expandWorktreeMaterialization: ( + input: VcsExpandWorktreeMaterializationInput, + ) => Promise; removeWorktree: (input: VcsRemoveWorktreeInput) => Promise; createRef: (input: VcsCreateRefInput) => Promise; switchRef: (input: VcsSwitchRefInput) => Promise; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d7e33ebb713b..e79c1092fef2 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -65,6 +65,33 @@ const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); const decodeDispatchCommandError = Schema.decodeUnknownEffect(OrchestrationDispatchCommandError); +it.effect("keeps materialization persistence off the client command surface", () => + Effect.gen(function* () { + const result = yield* Effect.result( + decodeClientOrchestrationCommand({ + type: "thread.materialization.set", + commandId: "cmd-forged-materialization", + threadId: "thread-1", + materialization: { + requestedProfileId: "governance-review", + effectiveProfileId: "governance-review", + mode: "sparse", + reason: null, + expectedContractSha256: "a".repeat(64), + contractSha256: "a".repeat(64), + manifestSha256: "b".repeat(64), + conePaths: ["docs"], + requiredPaths: ["docs/spec.md"], + taskId: "OC-1", + taskSlug: "task", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), +); + it.effect("decodes a dispatch error after its bootstrap thread was deleted", () => Effect.gen(function* () { const error = yield* decodeDispatchCommandError({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17cadc6d1d7f..e4b5250dd2ac 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -23,6 +23,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { VcsWorktreeMaterializationRequest, VcsWorktreeMaterializationState } from "./git.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -493,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + materialization: Schema.optional(VcsWorktreeMaterializationState), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, @@ -571,6 +573,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + materialization: Schema.optional(VcsWorktreeMaterializationState), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, @@ -943,6 +946,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ baseBranch: TrimmedNonEmptyString, branch: Schema.optional(TrimmedNonEmptyString), startFromOrigin: Schema.optional(Schema.Boolean), + materialization: Schema.optional(VcsWorktreeMaterializationRequest), }); const ThreadTurnStartBootstrap = Schema.Struct({ @@ -1168,6 +1172,14 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +const ThreadMaterializationSetCommand = Schema.Struct({ + type: Schema.Literal("thread.materialization.set"), + commandId: CommandId, + threadId: ThreadId, + materialization: VcsWorktreeMaterializationState, + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadAutoSettleCommand, ThreadSessionSetCommand, @@ -1178,6 +1190,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, + ThreadMaterializationSetCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1203,6 +1216,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unpinned", "thread.pin-reordered", "thread.meta-updated", + "thread.materialization-set", "thread.runtime-mode-set", "thread.interaction-mode-set", "thread.message-sent", @@ -1272,6 +1286,12 @@ export const ThreadCreatedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadMaterializationSetPayload = Schema.Struct({ + threadId: ThreadId, + materialization: VcsWorktreeMaterializationState, + updatedAt: IsoDateTime, +}); + export const ThreadDeletedPayload = Schema.Struct({ threadId: ThreadId, deletedAt: IsoDateTime, @@ -1510,6 +1530,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.created"), payload: ThreadCreatedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.materialization-set"), + payload: ThreadMaterializationSetPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.deleted"), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c0ef8cd56d6d..ab1a1f7cbdf4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -46,6 +46,8 @@ import { VcsCreateRefResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, + VcsExpandWorktreeMaterializationInput, + VcsExpandWorktreeMaterializationResult, VcsInitInput, VcsListRefsInput, VcsListRefsResult, @@ -262,6 +264,7 @@ export const WS_METHODS = { vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", vcsCreateWorktree: "vcs.createWorktree", + vcsExpandWorktreeMaterialization: "vcs.expandWorktreeMaterialization", vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", @@ -885,6 +888,19 @@ export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsExpandWorktreeMaterializationRpc = Rpc.make( + WS_METHODS.vcsExpandWorktreeMaterialization, + { + payload: VcsExpandWorktreeMaterializationInput, + success: VcsExpandWorktreeMaterializationResult, + error: Schema.Union([ + GitCommandError, + OrchestrationDispatchCommandError, + EnvironmentAuthorizationError, + ]), + }, +); + export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { payload: VcsRemoveWorktreeInput, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), @@ -1241,6 +1257,7 @@ export const WsRpcGroup = RpcGroup.make( WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, WsVcsCreateWorktreeRpc, + WsVcsExpandWorktreeMaterializationRpc, WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, WsVcsSwitchRefRpc, From 0ccd1e7feb6470d01d355e7664818c50d666c9af Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 07:40:20 +0100 Subject: [PATCH 02/31] fix(vcs): make sparse fallback fail safe --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 163 +++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 231 ++++++++++++++----- 2 files changed, 341 insertions(+), 53 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 6f501665047a..f521be5c6851 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1559,6 +1559,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("preserves remote-base tracking while pinning the created branch commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-worktree-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", initialBranch]); + yield* git(cwd, ["fetch", "origin"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "remote-tracking", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: `origin/${initialBranch}`, + newRefName: "feature/remote-tracking", + baseRefName: initialBranch, + }); + + assert.equal( + yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `origin/${initialBranch}`, + ); + }), + ); + + it.effect("disables inherited sparse configuration for an ordinary full worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "core.sparseCheckout", "true"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "inherited-sparse-full", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/inherited-sparse-full", + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal( + yield* git(worktreePath, ["config", "--bool", "core.sparseCheckout"]), + "false", + ); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + it.effect("falls back to full before release when sparse required paths are absent", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1816,6 +1875,12 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-GENERATED"}}\n'); const pathService = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; + const inheritedExcludePath = pathService.join( + yield* makeTmpDir("git-global-excludes-"), + "global-excludes", + ); + yield* fileSystem.writeFileString(inheritedExcludePath, "*.local-only\n"); + yield* git(cwd, ["config", "core.excludesFile", inheritedExcludePath]); const worktreePath = pathService.join( yield* makeTmpDir("git-worktrees-"), "generated-task-card", @@ -1840,6 +1905,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.match(created.materialization?.taskCardSha256 ?? "", /^[a-f0-9]{64}$/); assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, taskCardPath)), true); assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); + assert.equal( + yield* git(cwd, ["config", "--get", "core.excludesFile"]), + inheritedExcludePath, + ); + assert.equal(yield* git(cwd, ["config", "--get", "extensions.worktreeConfig"]), "true"); + const worktreeExcludePath = yield* git(worktreePath, [ + "config", + "--worktree", + "--get", + "core.excludesFile", + ]); + assert.notEqual(worktreeExcludePath, inheritedExcludePath); + assert.equal( + yield* fileSystem.readFileString(worktreeExcludePath), + `*.local-only\n${taskCardPath}\n`, + ); yield* fileSystem.writeFileString( pathService.join(worktreePath, taskCardPath), '{"tampered":true}\n', @@ -1858,6 +1939,54 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("completes full fallback when generated task-card bytes change during checkout", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const taskCardPath = "ops/stef-task/generated-race/stef-task.json"; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const sourcePath = pathService.join(cwd, taskCardPath); + yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-RACE"}}\n'); + const gitDir = yield* git(cwd, ["rev-parse", "--git-dir"]); + const hookPath = pathService.join(cwd, gitDir, "hooks", "post-checkout"); + yield* fileSystem.writeFileString( + hookPath, + `#!/bin/sh\nprintf '%s\\n' '{"issue":{"id":"OC-CHANGED"}}' > '${sourcePath}'\n`, + ); + yield* fileSystem.chmod(hookPath, 0o755); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-task-card-race", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-task-card-race", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-RACE", + taskSlug: "generated-race", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "task-card-materialization-failed"); + assert.equal(created.materialization?.taskCardSha256, null); + assert.equal(created.materialization?.requiredPaths.includes(taskCardPath), false); + assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, taskCardPath)), false); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + it.effect("falls back to full when tracked task-card working bytes differ from the base", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1921,6 +2050,40 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("verifies committed materialization bytes with checkout EOL conversion", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(cwd, ["config", "core.autocrlf", "true"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "autocrlf-materialization", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/autocrlf-materialization", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-EOL", + taskSlug: "autocrlf", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "sparse"); + }), + ); + it.effect("forces the schema-valid UI sentinel to full", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4fce2097b773..ca675e96d54c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1195,20 +1195,39 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...(cause === undefined ? {} : { cause }), }); + const readMaterializationBlobAtCommit = Effect.fn("readMaterializationBlobAtCommit")(function* ( + repoRoot: string, + pinnedCommit: string, + relativePath: string, + operation: string, + ) { + const shown = yield* Effect.exit( + executeGit(operation, repoRoot, ["show", `${pinnedCommit}:${relativePath}`]), + ); + if ( + Exit.isFailure(shown) || + shown.value.stdoutTruncated || + shown.value.stdoutInvalidUtf8 === true + ) { + return null; + } + return new TextEncoder().encode(shown.value.stdout); + }); + const readMaterializationContract = Effect.fn("readMaterializationContract")(function* ( repoRoot: string, pinnedCommit?: string, ) { let raw: Uint8Array; if (pinnedCommit) { - const shown = yield* Effect.exit( - executeGit("GitVcsDriver.materialization.readContractAtBase", repoRoot, [ - "show", - `${pinnedCommit}:${WORKTREE_MATERIALIZATION_CONTRACT_PATH}`, - ]), + const shown = yield* readMaterializationBlobAtCommit( + repoRoot, + pinnedCommit, + WORKTREE_MATERIALIZATION_CONTRACT_PATH, + "GitVcsDriver.materialization.readContractAtBase", ); - if (Exit.isFailure(shown) || shown.value.stdoutTruncated) return null; - raw = new TextEncoder().encode(shown.value.stdout); + if (!shown) return null; + raw = shown; } else { const file = yield* Effect.exit( fileSystem.readFile(path.join(repoRoot, WORKTREE_MATERIALIZATION_CONTRACT_PATH)), @@ -1268,15 +1287,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (Exit.isSuccess(source)) sourceTaskCardBytes = source.value; } if (taskCardAtBase.exitCode === 0) { - const shown = yield* Effect.exit( - executeGit("GitVcsDriver.materialization.taskCardBytesAtBase", repoRoot, [ - "show", - `${pinnedCommit}:${taskCardPath}`, - ]), + taskCardBytes = yield* readMaterializationBlobAtCommit( + repoRoot, + pinnedCommit, + taskCardPath, + "GitVcsDriver.materialization.taskCardBytesAtBase", ); - if (Exit.isSuccess(shown) && !shown.value.stdoutTruncated) { - taskCardBytes = new TextEncoder().encode(shown.value.stdout); - } if ( taskCardBytes && sourceTaskCardBytes && @@ -1357,7 +1373,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* )).trim(); const gitDir = path.isAbsolute(gitDirRaw) ? gitDirRaw : path.resolve(worktreePath, gitDirRaw); const excludePath = path.join(gitDir, "worktree-materialization-excludes"); - yield* fileSystem.writeFileString(excludePath, `${state.taskCardPath}\n`); + const inheritedExclude = yield* executeGit( + "GitVcsDriver.materialization.inheritedTaskCardExclude", + worktreePath, + ["config", "--path", "--get", "core.excludesFile"], + { allowNonZeroExit: true }, + ); + const inheritedExcludePath = + inheritedExclude.exitCode === 0 ? inheritedExclude.stdout.trim() : ""; + const inheritedExcludeBytes = + inheritedExcludePath && path.resolve(inheritedExcludePath) !== path.resolve(excludePath) + ? yield* Effect.exit(fileSystem.readFileString(inheritedExcludePath)) + : null; + const inheritedPatterns = + inheritedExcludeBytes && Exit.isSuccess(inheritedExcludeBytes) + ? inheritedExcludeBytes.value + : ""; + yield* fileSystem.writeFileString( + excludePath, + `${inheritedPatterns}${inheritedPatterns && !inheritedPatterns.endsWith("\n") ? "\n" : ""}${state.taskCardPath}\n`, + ); + yield* runGit("GitVcsDriver.materialization.enableWorktreeConfig", repoRoot, [ + "config", + "extensions.worktreeConfig", + "true", + ]); yield* runGit("GitVcsDriver.materialization.taskCardExclude", worktreePath, [ "config", "--worktree", @@ -1367,7 +1407,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const target = yield* Effect.exit(fileSystem.readFile(targetPath)); if (Exit.isSuccess(target)) { - if (worktreeMaterializationSha256(target.value) !== state.taskCardSha256) { + const identityMatches = state.taskCardGenerated + ? worktreeMaterializationSha256(target.value) === state.taskCardSha256 + : yield* taskCardIdentityMatches(worktreePath, state); + if (!identityMatches) { return yield* materializationError( "GitVcsDriver.materialization.taskCard", worktreePath, @@ -1406,8 +1449,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); - yield* fileSystem.writeFile(targetPath, source); yield* configureIgnore(); + yield* fileSystem.writeFile(targetPath, source); }); const materializationStatePath = Effect.fn("materializationStatePath")(function* (cwd: string) { @@ -1518,6 +1561,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* state: VcsWorktreeMaterializationState, ) { if (!state.taskCardPath || !state.taskCardSha256) return true; + if (!state.taskCardGenerated && state.baseSha) { + const repoRoot = (yield* runGitStdout("GitVcsDriver.materialization.taskCardRepoRoot", cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + const baseBytes = yield* readMaterializationBlobAtCommit( + repoRoot, + state.baseSha, + state.taskCardPath, + "GitVcsDriver.materialization.verifyTaskCardAtBase", + ); + if (!baseBytes || worktreeMaterializationSha256(baseBytes) !== state.taskCardSha256) { + return false; + } + const diff = yield* executeGit( + "GitVcsDriver.materialization.verifyTaskCardWorktree", + cwd, + ["diff", "--quiet", state.baseSha, "--", state.taskCardPath], + { allowNonZeroExit: true }, + ); + return diff.exitCode === 0; + } const loaded = yield* Effect.exit(fileSystem.readFile(path.join(cwd, state.taskCardPath))); return ( Exit.isSuccess(loaded) && worktreeMaterializationSha256(loaded.value) === state.taskCardSha256 @@ -1613,8 +1678,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "rev-parse", "--show-toplevel", ])).trim(); - const contract = yield* readMaterializationContract(repoRoot); - if (!contract || contract.sha256 !== persisted.contractSha256) { + const contract = persisted.baseSha + ? yield* readMaterializationContract(repoRoot, persisted.baseSha) + : null; + const contractDiff = persisted.baseSha + ? yield* executeGit( + "GitVcsDriver.materialization.verifyContractWorktree", + cwd, + ["diff", "--quiet", persisted.baseSha, "--", WORKTREE_MATERIALIZATION_CONTRACT_PATH], + { allowNonZeroExit: true }, + ) + : null; + if (!contract || contract.sha256 !== persisted.contractSha256 || contractDiff?.exitCode !== 0) { return yield* materializationError( "GitVcsDriver.verifyWorktreeMaterialization", cwd, @@ -1651,15 +1726,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "Sparse index is forbidden for materialized worktrees.", ); } - const observedCone = uniqueMaterializationPaths( - (yield* runGitStdout("GitVcsDriver.materialization.sparseList", cwd, [ - "sparse-checkout", - "list", - ])) - .split(/\r?\n/) - .map(normalizeMaterializationRepoPath) - .filter((candidate): candidate is string => candidate !== null), - ); + const observedConePaths = (yield* runGitStdout("GitVcsDriver.materialization.sparseList", cwd, [ + "sparse-checkout", + "list", + ])) + .split(/\r?\n/) + .filter((candidate) => candidate.length > 0) + .map(normalizeMaterializationRepoPath); + if (observedConePaths.some((candidate) => candidate === null)) { + return yield* materializationError( + "GitVcsDriver.verifyWorktreeMaterialization", + cwd, + "Git sparse cone contains an invalid path.", + ); + } + const observedCone = uniqueMaterializationPaths(observedConePaths as Array); // @effect-diagnostics-next-line preferSchemaOverJson:off if (JSON.stringify(observedCone) !== JSON.stringify(conePaths)) { return yield* materializationError( @@ -3675,6 +3756,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* timeoutMs: WORKTREE_ADD_TIMEOUT_MS, }); + if (input.newRefName) { + const remoteStart = yield* executeGit( + "GitVcsDriver.createWorktree.remoteStart", + repoRoot, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { allowNonZeroExit: true }, + ); + if (remoteStart.exitCode === 0) { + yield* runGit("GitVcsDriver.createWorktree.preserveUpstream", repoRoot, [ + "branch", + "--set-upstream-to", + input.refName, + input.newRefName, + ]); + } + } + let materialization: VcsWorktreeMaterializationState = requestedMaterialization; if (requestedMaterialization.mode === "sparse") { let fallbackReason: string | null = null; @@ -3768,27 +3866,43 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `${fallbackReason}; full fallback checkout failed. The never-released worktree was preserved for diagnosis.`, ); } - const fullTaskCard = yield* Effect.exit( - ensureMaterializedTaskCard(repoRoot, worktreePath, requestedMaterialization), - ); - if (Exit.isFailure(fullTaskCard)) { - const failedState: VcsWorktreeMaterializationState = { - ...requestedMaterialization, - status: "failed", - effectiveProfileId: "full", - mode: "full", - reason: `${fallbackReason}:full-fallback-task-card-failed`, - }; - yield* writeMaterializationState(worktreePath, failedState); - return yield* materializationError( - "GitVcsDriver.createWorktree", - worktreePath, - `${fallbackReason}; full fallback could not materialize the exact task card. The never-released worktree was preserved for diagnosis.`, + const dropTaskCardIdentity = fallbackReason === "task-card-materialization-failed"; + const fullMaterialization: VcsWorktreeMaterializationState = { + ...requestedMaterialization, + effectiveProfileId: "full", + mode: "full", + reason: fallbackReason, + ...(dropTaskCardIdentity + ? { + taskCardSha256: null, + taskCardGenerated: false, + requiredPaths: requestedMaterialization.requiredPaths.filter( + (candidate) => candidate !== requestedMaterialization.taskCardPath, + ), + } + : {}), + }; + if (!dropTaskCardIdentity) { + const fullTaskCard = yield* Effect.exit( + ensureMaterializedTaskCard(repoRoot, worktreePath, fullMaterialization), ); + if (Exit.isFailure(fullTaskCard)) { + const failedState: VcsWorktreeMaterializationState = { + ...fullMaterialization, + status: "failed", + reason: `${fallbackReason}:full-fallback-task-card-failed`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback could not materialize the exact task card. The never-released worktree was preserved for diagnosis.`, + ); + } } const missing = yield* requiredMaterializationPathsMissing( worktreePath, - requestedMaterialization.requiredPaths, + fullMaterialization.requiredPaths, ); if (missing.length > 0) { const failedState: VcsWorktreeMaterializationState = { @@ -3805,14 +3919,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `${fallbackReason}; full fallback is missing required path(s): ${missing.join(", ")}. The never-released worktree was preserved for diagnosis.`, ); } - materialization = { - ...requestedMaterialization, - effectiveProfileId: "full", - mode: "full", - reason: fallbackReason, - }; + materialization = fullMaterialization; } } else { + if (yield* sparseCheckoutEnabled(worktreePath)) { + yield* runGit( + "GitVcsDriver.createWorktree.disableInheritedSparse", + worktreePath, + ["sparse-checkout", "disable"], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ); + } const fullCheckout = yield* Effect.exit( runGit( "GitVcsDriver.createWorktree.checkoutFull", @@ -3889,7 +4006,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } yield* writeMaterializationState(worktreePath, materialization); - materialization = yield* verifyWorktreeMaterialization(worktreePath); + materialization = yield* verifyWorktreeMaterialization(worktreePath).pipe( + Effect.tapError(() => + writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "post-create-verification-failed", + }), + ), + ); // `git worktree add` leaves submodules empty, so a repo that keeps agent // skills, tooling or source in one gets a worktree that is quietly missing From e087ede7341a640b29686253f81090eb500f59de Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 07:42:59 +0100 Subject: [PATCH 03/31] fix(vcs): preserve raw materialization hashes --- apps/server/src/vcs/GitVcsDriver.ts | 2 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 32 ++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 7c22189378a3..dddb25e0ba86 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -58,6 +58,8 @@ export interface ExecuteGitResult { readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; + readonly stdoutInvalidUtf8?: boolean; + readonly stderrInvalidUtf8?: boolean; } export interface GitStatusDetails { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ca675e96d54c..dcb481d741af 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -872,18 +872,36 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); +function decodeUtf8ChunkIsValid( + decoder: TextDecoder, + chunk?: Uint8Array, + options?: { readonly stream?: boolean }, +): boolean { + try { + decoder.decode(chunk, options); + return true; + } catch { + return false; + } +} + const collectOutput = Effect.fnUntraced(function* ( input: Pick, stream: Stream.Stream, maxOutputBytes: number, appendTruncationMarker: boolean, onLine: ((line: string) => Effect.Effect) | undefined, -): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { +): Effect.fn.Return< + { readonly text: string; readonly truncated: boolean; readonly invalidUtf8: boolean }, + GitCommandError +> { const decoder = new TextDecoder(); + const utf8Validator = new TextDecoder("utf-8", { fatal: true }); let bytes = 0; let text = ""; let lineBuffer = ""; let truncated = false; + let invalidUtf8 = false; const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { let newlineIndex = lineBuffer.indexOf("\n"); @@ -926,6 +944,12 @@ const collectOutput = Effect.fnUntraced(function* ( truncated = appendTruncationMarker && nextBytes > maxOutputBytes; const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); + if ( + !invalidUtf8 && + !decodeUtf8ChunkIsValid(utf8Validator, chunkToDecode, { stream: !truncated }) + ) { + invalidUtf8 = true; + } text += decoded; lineBuffer += decoded; yield* emitCompleteLines(false); @@ -943,12 +967,16 @@ const collectOutput = Effect.fnUntraced(function* ( ); const remainder = truncated ? "" : decoder.decode(); + if (!truncated && !invalidUtf8 && !decodeUtf8ChunkIsValid(utf8Validator)) { + invalidUtf8 = true; + } text += remainder; lineBuffer += remainder; yield* emitCompleteLines(true); return { text, truncated, + invalidUtf8, }; }); @@ -1063,6 +1091,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stderr: stderr.text, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, + stdoutInvalidUtf8: stdout.invalidUtf8, + stderrInvalidUtf8: stderr.invalidUtf8, } satisfies GitVcsDriver.ExecuteGitResult; }); From a6fdf63010d0826865251997793c2621c0de6bc9 Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 07:55:23 +0100 Subject: [PATCH 04/31] fix(vcs): preserve inherited worktree ignores --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 181 ++++++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 116 ++++++++---- 2 files changed, 260 insertions(+), 37 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f521be5c6851..beece3ae9e88 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -140,6 +140,7 @@ const writeMaterializationFixture = Effect.fn("writeMaterializationFixture")(fun readonly requiredMissingEverywhere?: boolean; readonly invalidSharedPath?: boolean; readonly invalidSharedValue?: unknown; + readonly omitTaskCardCone?: boolean; } = {}, ) { const contract = { @@ -154,7 +155,9 @@ const writeMaterializationFixture = Effect.fn("writeMaterializationFixture")(fun ? [options.invalidSharedValue] : options.invalidSharedPath ? ["/absolute-cone"] - : ["config", "ops/stef-task", "ops/build-state"], + : options.omitTaskCardCone + ? ["config", "ops/build-state"] + : ["config", "ops/stef-task", "ops/build-state"], sharedRequiredPaths: ["config/worktree-materialization-profiles.json"], unsupportedTaskClasses: ["unclassified", "multi-domain", "live-runtime"], profiles: [ @@ -1987,6 +1990,118 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("preserves inherited ignores when full fallback revisits a generated task card", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + requiredOutsideCone: true, + }); + const taskCardPath = "ops/stef-task/generated-fallback/stef-task.json"; + yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-FALLBACK"}}\n'); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const inheritedExcludePath = pathService.join( + yield* makeTmpDir("git-global-excludes-"), + "global-excludes", + ); + yield* fileSystem.writeFileString(inheritedExcludePath, "*.local-only\n"); + yield* git(cwd, ["config", "core.excludesFile", inheritedExcludePath]); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-task-card-fallback", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-task-card-fallback", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-FALLBACK", + taskSlug: "generated-fallback", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "required-paths-missing"); + const worktreeExcludePath = yield* git(worktreePath, [ + "config", + "--worktree", + "--get", + "core.excludesFile", + ]); + assert.equal( + yield* fileSystem.readFileString(worktreeExcludePath), + `*.local-only\n${taskCardPath}\n`, + ); + }), + ); + + it.effect("preserves the default XDG Git ignore when adding a generated task card", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const taskCardPath = "ops/stef-task/generated-xdg/stef-task.json"; + yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-XDG"}}\n'); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const xdgRoot = yield* makeTmpDir("git-xdg-config-"); + const defaultExcludePath = pathService.join(xdgRoot, "git", "ignore"); + yield* fileSystem.makeDirectory(pathService.dirname(defaultExcludePath), { + recursive: true, + }); + yield* fileSystem.writeFileString(defaultExcludePath, "*.xdg-only\n"); + const previousXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdgRoot; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + }), + ); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-task-card-xdg", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-task-card-xdg", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-XDG", + taskSlug: "generated-xdg", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + const worktreeExcludePath = yield* git(worktreePath, [ + "config", + "--worktree", + "--get", + "core.excludesFile", + ]); + assert.equal( + yield* fileSystem.readFileString(worktreeExcludePath), + `*.xdg-only\n${taskCardPath}\n`, + ); + }), + ); + it.effect("falls back to full when tracked task-card working bytes differ from the base", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2081,6 +2196,70 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.materialization?.effectiveProfileId, "governance-review"); assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "sparse"); + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "autocrlf-expand", + ); + assert.equal(expanded.taskCardSha256, created.materialization?.taskCardSha256); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + + it.effect("drops unsafe task-card paths from ordinary full state", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "unsafe-full-card"), + refName: initialBranch, + newRefName: "feature/unsafe-full-card", + materialization: { + requestedProfileId: "full", + expectedContractSha256: "a".repeat(64), + taskId: "OC-FULL", + taskSlug: "unsafe-full", + taskCardPath: "../../outside.json", + scopePaths: ["../../outside.ts"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.taskCardPath, null); + assert.deepStrictEqual(created.materialization?.scopePaths, []); + }), + ); + + it.effect("falls back to full when the contract does not cone-cover task cards", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + omitTaskCardCone: true, + }); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "uncovered-card-root"), + refName: initialBranch, + newRefName: "feature/uncovered-card-root", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-CONE", + taskSlug: "uncovered-card-root", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "contract-unavailable"); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index dcb481d741af..ee779bbe63db 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,5 +1,6 @@ import * as Arr from "effect/Array"; import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; import * as Crypto from "effect/Crypto"; @@ -184,6 +185,8 @@ function fullMaterializationState( reason: string, contractSha256: string | null = null, ): VcsWorktreeMaterializationState { + const taskCardPath = normalizeMaterializationRepoPath(request?.taskCardPath); + const normalizedScopePaths = request?.scopePaths.map(normalizeMaterializationRepoPath) ?? []; return { ...FULL_WORKTREE_MATERIALIZATION_STATE, status: "ready", @@ -193,8 +196,10 @@ function fullMaterializationState( contractSha256, taskId: validMaterializationSegment(request?.taskId), taskSlug: validMaterializationSegment(request?.taskSlug), - taskCardPath: request?.taskCardPath ?? null, - scopePaths: request?.scopePaths ? [...request.scopePaths] : [], + taskCardPath, + scopePaths: normalizedScopePaths.every((candidate) => candidate !== null) + ? (normalizedScopePaths as Array) + : [], taskClasses: request?.taskClasses ? [...request.taskClasses] : [], includeResearchTask: request?.includeResearchTask === true, }; @@ -216,6 +221,11 @@ function parseWorktreeMaterializationContract(raw: Uint8Array): WorktreeMaterial } const contract = parsed as WorktreeMaterializationContract; const profileIds = contract.profiles.map((profile) => profile.id); + const taskCardRootCovered = contract.sharedConePaths.some( + (conePath) => + contract.taskContext.taskCardRoot === conePath || + contract.taskContext.taskCardRoot.startsWith(`${conePath}/`), + ); const declaredPaths = [ contract.taskContext.taskCardRoot, contract.taskContext.buildStateRoot, @@ -229,6 +239,7 @@ function parseWorktreeMaterializationContract(raw: Uint8Array): WorktreeMaterial JSON.stringify(["full", "governance-review", "brandt-source", "trading-strategy-source"]) || contract.profiles[0]?.id !== "full" || contract.profiles[0]?.mode !== "full" || + !taskCardRootCovered || contract.profiles.some( (profile) => !validMaterializationSegment(profile.id) || @@ -1393,7 +1404,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* state: VcsWorktreeMaterializationState, ) { if (!state.taskCardPath || !state.taskCardSha256) return; - const targetPath = path.join(worktreePath, state.taskCardPath); + const taskCardPath = state.taskCardPath; + const targetPath = path.join(worktreePath, taskCardPath); const configureIgnore = Effect.fn("configureGeneratedTaskCardIgnore")(function* () { if (!state.taskCardGenerated) return; const gitDirRaw = (yield* runGitStdout( @@ -1409,25 +1421,65 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["config", "--path", "--get", "core.excludesFile"], { allowNonZeroExit: true }, ); - const inheritedExcludePath = + const configuredExcludePath = inheritedExclude.exitCode === 0 ? inheritedExclude.stdout.trim() : ""; - const inheritedExcludeBytes = - inheritedExcludePath && path.resolve(inheritedExcludePath) !== path.resolve(excludePath) - ? yield* Effect.exit(fileSystem.readFileString(inheritedExcludePath)) - : null; + const defaultExcludePath = path.join( + process.env.XDG_CONFIG_HOME || path.join(NodeOS.homedir(), ".config"), + "git", + "ignore", + ); + const inheritedExcludePath = configuredExcludePath || defaultExcludePath; + const inheritedExcludeBytes = inheritedExcludePath + ? yield* Effect.exit(fileSystem.readFileString(inheritedExcludePath)) + : null; const inheritedPatterns = inheritedExcludeBytes && Exit.isSuccess(inheritedExcludeBytes) ? inheritedExcludeBytes.value : ""; - yield* fileSystem.writeFileString( - excludePath, - `${inheritedPatterns}${inheritedPatterns && !inheritedPatterns.endsWith("\n") ? "\n" : ""}${state.taskCardPath}\n`, + const inheritedLines = inheritedPatterns.split(/\r?\n/); + const nextPatterns = inheritedLines.includes(taskCardPath) + ? inheritedPatterns + : `${inheritedPatterns}${inheritedPatterns && !inheritedPatterns.endsWith("\n") ? "\n" : ""}${taskCardPath}\n`; + yield* fileSystem.writeFileString(excludePath, nextPatterns); + const worktreeConfig = yield* executeGit( + "GitVcsDriver.materialization.worktreeConfigEnabled", + repoRoot, + ["config", "--bool", "--get", "extensions.worktreeConfig"], + { allowNonZeroExit: true }, ); - yield* runGit("GitVcsDriver.materialization.enableWorktreeConfig", repoRoot, [ - "config", - "extensions.worktreeConfig", - "true", - ]); + if (worktreeConfig.exitCode !== 0 || worktreeConfig.stdout.trim() !== "true") { + const unsafeSharedKeys = yield* Effect.forEach( + ["core.worktree", "core.sparseCheckout"], + (key) => + executeGit( + "GitVcsDriver.materialization.worktreeConfigPreflight", + repoRoot, + ["config", "--local", "--get", key], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + ); + const sharedBare = yield* executeGit( + "GitVcsDriver.materialization.worktreeConfigBarePreflight", + repoRoot, + ["config", "--local", "--bool", "--get", "core.bare"], + { allowNonZeroExit: true }, + ); + if ( + unsafeSharedKeys.includes(true) || + (sharedBare.exitCode === 0 && sharedBare.stdout.trim() === "true") + ) { + return yield* materializationError( + "GitVcsDriver.materialization.enableWorktreeConfig", + repoRoot, + "Cannot safely enable per-worktree configuration while shared worktree-only core settings are present.", + ); + } + yield* runGit("GitVcsDriver.materialization.enableWorktreeConfig", repoRoot, [ + "config", + "extensions.worktreeConfig", + "true", + ]); + } yield* runGit("GitVcsDriver.materialization.taskCardExclude", worktreePath, [ "config", "--worktree", @@ -1863,7 +1915,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* mode: "full", reason: reason.trim() || "expand-full", taskCardSha256: - currentTaskCard && Exit.isSuccess(currentTaskCard) + baseState.taskCardGenerated && currentTaskCard && Exit.isSuccess(currentTaskCard) ? worktreeMaterializationSha256(currentTaskCard.value) : (baseState.taskCardSha256 ?? null), baseSha: baseState.baseSha ?? baseSha, @@ -3896,38 +3948,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `${fallbackReason}; full fallback checkout failed. The never-released worktree was preserved for diagnosis.`, ); } + const withoutTaskCardIdentity = (state: VcsWorktreeMaterializationState) => ({ + taskCardSha256: null, + taskCardGenerated: false, + requiredPaths: state.requiredPaths.filter( + (candidate) => candidate !== state.taskCardPath, + ), + }); const dropTaskCardIdentity = fallbackReason === "task-card-materialization-failed"; - const fullMaterialization: VcsWorktreeMaterializationState = { + let fullMaterialization: VcsWorktreeMaterializationState = { ...requestedMaterialization, effectiveProfileId: "full", mode: "full", reason: fallbackReason, - ...(dropTaskCardIdentity - ? { - taskCardSha256: null, - taskCardGenerated: false, - requiredPaths: requestedMaterialization.requiredPaths.filter( - (candidate) => candidate !== requestedMaterialization.taskCardPath, - ), - } - : {}), + ...(dropTaskCardIdentity ? withoutTaskCardIdentity(requestedMaterialization) : {}), }; if (!dropTaskCardIdentity) { const fullTaskCard = yield* Effect.exit( ensureMaterializedTaskCard(repoRoot, worktreePath, fullMaterialization), ); if (Exit.isFailure(fullTaskCard)) { - const failedState: VcsWorktreeMaterializationState = { + fullMaterialization = { ...fullMaterialization, - status: "failed", - reason: `${fallbackReason}:full-fallback-task-card-failed`, + ...withoutTaskCardIdentity(fullMaterialization), }; - yield* writeMaterializationState(worktreePath, failedState); - return yield* materializationError( - "GitVcsDriver.createWorktree", - worktreePath, - `${fallbackReason}; full fallback could not materialize the exact task card. The never-released worktree was preserved for diagnosis.`, - ); } } const missing = yield* requiredMaterializationPathsMissing( From 72efa0b935f64348a7029770bb363e4683d7d853 Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 10:20:21 +0100 Subject: [PATCH 05/31] fix(vcs): keep task-card identity immutable --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 59 +++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 112 +++++++++++-------- 2 files changed, 123 insertions(+), 48 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index beece3ae9e88..39bb23edd12b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1875,7 +1875,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const { initialBranch } = yield* initRepoWithCommit(cwd); const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); const taskCardPath = "ops/stef-task/generated/stef-task.json"; - yield* writeTextFile(cwd, taskCardPath, '{"issue":{"id":"OC-GENERATED"}}\n'); + const taskCardBytes = '{"issue":{"id":"OC-GENERATED"}}\n'; + yield* writeTextFile(cwd, taskCardPath, taskCardBytes); const pathService = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; const inheritedExcludePath = pathService.join( @@ -1933,9 +1934,21 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "Failure", ); assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); + const tamperedExpansion = yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "must-not-rebind-generated-card"), + ); + assert.equal(tamperedExpansion._tag, "Failure"); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "excluded/large.txt")), + false, + ); + yield* fileSystem.writeFileString( + pathService.join(worktreePath, taskCardPath), + taskCardBytes, + ); const expanded = yield* driver.expandWorktreeMaterializationFull( worktreePath, - "rebind-generated-card", + "restored-generated-card", ); assert.equal(expanded.effectiveProfileId, "full"); assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); @@ -2233,6 +2246,48 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("carries a valid generated task card through explicit full materialization", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const taskCardPath = "ops/stef-task/generated-full/stef-task.json"; + const taskCardBytes = '{"issue":{"id":"OC-FULL-CARD"}}\n'; + yield* writeTextFile(cwd, taskCardPath, taskCardBytes); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-full-card", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-full-card", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-FULL-CARD", + taskSlug: "generated-full", + taskCardPath, + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.taskCardGenerated, true); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, taskCardPath)), + taskCardBytes, + ); + assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); + }), + ); + it.effect("falls back to full when the contract does not cone-cover task cards", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ee779bbe63db..af301671f2a7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -82,6 +82,7 @@ const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); const WORKTREE_MATERIALIZATION_CONTRACT_PATH = "config/worktree-materialization-profiles.json"; const WORKTREE_MATERIALIZATION_CONTRACT_SCHEMA = "clawd.worktree-materialization-profiles.v1"; +const WORKTREE_MATERIALIZATION_TASK_CARD_ROOT = "ops/stef-task"; const SUPPORTED_SPARSE_TASK_CLASSES = new Set(["source-task", "task-evidence"]); const WORKTREE_MATERIALIZATION_STATE_SCHEMA = "clawd.worktree-materialization-state.v1"; const WORKTREE_MATERIALIZATION_STATE_FILE = "worktree-materialization.json"; @@ -1299,18 +1300,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* pinnedCommit: string, state: VcsWorktreeMaterializationState, ) { - if (state.mode !== "sparse") return { ...state, baseSha: pinnedCommit }; const taskCardPath = state.taskCardPath; - if (!taskCardPath) { + const fullWithoutTaskCard = (reason: string | null) => ({ + ...state, + effectiveProfileId: "full", + mode: "full" as const, + reason, + manifestSha256: null, + conePaths: [], + requiredPaths: [], + taskCardPath: null, + taskCardSha256: null, + taskCardGenerated: false, + baseSha: pinnedCommit, + }); + if ( + !taskCardPath || + (taskCardPath !== WORKTREE_MATERIALIZATION_TASK_CARD_ROOT && + !taskCardPath.startsWith(`${WORKTREE_MATERIALIZATION_TASK_CARD_ROOT}/`)) + ) { + if (state.mode !== "sparse") return fullWithoutTaskCard(state.reason); return { - ...state, - effectiveProfileId: "full", - mode: "full" as const, - reason: "task-card-missing-at-base", - manifestSha256: null, - conePaths: [], - requiredPaths: [], - baseSha: pinnedCommit, + ...fullWithoutTaskCard("task-card-missing-at-base"), + requestedProfileId: state.requestedProfileId, }; } const taskCardAtBase = yield* executeGit( @@ -1340,6 +1352,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* worktreeMaterializationSha256(taskCardBytes) !== worktreeMaterializationSha256(sourceTaskCardBytes) ) { + const requiredPaths = uniqueMaterializationPaths([taskCardPath]); return { ...state, effectiveProfileId: "full", @@ -1347,7 +1360,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* reason: "task-card-source-mismatch", manifestSha256: null, conePaths: [], - requiredPaths: [], + requiredPaths, + taskCardSha256: worktreeMaterializationSha256(taskCardBytes), + taskCardGenerated: false, baseSha: pinnedCommit, }; } @@ -1355,26 +1370,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* taskCardBytes = sourceTaskCardBytes; } if (!taskCardBytes) { - return { - ...state, - effectiveProfileId: "full", - mode: "full" as const, - reason: "task-card-missing-at-base", - manifestSha256: null, - conePaths: [], - requiredPaths: [], - baseSha: pinnedCommit, - }; + return fullWithoutTaskCard( + state.mode === "sparse" ? "task-card-missing-at-base" : state.reason, + ); } const presentDynamicPaths: Array = []; - for (const candidate of state.declaredDynamicPaths ?? []) { - const result = yield* executeGit( - "GitVcsDriver.materialization.pathAtBase", - repoRoot, - ["cat-file", "-e", `${pinnedCommit}:${candidate}`], - { allowNonZeroExit: true }, - ); - if (result.exitCode === 0) presentDynamicPaths.push(candidate); + if (state.mode === "sparse") { + for (const candidate of state.declaredDynamicPaths ?? []) { + const result = yield* executeGit( + "GitVcsDriver.materialization.pathAtBase", + repoRoot, + ["cat-file", "-e", `${pinnedCommit}:${candidate}`], + { allowNonZeroExit: true }, + ); + if (result.exitCode === 0) presentDynamicPaths.push(candidate); + } } const requiredPaths = uniqueMaterializationPaths([ ...state.requiredPaths, @@ -1387,14 +1397,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* taskCardSha256: worktreeMaterializationSha256(taskCardBytes), taskCardGenerated: taskCardAtBase.exitCode !== 0, requiredPaths, - manifestSha256: worktreeMaterializationSha256( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - profileId: state.effectiveProfileId, - conePaths: state.conePaths, - requiredPaths, - }), - ), + manifestSha256: + state.mode === "sparse" + ? worktreeMaterializationSha256( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + profileId: state.effectiveProfileId, + conePaths: state.conePaths, + requiredPaths, + }), + ) + : state.manifestSha256, }; }); @@ -1737,7 +1750,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* materializationError( "GitVcsDriver.verifyWorktreeMaterialization", cwd, - `Exact task card bytes do not match the persisted identity. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + persisted.taskCardGenerated + ? "Generated task card bytes do not match the persisted identity. Restore the exact task card bytes, then run expand-full and reverify." + : "Tracked task card changed from the materialization base. Preserve the work in a named commit and create a new worktree with a new explicit task-card identity; expand-full cannot rebind it.", ); } if (!(yield* generatedTaskCardIsIgnored(cwd, persisted))) { @@ -1843,7 +1858,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* materializationError( "GitVcsDriver.verifyWorktreeMaterialization", cwd, - `Exact task card bytes do not match the persisted identity. ${WORKTREE_MATERIALIZATION_RECOVERY}`, + persisted.taskCardGenerated + ? "Generated task card bytes do not match the persisted identity. Restore the exact task card bytes, then run expand-full and reverify." + : "Tracked task card changed from the materialization base. Preserve the work in a named commit and create a new worktree with a new explicit task-card identity; expand-full cannot rebind it.", ); } if (!(yield* generatedTaskCardIsIgnored(cwd, persisted))) { @@ -1871,6 +1888,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } const persisted = yield* readMaterializationState(cwd); + if (persisted && !(yield* taskCardIdentityMatches(cwd, persisted))) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + persisted.taskCardGenerated + ? "Generated task card bytes do not match the persisted identity. Restore the exact task card bytes before expand-full." + : "Tracked task card changed from the materialization base. Preserve the work in a named commit and create a new worktree with a new explicit task-card identity; expand-full cannot rebind it.", + ); + } const wasSparse = yield* sparseCheckoutEnabled(cwd); const needsSparseDisable = wasSparse || persisted?.mode === "sparse" || persisted?.status === "failed"; @@ -1905,19 +1931,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `Full expansion is missing required path(s): ${missing.join(", ")}`, ); } - const currentTaskCard = baseState.taskCardPath - ? yield* Effect.exit(fileSystem.readFile(path.join(cwd, baseState.taskCardPath))) - : null; const nextState: VcsWorktreeMaterializationState = { ...baseState, status: "ready", effectiveProfileId: "full", mode: "full", reason: reason.trim() || "expand-full", - taskCardSha256: - baseState.taskCardGenerated && currentTaskCard && Exit.isSuccess(currentTaskCard) - ? worktreeMaterializationSha256(currentTaskCard.value) - : (baseState.taskCardSha256 ?? null), + taskCardSha256: baseState.taskCardSha256 ?? null, baseSha: baseState.baseSha ?? baseSha, }; // Preserve the requested sparse manifest as provenance. Only the From b77525a1f71ba15769c81d3e0bcace3fc41521b1 Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 10:44:32 +0100 Subject: [PATCH 06/31] fix(vcs): preserve legacy worktree behavior --- apps/server/src/vcs/GitVcsDriver.ts | 2 - apps/server/src/vcs/GitVcsDriverCore.test.ts | 143 ++++++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 139 +++++++++++------- 3 files changed, 233 insertions(+), 51 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index dddb25e0ba86..7c22189378a3 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -58,8 +58,6 @@ export interface ExecuteGitResult { readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; - readonly stdoutInvalidUtf8?: boolean; - readonly stderrInvalidUtf8?: boolean; } export interface GitStatusDetails { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 39bb23edd12b..7472f3fc0f95 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -141,6 +141,7 @@ const writeMaterializationFixture = Effect.fn("writeMaterializationFixture")(fun readonly invalidSharedPath?: boolean; readonly invalidSharedValue?: unknown; readonly omitTaskCardCone?: boolean; + readonly utf8Bom?: boolean; } = {}, ) { const contract = { @@ -187,7 +188,7 @@ const writeMaterializationFixture = Effect.fn("writeMaterializationFixture")(fun ], } as const; // @effect-diagnostics-next-line preferSchemaOverJson:off - const raw = `${JSON.stringify(contract, null, 2)}\n`; + const raw = `${options.utf8Bom ? "\uFEFF" : ""}${JSON.stringify(contract, null, 2)}\n`; yield* writeTextFile(cwd, "config/worktree-materialization-profiles.json", raw); yield* writeTextFile(cwd, "docs/spec.md", "# sparse\n"); yield* writeTextFile(cwd, "ops/stef-task/task/stef-task.json", "{}\n"); @@ -1593,6 +1594,64 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("pins the one matching remote branch before legacy DWIM worktree creation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-worktree-dwim-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["checkout", "-b", "remote-only"]); + yield* git(cwd, ["push", "origin", "remote-only"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* git(cwd, ["branch", "-D", "remote-only"]); + yield* git(cwd, ["fetch", "origin"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "remote-only-dwim", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: "remote-only", + }); + + assert.equal(created.worktree.refName, "remote-only"); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "remote-only"); + assert.equal( + yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/remote-only", + ); + }), + ); + + it.effect("keeps default worktree placement named after a project subdirectory", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const projectCwd = pathService.join(cwd, "nested-project"); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(projectCwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd: projectCwd, + path: null, + refName: initialBranch, + newRefName: "feature/nested-project-placement", + }); + + assert.equal( + pathService.basename(pathService.dirname(created.worktree.path)), + "nested-project", + ); + }), + ); + it.effect("disables inherited sparse configuration for an ordinary full worktree", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1737,6 +1796,53 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("expand-full recovers a clean sparse worktree with unreadable state", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "unreadable-state-recovery", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/unreadable-state-recovery", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-STATE", + taskSlug: "unreadable-state", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + yield* fileSystem.writeFileString( + pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ), + "{not-json\n", + ); + + const expanded = yield* driver.expandWorktreeMaterializationFull( + worktreePath, + "recover-unreadable-state", + ); + + assert.equal(expanded.effectiveProfileId, "full"); + assert.equal(expanded.mode, "full"); + assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "full"); + }), + ); + it.effect("expand-full repopulates files when sparse config was already disabled", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2218,6 +2324,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("hashes the exact committed UTF-8 BOM bytes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd, { + utf8Bom: true, + }); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "bom-materialization", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/bom-materialization", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-BOM", + taskSlug: "bom", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal(created.materialization?.contractSha256, expectedContractSha256); + }), + ); + it.effect("drops unsafe task-card paths from ordinary full state", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index af301671f2a7..3ec271f9fee1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -884,36 +884,18 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); -function decodeUtf8ChunkIsValid( - decoder: TextDecoder, - chunk?: Uint8Array, - options?: { readonly stream?: boolean }, -): boolean { - try { - decoder.decode(chunk, options); - return true; - } catch { - return false; - } -} - const collectOutput = Effect.fnUntraced(function* ( input: Pick, stream: Stream.Stream, maxOutputBytes: number, appendTruncationMarker: boolean, onLine: ((line: string) => Effect.Effect) | undefined, -): Effect.fn.Return< - { readonly text: string; readonly truncated: boolean; readonly invalidUtf8: boolean }, - GitCommandError -> { +): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { const decoder = new TextDecoder(); - const utf8Validator = new TextDecoder("utf-8", { fatal: true }); let bytes = 0; let text = ""; let lineBuffer = ""; let truncated = false; - let invalidUtf8 = false; const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { let newlineIndex = lineBuffer.indexOf("\n"); @@ -956,12 +938,6 @@ const collectOutput = Effect.fnUntraced(function* ( truncated = appendTruncationMarker && nextBytes > maxOutputBytes; const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); - if ( - !invalidUtf8 && - !decodeUtf8ChunkIsValid(utf8Validator, chunkToDecode, { stream: !truncated }) - ) { - invalidUtf8 = true; - } text += decoded; lineBuffer += decoded; yield* emitCompleteLines(false); @@ -979,16 +955,12 @@ const collectOutput = Effect.fnUntraced(function* ( ); const remainder = truncated ? "" : decoder.decode(); - if (!truncated && !invalidUtf8 && !decodeUtf8ChunkIsValid(utf8Validator)) { - invalidUtf8 = true; - } text += remainder; lineBuffer += remainder; yield* emitCompleteLines(true); return { text, truncated, - invalidUtf8, }; }); @@ -1103,8 +1075,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stderr: stderr.text, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, - stdoutInvalidUtf8: stdout.invalidUtf8, - stderrInvalidUtf8: stderr.invalidUtf8, } satisfies GitVcsDriver.ExecuteGitResult; }); @@ -1243,17 +1213,65 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* relativePath: string, operation: string, ) { - const shown = yield* Effect.exit( - executeGit(operation, repoRoot, ["show", `${pinnedCommit}:${relativePath}`]), + const commandInput = { + operation, + cwd: repoRoot, + args: ["cat-file", "blob", `${pinnedCommit}:${relativePath}`], + } as const; + const loaded = yield* Effect.exit( + Effect.gen(function* () { + const child = yield* commandSpawner + .spawn(ChildProcess.make("git", commandInput.args, { cwd: repoRoot })) + .pipe( + Effect.mapError((cause) => + materializationError( + operation, + repoRoot, + "Failed to read committed blob bytes.", + cause, + ), + ), + ); + const [chunks, stderr, exitCode] = yield* Effect.all( + [ + Stream.runCollect(child.stdout).pipe( + Effect.mapError((cause) => + materializationError( + operation, + repoRoot, + "Failed to collect committed blob bytes.", + cause, + ), + ), + ), + collectOutput(commandInput, child.stderr, DEFAULT_MAX_OUTPUT_BYTES, false, undefined), + child.exitCode.pipe( + Effect.mapError((cause) => + materializationError( + operation, + repoRoot, + "Failed to read committed blob exit code.", + cause, + ), + ), + ), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0 || stderr.text.length > 0) return null; + const arrays = Array.from(chunks); + const byteLength = arrays.reduce((total, chunk) => total + chunk.byteLength, 0); + if (byteLength > DEFAULT_MAX_OUTPUT_BYTES) return null; + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of arrays) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + }).pipe(Effect.scoped), ); - if ( - Exit.isFailure(shown) || - shown.value.stdoutTruncated || - shown.value.stdoutInvalidUtf8 === true - ) { - return null; - } - return new TextEncoder().encode(shown.value.stdout); + return Exit.isSuccess(loaded) ? loaded.value : null; }); const readMaterializationContract = Effect.fn("readMaterializationContract")(function* ( @@ -1887,7 +1905,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "Worktree must be clean before expand-full.", ); } - const persisted = yield* readMaterializationState(cwd); + const persistedRead = yield* Effect.exit(readMaterializationState(cwd)); + const persisted = Exit.isSuccess(persistedRead) ? persistedRead.value : null; if (persisted && !(yield* taskCardIdentityMatches(cwd, persisted))) { return yield* materializationError( "GitVcsDriver.expandWorktreeMaterializationFull", @@ -3821,6 +3840,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const resolvePinnedWorktreeCommit = Effect.fn("resolvePinnedWorktreeCommit")(function* ( + repoRoot: string, + refName: string, + ) { + const exact = yield* executeGit( + "GitVcsDriver.createWorktree.pinCommit", + repoRoot, + ["rev-parse", "--verify", `${refName}^{commit}`], + { allowNonZeroExit: true }, + ); + if (exact.exitCode === 0) return exact.stdout.trim(); + const remoteMatches = yield* executeGit( + "GitVcsDriver.createWorktree.pinRemoteCommit", + repoRoot, + ["for-each-ref", "--format=%(objectname) %(refname)", `refs/remotes/*/${refName}`], + { allowNonZeroExit: true }, + ); + const matches = remoteMatches.stdout.split(/\r?\n/).filter(Boolean); + if (remoteMatches.exitCode === 0 && matches.length === 1) { + return matches[0]!.split(" ", 1)[0]!; + } + return yield* materializationError( + "GitVcsDriver.createWorktree.pinCommit", + repoRoot, + `Cannot resolve '${refName}' to one source commit.`, + ); + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { @@ -3830,13 +3877,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "rev-parse", "--show-toplevel", ])).trim(); - const repoName = path.basename(repoRoot); + const repoName = path.basename(input.cwd); const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const pinnedCommit = (yield* runGitStdout("GitVcsDriver.createWorktree.pinCommit", repoRoot, [ - "rev-parse", - "--verify", - `${input.refName}^{commit}`, - ])).trim(); + const pinnedCommit = yield* resolvePinnedWorktreeCommit(repoRoot, input.refName); const contract = input.materialization ? yield* readMaterializationContract(repoRoot, pinnedCommit) : null; From 8f89a968056cb8d7e76743104b311831726b62f2 Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 11:14:58 +0100 Subject: [PATCH 07/31] fix(vcs): isolate sparse worktree creation --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 137 ++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 361 ++++++++++++------- 2 files changed, 366 insertions(+), 132 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7472f3fc0f95..414064b8feb3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -16,7 +16,11 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; +import { + FULL_WORKTREE_MATERIALIZATION_STATE, + GitCommandError, + type ReviewDiffFileContentsInput, +} from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -1568,6 +1572,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-worktree-remote-"); const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); yield* git(remote, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["push", "origin", initialBranch]); @@ -1585,6 +1590,15 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { refName: `origin/${initialBranch}`, newRefName: "feature/remote-tracking", baseRefName: initialBranch, + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-TRACK", + taskSlug: "remote-tracking", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, }); assert.equal( @@ -1599,6 +1613,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-worktree-dwim-remote-"); const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); yield* git(remote, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["checkout", "-b", "remote-only"]); @@ -1617,10 +1632,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { cwd, path: worktreePath, refName: "remote-only", + newRefName: "feature/remote-only-materialized", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-DWIM", + taskSlug: "remote-only", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, }); - assert.equal(created.worktree.refName, "remote-only"); - assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "remote-only"); + assert.equal(created.worktree.refName, "feature/remote-only-materialized"); + assert.equal( + yield* git(worktreePath, ["branch", "--show-current"]), + "feature/remote-only-materialized", + ); assert.equal( yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), "origin/remote-only", @@ -1632,6 +1660,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); const pathService = yield* Path.Path; const projectCwd = pathService.join(cwd, "nested-project"); const fileSystem = yield* FileSystem.FileSystem; @@ -1652,10 +1681,48 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps omitted materialization on the legacy stateless create path", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "legacy-stateless", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/legacy-stateless", + }); + + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + assert.equal(created.materialization, undefined); + assert.equal( + yield* fileSystem.exists( + pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ), + ), + false, + ); + assert.deepStrictEqual( + yield* driver.verifyWorktreeMaterialization(worktreePath), + FULL_WORKTREE_MATERIALIZATION_STATE, + ); + }), + ); + it.effect("disables inherited sparse configuration for an ordinary full worktree", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); yield* git(cwd, ["config", "core.sparseCheckout", "true"]); const pathService = yield* Path.Path; const worktreePath = pathService.join( @@ -1669,6 +1736,15 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { path: worktreePath, refName: initialBranch, newRefName: "feature/inherited-sparse-full", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-INHERITED", + taskSlug: "inherited-sparse", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, }); assert.equal(created.materialization?.effectiveProfileId, "full"); @@ -2429,6 +2505,55 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect( + "falls back to full before a generated card can escape through a symlinked parent", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const outside = yield* makeTmpDir("generated-card-outside-"); + const outsideCard = pathService.join(outside, "stef-task.json"); + const taskCardBytes = '{"issue":{"id":"OC-SYMLINK"}}\n'; + yield* fileSystem.writeFileString(outsideCard, taskCardBytes); + yield* fileSystem.symlink(outside, pathService.join(cwd, "ops", "stef-task", "escape")); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "symlink task card parent"]); + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "generated-card-parent-symlink", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/generated-card-parent-symlink", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-SYMLINK", + taskSlug: "generated-card-parent-symlink", + taskCardPath: "ops/stef-task/escape/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "task-card-materialization-failed"); + assert.equal( + created.materialization?.taskCardPath, + "ops/stef-task/escape/stef-task.json", + ); + assert.equal(created.materialization?.taskCardSha256, null); + assert.equal(yield* fileSystem.readFileString(outsideCard), taskCardBytes); + }), + ); + it.effect("falls back to full when the contract does not cone-cover task cards", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2753,7 +2878,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* fileSystem.exists(pathService.join(sparsePath, "excluded/large.txt")), true, ); - assert.equal(full.materialization?.effectiveProfileId, "full"); + assert.equal(full.materialization, undefined); + assert.equal( + (yield* driver.verifyWorktreeMaterialization(fullPath)).effectiveProfileId, + "full", + ); } }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3ec271f9fee1..0c0cdd226498 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1218,60 +1218,57 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: repoRoot, args: ["cat-file", "blob", `${pinnedCommit}:${relativePath}`], } as const; - const loaded = yield* Effect.exit( - Effect.gen(function* () { - const child = yield* commandSpawner - .spawn(ChildProcess.make("git", commandInput.args, { cwd: repoRoot })) - .pipe( + return yield* Effect.gen(function* () { + const child = yield* commandSpawner + .spawn(ChildProcess.make("git", commandInput.args, { cwd: repoRoot })) + .pipe( + Effect.mapError((cause) => + materializationError( + operation, + repoRoot, + "Failed to read committed blob bytes.", + cause, + ), + ), + ); + const [chunks, , exitCode] = yield* Effect.all( + [ + Stream.runCollect(child.stdout).pipe( Effect.mapError((cause) => materializationError( operation, repoRoot, - "Failed to read committed blob bytes.", + "Failed to collect committed blob bytes.", cause, ), ), - ); - const [chunks, stderr, exitCode] = yield* Effect.all( - [ - Stream.runCollect(child.stdout).pipe( - Effect.mapError((cause) => - materializationError( - operation, - repoRoot, - "Failed to collect committed blob bytes.", - cause, - ), - ), - ), - collectOutput(commandInput, child.stderr, DEFAULT_MAX_OUTPUT_BYTES, false, undefined), - child.exitCode.pipe( - Effect.mapError((cause) => - materializationError( - operation, - repoRoot, - "Failed to read committed blob exit code.", - cause, - ), + ), + collectOutput(commandInput, child.stderr, DEFAULT_MAX_OUTPUT_BYTES, false, undefined), + child.exitCode.pipe( + Effect.mapError((cause) => + materializationError( + operation, + repoRoot, + "Failed to read committed blob exit code.", + cause, ), ), - ], - { concurrency: "unbounded" }, - ); - if (exitCode !== 0 || stderr.text.length > 0) return null; - const arrays = Array.from(chunks); - const byteLength = arrays.reduce((total, chunk) => total + chunk.byteLength, 0); - if (byteLength > DEFAULT_MAX_OUTPUT_BYTES) return null; - const bytes = new Uint8Array(byteLength); - let offset = 0; - for (const chunk of arrays) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return bytes; - }).pipe(Effect.scoped), - ); - return Exit.isSuccess(loaded) ? loaded.value : null; + ), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return null; + const arrays = Array.from(chunks); + const byteLength = arrays.reduce((total, chunk) => total + chunk.byteLength, 0); + if (byteLength > DEFAULT_MAX_OUTPUT_BYTES) return null; + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of arrays) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + }).pipe(Effect.scoped); }); const readMaterializationContract = Effect.fn("readMaterializationContract")(function* ( @@ -1280,14 +1277,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ) { let raw: Uint8Array; if (pinnedCommit) { - const shown = yield* readMaterializationBlobAtCommit( - repoRoot, - pinnedCommit, - WORKTREE_MATERIALIZATION_CONTRACT_PATH, - "GitVcsDriver.materialization.readContractAtBase", + const shownRead = yield* Effect.exit( + readMaterializationBlobAtCommit( + repoRoot, + pinnedCommit, + WORKTREE_MATERIALIZATION_CONTRACT_PATH, + "GitVcsDriver.materialization.readContractAtBase", + ), ); - if (!shown) return null; - raw = shown; + if (Exit.isFailure(shownRead) || !shownRead.value) return null; + raw = shownRead.value; } else { const file = yield* Effect.exit( fileSystem.readFile(path.join(repoRoot, WORKTREE_MATERIALIZATION_CONTRACT_PATH)), @@ -1358,12 +1357,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (Exit.isSuccess(source)) sourceTaskCardBytes = source.value; } if (taskCardAtBase.exitCode === 0) { - taskCardBytes = yield* readMaterializationBlobAtCommit( - repoRoot, - pinnedCommit, - taskCardPath, - "GitVcsDriver.materialization.taskCardBytesAtBase", + const taskCardRead = yield* Effect.exit( + readMaterializationBlobAtCommit( + repoRoot, + pinnedCommit, + taskCardPath, + "GitVcsDriver.materialization.taskCardBytesAtBase", + ), ); + taskCardBytes = Exit.isSuccess(taskCardRead) ? taskCardRead.value : null; if ( taskCardBytes && sourceTaskCardBytes && @@ -1518,6 +1520,41 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* excludePath, ]); }); + const ensureTargetParentContained = Effect.fn("ensureGeneratedTaskCardParentContained")( + function* () { + if (!state.taskCardGenerated) return; + const worktreeRealPath = yield* fileSystem.realPath(worktreePath); + const targetParent = path.dirname(targetPath); + const isInsideWorktree = (candidate: string) => { + const relative = path.relative(worktreeRealPath, candidate); + return ( + relative === "" || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }; + let existingParent = worktreePath; + for (const component of taskCardPath.split("/").slice(0, -1)) { + existingParent = path.join(existingParent, component); + if (!(yield* fileSystem.exists(existingParent))) break; + const realParent = yield* fileSystem.realPath(existingParent); + if (!isInsideWorktree(realParent)) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + worktreePath, + "Generated task card parent escapes the worktree through a symbolic link.", + ); + } + } + yield* fileSystem.makeDirectory(targetParent, { recursive: true }); + if (!isInsideWorktree(yield* fileSystem.realPath(targetParent))) { + return yield* materializationError( + "GitVcsDriver.materialization.taskCard", + worktreePath, + "Generated task card parent escapes the worktree.", + ); + } + }, + ); + yield* ensureTargetParentContained(); const target = yield* Effect.exit(fileSystem.readFile(targetPath)); if (Exit.isSuccess(target)) { const identityMatches = state.taskCardGenerated @@ -1561,7 +1598,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "Hash-bound task card source changed before materialization.", ); } - yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); yield* configureIgnore(); yield* fileSystem.writeFile(targetPath, source); }); @@ -3840,6 +3876,51 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const completeWorktreeCreation = Effect.fn("completeWorktreeCreation")(function* ( + input: Parameters[0], + worktreePath: string, + targetBranch: string, + materialization?: VcsWorktreeMaterializationState, + ) { + const hasSubmodules = yield* fileSystem + .exists(path.join(worktreePath, ".gitmodules")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasSubmodules) { + yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ + "submodule", + "update", + "--init", + "--recursive", + ]).pipe( + Effect.catch((cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }), + ), + ); + } + + if (input.newRefName && input.baseRefName) { + const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); + const parsedBaseRef = parseRemoteRefWithRemoteNames( + input.baseRefName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; + yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ + "config", + `branch.${input.newRefName}.gh-merge-base`, + baseBranch, + ]); + } + + return { + worktree: { path: worktreePath, refName: targetBranch }, + ...(materialization ? { materialization } : {}), + }; + }); + const resolvePinnedWorktreeCommit = Effect.fn("resolvePinnedWorktreeCommit")(function* ( repoRoot: string, refName: string, @@ -3850,7 +3931,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["rev-parse", "--verify", `${refName}^{commit}`], { allowNonZeroExit: true }, ); - if (exact.exitCode === 0) return exact.stdout.trim(); + if (exact.exitCode === 0) return { commit: exact.stdout.trim(), remoteRef: null }; const remoteMatches = yield* executeGit( "GitVcsDriver.createWorktree.pinRemoteCommit", repoRoot, @@ -3859,7 +3940,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const matches = remoteMatches.stdout.split(/\r?\n/).filter(Boolean); if (remoteMatches.exitCode === 0 && matches.length === 1) { - return matches[0]!.split(" ", 1)[0]!; + const [commit, fullRef] = matches[0]!.split(" ", 2); + return { + commit: commit!, + remoteRef: fullRef!.replace(/^refs\/remotes\//, ""), + }; } return yield* materializationError( "GitVcsDriver.createWorktree.pinCommit", @@ -3873,13 +3958,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* )(function* (input) { const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + if (!input.materialization) { + const legacyArgs = input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; + yield* executeGit("GitVcsDriver.createWorktree", input.cwd, legacyArgs, { + fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + }); + return yield* completeWorktreeCreation(input, worktreePath, targetBranch); + } const repoRoot = (yield* runGitStdout("GitVcsDriver.createWorktree.repoRoot", input.cwd, [ "rev-parse", "--show-toplevel", ])).trim(); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const pinnedCommit = yield* resolvePinnedWorktreeCommit(repoRoot, input.refName); + const pinned = yield* resolvePinnedWorktreeCommit(repoRoot, input.refName); + const pinnedCommit = pinned.commit; const contract = input.materialization ? yield* readMaterializationContract(repoRoot, pinnedCommit) : null; @@ -3902,19 +3998,43 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); if (input.newRefName) { - const remoteStart = yield* executeGit( - "GitVcsDriver.createWorktree.remoteStart", + let trackingRef = pinned.remoteRef; + if (!trackingRef) { + const remoteStart = yield* executeGit( + "GitVcsDriver.createWorktree.remoteStart", + repoRoot, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { allowNonZeroExit: true }, + ); + if (remoteStart.exitCode === 0) trackingRef = input.refName; + } + const autoSetupMerge = yield* executeGit( + "GitVcsDriver.createWorktree.autoSetupMerge", repoRoot, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + ["config", "--get", "branch.autoSetupMerge"], { allowNonZeroExit: true }, ); - if (remoteStart.exitCode === 0) { - yield* runGit("GitVcsDriver.createWorktree.preserveUpstream", repoRoot, [ - "branch", - "--set-upstream-to", - input.refName, - input.newRefName, - ]); + if (trackingRef && autoSetupMerge.stdout.trim() !== "false") { + const preserved = yield* Effect.exit( + runGit("GitVcsDriver.createWorktree.preserveUpstream", repoRoot, [ + "branch", + "--set-upstream-to", + trackingRef, + input.newRefName, + ]), + ); + if (Exit.isFailure(preserved)) { + yield* writeMaterializationState(worktreePath, { + ...requestedMaterialization, + status: "failed", + reason: "upstream-tracking-failed", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Worktree branch upstream tracking failed after creation. The never-released worktree was preserved for diagnosis.", + ); + } } } @@ -4060,12 +4180,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } else { if (yield* sparseCheckoutEnabled(worktreePath)) { - yield* runGit( - "GitVcsDriver.createWorktree.disableInheritedSparse", - worktreePath, - ["sparse-checkout", "disable"], - { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + const disabled = yield* Effect.exit( + runGit( + "GitVcsDriver.createWorktree.disableInheritedSparse", + worktreePath, + ["sparse-checkout", "disable"], + { timeoutMs: WORKTREE_ADD_TIMEOUT_MS }, + ), ); + if (Exit.isFailure(disabled)) { + yield* writeMaterializationState(worktreePath, { + ...requestedMaterialization, + status: "failed", + reason: "inherited-sparse-disable-failed", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Inherited sparse checkout could not be disabled. The never-released worktree was preserved for diagnosis.", + ); + } } const fullCheckout = yield* Effect.exit( runGit( @@ -4106,11 +4240,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const observedHead = (yield* runGitStdout( - "GitVcsDriver.createWorktree.verifyHead", - worktreePath, - ["rev-parse", "--verify", "HEAD^{commit}"], - )).trim(); + const observedHeadRead = yield* Effect.exit( + runGitStdout("GitVcsDriver.createWorktree.verifyHead", worktreePath, [ + "rev-parse", + "--verify", + "HEAD^{commit}", + ]), + ); + if (Exit.isFailure(observedHeadRead)) { + yield* writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "materialized-head-unreadable", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Materialized worktree HEAD could not be read. The never-released worktree was preserved for diagnosis.", + ); + } + const observedHead = observedHeadRead.value.trim(); if (observedHead !== pinnedCommit) { yield* writeMaterializationState(worktreePath, { ...materialization, @@ -4153,51 +4302,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); - // `git worktree add` leaves submodules empty, so a repo that keeps agent - // skills, tooling or source in one gets a worktree that is quietly missing - // them. Best-effort: the objects are usually already in the parent's - // `.git/modules`, but a first-ever clone needs the network, and failing to - // populate a submodule must not roll back the caller's thread. - const hasSubmodules = yield* fileSystem - .exists(path.join(worktreePath, ".gitmodules")) - .pipe(Effect.orElseSucceed(() => false)); - if (hasSubmodules) { - yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ - "submodule", - "update", - "--init", - "--recursive", - ]).pipe( - Effect.catch((cause) => - Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { - worktreePath, - cause, - }), - ), - ); - } - - if (input.newRefName && input.baseRefName) { - const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); - const parsedBaseRef = parseRemoteRefWithRemoteNames( - input.baseRefName, - remoteNames.toSorted((left, right) => right.length - left.length), - ); - const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; - yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ - "config", - `branch.${input.newRefName}.gh-merge-base`, - baseBranch, - ]); - } - - return { - worktree: { - path: worktreePath, - refName: targetBranch, - }, - materialization, - }; + return yield* completeWorktreeCreation(input, worktreePath, targetBranch, materialization); }); const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = From 6ffa133459ae4feabd9782665e7806e6cdc7e034 Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 11:26:27 +0100 Subject: [PATCH 08/31] fix(vcs): bound materialization failures --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 84 +++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 141 +++++++++++++++---- 2 files changed, 195 insertions(+), 30 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 414064b8feb3..42c8c958ae5b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1656,6 +1656,90 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("respects simple auto-setup when the local and remote branch names differ", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-worktree-simple-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", initialBranch]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["config", "branch.autoSetupMerge", "simple"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "simple-auto-setup", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: `origin/${initialBranch}`, + newRefName: "feature/simple-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-SIMPLE", + taskSlug: "simple-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + const upstream = yield* driver.execute({ + operation: "test.simpleAutoSetup", + args: ["rev-parse", "--abbrev-ref", "@{upstream}"], + cwd: worktreePath, + allowNonZeroExit: true, + }); + assert.notEqual(upstream.exitCode, 0); + }), + ); + + it.effect("inherits the start branch upstream when auto-setup is inherit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-worktree-inherit-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["config", "branch.autoSetupMerge", "inherit"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "inherit-auto-setup", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/inherit-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-INHERIT", + taskSlug: "inherit-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + + assert.equal( + yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `origin/${initialBranch}`, + ); + }), + ); + it.effect("keeps default worktree placement named after a project subdirectory", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0c0cdd226498..0978c686dc90 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1218,7 +1218,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: repoRoot, args: ["cat-file", "blob", `${pinnedCommit}:${relativePath}`], } as const; - return yield* Effect.gen(function* () { + const timed = yield* Effect.gen(function* () { const child = yield* commandSpawner .spawn(ChildProcess.make("git", commandInput.args, { cwd: repoRoot })) .pipe( @@ -1231,16 +1231,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ), ); - const [chunks, , exitCode] = yield* Effect.all( + const chunks: Array = []; + let byteLength = 0; + const [, , exitCode] = yield* Effect.all( [ - Stream.runCollect(child.stdout).pipe( + Stream.runForEach(child.stdout, (chunk) => { + byteLength += chunk.byteLength; + if (byteLength > DEFAULT_MAX_OUTPUT_BYTES) { + return Effect.fail( + materializationError( + operation, + repoRoot, + "Committed blob exceeded the materialization byte limit.", + ), + ); + } + chunks.push(chunk); + return Effect.void; + }).pipe( Effect.mapError((cause) => - materializationError( - operation, - repoRoot, - "Failed to collect committed blob bytes.", - cause, - ), + Schema.is(GitCommandError)(cause) + ? cause + : materializationError( + operation, + repoRoot, + "Failed to collect committed blob bytes.", + cause, + ), ), ), collectOutput(commandInput, child.stderr, DEFAULT_MAX_OUTPUT_BYTES, false, undefined), @@ -1258,17 +1275,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { concurrency: "unbounded" }, ); if (exitCode !== 0) return null; - const arrays = Array.from(chunks); - const byteLength = arrays.reduce((total, chunk) => total + chunk.byteLength, 0); - if (byteLength > DEFAULT_MAX_OUTPUT_BYTES) return null; const bytes = new Uint8Array(byteLength); let offset = 0; - for (const chunk of arrays) { + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes; - }).pipe(Effect.scoped); + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.scoped); + if (Option.isNone(timed)) { + return yield* materializationError( + operation, + repoRoot, + "Timed out while reading committed blob bytes.", + ); + } + return timed.value; }); const readMaterializationContract = Effect.fn("readMaterializationContract")(function* ( @@ -4014,12 +4036,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["config", "--get", "branch.autoSetupMerge"], { allowNonZeroExit: true }, ); - if (trackingRef && autoSetupMerge.stdout.trim() !== "false") { + const autoSetupMode = autoSetupMerge.stdout.trim() || "true"; + let upstreamToSet: string | null = null; + if (autoSetupMode === "inherit") { + const inheritedUpstream = yield* executeGit( + "GitVcsDriver.createWorktree.inheritedUpstream", + repoRoot, + ["rev-parse", "--abbrev-ref", `${input.refName}@{upstream}`], + { allowNonZeroExit: true }, + ); + if (inheritedUpstream.exitCode === 0) upstreamToSet = inheritedUpstream.stdout.trim(); + } else if (trackingRef && autoSetupMode !== "false") { + if (autoSetupMode === "simple") { + const remoteNames = yield* listRemoteNames(repoRoot).pipe(Effect.orElseSucceed(() => [])); + const remote = parseRemoteRefWithRemoteNames( + trackingRef, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + if (remote?.branchName === input.newRefName) upstreamToSet = trackingRef; + } else { + upstreamToSet = trackingRef; + } + } else if (autoSetupMode === "always") { + const localStart = yield* executeGit( + "GitVcsDriver.createWorktree.localTrackingStart", + repoRoot, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { allowNonZeroExit: true }, + ); + if (localStart.exitCode === 0) upstreamToSet = input.refName; + } + if (upstreamToSet) { const preserved = yield* Effect.exit( runGit("GitVcsDriver.createWorktree.preserveUpstream", repoRoot, [ "branch", "--set-upstream-to", - trackingRef, + upstreamToSet, input.newRefName, ]), ); @@ -4075,11 +4127,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (Exit.isFailure(taskCard)) { fallbackReason = "task-card-materialization-failed"; } else { - const missing = yield* requiredMaterializationPathsMissing( - worktreePath, - requestedMaterialization.requiredPaths, + const missingRead = yield* Effect.exit( + requiredMaterializationPathsMissing( + worktreePath, + requestedMaterialization.requiredPaths, + ), ); - if (missing.length > 0) fallbackReason = "required-paths-missing"; + if (Exit.isFailure(missingRead)) fallbackReason = "required-path-inspection-failed"; + else if (missingRead.value.length > 0) fallbackReason = "required-paths-missing"; } } } @@ -4157,11 +4212,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } } - const missing = yield* requiredMaterializationPathsMissing( - worktreePath, - fullMaterialization.requiredPaths, + const missingRead = yield* Effect.exit( + requiredMaterializationPathsMissing(worktreePath, fullMaterialization.requiredPaths), ); - if (missing.length > 0) { + if (Exit.isFailure(missingRead)) { + const failedState: VcsWorktreeMaterializationState = { + ...fullMaterialization, + status: "failed", + reason: `${fallbackReason}:full-fallback-required-paths-unreadable`, + }; + yield* writeMaterializationState(worktreePath, failedState); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + `${fallbackReason}; full fallback required paths could not be inspected. The never-released worktree was preserved for diagnosis.`, + ); + } + if (missingRead.value.length > 0) { const failedState: VcsWorktreeMaterializationState = { ...requestedMaterialization, status: "failed", @@ -4173,7 +4240,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* materializationError( "GitVcsDriver.createWorktree", worktreePath, - `${fallbackReason}; full fallback is missing required path(s): ${missing.join(", ")}. The never-released worktree was preserved for diagnosis.`, + `${fallbackReason}; full fallback is missing required path(s): ${missingRead.value.join(", ")}. The never-released worktree was preserved for diagnosis.`, ); } materialization = fullMaterialization; @@ -4273,11 +4340,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } if (input.newRefName) { - const observedBranch = (yield* runGitStdout( - "GitVcsDriver.createWorktree.verifyBranch", - worktreePath, - ["branch", "--show-current"], - )).trim(); + const observedBranchRead = yield* Effect.exit( + runGitStdout("GitVcsDriver.createWorktree.verifyBranch", worktreePath, [ + "branch", + "--show-current", + ]), + ); + if (Exit.isFailure(observedBranchRead)) { + yield* writeMaterializationState(worktreePath, { + ...materialization, + status: "failed", + reason: "materialized-branch-unreadable", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Materialized worktree branch could not be read. The never-released worktree was preserved for diagnosis.", + ); + } + const observedBranch = observedBranchRead.value.trim(); if (observedBranch !== input.newRefName) { yield* writeMaterializationState(worktreePath, { ...materialization, From 58da67b2402d0da0956dbc22f72bb010246ad312 Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 13:05:51 +0100 Subject: [PATCH 09/31] fix(vcs): close post-add failure gaps --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 5 +- apps/server/src/vcs/GitVcsDriverCore.ts | 153 ++++++++++++------- 2 files changed, 96 insertions(+), 62 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 42c8c958ae5b..da0353f2b041 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2629,10 +2629,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.materialization?.effectiveProfileId, "full"); assert.equal(created.materialization?.reason, "task-card-materialization-failed"); - assert.equal( - created.materialization?.taskCardPath, - "ops/stef-task/escape/stef-task.json", - ); + assert.equal(created.materialization?.taskCardPath, null); assert.equal(created.materialization?.taskCardSha256, null); assert.equal(yield* fileSystem.readFileString(outsideCard), taskCardBytes); }), diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0978c686dc90..fe9bb5e1b201 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1353,16 +1353,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* taskCardGenerated: false, baseSha: pinnedCommit, }); + if (!taskCardPath) { + if (state.mode !== "sparse") return fullWithoutTaskCard(state.reason); + return fullWithoutTaskCard("task-card-missing-at-base"); + } if ( - !taskCardPath || - (taskCardPath !== WORKTREE_MATERIALIZATION_TASK_CARD_ROOT && - !taskCardPath.startsWith(`${WORKTREE_MATERIALIZATION_TASK_CARD_ROOT}/`)) + taskCardPath !== WORKTREE_MATERIALIZATION_TASK_CARD_ROOT && + !taskCardPath.startsWith(`${WORKTREE_MATERIALIZATION_TASK_CARD_ROOT}/`) ) { - if (state.mode !== "sparse") return fullWithoutTaskCard(state.reason); - return { - ...fullWithoutTaskCard("task-card-missing-at-base"), - requestedProfileId: state.requestedProfileId, - }; + return fullWithoutTaskCard(state.mode === "sparse" ? "task-card-outside-root" : state.reason); } const taskCardAtBase = yield* executeGit( "GitVcsDriver.materialization.taskCardAtBase", @@ -1387,7 +1386,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.materialization.taskCardBytesAtBase", ), ); - taskCardBytes = Exit.isSuccess(taskCardRead) ? taskCardRead.value : null; + if (Exit.isFailure(taskCardRead) || !taskCardRead.value) { + return fullWithoutTaskCard( + state.mode === "sparse" ? "task-card-unreadable-at-base" : state.reason, + ); + } + taskCardBytes = taskCardRead.value; if ( taskCardBytes && sourceTaskCardBytes && @@ -1796,7 +1800,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "Sparse checkout has no persisted materialization identity. Preserve changes in an ordinary named commit or operator-approved external copy, reach a clean state without automated stash/reset/clean/removal, run expand-full, then reverify.", ); } - return FULL_WORKTREE_MATERIALIZATION_STATE; + return { + ...FULL_WORKTREE_MATERIALIZATION_STATE, + conePaths: [], + requiredPaths: [], + }; } if (persisted.status === "failed") { return yield* materializationError( @@ -3998,9 +4006,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ])).trim(); const pinned = yield* resolvePinnedWorktreeCommit(repoRoot, input.refName); const pinnedCommit = pinned.commit; - const contract = input.materialization - ? yield* readMaterializationContract(repoRoot, pinnedCommit) - : null; + const contract = yield* readMaterializationContract(repoRoot, pinnedCommit); const requestedMaterialization = yield* pinMaterializationRequiredPaths( repoRoot, pinnedCommit, @@ -4019,53 +4025,83 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* timeoutMs: WORKTREE_ADD_TIMEOUT_MS, }); - if (input.newRefName) { - let trackingRef = pinned.remoteRef; - if (!trackingRef) { - const remoteStart = yield* executeGit( - "GitVcsDriver.createWorktree.remoteStart", - repoRoot, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], - { allowNonZeroExit: true }, - ); - if (remoteStart.exitCode === 0) trackingRef = input.refName; - } - const autoSetupMerge = yield* executeGit( - "GitVcsDriver.createWorktree.autoSetupMerge", - repoRoot, - ["config", "--get", "branch.autoSetupMerge"], - { allowNonZeroExit: true }, + const sparseStateAfterAdd = Effect.fn("sparseStateAfterAdd")(function* (reason: string) { + const observed = yield* Effect.exit(sparseCheckoutEnabled(worktreePath)); + if (Exit.isSuccess(observed)) return observed.value; + yield* writeMaterializationState(worktreePath, { + ...requestedMaterialization, + status: "failed", + reason, + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Worktree sparse-checkout state could not be read. The never-released worktree was preserved for diagnosis.", ); - const autoSetupMode = autoSetupMerge.stdout.trim() || "true"; - let upstreamToSet: string | null = null; - if (autoSetupMode === "inherit") { - const inheritedUpstream = yield* executeGit( - "GitVcsDriver.createWorktree.inheritedUpstream", - repoRoot, - ["rev-parse", "--abbrev-ref", `${input.refName}@{upstream}`], - { allowNonZeroExit: true }, - ); - if (inheritedUpstream.exitCode === 0) upstreamToSet = inheritedUpstream.stdout.trim(); - } else if (trackingRef && autoSetupMode !== "false") { - if (autoSetupMode === "simple") { - const remoteNames = yield* listRemoteNames(repoRoot).pipe(Effect.orElseSucceed(() => [])); - const remote = parseRemoteRefWithRemoteNames( - trackingRef, - remoteNames.toSorted((left, right) => right.length - left.length), + }); + + if (input.newRefName) { + const trackingDecision = yield* Effect.exit( + Effect.gen(function* () { + let trackingRef = pinned.remoteRef; + if (!trackingRef) { + const remoteStart = yield* executeGit( + "GitVcsDriver.createWorktree.remoteStart", + repoRoot, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { allowNonZeroExit: true }, + ); + if (remoteStart.exitCode === 0) trackingRef = input.refName; + } + const autoSetupMerge = yield* executeGit( + "GitVcsDriver.createWorktree.autoSetupMerge", + repoRoot, + ["config", "--get", "branch.autoSetupMerge"], + { allowNonZeroExit: true }, ); - if (remote?.branchName === input.newRefName) upstreamToSet = trackingRef; - } else { - upstreamToSet = trackingRef; - } - } else if (autoSetupMode === "always") { - const localStart = yield* executeGit( - "GitVcsDriver.createWorktree.localTrackingStart", - repoRoot, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], - { allowNonZeroExit: true }, + const autoSetupMode = (autoSetupMerge.stdout.trim() || "true").toLowerCase(); + if (["false", "no", "off", "0"].includes(autoSetupMode)) return null; + if (autoSetupMode === "inherit") { + const inheritedUpstream = yield* executeGit( + "GitVcsDriver.createWorktree.inheritedUpstream", + repoRoot, + ["rev-parse", "--abbrev-ref", `${input.refName}@{upstream}`], + { allowNonZeroExit: true }, + ); + return inheritedUpstream.exitCode === 0 ? inheritedUpstream.stdout.trim() : null; + } + if (trackingRef) { + if (autoSetupMode !== "simple") return trackingRef; + const remoteNames = yield* listRemoteNames(repoRoot); + const remote = parseRemoteRefWithRemoteNames( + trackingRef, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + return remote?.branchName === input.newRefName ? trackingRef : null; + } + if (autoSetupMode !== "always") return null; + const localStart = yield* executeGit( + "GitVcsDriver.createWorktree.localTrackingStart", + repoRoot, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { allowNonZeroExit: true }, + ); + return localStart.exitCode === 0 ? input.refName : null; + }), + ); + if (Exit.isFailure(trackingDecision)) { + yield* writeMaterializationState(worktreePath, { + ...requestedMaterialization, + status: "failed", + reason: "tracking-inspection-failed", + }); + return yield* materializationError( + "GitVcsDriver.createWorktree", + worktreePath, + "Worktree branch tracking could not be inspected. The never-released worktree was preserved for diagnosis.", ); - if (localStart.exitCode === 0) upstreamToSet = input.refName; } + const upstreamToSet = trackingDecision.value; if (upstreamToSet) { const preserved = yield* Effect.exit( runGit("GitVcsDriver.createWorktree.preserveUpstream", repoRoot, [ @@ -4140,7 +4176,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (fallbackReason) { - if (yield* sparseCheckoutEnabled(worktreePath)) { + if (yield* sparseStateAfterAdd(`${fallbackReason}:sparse-state-unreadable`)) { const disabled = yield* Effect.exit( runGit( "GitVcsDriver.createWorktree.disableSparseFallback", @@ -4187,6 +4223,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } const withoutTaskCardIdentity = (state: VcsWorktreeMaterializationState) => ({ + taskCardPath: null, taskCardSha256: null, taskCardGenerated: false, requiredPaths: state.requiredPaths.filter( @@ -4246,7 +4283,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* materialization = fullMaterialization; } } else { - if (yield* sparseCheckoutEnabled(worktreePath)) { + if (yield* sparseStateAfterAdd("full-sparse-state-unreadable")) { const disabled = yield* Effect.exit( runGit( "GitVcsDriver.createWorktree.disableInheritedSparse", From 9ad8c4687193da1a6ca0269b244bbfa319efe2ee Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 13:18:43 +0100 Subject: [PATCH 10/31] fix(vcs): preserve terminal materialization failures --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 81 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 77 +++++++++++++------ 2 files changed, 135 insertions(+), 23 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index da0353f2b041..6980398e5337 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1697,6 +1697,34 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { allowNonZeroExit: true, }); assert.notEqual(upstream.exitCode, 0); + + yield* git(cwd, ["config", "branch.autoSetupMerge", ""]); + const emptyModePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "empty-auto-setup", + ); + yield* driver.createWorktree({ + cwd, + path: emptyModePath, + refName: `origin/${initialBranch}`, + newRefName: "feature/empty-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-EMPTY", + taskSlug: "empty-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const emptyModeUpstream = yield* driver.execute({ + operation: "test.emptyAutoSetup", + args: ["rev-parse", "--abbrev-ref", "@{upstream}"], + cwd: emptyModePath, + allowNonZeroExit: true, + }); + assert.notEqual(emptyModeUpstream.exitCode, 0); }), ); @@ -2003,6 +2031,58 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("expand-full refuses failed source identity and branch setup states", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "failed-source-identity", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/failed-source-identity", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-IDENTITY", + taskSlug: "failed-source-identity", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const gitDir = yield* git(worktreePath, ["rev-parse", "--git-dir"]); + const statePath = pathService.join( + pathService.resolve(worktreePath, gitDir), + "worktree-materialization.json", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const state = JSON.parse(yield* fileSystem.readFileString(statePath)); + yield* fileSystem.writeFileString( + statePath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ ...state, status: "failed", reason: "materialized-head-mismatch" })}\n`, + ); + + const expansion = yield* Effect.result( + driver.expandWorktreeMaterializationFull(worktreePath, "must-refuse"), + ); + + assert.equal(expansion._tag, "Failure"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const preserved = JSON.parse(yield* fileSystem.readFileString(statePath)); + assert.equal(preserved.status, "failed"); + assert.equal(preserved.reason, "materialized-head-mismatch"); + }), + ); + it.effect("expand-full repopulates files when sparse config was already disabled", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2262,6 +2342,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.materialization?.effectiveProfileId, "full"); assert.equal(created.materialization?.reason, "task-card-materialization-failed"); + assert.equal(created.materialization?.taskCardPath, null); assert.equal(created.materialization?.taskCardSha256, null); assert.equal(created.materialization?.requiredPaths.includes(taskCardPath), false); assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, taskCardPath)), false); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index fe9bb5e1b201..0df059247761 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -190,6 +190,8 @@ function fullMaterializationState( const normalizedScopePaths = request?.scopePaths.map(normalizeMaterializationRepoPath) ?? []; return { ...FULL_WORKTREE_MATERIALIZATION_STATE, + conePaths: [], + requiredPaths: [], status: "ready", requestedProfileId: request?.requestedProfileId ?? "full", reason, @@ -1295,27 +1297,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readMaterializationContract = Effect.fn("readMaterializationContract")(function* ( repoRoot: string, - pinnedCommit?: string, + pinnedCommit: string, ) { - let raw: Uint8Array; - if (pinnedCommit) { - const shownRead = yield* Effect.exit( - readMaterializationBlobAtCommit( - repoRoot, - pinnedCommit, - WORKTREE_MATERIALIZATION_CONTRACT_PATH, - "GitVcsDriver.materialization.readContractAtBase", - ), - ); - if (Exit.isFailure(shownRead) || !shownRead.value) return null; - raw = shownRead.value; - } else { - const file = yield* Effect.exit( - fileSystem.readFile(path.join(repoRoot, WORKTREE_MATERIALIZATION_CONTRACT_PATH)), - ); - if (Exit.isFailure(file)) return null; - raw = file.value; - } + const shownRead = yield* Effect.exit( + readMaterializationBlobAtCommit( + repoRoot, + pinnedCommit, + WORKTREE_MATERIALIZATION_CONTRACT_PATH, + "GitVcsDriver.materialization.readContractAtBase", + ), + ); + if (Exit.isFailure(shownRead) || !shownRead.value) return null; + const raw = shownRead.value; const loaded = yield* Effect.exit( Effect.try({ try: () => ({ @@ -1354,8 +1347,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* baseSha: pinnedCommit, }); if (!taskCardPath) { - if (state.mode !== "sparse") return fullWithoutTaskCard(state.reason); - return fullWithoutTaskCard("task-card-missing-at-base"); + return fullWithoutTaskCard(state.reason); } if ( taskCardPath !== WORKTREE_MATERIALIZATION_TASK_CARD_ROOT && @@ -1399,6 +1391,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* worktreeMaterializationSha256(sourceTaskCardBytes) ) { const requiredPaths = uniqueMaterializationPaths([taskCardPath]); + if (state.mode !== "sparse") { + return { + ...state, + requiredPaths: uniqueMaterializationPaths([...state.requiredPaths, taskCardPath]), + taskCardSha256: worktreeMaterializationSha256(taskCardBytes), + taskCardGenerated: false, + baseSha: pinnedCommit, + }; + } return { ...state, effectiveProfileId: "full", @@ -1973,6 +1974,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const persistedRead = yield* Effect.exit(readMaterializationState(cwd)); const persisted = Exit.isSuccess(persistedRead) ? persistedRead.value : null; + if ( + persisted?.status === "failed" && + [ + "materialized-head-mismatch", + "materialized-head-unreadable", + "materialized-branch-mismatch", + "materialized-branch-unreadable", + "upstream-tracking-failed", + "tracking-inspection-failed", + ].includes(persisted.reason ?? "") + ) { + return yield* materializationError( + "GitVcsDriver.expandWorktreeMaterializationFull", + cwd, + "This worktree failed source identity or branch setup before release. Preserve diagnostic evidence and create a new worktree; expand-full cannot certify it.", + ); + } if (persisted && !(yield* taskCardIdentityMatches(cwd, persisted))) { return yield* materializationError( "GitVcsDriver.expandWorktreeMaterializationFull", @@ -2006,6 +2024,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ])).trim(); const baseState = persisted ?? { ...FULL_WORKTREE_MATERIALIZATION_STATE, + conePaths: [], + requiredPaths: [], baseSha, }; const missing = yield* requiredMaterializationPathsMissing(cwd, baseState.requiredPaths); @@ -4059,7 +4079,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["config", "--get", "branch.autoSetupMerge"], { allowNonZeroExit: true }, ); - const autoSetupMode = (autoSetupMerge.stdout.trim() || "true").toLowerCase(); + let autoSetupMode = autoSetupMerge.stdout.trim().toLowerCase(); + if (autoSetupMerge.exitCode !== 0) { + autoSetupMode = "true"; + } else if (!autoSetupMode) { + const booleanMode = yield* executeGit( + "GitVcsDriver.createWorktree.autoSetupMergeBoolean", + repoRoot, + ["config", "--type=bool", "--get", "branch.autoSetupMerge"], + { allowNonZeroExit: true }, + ); + autoSetupMode = booleanMode.exitCode === 0 ? booleanMode.stdout.trim() : "true"; + } if (["false", "no", "off", "0"].includes(autoSetupMode)) return null; if (autoSetupMode === "inherit") { const inheritedUpstream = yield* executeGit( From 27c4dbd4520083880f65eec759b4cd4d20165c5e Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 14:01:12 +0100 Subject: [PATCH 11/31] fix(vcs): preserve fallback diagnostics --- apps/server/src/vcs/GitVcsDriverCore.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0df059247761..c325d5a1c861 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3932,6 +3932,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* targetBranch: string, materialization?: VcsWorktreeMaterializationState, ) { + // `git worktree add` leaves submodules empty. Populate them best-effort; + // an unreachable first-time submodule must not roll back the thread. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); @@ -4298,10 +4300,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (missingRead.value.length > 0) { const failedState: VcsWorktreeMaterializationState = { - ...requestedMaterialization, + ...fullMaterialization, status: "failed", - effectiveProfileId: "full", - mode: "full", reason: `${fallbackReason}:full-fallback-required-paths-missing`, }; yield* writeMaterializationState(worktreePath, failedState); From e0c5d5ca65de37b67b116343e84f7f3617885fed Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 14:17:33 +0100 Subject: [PATCH 12/31] test(vcs): bind sparse materialization invariants --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 156 ++++++++++++++++++- 1 file changed, 154 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 6980398e5337..1dd919c84962 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -205,6 +205,7 @@ const writeMaterializationFixture = Effect.fn("writeMaterializationFixture")(fun yield* git(cwd, ["commit", "-m", "materialization fixture"]); return { expectedContractSha256: NodeCrypto.createHash("sha256").update(raw).digest("hex"), + expectedTaskCardSha256: NodeCrypto.createHash("sha256").update("{}\n").digest("hex"), }; }); @@ -1601,6 +1602,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }, }); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD^{commit}"]), + yield* git(cwd, ["rev-parse", `origin/${initialBranch}^{commit}`]), + ); assert.equal( yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), `origin/${initialBranch}`, @@ -1649,6 +1654,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(worktreePath, ["branch", "--show-current"]), "feature/remote-only-materialized", ); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD^{commit}"]), + yield* git(cwd, ["rev-parse", "origin/remote-only^{commit}"]), + ); assert.equal( yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), "origin/remote-only", @@ -1656,6 +1665,55 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("refuses ambiguous bare branch names shared by two remotes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remoteA = yield* makeTmpDir("git-worktree-ambiguous-a-"); + const remoteB = yield* makeTmpDir("git-worktree-ambiguous-b-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + yield* git(remoteA, ["init", "--bare"]); + yield* git(remoteB, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remoteA]); + yield* git(cwd, ["remote", "add", "upstream", remoteB]); + yield* git(cwd, ["checkout", "-b", "ambiguous"]); + yield* git(cwd, ["push", "origin", "ambiguous"]); + yield* git(cwd, ["push", "upstream", "ambiguous"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* git(cwd, ["branch", "-D", "ambiguous"]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["fetch", "upstream"]); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "ambiguous-remote-branch", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const result = yield* Effect.result( + driver.createWorktree({ + cwd, + path: worktreePath, + refName: "ambiguous", + newRefName: "feature/ambiguous-remote-branch", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-AMBIGUOUS", + taskSlug: "ambiguous-remote-branch", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }), + ); + + assert.equal(result._tag, "Failure"); + assert.equal(yield* fileSystem.exists(worktreePath), false); + }), + ); + it.effect("respects simple auto-setup when the local and remote branch names differ", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1725,6 +1783,31 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { allowNonZeroExit: true, }); assert.notEqual(emptyModeUpstream.exitCode, 0); + + yield* git(cwd, ["config", "branch.autoSetupMerge", "off"]); + const offModePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "off-auto-setup"); + yield* driver.createWorktree({ + cwd, + path: offModePath, + refName: `origin/${initialBranch}`, + newRefName: "feature/off-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-OFF", + taskSlug: "off-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + const offModeUpstream = yield* driver.execute({ + operation: "test.offAutoSetup", + args: ["rev-parse", "--abbrev-ref", "@{upstream}"], + cwd: offModePath, + allowNonZeroExit: true, + }); + assert.notEqual(offModeUpstream.exitCode, 0); }), ); @@ -1765,6 +1848,31 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), `origin/${initialBranch}`, ); + + yield* git(cwd, ["config", "branch.autoSetupMerge", "always"]); + const alwaysPath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "always-auto-setup", + ); + yield* driver.createWorktree({ + cwd, + path: alwaysPath, + refName: initialBranch, + newRefName: "feature/always-auto-setup", + materialization: { + requestedProfileId: "full", + expectedContractSha256, + taskId: "OC-ALWAYS", + taskSlug: "always-auto-setup", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }, + }); + assert.equal( + yield* git(alwaysPath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + initialBranch, + ); }), ); @@ -2253,7 +2361,14 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.equal(created.materialization?.effectiveProfileId, "governance-review"); assert.match(created.materialization?.taskCardSha256 ?? "", /^[a-f0-9]{64}$/); - assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, taskCardPath)), true); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, taskCardPath)), + taskCardBytes, + ); + assert.equal( + (yield* driver.verifyWorktreeMaterialization(worktreePath)).effectiveProfileId, + "governance-review", + ); assert.equal(yield* git(worktreePath, ["status", "--porcelain=v1"]), ""); assert.equal( yield* git(cwd, ["config", "--get", "core.excludesFile"]), @@ -2419,12 +2534,18 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { recursive: true, }); yield* fileSystem.writeFileString(defaultExcludePath, "*.xdg-only\n"); + const emptyGlobalConfig = pathService.join(xdgRoot, "empty-global-config"); + yield* fileSystem.writeFileString(emptyGlobalConfig, ""); const previousXdg = process.env.XDG_CONFIG_HOME; + const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; process.env.XDG_CONFIG_HOME = xdgRoot; + process.env.GIT_CONFIG_GLOBAL = emptyGlobalConfig; yield* Effect.addFinalizer(() => Effect.sync(() => { if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previousXdg; + if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig; }), ); const worktreePath = pathService.join( @@ -2529,7 +2650,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const { expectedContractSha256, expectedTaskCardSha256 } = + yield* writeMaterializationFixture(cwd); yield* git(cwd, ["config", "core.autocrlf", "true"]); const pathService = yield* Path.Path; const worktreePath = pathService.join( @@ -2555,6 +2677,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.equal(created.materialization?.effectiveProfileId, "governance-review"); + assert.equal(created.materialization?.taskCardSha256, expectedTaskCardSha256); assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "sparse"); const expanded = yield* driver.expandWorktreeMaterializationFull( worktreePath, @@ -2777,6 +2900,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("falls back to full when requested work is outside the selected profile cone", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: pathService.join(yield* makeTmpDir("git-worktrees-"), "outside-profile-cone"), + refName: initialBranch, + newRefName: "feature/outside-profile-cone", + materialization: { + requestedProfileId: "governance-review", + expectedContractSha256, + taskId: "OC-CONE", + taskSlug: "outside-profile-cone", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["strategies/source.ts"], + taskClasses: ["source-task"], + }, + }); + + assert.equal(created.materialization?.effectiveProfileId, "full"); + assert.equal(created.materialization?.reason, "multi-domain"); + }), + ); + it.effect("falls back to full on a mismatched expected contract hash", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); From 0681f11b486972f43ad31031a4ba9c06fc44845b Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 14:26:51 +0100 Subject: [PATCH 13/31] test(vcs): make materialization fixtures hermetic --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 1dd919c84962..a0b9a9a948d7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1578,6 +1578,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["push", "origin", initialBranch]); yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["config", "branch.autoSetupMerge", "true"]); const pathService = yield* Path.Path; const worktreePath = pathService.join( yield* makeTmpDir("git-worktrees-"), @@ -1626,6 +1627,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["checkout", initialBranch]); yield* git(cwd, ["branch", "-D", "remote-only"]); yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["config", "branch.autoSetupMerge", "true"]); const pathService = yield* Path.Path; const worktreePath = pathService.join( yield* makeTmpDir("git-worktrees-"), @@ -1710,6 +1712,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure.operation, "GitVcsDriver.createWorktree.pinCommit"); + } assert.equal(yield* fileSystem.exists(worktreePath), false); }), ); @@ -1880,7 +1885,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - const { expectedContractSha256 } = yield* writeMaterializationFixture(cwd); const pathService = yield* Path.Path; const projectCwd = pathService.join(cwd, "nested-project"); const fileSystem = yield* FileSystem.FileSystem; @@ -2538,14 +2542,18 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* fileSystem.writeFileString(emptyGlobalConfig, ""); const previousXdg = process.env.XDG_CONFIG_HOME; const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; + const previousNoSystem = process.env.GIT_CONFIG_NOSYSTEM; process.env.XDG_CONFIG_HOME = xdgRoot; process.env.GIT_CONFIG_GLOBAL = emptyGlobalConfig; + process.env.GIT_CONFIG_NOSYSTEM = "1"; yield* Effect.addFinalizer(() => Effect.sync(() => { if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previousXdg; if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL; else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig; + if (previousNoSystem === undefined) delete process.env.GIT_CONFIG_NOSYSTEM; + else process.env.GIT_CONFIG_NOSYSTEM = previousNoSystem; }), ); const worktreePath = pathService.join( @@ -2654,6 +2662,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* writeMaterializationFixture(cwd); yield* git(cwd, ["config", "core.autocrlf", "true"]); const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; const worktreePath = pathService.join( yield* makeTmpDir("git-worktrees-"), "autocrlf-materialization", @@ -2678,6 +2687,16 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.materialization?.effectiveProfileId, "governance-review"); assert.equal(created.materialization?.taskCardSha256, expectedTaskCardSha256); + assert.notEqual( + NodeCrypto.createHash("sha256") + .update( + yield* fileSystem.readFile( + pathService.join(worktreePath, "ops/stef-task/task/stef-task.json"), + ), + ) + .digest("hex"), + expectedTaskCardSha256, + ); assert.equal((yield* driver.verifyWorktreeMaterialization(worktreePath)).mode, "sparse"); const expanded = yield* driver.expandWorktreeMaterializationFull( worktreePath, From f323e178a859f158f2c778b6b1cc71cc491715f8 Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 15:42:29 +0100 Subject: [PATCH 14/31] test(vcs): pin remote and ignore containment --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index a0b9a9a948d7..ec550d0cb332 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1579,6 +1579,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["push", "origin", initialBranch]); yield* git(cwd, ["fetch", "origin"]); yield* git(cwd, ["config", "branch.autoSetupMerge", "true"]); + yield* writeTextFile(cwd, "local-only.txt", "local divergence\n"); + yield* git(cwd, ["add", "local-only.txt"]); + yield* git(cwd, ["commit", "-m", "local divergence"]); const pathService = yield* Path.Path; const worktreePath = pathService.join( yield* makeTmpDir("git-worktrees-"), @@ -1607,6 +1610,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(worktreePath, ["rev-parse", "HEAD^{commit}"]), yield* git(cwd, ["rev-parse", `origin/${initialBranch}^{commit}`]), ); + assert.notEqual( + yield* git(worktreePath, ["rev-parse", "HEAD^{commit}"]), + yield* git(cwd, ["rev-parse", `${initialBranch}^{commit}`]), + ); assert.equal( yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"]), `origin/${initialBranch}`, @@ -2584,6 +2591,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "--get", "core.excludesFile", ]); + assert.notEqual(worktreeExcludePath, defaultExcludePath); + assert.equal(yield* fileSystem.readFileString(defaultExcludePath), "*.xdg-only\n"); assert.equal( yield* fileSystem.readFileString(worktreeExcludePath), `*.xdg-only\n${taskCardPath}\n`, From 9908bd16312fd61bd3b2bf10a73e16403cb0587e Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 16:25:38 +0100 Subject: [PATCH 15/31] test(vcs): isolate contract and symlink cases --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ec550d0cb332..86767974b263 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -157,9 +157,9 @@ const writeMaterializationFixture = Effect.fn("writeMaterializationFixture")(fun }, sharedConePaths: options.invalidSharedValue !== undefined - ? [options.invalidSharedValue] + ? ["config", "ops/stef-task", "ops/build-state", options.invalidSharedValue] : options.invalidSharedPath - ? ["/absolute-cone"] + ? ["config", "ops/stef-task", "ops/build-state", "/absolute-cone"] : options.omitTaskCardCone ? ["config", "ops/build-state"] : ["config", "ops/stef-task", "ops/build-state"], @@ -217,6 +217,7 @@ const logicalWorkingTreeBytes = ( const pathService = yield* Path.Path; const visit = (candidate: string): Effect.Effect => Effect.gen(function* () { + if (Option.isSome(yield* fileSystem.readLink(candidate).pipe(Effect.option))) return 0; const infoOption = yield* fileSystem.stat(candidate).pipe(Effect.option); if (Option.isNone(infoOption)) return 0; const info = infoOption.value; From bd82b214fa77ba6227e9f003556a71c5dd88249b Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 16:34:46 +0100 Subject: [PATCH 16/31] fix(server): fail closed on materialization gaps --- .../Layers/ProviderCommandReactor.ts | 8 +- apps/server/src/server.test.ts | 111 ++++++++++++++++++ apps/server/src/ws.ts | 11 +- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 238f9f6198fe..8ac930498719 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1338,7 +1338,13 @@ const make = Effect.gen(function* () { }); } return true; - }).pipe(Effect.catchCause((cause) => recoverTurnStartFailure(cause).pipe(Effect.as(false)))); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : recoverTurnStartFailure(cause).pipe(Effect.as(false)), + ), + ); if (!materializationReady) return; const isCompactCommand = isCompactCommandMessage(message); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8763e090fb53..529250360ebf 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8962,6 +8962,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(expandWorktreeMaterializationFull.mock.calls.length, 0); assert.equal(dispatchedCommands.length, 0); + threadShell = makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const readySession = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.vcsExpandWorktreeMaterialization]({ cwd: worktreePath, threadId }), + ).pipe(Effect.result), + ); + assertTrue(readySession._tag === "Failure"); + assertTrue(readySession.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(readySession.failure.message, "thread session is active"); + assert.equal(expandWorktreeMaterializationFull.mock.calls.length, 0); + assert.equal(dispatchedCommands.length, 0); + threadShell = makeDefaultOrchestrationThreadShell({ id: threadId, worktreePath, @@ -9364,6 +9388,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("fails bootstrap closed when requested materialization returns no identity", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const materializationRequest = { + requestedProfileId: "governance-review", + expectedContractSha256: "a".repeat(64), + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + } as const; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-missing-materialization", + path: "/tmp/bootstrap-missing-materialization-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { createWorktree }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-missing-materialization"), + threadId: ThreadId.make("thread-bootstrap-missing-materialization"), + message: { + messageId: MessageId.make("msg-bootstrap-missing-materialization"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-missing-materialization", + materialization: materializationRequest, + }, + runSetupScript: false, + }, + createdAt, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "returned no persisted identity"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.delete"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect.each([ { caseName: "the origin remote is missing", hasOrigin: false }, { caseName: "the base branch exists only locally", hasOrigin: true }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ceb701d1efb1..e94baf0257bc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1139,6 +1139,11 @@ const makeWsRpcLayer = ( ? { materialization: bootstrap.prepareWorktree.materialization } : {}), }); + if (bootstrap.prepareWorktree.materialization && !worktree.materialization) { + return yield* new OrchestrationDispatchCommandError({ + message: "Requested worktree materialization returned no persisted identity.", + }); + } targetWorktreePath = worktree.worktree.path; yield* dispatchFromClient({ type: "thread.meta.update", @@ -2448,7 +2453,11 @@ const makeWsRpcLayer = ( "expand-full requires the exact persisted worktree path for the named thread.", }); } - if (thread.session?.status === "starting" || thread.session?.status === "running") { + if ( + thread.session && + thread.session.status !== "stopped" && + thread.session.status !== "error" + ) { return yield* new OrchestrationDispatchCommandError({ message: "expand-full is unavailable while the thread session is active.", }); From 026a19d8ab916b8e60c8428d7efe0edc5ec73646 Mon Sep 17 00:00:00 2001 From: Stef Date: Fri, 4 Sep 2026 17:03:27 +0100 Subject: [PATCH 17/31] fix(web): make sparse profile selection resilient --- .../web/src/components/ChatView.logic.test.ts | 43 +++++++++++++ apps/web/src/components/ChatView.logic.ts | 17 +++-- apps/web/src/components/ChatView.tsx | 63 ++++++++----------- 3 files changed, 75 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e30217b1cd43..9ed27b0ac01b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1281,6 +1281,49 @@ describe("worktree materialization selection", () => { ).toBeUndefined(); }); + it("reads only declared verifier paths and accepts other typed verifier arguments", () => { + const sha = "a".repeat(64); + const materialization = { + requestedProfileId: "governance-review", + expectedContractSha256: sha, + taskId: "OC-1", + taskSlug: "task", + taskCardPath: "ops/stef-task/task/stef-task.json", + scopePaths: ["docs/spec.md"], + taskClasses: ["source-task"], + }; + const withArgs = resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + issue: { id: "OC-1" }, + materialization, + verification: { + status: "declared", + args: { + paths: ["scripts/test/materialization.test.js"], + tags: ["not-a-path"], + timeout: 300, + }, + }, + }), + }); + expect(withArgs?.scopePaths).toEqual(["docs/spec.md", "scripts/test/materialization.test.js"]); + + const withoutArgs = resolveUiWorktreeMaterializationRequest({ + requestedProfileId: "governance-review", + contractSha256: sha, + taskCardPath: "ops/stef-task/task/stef-task.json", + taskCardContents: JSON.stringify({ + issue: { id: "OC-1" }, + materialization, + verification: { status: "declared" }, + }), + }); + expect(withoutArgs?.scopePaths).toEqual(["docs/spec.md"]); + }); + it("builds a schema-valid unclassified sentinel that must fall back full", () => { const sha = "a".repeat(64); expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 113826522296..99fa8a47a3a0 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -110,18 +110,15 @@ export function resolveUiWorktreeMaterializationRequest(input: { }; const declared = card.materialization; const verificationArgs = card.verification?.status === "declared" ? card.verification.args : {}; + const verifierPathsValue = verificationArgs?.paths; const verificationArgsValid = - verificationArgs !== undefined && - verificationArgs !== null && - typeof verificationArgs === "object" && - !Array.isArray(verificationArgs) && - Object.values(verificationArgs).every( - (value) => - Array.isArray(value) && - value.every((candidate) => typeof candidate === "string" && candidate.trim().length > 0), - ); + verifierPathsValue === undefined || + (Array.isArray(verifierPathsValue) && + verifierPathsValue.every( + (candidate) => typeof candidate === "string" && candidate.trim().length > 0, + )); const verifierPaths = verificationArgsValid - ? Object.values(verificationArgs ?? {}).flatMap((value) => value as ReadonlyArray) + ? ((verifierPathsValue ?? []) as ReadonlyArray) : []; const normalizeIssueId = (value: unknown) => String(value ?? "") diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3aef192e5efb..5f188fac17a9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,6 +26,7 @@ import { TerminalOpenInput, FULL_WORKTREE_MATERIALIZATION_STATE, } from "@t3tools/contracts"; +import { sha256 as sha256Bytes } from "@noble/hashes/sha2"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; @@ -267,6 +268,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { useDebouncedValue } from "../state/queries"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, @@ -4946,6 +4948,7 @@ function ChatViewContent(props: ChatViewProps) { const [requestedMaterializationProfileId, setRequestedMaterializationProfileId] = useState("full"); const [materializationTaskCardPath, setMaterializationTaskCardPath] = useState(""); + const debouncedMaterializationTaskCardPath = useDebouncedValue(materializationTaskCardPath, 300); const [materializationExpandPending, setMaterializationExpandPending] = useState(false); useEffect(() => { setRequestedMaterializationProfileId("full"); @@ -4964,12 +4967,14 @@ function ChatViewContent(props: ChatViewProps) { : null, ); const materializationTaskCardQuery = useEnvironmentQuery( - activeProject && envMode === "worktree" && materializationTaskCardPath.trim().length > 0 + activeProject && + envMode === "worktree" && + debouncedMaterializationTaskCardPath.trim().length > 0 ? projectEnvironment.readFile({ environmentId, input: { cwd: activeProject.workspaceRoot, - relativePath: materializationTaskCardPath.trim(), + relativePath: debouncedMaterializationTaskCardPath.trim(), }, }) : null, @@ -4981,46 +4986,16 @@ function ChatViewContent(props: ChatViewProps) { : null, [materializationContractQuery.data], ); - const [materializationContractSha256, setMaterializationContractSha256] = useState( - null, - ); - useEffect(() => { - let cancelled = false; + const materializationContractSha256 = useMemo(() => { const source = materializationContractQuery.data; if (!source || source.truncated || materializationContract === null) { - setMaterializationContractSha256(null); - return () => { - cancelled = true; - }; + return null; } const bytes = new TextEncoder().encode(source.contents); if (bytes.byteLength !== source.byteLength) { - setMaterializationContractSha256(null); - return () => { - cancelled = true; - }; - } - const subtle = globalThis.crypto?.subtle; - if (!subtle) { - setMaterializationContractSha256(null); - return () => { - cancelled = true; - }; + return null; } - void subtle.digest("SHA-256", bytes).then( - (digest) => { - if (cancelled) return; - setMaterializationContractSha256( - [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""), - ); - }, - () => { - if (!cancelled) setMaterializationContractSha256(null); - }, - ); - return () => { - cancelled = true; - }; + return [...sha256Bytes(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); }, [materializationContract, materializationContractQuery.data]); useEffect(() => { if ( @@ -5033,18 +5008,28 @@ function ChatViewContent(props: ChatViewProps) { } }, [materializationContract, requestedMaterializationProfileId]); const requestedWorktreeMaterialization = useMemo(() => { + const taskCardPathMatches = + debouncedMaterializationTaskCardPath.trim() === materializationTaskCardPath.trim(); return buildUiWorktreeMaterializationRequest({ requestedProfileId: requestedMaterializationProfileId, contractSha256: materializationContractSha256, - taskCardContents: materializationTaskCardQuery.data?.contents ?? null, + taskCardContents: taskCardPathMatches + ? (materializationTaskCardQuery.data?.contents ?? null) + : null, taskCardPath: materializationTaskCardPath, }); }, [ materializationContractSha256, + debouncedMaterializationTaskCardPath, materializationTaskCardPath, materializationTaskCardQuery.data?.contents, requestedMaterializationProfileId, ]); + const materializationTaskCardReadPending = + requestedMaterializationProfileId !== "full" && + materializationTaskCardPath.trim().length > 0 && + (debouncedMaterializationTaskCardPath.trim() !== materializationTaskCardPath.trim() || + materializationTaskCardQuery.isPending); const activeMaterializationPresentation = useMemo( () => activeThread @@ -8156,7 +8141,7 @@ function ChatViewContent(props: ChatViewProps) {