mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
3 files changed
+639
-9
No files matched your search
@@ -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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 : "<detached>";
|
||||
}
|
||||
@@ -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<GitWorktreeBranchCoherenceResult> {
|
||||
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<string, unknown>;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
heartbeatRunId?: string | null;
|
||||
enableWorkspaceBranchReconcileForward?: boolean;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}): Promise<RealizedExecutionWorkspace> {
|
||||
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<RealizedExecutionWorkspace | null> {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user