From 5163208c3caeda7690cb89175efcb5778c0fc269 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 6 Jul 2026 16:29:01 -0700 Subject: [PATCH] Add workspace branch ancestry diagnostics (#9117) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run in git worktrees tied to a workspace branch; when the actual branch diverges from the expected one (e.g. a parent feature branch was renamed), Paperclip currently has no structured field to report *why* the branch is incoherent or whether it can be auto-reconciled > - The workspace-incoherence fingerprint already captures SHA mismatches, but there is no evidence field distinguishing "actual branch is a descendant of expected" (safe to fast-forward) from "branches have diverged" (needs human review) or "SHAs are unavailable" (unknown) > - Operators and future recovery flows need a typed verdict to make decisions without re-running git commands themselves > - This pull request adds `ancestryVerdict` and `plainLanguageReason` evidence fields computed via `git merge-base --is-ancestor`, and scaffolds the off-by-default `enableWorkspaceBranchReconcileForward` instance setting with no runtime behavior yet > - The benefit is that future recovery logic can branch on a typed verdict rather than parsing prose, while the fingerprint v1 payload stays stable ## Linked Issues or Issue Description No public GitHub issue pre-exists for this diagnostic addition. **Problem or motivation** When Paperclip detects that an agent's actual workspace branch differs from the recorded expected branch, the current fingerprint carries only raw SHAs. There is no typed field indicating whether the actual branch is a descendant of the expected one (safe reconcile path) vs. a true divergence (requires human intervention) vs. an indeterminate state (missing SHAs or git errors). Downstream recovery logic cannot branch safely without re-running git. **Proposed solution** Add `ancestryVerdict` and `plainLanguageReason` to the workspace incoherence evidence type; compute via `git merge-base --is-ancestor`; scaffold a feature-flag for future forward-reconcile behavior (`enableWorkspaceBranchReconcileForward`, off by default, not yet read by any runtime path). **Alternatives considered** Encoding the verdict in the existing fingerprint string was rejected because the fingerprint is a stable identity hash, not a mutable evidence bag. Changing it would break monitors keyed on the string. **Roadmap alignment** Supports future workspace auto-reconcile work; ROADMAP.md has no conflicting entry for this diagnostic layer. ## What Changed - `packages/shared/src/types/heartbeat.ts` adds `ancestryVerdict` and `plainLanguageReason` fields to `WorkspaceIncoherenceEvidence` - `packages/shared/src/types/instance.ts` adds `enableWorkspaceBranchReconcileForward` boolean (off by default) - `packages/shared/src/validators/instance.ts` exports the new flag from the settings validator - `server/src/services/workspace-runtime.ts` computes `ancestryVerdict` via `git merge-base --is-ancestor`; falls back to `unknown` on missing SHAs or command errors; excludes verdict fields from fingerprint v1 computation - `server/src/services/instance-settings.ts` wires the new setting through to the settings service - Tests updated in `workspace-runtime.test.ts`, `instance-settings-service.test.ts`, `instance-settings-routes.test.ts`, and `instance.test.ts` (104 tests total) ## Verification ```bash pnpm exec vitest run \ server/src/__tests__/workspace-runtime.test.ts \ server/src/__tests__/instance-settings-service.test.ts \ server/src/__tests__/instance-settings-routes.test.ts \ packages/shared/src/validators/instance.test.ts # 104 tests pass pnpm --filter @paperclipai/shared typecheck pnpm --filter @paperclipai/server typecheck # both exit 0 ``` Manual: trigger a workspace incoherence event and confirm the evidence object carries `ancestryVerdict` and `plainLanguageReason`; confirm the fingerprint string stays `workspace_incoherence:v1:sha256:...`. ## Risks **Low risk.** Purely additive. Fingerprint v1 payload is unchanged. The new flag has no runtime effect in this PR. `git merge-base --is-ancestor` exits non-zero for both "not an ancestor" and "command error"; both are handled and collapsed to typed values with a prose reason. ## Model Used Provider: Anthropic, model: Claude Sonnet 4.6 (`claude-sonnet-4-6`), 200k context, tool use enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: Claude Sonnet 4.6 --- packages/shared/src/index.ts | 2 + packages/shared/src/types/heartbeat.ts | 36 +++ packages/shared/src/types/index.ts | 2 + packages/shared/src/types/instance.ts | 1 + .../shared/src/validators/instance.test.ts | 16 + packages/shared/src/validators/instance.ts | 1 + .../instance-settings-routes.test.ts | 5 + .../instance-settings-service.test.ts | 11 + .../src/__tests__/workspace-runtime.test.ts | 278 +++++++++++++++--- server/src/services/instance-settings.ts | 2 + server/src/services/workspace-runtime.ts | 93 ++++-- .../InstanceExperimentalSettings.test.tsx | 1 + 12 files changed, 383 insertions(+), 65 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dfef663433..3103675b6c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -762,6 +762,8 @@ export type { FinanceByKind, AgentWakeupResponse, AgentWakeupSkipped, + GitWorktreeBranchAncestryVerdict, + GitWorktreeBranchIncoherenceEvidence, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatusPhase, diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index e8a5b9b519..427ebab800 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -8,6 +8,42 @@ import type { WakeupRequestStatus, } from "../constants.js"; +export type GitWorktreeBranchAncestryVerdict = "ancestor" | "diverged" | "unknown"; + +export interface GitWorktreeBranchIncoherenceEvidence { + reason: "git_worktree_branch_incoherence"; + fingerprint: string; + sourceIssueId: string | null; + sourceIdentifier: string | null; + executionWorkspaceId: string | null; + worktreePath: string; + repoRoot: string; + expectedBranch: string; + actualBranch: string | null; + cleanliness: "clean" | "dirty" | "unknown"; + statusEntryCount: number | null; + provenance: { + expectedBranchRef: string; + actualBranchRef: string | null; + registeredBranchRef: string | null; + registeredPathFound: boolean; + registeredBranchMatchesHead: boolean; + expectedBranchExists: boolean; + actualBranchExists: boolean | null; + expectedHeadSha: string | null; + actualHeadSha: string | null; + sameHead: boolean; + ancestryVerdict: GitWorktreeBranchAncestryVerdict; + plainLanguageReason: string; + }; + safeRepair: { + eligible: boolean; + attempted: boolean; + succeeded: boolean; + reason: string; + }; +} + export interface HeartbeatRun { id: string; companyId: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8d73510b1c..aaf2883ea4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -467,6 +467,8 @@ export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } fro export type { AgentWakeupResponse, AgentWakeupSkipped, + GitWorktreeBranchAncestryVerdict, + GitWorktreeBranchIncoherenceEvidence, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatusPhase, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 12ad91437a..038fac8068 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -58,6 +58,7 @@ export interface InstanceExperimentalSettings { enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; + enableWorkspaceBranchReconcileForward: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; } diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index de84c6f7fa..f754afea62 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -11,6 +11,12 @@ describe("instance experimental settings validators", () => { expect(settings.enableServerInfoDebugView).toBe(false); }); + it("defaults workspace branch forward reconciliation off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableWorkspaceBranchReconcileForward).toBe(false); + }); + it("accepts server info debug view patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ @@ -20,4 +26,14 @@ describe("instance experimental settings validators", () => { enableServerInfoDebugView: true, }); }); + + it("accepts workspace branch forward reconciliation patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableWorkspaceBranchReconcileForward: true, + }), + ).toEqual({ + enableWorkspaceBranchReconcileForward: true, + }); + }); }); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 4831e7efa7..c405f217a5 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -52,6 +52,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), + enableWorkspaceBranchReconcileForward: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z .number() .int() diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 05c0210e73..b9fa242b16 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -84,6 +84,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: "2026-06-20T00:00:00.000Z", @@ -105,6 +106,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }); mockInstanceSettingsService.update.mockResolvedValue({ @@ -124,6 +126,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: "2026-06-20T00:00:00.000Z", @@ -150,6 +153,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, }); @@ -204,6 +208,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 0d00f3c0ef..1ac0eeb4fb 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -13,6 +13,7 @@ describe("instance settings service", () => { enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, enableNewestFirstIssueThread: true, })).toEqual({ @@ -29,6 +30,7 @@ describe("instance settings service", () => { enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, }); }); @@ -58,6 +60,15 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableWorkspaceBranchReconcileForward to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(false); + expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(false); + expect( + normalizeExperimentalSettings({ enableIssueGraphLivenessAutoRecovery: true }) + .enableWorkspaceBranchReconcileForward, + ).toBe(false); + }); + it("round-trips an enableConferenceRoomChat patch through the update merge", () => { // updateExperimental merges `{ ...normalize(current), ...patch }` and // re-normalizes; emulate that to prove the flag survives the roundtrip diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index c936b95f7b..d0fd0d6846 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -59,6 +59,34 @@ function stableStringifyForTest(value: unknown): string { } return JSON.stringify(value); } + +function workspaceBranchIncoherenceFingerprintForTest(input: { + sourceIssueId: string | null; + executionWorkspaceId: string | null; + worktreePath: string; + expectedBranch: string; + actualBranch: string | null; + cleanliness: "clean" | "dirty" | "unknown"; + expectedHeadSha: string | null; + actualHeadSha: string | null; +}) { + const digest = createHash("sha256") + .update(stableStringifyForTest({ + version: 1, + reason: "git_worktree_branch_incoherence", + sourceIssueId: input.sourceIssueId, + executionWorkspaceId: input.executionWorkspaceId, + worktreePath: path.resolve(input.worktreePath), + expectedBranch: input.expectedBranch, + actualBranch: input.actualBranch, + cleanliness: input.cleanliness, + expectedHeadSha: input.expectedHeadSha, + actualHeadSha: input.actualHeadSha, + })) + .digest("hex"); + return `workspace_incoherence:v1:sha256:${digest}`; +} + const leasedRunIds = new Set(); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -2294,6 +2322,8 @@ describe("realizeExecutionWorkspace", () => { expectedBranchExists: true, actualBranchExists: true, sameHead: true, + ancestryVerdict: "ancestor", + plainLanguageReason: expect.stringContaining("same commit"), }), safeRepair: expect.objectContaining({ eligible: false, @@ -2394,43 +2424,64 @@ describe("realizeExecutionWorkspace", () => { await runGit(initial.cwd, ["add", "publish.txt"]); await runGit(initial.cwd, ["commit", "-m", "Add publish branch work"]); - await expect(ensurePersistedExecutionWorkspaceAvailable({ - base: { - baseCwd: repoRoot, - source: "project_primary", - projectId: "project-1", - workspaceId: "workspace-1", - repoUrl: null, - repoRef: "HEAD", - }, - workspace: { - id: "execution-workspace-3", - mode: "isolated_workspace", - strategyType: "git_worktree", - cwd: initial.cwd, - providerRef: initial.worktreePath, - projectId: "project-1", - projectWorkspaceId: "workspace-1", - repoUrl: null, - baseRef: "HEAD", - branchName: initial.branchName, - }, - issue: { - id: "issue-3", - identifier: "PAP-456", - title: "Keep persisted branch coherent", - }, - agent: { - id: "agent-1", - name: "Codex Coder", - companyId: "company-1", - }, - })).rejects.toMatchObject({ + if (!initial.branchName) throw new Error("expected realized worktree branch name"); + const expectedHeadSha = await readGit(repoRoot, ["rev-parse", `refs/heads/${initial.branchName}^{commit}`]); + const actualHeadSha = await readGit(initial.cwd, ["rev-parse", "HEAD"]); + const expectedFingerprint = workspaceBranchIncoherenceFingerprintForTest({ + sourceIssueId: "issue-3", + executionWorkspaceId: "execution-workspace-3", + worktreePath: initial.cwd, + expectedBranch: initial.branchName, + actualBranch, + cleanliness: "clean", + expectedHeadSha, + actualHeadSha, + }); + + let error: unknown = null; + try { + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-3", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: initial.cwd, + providerRef: initial.worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: initial.branchName, + }, + issue: { + id: "issue-3", + identifier: "PAP-456", + title: "Keep persisted branch coherent", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + } catch (err) { + error = err; + } + + expect(error).toMatchObject({ code: "workspace_validation_failed", resultJson: { workspaceValidation: expect.objectContaining({ reason: "git_worktree_branch_incoherence", - fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/), + fingerprint: expectedFingerprint, sourceIssueId: "issue-3", sourceIdentifier: "PAP-456", executionWorkspaceId: "execution-workspace-3", @@ -2441,6 +2492,8 @@ describe("realizeExecutionWorkspace", () => { expectedBranchExists: true, actualBranchExists: true, sameHead: false, + ancestryVerdict: "ancestor", + plainLanguageReason: expect.stringContaining("forward of the recorded branch"), }), safeRepair: expect.objectContaining({ eligible: false, @@ -2453,6 +2506,165 @@ describe("realizeExecutionWorkspace", () => { }); }, 15_000); + it("classifies persisted git worktree branch incoherence as diverged when the checked-out branch is not forward", async () => { + const repoRoot = await createTempRepo(); + const expectedBranch = "PAP-457-recorded-work"; + const actualBranch = "PAP-457-sibling-work"; + const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", expectedBranch); + + await fs.mkdir(path.dirname(worktreePath), { recursive: true }); + await runGit(repoRoot, ["branch", expectedBranch]); + await runGit(repoRoot, ["worktree", "add", "-b", actualBranch, worktreePath, "HEAD"]); + + await runGit(repoRoot, ["checkout", expectedBranch]); + await fs.writeFile(path.join(repoRoot, "recorded.txt"), "recorded branch work\n", "utf8"); + await runGit(repoRoot, ["add", "recorded.txt"]); + await runGit(repoRoot, ["commit", "-m", "Add recorded branch work"]); + + await fs.writeFile(path.join(worktreePath, "actual.txt"), "actual branch work\n", "utf8"); + await runGit(worktreePath, ["add", "actual.txt"]); + await runGit(worktreePath, ["commit", "-m", "Add actual branch work"]); + + let error: unknown = null; + try { + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-diverged", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: expectedBranch, + }, + issue: { + id: "issue-diverged", + identifier: "PAP-457", + title: "Classify diverged branch incoherence", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + } catch (err) { + error = err; + } + + expect(error).toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_branch_incoherence", + sourceIssueId: "issue-diverged", + sourceIdentifier: "PAP-457", + executionWorkspaceId: "execution-workspace-diverged", + expectedBranch, + actualBranch, + cleanliness: "clean", + provenance: expect.objectContaining({ + expectedBranchExists: true, + actualBranchExists: true, + sameHead: false, + ancestryVerdict: "diverged", + plainLanguageReason: expect.stringContaining("cannot prove a forward-only reconciliation"), + }), + }), + }, + }); + }, 15_000); + + it("classifies persisted git worktree branch incoherence as unknown when the recorded branch was deleted", async () => { + const repoRoot = await createTempRepo(); + const expectedBranch = "PAP-458-deleted-recorded-branch"; + const actualBranch = "PAP-458-actual-work"; + const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", expectedBranch); + + await fs.mkdir(path.dirname(worktreePath), { recursive: true }); + await runGit(repoRoot, ["branch", expectedBranch]); + await runGit(repoRoot, ["worktree", "add", "-b", actualBranch, worktreePath, "HEAD"]); + await runGit(repoRoot, ["branch", "-D", expectedBranch]); + + let error: unknown = null; + try { + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-deleted-branch", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: expectedBranch, + }, + issue: { + id: "issue-deleted-branch", + identifier: "PAP-458", + title: "Classify deleted branch ancestry", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + } catch (err) { + error = err; + } + + expect(error).toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_branch_incoherence", + sourceIssueId: "issue-deleted-branch", + sourceIdentifier: "PAP-458", + executionWorkspaceId: "execution-workspace-deleted-branch", + expectedBranch, + actualBranch, + cleanliness: "clean", + provenance: expect.objectContaining({ + expectedBranchExists: false, + actualBranchExists: true, + expectedHeadSha: null, + sameHead: false, + ancestryVerdict: "unknown", + plainLanguageReason: expect.stringContaining("missing a resolvable HEAD commit"), + }), + safeRepair: expect.objectContaining({ + eligible: false, + attempted: false, + succeeded: false, + reason: "expected branch does not exist", + }), + }), + }, + }); + }, 15_000); + it("does not reuse a missing persisted local filesystem workspace", async () => { const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-base-")); const missingCwd = path.join(baseCwd, "missing-workspace"); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 8b99bdedb7..f61c966cb3 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -57,6 +57,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, + enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? false, issueGraphLivenessAutoRecoveryLookbackHours: parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ?? DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, @@ -76,6 +77,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, }; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 866e189831..14af9af6b0 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -10,6 +10,8 @@ import type { Db } from "@paperclipai/db"; import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import { listWorkspaceServiceCommandDefinitions, + type GitWorktreeBranchAncestryVerdict, + type GitWorktreeBranchIncoherenceEvidence as SharedGitWorktreeBranchIncoherenceEvidence, type WorkspaceRuntimeDesiredState, type WorkspaceRuntimeServiceStateMap, } from "@paperclipai/shared"; @@ -653,39 +655,9 @@ async function remoteExists(repoRoot: string, remote: string): Promise const GIT_WORKTREE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence"; -type GitWorktreeCleanliness = "clean" | "dirty" | "unknown"; +type GitWorktreeCleanliness = SharedGitWorktreeBranchIncoherenceEvidence["cleanliness"]; -type GitWorktreeBranchIncoherenceEvidence = { - reason: typeof GIT_WORKTREE_BRANCH_INCOHERENCE_REASON; - fingerprint: string; - sourceIssueId: string | null; - sourceIdentifier: string | null; - executionWorkspaceId: string | null; - worktreePath: string; - repoRoot: string; - expectedBranch: string; - actualBranch: string | null; - cleanliness: GitWorktreeCleanliness; - statusEntryCount: number | null; - provenance: { - expectedBranchRef: string; - actualBranchRef: string | null; - registeredBranchRef: string | null; - registeredPathFound: boolean; - registeredBranchMatchesHead: boolean; - expectedBranchExists: boolean; - actualBranchExists: boolean | null; - expectedHeadSha: string | null; - actualHeadSha: string | null; - sameHead: boolean; - }; - safeRepair: { - eligible: boolean; - attempted: boolean; - succeeded: boolean; - reason: string; - }; -}; +type GitWorktreeBranchIncoherenceEvidence = SharedGitWorktreeBranchIncoherenceEvidence; function formatBranchForMessage(branch: string | null | undefined) { return branch && branch.length > 0 ? branch : ""; @@ -718,6 +690,48 @@ function fingerprintWorkspaceBranchIncoherence(input: { return `workspace_incoherence:v1:sha256:${digest}`; } +async function getGitWorktreeBranchAncestryVerdict(input: { + repoRoot: string; + expectedHeadSha: string | null; + actualHeadSha: string | null; +}): Promise { + if (!input.expectedHeadSha || !input.actualHeadSha) return "unknown"; + + const proc = await executeProcess({ + command: "git", + args: ["merge-base", "--is-ancestor", input.expectedHeadSha, input.actualHeadSha], + cwd: input.repoRoot, + }).catch(() => null); + if (!proc) return "unknown"; + if (proc.code === 0) return "ancestor"; + if (proc.code === 1) return "diverged"; + return "unknown"; +} + +function explainGitWorktreeBranchIncoherence(input: { + expectedBranchName: string; + actualBranchName: string | null; + expectedHeadSha: string | null; + actualHeadSha: string | null; + sameHead: boolean; + ancestryVerdict: GitWorktreeBranchAncestryVerdict; +}) { + const actualBranch = formatBranchForMessage(input.actualBranchName); + if (!input.expectedHeadSha || !input.actualHeadSha) { + return `Paperclip could not determine branch ancestry because the recorded branch "${input.expectedBranchName}" or checked-out branch "${actualBranch}" is missing a resolvable HEAD commit.`; + } + if (input.sameHead) { + return `The recorded branch "${input.expectedBranchName}" and checked-out branch "${actualBranch}" resolve to the same commit, so the mismatch is branch metadata rather than commit divergence.`; + } + if (input.ancestryVerdict === "ancestor") { + return `The recorded branch "${input.expectedBranchName}" is an ancestor of the checked-out branch "${actualBranch}", so the checked-out branch is forward of the recorded branch.`; + } + if (input.ancestryVerdict === "diverged") { + return `The recorded branch "${input.expectedBranchName}" is not an ancestor of the checked-out branch "${actualBranch}", so Paperclip cannot prove a forward-only reconciliation.`; + } + return `Paperclip could not determine whether the checked-out branch "${actualBranch}" is forward of the recorded branch "${input.expectedBranchName}".`; +} + async function inspectGitWorktreeBranchIncoherence(input: { repoRoot: string; worktreePath: string; @@ -749,6 +763,19 @@ async function inspectGitWorktreeBranchIncoherence(input: { const registeredBranchMatchesHead = Boolean(registered && registeredBranchRef === actualBranchRef); const sameHead = Boolean(expectedHeadSha && actualHeadSha && expectedHeadSha === actualHeadSha); const expectedBranchExists = Boolean(expectedHeadSha); + const ancestryVerdict = await getGitWorktreeBranchAncestryVerdict({ + repoRoot: input.repoRoot, + expectedHeadSha, + actualHeadSha, + }); + const plainLanguageReason = explainGitWorktreeBranchIncoherence({ + expectedBranchName: input.expectedBranchName, + actualBranchName: input.actualBranchName, + expectedHeadSha, + actualHeadSha, + sameHead, + ancestryVerdict, + }); const eligible = cleanliness === "clean" && expectedBranchExists && sameHead && registeredBranchMatchesHead; const safeRepairReason = eligible ? "clean worktree and expected branch points at the current HEAD" @@ -797,6 +824,8 @@ async function inspectGitWorktreeBranchIncoherence(input: { expectedHeadSha, actualHeadSha, sameHead, + ancestryVerdict, + plainLanguageReason, }, safeRepair: { eligible, diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 8f5b5cf669..ac41aebed8 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -63,6 +63,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, + enableWorkspaceBranchReconcileForward: false, }; }