Preserve git handoff refs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Marcus WebbandPaperclip committed 2026-07-09 01:52:42 +00:00
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
+110
View File
@@ -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}`;
}
+165
View File
@@ -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 }),
});
+337 -62
View File
@@ -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}`);
}
}
}
}