fix(server): skip execution-workspace inheritance from a terminal parent issue

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for work
> - Creating a child issue inherits the parent's execution workspace and
    forces executionWorkspacePreference to reuse_existing
> - The inheritance source was never checked for status, so a child of a
    cancelled or done issue bound itself to a worktree that was already
    torn down or carried abandoned state
> - This pull request reads the source issue status and skips only the
    execution-workspace hand-down for terminal sources; project and
    project-workspace grouping still inherit
> - The benefit is that a child of a finished issue gets a fresh
    workspace instead of inheriting a dead one

## Linked Issues or Issue Description

Refs #11464 — covers the terminal-status shape; the implicit live-parent
sharing noted in the issue comments is left as designed (children share
the live parent's worktree deliberately).

## What Changed

- server/src/services/issues.ts: getWorkspaceInheritanceIssue now reads
  the source issue status, and the create path skips execution-workspace
  inheritance when that status is done or cancelled. The project and
  projectWorkspaceId hand-downs are unchanged.
- server/src/__tests__/issues-service.test.ts: a cancelled parent no
  longer passes its executionWorkspaceId, preference, or settings to a
  child created through parentId, while projectWorkspaceId still
  inherits.

## Verification

- pnpm --filter @paperclipai/server vitest run
  src/__tests__/issues-service.test.ts — 128 tests pass (embedded
  Postgres).

## Risks

- Low risk. Only the terminal-source case changes; live parents behave
  exactly as before, and an explicit executionWorkspaceId or
  inheritExecutionWorkspaceFromIssueId caller override is untouched.

## Model Used

- Anthropic Claude — SWE-2 Max agent via Devin CLI, tool use and code
  execution.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Waseem Ilyas 2026-09-10 22:48:30 +00:00
parent 4042eb1c48
commit e92cff7bfe
2 changed files with 78 additions and 1 deletions

View File

@ -3683,6 +3683,75 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
});
});
it("does not inherit the execution workspace from a cancelled parent issue", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Cancelled parent worktree",
status: "archived",
providerType: "git_worktree",
closedAt: new Date(),
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Cancelled parent issue",
status: "cancelled",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
});
// Grouping still inherits; the dead worktree does not.
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBeNull();
expect(child.executionWorkspacePreference).toBeNull();
});
it("createChild applies parent defaults, acceptance criteria, workspace inheritance, and optional parent blocker chaining", async () => {
const companyId = randomUUID();
const projectId = randomUUID();

View File

@ -2678,6 +2678,7 @@ async function getWorkspaceInheritanceIssue(
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
status: issues.status,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
@ -9954,11 +9955,18 @@ export function issueService(db: Db) {
) {
projectWorkspaceId = workspaceSource.projectWorkspaceId;
}
// A terminal source issue (done or cancelled) must not pass its
// execution workspace down: the worktree is already torn down or
// carries abandoned state, and `reuse_existing` would bind the new
// issue to it. Grouping fields still inherit — the child remains
// topically part of the same project — only the execution
// worktree falls back to a fresh one.
if (
inheritsSourceProject &&
isolatedWorkspacesEnabled &&
!hasExplicitExecutionWorkspaceOverride &&
workspaceSource.executionWorkspaceId
workspaceSource.executionWorkspaceId &&
!["done", "cancelled"].includes(workspaceSource.status)
) {
const sourceWorkspace = await tx
.select({