From d3fd589c1e5e258ae5f77ebd13a36cb0746dd5e8 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 7 Jul 2026 15:42:27 +0000 Subject: [PATCH] Add workspace branch lineage regression Reconcile forward git-worktree branch records inside the heartbeat runtime when the experimental flag is enabled, and carry the reconciled branch through realization, persisted reuse, and finalization. Add an embedded-Postgres regression covering a parent run that moves a shared execution workspace branch forward, a git-handoff child that reuses the reconciled workspace without a second reconcile, and an isolated sibling workspace record that remains untouched. Co-Authored-By: Paperclip --- ...tbeat-workspace-branch-containment.test.ts | 365 ++++++++++++++++++ server/src/services/heartbeat.ts | 21 +- server/src/services/workspace-runtime.ts | 262 ++++++++++++- 3 files changed, 639 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index 31d480722aa..6e6f449b527 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -83,6 +83,10 @@ async function runGit(cwd: string, args: string[]) { await execFileAsync("git", args, { cwd }); } +async function readGit(cwd: string, args: string[]) { + return (await execFileAsync("git", args, { cwd })).stdout.trim(); +} + async function createGitRepo() { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-branch-containment-repo-")); await runGit(repoRoot, ["init"]); @@ -231,6 +235,21 @@ function readAdapterWorkspace(input: unknown) { return { cwd, branchName, executionWorkspaceId }; } +async function wakeIssue(heartbeat: Heartbeat, agentId: string, issueId: string) { + return heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId }, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_commented", + skipIssueComment: true, + }, + }); +} + async function seedBranchContainmentRun(db: Db, repoRoot: string, callSite: BranchContainmentCallSite) { const companyId = randomUUID(); const projectId = randomUUID(); @@ -490,6 +509,179 @@ async function seedBranchContainmentRun(db: Db, repoRoot: string, callSite: Bran }; } +async function seedLineagePropagationRun(db: Db, repoRoot: string) { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const agentId = randomUUID(); + const parentIssueId = randomUUID(); + const childIssueId = randomUUID(); + const isolatedSiblingIssueId = randomUUID(); + const isolatedSiblingExecutionWorkspaceId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const parentIdentifier = `${issuePrefix}-1`; + const childIdentifier = `${issuePrefix}-2`; + const isolatedSiblingIdentifier = `${issuePrefix}-3`; + const isolatedSiblingBranch = `${isolatedSiblingIdentifier}-recorded`; + const now = new Date("2026-07-07T00:00:00.000Z"); + + await instanceSettingsService(db).updateExperimental({ + enableIsolatedWorkspaces: true, + enableWorkspaceBranchReconcileForward: true, + }); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + issuePrefix, + status: "active", + defaultResponsibleUserId: "responsible-user", + createdAt: now, + updatedAt: now, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Lineage propagation", + status: "active", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "isolated_workspace", + workspaceStrategy: { + type: "git_worktree", + baseRef: "HEAD", + branchTemplate: "{{issue.identifier}}-recorded", + }, + }, + createdAt: now, + updatedAt: now, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + cwd: repoRoot, + isPrimary: true, + createdAt: now, + updatedAt: now, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + createdAt: now, + updatedAt: now, + }); + await db.insert(executionWorkspaces).values({ + id: isolatedSiblingExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + sourceIssueId: null, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: isolatedSiblingBranch, + status: "active", + cwd: path.join(repoRoot, ".paperclip", "worktrees", isolatedSiblingBranch), + repoUrl: null, + baseRef: "HEAD", + branchName: isolatedSiblingBranch, + providerType: "git_worktree", + providerRef: path.join(repoRoot, ".paperclip", "worktrees", isolatedSiblingBranch), + lastUsedAt: now, + openedAt: now, + createdAt: now, + updatedAt: now, + }); + await db.insert(issues).values([ + { + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Parent branch reconciliation source", + status: "in_progress", + workMode: "standard", + priority: "medium", + assigneeAgentId: agentId, + responsibleUserId: "responsible-user", + issueNumber: 1, + identifier: parentIdentifier, + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + startedAt: now, + createdAt: now, + updatedAt: now, + }, + { + id: childIssueId, + companyId, + projectId, + projectWorkspaceId, + parentId: parentIssueId, + title: "Git handoff child", + status: "todo", + workMode: "standard", + priority: "medium", + assigneeAgentId: agentId, + responsibleUserId: "responsible-user", + issueNumber: 2, + identifier: childIdentifier, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + createdAt: now, + updatedAt: now, + }, + { + id: isolatedSiblingIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Isolated sibling", + status: "todo", + workMode: "standard", + priority: "medium", + assigneeAgentId: agentId, + responsibleUserId: "responsible-user", + issueNumber: 3, + identifier: isolatedSiblingIdentifier, + executionWorkspaceId: isolatedSiblingExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + createdAt: now, + updatedAt: now, + }, + ]); + + return { + companyId, + agentId, + parentIssueId, + childIssueId, + isolatedSiblingIssueId, + isolatedSiblingExecutionWorkspaceId, + parentIdentifier, + childIdentifier, + isolatedSiblingBranch, + }; +} + function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -722,4 +914,177 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { }); expect(adapterExecute).toHaveBeenCalledTimes(callSite === "finalize" ? 1 : 0); }, 30_000); + + it("propagates a reconciled parent execution workspace branch to a git-handoff child without reuse clobbering the record", async () => { + const repoRoot = await createGitRepo(); + tempRoots.push(repoRoot); + const seeded = await seedLineagePropagationRun(db, repoRoot); + const heartbeat = heartbeatService(db); + let sharedExecutionWorkspaceId: string | null = null; + let worktreePath: string | null = null; + let recordedBranch: string | null = null; + let reconciledBranch: string | null = null; + let reconciledHead: string | null = null; + const childTemplateBranch = `${seeded.childIdentifier}-recorded`; + + adapterExecute.mockImplementationOnce(async (adapterInput) => { + const workspace = readAdapterWorkspace(adapterInput); + sharedExecutionWorkspaceId = workspace.executionWorkspaceId; + worktreePath = workspace.cwd; + recordedBranch = workspace.branchName; + reconciledBranch = `${workspace.branchName.replace(/-recorded$/, "")}-actual`; + + await db + .update(issues) + .set({ + executionWorkspaceId: workspace.executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + updatedAt: new Date(), + }) + .where(eq(issues.id, seeded.childIssueId)); + + await runGit(workspace.cwd, ["checkout", "-b", reconciledBranch]); + await writeFile(path.join(workspace.cwd, "parent-forward.txt"), "parent moved branch forward\n", "utf8"); + await runGit(workspace.cwd, ["add", "parent-forward.txt"]); + await runGit(workspace.cwd, ["commit", "-m", "Move parent workspace branch forward"]); + reconciledHead = await readGit(workspace.cwd, ["rev-parse", "HEAD"]); + + await db + .update(issues) + .set({ + status: "done", + completedAt: new Date(), + checkoutRunId: null, + executionRunId: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, seeded.parentIssueId)); + + return { + exitCode: 0, + signal: null, + timedOut: false, + summary: "Parent completed after moving the workspace branch forward.", + provider: "test", + model: "test-model", + }; + }); + + const parentRun = await wakeIssue(heartbeat, seeded.agentId, seeded.parentIssueId); + expect(parentRun).not.toBeNull(); + const finishedParentRun = await waitForRunToFinish(heartbeat, parentRun!.id); + expect(finishedParentRun).toMatchObject({ + status: "succeeded", + errorCode: null, + }); + expect(sharedExecutionWorkspaceId).toEqual(expect.any(String)); + expect(worktreePath).toEqual(expect.any(String)); + expect(recordedBranch).toBe(`${seeded.parentIdentifier}-recorded`); + expect(reconciledBranch).toBe(`${seeded.parentIdentifier}-actual`); + expect(reconciledHead).toEqual(expect.stringMatching(/^[a-f0-9]{40}$/)); + + const [workspaceAfterParent] = await db + .select({ + name: executionWorkspaces.name, + branchName: executionWorkspaces.branchName, + providerRef: executionWorkspaces.providerRef, + }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, sharedExecutionWorkspaceId!)); + expect(workspaceAfterParent).toMatchObject({ + name: reconciledBranch, + branchName: reconciledBranch, + providerRef: worktreePath, + }); + + const [isolatedSiblingWorkspaceAfterParent] = await db + .select({ branchName: executionWorkspaces.branchName }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, seeded.isolatedSiblingExecutionWorkspaceId)); + expect(isolatedSiblingWorkspaceAfterParent?.branchName).toBe(seeded.isolatedSiblingBranch); + + const parentOperations = await db + .select() + .from(workspaceOperations) + .where(eq(workspaceOperations.heartbeatRunId, parentRun!.id)); + expect(parentOperations.filter((operation) => + asRecord(operation.metadata).branchIncoherenceReconcileForward === true + )).toHaveLength(1); + + const branchReconcileComments = await readContainmentComments(db, [seeded.parentIssueId]); + expect(branchReconcileComments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorType: "system", + body: expect.stringContaining("Execution workspace branch reconciled."), + }), + ]), + ); + + adapterExecute.mockImplementationOnce(async (adapterInput) => { + const workspace = readAdapterWorkspace(adapterInput); + expect(workspace.executionWorkspaceId).toBe(sharedExecutionWorkspaceId); + expect(workspace.cwd).toBe(worktreePath); + expect(workspace.branchName).toBe(reconciledBranch); + await expect(readGit(workspace.cwd, ["branch", "--show-current"])).resolves.toBe(reconciledBranch); + await expect(readGit(workspace.cwd, ["rev-parse", "HEAD"])).resolves.toBe(reconciledHead); + + await db + .update(issues) + .set({ + status: "done", + completedAt: new Date(), + checkoutRunId: null, + executionRunId: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, seeded.childIssueId)); + + return { + exitCode: 0, + signal: null, + timedOut: false, + summary: "Git handoff child reused the reconciled workspace.", + provider: "test", + model: "test-model", + }; + }); + + const childRun = await wakeIssue(heartbeat, seeded.agentId, seeded.childIssueId); + expect(childRun).not.toBeNull(); + const finishedChildRun = await waitForRunToFinish(heartbeat, childRun!.id); + expect(finishedChildRun).toMatchObject({ + status: "succeeded", + errorCode: null, + }); + + const [workspaceAfterChild] = await db + .select({ + name: executionWorkspaces.name, + branchName: executionWorkspaces.branchName, + }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, sharedExecutionWorkspaceId!)); + expect(workspaceAfterChild).toMatchObject({ + name: reconciledBranch, + branchName: reconciledBranch, + }); + expect(workspaceAfterChild?.branchName).not.toBe(childTemplateBranch); + + const childOperations = await db + .select() + .from(workspaceOperations) + .where(eq(workspaceOperations.heartbeatRunId, childRun!.id)); + expect(childOperations.filter((operation) => + asRecord(operation.metadata).branchIncoherenceReconcileForward === true + )).toHaveLength(0); + + const [isolatedSiblingWorkspaceAfterChild] = await db + .select({ branchName: executionWorkspaces.branchName }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, seeded.isolatedSiblingExecutionWorkspaceId)); + expect(isolatedSiblingWorkspaceAfterChild?.branchName).toBe(seeded.isolatedSiblingBranch); + expect(adapterExecute).toHaveBeenCalledTimes(2); + }, 30_000); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6f420da72db..4573b5bd4c6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10779,6 +10779,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) workspaceConfigFreshness, restoreExistingWorkspace: reusableExistingExecutionWorkspace ? () => ensurePersistedExecutionWorkspaceAvailable({ + db, base: executionWorkspaceBase, workspace: { id: reusableExistingExecutionWorkspace.id, @@ -10806,10 +10807,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) name: agent.name, companyId: agent.companyId, }, + heartbeatRunId: run.id, + enableWorkspaceBranchReconcileForward: + resolvedInstanceSettings.experimental.enableWorkspaceBranchReconcileForward, recorder: workspaceOperationRecorder, }) : null, realizeWorkspace: () => realizeExecutionWorkspace({ + db, base: executionWorkspaceBase, config: hostExecutionWorkspaceConfig, issue: issueRef, @@ -10818,6 +10823,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) name: agent.name, companyId: agent.companyId, }, + heartbeatRunId: run.id, + enableWorkspaceBranchReconcileForward: + resolvedInstanceSettings.experimental.enableWorkspaceBranchReconcileForward, recorder: workspaceOperationRecorder, }), }); @@ -11568,8 +11576,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) let inspection = branchInspection.inspection; const initialManagedGitWorktreeBranch = formatManagedGitWorktreeBranchInspection(inspection); if (!inspection.valid && inspection.reasonCode === "branch_mismatch" && inspection.repoRoot) { + let reconciledBranchName: string | null = null; try { - await ensureGitWorktreeBranchCoherent({ + const coherence = await ensureGitWorktreeBranchCoherent({ + db, repoRoot: inspection.repoRoot, worktreePath: inspection.worktreePath, expectedBranchName: inspection.expectedBranchName, @@ -11583,8 +11593,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } : null, executionWorkspaceId: branchInspection.workspaceRecord.id, + heartbeatRunId: run.id, + enableWorkspaceBranchReconcileForward: + resolvedInstanceSettings.experimental.enableWorkspaceBranchReconcileForward, + reconcileOperationPhase: "workspace_finalize", recorder: workspaceOperationRecorder, }); + if (coherence.reconciledForward && coherence.branchName) { + reconciledBranchName = coherence.branchName; + } } catch (repairErr) { const workspaceValidationFailure = isWorkspaceValidationFailure(repairErr) ? repairErr : null; finalizeBranchMetadata = { @@ -11621,7 +11638,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const repairedInspection = await inspectManagedGitWorktreeBranch({ worktreePath: inspection.worktreePath, - expectedBranchName: inspection.expectedBranchName, + expectedBranchName: reconciledBranchName ?? inspection.expectedBranchName, repoRoot: inspection.repoRoot, }); finalizeBranchRepairMetadata = { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 14af9af6b0a..62f42dc2344 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; import type { Db } from "@paperclipai/db"; -import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; +import { executionWorkspaces, issueComments, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import { listWorkspaceServiceCommandDefinitions, type GitWorktreeBranchAncestryVerdict, @@ -28,6 +28,7 @@ import { touchLocalServiceRegistryRecord, writeLocalServiceRegistryRecord, } from "./local-service-supervisor.js"; +import { logActivity } from "./activity-log.js"; import type { WorkspaceOperationRecorder } from "./workspace-operations.js"; import { readExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; @@ -659,6 +660,11 @@ type GitWorktreeCleanliness = SharedGitWorktreeBranchIncoherenceEvidence["cleanl type GitWorktreeBranchIncoherenceEvidence = SharedGitWorktreeBranchIncoherenceEvidence; +type GitWorktreeBranchCoherenceResult = { + branchName: string | null; + reconciledForward: boolean; +}; + function formatBranchForMessage(branch: string | null | undefined) { return branch && branch.length > 0 ? branch : ""; } @@ -845,22 +851,181 @@ function branchIncoherenceValidationFailure(evidence: GitWorktreeBranchIncoheren ); } +async function recordForwardBranchReconcileOperation(input: { + recorder?: WorkspaceOperationRecorder | null; + phase?: "worktree_prepare" | "workspace_finalize"; + cwd: string; + repoRoot: string; + worktreePath: string; + expectedBranchName: string; + actualBranchName: string; + executionWorkspaceId: string | null; + sourceIssueId: string | null; + fingerprint: string; + expectedHeadSha: string | null; + actualHeadSha: string | null; + ancestryVerdict: GitWorktreeBranchAncestryVerdict; + mode: "record_updated" | "adopt_for_realize"; + auditCommentId?: string | null; +}) { + if (!input.recorder) return; + + await input.recorder.recordOperation({ + phase: input.phase ?? "worktree_prepare", + cwd: input.cwd, + metadata: { + repoRoot: input.repoRoot, + worktreePath: input.worktreePath, + expectedBranchName: input.expectedBranchName, + actualBranchName: input.actualBranchName, + branchIncoherenceReconcileForward: true, + reconcileMode: input.mode, + fingerprint: input.fingerprint, + sourceIssueId: input.sourceIssueId, + executionWorkspaceId: input.executionWorkspaceId, + expectedHeadSha: input.expectedHeadSha, + actualHeadSha: input.actualHeadSha, + ancestryVerdict: input.ancestryVerdict, + auditCommentId: input.auditCommentId ?? null, + }, + run: async () => ({ + status: "succeeded", + system: + input.mode === "record_updated" + ? `Reconciled execution workspace branch record from ${input.expectedBranchName} to ${input.actualBranchName}; worktree left unchanged.\n` + : `Adopted live git worktree branch ${input.actualBranchName} for this execution workspace realization; worktree left unchanged.\n`, + }), + }); +} + +function formatForwardBranchReconcileComment(input: { + workspaceId: string; + evidence: GitWorktreeBranchIncoherenceEvidence; + actualBranchName: string; +}) { + return [ + "Execution workspace branch reconciled.", + "", + `- Workspace: \`${input.workspaceId}\``, + "- Mode: `forward`", + `- From branch: \`${input.evidence.expectedBranch}\``, + `- To branch: \`${input.actualBranchName}\``, + `- From SHA: \`${input.evidence.provenance.expectedHeadSha ?? "unknown"}\``, + `- To SHA: \`${input.evidence.provenance.actualHeadSha ?? "unknown"}\``, + `- Verdict: \`${input.evidence.provenance.ancestryVerdict}\``, + `- Fingerprint: \`${input.evidence.fingerprint}\``, + "- Operator reason: Automatic forward reconciliation: recorded branch is an ancestor of the checked-out branch.", + ].join("\n"); +} + +async function reconcileForwardPersistedExecutionWorkspaceBranch(input: { + db: Db; + executionWorkspaceId: string; + evidence: GitWorktreeBranchIncoherenceEvidence; + actualBranchName: string; + heartbeatRunId?: string | null; +}) { + const existing = await input.db + .select() + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, input.executionWorkspaceId)) + .then((rows) => rows[0] ?? null); + if (!existing) { + throw new Error(`Execution workspace ${input.executionWorkspaceId} not found for branch reconciliation`); + } + + const recordedBranch = existing.branchName?.trim() || null; + if (recordedBranch && recordedBranch !== input.evidence.expectedBranch) { + if (recordedBranch === input.actualBranchName) { + return { branchName: input.actualBranchName, auditCommentId: null }; + } + throw new Error( + `Execution workspace ${input.executionWorkspaceId} branch changed from "${input.evidence.expectedBranch}" to "${recordedBranch}" before reconciliation`, + ); + } + + const now = new Date(); + await input.db + .update(executionWorkspaces) + .set({ + branchName: input.actualBranchName, + ...(existing.name === input.evidence.expectedBranch ? { name: input.actualBranchName } : {}), + updatedAt: now, + }) + .where(eq(executionWorkspaces.id, existing.id)); + + const auditComment = existing.sourceIssueId + ? await input.db + .insert(issueComments) + .values({ + companyId: existing.companyId, + issueId: existing.sourceIssueId, + authorAgentId: null, + authorUserId: null, + authorType: "system", + createdByRunId: input.heartbeatRunId ?? null, + body: formatForwardBranchReconcileComment({ + workspaceId: existing.id, + evidence: input.evidence, + actualBranchName: input.actualBranchName, + }), + }) + .returning({ id: issueComments.id }) + .then((rows) => rows[0] ?? null) + : null; + + await logActivity(input.db, { + companyId: existing.companyId, + actorType: "system", + actorId: "workspace_runtime", + runId: input.heartbeatRunId ?? null, + action: "execution_workspace.branch_reconciled", + entityType: "execution_workspace", + entityId: existing.id, + details: { + mode: "forward", + reason: "Automatic forward reconciliation: recorded branch is an ancestor of the checked-out branch.", + fromBranch: input.evidence.expectedBranch, + toBranch: input.actualBranchName, + fromSha: input.evidence.provenance.expectedHeadSha, + toSha: input.evidence.provenance.actualHeadSha, + ancestryVerdict: input.evidence.provenance.ancestryVerdict, + fingerprint: input.evidence.fingerprint, + sourceIssueId: existing.sourceIssueId ?? input.evidence.sourceIssueId, + auditCommentId: auditComment?.id ?? null, + actor: { + type: "system", + id: "workspace_runtime", + source: "workspace_runtime", + }, + }, + }); + + return { branchName: input.actualBranchName, auditCommentId: auditComment?.id ?? null }; +} + export async function ensureGitWorktreeBranchCoherent(input: { + db?: Db | null; repoRoot: string; worktreePath: string; expectedBranchName: string | null; sourceIssue: ExecutionWorkspaceIssueRef | null; executionWorkspaceId?: string | null; actualBranchName?: string | null; + heartbeatRunId?: string | null; + enableWorkspaceBranchReconcileForward?: boolean; + reconcileOperationPhase?: "worktree_prepare" | "workspace_finalize"; recorder?: WorkspaceOperationRecorder | null; -}) { +}): Promise { const expectedBranchName = input.expectedBranchName?.trim(); - if (!expectedBranchName) return; + if (!expectedBranchName) return { branchName: null, reconciledForward: false }; const currentBranch = input.actualBranchName !== undefined ? input.actualBranchName : await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath).catch(() => null); - if (currentBranch === expectedBranchName) return; + if (currentBranch === expectedBranchName) { + return { branchName: expectedBranchName, reconciledForward: false }; + } const evidence = await inspectGitWorktreeBranchIncoherence({ repoRoot: input.repoRoot, @@ -871,6 +1036,68 @@ export async function ensureGitWorktreeBranchCoherent(input: { executionWorkspaceId: input.executionWorkspaceId ?? null, }); + if ( + input.enableWorkspaceBranchReconcileForward === true && + evidence.provenance.ancestryVerdict === "ancestor" && + currentBranch + ) { + if (input.executionWorkspaceId) { + if (!input.db) { + evidence.safeRepair.reason = "forward reconciliation requires database access to update the execution workspace record"; + throw branchIncoherenceValidationFailure(evidence); + } + try { + const result = await reconcileForwardPersistedExecutionWorkspaceBranch({ + db: input.db, + executionWorkspaceId: input.executionWorkspaceId, + evidence, + actualBranchName: currentBranch, + heartbeatRunId: input.heartbeatRunId ?? null, + }); + await recordForwardBranchReconcileOperation({ + recorder: input.recorder, + phase: input.reconcileOperationPhase, + cwd: input.worktreePath, + repoRoot: input.repoRoot, + worktreePath: evidence.worktreePath, + expectedBranchName, + actualBranchName: currentBranch, + executionWorkspaceId: input.executionWorkspaceId, + sourceIssueId: evidence.sourceIssueId, + fingerprint: evidence.fingerprint, + expectedHeadSha: evidence.provenance.expectedHeadSha, + actualHeadSha: evidence.provenance.actualHeadSha, + ancestryVerdict: evidence.provenance.ancestryVerdict, + mode: "record_updated", + auditCommentId: result.auditCommentId, + }); + return { branchName: result.branchName, reconciledForward: true }; + } catch (error) { + evidence.safeRepair.reason = + `forward reconciliation failed: ${error instanceof Error ? error.message : String(error)}`; + throw branchIncoherenceValidationFailure(evidence); + } + } + + await recordForwardBranchReconcileOperation({ + recorder: input.recorder, + phase: input.reconcileOperationPhase, + cwd: input.worktreePath, + repoRoot: input.repoRoot, + worktreePath: evidence.worktreePath, + expectedBranchName, + actualBranchName: currentBranch, + executionWorkspaceId: null, + sourceIssueId: evidence.sourceIssueId, + fingerprint: evidence.fingerprint, + expectedHeadSha: evidence.provenance.expectedHeadSha, + actualHeadSha: evidence.provenance.actualHeadSha, + ancestryVerdict: evidence.provenance.ancestryVerdict, + mode: "adopt_for_realize", + }); + return { branchName: currentBranch, reconciledForward: true }; + } + if (!evidence.safeRepair.eligible) { throw branchIncoherenceValidationFailure(evidence); } @@ -910,6 +1137,7 @@ export async function ensureGitWorktreeBranchCoherent(input: { evidence.safeRepair.succeeded = true; evidence.safeRepair.reason = "clean worktree checked out the recorded branch"; + return { branchName: expectedBranchName, reconciledForward: false }; } // Resolve the authoritative base ref for a fresh worktree. A configured local @@ -1603,10 +1831,13 @@ async function resolveGitRepoRootForWorkspaceCleanup( } export async function realizeExecutionWorkspace(input: { + db?: Db | null; base: ExecutionWorkspaceInput; config: Record; issue: ExecutionWorkspaceIssueRef | null; agent: ExecutionWorkspaceAgentRef; + heartbeatRunId?: string | null; + enableWorkspaceBranchReconcileForward?: boolean; recorder?: WorkspaceOperationRecorder | null; }): Promise { const rawStrategy = parseObject(input.config.workspaceStrategy); @@ -1632,7 +1863,7 @@ export async function realizeExecutionWorkspace(input: { projectId: input.base.projectId, repoRef: input.base.repoRef, }); - const branchName = sanitizeBranchName(renderedBranch); + let branchName = sanitizeBranchName(renderedBranch); const configuredParentDir = asString(rawStrategy.worktreeParentDir, ""); const worktreeParentDir = configuredParentDir ? resolveConfiguredPath(configuredParentDir, repoRoot) @@ -1725,15 +1956,22 @@ export async function realizeExecutionWorkspace(input: { expectedBranchName: branchName, }).catch(() => null); if (validation && !validation.valid && validation.reasonCode === "branch_mismatch") { - await ensureGitWorktreeBranchCoherent({ + const coherence = await ensureGitWorktreeBranchCoherent({ + db: input.db ?? null, repoRoot, worktreePath: reusablePath, expectedBranchName: branchName, actualBranchName: validation.actualBranchName ?? null, sourceIssue: input.issue, executionWorkspaceId: null, + heartbeatRunId: input.heartbeatRunId ?? null, + enableWorkspaceBranchReconcileForward: input.enableWorkspaceBranchReconcileForward === true, + reconcileOperationPhase: "worktree_prepare", recorder: input.recorder ?? null, }); + if (coherence.reconciledForward && coherence.branchName) { + branchName = coherence.branchName; + } return await validateLinkedGitWorktree({ repoRoot, worktreePath: reusablePath, @@ -1837,6 +2075,7 @@ export async function realizeExecutionWorkspace(input: { } export async function ensurePersistedExecutionWorkspaceAvailable(input: { + db?: Db | null; base: ExecutionWorkspaceInput; workspace: { id?: string | null; @@ -1856,6 +2095,8 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { }; issue: ExecutionWorkspaceIssueRef | null; agent: ExecutionWorkspaceAgentRef; + heartbeatRunId?: string | null; + enableWorkspaceBranchReconcileForward?: boolean; recorder?: WorkspaceOperationRecorder | null; }): Promise { const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim(); @@ -1891,14 +2132,21 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null; const reuseWorktreePath = realized.worktreePath ?? cwd; if (await isGitCheckout(reuseWorktreePath)) { - await ensureGitWorktreeBranchCoherent({ + const coherence = await ensureGitWorktreeBranchCoherent({ + db: input.db ?? null, repoRoot, worktreePath: reuseWorktreePath, expectedBranchName: realized.branchName, sourceIssue: input.issue, executionWorkspaceId: input.workspace.id ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + enableWorkspaceBranchReconcileForward: input.enableWorkspaceBranchReconcileForward === true, + reconcileOperationPhase: "worktree_prepare", recorder: input.recorder ?? null, }); + if (coherence.reconciledForward && coherence.branchName) { + realized.branchName = coherence.branchName; + } } const validation = await validateLinkedGitWorktree({ repoRoot,