mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +01:00
Preserve git handoff refs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
187a929d97
commit
83a8ec9150
7 files changed
+1050
-62
No files matched your search
@@ -27,6 +27,7 @@ import {
|
||||
mergeExecutionWorkspaceConfig,
|
||||
readExecutionWorkspaceConfig,
|
||||
} from "../services/execution-workspaces.ts";
|
||||
import { buildGitHandoffRefName } from "../services/git-handoff-refs.ts";
|
||||
import {
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
@@ -317,6 +318,79 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("blocks closing an isolated workspace while durable Git handoff refs are recorded", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const durableRef = buildGitHandoffRefName({ issueId, runId, sha });
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
sourceIssueId: null,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-1942",
|
||||
status: "idle",
|
||||
providerType: "git_worktree",
|
||||
cwd: "/tmp/paperclip-handoff-workspace",
|
||||
providerRef: "/tmp/paperclip-handoff-workspace",
|
||||
branchName: "PAP-1942",
|
||||
baseRef: "main",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
gitHandoffRefs: [{
|
||||
version: 1,
|
||||
ref: durableRef,
|
||||
sha,
|
||||
shortSha: sha.slice(0, 12),
|
||||
issueId,
|
||||
issueIdentifier: "PAP-1942",
|
||||
executionWorkspaceId,
|
||||
runId,
|
||||
branchName: "PAP-1942",
|
||||
baseRef: "main",
|
||||
relatedIssueIds: [issueId],
|
||||
workProductIds: [],
|
||||
signalKinds: ["issue_handoff_block"],
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
|
||||
|
||||
expect(readiness).toMatchObject({
|
||||
workspaceId: executionWorkspaceId,
|
||||
state: "blocked",
|
||||
isDestructiveCloseAllowed: false,
|
||||
});
|
||||
expect(readiness?.blockingReasons).toEqual([
|
||||
expect.stringContaining("durable Git handoff ref"),
|
||||
]);
|
||||
expect(readiness?.blockingReasons[0]).toContain(durableRef);
|
||||
});
|
||||
|
||||
it("clears matching environment selections transactionally without touching other workspaces", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { readGitHandoffRefs } from "../services/git-handoff-refs.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -81,6 +82,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-finalize-branch-repo-"));
|
||||
await runGit(repoRoot, ["init"]);
|
||||
@@ -303,6 +308,107 @@ describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () =>
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("preserves finalized HEAD under a durable Git handoff ref when a pending handoff issue exists", async () => {
|
||||
const repoRoot = await createGitRepo();
|
||||
tempRoots.push(repoRoot);
|
||||
const { agentId, companyId, projectId, projectWorkspaceId, issueId } = await seedRunTarget(db, repoRoot);
|
||||
const handoffIssueId = randomUUID();
|
||||
let executionWorkspaceId: string | null = null;
|
||||
let handoffSha: string | null = null;
|
||||
|
||||
adapterExecute.mockImplementationOnce(async (input) => {
|
||||
const workspace = readAdapterWorkspace(input);
|
||||
executionWorkspaceId = workspace.executionWorkspaceId;
|
||||
await writeFile(path.join(workspace.cwd, "handoff.txt"), "local handoff commit\n", "utf8");
|
||||
await runGit(workspace.cwd, ["add", "handoff.txt"]);
|
||||
await runGit(workspace.cwd, ["commit", "-m", "Add local handoff commit"]);
|
||||
handoffSha = await readGit(workspace.cwd, ["rev-parse", "HEAD"]);
|
||||
await db.insert(issues).values({
|
||||
id: handoffIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
parentId: issueId,
|
||||
title: "Push local handoff commit",
|
||||
description: [
|
||||
"## Git handoff block",
|
||||
"- Repo: paperclipai/paperclip",
|
||||
`- Branch HEAD at submit: ${handoffSha}`,
|
||||
"- Requested GitHub operation: push branch",
|
||||
].join("\n"),
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "high",
|
||||
assigneeAgentId: agentId,
|
||||
identifier: `PAP-${handoffIssueId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
executionWorkspaceId: workspace.executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
summary: "Adapter completed after creating a Git handoff issue.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
};
|
||||
});
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
const run = await wakeIssue(heartbeat, agentId, issueId);
|
||||
expect(run).not.toBeNull();
|
||||
|
||||
const finishedRun = await waitForRunToFinish(heartbeat, run!.id);
|
||||
expect(finishedRun).toMatchObject({
|
||||
status: "succeeded",
|
||||
errorCode: null,
|
||||
error: null,
|
||||
});
|
||||
await waitForRuntimeStateLastRun(db, agentId, run!.id);
|
||||
expect(adapterExecute).toHaveBeenCalledTimes(1);
|
||||
expect(handoffSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
|
||||
const workspace = await db
|
||||
.select({ metadata: executionWorkspaces.metadata })
|
||||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, executionWorkspaceId!))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const refs = readGitHandoffRefs(workspace?.metadata as Record<string, unknown> | null);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0]).toMatchObject({
|
||||
sha: handoffSha,
|
||||
issueId,
|
||||
issueIdentifier: expect.any(String),
|
||||
executionWorkspaceId,
|
||||
runId: run!.id,
|
||||
relatedIssueIds: expect.arrayContaining([handoffIssueId]),
|
||||
signalKinds: expect.arrayContaining(["issue_handoff_block"]),
|
||||
});
|
||||
await expect(readGit(repoRoot, ["rev-parse", "--verify", `${refs[0]!.ref}^{commit}`]))
|
||||
.resolves.toBe(handoffSha);
|
||||
|
||||
const finalizeOps = await listFinalizeOperations(db, run!.id);
|
||||
expect(finalizeOps).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
metadata: expect.objectContaining({
|
||||
durableGitHandoffRef: expect.objectContaining({
|
||||
ref: refs[0]!.ref,
|
||||
sha: handoffSha,
|
||||
relatedIssueIds: expect.arrayContaining([handoffIssueId]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
}, 20_000);
|
||||
|
||||
it("repairs clean unrecorded branch drift before recording workspace finalization", async () => {
|
||||
const repoRoot = await createGitRepo();
|
||||
tempRoots.push(repoRoot);
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildGitHandoffRefName, type GitHandoffRefMetadata } from "../services/git-handoff-refs.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -122,6 +123,40 @@ async function createTempRepo(defaultBranch = "main") {
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function gitHandoffRefRecord(input: {
|
||||
issueId: string;
|
||||
issueIdentifier?: string | null;
|
||||
executionWorkspaceId: string;
|
||||
runId: string;
|
||||
branchName: string;
|
||||
baseRef?: string | null;
|
||||
sha: string;
|
||||
relatedIssueIds?: string[];
|
||||
workProductIds?: string[];
|
||||
}): GitHandoffRefMetadata {
|
||||
return {
|
||||
version: 1,
|
||||
ref: buildGitHandoffRefName({
|
||||
issueId: input.issueId,
|
||||
runId: input.runId,
|
||||
sha: input.sha,
|
||||
}),
|
||||
sha: input.sha,
|
||||
shortSha: input.sha.slice(0, 12),
|
||||
issueId: input.issueId,
|
||||
issueIdentifier: input.issueIdentifier ?? null,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
runId: input.runId,
|
||||
branchName: input.branchName,
|
||||
baseRef: input.baseRef ?? "HEAD",
|
||||
relatedIssueIds: input.relatedIssueIds ?? [input.issueId],
|
||||
workProductIds: input.workProductIds ?? [],
|
||||
signalKinds: ["issue_handoff_block"],
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectPersistedBranchMismatchRejected(input: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
@@ -2269,6 +2304,139 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
}, 15_000);
|
||||
|
||||
it("restores a missing persisted worktree from a durable Git handoff ref", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-1942-preserve-handoff-sha";
|
||||
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", branchName);
|
||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
||||
await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, "HEAD"]);
|
||||
await fs.writeFile(path.join(worktreePath, "handoff.txt"), "local handoff commit\n", "utf8");
|
||||
await runGit(worktreePath, ["add", "handoff.txt"]);
|
||||
await runGit(worktreePath, ["commit", "-m", "Add local handoff commit"]);
|
||||
const handoffSha = await readGit(worktreePath, ["rev-parse", "HEAD"]);
|
||||
const handoffRef = gitHandoffRefRecord({
|
||||
issueId: "issue-handoff",
|
||||
issueIdentifier: "PAP-1942",
|
||||
executionWorkspaceId: "execution-workspace-handoff",
|
||||
runId: "run-handoff",
|
||||
branchName,
|
||||
sha: handoffSha,
|
||||
});
|
||||
await runGit(repoRoot, ["update-ref", handoffRef.ref, handoffSha]);
|
||||
await runGit(repoRoot, ["worktree", "remove", "--force", worktreePath]);
|
||||
await runGit(repoRoot, ["branch", "-D", branchName]);
|
||||
|
||||
const restored = await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
workspace: {
|
||||
id: "execution-workspace-handoff",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName,
|
||||
metadata: {
|
||||
gitHandoffRefs: [handoffRef],
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-handoff",
|
||||
identifier: "PAP-1942",
|
||||
title: "Push local handoff commit",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(restored?.cwd).toBe(worktreePath);
|
||||
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(branchName);
|
||||
await expect(readGit(worktreePath, ["rev-parse", "HEAD"])).resolves.toBe(handoffSha);
|
||||
await expect(readGit(repoRoot, ["rev-parse", "--verify", `${handoffRef.ref}^{commit}`])).resolves.toBe(handoffSha);
|
||||
}, 15_000);
|
||||
|
||||
it("fails closed instead of recreating a missing handoff worktree from base when the durable ref is gone", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-1942-missing-handoff-ref";
|
||||
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", branchName);
|
||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
||||
await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, "HEAD"]);
|
||||
await fs.writeFile(path.join(worktreePath, "handoff.txt"), "local handoff commit\n", "utf8");
|
||||
await runGit(worktreePath, ["add", "handoff.txt"]);
|
||||
await runGit(worktreePath, ["commit", "-m", "Add local handoff commit"]);
|
||||
const handoffSha = await readGit(worktreePath, ["rev-parse", "HEAD"]);
|
||||
const handoffRef = gitHandoffRefRecord({
|
||||
issueId: "issue-missing-ref",
|
||||
issueIdentifier: "PAP-1942",
|
||||
executionWorkspaceId: "execution-workspace-missing-ref",
|
||||
runId: "run-missing-ref",
|
||||
branchName,
|
||||
sha: handoffSha,
|
||||
});
|
||||
await runGit(repoRoot, ["worktree", "remove", "--force", worktreePath]);
|
||||
await runGit(repoRoot, ["branch", "-D", branchName]);
|
||||
|
||||
await expect(ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
workspace: {
|
||||
id: "execution-workspace-missing-ref",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName,
|
||||
metadata: {
|
||||
gitHandoffRefs: [handoffRef],
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-missing-ref",
|
||||
identifier: "PAP-1942",
|
||||
title: "Push missing local handoff commit",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
code: "workspace_validation_failed",
|
||||
resultJson: {
|
||||
workspaceValidation: expect.objectContaining({
|
||||
reason: "git_handoff_ref_missing",
|
||||
worktreePath,
|
||||
branchName,
|
||||
executionWorkspaceId: "execution-workspace-missing-ref",
|
||||
}),
|
||||
},
|
||||
});
|
||||
await expect(fs.stat(worktreePath)).rejects.toThrow();
|
||||
}, 15_000);
|
||||
|
||||
it("repairs a clean persisted git worktree branch mismatch when both branches point at the same commit", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const expectedBranch = "PAP-454-repair-clean-branch-mismatch";
|
||||
@@ -3127,6 +3295,83 @@ describe("realizeExecutionWorkspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not remove a git worktree or runtime branch while a durable Git handoff ref is recorded", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1942",
|
||||
title: "Cleanup workspace with handoff ref",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await fs.writeFile(path.join(workspace.cwd, "handoff.txt"), "preserved\n", "utf8");
|
||||
await runGit(workspace.cwd, ["add", "handoff.txt"]);
|
||||
await runGit(workspace.cwd, ["commit", "-m", "Add preserved handoff work"]);
|
||||
const handoffSha = await readGit(workspace.cwd, ["rev-parse", "HEAD"]);
|
||||
const handoffRef = gitHandoffRefRecord({
|
||||
issueId: "issue-1",
|
||||
issueIdentifier: "PAP-1942",
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
runId: "run-1",
|
||||
branchName: workspace.branchName!,
|
||||
sha: handoffSha,
|
||||
});
|
||||
await runGit(repoRoot, ["update-ref", handoffRef.ref, handoffSha]);
|
||||
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
providerRef: workspace.worktreePath,
|
||||
branchName: workspace.branchName,
|
||||
repoUrl: workspace.repoUrl,
|
||||
baseRef: workspace.repoRef,
|
||||
projectId: workspace.projectId,
|
||||
projectWorkspaceId: workspace.workspaceId,
|
||||
sourceIssueId: "issue-1",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
gitHandoffRefs: [handoffRef],
|
||||
},
|
||||
},
|
||||
projectWorkspace: {
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cleanup.cleaned).toBe(false);
|
||||
expect(cleanup.warnings).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining("Skipped removing git worktree"),
|
||||
expect.stringContaining("Skipped deleting branch"),
|
||||
]));
|
||||
await expect(fs.stat(workspace.cwd)).resolves.toBeTruthy();
|
||||
await expect(readGit(repoRoot, ["branch", "--list", workspace.branchName!])).resolves.toContain(workspace.branchName!);
|
||||
await expect(readGit(repoRoot, ["rev-parse", "--verify", `${handoffRef.ref}^{commit}`])).resolves.toBe(handoffSha);
|
||||
}, 10_000);
|
||||
|
||||
it("keeps an unmerged runtime-created branch and warns instead of force deleting it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
listCurrentRuntimeServicesForExecutionWorkspaces,
|
||||
listCurrentRuntimeServicesForProjectWorkspaces,
|
||||
} from "./workspace-runtime-read-model.js";
|
||||
import { readGitHandoffRefs } from "./git-handoff-refs.js";
|
||||
|
||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
@@ -1130,6 +1131,7 @@ export function executionWorkspaceService(db: Db) {
|
||||
const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices);
|
||||
const config = readExecutionWorkspaceConfig((workspace.metadata as Record<string, unknown> | null) ?? null);
|
||||
const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace);
|
||||
const durableGitHandoffRefs = readGitHandoffRefs((workspace.metadata as Record<string, unknown> | null) ?? null);
|
||||
const warnings = [...gitWarnings];
|
||||
const blockingReasons: string[] = [];
|
||||
const isSharedWorkspace = executionWorkspace.mode === "shared_workspace";
|
||||
@@ -1165,6 +1167,17 @@ export function executionWorkspaceService(db: Db) {
|
||||
warnings.push("This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.");
|
||||
}
|
||||
|
||||
if (durableGitHandoffRefs.length > 0) {
|
||||
const refSummary = durableGitHandoffRefs
|
||||
.map((record) => `${record.shortSha} at ${record.ref}`)
|
||||
.join(", ");
|
||||
blockingReasons.push(
|
||||
durableGitHandoffRefs.length === 1
|
||||
? `This workspace has a durable Git handoff ref recorded for an unresolved push handoff: ${refSummary}.`
|
||||
: `This workspace has ${durableGitHandoffRefs.length} durable Git handoff refs recorded for unresolved push handoffs: ${refSummary}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeServices.some((service) => service.status !== "stopped")) {
|
||||
warnings.push(
|
||||
runtimeServices.length === 1
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
const GIT_HANDOFF_REFS_METADATA_KEY = "gitHandoffRefs";
|
||||
const GIT_HANDOFF_REF_PREFIX = "refs/paperclip/handoffs";
|
||||
const FULL_SHA_RE = /^[0-9a-f]{40}$/i;
|
||||
|
||||
export type GitHandoffRefMetadata = {
|
||||
version: 1;
|
||||
ref: string;
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
issueId: string | null;
|
||||
issueIdentifier: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
runId: string | null;
|
||||
branchName: string | null;
|
||||
baseRef: string | null;
|
||||
relatedIssueIds: string[];
|
||||
workProductIds: string[];
|
||||
signalKinds: string[];
|
||||
status: "pending";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.map(readString).filter((entry): entry is string => Boolean(entry)))];
|
||||
}
|
||||
|
||||
function sanitizeGitRefComponent(value: string | null | undefined, fallback: string): string {
|
||||
const sanitized = (value ?? "")
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/\.+/g, ".")
|
||||
.replace(/^[-.]+|[-.]+$/g, "")
|
||||
.slice(0, 96);
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
function normalizeGitHandoffRef(value: unknown): GitHandoffRefMetadata | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const ref = readString(value.ref);
|
||||
const sha = readString(value.sha);
|
||||
if (!ref || !sha || !FULL_SHA_RE.test(sha)) return null;
|
||||
if (!ref.startsWith(`${GIT_HANDOFF_REF_PREFIX}/`)) return null;
|
||||
|
||||
const createdAt = readString(value.createdAt) ?? new Date(0).toISOString();
|
||||
return {
|
||||
version: 1,
|
||||
ref,
|
||||
sha,
|
||||
shortSha: readString(value.shortSha) ?? sha.slice(0, 12),
|
||||
issueId: readString(value.issueId),
|
||||
issueIdentifier: readString(value.issueIdentifier),
|
||||
executionWorkspaceId: readString(value.executionWorkspaceId),
|
||||
runId: readString(value.runId),
|
||||
branchName: readString(value.branchName),
|
||||
baseRef: readString(value.baseRef),
|
||||
relatedIssueIds: readStringArray(value.relatedIssueIds),
|
||||
workProductIds: readStringArray(value.workProductIds),
|
||||
signalKinds: readStringArray(value.signalKinds),
|
||||
status: "pending",
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function readGitHandoffRefs(metadata: Record<string, unknown> | null | undefined): GitHandoffRefMetadata[] {
|
||||
const raw = metadata?.[GIT_HANDOFF_REFS_METADATA_KEY];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.map(normalizeGitHandoffRef)
|
||||
.filter((entry): entry is GitHandoffRefMetadata => entry !== null)
|
||||
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
|
||||
}
|
||||
|
||||
export function mergeGitHandoffRefMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
handoffRef: GitHandoffRefMetadata,
|
||||
): Record<string, unknown> {
|
||||
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
|
||||
const existing = readGitHandoffRefs(metadata);
|
||||
const withoutDuplicate = existing.filter((entry) =>
|
||||
entry.ref !== handoffRef.ref &&
|
||||
!(entry.sha === handoffRef.sha && entry.runId === handoffRef.runId && entry.issueId === handoffRef.issueId)
|
||||
);
|
||||
nextMetadata[GIT_HANDOFF_REFS_METADATA_KEY] = [handoffRef, ...withoutDuplicate].slice(0, 50);
|
||||
return nextMetadata;
|
||||
}
|
||||
|
||||
export function buildGitHandoffRefName(input: {
|
||||
issueId: string | null | undefined;
|
||||
runId: string | null | undefined;
|
||||
sha: string;
|
||||
}): string {
|
||||
const issuePart = sanitizeGitRefComponent(input.issueId, "unknown-issue");
|
||||
const runPart = sanitizeGitRefComponent(input.runId, "unknown-run");
|
||||
const shaPart = sanitizeGitRefComponent(input.sha.toLowerCase(), "unknown-sha");
|
||||
return `${GIT_HANDOFF_REF_PREFIX}/${issuePart}/${runPart}/${shaPart}`;
|
||||
}
|
||||
|
||||
@@ -105,12 +105,14 @@ import {
|
||||
formatManagedGitWorktreeBranchInspection,
|
||||
inspectManagedGitWorktreeBranch,
|
||||
persistAdapterManagedRuntimeServices,
|
||||
preserveGitHandoffHeadRef,
|
||||
realizeExecutionWorkspace,
|
||||
releaseRuntimeServicesForRun,
|
||||
type ExecutionWorkspaceInput,
|
||||
type RealizedExecutionWorkspace,
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { mergeGitHandoffRefMetadata } from "./git-handoff-refs.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import {
|
||||
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
|
||||
@@ -1335,6 +1337,123 @@ function fingerprintFinalizeWorkspaceBranchValidation(input: {
|
||||
return `workspace_finalize_branch_mismatch:v1:sha256:${digest}`;
|
||||
}
|
||||
|
||||
const GIT_HANDOFF_TEXT_PATTERNS = [
|
||||
/^##\s+Git handoff block\b/im,
|
||||
/\bBranch HEAD at submit\s*:/i,
|
||||
/\bRequested GitHub operation\s*:/i,
|
||||
];
|
||||
const TERMINAL_ISSUE_STATUSES_FOR_GIT_HANDOFF = new Set(["done", "cancelled"]);
|
||||
const TERMINAL_WORK_PRODUCT_STATUSES_FOR_GIT_HANDOFF = new Set(["merged", "closed", "failed", "archived"]);
|
||||
|
||||
function hasGitHandoffTextSignal(value: unknown) {
|
||||
const text = readNonEmptyString(value);
|
||||
if (!text) return false;
|
||||
return GIT_HANDOFF_TEXT_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
async function findPendingGitHandoffSignalsForFinalize(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
issueId: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
runId: string;
|
||||
},
|
||||
): Promise<{
|
||||
signalKinds: string[];
|
||||
relatedIssueIds: string[];
|
||||
workProductIds: string[];
|
||||
} | null> {
|
||||
const issuePredicates = [];
|
||||
if (input.issueId) {
|
||||
issuePredicates.push(eq(issues.id, input.issueId));
|
||||
issuePredicates.push(eq(issues.parentId, input.issueId));
|
||||
}
|
||||
if (input.executionWorkspaceId) {
|
||||
issuePredicates.push(eq(issues.executionWorkspaceId, input.executionWorkspaceId));
|
||||
}
|
||||
if (issuePredicates.length === 0) return null;
|
||||
|
||||
const candidateIssues = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
status: issues.status,
|
||||
title: issues.title,
|
||||
description: issues.description,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, input.companyId),
|
||||
or(...issuePredicates),
|
||||
));
|
||||
const candidateIssueIds = [...new Set(candidateIssues.map((issue) => issue.id))];
|
||||
if (candidateIssueIds.length === 0) return null;
|
||||
|
||||
const issueStatusById = new Map(candidateIssues.map((issue) => [issue.id, issue.status]));
|
||||
const signalKinds = new Set<string>();
|
||||
const relatedIssueIds = new Set<string>();
|
||||
const workProductIds = new Set<string>();
|
||||
|
||||
for (const issue of candidateIssues) {
|
||||
if (TERMINAL_ISSUE_STATUSES_FOR_GIT_HANDOFF.has(issue.status)) continue;
|
||||
if (hasGitHandoffTextSignal(issue.title) || hasGitHandoffTextSignal(issue.description)) {
|
||||
signalKinds.add("issue_handoff_block");
|
||||
relatedIssueIds.add(issue.id);
|
||||
}
|
||||
}
|
||||
|
||||
const comments = await db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
createdByRunId: issueComments.createdByRunId,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
inArray(issueComments.issueId, candidateIssueIds),
|
||||
isNull(issueComments.deletedAt),
|
||||
));
|
||||
for (const comment of comments) {
|
||||
if (!hasGitHandoffTextSignal(comment.body)) continue;
|
||||
const issueIsOpen = !TERMINAL_ISSUE_STATUSES_FOR_GIT_HANDOFF.has(issueStatusById.get(comment.issueId) ?? "");
|
||||
if (!issueIsOpen && comment.createdByRunId !== input.runId) continue;
|
||||
signalKinds.add(comment.createdByRunId === input.runId ? "run_comment_handoff_block" : "issue_comment_handoff_block");
|
||||
relatedIssueIds.add(comment.issueId);
|
||||
}
|
||||
|
||||
const productPredicates = [inArray(issueWorkProducts.issueId, candidateIssueIds)];
|
||||
if (input.executionWorkspaceId) {
|
||||
productPredicates.push(eq(issueWorkProducts.executionWorkspaceId, input.executionWorkspaceId));
|
||||
}
|
||||
const workProducts = await db
|
||||
.select({
|
||||
id: issueWorkProducts.id,
|
||||
issueId: issueWorkProducts.issueId,
|
||||
type: issueWorkProducts.type,
|
||||
status: issueWorkProducts.status,
|
||||
})
|
||||
.from(issueWorkProducts)
|
||||
.where(and(
|
||||
eq(issueWorkProducts.companyId, input.companyId),
|
||||
or(...productPredicates),
|
||||
inArray(issueWorkProducts.type, ["commit", "branch"]),
|
||||
));
|
||||
for (const product of workProducts) {
|
||||
if (TERMINAL_WORK_PRODUCT_STATUSES_FOR_GIT_HANDOFF.has(product.status)) continue;
|
||||
signalKinds.add(product.type === "commit" ? "commit_work_product" : "branch_work_product");
|
||||
relatedIssueIds.add(product.issueId);
|
||||
workProductIds.add(product.id);
|
||||
}
|
||||
|
||||
if (signalKinds.size === 0) return null;
|
||||
return {
|
||||
signalKinds: [...signalKinds].sort(),
|
||||
relatedIssueIds: [...relatedIssueIds].sort(),
|
||||
workProductIds: [...workProductIds].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function isConfigurationIncompleteFailure(error: unknown): error is ConfigurationIncompleteFailure {
|
||||
return error instanceof ConfigurationIncompleteFailure;
|
||||
}
|
||||
@@ -11774,6 +11893,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
if (adapterFinalizeOutcome) return;
|
||||
let finalizeBranchMetadata: Record<string, unknown> | null = null;
|
||||
let finalizeBranchRepairMetadata: Record<string, unknown> | null = null;
|
||||
let durableGitHandoffRefMetadata: Record<string, unknown> | null = null;
|
||||
if (status === "succeeded") {
|
||||
const branchInspection = await inspectFinalizeWorkspaceBranch();
|
||||
if (branchInspection) {
|
||||
@@ -11900,6 +12020,50 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
},
|
||||
);
|
||||
}
|
||||
const handoffSignal = await findPendingGitHandoffSignalsForFinalize(db, {
|
||||
companyId: run.companyId,
|
||||
issueId: issueRef?.id ?? null,
|
||||
executionWorkspaceId: branchInspection.workspaceRecord.id,
|
||||
runId: run.id,
|
||||
});
|
||||
if (handoffSignal) {
|
||||
const handoffRef = await preserveGitHandoffHeadRef({
|
||||
worktreePath: inspection.worktreePath,
|
||||
issueId: issueRef?.id ?? null,
|
||||
issueIdentifier: issueRef?.identifier ?? null,
|
||||
executionWorkspaceId: branchInspection.workspaceRecord.id,
|
||||
runId: run.id,
|
||||
branchName:
|
||||
readNonEmptyString(inspection.actualBranchName) ??
|
||||
readNonEmptyString(inspection.expectedBranchName) ??
|
||||
readNonEmptyString(branchInspection.workspaceRecord.branchName),
|
||||
baseRef: branchInspection.workspaceRecord.baseRef ?? null,
|
||||
relatedIssueIds: handoffSignal.relatedIssueIds,
|
||||
workProductIds: handoffSignal.workProductIds,
|
||||
signalKinds: handoffSignal.signalKinds,
|
||||
recorder: workspaceOperationRecorder,
|
||||
});
|
||||
const latestWorkspaceRecord =
|
||||
await executionWorkspacesSvc.getById(branchInspection.workspaceRecord.id)
|
||||
?? branchInspection.workspaceRecord;
|
||||
const nextMetadata = mergeGitHandoffRefMetadata(
|
||||
latestWorkspaceRecord.metadata as Record<string, unknown> | null,
|
||||
handoffRef,
|
||||
);
|
||||
await executionWorkspacesSvc.update(branchInspection.workspaceRecord.id, {
|
||||
metadata: nextMetadata,
|
||||
});
|
||||
durableGitHandoffRefMetadata = {
|
||||
ref: handoffRef.ref,
|
||||
sha: handoffRef.sha,
|
||||
shortSha: handoffRef.shortSha,
|
||||
issueId: handoffRef.issueId,
|
||||
issueIdentifier: handoffRef.issueIdentifier,
|
||||
relatedIssueIds: handoffRef.relatedIssueIds,
|
||||
workProductIds: handoffRef.workProductIds,
|
||||
signalKinds: handoffRef.signalKinds,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
await workspaceOperationRecorder.recordOperation({
|
||||
@@ -11911,6 +12075,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
...metadata,
|
||||
...(finalizeBranchMetadata ? { managedGitWorktreeBranch: finalizeBranchMetadata } : {}),
|
||||
...(finalizeBranchRepairMetadata ? { managedGitWorktreeBranchRepair: finalizeBranchRepairMetadata } : {}),
|
||||
...(durableGitHandoffRefMetadata ? { durableGitHandoffRef: durableGitHandoffRefMetadata } : {}),
|
||||
},
|
||||
run: async () => ({ status }),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,11 @@ import type { WorkspaceOperationRecorder } from "./workspace-operations.js";
|
||||
import { executionWorkspaceService, readExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
import {
|
||||
buildGitHandoffRefName,
|
||||
readGitHandoffRefs,
|
||||
type GitHandoffRefMetadata,
|
||||
} from "./git-handoff-refs.js";
|
||||
|
||||
export function resolveShell(): string {
|
||||
const fallback = process.platform === "win32" ? "sh" : "/bin/sh";
|
||||
@@ -1747,7 +1752,7 @@ async function runWorkspaceCommand(input: {
|
||||
async function recordGitOperation(
|
||||
recorder: WorkspaceOperationRecorder | null | undefined,
|
||||
input: {
|
||||
phase: "worktree_prepare" | "worktree_cleanup";
|
||||
phase: "worktree_prepare" | "worktree_cleanup" | "workspace_finalize";
|
||||
args: string[];
|
||||
cwd: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
@@ -1806,6 +1811,235 @@ async function recordGitOperation(
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function resolveGitHandoffRef(input: {
|
||||
repoRoot: string;
|
||||
record: GitHandoffRefMetadata;
|
||||
}): Promise<string | null> {
|
||||
const resolved = await runGit(["rev-parse", "--verify", `${input.record.ref}^{commit}`], input.repoRoot)
|
||||
.catch(() => null);
|
||||
if (!resolved) return null;
|
||||
if (resolved.toLowerCase() !== input.record.sha.toLowerCase()) {
|
||||
throw new WorkspaceRuntimeValidationFailure(
|
||||
`Durable Git handoff ref ${input.record.ref} resolves to ${resolved}, but expected ${input.record.sha}.`,
|
||||
{
|
||||
workspaceValidation: {
|
||||
reason: "git_handoff_ref_mismatch",
|
||||
durableRef: input.record.ref,
|
||||
expectedSha: input.record.sha,
|
||||
actualSha: resolved,
|
||||
executionWorkspaceId: input.record.executionWorkspaceId,
|
||||
issueId: input.record.issueId,
|
||||
issueIdentifier: input.record.issueIdentifier,
|
||||
runId: input.record.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function preserveGitHandoffHeadRef(input: {
|
||||
worktreePath: string;
|
||||
issueId: string | null;
|
||||
issueIdentifier: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
runId: string | null;
|
||||
branchName: string | null;
|
||||
baseRef: string | null;
|
||||
relatedIssueIds: string[];
|
||||
workProductIds: string[];
|
||||
signalKinds: string[];
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}): Promise<GitHandoffRefMetadata> {
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.worktreePath)
|
||||
.catch((error) => {
|
||||
throw new WorkspaceRuntimeValidationFailure(
|
||||
`Cannot preserve Git handoff SHA because "${input.worktreePath}" is not inside a usable git repository: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{
|
||||
workspaceValidation: {
|
||||
reason: "git_handoff_ref_repo_unavailable",
|
||||
worktreePath: input.worktreePath,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
issueId: input.issueId,
|
||||
issueIdentifier: input.issueIdentifier,
|
||||
runId: input.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
const sha = await runGit(["rev-parse", "--verify", "HEAD^{commit}"], input.worktreePath)
|
||||
.catch((error) => {
|
||||
throw new WorkspaceRuntimeValidationFailure(
|
||||
`Cannot preserve Git handoff SHA because HEAD in "${input.worktreePath}" is not a commit: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{
|
||||
workspaceValidation: {
|
||||
reason: "git_handoff_head_unavailable",
|
||||
worktreePath: input.worktreePath,
|
||||
repoRoot,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
issueId: input.issueId,
|
||||
issueIdentifier: input.issueIdentifier,
|
||||
runId: input.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
const ref = buildGitHandoffRefName({
|
||||
issueId: input.issueId,
|
||||
runId: input.runId,
|
||||
sha,
|
||||
});
|
||||
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "workspace_finalize",
|
||||
args: ["update-ref", ref, sha],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath: input.worktreePath,
|
||||
durableGitHandoffRef: true,
|
||||
ref,
|
||||
sha,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
issueId: input.issueId,
|
||||
issueIdentifier: input.issueIdentifier,
|
||||
runId: input.runId,
|
||||
branchName: input.branchName,
|
||||
signalKinds: input.signalKinds,
|
||||
relatedIssueIds: input.relatedIssueIds,
|
||||
workProductIds: input.workProductIds,
|
||||
},
|
||||
successMessage: `Preserved Git handoff commit ${sha} at ${ref}\n`,
|
||||
failureLabel: `git update-ref ${ref}`,
|
||||
}).catch((error) => {
|
||||
throw new WorkspaceRuntimeValidationFailure(
|
||||
`Cannot preserve Git handoff SHA ${sha} at ${ref}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{
|
||||
workspaceValidation: {
|
||||
reason: "git_handoff_ref_update_failed",
|
||||
worktreePath: input.worktreePath,
|
||||
repoRoot,
|
||||
durableRef: ref,
|
||||
sha,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
issueId: input.issueId,
|
||||
issueIdentifier: input.issueIdentifier,
|
||||
runId: input.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const record: GitHandoffRefMetadata = {
|
||||
version: 1,
|
||||
ref,
|
||||
sha,
|
||||
shortSha: sha.slice(0, 12),
|
||||
issueId: input.issueId,
|
||||
issueIdentifier: input.issueIdentifier,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
runId: input.runId,
|
||||
branchName: input.branchName,
|
||||
baseRef: input.baseRef,
|
||||
relatedIssueIds: [...new Set(input.relatedIssueIds)],
|
||||
workProductIds: [...new Set(input.workProductIds)],
|
||||
signalKinds: [...new Set(input.signalKinds)],
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await resolveGitHandoffRef({ repoRoot, record });
|
||||
return record;
|
||||
}
|
||||
|
||||
async function restoreBranchFromGitHandoffRef(input: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
metadata: Record<string, unknown> | null | undefined;
|
||||
executionWorkspaceId: string | null;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}): Promise<{ restored: boolean; record: GitHandoffRefMetadata | null }> {
|
||||
const allRecords = readGitHandoffRefs(input.metadata);
|
||||
const candidates = allRecords.filter((record) => !record.branchName || record.branchName === input.branchName);
|
||||
if (candidates.length === 0) {
|
||||
if (allRecords.length > 0) {
|
||||
throw new WorkspaceRuntimeValidationFailure(
|
||||
`Execution workspace "${input.worktreePath}" has durable Git handoff refs, but none match recorded branch "${input.branchName}".`,
|
||||
{
|
||||
workspaceValidation: {
|
||||
reason: "git_handoff_ref_branch_mismatch",
|
||||
repoRoot: input.repoRoot,
|
||||
worktreePath: input.worktreePath,
|
||||
branchName: input.branchName,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
issueId: input.issue?.id ?? null,
|
||||
issueIdentifier: input.issue?.identifier ?? null,
|
||||
durableRefs: allRecords.map((record) => ({
|
||||
ref: record.ref,
|
||||
sha: record.sha,
|
||||
branchName: record.branchName,
|
||||
issueId: record.issueId,
|
||||
runId: record.runId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return { restored: false, record: null };
|
||||
}
|
||||
|
||||
const missingRefs: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const resolved = await resolveGitHandoffRef({ repoRoot: input.repoRoot, record: candidate });
|
||||
if (!resolved) {
|
||||
missingRefs.push(candidate.ref);
|
||||
continue;
|
||||
}
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["branch", "--force", input.branchName, candidate.ref],
|
||||
cwd: input.repoRoot,
|
||||
metadata: {
|
||||
repoRoot: input.repoRoot,
|
||||
worktreePath: input.worktreePath,
|
||||
branchName: input.branchName,
|
||||
durableGitHandoffRefRestore: true,
|
||||
ref: candidate.ref,
|
||||
sha: candidate.sha,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
sourceIssueId: input.issue?.id ?? candidate.issueId,
|
||||
sourceIdentifier: input.issue?.identifier ?? candidate.issueIdentifier,
|
||||
},
|
||||
successMessage: `Restored branch ${input.branchName} from durable Git handoff ref ${candidate.ref}\n`,
|
||||
failureLabel: `git branch --force ${input.branchName} ${candidate.ref}`,
|
||||
});
|
||||
return { restored: true, record: candidate };
|
||||
}
|
||||
|
||||
throw new WorkspaceRuntimeValidationFailure(
|
||||
`Execution workspace "${input.worktreePath}" has pending durable Git handoff refs, but none of them are present in the repository: ${missingRefs.join(", ")}.`,
|
||||
{
|
||||
workspaceValidation: {
|
||||
reason: "git_handoff_ref_missing",
|
||||
repoRoot: input.repoRoot,
|
||||
worktreePath: input.worktreePath,
|
||||
branchName: input.branchName,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
issueId: input.issue?.id ?? null,
|
||||
issueIdentifier: input.issue?.identifier ?? null,
|
||||
durableRefs: candidates.map((candidate) => ({
|
||||
ref: candidate.ref,
|
||||
sha: candidate.sha,
|
||||
issueId: candidate.issueId,
|
||||
runId: candidate.runId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function recordWorkspaceCommandOperation(
|
||||
recorder: WorkspaceOperationRecorder | null | undefined,
|
||||
input: {
|
||||
@@ -2396,25 +2630,53 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
|
||||
const recreatedBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
baseRefSha: recreatedBaseRefSha,
|
||||
created: true,
|
||||
restored: true,
|
||||
},
|
||||
successMessage: `Recreated missing git worktree at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
const handoffRestore = await restoreBranchFromGitHandoffRef({
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
metadata: input.workspace.metadata,
|
||||
executionWorkspaceId: input.workspace.id ?? null,
|
||||
issue: input.issue,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
created = true;
|
||||
if (handoffRestore.restored) {
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", worktreePath, branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
restoredFromDurableGitHandoffRef: true,
|
||||
durableRef: handoffRestore.record?.ref ?? null,
|
||||
sha: handoffRestore.record?.sha ?? null,
|
||||
executionWorkspaceId: input.workspace.id ?? null,
|
||||
},
|
||||
successMessage: `Reattached missing git worktree at ${worktreePath} from durable Git handoff ref\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
} else {
|
||||
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
|
||||
const recreatedBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
baseRefSha: recreatedBaseRefSha,
|
||||
created: true,
|
||||
restored: true,
|
||||
},
|
||||
successMessage: `Recreated missing git worktree at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
created = true;
|
||||
}
|
||||
}
|
||||
|
||||
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
||||
@@ -2489,6 +2751,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
|
||||
});
|
||||
const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
|
||||
const durableGitHandoffRefs = readGitHandoffRefs(input.workspace.metadata);
|
||||
const cleanupCommands = [
|
||||
input.cleanupCommand ?? null,
|
||||
input.projectWorkspace?.cleanupCommand ?? null,
|
||||
@@ -2524,51 +2787,63 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
}
|
||||
|
||||
if (input.workspace.providerType === "git_worktree" && workspacePath) {
|
||||
const worktreeExists = await directoryExists(workspacePath);
|
||||
if (worktreeExists) {
|
||||
if (!repoRoot) {
|
||||
warnings.push(`Could not resolve git repo root for "${workspacePath}".`);
|
||||
} else {
|
||||
try {
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_cleanup",
|
||||
args: ["worktree", "remove", "--force", workspacePath],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
workspaceId: input.workspace.id,
|
||||
workspacePath,
|
||||
branchName: input.workspace.branchName,
|
||||
cleanupAction: "worktree_remove",
|
||||
},
|
||||
successMessage: `Removed git worktree ${workspacePath}\n`,
|
||||
failureLabel: `git worktree remove ${workspacePath}`,
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(err instanceof Error ? err.message : String(err));
|
||||
if (durableGitHandoffRefs.length > 0) {
|
||||
const refList = durableGitHandoffRefs.map((record) => `${record.ref} (${record.shortSha})`).join(", ");
|
||||
warnings.push(
|
||||
`Skipped removing git worktree "${workspacePath}" because durable Git handoff refs are still recorded: ${refList}.`,
|
||||
);
|
||||
if (createdByRuntime && input.workspace.branchName) {
|
||||
warnings.push(
|
||||
`Skipped deleting branch "${input.workspace.branchName}" because durable Git handoff refs are still recorded: ${refList}.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const worktreeExists = await directoryExists(workspacePath);
|
||||
if (worktreeExists) {
|
||||
if (!repoRoot) {
|
||||
warnings.push(`Could not resolve git repo root for "${workspacePath}".`);
|
||||
} else {
|
||||
try {
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_cleanup",
|
||||
args: ["worktree", "remove", "--force", workspacePath],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
workspaceId: input.workspace.id,
|
||||
workspacePath,
|
||||
branchName: input.workspace.branchName,
|
||||
cleanupAction: "worktree_remove",
|
||||
},
|
||||
successMessage: `Removed git worktree ${workspacePath}\n`,
|
||||
failureLabel: `git worktree remove ${workspacePath}`,
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (createdByRuntime && input.workspace.branchName) {
|
||||
if (!repoRoot) {
|
||||
warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`);
|
||||
} else {
|
||||
try {
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_cleanup",
|
||||
args: ["branch", "-d", input.workspace.branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
workspaceId: input.workspace.id,
|
||||
workspacePath,
|
||||
branchName: input.workspace.branchName,
|
||||
cleanupAction: "branch_delete",
|
||||
},
|
||||
successMessage: `Deleted branch ${input.workspace.branchName}\n`,
|
||||
failureLabel: `git branch -d ${input.workspace.branchName}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
warnings.push(`Skipped deleting branch "${input.workspace.branchName}": ${message}`);
|
||||
if (createdByRuntime && input.workspace.branchName) {
|
||||
if (!repoRoot) {
|
||||
warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`);
|
||||
} else {
|
||||
try {
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_cleanup",
|
||||
args: ["branch", "-d", input.workspace.branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
workspaceId: input.workspace.id,
|
||||
workspacePath,
|
||||
branchName: input.workspace.branchName,
|
||||
cleanupAction: "branch_delete",
|
||||
},
|
||||
successMessage: `Deleted branch ${input.workspace.branchName}\n`,
|
||||
failureLabel: `git branch -d ${input.workspace.branchName}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
warnings.push(`Skipped deleting branch "${input.workspace.branchName}": ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user