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:
parent
c5d73844a5
commit
5163208c3c
|
|
@ -762,6 +762,8 @@ export type {
|
|||
FinanceByKind,
|
||||
AgentWakeupResponse,
|
||||
AgentWakeupSkipped,
|
||||
GitWorktreeBranchAncestryVerdict,
|
||||
GitWorktreeBranchIncoherenceEvidence,
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
HeartbeatRunStatusPhase,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -467,6 +467,8 @@ export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } fro
|
|||
export type {
|
||||
AgentWakeupResponse,
|
||||
AgentWakeupSkipped,
|
||||
GitWorktreeBranchAncestryVerdict,
|
||||
GitWorktreeBranchIncoherenceEvidence,
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
HeartbeatRunStatusPhase,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableServerInfoDebugView: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
enableWorkspaceBranchReconcileForward: boolean;
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
enableWorkspaceBranchReconcileForward: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue