fix: repair dirty/foreign-branch execution worktrees (#9297)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Execution workspaces are the bridge between Paperclip's control
plane and a local agent's checked-out repository state.
> - When a workspace is restored after a failed or interrupted run, the
recorded branch can disagree with the branch currently checked out on
disk.
> - A clean branch mismatch can be reconciled safely, but a dirty
mismatch needs a lossless path that does not discard uncommitted agent
work.
> - This pull request adds a quarantine-and-restore path that saves
dirty work to a rescue branch, restores the recorded branch, and exposes
the repair from the board UI and run page.
> - The benefit is that operators can recover wedged execution
workspaces without losing work or moving another live branch
unexpectedly.

## Linked Issues or Issue Description

No public GitHub issue exists for this workspace-recovery failure, so
this PR includes the bug report inline.

**What happened**

A git worktree-backed execution workspace could become wedged when
Paperclip expected one branch but found a different checked-out branch
with dirty tracked or untracked files. The existing safe repair path
refused the restore, leaving the source task blocked with no lossless
one-click recovery path.

**Expected behavior**

Paperclip should preserve dirty work before restoring the recorded
workspace branch. If another live workspace claims the checked-out
branch, or an attached runtime service is active, the repair should
refuse with clear operator-facing evidence instead of risking work loss
or file contention.

**Steps to reproduce**

Create a git worktree execution workspace whose persisted branch name
differs from the checked-out branch, add dirty tracked or untracked
files in that worktree, then trigger workspace validation or use the
branch reconcile endpoint. Before this change, the dirty mismatch
remained blocked because Paperclip had no quarantine restore mode.

**Paperclip version or commit**

Observed on the pre-fix workspace-recovery implementation. Verified on
this PR head after rebasing onto current `master`.

**Deployment mode**

Local trusted development/worktree deployments using git worktree
execution workspaces and optional workspace runtime services.

## What Changed

- Added dirty-worktree quarantine repair that creates a rescue branch,
commits dirty tracked and untracked files there, restores the recorded
branch, writes audit comments/activity, and preserves the live foreign
branch ref.
- Added `quarantine_restore` branch reconcile API support,
recovery-action resolution, source-task wake behavior, execution-review
preservation, claimant refusal, runtime-service refusal, and coverage
for the non-transactional git ordering.
- Added board UI controls for the repair action in the recovery card
plus a compact failed-run workspace recovery surface that uses the same
reconcile handlers.
- Hardened Greptile follow-up cases by best-effort restoring the
recorded branch after a mid-sequence rescue commit failure and by
refusing quarantine restore while attached runtime services are active.

## Verification

- `pnpm exec vitest run
server/src/__tests__/execution-workspaces-service.test.ts -t
"quarantine_restore"`
- `pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts
-t "workspace dirty quarantine branch repair"`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts -t
"repairs clean unrecorded branch drift|adopts unrecorded forward branch
drift"`
- `pnpm exec vitest run
server/src/__tests__/workspace-runtime-routes-authz.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- Earlier PR verification covered the route, service, heartbeat, UI
component, and run-page recovery surfaces; Cutter posted public preview
screenshots for the repair popover and run-page panel at
https://github.com/paperclipai/paperclip/pull/9297#issuecomment-4926934211.
- GitHub PR checks are green on
`dcac76b05f4cf6e1ee16544c2831d83c7857e475`.
- Greptile is 5/5 with zero annotations and no unresolved review threads
on `dcac76b05f4cf6e1ee16544c2831d83c7857e475`.

## Risks

- Moderate risk because the change intentionally runs git commands
against local worktrees; the implementation refuses dirty repair when
another claimant or active runtime service is detected and records
rescue refs for auditability.
- Compatibility / release-note callout for self-hosted operators:
existing instances that left `enableWorkspaceBranchReconcileForward`
unset now get automatic forward branch reconciliation during heartbeat
workspace recovery. Operators who want the previous advisory-only
behavior can set `experimental.enableWorkspaceBranchReconcileForward` to
`false`; dirty quarantine repair can likewise be disabled with
`experimental.enableWorkspaceDirtyQuarantineRepair: false`.
- If the git rescue succeeds but a later database write fails, the
worktree may already be restored while the recovery action remains open;
this ordering is documented in code because git side effects cannot
participate in the database transaction.
- UI risk is limited to the workspace recovery surfaces and covered by
component tests plus the existing Cutter visual preview.

## Model Used

OpenAI Codex coding agent based on GPT-5, with repository tool use,
shell execution, and local test execution. Earlier preserved commits on
this branch also show Claude Code / Claude Opus 4.8 assistance in their
commit metadata.

## 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] Branch naming exception documented: this PR preserves the existing
worktree branch requested for publication while keeping the PR title and
body public-facing
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-07-09 18:19:49 -05:00 committed by GitHub
parent 4d898aa7af
commit f3ca4d24bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 3783 additions and 140 deletions

View File

@ -819,6 +819,7 @@ export type {
AgentWakeupSkipped,
GitWorktreeBranchAncestryVerdict,
GitWorktreeBranchIncoherenceEvidence,
GitWorktreeInProgressOperation,
HeartbeatRun,
HeartbeatRunEvent,
HeartbeatRunStatusPhase,

View File

@ -10,6 +10,8 @@ import type {
export type GitWorktreeBranchAncestryVerdict = "ancestor" | "diverged" | "unknown";
export type GitWorktreeInProgressOperation = "rebase" | "merge" | "cherry_pick" | "revert" | "bisect";
export interface GitWorktreeBranchIncoherenceEvidence {
reason: "git_worktree_branch_incoherence";
fingerprint: string;
@ -21,7 +23,25 @@ export interface GitWorktreeBranchIncoherenceEvidence {
expectedBranch: string;
actualBranch: string | null;
cleanliness: "clean" | "dirty" | "unknown";
/**
* Interrupted git operation (rebase/merge/cherry-pick/revert/bisect) whose
* state directory is still present in the worktree. Optional so previously
* persisted evidence payloads stay valid.
*/
inProgressOperation?: GitWorktreeInProgressOperation | null;
statusEntryCount: number | null;
dirtyPathSample: string[];
contention: {
claimedByWorkspaceId: string;
claimedByIssueId: string | null;
claimedByIssueIdentifier: string | null;
activeRun: {
id: string;
status: "queued" | "running";
issueId: string | null;
issueIdentifier: string | null;
} | null;
} | null;
provenance: {
expectedBranchRef: string;
actualBranchRef: string | null;

View File

@ -509,6 +509,7 @@ export type {
AgentWakeupSkipped,
GitWorktreeBranchAncestryVerdict,
GitWorktreeBranchIncoherenceEvidence,
GitWorktreeInProgressOperation,
HeartbeatRun,
HeartbeatRunEvent,
HeartbeatRunStatusPhase,

View File

@ -61,6 +61,7 @@ export interface InstanceExperimentalSettings {
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
enableWorkspaceBranchReconcileForward: boolean;
enableWorkspaceDirtyQuarantineRepair: boolean;
/**
* Worktree preview instances (`PAPERCLIP_IN_WORKTREE=true`) suppress the
* heartbeat run engine by default so previews never self-execute tasks. When

View File

@ -160,6 +160,10 @@ export const reconcileExecutionWorkspaceBranchSchema = z.discriminatedUnion("mod
mode: z.literal("override"),
reason: branchReconcileReasonSchema,
}).strict(),
z.object({
mode: z.literal("quarantine_restore"),
reason: branchReconcileReasonSchema.optional().nullable(),
}).strict(),
]);
export type UpdateExecutionWorkspace = z.infer<typeof updateExecutionWorkspaceSchema>;

View File

@ -11,10 +11,11 @@ describe("instance experimental settings validators", () => {
expect(settings.enableServerInfoDebugView).toBe(false);
});
it("defaults workspace branch forward reconciliation off", () => {
it("defaults workspace branch repair settings on", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
expect(settings.enableWorkspaceBranchReconcileForward).toBe(false);
expect(settings.enableWorkspaceBranchReconcileForward).toBe(true);
expect(settings.enableWorkspaceDirtyQuarantineRepair).toBe(true);
});
it("defaults the goals sidebar link off", () => {
@ -58,10 +59,12 @@ describe("instance experimental settings validators", () => {
it("accepts workspace branch forward reconciliation patches", () => {
expect(
patchInstanceExperimentalSettingsSchema.parse({
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceDirtyQuarantineRepair: false,
}),
).toEqual({
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceDirtyQuarantineRepair: false,
});
});

View File

@ -54,7 +54,8 @@ 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),
enableWorkspaceBranchReconcileForward: z.boolean().default(true),
enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true),
enableWorktreeRunExecution: z.boolean().default(false),
issueGraphLivenessAutoRecoveryLookbackHours: z
.number()

View File

@ -19,6 +19,10 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({
createRecorder: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
@ -27,6 +31,7 @@ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
@ -77,6 +82,7 @@ describe.sequential("execution workspace routes", () => {
]);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch.mockResolvedValue(null);
mockHeartbeatService.wakeup.mockResolvedValue(null);
});
it("uses summary mode for lightweight workspace lookups", async () => {
@ -133,6 +139,7 @@ describe.sequential("execution workspace routes", () => {
it.each([
["forward", { mode: "forward" }],
["override", { mode: "override", reason: "operator break-glass" }],
["quarantine_restore", { mode: "quarantine_restore", reason: "rescue dirty branch" }],
])("rejects agent actors for %s branch reconciliation", async (_mode, body) => {
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "workspace-1",
@ -220,4 +227,166 @@ describe.sequential("execution workspace routes", () => {
}),
}));
});
it("accepts quarantine_restore, logs the rescue ref, and wakes the restored source issue", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
});
mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch.mockResolvedValue({
workspace: {
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
branchName: "feature/recorded",
},
inspection: {
fingerprint: "workspace_incoherence:v1:sha256:dirty",
worktreePath: "/tmp/worktree",
repoRoot: "/tmp/repo",
fromBranch: "feature/recorded",
toBranch: "feature/live",
fromSha: "1111111",
toSha: "2222222",
ancestryVerdict: "diverged",
cleanliness: "dirty",
statusEntryCount: 2,
plainLanguageReason: "dirty live branch",
},
recoveryAction: {
id: "recovery-1",
},
auditCommentId: "comment-1",
rescueRef: {
branchName: "paperclip/rescue/PAP-123/20260709T120000Z",
commitSha: "3333333",
fileCount: 2,
sourceAuditCommentId: "comment-0",
claimantAuditCommentId: null,
},
restoredSourceIssue: {
id: "issue-1",
companyId: "company-1",
status: "todo",
assigneeAgentId: "agent-1",
},
sourceIssueStatusChanged: true,
});
const res = await request(createApp())
.post("/api/execution-workspaces/workspace-1/reconcile-branch")
.send({ mode: "quarantine_restore" });
expect(res.status).toBe(200);
expect(mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch).toHaveBeenCalledWith("workspace-1", {
mode: "quarantine_restore",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "execution_workspace.branch_reconciled",
entityType: "execution_workspace",
entityId: "workspace-1",
details: expect.objectContaining({
mode: "quarantine_restore",
fingerprint: "workspace_incoherence:v1:sha256:dirty",
recoveryActionId: "recovery-1",
rescueRef: expect.objectContaining({
branchName: "paperclip/rescue/PAP-123/20260709T120000Z",
commitSha: "3333333",
}),
sourceIssueStatus: "todo",
}),
}));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith("agent-1", expect.objectContaining({
source: "automation",
reason: "issue_recovery_action_restored",
payload: expect.objectContaining({
issueId: "issue-1",
recoveryActionId: "recovery-1",
executionWorkspaceId: "workspace-1",
rescueRef: "paperclip/rescue/PAP-123/20260709T120000Z",
mutation: "execution_workspace_quarantine_restore",
}),
contextSnapshot: expect.objectContaining({
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "issue_recovery_action_restored",
source: "execution_workspace.quarantine_restore",
recoveryActionId: "recovery-1",
executionWorkspaceId: "workspace-1",
rescueRef: "paperclip/rescue/PAP-123/20260709T120000Z",
}),
}));
});
it("wakes a restored in_review agent participant after quarantine_restore", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
});
mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch.mockResolvedValue({
workspace: {
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
branchName: "feature/recorded",
},
inspection: {
fingerprint: "workspace_incoherence:v1:sha256:dirty",
worktreePath: "/tmp/worktree",
repoRoot: "/tmp/repo",
fromBranch: "feature/recorded",
toBranch: "feature/live",
fromSha: "1111111",
toSha: "2222222",
ancestryVerdict: "diverged",
cleanliness: "dirty",
statusEntryCount: 2,
plainLanguageReason: "dirty live branch",
},
recoveryAction: {
id: "recovery-1",
},
auditCommentId: "comment-1",
rescueRef: null,
restoredSourceIssue: {
id: "issue-1",
companyId: "company-1",
status: "in_review",
assigneeAgentId: "reviewer-agent-1",
},
sourceIssueStatusChanged: true,
});
const res = await request(createApp())
.post("/api/execution-workspaces/workspace-1/reconcile-branch")
.send({ mode: "quarantine_restore" });
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
details: expect.objectContaining({
sourceIssueStatus: "in_review",
}),
}));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith("reviewer-agent-1", expect.objectContaining({
reason: "issue_recovery_action_restored",
payload: expect.objectContaining({
issueId: "issue-1",
mutation: "execution_workspace_quarantine_restore",
}),
contextSnapshot: expect.objectContaining({
issueId: "issue-1",
wakeReason: "issue_recovery_action_restored",
source: "execution_workspace.quarantine_restore",
}),
}));
});
});

View File

@ -8,9 +8,12 @@ import { promisify } from "node:util";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, inArray, sql } from "drizzle-orm";
import {
activityLog,
agents,
companies,
createDb,
executionWorkspaces,
heartbeatRuns,
issueComments,
issueRecoveryActions,
issues,
@ -226,12 +229,15 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
afterEach(async () => {
await db.delete(workspaceRuntimeServices);
await db.delete(activityLog);
await db.delete(issueRecoveryActions);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
for (const dir of tempDirs) {
@ -625,6 +631,640 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
});
}, 20_000);
it("quarantine_restore rescues dirty live-branch work, resolves recovery, and returns the source issue to todo", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-restore-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "dirty untracked work\n", "utf8");
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const actualBranch = await readGit(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
const fingerprint = await fingerprintWorkspaceBranchIncoherenceForTest({
repoRoot,
worktreePath,
sourceIssueId: issueId,
executionWorkspaceId,
expectedBranch: "feature/recorded",
actualBranch,
});
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source task",
identifier: "PAP-124",
status: "blocked",
priority: "medium",
assigneeAgentId: agentId,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(issueRecoveryActions).values({
companyId,
sourceIssueId: issueId,
kind: "workspace_validation",
status: "active",
ownerType: "board",
cause: "workspace_validation_failed",
fingerprint,
evidence: {
workspaceValidation: {
fingerprint,
},
},
nextAction: "Repair the source issue workspace link.",
});
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "quarantine_restore",
reason: "rescue dirty work and restore recorded branch",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(result.workspace.branchName).toBe("feature/recorded");
expect(result.inspection).toMatchObject({
fromBranch: "feature/recorded",
toBranch: "feature/live",
cleanliness: "dirty",
fingerprint,
});
expect(result.rescueRef).toMatchObject({
branchName: expect.stringMatching(/^paperclip\/rescue\/PAP-124\/\d{8}T\d{6}Z$/),
fileCount: 2,
});
expect(result.restoredSourceIssue).toMatchObject({
id: issueId,
status: "todo",
assigneeAgentId: agentId,
});
expect(result.sourceIssueStatusChanged).toBe(true);
expect(result.recoveryAction).toMatchObject({
kind: "workspace_validation",
status: "resolved",
outcome: "restored",
fingerprint,
});
const rescueRef = result.rescueRef!.branchName;
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/recorded");
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.toBeNull();
await expect(readGit(repoRoot, ["show", `${rescueRef}:untracked.txt`])).resolves.toBe("dirty untracked work");
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(sourceIssue).toMatchObject({
status: "todo",
checkoutRunId: null,
executionRunId: null,
});
const [recoveryAction] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, issueId));
expect(recoveryAction).toMatchObject({
status: "resolved",
outcome: "restored",
resolutionNote: `Execution workspace dirty worktree quarantined on "${rescueRef}" and restored recorded branch "feature/recorded".`,
});
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(issueComments.createdAt);
expect(comments).toHaveLength(2);
expect(comments[0]?.body).toContain("Execution workspace dirty worktree quarantined before restore.");
expect(comments[0]?.body).toContain(`Rescue branch: \`${rescueRef}\``);
expect(comments[1]?.body).toContain("Execution workspace branch reconciled.");
expect(comments[1]?.body).toContain("- Mode: `quarantine_restore`");
expect(comments[1]?.body).toContain(`- Rescue ref: \`${rescueRef}\``);
}, 20_000);
it("quarantine_restore rejects active runtime services before creating a rescue branch", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-running-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeServiceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
identifier: "PAP-125",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId,
projectId,
executionWorkspaceId,
issueId,
scopeType: "execution_workspace",
serviceName: "web",
status: "running",
lifecycle: "shared",
command: "pnpm dev",
cwd: worktreePath,
provider: "local_process",
healthStatus: "healthy",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "quarantine_restore",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires all runtime services to be stopped",
details: {
inspection: expect.objectContaining({
cleanliness: "dirty",
fromBranch: "feature/recorded",
toBranch: "feature/live",
}),
runtimeServices: [
{
id: runtimeServiceId,
serviceName: "web",
status: "running",
},
],
},
});
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/live");
await expect(readGit(
repoRoot,
["for-each-ref", "--format=%(refname:short)", "refs/heads/paperclip/rescue"],
)).resolves.toBeNull();
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it.each(["review", "approval"] as const)(
"quarantine_restore preserves pending execution-%s semantics on the source issue",
async (stageType) => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-${stageType}-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked review work\n", "utf8");
const companyId = randomUUID();
const coderAgentId = randomUUID();
const reviewerAgentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const reviewStageId = randomUUID();
const actualBranch = await readGit(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
const fingerprint = await fingerprintWorkspaceBranchIncoherenceForTest({
repoRoot,
worktreePath,
sourceIssueId: issueId,
executionWorkspaceId,
expectedBranch: "feature/recorded",
actualBranch,
});
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: coderAgentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: reviewerAgentId,
companyId,
name: "QA Reviewer",
role: "qa",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source task awaiting review",
identifier: "PAP-125",
status: "blocked",
priority: "medium",
assigneeAgentId: coderAgentId,
executionPolicy: {
stages: [
{
id: reviewStageId,
type: stageType,
participants: [{ type: "agent", agentId: reviewerAgentId }],
},
],
},
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageIndex: 0,
currentStageType: stageType,
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(issueRecoveryActions).values({
companyId,
sourceIssueId: issueId,
kind: "workspace_validation",
status: "active",
ownerType: "board",
cause: "workspace_validation_failed",
fingerprint,
evidence: {
workspaceValidation: {
fingerprint,
},
},
nextAction: "Repair the source issue workspace link.",
});
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "quarantine_restore",
reason: "rescue dirty work and restore recorded branch",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(result.restoredSourceIssue).toMatchObject({
id: issueId,
status: "in_review",
assigneeAgentId: reviewerAgentId,
});
expect(result.sourceIssueStatusChanged).toBe(true);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(sourceIssue).toMatchObject({
status: "in_review",
assigneeAgentId: reviewerAgentId,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
});
expect(sourceIssue?.executionState).toMatchObject({
status: "pending",
currentStageId: reviewStageId,
currentStageType: stageType,
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
});
}, 20_000);
it.each([
{
claimantLabel: "active",
claimantIssueIdentifier: "PAP-126",
claimantHasActiveRun: true,
expectedReason: "active run",
},
{
claimantLabel: "idle",
claimantIssueIdentifier: "PAP-127",
claimantHasActiveRun: false,
expectedReason: "no active run",
},
])(
"quarantine_restore refuses dirty repair when the live branch has a $claimantLabel claimant",
async ({ claimantIssueIdentifier, claimantHasActiveRun, expectedReason }) => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-claimant-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "dirty untracked work\n", "utf8");
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const claimantIssueId = randomUUID();
const executionWorkspaceId = randomUUID();
const claimantWorkspaceId = randomUUID();
const claimantRunId = claimantHasActiveRun ? randomUUID() : null;
const claimantWorkspacePath = path.join(path.dirname(repoRoot), `paperclip-claimant-${randomUUID()}`);
const now = new Date();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
});
if (claimantRunId) {
await db.insert(heartbeatRuns).values({
id: claimantRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: now,
updatedAt: now,
});
}
await db.insert(issues).values([
{
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source task",
identifier: "PAP-125",
status: "blocked",
priority: "medium",
assigneeAgentId: agentId,
},
{
id: claimantIssueId,
companyId,
projectId,
projectWorkspaceId,
title: claimantHasActiveRun ? "Active claimant" : "Idle claimant",
identifier: claimantIssueIdentifier,
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: claimantRunId,
},
]);
await db.insert(executionWorkspaces).values([
{
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
},
{
id: claimantWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: claimantIssueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/live",
status: "active",
providerType: "git_worktree",
cwd: claimantWorkspacePath,
providerRef: claimantWorkspacePath,
branchName: "feature/live",
baseRef: "main",
lastUsedAt: new Date(now.getTime() + 1_000),
updatedAt: new Date(now.getTime() + 1_000),
},
]);
await db
.update(issues)
.set({ executionWorkspaceId: claimantWorkspaceId })
.where(eq(issues.id, claimantIssueId));
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "quarantine_restore",
reason: "should refuse branch claimant",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
details: {
code: "workspace_validation_failed",
workspaceValidation: expect.objectContaining({
cleanliness: "dirty",
contention: expect.objectContaining({
claimedByWorkspaceId: claimantWorkspaceId,
claimedByIssueIdentifier: claimantIssueIdentifier,
activeRun: claimantRunId
? expect.objectContaining({
id: claimantRunId,
status: "running",
})
: null,
}),
safeRepair: expect.objectContaining({
eligible: false,
succeeded: false,
reason: expect.stringContaining(expectedReason),
}),
}),
},
});
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/live");
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.not.toBeNull();
},
20_000,
);
it("rejects branch reconciliation when the worktree is dirty", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { shouldAutoCheckoutIssueForWake } from "../services/heartbeat.ts";
describe("shouldAutoCheckoutIssueForWake", () => {
it("auto-checks out an assigned todo issue for an actionable wake", () => {
expect(shouldAutoCheckoutIssueForWake({
contextSnapshot: { wakeReason: "issue_assigned" },
issueStatus: "todo",
issueAssigneeAgentId: "agent-1",
isDependencyReady: true,
agentId: "agent-1",
})).toBe(true);
});
it("does not auto-checkout pending execution-review state even if the row status is todo", () => {
const reviewerAgentId = "11111111-1111-4111-8111-111111111111";
const coderAgentId = "22222222-2222-4222-8222-222222222222";
expect(shouldAutoCheckoutIssueForWake({
contextSnapshot: { wakeReason: "issue_recovery_action_restored" },
issueStatus: "todo",
issueAssigneeAgentId: reviewerAgentId,
issueExecutionState: {
status: "pending",
currentStageId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
isDependencyReady: true,
agentId: reviewerAgentId,
})).toBe(false);
});
});

View File

@ -87,7 +87,8 @@ describe("instance settings routes", () => {
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
},
createdAt: "2026-06-20T00:00:00.000Z",
@ -111,7 +112,8 @@ describe("instance settings routes", () => {
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
});
mockInstanceSettingsService.update.mockResolvedValue({
@ -134,7 +136,8 @@ describe("instance settings routes", () => {
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
},
createdAt: "2026-06-20T00:00:00.000Z",
@ -163,7 +166,8 @@ describe("instance settings routes", () => {
enableServerInfoDebugView: true,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
},
});
@ -220,7 +224,8 @@ describe("instance settings routes", () => {
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
});

View File

@ -16,6 +16,7 @@ describe("instance settings service", () => {
autoRestartDevServerWhenIdle: true,
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: false,
issueGraphLivenessAutoRecoveryLookbackHours: 48,
enableNewestFirstIssueThread: true,
})).toEqual({
@ -35,6 +36,7 @@ describe("instance settings service", () => {
autoRestartDevServerWhenIdle: true,
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: false,
enableWorktreeRunExecution: false,
issueGraphLivenessAutoRecoveryLookbackHours: 48,
});
@ -73,13 +75,19 @@ 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);
it("defaults workspace branch repair settings to true for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(true);
expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(true);
expect(
normalizeExperimentalSettings({ enableIssueGraphLivenessAutoRecovery: true })
.enableWorkspaceBranchReconcileForward,
).toBe(false);
).toBe(true);
expect(normalizeExperimentalSettings(undefined).enableWorkspaceDirtyQuarantineRepair).toBe(true);
expect(normalizeExperimentalSettings({}).enableWorkspaceDirtyQuarantineRepair).toBe(true);
expect(
normalizeExperimentalSettings({ enableWorkspaceBranchReconcileForward: false })
.enableWorkspaceDirtyQuarantineRepair,
).toBe(true);
});
it("round-trips an enableConferenceRoomChat patch through the update merge", () => {

View File

@ -26,6 +26,7 @@ const mockEnvironmentService = vi.hoisted(() => ({
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockHeartbeatService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockAccessService = vi.hoisted(() => ({
@ -42,6 +43,7 @@ vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
@ -69,6 +71,7 @@ function registerWorkspaceRouteMocks() {
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,

View File

@ -1,5 +1,6 @@
import { execFile, spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
@ -9,11 +10,14 @@ import { promisify } from "node:util";
import { parse as parseEnvContents } from "dotenv";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
companies,
createDb,
executionWorkspaces,
heartbeatRuns,
issueComments,
issues,
projectWorkspaces,
projects,
workspaceRuntimeServices,
@ -2432,6 +2436,7 @@ describe("realizeExecutionWorkspace", () => {
name: "Codex Coder",
companyId: "company-1",
},
enableWorkspaceDirtyQuarantineRepair: false,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
@ -2444,6 +2449,7 @@ describe("realizeExecutionWorkspace", () => {
expectedBranch,
actualBranch,
cleanliness: "dirty",
dirtyPathSample: ["untracked.txt"],
provenance: expect.objectContaining({
expectedBranchExists: true,
actualBranchExists: true,
@ -4090,6 +4096,648 @@ describe("readLocalServicePortOwner", () => {
});
});
describeEmbeddedPostgres("workspace dirty quarantine branch repair", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-dirty-quarantine-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterAll(async () => {
await tempDb?.cleanup();
});
afterEach(async () => {
await db.delete(issueComments);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(workspaceRuntimeServices);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
async function createDirtyMismatchRepo(input: {
expectedBranch: string;
actualBranch: string;
}) {
const repoRoot = await createTempRepo();
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", input.expectedBranch);
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
await runGit(repoRoot, ["branch", input.expectedBranch]);
await runGit(repoRoot, ["worktree", "add", "-b", input.actualBranch, worktreePath, input.expectedBranch]);
const actualBranchHead = await readGit(worktreePath, ["rev-parse", input.actualBranch]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "dirty untracked work\n", "utf8");
return { repoRoot, worktreePath, actualBranchHead };
}
async function seedDirtyQuarantineRecords(input: {
repoRoot: string;
worktreePath: string;
expectedBranch: string;
actualBranch: string;
sourceIdentifier?: string;
claimant?: "idle" | "active" | "none";
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const sourceIssueId = randomUUID();
const sourceWorkspaceId = randomUUID();
const runId = randomUUID();
const now = new Date();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `Q${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Paperclip App",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: input.repoRoot,
isPrimary: true,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: now,
updatedAt: now,
});
await db.insert(issues).values({
id: sourceIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Repair dirty branch mismatch",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
identifier: input.sourceIdentifier ?? "PAP-455",
});
await db.insert(executionWorkspaces).values({
id: sourceWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: input.expectedBranch,
status: "active",
cwd: input.worktreePath,
providerRef: input.worktreePath,
baseRef: "HEAD",
branchName: input.expectedBranch,
providerType: "git_worktree",
lastUsedAt: now,
updatedAt: now,
});
await db
.update(issues)
.set({ executionWorkspaceId: sourceWorkspaceId, executionRunId: runId, updatedAt: now })
.where(eq(issues.id, sourceIssueId));
let claimant:
| {
issueId: string;
workspaceId: string;
runId: string | null;
identifier: string;
}
| null = null;
if (input.claimant && input.claimant !== "none") {
const claimantIssueId = randomUUID();
const claimantWorkspaceId = randomUUID();
const claimantRunId = input.claimant === "active" ? randomUUID() : null;
const claimantIdentifier = "PAP-999";
await db.insert(issues).values({
id: claimantIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Live branch claimant",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
identifier: claimantIdentifier,
});
if (claimantRunId) {
await db.insert(heartbeatRuns).values({
id: claimantRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: now,
updatedAt: now,
});
}
await db.insert(executionWorkspaces).values({
id: claimantWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: claimantIssueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: input.actualBranch,
status: "active",
cwd: path.join(input.repoRoot, ".paperclip", "claimants", claimantWorkspaceId),
providerRef: path.join(input.repoRoot, ".paperclip", "claimants", claimantWorkspaceId),
baseRef: "HEAD",
branchName: input.actualBranch,
providerType: "git_worktree",
lastUsedAt: new Date(now.getTime() + 1_000),
updatedAt: new Date(now.getTime() + 1_000),
});
await db
.update(issues)
.set({
executionWorkspaceId: claimantWorkspaceId,
executionRunId: claimantRunId,
updatedAt: now,
})
.where(eq(issues.id, claimantIssueId));
claimant = {
issueId: claimantIssueId,
workspaceId: claimantWorkspaceId,
runId: claimantRunId,
identifier: claimantIdentifier,
};
}
return {
companyId,
agentId,
projectId,
projectWorkspaceId,
sourceIssueId,
sourceWorkspaceId,
runId,
claimant,
sourceIdentifier: input.sourceIdentifier ?? "PAP-455",
};
}
async function restoreDirtyQuarantine(input: {
repoRoot: string;
worktreePath: string;
expectedBranch: string;
actualBranch: string;
ids: Awaited<ReturnType<typeof seedDirtyQuarantineRecords>>;
recorder?: WorkspaceOperationRecorder | null;
}) {
return ensurePersistedExecutionWorkspaceAvailable({
db,
base: {
baseCwd: input.repoRoot,
source: "project_primary",
projectId: input.ids.projectId,
workspaceId: input.ids.projectWorkspaceId,
repoUrl: null,
repoRef: "HEAD",
},
workspace: {
id: input.ids.sourceWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
cwd: input.worktreePath,
providerRef: input.worktreePath,
projectId: input.ids.projectId,
projectWorkspaceId: input.ids.projectWorkspaceId,
repoUrl: null,
baseRef: "HEAD",
branchName: input.expectedBranch,
},
issue: {
id: input.ids.sourceIssueId,
identifier: input.ids.sourceIdentifier,
title: "Repair dirty branch mismatch",
},
agent: {
id: input.ids.agentId,
name: "Codex Coder",
companyId: input.ids.companyId,
},
heartbeatRunId: input.ids.runId,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
recorder: input.recorder ?? null,
});
}
it("quarantines dirty foreign-branch work into a rescue branch before restoring the recorded branch", async () => {
const expectedBranch = "PAP-455-recorded";
const actualBranch = "PAP-455-live";
const { repoRoot, worktreePath, actualBranchHead } = await createDirtyMismatchRepo({
expectedBranch,
actualBranch,
});
const ids = await seedDirtyQuarantineRecords({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
sourceIdentifier: "PAP-455",
claimant: "none",
});
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
const restored = await restoreDirtyQuarantine({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
ids,
recorder,
});
expect(restored?.branchName).toBe(expectedBranch);
const warning = restored?.warnings.find((entry) => entry.includes("dirty worktree state was quarantined"));
expect(warning).toBeTruthy();
const rescueBranch = warning?.match(/"([^"]+)"/)?.[1] ?? "";
expect(rescueBranch).toMatch(/^paperclip\/rescue\/PAP-455\/\d{8}T\d{6}Z$/);
const rescueCommitSha = await readGit(repoRoot, ["rev-parse", rescueBranch]);
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(expectedBranch);
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.toBe("");
await expect(readGit(repoRoot, ["rev-parse", actualBranch])).resolves.toBe(actualBranchHead);
await expect(readGit(repoRoot, ["show", `${rescueBranch}:untracked.txt`])).resolves.toBe("dirty untracked work");
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.companyId, ids.companyId));
expect(comments).toHaveLength(1);
expect(comments[0]?.issueId).toBe(ids.sourceIssueId);
expect(comments[0]?.body).toContain(`Rescue branch: \`${rescueBranch}\``);
expect(comments[0]?.body).toContain(`Rescue commit: \`${rescueCommitSha}\``);
expect(comments[0]?.body).toContain("Dirty file count: `2`");
expect(comments[0]?.body).toContain("`untracked.txt`");
expect(comments[0]?.body).toContain("- Claimant: none");
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.companyId, ids.companyId));
expect(activityRows).toEqual([
expect.objectContaining({
action: "execution_workspace.dirty_worktree_quarantined",
entityType: "execution_workspace",
entityId: ids.sourceWorkspaceId,
details: expect.objectContaining({
rescueBranch,
rescueCommitSha,
fileCount: 2,
dirtyPathSample: expect.arrayContaining(["README.md", "untracked.txt"]),
}),
}),
]);
expect(operations).toEqual(expect.arrayContaining([
expect.objectContaining({
command: `git checkout -b ${rescueBranch}`,
metadata: expect.objectContaining({
branchIncoherenceDirtyQuarantineRepair: true,
rescueBranch,
fileCount: 2,
}),
}),
expect.objectContaining({
command: null,
metadata: expect.objectContaining({
branchIncoherenceDirtyQuarantineRepair: true,
rescueBranch,
rescueCommitSha,
}),
}),
]));
}, 20_000);
it("quarantines a worktree wedged mid-rebase and clears the interrupted rebase state", async () => {
const expectedBranch = "PAP-456-recorded";
const repoRoot = await createTempRepo("master");
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", worktreePath, expectedBranch]);
await fs.writeFile(path.join(worktreePath, "README.md"), "feature change\n", "utf8");
await runGit(worktreePath, ["commit", "-am", "Feature change"]);
const expectedBranchHead = await readGit(worktreePath, ["rev-parse", expectedBranch]);
await fs.writeFile(path.join(repoRoot, "README.md"), "master change\n", "utf8");
await runGit(repoRoot, ["commit", "-am", "Master change"]);
await expect(runGit(worktreePath, ["rebase", "master"])).rejects.toThrow();
const rebaseStatePath = await readGit(worktreePath, ["rev-parse", "--git-path", "rebase-merge"]);
expect(existsSync(path.resolve(worktreePath, rebaseStatePath))).toBe(true);
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("");
const ids = await seedDirtyQuarantineRecords({
repoRoot,
worktreePath,
expectedBranch,
actualBranch: "PAP-456-live",
sourceIdentifier: "PAP-456",
claimant: "none",
});
const { recorder } = createWorkspaceOperationRecorderDouble();
const restored = await restoreDirtyQuarantine({
repoRoot,
worktreePath,
expectedBranch,
actualBranch: "PAP-456-live",
ids,
recorder,
});
expect(restored?.branchName).toBe(expectedBranch);
const warning = restored?.warnings.find((entry) => entry.includes("dirty worktree state was quarantined"));
expect(warning).toContain("An interrupted git rebase was also cleared");
const rescueBranch = warning?.match(/"([^"]+)"/)?.[1] ?? "";
expect(rescueBranch).toMatch(/^paperclip\/rescue\/PAP-456\/\d{8}T\d{6}Z$/);
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(expectedBranch);
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.toBe("");
expect(existsSync(path.resolve(worktreePath, rebaseStatePath))).toBe(false);
await expect(readGit(repoRoot, ["rev-parse", expectedBranch])).resolves.toBe(expectedBranchHead);
await expect(readGit(repoRoot, ["show", `${rescueBranch}:README.md`])).resolves.toContain("<<<<<<<");
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.companyId, ids.companyId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("Interrupted operation: `git rebase`");
}, 20_000);
it("refuses dirty quarantine repair when the live branch has an active claimant", async () => {
const expectedBranch = "PAP-456-recorded";
const actualBranch = "PAP-456-live";
const { repoRoot, worktreePath } = await createDirtyMismatchRepo({ expectedBranch, actualBranch });
const ids = await seedDirtyQuarantineRecords({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
sourceIdentifier: "PAP-456",
claimant: "active",
});
await expect(restoreDirtyQuarantine({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
ids,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
cleanliness: "dirty",
dirtyPathSample: expect.arrayContaining(["README.md", "untracked.txt"]),
contention: expect.objectContaining({
claimedByWorkspaceId: ids.claimant!.workspaceId,
claimedByIssueIdentifier: ids.claimant!.identifier,
activeRun: expect.objectContaining({
id: ids.claimant!.runId,
status: "running",
issueIdentifier: ids.claimant!.identifier,
}),
}),
safeRepair: expect.objectContaining({
eligible: false,
succeeded: false,
reason: expect.stringContaining("active run"),
}),
}),
},
});
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(actualBranch);
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.not.toBe("");
}, 20_000);
it("refuses dirty quarantine repair when the live branch has an idle claimant", async () => {
const expectedBranch = "PAP-457-recorded";
const actualBranch = "PAP-457-live";
const { repoRoot, worktreePath } = await createDirtyMismatchRepo({ expectedBranch, actualBranch });
const ids = await seedDirtyQuarantineRecords({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
sourceIdentifier: "PAP-457",
claimant: "idle",
});
await expect(restoreDirtyQuarantine({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
ids,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
cleanliness: "dirty",
dirtyPathSample: expect.arrayContaining(["README.md", "untracked.txt"]),
contention: expect.objectContaining({
claimedByWorkspaceId: ids.claimant!.workspaceId,
claimedByIssueIdentifier: ids.claimant!.identifier,
activeRun: null,
}),
safeRepair: expect.objectContaining({
eligible: false,
succeeded: false,
reason: expect.stringContaining("no active run"),
}),
}),
},
});
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(actualBranch);
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.not.toBe("");
}, 20_000);
it("refuses dirty quarantine repair while the execution workspace has an active runtime service", async () => {
const expectedBranch = "PAP-458-recorded";
const actualBranch = "PAP-458-live";
const { repoRoot, worktreePath } = await createDirtyMismatchRepo({ expectedBranch, actualBranch });
const ids = await seedDirtyQuarantineRecords({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
sourceIdentifier: "PAP-458",
claimant: "none",
});
const runtimeServiceId = randomUUID();
await db.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId: ids.companyId,
projectId: ids.projectId,
projectWorkspaceId: ids.projectWorkspaceId,
executionWorkspaceId: ids.sourceWorkspaceId,
issueId: ids.sourceIssueId,
scopeType: "execution_workspace",
scopeId: ids.sourceWorkspaceId,
serviceName: "paperclip-dev",
status: "running",
lifecycle: "shared",
reuseKey: `execution_workspace:${ids.sourceWorkspaceId}:paperclip-dev`,
command: "pnpm dev",
cwd: worktreePath,
port: 49195,
url: "http://127.0.0.1:49195",
provider: "local_process",
providerRef: "999999",
ownerAgentId: ids.agentId,
startedByRunId: ids.runId,
lastUsedAt: new Date(),
startedAt: new Date(),
stoppedAt: null,
stopPolicy: { type: "manual" },
healthStatus: "healthy",
});
await expect(restoreDirtyQuarantine({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
ids,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
cleanliness: "dirty",
safeRepair: expect.objectContaining({
eligible: false,
attempted: false,
succeeded: false,
reason: expect.stringContaining("runtime service"),
}),
}),
},
});
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(actualBranch);
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.not.toBe("");
await expect(readGit(repoRoot, [
"for-each-ref",
"--format=%(refname:short)",
"refs/heads/paperclip/rescue",
])).resolves.toBe("");
}, 20_000);
it("falls back to validation failure when git reports index-lock contention during quarantine", async () => {
const expectedBranch = "PAP-459-recorded";
const actualBranch = "PAP-459-live";
const { repoRoot, worktreePath } = await createDirtyMismatchRepo({ expectedBranch, actualBranch });
const ids = await seedDirtyQuarantineRecords({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
sourceIdentifier: "PAP-459",
claimant: "none",
});
const lockPath = await readGit(worktreePath, ["rev-parse", "--git-path", "index.lock"]);
await fs.writeFile(lockPath, "locked\n", "utf8");
try {
await expect(restoreDirtyQuarantine({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
ids,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
cleanliness: "dirty",
safeRepair: expect.objectContaining({
attempted: true,
succeeded: false,
reason: expect.stringContaining("index contention"),
}),
}),
},
});
} finally {
await fs.rm(lockPath, { force: true });
}
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(actualBranch);
}, 20_000);
it("best-effort restores the recorded branch when the rescue commit fails", async () => {
const expectedBranch = "PAP-460-recorded";
const actualBranch = "PAP-460-live";
const { repoRoot, worktreePath } = await createDirtyMismatchRepo({ expectedBranch, actualBranch });
const ids = await seedDirtyQuarantineRecords({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
sourceIdentifier: "PAP-460",
claimant: "none",
});
const commonDirRaw = await readGit(worktreePath, ["rev-parse", "--git-common-dir"]);
const commonDir = path.isAbsolute(commonDirRaw) ? commonDirRaw : path.resolve(worktreePath, commonDirRaw);
const hookPath = path.join(commonDir, "hooks", "commit-msg");
await fs.mkdir(path.dirname(hookPath), { recursive: true });
await fs.writeFile(hookPath, "#!/bin/sh\necho rescue commit blocked >&2\nexit 1\n", { mode: 0o755 });
await expect(restoreDirtyQuarantine({
repoRoot,
worktreePath,
expectedBranch,
actualBranch,
ids,
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
safeRepair: expect.objectContaining({
attempted: true,
succeeded: false,
reason: expect.stringContaining("rescue commit blocked"),
}),
}),
},
});
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(expectedBranch);
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.not.toBe("");
}, 20_000);
});
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;

View File

@ -12,7 +12,7 @@ import {
} from "@paperclipai/shared";
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { accessService, executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
import { accessService, executionWorkspaceService, heartbeatService, logActivity, workspaceOperationService } from "../services/index.js";
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
@ -26,6 +26,7 @@ import {
stopRuntimeServicesForExecutionWorkspace,
} from "../services/workspace-runtime.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { logger } from "../middleware/logger.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectExecutionWorkspaceCommandPaths,
@ -42,6 +43,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
const svc = executionWorkspaceService(db);
const access = accessService(db);
const workspaceOperationsSvc = workspaceOperationService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
const environmentRuntime = environmentRuntimeService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
@ -536,6 +540,8 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
sourceIssueId: existing.sourceIssueId,
auditCommentId: result.auditCommentId,
recoveryActionId: result.recoveryAction?.id ?? null,
rescueRef: result.rescueRef,
sourceIssueStatus: result.restoredSourceIssue?.status ?? null,
actor: {
type: actor.actorType,
id: actor.actorId,
@ -544,6 +550,41 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
},
});
if (
result.restoredSourceIssue &&
(result.restoredSourceIssue.status === "todo" || result.restoredSourceIssue.status === "in_review") &&
result.sourceIssueStatusChanged &&
result.restoredSourceIssue.assigneeAgentId
) {
void heartbeat.wakeup(result.restoredSourceIssue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_recovery_action_restored",
payload: {
issueId: result.restoredSourceIssue.id,
recoveryActionId: result.recoveryAction?.id ?? null,
executionWorkspaceId: existing.id,
rescueRef: result.rescueRef?.branchName ?? null,
mutation: "execution_workspace_quarantine_restore",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: result.restoredSourceIssue.id,
taskId: result.restoredSourceIssue.id,
wakeReason: "issue_recovery_action_restored",
source: "execution_workspace.quarantine_restore",
recoveryActionId: result.recoveryAction?.id ?? null,
executionWorkspaceId: existing.id,
rescueRef: result.rescueRef?.branchName ?? null,
},
}).catch((err) =>
logger.warn(
{ err, issueId: result.restoredSourceIssue?.id, agentId: result.restoredSourceIssue?.assigneeAgentId },
"failed to wake agent after execution workspace quarantine restore",
));
}
res.json(result);
});

View File

@ -3,9 +3,9 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { and, asc, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { executionWorkspaces, issueComments, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import { executionWorkspaces, heartbeatRuns, issueComments, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import type {
ExecutionWorkspace,
ExecutionWorkspaceSummary,
@ -25,6 +25,11 @@ import type {
} from "@paperclipai/shared";
import { deriveProjectUrlKey, WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
parseIssueExecutionState,
} from "./issue-execution-policy.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
import { visibleIssueCondition } from "./issue-visibility.js";
@ -42,7 +47,7 @@ const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
const WORKSPACE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence";
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
export type ExecutionWorkspaceBranchReconcileMode = "forward" | "override";
export type ExecutionWorkspaceBranchReconcileMode = "forward" | "override" | "quarantine_restore";
export type ExecutionWorkspaceBranchReconcileActor = {
actorType: "agent" | "user" | "system";
@ -70,8 +75,34 @@ export type ExecutionWorkspaceBranchReconcileResult = {
inspection: ExecutionWorkspaceBranchReconcileInspection;
recoveryAction: IssueRecoveryAction | null;
auditCommentId: string | null;
rescueRef: {
branchName: string;
commitSha: string;
fileCount: number;
sourceAuditCommentId: string | null;
claimantAuditCommentId: string | null;
} | null;
restoredSourceIssue: {
id: string;
companyId: string;
status: string;
assigneeAgentId: string | null;
} | null;
sourceIssueStatusChanged: boolean;
};
export type ExecutionWorkspaceGitWorktreeContention = {
claimedByWorkspaceId: string;
claimedByIssueId: string | null;
claimedByIssueIdentifier: string | null;
activeRun: {
id: string;
status: "queued" | "running";
issueId: string | null;
issueIdentifier: string | null;
} | null;
} | null;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@ -87,6 +118,37 @@ function cloneRecord(value: unknown): Record<string, unknown> | null {
return { ...value };
}
function assigneeMatchesExecutionPrincipal(input: {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}, principal: { type: string; agentId?: string | null; userId?: string | null } | null): boolean {
if (!principal) return false;
if (principal.type === "agent") {
return input.assigneeAgentId === principal.agentId && input.assigneeUserId === null;
}
if (principal.type === "user") {
return input.assigneeAgentId === null && input.assigneeUserId === principal.userId;
}
return false;
}
function quarantineRestoreRequestedSourceStatus(input: {
status: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
executionState: unknown;
}): "todo" | undefined {
const state = parseIssueExecutionState(input.executionState);
if (
state?.status === "pending" &&
input.status === "in_review" &&
assigneeMatchesExecutionPrincipal(input, state.currentParticipant)
) {
return undefined;
}
return "todo";
}
function readDesiredState(value: unknown): WorkspaceRuntimeDesiredState | null {
return value === "running" || value === "stopped" || value === "manual" ? value : null;
}
@ -279,6 +341,7 @@ function formatBranchReconcileAuditComment(input: {
workspaceId: string;
inspection: ExecutionWorkspaceBranchReconcileInspection;
recoveryActionId: string | null;
rescueRef: ExecutionWorkspaceBranchReconcileResult["rescueRef"];
}) {
return [
"Execution workspace branch reconciled.",
@ -292,10 +355,31 @@ function formatBranchReconcileAuditComment(input: {
`- Verdict: \`${input.inspection.ancestryVerdict}\``,
`- Fingerprint: \`${input.inspection.fingerprint}\``,
`- Recovery action: ${input.recoveryActionId ? `\`${input.recoveryActionId}\`` : "none matched"}`,
...(input.rescueRef
? [
`- Rescue ref: \`${input.rescueRef.branchName}\``,
`- Rescue commit: \`${input.rescueRef.commitSha}\``,
`- Rescued file count: \`${input.rescueRef.fileCount}\``,
]
: []),
...(input.reason ? [`- Operator reason: ${input.reason}`] : []),
].join("\n");
}
function isWorkspaceRuntimeValidationFailure(error: unknown): error is {
code: "workspace_validation_failed";
message: string;
resultJson: Record<string, unknown>;
} {
if (!error || typeof error !== "object") return false;
const maybe = error as { code?: unknown; resultJson?: unknown; message?: unknown };
return maybe.code === "workspace_validation_failed" &&
typeof maybe.message === "string" &&
Boolean(maybe.resultJson) &&
typeof maybe.resultJson === "object" &&
!Array.isArray(maybe.resultJson);
}
function assertBranchReconcileWorkspaceIsSafe(input: {
workspaceStatus: ExecutionWorkspace["status"];
inspection: ExecutionWorkspaceBranchReconcileInspection;
@ -316,6 +400,16 @@ function assertBranchReconcileWorkspaceIsSafe(input: {
});
}
assertBranchReconcileRuntimeServicesStopped({
inspection: input.inspection,
runtimeServices: input.runtimeServices,
});
}
function assertBranchReconcileRuntimeServicesStopped(input: {
inspection: ExecutionWorkspaceBranchReconcileInspection;
runtimeServices: WorkspaceRuntimeService[];
}) {
const activeRuntimeServices = input.runtimeServices.filter((service) => service.status !== "stopped");
if (activeRuntimeServices.length > 0) {
throw unprocessable("Execution workspace branch reconciliation requires all runtime services to be stopped", {
@ -364,6 +458,66 @@ function assertLockedBranchReconcileWorkspaceStillMatchesInspection(input: {
}
}
async function quarantineRestoreDirtyWorkspaceBranch(input: {
db: Db;
workspace: Pick<ExecutionWorkspace, "id" | "sourceIssueId">;
inspection: ExecutionWorkspaceBranchReconcileInspection;
actor: ExecutionWorkspaceBranchReconcileActor;
}): Promise<NonNullable<ExecutionWorkspaceBranchReconcileResult["rescueRef"]>> {
const sourceIssue = await input.db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
workMode: issues.workMode,
})
.from(issues)
.where(eq(issues.id, input.workspace.sourceIssueId!))
.then((rows) => rows[0] ?? null);
if (!sourceIssue) throw notFound("Source issue not found");
const { ensureGitWorktreeBranchCoherent } = await import("./workspace-runtime.js");
try {
const result = await ensureGitWorktreeBranchCoherent({
db: input.db,
repoRoot: input.inspection.repoRoot,
worktreePath: input.inspection.worktreePath,
expectedBranchName: input.inspection.fromBranch,
actualBranchName: input.inspection.toBranch,
sourceIssue,
executionWorkspaceId: input.workspace.id,
heartbeatRunId: input.actor.runId,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceDirtyQuarantineRepair: true,
persistForwardReconcile: false,
reconcileOperationPhase: "worktree_prepare",
recorder: null,
});
if (!result.dirtyQuarantineRepair) {
throw unprocessable("Quarantine restore requires a dirty foreign-branch worktree to repair", {
inspection: input.inspection,
});
}
return {
branchName: result.dirtyQuarantineRepair.rescueBranch,
commitSha: result.dirtyQuarantineRepair.rescueCommitSha,
fileCount: result.dirtyQuarantineRepair.fileCount,
sourceAuditCommentId: result.dirtyQuarantineRepair.sourceAuditCommentId,
claimantAuditCommentId: result.dirtyQuarantineRepair.claimantAuditCommentId,
};
} catch (error) {
if (isWorkspaceRuntimeValidationFailure(error)) {
throw unprocessable(error.message, {
code: error.code,
...error.resultJson,
});
}
throw error;
}
}
async function inspectGitCloseReadiness(workspace: ExecutionWorkspace): Promise<{
git: ExecutionWorkspaceCloseGitReadiness | null;
warnings: string[];
@ -1049,6 +1203,124 @@ export function executionWorkspaceService(db: Db) {
return rows.map((row) => toExecutionWorkspaceSummary(row));
},
findGitWorktreeContention: async (input: {
companyId: string;
worktreePath: string;
liveBranchName: string | null;
excludingExecutionWorkspaceId?: string | null;
}): Promise<ExecutionWorkspaceGitWorktreeContention> => {
const resolvedWorktreePath = path.resolve(input.worktreePath);
const pathOrBranchConditions = [
eq(executionWorkspaces.providerRef, input.worktreePath),
eq(executionWorkspaces.cwd, input.worktreePath),
];
if (input.liveBranchName) {
pathOrBranchConditions.push(eq(executionWorkspaces.branchName, input.liveBranchName));
}
const candidates = await db
.select({
id: executionWorkspaces.id,
cwd: executionWorkspaces.cwd,
providerRef: executionWorkspaces.providerRef,
branchName: executionWorkspaces.branchName,
sourceIssueId: executionWorkspaces.sourceIssueId,
sourceIssueIdentifier: issues.identifier,
})
.from(executionWorkspaces)
.leftJoin(
issues,
and(
eq(issues.companyId, executionWorkspaces.companyId),
eq(issues.id, executionWorkspaces.sourceIssueId),
),
)
.where(and(
eq(executionWorkspaces.companyId, input.companyId),
isNull(executionWorkspaces.closedAt),
ne(executionWorkspaces.status, "archived"),
input.excludingExecutionWorkspaceId
? ne(executionWorkspaces.id, input.excludingExecutionWorkspaceId)
: sql`true`,
or(...pathOrBranchConditions),
))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.updatedAt))
.limit(20);
for (const candidate of candidates) {
const candidatePath = readNullableString(candidate.providerRef) ?? readNullableString(candidate.cwd);
const matchesPath = candidatePath ? path.resolve(candidatePath) === resolvedWorktreePath : false;
const matchesBranch = Boolean(input.liveBranchName && candidate.branchName === input.liveBranchName);
if (!matchesPath && !matchesBranch) continue;
const linkedIssueConditions = [eq(issues.executionWorkspaceId, candidate.id)];
if (candidate.sourceIssueId) linkedIssueConditions.push(eq(issues.id, candidate.sourceIssueId));
const linkedIssueRows = await db
.select({
id: issues.id,
identifier: issues.identifier,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(and(
eq(issues.companyId, input.companyId),
isNull(issues.hiddenAt),
linkedIssueConditions.length === 1 ? linkedIssueConditions[0]! : or(...linkedIssueConditions),
))
.orderBy(desc(issues.updatedAt))
.limit(20);
const runToIssue = new Map<string, { id: string; identifier: string | null }>();
for (const issue of linkedIssueRows) {
if (issue.executionRunId) runToIssue.set(issue.executionRunId, { id: issue.id, identifier: issue.identifier ?? null });
if (issue.checkoutRunId) runToIssue.set(issue.checkoutRunId, { id: issue.id, identifier: issue.identifier ?? null });
}
let activeRun: NonNullable<ExecutionWorkspaceGitWorktreeContention>["activeRun"] = null;
const runIds = [...runToIssue.keys()];
if (runIds.length > 0) {
const [row] = await db
.select({
id: heartbeatRuns.id,
status: heartbeatRuns.status,
})
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.companyId, input.companyId),
inArray(heartbeatRuns.id, runIds),
inArray(heartbeatRuns.status, ["queued", "running"]),
))
.orderBy(desc(heartbeatRuns.startedAt), desc(heartbeatRuns.createdAt))
.limit(1);
if (row && (row.status === "queued" || row.status === "running")) {
const issue = runToIssue.get(row.id) ?? null;
activeRun = {
id: row.id,
status: row.status,
issueId: issue?.id ?? null,
issueIdentifier: issue?.identifier ?? null,
};
}
}
const claimedIssue =
linkedIssueRows.find((issue) => issue.id === candidate.sourceIssueId)
?? linkedIssueRows[0]
?? null;
return {
claimedByWorkspaceId: candidate.id,
claimedByIssueId: claimedIssue?.id ?? candidate.sourceIssueId ?? null,
claimedByIssueIdentifier:
claimedIssue?.identifier ?? candidate.sourceIssueIdentifier ?? null,
activeRun,
};
}
return null;
},
getById: async (id: string) => {
const row = await db
.select()
@ -1363,6 +1635,29 @@ export function executionWorkspaceService(db: Db) {
}
const reason = readNullableString(input.reason);
const rescueRef = input.mode === "quarantine_restore"
? await (async () => {
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(
db,
existing.companyId,
[existingRow],
);
assertBranchReconcileRuntimeServicesStopped({
inspection,
runtimeServices: (runtimeServicesByWorkspaceId.get(existing.id) ?? []).map(toRuntimeService),
});
// The git rescue has to happen before the DB transaction because the
// transaction may be retried/rolled back, while git side effects cannot.
// The preflight runtime-service guard above keeps known local services
// from holding files open during the non-transactional git sequence.
return quarantineRestoreDirtyWorkspaceBranch({
db,
workspace: existing,
inspection,
actor: input.actor,
});
})()
: null;
const now = new Date();
const allowActiveWorkspace =
input.mode === "forward" &&
@ -1427,57 +1722,62 @@ export function executionWorkspaceService(db: Db) {
if (!lockedWorkspace.sourceIssueId) {
throw unprocessable("Execution workspace needs a source issue before Paperclip can audit branch reconciliation");
}
assertBranchReconcileWorkspaceIsSafe({
workspaceStatus: lockedWorkspace.status,
inspection,
runtimeServices: lockedRuntimeServices,
allowActiveWorkspace,
});
if (lockedWorkspace.branchName !== inspection.fromBranch) {
throw unprocessable("Execution workspace branch changed during reconciliation; retry with a fresh inspection", {
workspaceBranch: lockedWorkspace.branchName,
inspection,
});
}
const updatePatch: Partial<typeof executionWorkspaces.$inferInsert> = {
branchName: inspection.toBranch,
updatedAt: now,
};
if (lockedWorkspace.name === inspection.fromBranch) {
updatePatch.name = inspection.toBranch;
}
const [updatedRow] = await tx
.update(executionWorkspaces)
.set(updatePatch)
.where(
and(
eq(executionWorkspaces.id, lockedWorkspace.id),
allowActiveWorkspace
? inArray(executionWorkspaces.status, ["idle", "active"])
: eq(executionWorkspaces.status, "idle"),
eq(executionWorkspaces.branchName, inspection.fromBranch),
noActiveRuntimeServicesForWorkspaceCondition(lockedRow),
),
)
.returning();
if (!updatedRow) {
const latestRuntimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(
txDb,
lockedRow.companyId,
[lockedRow],
);
const latestRuntimeServices = (latestRuntimeServicesByWorkspaceId.get(lockedRow.id) ?? []).map(toRuntimeService);
let updatedRow: ExecutionWorkspaceRow = lockedRow;
if (input.mode !== "quarantine_restore") {
assertBranchReconcileWorkspaceIsSafe({
workspaceStatus: lockedWorkspace.status,
inspection,
runtimeServices: latestRuntimeServices,
runtimeServices: lockedRuntimeServices,
allowActiveWorkspace,
});
throw unprocessable("Execution workspace branch reconciliation requires the workspace to stay idle with stopped runtime services during the update", {
inspection,
});
if (lockedWorkspace.branchName !== inspection.fromBranch) {
throw unprocessable("Execution workspace branch changed during reconciliation; retry with a fresh inspection", {
workspaceBranch: lockedWorkspace.branchName,
inspection,
});
}
const updatePatch: Partial<typeof executionWorkspaces.$inferInsert> = {
branchName: inspection.toBranch,
updatedAt: now,
};
if (lockedWorkspace.name === inspection.fromBranch) {
updatePatch.name = inspection.toBranch;
}
const [branchUpdatedRow] = await tx
.update(executionWorkspaces)
.set(updatePatch)
.where(
and(
eq(executionWorkspaces.id, lockedWorkspace.id),
allowActiveWorkspace
? inArray(executionWorkspaces.status, ["idle", "active"])
: eq(executionWorkspaces.status, "idle"),
eq(executionWorkspaces.branchName, inspection.fromBranch),
noActiveRuntimeServicesForWorkspaceCondition(lockedRow),
),
)
.returning();
if (!branchUpdatedRow) {
const latestRuntimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(
txDb,
lockedRow.companyId,
[lockedRow],
);
const latestRuntimeServices = (latestRuntimeServicesByWorkspaceId.get(lockedRow.id) ?? []).map(toRuntimeService);
assertBranchReconcileWorkspaceIsSafe({
workspaceStatus: lockedWorkspace.status,
inspection,
runtimeServices: latestRuntimeServices,
allowActiveWorkspace,
});
throw unprocessable("Execution workspace branch reconciliation requires the workspace to stay idle with stopped runtime services during the update", {
inspection,
});
}
updatedRow = branchUpdatedRow;
}
let recoveryAction = await recoveryActionsSvc.resolveActiveForIssue(
@ -1489,7 +1789,9 @@ export function executionWorkspaceService(db: Db) {
fingerprint: inspection.fingerprint,
status: "resolved",
outcome: "restored",
resolutionNote: `Execution workspace branch record reconciled from "${inspection.fromBranch}" to "${inspection.toBranch}".`,
resolutionNote: input.mode === "quarantine_restore" && rescueRef
? `Execution workspace dirty worktree quarantined on "${rescueRef.branchName}" and restored recorded branch "${inspection.fromBranch}".`
: `Execution workspace branch record reconciled from "${inspection.fromBranch}" to "${inspection.toBranch}".`,
},
tx,
);
@ -1505,7 +1807,9 @@ export function executionWorkspaceService(db: Db) {
fingerprint: alternateFingerprint,
status: "resolved",
outcome: "restored",
resolutionNote: `Execution workspace branch record reconciled from "${inspection.fromBranch}" to "${inspection.toBranch}".`,
resolutionNote: input.mode === "quarantine_restore" && rescueRef
? `Execution workspace dirty worktree quarantined on "${rescueRef.branchName}" and restored recorded branch "${inspection.fromBranch}".`
: `Execution workspace branch record reconciled from "${inspection.fromBranch}" to "${inspection.toBranch}".`,
},
tx,
);
@ -1513,6 +1817,65 @@ export function executionWorkspaceService(db: Db) {
}
}
let restoredSourceIssue: ExecutionWorkspaceBranchReconcileResult["restoredSourceIssue"] = null;
let sourceIssueStatusChanged = false;
if (input.mode === "quarantine_restore") {
const [sourceBefore] = await tx
.select({
id: issues.id,
companyId: issues.companyId,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
executionPolicy: issues.executionPolicy,
executionState: issues.executionState,
monitorNextCheckAt: issues.monitorNextCheckAt,
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
monitorAttemptCount: issues.monitorAttemptCount,
monitorNotes: issues.monitorNotes,
monitorScheduledBy: issues.monitorScheduledBy,
})
.from(issues)
.where(eq(issues.id, lockedWorkspace.sourceIssueId))
.for("update");
if (!sourceBefore) throw notFound("Source issue not found");
const requestedStatus = quarantineRestoreRequestedSourceStatus(sourceBefore);
const policy = normalizeIssueExecutionPolicy(sourceBefore.executionPolicy ?? null);
const transition = applyIssueExecutionPolicyTransition({
issue: sourceBefore,
policy,
previousPolicy: policy,
requestedStatus,
requestedAssigneePatch: {},
actor: {
agentId: input.actor.agentId ?? null,
userId: input.actor.actorType === "user" ? input.actor.actorId : null,
},
commentBody: null,
});
const { issueService } = await import("./issues.js");
const updatedIssue = await issueService(db).update(
lockedWorkspace.sourceIssueId,
{
...(requestedStatus ? { status: requestedStatus } : {}),
...transition.patch,
actorAgentId: input.actor.agentId ?? null,
actorUserId: input.actor.actorType === "user" ? input.actor.actorId : null,
},
tx,
);
if (!updatedIssue) throw notFound("Source issue not found");
restoredSourceIssue = {
id: updatedIssue.id,
companyId: updatedIssue.companyId,
status: updatedIssue.status,
assigneeAgentId: updatedIssue.assigneeAgentId,
};
sourceIssueStatusChanged = sourceBefore.status !== updatedIssue.status;
}
const [auditComment] = await tx
.insert(issueComments)
.values({
@ -1528,6 +1891,7 @@ export function executionWorkspaceService(db: Db) {
workspaceId: existing.id,
inspection,
recoveryActionId: recoveryAction?.id ?? null,
rescueRef,
}),
})
.returning({ id: issueComments.id });
@ -1542,6 +1906,9 @@ export function executionWorkspaceService(db: Db) {
inspection,
recoveryAction,
auditCommentId: auditComment?.id ?? null,
rescueRef,
restoredSourceIssue,
sourceIssueStatusChanged,
};
});
},

View File

@ -3726,15 +3726,18 @@ export function resolveTaskSessionConfigFreshness(input: {
};
}
function shouldAutoCheckoutIssueForWake(input: {
export function shouldAutoCheckoutIssueForWake(input: {
contextSnapshot: Record<string, unknown> | null | undefined;
issueStatus: string | null;
issueAssigneeAgentId: string | null;
issueExecutionState?: unknown;
isDependencyReady: boolean;
agentId: string;
}) {
if (input.issueAssigneeAgentId !== input.agentId) return false;
if (!input.isDependencyReady) return false;
const executionState = parseIssueExecutionState(input.issueExecutionState);
if (executionState?.status === "pending") return false;
const issueStatus = readNonEmptyString(input.issueStatus);
if (
@ -5231,6 +5234,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionPolicy: issues.executionPolicy,
executionState: issues.executionState,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
parentId: issues.parentId,
createdByUserId: issues.createdByUserId,
@ -10428,6 +10432,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
contextSnapshot: context,
issueStatus: issueContext.status,
issueAssigneeAgentId: issueContext.assigneeAgentId,
issueExecutionState: issueContext.executionState,
isDependencyReady: issueDependencyReadiness?.isDependencyReady ?? true,
agentId: agent.id,
})
@ -11171,6 +11176,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
heartbeatRunId: run.id,
enableWorkspaceBranchReconcileForward:
resolvedInstanceSettings.experimental.enableWorkspaceBranchReconcileForward,
enableWorkspaceDirtyQuarantineRepair:
resolvedInstanceSettings.experimental.enableWorkspaceDirtyQuarantineRepair,
recorder: workspaceOperationRecorder,
})
: null,
@ -11187,6 +11194,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
heartbeatRunId: run.id,
enableWorkspaceBranchReconcileForward:
resolvedInstanceSettings.experimental.enableWorkspaceBranchReconcileForward,
enableWorkspaceDirtyQuarantineRepair:
resolvedInstanceSettings.experimental.enableWorkspaceDirtyQuarantineRepair,
recorder: workspaceOperationRecorder,
}),
});
@ -12022,6 +12031,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
heartbeatRunId: run.id,
enableWorkspaceBranchReconcileForward:
resolvedInstanceSettings.experimental.enableWorkspaceBranchReconcileForward,
enableWorkspaceDirtyQuarantineRepair:
resolvedInstanceSettings.experimental.enableWorkspaceDirtyQuarantineRepair,
persistForwardReconcile: false,
reconcileOperationPhase: "workspace_finalize",
recorder: workspaceOperationRecorder,

View File

@ -59,7 +59,8 @@ 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,
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
issueGraphLivenessAutoRecoveryLookbackHours:
parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ??
@ -82,7 +83,8 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
issueGraphLivenessAutoRecoveryLookbackHours:
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,

View File

@ -7,15 +7,17 @@ import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils";
import type { Db } from "@paperclipai/db";
import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import { executionWorkspaces, issueComments, issues, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import {
listWorkspaceServiceCommandDefinitions,
type GitWorktreeBranchAncestryVerdict,
type GitWorktreeBranchIncoherenceEvidence as SharedGitWorktreeBranchIncoherenceEvidence,
type GitWorktreeInProgressOperation,
type WorkspaceOperationPhase,
type WorkspaceRuntimeDesiredState,
type WorkspaceRuntimeServiceStateMap,
} from "@paperclipai/shared";
import { and, desc, eq, inArray } from "drizzle-orm";
import { and, desc, eq, inArray, ne } from "drizzle-orm";
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
import { resolveHomeAwarePath } from "../home-paths.js";
import {
@ -661,13 +663,25 @@ type GitWorktreeCleanliness = SharedGitWorktreeBranchIncoherenceEvidence["cleanl
type GitWorktreeBranchIncoherenceEvidence = SharedGitWorktreeBranchIncoherenceEvidence;
type GitWorktreeBranchContention = NonNullable<GitWorktreeBranchIncoherenceEvidence["contention"]>;
type GitWorktreeBranchCoherenceResult = {
branchName: string | null;
reconciledForward: boolean;
pendingForwardBranchReconcile?: PendingForwardBranchReconcile | null;
dirtyQuarantineRepair?: DirtyQuarantineRepairResult | null;
warnings: string[];
};
type DirtyQuarantineRepairResult = {
rescueBranch: string;
rescueCommitSha: string;
fileCount: number;
clearedInProgressOperation: GitWorktreeInProgressOperation | null;
sourceAuditCommentId: string | null;
claimantAuditCommentId: string | null;
};
export type PendingForwardBranchReconcile = {
recordedBranchName: string;
adoptedBranchName: string;
@ -679,6 +693,186 @@ function formatBranchForMessage(branch: string | null | undefined) {
return branch && branch.length > 0 ? branch : "<detached>";
}
const GIT_IN_PROGRESS_OPERATION_MARKERS: ReadonlyArray<{
operation: GitWorktreeInProgressOperation;
marker: string;
}> = [
{ operation: "rebase", marker: "rebase-merge" },
{ operation: "rebase", marker: "rebase-apply" },
{ operation: "merge", marker: "MERGE_HEAD" },
{ operation: "cherry_pick", marker: "CHERRY_PICK_HEAD" },
{ operation: "revert", marker: "REVERT_HEAD" },
{ operation: "bisect", marker: "BISECT_LOG" },
];
const GIT_IN_PROGRESS_OPERATION_LABELS: Record<GitWorktreeInProgressOperation, string> = {
rebase: "rebase",
merge: "merge",
cherry_pick: "cherry-pick",
revert: "revert",
bisect: "bisect",
};
// `--quit` clears the interrupted operation's state directory without touching
// the working tree or moving HEAD, unlike `--abort` which resets both.
const GIT_IN_PROGRESS_OPERATION_QUIT_ARGS: Record<GitWorktreeInProgressOperation, string[]> = {
rebase: ["rebase", "--quit"],
merge: ["merge", "--quit"],
cherry_pick: ["cherry-pick", "--quit"],
revert: ["revert", "--quit"],
bisect: ["bisect", "reset", "HEAD"],
};
async function detectGitWorktreeInProgressOperation(
worktreePath: string,
): Promise<GitWorktreeInProgressOperation | null> {
for (const { operation, marker } of GIT_IN_PROGRESS_OPERATION_MARKERS) {
const markerPath = await runGit(["rev-parse", "--git-path", marker], worktreePath).catch(() => null);
if (!markerPath) continue;
if (existsSync(path.resolve(worktreePath, markerPath))) return operation;
}
return null;
}
const DIRTY_PATH_SAMPLE_LIMIT = 5;
function parseGitPorcelainPath(line: string) {
const raw = line.trimEnd();
if (raw.trim().length <= 3) return raw.trim();
if (raw[1] === " " && raw[2] !== " ") return raw.slice(2).trim();
return raw.slice(3).trim();
}
function sampleDirtyStatusPaths(statusLines: string[] | null) {
return (statusLines ?? [])
.map(parseGitPorcelainPath)
.filter((value) => value.length > 0)
.slice(0, DIRTY_PATH_SAMPLE_LIMIT);
}
function formatUtcBranchTimestamp(date = new Date()) {
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function buildDirtyQuarantineRescueBranch(sourceIssue: ExecutionWorkspaceIssueRef | null) {
const issueComponent = sanitizeBranchName(sourceIssue?.identifier ?? sourceIssue?.id ?? "issue");
return sanitizeBranchName(`paperclip/rescue/${issueComponent}/${formatUtcBranchTimestamp()}`);
}
function formatIssueReference(issueId: string | null | undefined, identifier: string | null | undefined) {
if (!identifier) return issueId ? `\`${issueId}\`` : "`unknown`";
const match = identifier.match(/^([A-Z]+)-\d+$/);
if (!match) return `\`${identifier}\``;
return `[${identifier}](/${match[1]}/issues/${identifier})`;
}
async function readIssueCompanyId(db: Db, issueId: string | null | undefined): Promise<string | null> {
if (!issueId) return null;
return db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]?.companyId ?? null);
}
async function findGitWorktreeBranchContention(input: {
db: Db | null | undefined;
sourceIssue: ExecutionWorkspaceIssueRef | null;
executionWorkspaceId: string | null;
worktreePath: string;
actualBranchName: string | null;
}): Promise<GitWorktreeBranchContention | null> {
if (!input.db) return null;
const companyId = await readIssueCompanyId(input.db, input.sourceIssue?.id);
if (!companyId) return null;
return executionWorkspaceService(input.db).findGitWorktreeContention({
companyId,
worktreePath: input.worktreePath,
liveBranchName: input.actualBranchName,
excludingExecutionWorkspaceId: input.executionWorkspaceId,
});
}
function executionWorkspaceUsesInheritedProjectRuntimeServices(
row: typeof executionWorkspaces.$inferSelect,
) {
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
}
async function findActiveRuntimeServiceBlockingDirtyQuarantine(input: {
db: Db;
workspace: typeof executionWorkspaces.$inferSelect;
}) {
const inheritedProjectWorkspaceId = executionWorkspaceUsesInheritedProjectRuntimeServices(input.workspace)
? input.workspace.projectWorkspaceId
: null;
const serviceScopeCondition = inheritedProjectWorkspaceId
? and(
eq(workspaceRuntimeServices.companyId, input.workspace.companyId),
eq(workspaceRuntimeServices.projectWorkspaceId, inheritedProjectWorkspaceId),
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
)
: and(
eq(workspaceRuntimeServices.companyId, input.workspace.companyId),
eq(workspaceRuntimeServices.executionWorkspaceId, input.workspace.id),
);
const [service] = await input.db
.select({
id: workspaceRuntimeServices.id,
serviceName: workspaceRuntimeServices.serviceName,
status: workspaceRuntimeServices.status,
scopeType: workspaceRuntimeServices.scopeType,
})
.from(workspaceRuntimeServices)
.where(and(serviceScopeCondition, ne(workspaceRuntimeServices.status, "stopped")))
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt))
.limit(1);
return service ?? null;
}
async function assertDirtyQuarantineRuntimeServicesStopped(input: {
db: Db;
executionWorkspaceId: string | null;
evidence: GitWorktreeBranchIncoherenceEvidence;
}) {
if (!input.executionWorkspaceId) {
input.evidence.safeRepair.eligible = false;
input.evidence.safeRepair.reason = "dirty quarantine repair requires an execution workspace id for runtime-service checks";
throw branchIncoherenceValidationFailure(input.evidence);
}
const [workspace] = await input.db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, input.executionWorkspaceId));
if (!workspace) {
input.evidence.safeRepair.eligible = false;
input.evidence.safeRepair.reason = "dirty quarantine repair requires a persisted execution workspace for runtime-service checks";
throw branchIncoherenceValidationFailure(input.evidence);
}
const activeService = await findActiveRuntimeServiceBlockingDirtyQuarantine({
db: input.db,
workspace,
});
if (!activeService) return;
input.evidence.safeRepair.eligible = false;
input.evidence.safeRepair.reason =
`dirty quarantine repair requires runtime service "${activeService.serviceName}" (${activeService.id}) to be stopped; current status is ${activeService.status}`;
throw branchIncoherenceValidationFailure(input.evidence);
}
async function assertGitIndexIsUnlocked(worktreePath: string) {
const indexLockPath = await runGit(["rev-parse", "--git-path", "index.lock"], worktreePath)
.catch(() => null);
if (indexLockPath && existsSync(indexLockPath)) {
throw new Error(`git index lock exists at ${indexLockPath}`);
}
}
function fingerprintWorkspaceBranchIncoherence(input: {
sourceIssueId: string | null;
executionWorkspaceId: string | null;
@ -749,6 +943,7 @@ function explainGitWorktreeBranchIncoherence(input: {
}
async function inspectGitWorktreeBranchIncoherence(input: {
db?: Db | null;
repoRoot: string;
worktreePath: string;
expectedBranchName: string;
@ -762,9 +957,11 @@ async function inspectGitWorktreeBranchIncoherence(input: {
).catch(() => null);
const statusLines = status === null
? null
: status.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
: status.split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
const dirtyPathSample = sampleDirtyStatusPaths(statusLines);
const cleanliness: GitWorktreeCleanliness =
status === null ? "unknown" : status.trim().length > 0 ? "dirty" : "clean";
const inProgressOperation = await detectGitWorktreeInProgressOperation(input.worktreePath);
const expectedHeadSha = await runGit(
["rev-parse", "--verify", `refs/heads/${input.expectedBranchName}^{commit}`],
input.repoRoot,
@ -784,7 +981,7 @@ async function inspectGitWorktreeBranchIncoherence(input: {
expectedHeadSha,
actualHeadSha,
});
const plainLanguageReason = explainGitWorktreeBranchIncoherence({
const basePlainLanguageReason = explainGitWorktreeBranchIncoherence({
expectedBranchName: input.expectedBranchName,
actualBranchName: input.actualBranchName,
expectedHeadSha,
@ -792,6 +989,9 @@ async function inspectGitWorktreeBranchIncoherence(input: {
sameHead,
ancestryVerdict,
});
const plainLanguageReason = inProgressOperation
? `${basePlainLanguageReason} An interrupted git ${GIT_IN_PROGRESS_OPERATION_LABELS[inProgressOperation]} is still in progress in this worktree.`
: basePlainLanguageReason;
const canCheckoutRecordedBranch =
cleanliness === "clean" && expectedBranchExists && sameHead && registeredBranchMatchesHead;
const canAdoptForwardActualBranch =
@ -817,7 +1017,9 @@ async function inspectGitWorktreeBranchIncoherence(input: {
? "clean worktree and checked-out branch is forward of the recorded branch"
: "clean detached worktree HEAD is forward of the recorded branch"
: cleanliness !== "clean"
? "worktree is not clean"
? inProgressOperation
? `worktree is not clean and a git ${GIT_IN_PROGRESS_OPERATION_LABELS[inProgressOperation]} is in progress`
: "worktree is not clean"
: !registered
? "worktree path is not registered"
: !registeredBranchMatchesHead
@ -837,6 +1039,13 @@ async function inspectGitWorktreeBranchIncoherence(input: {
expectedHeadSha,
actualHeadSha,
});
const contention = await findGitWorktreeBranchContention({
db: input.db ?? null,
sourceIssue: input.sourceIssue,
executionWorkspaceId: input.executionWorkspaceId ?? null,
worktreePath: input.worktreePath,
actualBranchName: input.actualBranchName,
});
return {
reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON,
@ -849,7 +1058,10 @@ async function inspectGitWorktreeBranchIncoherence(input: {
expectedBranch: input.expectedBranchName,
actualBranch: input.actualBranchName,
cleanliness,
inProgressOperation,
statusEntryCount: statusLines?.length ?? null,
dirtyPathSample,
contention,
provenance: {
expectedBranchRef: `refs/heads/${input.expectedBranchName}`,
actualBranchRef,
@ -882,6 +1094,407 @@ function branchIncoherenceValidationFailure(evidence: GitWorktreeBranchIncoheren
);
}
function formatDirtyQuarantineContentionRefusal(contention: GitWorktreeBranchContention) {
const activeRunText = contention.activeRun
? ` with active run ${contention.activeRun.id}`
: " with no active run";
return `dirty quarantine repair refused because workspace ${contention.claimedByWorkspaceId} already claims the live branch${activeRunText}`;
}
function formatDirtyQuarantineFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (
gitErrorIncludes(error, "index.lock") ||
gitErrorIncludes(error, "index lock") ||
gitErrorIncludes(error, "another git process") ||
gitErrorIncludes(error, "Unable to create")
) {
return `dirty quarantine repair aborted because git reported index contention: ${message}`;
}
return `dirty quarantine repair failed: ${message}`;
}
function formatDirtyQuarantineAuditComment(input: {
evidence: GitWorktreeBranchIncoherenceEvidence;
rescueBranch: string;
rescueCommitSha: string;
fileCount: number;
sourceIssue: ExecutionWorkspaceIssueRef | null;
claimant: GitWorktreeBranchContention | null;
}) {
const dirtySample = input.evidence.dirtyPathSample.length > 0
? input.evidence.dirtyPathSample.map((entry) => `\`${entry}\``).join(", ")
: "`none captured`";
return [
"Execution workspace dirty worktree quarantined before restore.",
"",
`- Source issue: ${formatIssueReference(input.evidence.sourceIssueId, input.evidence.sourceIdentifier ?? input.sourceIssue?.identifier ?? null)}`,
`- Workspace: \`${input.evidence.executionWorkspaceId ?? "unpersisted"}\``,
`- Worktree: \`${input.evidence.worktreePath}\``,
`- Recorded branch: \`${input.evidence.expectedBranch}\``,
`- Live branch: \`${formatBranchForMessage(input.evidence.actualBranch)}\``,
`- Rescue branch: \`${input.rescueBranch}\``,
`- Rescue commit: \`${input.rescueCommitSha}\``,
`- Dirty file count: \`${input.fileCount}\``,
`- Dirty path sample: ${dirtySample}`,
...(input.evidence.inProgressOperation
? [`- Interrupted operation: \`git ${GIT_IN_PROGRESS_OPERATION_LABELS[input.evidence.inProgressOperation]}\` (state cleared after rescue; resolution preserved on the rescue branch)`]
: []),
`- Fingerprint: \`${input.evidence.fingerprint}\``,
input.claimant
? `- Claimant: workspace \`${input.claimant.claimedByWorkspaceId}\` on issue ${formatIssueReference(input.claimant.claimedByIssueId, input.claimant.claimedByIssueIdentifier)}${input.claimant.activeRun ? ` with active run \`${input.claimant.activeRun.id}\`` : " with no active run"}`
: "- Claimant: none",
].join("\n");
}
async function writeDirtyQuarantineAuditComments(input: {
db: Db;
companyId: string;
evidence: GitWorktreeBranchIncoherenceEvidence;
sourceIssue: ExecutionWorkspaceIssueRef | null;
rescueBranch: string;
rescueCommitSha: string;
fileCount: number;
heartbeatRunId: string | null;
}): Promise<{ sourceAuditCommentId: string | null; claimantAuditCommentId: string | null }> {
const body = formatDirtyQuarantineAuditComment({
evidence: input.evidence,
rescueBranch: input.rescueBranch,
rescueCommitSha: input.rescueCommitSha,
fileCount: input.fileCount,
sourceIssue: input.sourceIssue,
claimant: input.evidence.contention,
});
let sourceAuditCommentId: string | null = null;
let claimantAuditCommentId: string | null = null;
const now = new Date();
if (input.evidence.sourceIssueId) {
const [sourceComment] = await input.db
.insert(issueComments)
.values({
companyId: input.companyId,
issueId: input.evidence.sourceIssueId,
authorAgentId: null,
authorUserId: null,
authorType: "system",
createdByRunId: input.heartbeatRunId,
body,
})
.returning({ id: issueComments.id });
sourceAuditCommentId = sourceComment?.id ?? null;
await input.db
.update(issues)
.set({ updatedAt: now })
.where(eq(issues.id, input.evidence.sourceIssueId));
}
const claimantIssueId = input.evidence.contention?.claimedByIssueId ?? null;
if (claimantIssueId && claimantIssueId !== input.evidence.sourceIssueId) {
const [claimantComment] = await input.db
.insert(issueComments)
.values({
companyId: input.companyId,
issueId: claimantIssueId,
authorAgentId: null,
authorUserId: null,
authorType: "system",
createdByRunId: input.heartbeatRunId,
body,
})
.returning({ id: issueComments.id });
claimantAuditCommentId = claimantComment?.id ?? null;
await input.db
.update(issues)
.set({ updatedAt: now })
.where(eq(issues.id, claimantIssueId));
}
return { sourceAuditCommentId, claimantAuditCommentId };
}
async function logDirtyQuarantineActivity(input: {
db: Db;
companyId: string;
evidence: GitWorktreeBranchIncoherenceEvidence;
rescueBranch: string;
rescueCommitSha: string;
fileCount: number;
heartbeatRunId: string | null;
sourceAuditCommentId: string | null;
claimantAuditCommentId: string | null;
}) {
await logActivity(input.db, {
companyId: input.companyId,
actorType: "system",
actorId: "workspace_runtime",
runId: input.heartbeatRunId,
action: "execution_workspace.dirty_worktree_quarantined",
entityType: input.evidence.executionWorkspaceId ? "execution_workspace" : "issue",
entityId: input.evidence.executionWorkspaceId ?? input.evidence.sourceIssueId ?? input.companyId,
details: {
reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON,
sourceIssueId: input.evidence.sourceIssueId,
executionWorkspaceId: input.evidence.executionWorkspaceId,
worktreePath: input.evidence.worktreePath,
expectedBranch: input.evidence.expectedBranch,
actualBranch: input.evidence.actualBranch,
rescueBranch: input.rescueBranch,
rescueCommitSha: input.rescueCommitSha,
fileCount: input.fileCount,
dirtyPathSample: input.evidence.dirtyPathSample,
fingerprint: input.evidence.fingerprint,
contention: input.evidence.contention,
sourceAuditCommentId: input.sourceAuditCommentId,
claimantAuditCommentId: input.claimantAuditCommentId,
actor: {
type: "system",
id: "workspace_runtime",
source: "workspace_runtime",
},
},
});
}
async function recordDirtyQuarantineOperation(input: {
recorder?: WorkspaceOperationRecorder | null;
phase?: "worktree_prepare" | "workspace_finalize";
cwd: string;
evidence: GitWorktreeBranchIncoherenceEvidence;
rescueBranch: string;
rescueCommitSha: string;
fileCount: number;
sourceAuditCommentId: string | null;
claimantAuditCommentId: string | null;
}) {
if (!input.recorder) return;
await input.recorder.recordOperation({
phase: input.phase ?? "worktree_prepare",
cwd: input.cwd,
metadata: {
repoRoot: input.evidence.repoRoot,
worktreePath: input.evidence.worktreePath,
expectedBranchName: input.evidence.expectedBranch,
actualBranchName: input.evidence.actualBranch,
branchIncoherenceDirtyQuarantineRepair: true,
rescueBranch: input.rescueBranch,
rescueCommitSha: input.rescueCommitSha,
fileCount: input.fileCount,
dirtyPathSample: input.evidence.dirtyPathSample,
fingerprint: input.evidence.fingerprint,
sourceIssueId: input.evidence.sourceIssueId,
executionWorkspaceId: input.evidence.executionWorkspaceId,
sourceAuditCommentId: input.sourceAuditCommentId,
claimantAuditCommentId: input.claimantAuditCommentId,
},
run: async () => ({
status: "succeeded",
system:
`Quarantined dirty git worktree state on ${input.rescueBranch} (${formatShortSha(input.rescueCommitSha)}) and restored recorded branch ${input.evidence.expectedBranch}.\n`,
}),
});
}
async function quarantineDirtyWorktreeBranchIncoherence(input: {
db: Db;
repoRoot: string;
worktreePath: string;
expectedBranchName: string;
sourceIssue: ExecutionWorkspaceIssueRef | null;
executionWorkspaceId: string | null;
heartbeatRunId: string | null;
evidence: GitWorktreeBranchIncoherenceEvidence;
phase?: "worktree_prepare" | "workspace_finalize";
recorder?: WorkspaceOperationRecorder | null;
}): Promise<DirtyQuarantineRepairResult> {
const companyId = await readIssueCompanyId(input.db, input.evidence.sourceIssueId);
if (!companyId) {
input.evidence.safeRepair.eligible = false;
input.evidence.safeRepair.reason = "dirty quarantine repair requires a source issue company for audit";
throw branchIncoherenceValidationFailure(input.evidence);
}
const freshContention = await findGitWorktreeBranchContention({
db: input.db,
sourceIssue: input.sourceIssue,
executionWorkspaceId: input.executionWorkspaceId,
worktreePath: input.worktreePath,
actualBranchName: input.evidence.actualBranch,
});
input.evidence.contention = freshContention;
if (freshContention) {
input.evidence.safeRepair.eligible = false;
input.evidence.safeRepair.reason = formatDirtyQuarantineContentionRefusal(freshContention);
throw branchIncoherenceValidationFailure(input.evidence);
}
const rescueBranch = buildDirtyQuarantineRescueBranch(input.sourceIssue);
const fileCount = input.evidence.statusEntryCount ?? input.evidence.dirtyPathSample.length;
const baseMetadata = {
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
expectedBranchName: input.expectedBranchName,
actualBranchName: input.evidence.actualBranch,
branchIncoherenceDirtyQuarantineRepair: true,
rescueBranch,
fingerprint: input.evidence.fingerprint,
sourceIssueId: input.evidence.sourceIssueId,
executionWorkspaceId: input.evidence.executionWorkspaceId,
fileCount,
dirtyPathSample: input.evidence.dirtyPathSample,
contention: input.evidence.contention,
};
let rescueBranchCreated = false;
let expectedBranchRestored = false;
try {
await assertGitIndexIsUnlocked(input.worktreePath);
await recordGitOperation(input.recorder, {
phase: input.phase ?? "worktree_prepare",
args: ["checkout", "-b", rescueBranch],
cwd: input.worktreePath,
metadata: baseMetadata,
successMessage: `Created rescue branch ${rescueBranch} for dirty git worktree state at ${input.worktreePath}\n`,
failureLabel: `git checkout -b ${rescueBranch}`,
});
rescueBranchCreated = true;
await recordGitOperation(input.recorder, {
phase: input.phase ?? "worktree_prepare",
args: ["add", "-A"],
cwd: input.worktreePath,
metadata: baseMetadata,
successMessage: `Staged dirty git worktree state for rescue branch ${rescueBranch}\n`,
failureLabel: "git add -A",
});
await recordGitOperation(input.recorder, {
phase: input.phase ?? "worktree_prepare",
args: [
"commit",
"-m",
"Paperclip dirty workspace rescue",
"-m",
[
`Source-Issue: ${input.evidence.sourceIdentifier ?? input.evidence.sourceIssueId ?? "unknown"}`,
`Run-Id: ${input.heartbeatRunId ?? "unknown"}`,
`Recorded-Branch: ${input.expectedBranchName}`,
`Live-Branch: ${formatBranchForMessage(input.evidence.actualBranch)}`,
`Fingerprint: ${input.evidence.fingerprint}`,
].join("\n"),
],
cwd: input.worktreePath,
metadata: baseMetadata,
successMessage: `Committed dirty git worktree state to rescue branch ${rescueBranch}\n`,
failureLabel: "git commit dirty workspace rescue",
});
const rescueCommitSha = await runGit(["rev-parse", "HEAD"], input.worktreePath);
await recordGitOperation(input.recorder, {
phase: input.phase ?? "worktree_prepare",
args: ["checkout", input.expectedBranchName],
cwd: input.worktreePath,
metadata: {
...baseMetadata,
rescueCommitSha,
},
successMessage: `Restored recorded branch ${input.expectedBranchName} after dirty workspace rescue ${rescueBranch}\n`,
failureLabel: `git checkout ${input.expectedBranchName}`,
});
expectedBranchRestored = true;
// A run that died mid-rebase (or mid-merge/cherry-pick/revert/bisect)
// leaves the operation's state directory behind even after the recorded
// branch is checked out, which wedges the next git command in the
// worktree. The rescue commit above already preserved the in-flight
// resolution, so clearing the state metadata here loses nothing.
let clearedInProgressOperation: GitWorktreeInProgressOperation | null = null;
const lingeringOperation = await detectGitWorktreeInProgressOperation(input.worktreePath);
if (lingeringOperation) {
const operationLabel = GIT_IN_PROGRESS_OPERATION_LABELS[lingeringOperation];
const quitArgs = GIT_IN_PROGRESS_OPERATION_QUIT_ARGS[lingeringOperation];
await recordGitOperation(input.recorder, {
phase: input.phase ?? "worktree_prepare",
args: quitArgs,
cwd: input.worktreePath,
metadata: {
...baseMetadata,
clearedInProgressOperation: lingeringOperation,
},
successMessage: `Cleared interrupted git ${operationLabel} state after dirty workspace rescue ${rescueBranch}\n`,
failureLabel: `git ${quitArgs.join(" ")}`,
});
const stillInProgress = await detectGitWorktreeInProgressOperation(input.worktreePath);
if (stillInProgress) {
input.evidence.safeRepair.succeeded = false;
input.evidence.safeRepair.reason =
`dirty quarantine repair could not clear the interrupted git ${GIT_IN_PROGRESS_OPERATION_LABELS[stillInProgress]} state`;
throw branchIncoherenceValidationFailure(input.evidence);
}
clearedInProgressOperation = lingeringOperation;
}
const repairedBranch = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath)
.catch(() => null);
if (repairedBranch !== input.expectedBranchName) {
input.evidence.safeRepair.succeeded = false;
input.evidence.safeRepair.reason =
`dirty quarantine repair checked out ${formatBranchForMessage(repairedBranch)} instead of ${input.expectedBranchName}`;
throw branchIncoherenceValidationFailure(input.evidence);
}
const repairedStatus = await runGit(["status", "--porcelain", "--untracked-files=all"], input.worktreePath);
if (repairedStatus.trim().length > 0) {
input.evidence.safeRepair.succeeded = false;
input.evidence.safeRepair.reason = "dirty quarantine repair completed but the worktree is still dirty";
throw branchIncoherenceValidationFailure(input.evidence);
}
const comments = await writeDirtyQuarantineAuditComments({
db: input.db,
companyId,
evidence: input.evidence,
sourceIssue: input.sourceIssue,
rescueBranch,
rescueCommitSha,
fileCount,
heartbeatRunId: input.heartbeatRunId,
});
await logDirtyQuarantineActivity({
db: input.db,
companyId,
evidence: input.evidence,
rescueBranch,
rescueCommitSha,
fileCount,
heartbeatRunId: input.heartbeatRunId,
sourceAuditCommentId: comments.sourceAuditCommentId,
claimantAuditCommentId: comments.claimantAuditCommentId,
});
await recordDirtyQuarantineOperation({
recorder: input.recorder,
phase: input.phase,
cwd: input.worktreePath,
evidence: input.evidence,
rescueBranch,
rescueCommitSha,
fileCount,
sourceAuditCommentId: comments.sourceAuditCommentId,
claimantAuditCommentId: comments.claimantAuditCommentId,
});
return {
rescueBranch,
rescueCommitSha,
fileCount,
clearedInProgressOperation,
...comments,
};
} catch (error) {
if (rescueBranchCreated && !expectedBranchRestored) {
await runGit(["checkout", input.expectedBranchName], input.worktreePath).catch(() => null);
}
if (error instanceof WorkspaceRuntimeValidationFailure) throw error;
input.evidence.safeRepair.succeeded = false;
input.evidence.safeRepair.reason = formatDirtyQuarantineFailure(error);
throw branchIncoherenceValidationFailure(input.evidence);
}
}
async function recordForwardBranchReconcileOperation(input: {
recorder?: WorkspaceOperationRecorder | null;
phase?: "worktree_prepare" | "workspace_finalize";
@ -1047,6 +1660,7 @@ export async function ensureGitWorktreeBranchCoherent(input: {
actualBranchName?: string | null;
heartbeatRunId?: string | null;
enableWorkspaceBranchReconcileForward?: boolean;
enableWorkspaceDirtyQuarantineRepair?: boolean;
persistForwardReconcile?: boolean;
reconcileOperationPhase?: "worktree_prepare" | "workspace_finalize";
recorder?: WorkspaceOperationRecorder | null;
@ -1062,6 +1676,7 @@ export async function ensureGitWorktreeBranchCoherent(input: {
}
const evidence = await inspectGitWorktreeBranchIncoherence({
db: input.db ?? null,
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
expectedBranchName,
@ -1070,9 +1685,63 @@ export async function ensureGitWorktreeBranchCoherent(input: {
executionWorkspaceId: input.executionWorkspaceId ?? null,
});
if (evidence.cleanliness === "dirty" && input.enableWorkspaceDirtyQuarantineRepair === true) {
if (!input.db) {
evidence.safeRepair.reason = "dirty quarantine repair requires database access for claimant checks and audit";
throw branchIncoherenceValidationFailure(evidence);
}
if (!evidence.provenance.registeredPathFound) {
evidence.safeRepair.reason = "dirty quarantine repair requires a registered git worktree path";
throw branchIncoherenceValidationFailure(evidence);
}
if (!evidence.provenance.expectedBranchExists) {
evidence.safeRepair.reason = "dirty quarantine repair requires the recorded branch to exist";
throw branchIncoherenceValidationFailure(evidence);
}
if (evidence.contention) {
evidence.safeRepair.eligible = false;
evidence.safeRepair.reason = formatDirtyQuarantineContentionRefusal(evidence.contention);
throw branchIncoherenceValidationFailure(evidence);
}
await assertDirtyQuarantineRuntimeServicesStopped({
db: input.db,
executionWorkspaceId: input.executionWorkspaceId ?? null,
evidence,
});
evidence.safeRepair.eligible = true;
evidence.safeRepair.attempted = true;
evidence.safeRepair.reason = "dirty worktree can be quarantined on a rescue branch before restoring the recorded branch";
const result = await quarantineDirtyWorktreeBranchIncoherence({
db: input.db,
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
expectedBranchName,
sourceIssue: input.sourceIssue,
executionWorkspaceId: input.executionWorkspaceId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
evidence,
phase: input.reconcileOperationPhase,
recorder: input.recorder ?? null,
});
evidence.safeRepair.succeeded = true;
evidence.safeRepair.reason = result.clearedInProgressOperation
? `dirty worktree quarantined on ${result.rescueBranch} at ${formatShortSha(result.rescueCommitSha)}; interrupted git ${GIT_IN_PROGRESS_OPERATION_LABELS[result.clearedInProgressOperation]} state cleared`
: `dirty worktree quarantined on ${result.rescueBranch} at ${formatShortSha(result.rescueCommitSha)}`;
return {
branchName: expectedBranchName,
reconciledForward: false,
dirtyQuarantineRepair: result,
warnings: [
`Execution workspace dirty worktree state was quarantined on rescue branch "${result.rescueBranch}" (${formatShortSha(result.rescueCommitSha)}; ${result.fileCount} ${result.fileCount === 1 ? "file" : "files"}) before restoring recorded branch "${expectedBranchName}".${result.clearedInProgressOperation ? ` An interrupted git ${GIT_IN_PROGRESS_OPERATION_LABELS[result.clearedInProgressOperation]} was also cleared; its in-flight state is preserved on the rescue branch.` : ""}`,
],
};
}
if (
input.enableWorkspaceBranchReconcileForward === true &&
evidence.provenance.ancestryVerdict === "ancestor" &&
!evidence.provenance.sameHead &&
evidence.cleanliness === "clean" &&
currentBranch
) {
const reason = "Automatic forward reconciliation: recorded branch is an ancestor of the checked-out branch.";
@ -1747,7 +2416,7 @@ async function runWorkspaceCommand(input: {
async function recordGitOperation(
recorder: WorkspaceOperationRecorder | null | undefined,
input: {
phase: "worktree_prepare" | "worktree_cleanup";
phase: WorkspaceOperationPhase;
args: string[];
cwd: string;
metadata?: Record<string, unknown> | null;
@ -1970,6 +2639,7 @@ export async function realizeExecutionWorkspace(input: {
agent: ExecutionWorkspaceAgentRef;
heartbeatRunId?: string | null;
enableWorkspaceBranchReconcileForward?: boolean;
enableWorkspaceDirtyQuarantineRepair?: boolean;
recorder?: WorkspaceOperationRecorder | null;
}): Promise<RealizedExecutionWorkspace> {
const rawStrategy = parseObject(input.config.workspaceStrategy);
@ -2100,6 +2770,7 @@ export async function realizeExecutionWorkspace(input: {
executionWorkspaceId: null,
heartbeatRunId: input.heartbeatRunId ?? null,
enableWorkspaceBranchReconcileForward: input.enableWorkspaceBranchReconcileForward === true,
enableWorkspaceDirtyQuarantineRepair: input.enableWorkspaceDirtyQuarantineRepair === true,
reconcileOperationPhase: "worktree_prepare",
recorder: input.recorder ?? null,
});
@ -2240,6 +2911,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
agent: ExecutionWorkspaceAgentRef;
heartbeatRunId?: string | null;
enableWorkspaceBranchReconcileForward?: boolean;
enableWorkspaceDirtyQuarantineRepair?: boolean;
recorder?: WorkspaceOperationRecorder | null;
}): Promise<RealizedExecutionWorkspace | null> {
const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim();
@ -2285,6 +2957,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
executionWorkspaceId: input.workspace.id ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
enableWorkspaceBranchReconcileForward: input.enableWorkspaceBranchReconcileForward === true,
enableWorkspaceDirtyQuarantineRepair: input.enableWorkspaceDirtyQuarantineRepair === true,
persistForwardReconcile: false,
reconcileOperationPhase: "worktree_prepare",
recorder: input.recorder ?? null,

View File

@ -129,7 +129,11 @@ export const executionWorkspacesApi = {
* never trusted); no `reason` needed.
* - `mode: "override"` audited break-glass; the server rejects agent actors, re-checks
* `runtime:manage` permission, and requires a non-empty operator `reason`.
* - `mode: "quarantine_restore"` lossless dirty-worktree repair; the server quarantines the
* dirty changes onto a rescue branch and restores the recorded branch. No `reason` needed.
*/
reconcile: (id: string, body: { mode: "forward" } | { mode: "override"; reason: string }) =>
api.post<ExecutionWorkspace>(`/execution-workspaces/${id}/reconcile-branch`, body),
reconcile: (
id: string,
body: { mode: "forward" } | { mode: "override"; reason: string } | { mode: "quarantine_restore" },
) => api.post<ExecutionWorkspace>(`/execution-workspaces/${id}/reconcile-branch`, body),
};

View File

@ -433,6 +433,8 @@ interface IssueChatThreadProps {
reissueIsolatedRecoveryActionPending?: boolean;
onReconcileForwardRecoveryAction?: () => void;
onBreakGlassOverrideRecoveryAction?: (reason: string) => void;
onQuarantineRestoreRecoveryAction?: () => void;
quarantineRestoreRecoveryActionPending?: boolean;
canBreakGlassRecoveryAction?: boolean;
reconcileRecoveryActionPending?: boolean;
canFalsePositiveRecoveryAction?: boolean;
@ -4176,6 +4178,8 @@ export function IssueChatThread({
reissueIsolatedRecoveryActionPending = false,
onReconcileForwardRecoveryAction,
onBreakGlassOverrideRecoveryAction,
onQuarantineRestoreRecoveryAction,
quarantineRestoreRecoveryActionPending = false,
canBreakGlassRecoveryAction = false,
reconcileRecoveryActionPending = false,
canFalsePositiveRecoveryAction = false,
@ -4895,6 +4899,8 @@ export function IssueChatThread({
reissuePending={reissueIsolatedRecoveryActionPending}
onReconcileForward={onReconcileForwardRecoveryAction}
onBreakGlassOverride={onBreakGlassOverrideRecoveryAction}
onQuarantineRestore={onQuarantineRestoreRecoveryAction}
quarantineRestorePending={quarantineRestoreRecoveryActionPending}
canBreakGlass={canBreakGlassRecoveryAction}
reconcilePending={reconcileRecoveryActionPending}
canFalsePositive={canFalsePositiveRecoveryAction}

View File

@ -502,3 +502,163 @@ describe("IssueRecoveryActionCard W7 reconcile actions", () => {
expect(node.querySelector("[data-testid='recovery-action-breakglass-trigger']")).toBeNull();
});
});
function buildDirtyDivergenceAction(
overrides: {
action?: Partial<IssueRecoveryAction>;
provenance?: Record<string, unknown>;
workspaceValidation?: Record<string, unknown>;
} = {},
): IssueRecoveryAction {
return buildWorkspaceValidationAction({
...overrides,
workspaceValidation: {
cleanliness: "dirty",
statusEntryCount: 3,
dirtyPathSample: ["src/app.ts", "README.md"],
sourceIdentifier: "PAP-1405",
...overrides.workspaceValidation,
},
});
}
describe("IssueRecoveryActionCard repair workspace (quarantine_restore)", () => {
it("offers the repair action only for a dirty divergence", () => {
const cleanNode = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ workspaceValidation: { cleanliness: "clean" } })}
onQuarantineRestore={() => {}}
/>,
);
expect(cleanNode.querySelector("[data-testid='recovery-action-repair-trigger']")).toBeNull();
const dirtyNode = render(
<IssueRecoveryActionCard action={buildDirtyDivergenceAction()} onQuarantineRestore={() => {}} />,
);
expect(dirtyNode.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
});
it("does not offer the repair action without a handler or for non-workspace kinds", () => {
const noHandler = render(<IssueRecoveryActionCard action={buildDirtyDivergenceAction()} />);
expect(noHandler.querySelector("[data-testid='recovery-action-repair-trigger']")).toBeNull();
const nonWorkspace = render(
<IssueRecoveryActionCard action={buildAction()} onQuarantineRestore={() => {}} />,
);
expect(nonWorkspace.querySelector("[data-testid='recovery-action-repair-trigger']")).toBeNull();
});
it("confirm popover restates the dirty count, live branch, rescue branch and recorded branch, then fires the handler", () => {
const onQuarantineRestore = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction()}
onQuarantineRestore={onQuarantineRestore}
/>,
);
click(node.querySelector("[data-testid='recovery-action-repair-trigger']"));
const restated = document.body.querySelector("[data-testid='recovery-repair-restated']");
const text = restated?.textContent ?? "";
expect(text).toContain("3 uncommitted changes");
// live branch is explicitly left untouched
expect(text).toContain("nleach/PAP-1405-live");
expect(text).toContain("left untouched");
// rescue branch preview mirrors the server naming (prefix + timestamp marker)
expect(
document.body.querySelector("[data-testid='recovery-repair-rescue-branch']")?.textContent,
).toContain("paperclip/rescue/PAP-1405/");
// recorded branch to be restored
expect(text).toContain("PAP-522-recorded");
// No reason field is present — the operation is lossless.
expect(document.body.querySelector("textarea")).toBeNull();
click(document.body.querySelector("[data-testid='recovery-action-repair-confirm']"));
expect(onQuarantineRestore).toHaveBeenCalledTimes(1);
});
it("singularizes the dirty change count", () => {
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction({ workspaceValidation: { statusEntryCount: 1 } })}
onQuarantineRestore={() => {}}
/>,
);
click(node.querySelector("[data-testid='recovery-action-repair-trigger']"));
expect(
document.body.querySelector("[data-testid='recovery-repair-dirty-count']")?.textContent,
).toBe("1 uncommitted change");
});
it("disables the repair trigger while a quarantine-restore is pending", () => {
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction()}
onQuarantineRestore={() => {}}
quarantineRestorePending
/>,
);
expect(
node.querySelector<HTMLButtonElement>("[data-testid='recovery-action-repair-trigger']")?.disabled,
).toBe(true);
});
it("in the contended case disables repair, explains the claimant, and recommends re-issue", () => {
const onQuarantineRestore = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction({
workspaceValidation: {
contention: {
claimedByWorkspaceId: "ws-99",
claimedByIssueId: "issue-99",
claimedByIssueIdentifier: "PAP-9001",
activeRun: { id: "run-9001", status: "running", issueId: "issue-99", issueIdentifier: "PAP-9001" },
},
},
})}
onQuarantineRestore={onQuarantineRestore}
onReissueIsolated={() => {}}
/>,
);
// Diagnosis gains a claimant line naming the issue + active run.
const notice = node.querySelector("[data-testid='recovery-contention-notice']");
expect(notice?.textContent).toContain("PAP-9001");
expect(notice?.textContent).toContain("active run");
// The repair control is present but disabled, with the claimant as the explanation.
const disabled = node.querySelector("[data-testid='recovery-action-repair-disabled']");
expect(disabled).not.toBeNull();
const trigger = disabled?.querySelector<HTMLButtonElement>(
"[data-testid='recovery-action-repair-trigger']",
);
expect(trigger?.disabled).toBe(true);
expect(disabled?.textContent).toContain("PAP-9001");
// Clicking the disabled control never fires the repair.
click(trigger ?? null);
expect(onQuarantineRestore).not.toHaveBeenCalled();
// Re-issue is surfaced as the recommended action.
expect(node.querySelector("[data-testid='recovery-reissue-recommended']")).not.toBeNull();
expect(
node
.querySelector("[data-testid='recovery-action-reissue-trigger']")
?.getAttribute("data-recommended"),
).toBe("true");
});
it("compact variant drops the metadata table but keeps the diagnosis and repair action", () => {
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction()}
onQuarantineRestore={() => {}}
variant="compact"
/>,
);
// Metadata rows (e.g. the Owner/Next action table) are dropped in compact mode.
expect(node.textContent).not.toContain("Choose and record a valid issue disposition.");
// The divergence diagnosis and repair action still render.
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
});
});

View File

@ -12,10 +12,12 @@ import {
GitBranch,
GitBranchPlus,
Loader2,
Lock,
OctagonAlert,
RefreshCw,
Sparkles,
TriangleAlert,
Wrench,
} from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
@ -90,10 +92,25 @@ export interface IssueRecoveryActionCardProps {
* not rendered at all a non-permitted user never sees the "reconcile anyway" affordance.
*/
canBreakGlass?: boolean;
/** Whether a reconcile (forward or override) is currently in flight (disables both actions). */
/**
* Handler for the lossless repair "Repair workspace — quarantine changes & restore branch"
* (workspace_validation only). Rendered only for a *dirty* divergence; the caller invokes the S4
* reconcile op in `quarantine_restore` mode, which quarantines the dirty worktree onto a rescue
* branch and restores the recorded branch. If omitted, the repair action is not shown.
*/
onQuarantineRestore?: () => void;
/** Whether a quarantine-restore repair is currently in flight (shares the reconcile spinner). */
quarantineRestorePending?: boolean;
/** Whether a reconcile (forward, override, or quarantine-restore) is currently in flight. */
reconcilePending?: boolean;
/** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */
canFalsePositive?: boolean;
/**
* Rendering density. `full` (default) shows the complete metadata table; `compact` drops the
* metadata rows for embedding beside a run on the agent run page, keeping the header, divergence
* diagnosis, and action footer.
*/
variant?: "full" | "compact";
className?: string;
}
@ -248,6 +265,13 @@ function formatShortSha(sha: string | null): string | null {
* live ("actual"/checked-out) branch, both HEAD shas, and a server-computed ancestry verdict +
* plain-language explanation of why the run was declined.
*/
interface WorkspaceContention {
claimedByIssueId: string | null;
claimedByIssueIdentifier: string | null;
/** True when the claiming workspace has a queued/running run (not just a stale claim). */
hasActiveRun: boolean;
}
interface WorkspaceDivergence {
expectedBranch: string | null;
liveBranch: string | null;
@ -256,10 +280,60 @@ interface WorkspaceDivergence {
ancestryVerdict: GitWorktreeBranchAncestryVerdict | null;
plainLanguageReason: string | null;
cleanliness: "clean" | "dirty" | "unknown" | null;
/** Number of dirty (uncommitted) status entries in the live worktree, when known. */
dirtyFileCount: number | null;
/** Sample of dirty paths (already truncated server-side) for the confirm step. */
dirtyPathSample: string[];
/**
* Another workspace is holding the live branch. When present, the lossless quarantine repair is
* refused server-side re-issuing on an isolated workspace is the recommended path instead.
*/
contention: WorkspaceContention | null;
/**
* Preview of the rescue branch the quarantine repair will create. The server appends a UTC
* timestamp at repair time, so this is the stable prefix only (rendered with a trailing marker).
*/
rescueBranchPreview: string;
/** Ref a re-issue should base off — the live branch when known, else the live HEAD sha. */
reissueBaseRef: string | null;
}
/** Mirrors the server's `sanitizeBranchName` for a faithful rescue-branch preview. */
function sanitizeBranchComponent(value: string): string {
return (
value
.trim()
.replace(/[^A-Za-z0-9._/-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-/.]+|[-/.]+$/g, "")
.slice(0, 120) || "issue"
);
}
function buildRescueBranchPreview(sourceIdentifier: string | null): string {
return `paperclip/rescue/${sanitizeBranchComponent(sourceIdentifier ?? "issue")}/`;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
}
function asNonNegativeInt(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
}
function readContention(value: unknown): WorkspaceContention | null {
const record = asRecord(value);
if (!record) return null;
const activeRun = asRecord(record.activeRun);
return {
claimedByIssueId: asNonEmptyString(record.claimedByIssueId),
claimedByIssueIdentifier: asNonEmptyString(record.claimedByIssueIdentifier),
hasActiveRun: activeRun !== null,
};
}
function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergence | null {
if (action.kind !== "workspace_validation") return null;
const workspaceValidation = asRecord(action.evidence?.workspaceValidation);
@ -275,6 +349,7 @@ function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergen
cleanlinessRaw === "clean" || cleanlinessRaw === "dirty" || cleanlinessRaw === "unknown"
? cleanlinessRaw
: null;
const sourceIdentifier = asNonEmptyString(workspaceValidation.sourceIdentifier);
return {
expectedBranch,
liveBranch,
@ -283,6 +358,10 @@ function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergen
ancestryVerdict: asAncestryVerdict(provenance.ancestryVerdict),
plainLanguageReason: asNonEmptyString(provenance.plainLanguageReason),
cleanliness,
dirtyFileCount: asNonNegativeInt(workspaceValidation.statusEntryCount),
dirtyPathSample: asStringArray(workspaceValidation.dirtyPathSample),
contention: readContention(workspaceValidation.contention),
rescueBranchPreview: buildRescueBranchPreview(sourceIdentifier),
reissueBaseRef: liveBranch ?? liveHeadSha,
};
}
@ -380,10 +459,31 @@ function DivergenceDiagnosis({
{divergence.plainLanguageReason ? (
<p className="text-xs leading-5 text-foreground/80">{divergence.plainLanguageReason}</p>
) : null}
{divergence.contention ? (
<p
data-testid="recovery-contention-notice"
className="flex items-start gap-1.5 rounded-md border border-amber-400/40 bg-amber-500/5 px-2.5 py-1.5 text-xs leading-5 text-amber-900 dark:text-amber-200"
>
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<span>
Worktree claimed by{" "}
<code className="font-mono text-foreground/90">{contentionLabel(divergence.contention)}</code>{" "}
{divergence.contention.hasActiveRun ? "(active run)" : "(claim held)"} the lossless repair
can&apos;t run while another workspace holds the live branch.
</span>
</p>
) : null}
</div>
);
}
function contentionLabel(contention: WorkspaceContention): string {
return (
contention.claimedByIssueIdentifier ??
(contention.claimedByIssueId ? `issue ${contention.claimedByIssueId.slice(0, 8)}` : "another task")
);
}
/**
* Action 2 the audited break-glass override. Gated by an explicit confirm step that *restates the
* divergence* (both branches + short SHAs + ancestry verdict) and a required, non-empty reason: the
@ -498,6 +598,138 @@ function BreakGlassOverride({
);
}
/**
* The lossless repair quarantine the dirty worktree onto a rescue branch, then restore the
* recorded branch. Unlike break-glass, this is *non-destructive* (no work is lost, so no reason is
* required): the confirm popover simply restates what will happen the dirty file count, that the
* live branch is left untouched, the rescue branch that will hold the changes, and the recorded
* branch to be restored. Disabled (with an inline explanation, no popover) when the live branch is
* contended by another workspace, since the server refuses the repair in that case.
*/
function RepairWorkspace({
divergence,
onConfirm,
pending,
disabled,
disabledReason,
}: {
divergence: WorkspaceDivergence;
onConfirm: () => void;
pending: boolean;
disabled: boolean;
disabledReason: string | null;
}) {
const dirtyCount = divergence.dirtyFileCount;
const dirtyLabel =
dirtyCount === null
? "Uncommitted changes"
: `${dirtyCount} uncommitted ${dirtyCount === 1 ? "change" : "changes"}`;
const trigger = (
<Button
type="button"
size="sm"
variant="outline"
disabled={pending || disabled}
data-testid="recovery-action-repair-trigger"
className="border-sky-400/50 text-sky-700 hover:bg-sky-500/10 dark:border-sky-500/40 dark:text-sky-300"
>
{pending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Wrench className="h-3.5 w-3.5" aria-hidden />
)}
Repair workspace quarantine changes &amp; restore branch
</Button>
);
if (disabled) {
// Contended: the server refuses the repair, so render a plainly disabled control with the reason
// inline rather than a popover the operator can't act on.
return (
<div className="flex flex-col gap-1" data-testid="recovery-action-repair-disabled">
{trigger}
{disabledReason ? (
<span className="text-(length:--text-nano) leading-4 text-muted-foreground">
{disabledReason}
</span>
) : null}
</div>
);
}
return (
<Popover>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
aria-labelledby="recovery-repair-title"
className="w-96 max-w-(--sz-calc-4) space-y-3 p-3"
>
<div className="space-y-1">
<div
id="recovery-repair-title"
className="flex items-center gap-1.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-sky-700 dark:text-sky-300"
>
<Wrench className="h-3.5 w-3.5" aria-hidden />
Repair workspace
</div>
<p className="text-xs leading-5 text-muted-foreground">
This is lossless no reason required. Your uncommitted changes are committed onto a fresh
rescue branch, then the recorded branch is restored so the task can resume. The live branch
is left exactly as it is.
</p>
</div>
<dl
data-testid="recovery-repair-restated"
className="space-y-1.5 rounded-md border border-sky-400/30 bg-sky-500/5 px-2.5 py-2 text-(length:--text-micro)"
>
<div className="flex items-center justify-between gap-2">
<dt className="shrink-0 text-muted-foreground">Dirty changes</dt>
<dd data-testid="recovery-repair-dirty-count" className="font-medium text-foreground/90">
{dirtyLabel}
</dd>
</div>
<div className="flex items-center justify-between gap-2">
<dt className="shrink-0 text-muted-foreground">Live branch</dt>
<dd className="min-w-0 truncate font-mono text-foreground/90">
{divergence.liveBranch ?? "detached"}
<span className="ml-1 font-sans text-muted-foreground">(left untouched)</span>
</dd>
</div>
<div className="flex items-center justify-between gap-2">
<dt className="shrink-0 text-muted-foreground">Rescue branch</dt>
<dd
data-testid="recovery-repair-rescue-branch"
className="min-w-0 truncate font-mono text-foreground/90"
>
{divergence.rescueBranchPreview}
<span className="text-muted-foreground">&lt;timestamp&gt;</span>
</dd>
</div>
<div className="flex items-center justify-between gap-2">
<dt className="shrink-0 text-muted-foreground">Restore to</dt>
<dd className="min-w-0 truncate font-mono text-foreground/90">
{divergence.expectedBranch ?? "recorded branch"}
</dd>
</div>
</dl>
<Button
type="button"
size="sm"
className="w-full"
disabled={pending}
data-testid="recovery-action-repair-confirm"
onClick={() => {
if (pending) return;
onConfirm();
}}
>
{pending ? "Repairing…" : "Quarantine changes & restore branch"}
</Button>
</PopoverContent>
</Popover>
);
}
function readWakePolicySummary(action: IssueRecoveryAction): string | null {
const policy = action.wakePolicy;
if (!policy) return null;
@ -671,9 +903,12 @@ export function IssueRecoveryActionCard({
reissuePending = false,
onReconcileForward,
onBreakGlassOverride,
onQuarantineRestore,
quarantineRestorePending = false,
canBreakGlass = false,
reconcilePending = false,
canFalsePositive = false,
variant = "full",
className,
}: IssueRecoveryActionCardProps) {
const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action);
@ -741,8 +976,27 @@ export function IssueRecoveryActionCard({
cardState !== "resolved" &&
divergence !== null &&
canBreakGlass;
// The lossless repair — offered only for a *dirty* divergence (a clean one reconciles forward or
// via break-glass, with nothing to quarantine). Disabled when the live branch is contended by an
// active claimant, since the server refuses `quarantine_restore` in that case.
const repairContention = divergence?.contention ?? null;
const showRepairAction =
onQuarantineRestore !== undefined &&
cardState !== "resolved" &&
divergence !== null &&
divergence.cleanliness === "dirty";
const repairDisabledReason = repairContention
? `Held by ${contentionLabel(repairContention)} — re-issue on an isolated workspace instead.`
: null;
// When contended, the re-issue is the recommended path, so it takes the primary emphasis and a
// "Recommended" hint while the repair button is disabled.
const reissueRecommended = showRepairAction && repairContention !== null;
const showFooter =
showResolveActions || showReissueAction || showReconcileForward || showBreakGlass;
showResolveActions ||
showReissueAction ||
showReconcileForward ||
showBreakGlass ||
showRepairAction;
return (
<section
@ -785,6 +1039,7 @@ export function IssueRecoveryActionCard({
<p className="mt-1 text-sm leading-6">{headline}</p>
</div>
</header>
{variant === "compact" ? null : (
<dl className={cn("border-t bg-background/40 dark:bg-background/20", tone.divider)}>
<MetadataRow label="Owner">
<span className="inline-flex flex-wrap items-center gap-1.5">
@ -852,6 +1107,7 @@ export function IssueRecoveryActionCard({
</MetadataRow>
) : null}
</dl>
)}
{divergence ? <DivergenceDiagnosis divergence={divergence} dividerClass={tone.divider} /> : null}
{showFooter ? (
<div className={cn("flex flex-wrap items-center gap-2 border-t px-3 py-2.5 sm:px-4", tone.divider)}>
@ -913,15 +1169,25 @@ export function IssueRecoveryActionCard({
Reconcile forward &amp; continue
</Button>
) : null}
{showRepairAction && divergence ? (
<RepairWorkspace
divergence={divergence}
pending={quarantineRestorePending}
disabled={repairContention !== null}
disabledReason={repairDisabledReason}
onConfirm={() => onQuarantineRestore?.()}
/>
) : null}
{showReissueAction && divergence && reissueBaseRef ? (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
variant={reissueRecommended ? "default" : "outline"}
disabled={reissuePending}
data-testid="recovery-action-reissue-trigger"
data-recommended={reissueRecommended ? "true" : undefined}
>
{reissuePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
@ -929,6 +1195,14 @@ export function IssueRecoveryActionCard({
<GitBranchPlus className="h-3.5 w-3.5" aria-hidden />
)}
Re-issue on isolated workspace
{reissueRecommended ? (
<span
data-testid="recovery-reissue-recommended"
className="ml-1 rounded-sm bg-background/25 px-1.5 py-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-label)"
>
Recommended
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-80 space-y-3 p-3">

View File

@ -0,0 +1,217 @@
// @vitest-environment jsdom
import type { ComponentProps, ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { HeartbeatRun, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { RunWorkspaceRecoverySurface } from "./RunWorkspaceRecoverySurface";
import { ToastProvider } from "../context/ToastContext";
const navigateMock = vi.hoisted(() => vi.fn());
const issueGetMock = vi.hoisted(() => vi.fn());
const issueCreateMock = vi.hoisted(() => vi.fn());
const resolveRecoveryMock = vi.hoisted(() => vi.fn());
const reconcileMock = vi.hoisted(() => vi.fn());
const boardAccessMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
get: issueGetMock,
create: issueCreateMock,
resolveRecoveryAction: resolveRecoveryMock,
},
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: {
reconcile: reconcileMock,
},
}));
vi.mock("../api/access", () => ({
accessApi: {
getCurrentBoardAccess: boardAccessMock,
},
}));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// flushSync-based sync `act` shim (matches the repo pattern in IssueRecoveryActionCard.test.tsx).
function act(callback: () => void): void {
flushSync(callback);
}
// Resolve pending mocked-promise micro/macrotasks, then flush the React state updates react-query
// queued, repeating so chained query → render → dependent-render settles fully.
async function flush() {
for (let i = 0; i < 6; i += 1) {
for (let j = 0; j < 4; j += 1) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
act(() => {});
}
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
vi.clearAllMocks();
});
function buildRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
return {
id: "run-aaaa1111",
companyId: "company-1",
agentId: "agent-1",
status: "failed",
errorCode: "workspace_validation_failed",
contextSnapshot: { issueId: "issue-1" },
resultJson: null,
contextSnapshotJson: null,
...overrides,
} as unknown as HeartbeatRun;
}
function buildRecoveryAction(): IssueRecoveryAction {
return {
id: "action-1",
companyId: "company-1",
sourceIssueId: "issue-1",
recoveryIssueId: null,
kind: "workspace_validation",
status: "active",
ownerType: "board",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "workspace_validation_failed",
fingerprint: "fp",
evidence: {
workspaceValidation: {
reason: "git_worktree_branch_incoherence",
expectedBranch: "PAP-1405-recorded",
actualBranch: "live-branch",
cleanliness: "dirty",
statusEntryCount: 2,
dirtyPathSample: ["a.ts"],
sourceIdentifier: "PAP-1405",
persistedExecutionWorkspaceId: "ws-1",
provenance: {
expectedHeadSha: "aaaa1111bbbb",
actualHeadSha: "cccc2222dddd",
ancestryVerdict: "diverged",
plainLanguageReason: "Recorded branch is not an ancestor of the live branch.",
},
},
},
nextAction: "Repair the workspace.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-07-09T00:00:00.000Z",
updatedAt: "2026-07-09T00:00:00.000Z",
};
}
function buildIssue(action: IssueRecoveryAction | null): Issue {
return {
id: "issue-1",
identifier: "PAP-1405",
companyId: "company-1",
title: "Do the thing",
description: "body",
priority: "medium",
projectId: null,
parentId: null,
assigneeAgentId: null,
executionWorkspaceId: "ws-1",
activeRecoveryAction: action,
} as unknown as Issue;
}
async function renderSurface(run: HeartbeatRun) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<QueryClientProvider client={client}>
<ToastProvider>
<RunWorkspaceRecoverySurface run={run} />
</ToastProvider>
</QueryClientProvider>,
);
});
// Let the issue + board-access queries settle and commit.
await flush();
return container!;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
act(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
describe("RunWorkspaceRecoverySurface", () => {
it("renders nothing for a run that is not a workspace-validation failure", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue(undefined);
const node = await renderSurface(buildRun({ errorCode: "process_lost" }));
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
expect(issueGetMock).not.toHaveBeenCalled();
});
it("renders nothing when the source issue has no workspace_validation recovery action", async () => {
issueGetMock.mockResolvedValue(buildIssue(null));
boardAccessMock.mockResolvedValue(undefined);
const node = await renderSurface(buildRun());
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
});
it("renders the compact recovery card and repair action for a dirty divergence", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
const node = await renderSurface(buildRun());
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
// Compact: metadata rows dropped.
expect(node.textContent).not.toContain("Repair the workspace.");
});
it("wires the repair confirm to reconcile in quarantine_restore mode against the pinned workspace", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
reconcileMock.mockResolvedValue({ id: "ws-1" });
const node = await renderSurface(buildRun());
click(node.querySelector("[data-testid='recovery-action-repair-trigger']"));
click(document.body.querySelector("[data-testid='recovery-action-repair-confirm']"));
await flush();
expect(reconcileMock).toHaveBeenCalledWith("ws-1", { mode: "quarantine_restore" });
});
});

View File

@ -0,0 +1,289 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { HeartbeatRun } from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { issuesApi } from "../api/issues";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { accessApi } from "../api/access";
import { queryKeys } from "../lib/queryKeys";
import { useToastActions } from "../context/ToastContext";
import {
IssueRecoveryActionCard,
type RecoveryReissueRequest,
type RecoveryResolveOutcome,
} from "./IssueRecoveryActionCard";
import {
canBoardManageRuntime,
readRecoveryReconcileWorkspaceId,
} from "../lib/recovery-reconcile";
/** The run errorCode Paperclip stamps when it declines a run over a git workspace it can't validate. */
export const WORKSPACE_VALIDATION_RUN_ERROR_CODE = "workspace_validation_failed";
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Reads the source issue id a failed run was working when it was declined over workspace validation.
* The run's context snapshot pins the issue the recovery action lives on.
*/
function readRunIssueId(run: HeartbeatRun): string | null {
const context = asRecord(run.contextSnapshot);
if (!context) return null;
return asNonEmptyString(context.issueId);
}
/**
* Run-page recovery surface. When a run *failed* with workspace-validation evidence, this fetches
* the source issue's active recovery action and renders the same `IssueRecoveryActionCard`
* (compact) that `IssueDetail` shows wired to the same reconcile-forward / repair / re-issue /
* break-glass / resolve handlers so the divergence can be resolved from the run view directly.
*
* Renders nothing unless the run is a workspace-validation failure whose source issue still carries
* a live `workspace_validation` recovery action.
*/
export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const isWorkspaceValidationFailure =
run.status === "failed" && run.errorCode === WORKSPACE_VALIDATION_RUN_ERROR_CODE;
const issueId = readRunIssueId(run);
const { data: issue } = useQuery({
queryKey: queryKeys.issues.detail(issueId ?? "__none__"),
queryFn: () => issuesApi.get(issueId!),
enabled: Boolean(isWorkspaceValidationFailure && issueId),
});
const { data: boardAccess } = useQuery({
queryKey: queryKeys.access.currentBoardAccess,
queryFn: () => accessApi.getCurrentBoardAccess(),
enabled: Boolean(isWorkspaceValidationFailure && issueId),
retry: false,
});
const recoveryAction = issue?.activeRecoveryAction ?? null;
const canManageBoardRuntime = canBoardManageRuntime(run.companyId, boardAccess);
// Prefer the workspace pinned by the recovery action's evidence (the workspace that actually
// diverged) over the page-level id, which can drift after a re-issue rebinds the issue.
const reconcileWorkspaceId =
readRecoveryReconcileWorkspaceId(recoveryAction) ?? issue?.executionWorkspaceId ?? null;
const invalidate = useCallback(() => {
if (issueId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) });
}
queryClient.invalidateQueries({ queryKey: queryKeys.runIssues(run.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.runDetail(run.id) });
}, [issueId, queryClient, run.id]);
const reconcile = useMutation({
mutationFn: (
input:
| { workspaceId: string; mode: "forward" }
| { workspaceId: string; mode: "override"; reason: string }
| { workspaceId: string; mode: "quarantine_restore" },
) => {
const { workspaceId, ...body } = input;
return executionWorkspacesApi.reconcile(workspaceId, body);
},
onSuccess: (_result, variables) => {
invalidate();
pushToast(
variables.mode === "quarantine_restore"
? {
title: "Workspace repaired",
body: "Dirty changes were quarantined onto a rescue branch and the recorded branch restored; the task will resume.",
tone: "success",
}
: {
title: "Workspace branch reconciled",
body: "The recorded branch now matches the live branch; the task will resume.",
tone: "success",
},
);
},
onError: (err) => {
pushToast({
title: "Reconcile failed",
body: err instanceof Error ? err.message : "Unable to reconcile the workspace branch.",
tone: "error",
});
},
});
const reissue = useMutation({
mutationFn: async (request: RecoveryReissueRequest) => {
if (!issue) throw new Error("Task is not loaded yet.");
const sourceLabel = issue.identifier ?? "the stalled task";
const descriptionLines = [
`Re-issued from ${sourceLabel} on an isolated git worktree after a workspace branch divergence.`,
"",
`- Base ref (live branch): \`${request.baseRef}\``,
...(request.expectedBranch ? [`- Recorded branch: \`${request.expectedBranch}\``] : []),
"",
"---",
"",
issue.description ?? "",
];
return issuesApi.create(issue.companyId, {
title: `Re-issue (isolated): ${issue.title ?? sourceLabel}`,
description: descriptionLines.join("\n"),
priority: issue.priority,
projectId: issue.projectId ?? null,
parentId: issue.parentId ?? null,
assigneeAgentId:
recoveryAction?.returnOwnerAgentId ??
recoveryAction?.previousOwnerAgentId ??
issue.assigneeAgentId ??
null,
executionWorkspacePreference: "isolated_workspace",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree", baseRef: request.baseRef },
},
});
},
onSuccess: (created) => {
invalidate();
pushToast({
title: "Isolated re-issue created",
body: created.identifier
? `${created.identifier} will run on a fresh isolated workspace.`
: "A fresh isolated re-issue was created.",
tone: "success",
});
if (created.identifier) {
navigate(`/issues/${created.identifier}`);
}
},
onError: (err) => {
pushToast({
title: "Re-issue failed",
body: err instanceof Error ? err.message : "Unable to create an isolated re-issue.",
tone: "error",
});
},
});
const resolve = useMutation({
mutationFn: (data: {
outcome: "restored" | "false_positive";
sourceIssueStatus: "todo" | "done" | "in_review";
}) => {
if (!issueId || !recoveryAction) throw new Error("No recovery action to resolve.");
return issuesApi.resolveRecoveryAction(issueId, {
actionId: recoveryAction.id,
outcome: data.outcome,
sourceIssueStatus: data.sourceIssueStatus,
});
},
onSuccess: () => {
invalidate();
},
onError: (err) => {
pushToast({
title: "Recovery resolution failed",
body: err instanceof Error ? err.message : "Unable to resolve recovery action",
tone: "error",
});
},
});
const handleReconcileForward = useCallback(() => {
if (!reconcileWorkspaceId) return;
void reconcile.mutateAsync({ workspaceId: reconcileWorkspaceId, mode: "forward" });
}, [reconcile, reconcileWorkspaceId]);
const handleBreakGlass = useCallback(
(reason: string) => {
if (!reconcileWorkspaceId) return;
void reconcile.mutateAsync({ workspaceId: reconcileWorkspaceId, mode: "override", reason });
},
[reconcile, reconcileWorkspaceId],
);
const handleQuarantineRestore = useCallback(() => {
if (!reconcileWorkspaceId) return;
void reconcile.mutateAsync({ workspaceId: reconcileWorkspaceId, mode: "quarantine_restore" });
}, [reconcile, reconcileWorkspaceId]);
const handleReissue = useCallback(
(request: RecoveryReissueRequest) => {
void reissue.mutateAsync(request);
},
[reissue],
);
const handleResolve = useCallback(
(outcome: RecoveryResolveOutcome) => {
switch (outcome) {
case "todo":
void resolve.mutateAsync({ outcome: "restored", sourceIssueStatus: "todo" });
return;
case "done":
void resolve.mutateAsync({ outcome: "restored", sourceIssueStatus: "done" });
return;
case "in_review":
void resolve.mutateAsync({ outcome: "restored", sourceIssueStatus: "in_review" });
return;
case "false_positive_done":
void resolve.mutateAsync({ outcome: "false_positive", sourceIssueStatus: "done" });
return;
case "false_positive_in_review":
void resolve.mutateAsync({ outcome: "false_positive", sourceIssueStatus: "in_review" });
return;
}
},
[resolve],
);
if (!isWorkspaceValidationFailure || !issueId) return null;
if (!recoveryAction || recoveryAction.kind !== "workspace_validation") return null;
return (
<div className="space-y-2" data-testid="run-workspace-recovery-surface">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">Workspace recovery</span>
{issue?.identifier ? (
<a
href={`/issues/${issue.identifier}`}
className="font-mono text-xs text-muted-foreground underline-offset-2 hover:underline"
onClick={(event) => {
event.preventDefault();
navigate(`/issues/${issue.identifier}`);
}}
>
{issue.identifier}
</a>
) : null}
</div>
<IssueRecoveryActionCard
action={recoveryAction}
variant="compact"
onResolve={handleResolve}
onReissueIsolated={handleReissue}
reissuePending={reissue.isPending}
onReconcileForward={handleReconcileForward}
onBreakGlassOverride={handleBreakGlass}
onQuarantineRestore={handleQuarantineRestore}
quarantineRestorePending={reconcile.isPending}
reconcilePending={reconcile.isPending}
canBreakGlass={canManageBoardRuntime}
/>
</div>
);
}
export default RunWorkspaceRecoverySurface;

View File

@ -0,0 +1,62 @@
import type { IssueRecoveryAction } from "@paperclipai/shared";
import type { CurrentBoardAccess } from "../api/access";
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Best-effort client mirror of the backend `runtime:manage` gate that the break-glass override
* reconcile (`POST /execution-workspaces/:id/reconcile-branch` in `override` mode) actually
* enforces. The server re-checks `runtime:manage` for every reconcile and is authoritative, so
* this is defense-in-depth: it hides the "reconcile anyway" affordance from viewers rather than
* showing a button that always 403s. For human board members `runtime:manage` grants on the
* same non-viewer, active-membership condition as recovery resolution (see
* `server/src/services/authorization.ts`), so the shape matches; per-permission-key overrides
* are not surfaced to the client and remain the server's call.
*/
export function canBoardManageRuntime(
companyId: string | null | undefined,
boardAccess: CurrentBoardAccess | undefined,
) {
if (!companyId || !boardAccess) return false;
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
if (!boardAccess.memberships || boardAccess.memberships.length === 0) {
return boardAccess.companyIds.includes(companyId);
}
const membership = boardAccess.memberships.find(
(item) => item.companyId === companyId && item.status === "active",
);
if (!membership) return false;
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
}
/**
* The execution workspace a reconcile action should target. The recovery card is rendered from a
* specific `workspace_validation` recovery action whose evidence pins the workspace that diverged;
* that workspace not the page-level `issue.executionWorkspaceId` is the authoritative target.
* The page-level id can drift (e.g. a re-issue rebinds the issue to a new workspace) while the card
* still shows the older action, so we prefer the action's evidence and only fall back to the
* page-level id when the evidence carries no workspace reference.
*
* The branch-incoherence failure (the one that renders the reconcile-forward / break-glass actions)
* records the workspace under `persistedExecutionWorkspaceId`; the not-reusable failure records it
* under `executionWorkspaceId`. We accept either key so both divergence shapes pin correctly.
*/
export function readRecoveryReconcileWorkspaceId(
action: IssueRecoveryAction | null | undefined,
): string | null {
if (!action || action.kind !== "workspace_validation") return null;
const workspaceValidation = asRecord(action.evidence?.workspaceValidation);
if (!workspaceValidation) return null;
const persisted = workspaceValidation.persistedExecutionWorkspaceId;
if (typeof persisted === "string" && persisted.length > 0) return persisted;
const executionWorkspaceId = workspaceValidation.executionWorkspaceId;
if (typeof executionWorkspaceId === "string" && executionWorkspaceId.length > 0) {
return executionWorkspaceId;
}
return null;
}

View File

@ -101,6 +101,7 @@ import {
responsibleUserLabel,
} from "@paperclipai/shared";
import { ResponsibleUserDenialNotice } from "../components/ResponsibleUserDenialNotice";
import { RunWorkspaceRecoverySurface } from "../components/RunWorkspaceRecoverySurface";
import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
@ -3049,6 +3050,10 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
return (
<div className="space-y-4 min-w-0">
{/* Workspace-validation recovery: surfaces the recovery card when this run was declined over a
git workspace it could not validate, wired to the same reconcile / repair / re-issue /
break-glass handlers as the task detail page. */}
<RunWorkspaceRecoverySurface run={run} />
{/* Run summary card */}
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex flex-col sm:flex-row">

View File

@ -69,7 +69,8 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
enableWorkspaceBranchReconcileForward: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
};
}

View File

@ -11,6 +11,10 @@ import { activityApi, type RunForIssue } from "../api/activity";
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { accessApi, type CurrentBoardAccess } from "../api/access";
import {
canBoardManageRuntime,
readRecoveryReconcileWorkspaceId,
} from "../lib/recovery-reconcile";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
@ -295,59 +299,11 @@ export function canBoardResolveRecoveryAction(
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
}
/**
* Best-effort client mirror of the backend `runtime:manage` gate that the break-glass override
* reconcile (`POST /execution-workspaces/:id/reconcile-branch` in `override` mode) actually
* enforces. The server re-checks `runtime:manage` for every reconcile and is authoritative, so
* this is defense-in-depth: it hides the "reconcile anyway" affordance from viewers rather than
* showing a button that always 403s. For human board members `runtime:manage` grants on the
* same non-viewer, active-membership condition as recovery resolution (see
* `server/src/services/authorization.ts`), so the shape matches; per-permission-key overrides
* are not surfaced to the client and remain the server's call.
*/
export function canBoardManageRuntime(
companyId: string | null | undefined,
boardAccess: CurrentBoardAccess | undefined,
) {
if (!companyId || !boardAccess) return false;
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
if (!boardAccess.memberships || boardAccess.memberships.length === 0) {
return boardAccess.companyIds.includes(companyId);
}
const membership = boardAccess.memberships.find(
(item) => item.companyId === companyId && item.status === "active",
);
if (!membership) return false;
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
}
/**
* The execution workspace a reconcile action should target. The recovery card is rendered from a
* specific `workspace_validation` recovery action whose evidence pins the workspace that diverged;
* that workspace not the page-level `issue.executionWorkspaceId` is the authoritative target.
* The page-level id can drift (e.g. a re-issue rebinds the issue to a new workspace) while the card
* still shows the older action, so we prefer the action's evidence and only fall back to the
* page-level id when the evidence carries no workspace reference.
*
* The branch-incoherence failure (the one that renders the reconcile-forward / break-glass actions)
* records the workspace under `persistedExecutionWorkspaceId`; the not-reusable failure records it
* under `executionWorkspaceId`. We accept either key so both divergence shapes pin correctly.
*/
export function readRecoveryReconcileWorkspaceId(
action: IssueRecoveryAction | null | undefined,
): string | null {
if (!action || action.kind !== "workspace_validation") return null;
const workspaceValidation = asRecord(action.evidence?.workspaceValidation);
if (!workspaceValidation) return null;
const persisted = workspaceValidation.persistedExecutionWorkspaceId;
if (typeof persisted === "string" && persisted.length > 0) return persisted;
const executionWorkspaceId = workspaceValidation.executionWorkspaceId;
if (typeof executionWorkspaceId === "string" && executionWorkspaceId.length > 0) {
return executionWorkspaceId;
}
return null;
}
// `canBoardManageRuntime` and `readRecoveryReconcileWorkspaceId` moved to `@/lib/recovery-reconcile`
// so the run-page recovery surface can reuse them without importing this page module. Re-exported
// here (from the top-of-file import) to keep existing import sites — and their tests — stable, while
// the imported bindings stay usable within this module.
export { canBoardManageRuntime, readRecoveryReconcileWorkspaceId };
export function shouldScrollIssueDetailToTopOnNavigation(input: {
previousIssueId: string | undefined;
@ -899,6 +855,8 @@ type IssueDetailChatTabProps = {
reissueIsolatedRecoveryActionPending?: boolean;
onReconcileForwardRecoveryAction?: () => void;
onBreakGlassOverrideRecoveryAction?: (reason: string) => void;
onQuarantineRestoreRecoveryAction?: () => void;
quarantineRestoreRecoveryActionPending?: boolean;
canBreakGlassRecoveryAction?: boolean;
reconcileRecoveryActionPending?: boolean;
canFalsePositiveRecoveryAction?: boolean;
@ -983,6 +941,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
reissueIsolatedRecoveryActionPending,
onReconcileForwardRecoveryAction,
onBreakGlassOverrideRecoveryAction,
onQuarantineRestoreRecoveryAction,
quarantineRestoreRecoveryActionPending,
canBreakGlassRecoveryAction,
reconcileRecoveryActionPending,
canFalsePositiveRecoveryAction,
@ -1207,6 +1167,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryActionPending}
onReconcileForwardRecoveryAction={onReconcileForwardRecoveryAction}
onBreakGlassOverrideRecoveryAction={onBreakGlassOverrideRecoveryAction}
onQuarantineRestoreRecoveryAction={onQuarantineRestoreRecoveryAction}
quarantineRestoreRecoveryActionPending={quarantineRestoreRecoveryActionPending}
canBreakGlassRecoveryAction={canBreakGlassRecoveryAction}
reconcileRecoveryActionPending={reconcileRecoveryActionPending}
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
@ -3729,21 +3691,30 @@ export function IssueDetail() {
mutationFn: async (
input:
| { workspaceId: string; mode: "forward" }
| { workspaceId: string; mode: "override"; reason: string },
| { workspaceId: string; mode: "override"; reason: string }
| { workspaceId: string; mode: "quarantine_restore" },
) => {
const { workspaceId, ...body } = input;
return executionWorkspacesApi.reconcile(workspaceId, body);
},
onSuccess: () => {
onSuccess: (_result, variables) => {
// Refresh the detail card itself (not just the list collections): a successful reconcile
// clears the active recovery action, so the card must re-fetch to stop showing stale actions.
invalidateIssueDetail();
invalidateIssueCollections();
pushToast({
title: "Workspace branch reconciled",
body: "The recorded branch now matches the live branch; the task will resume.",
tone: "success",
});
pushToast(
variables.mode === "quarantine_restore"
? {
title: "Workspace repaired",
body: "Dirty changes were quarantined onto a rescue branch and the recorded branch restored; the task will resume.",
tone: "success",
}
: {
title: "Workspace branch reconciled",
body: "The recorded branch now matches the live branch; the task will resume.",
tone: "success",
},
);
},
onError: (err) => {
pushToast({
@ -3793,6 +3764,22 @@ export function IssueDetail() {
},
[reconcileExecutionWorkspaceId, reconcileRecoveryAction.mutateAsync, pushToast],
);
// Repair action (workspace_validation, dirty divergence): quarantine the dirty worktree onto a
// rescue branch and restore the recorded branch. Lossless — no reason required.
const handleQuarantineRestoreRecoveryAction = useCallback(() => {
if (!reconcileExecutionWorkspaceId) {
pushToast({
title: "Repair failed",
body: "This task has no execution workspace to repair.",
tone: "error",
});
return;
}
void reconcileRecoveryAction.mutateAsync({
workspaceId: reconcileExecutionWorkspaceId,
mode: "quarantine_restore",
});
}, [reconcileExecutionWorkspaceId, reconcileRecoveryAction.mutateAsync, pushToast]);
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
@ -4690,6 +4677,8 @@ export function IssueDetail() {
reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryAction.isPending}
onReconcileForwardRecoveryAction={handleReconcileForwardRecoveryAction}
onBreakGlassOverrideRecoveryAction={handleBreakGlassOverrideRecoveryAction}
onQuarantineRestoreRecoveryAction={handleQuarantineRestoreRecoveryAction}
quarantineRestoreRecoveryActionPending={reconcileRecoveryAction.isPending}
canBreakGlassRecoveryAction={canManageBoardRuntime}
reconcileRecoveryActionPending={reconcileRecoveryAction.isPending}
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}