diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 32176268511..a701ec0ca7e 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -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(); diff --git a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts index 15871f94a6f..e530fde4777 100644 --- a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts +++ b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts @@ -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 | 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); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 61c612bfdc0..873bbb48b6c 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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(); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 5a5f486cce2..6fbbe74e807 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -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 | null) ?? null); const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace); + const durableGitHandoffRefs = readGitHandoffRefs((workspace.metadata as Record | 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 diff --git a/server/src/services/git-handoff-refs.ts b/server/src/services/git-handoff-refs.ts new file mode 100644 index 00000000000..dabff1b4327 --- /dev/null +++ b/server/src/services/git-handoff-refs.ts @@ -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 { + 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 | 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 | null | undefined, + handoffRef: GitHandoffRefMetadata, +): Record { + 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}`; +} + diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 41cd18af7a6..fb3dc99ee2d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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(); + const relatedIssueIds = new Set(); + const workProductIds = new Set(); + + 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 | null = null; let finalizeBranchRepairMetadata: Record | null = null; + let durableGitHandoffRefMetadata: Record | 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 | 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 }), }); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 3a062137bed..552ad0e9e42 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -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 | null; @@ -1806,6 +1811,235 @@ async function recordGitOperation( return stdout.trim(); } +async function resolveGitHandoffRef(input: { + repoRoot: string; + record: GitHandoffRefMetadata; +}): Promise { + 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 { + 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 | 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}`); + } } } }