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 <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicky Leach 2026-07-06 16:29:01 -07:00 committed by GitHub
parent c5d73844a5
commit 5163208c3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 383 additions and 65 deletions

View File

@ -762,6 +762,8 @@ export type {
FinanceByKind,
AgentWakeupResponse,
AgentWakeupSkipped,
GitWorktreeBranchAncestryVerdict,
GitWorktreeBranchIncoherenceEvidence,
HeartbeatRun,
HeartbeatRunEvent,
HeartbeatRunStatusPhase,

View File

@ -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;

View File

@ -467,6 +467,8 @@ export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } fro
export type {
AgentWakeupResponse,
AgentWakeupSkipped,
GitWorktreeBranchAncestryVerdict,
GitWorktreeBranchIncoherenceEvidence,
HeartbeatRun,
HeartbeatRunEvent,
HeartbeatRunStatusPhase,

View File

@ -58,6 +58,7 @@ export interface InstanceExperimentalSettings {
enableServerInfoDebugView: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
enableWorkspaceBranchReconcileForward: boolean;
issueGraphLivenessAutoRecoveryLookbackHours: number;
}

View File

@ -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,
});
});
});

View File

@ -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()

View File

@ -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,
});

View File

@ -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

View File

@ -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<string>();
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");

View File

@ -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,
};

View File

@ -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<boolean>
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 : "<detached>";
@ -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<GitWorktreeBranchAncestryVerdict> {
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,

View File

@ -63,6 +63,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
enableWorkspaceBranchReconcileForward: false,
};
}