From ec1210caaab8f2645c74b3555e0f2aaa1a0c9a24 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 30 Mar 2026 14:08:44 -0500 Subject: [PATCH] Preserve workspaces for follow-up issues Co-Authored-By: Paperclip --- packages/plugins/sdk/src/protocol.ts | 1 + packages/plugins/sdk/src/types.ts | 1 + packages/plugins/sdk/src/worker-rpc-host.ts | 1 + packages/shared/src/validators/issue.ts | 1 + server/src/__tests__/issues-service.test.ts | 280 ++++++++++++++++++ server/src/onboarding-assets/ceo/HEARTBEAT.md | 2 +- server/src/services/issues.ts | 107 ++++++- skills/paperclip/SKILL.md | 3 +- 8 files changed, 380 insertions(+), 16 deletions(-) diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 8a99bcba..a26bf5dc 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -579,6 +579,7 @@ export interface WorkerToHostMethods { projectId?: string; goalId?: string; parentId?: string; + inheritExecutionWorkspaceFromIssueId?: string; title: string; description?: string; priority?: string; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 06046983..51824651 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -872,6 +872,7 @@ export interface PluginIssuesClient { projectId?: string; goalId?: string; parentId?: string; + inheritExecutionWorkspaceFromIssueId?: string; title: string; description?: string; priority?: Issue["priority"]; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index df387490..20ca02fc 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -590,6 +590,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost projectId: input.projectId, goalId: input.goalId, parentId: input.parentId, + inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId, title: input.title, description: input.description, priority: input.priority, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 22ae43d2..e6d1a34a 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -32,6 +32,7 @@ export const createIssueSchema = z.object({ projectWorkspaceId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), + inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(), title: z.string().min(1), description: z.string().optional().nullable(), status: z.enum(ISSUE_STATUSES).optional().default("backlog"), diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ee79d514..9543a614 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -6,15 +6,18 @@ import { companies, createDb, executionWorkspaces, + instanceSettings, issueComments, issueInboxArchives, issues, + projectWorkspaces, projects, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +import { instanceSettingsService } from "../services/instance-settings.ts"; import { issueService } from "../services/issues.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -43,8 +46,10 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await db.delete(activityLog); await db.delete(issues); await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(agents); + await db.delete(instanceSettings); await db.delete(companies); }); @@ -398,3 +403,278 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { ])); }); }); + +describeEmbeddedPostgres("issueService.create workspace inheritance", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("inherits the parent issue workspace linkage when child workspace fields are omitted", 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", + isPrimary: true, + sharedWorkspaceKey: "workspace-key", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBe(executionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }); + }); + + it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const parentProjectWorkspaceId = randomUUID(); + const parentExecutionWorkspaceId = randomUUID(); + const explicitProjectWorkspaceId = randomUUID(); + const explicitExecutionWorkspaceId = 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: parentProjectWorkspaceId, + companyId, + projectId, + name: "Parent workspace", + }, + { + id: explicitProjectWorkspaceId, + companyId, + projectId, + name: "Explicit workspace", + }, + ]); + + await db.insert(executionWorkspaces).values([ + { + id: parentExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Parent worktree", + status: "active", + providerType: "git_worktree", + }, + { + id: explicitExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: explicitProjectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Explicit shared workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId: parentExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + projectWorkspaceId: explicitProjectWorkspaceId, + executionWorkspaceId: explicitExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "shared_workspace", + }, + }); + + expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId); + expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", + }); + }); + + it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const sourceIssueId = 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: "operator_branch", + strategyType: "git_worktree", + name: "Operator branch", + status: "active", + providerType: "git_worktree", + }); + + await db.insert(issues).values({ + id: sourceIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Source issue", + status: "todo", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "operator_branch", + }, + }); + + const followUp = await svc.create(companyId, { + projectId, + title: "Follow-up issue", + inheritExecutionWorkspaceFromIssueId: sourceIssueId, + }); + + expect(followUp.parentId).toBeNull(); + expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId); + expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId); + expect(followUp.executionWorkspacePreference).toBe("reuse_existing"); + expect(followUp.executionWorkspaceSettings).toEqual({ + mode: "operator_branch", + }); + }); +}); diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 161348a2..bc6273c7 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -37,7 +37,7 @@ If `PAPERCLIP_APPROVAL_ID` is set: ## 6. Delegation -- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. +- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. - Use `paperclip-create-agent` skill when hiring new agents. - Assign work to the right agent for the job. diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 0e1defe3..b6bdb066 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -26,6 +26,7 @@ import { conflict, notFound, unprocessable } from "../errors.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseProjectExecutionWorkspacePolicy, } from "./execution-workspace-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; @@ -105,6 +106,11 @@ type IssueUserContextInput = { updatedAt: Date | string; }; type ProjectGoalReader = Pick; +type DbReader = Pick; +type IssueCreateInput = Omit & { + labelIds?: string[]; + inheritExecutionWorkspaceFromIssueId?: string | null; +}; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -131,6 +137,28 @@ async function getProjectDefaultGoalId( return row?.goalId ?? null; } +async function getWorkspaceInheritanceIssue( + db: DbReader, + companyId: string, + issueId: string, +) { + const issue = await db + .select({ + id: issues.id, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!issue) { + throw notFound("Workspace inheritance issue not found"); + } + return issue; +} + function touchedByUserCondition(companyId: string, userId: string) { return sql` ( @@ -487,8 +515,13 @@ export function issueService(db: Db) { } } - async function assertValidProjectWorkspace(companyId: string, projectId: string | null | undefined, projectWorkspaceId: string) { - const workspace = await db + async function assertValidProjectWorkspace( + companyId: string, + projectId: string | null | undefined, + projectWorkspaceId: string, + dbOrTx: DbReader = db, + ) { + const workspace = await dbOrTx .select({ id: projectWorkspaces.id, companyId: projectWorkspaces.companyId, @@ -504,8 +537,13 @@ export function issueService(db: Db) { } } - async function assertValidExecutionWorkspace(companyId: string, projectId: string | null | undefined, executionWorkspaceId: string) { - const workspace = await db + async function assertValidExecutionWorkspace( + companyId: string, + projectId: string | null | undefined, + executionWorkspaceId: string, + dbOrTx: DbReader = db, + ) { + const workspace = await dbOrTx .select({ id: executionWorkspaces.id, companyId: executionWorkspaces.companyId, @@ -869,9 +907,9 @@ export function issueService(db: Db) { create: async ( companyId: string, - data: Omit & { labelIds?: string[] }, + data: IssueCreateInput, ) => { - const { labelIds: inputLabelIds, ...issueData } = data; + const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; if (!isolatedWorkspacesEnabled) { delete issueData.executionWorkspaceId; @@ -887,21 +925,55 @@ export function issueService(db: Db) { if (data.assigneeUserId) { await assertAssignableUser(companyId, data.assigneeUserId); } - if (data.projectWorkspaceId) { - await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId); - } - if (data.executionWorkspaceId) { - await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId); - } if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) { throw unprocessable("in_progress issues require an assignee"); } return db.transaction(async (tx) => { const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); + let projectWorkspaceId = issueData.projectWorkspaceId ?? null; + let executionWorkspaceId = issueData.executionWorkspaceId ?? null; + let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null; let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; - if (executionWorkspaceSettings == null && issueData.projectId) { + const workspaceInheritanceIssueId = inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null; + const hasExplicitExecutionWorkspaceOverride = + issueData.executionWorkspaceId !== undefined || + issueData.executionWorkspacePreference !== undefined || + issueData.executionWorkspaceSettings !== undefined; + if (workspaceInheritanceIssueId) { + const workspaceSource = await getWorkspaceInheritanceIssue(tx, companyId, workspaceInheritanceIssueId); + if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) { + projectWorkspaceId = workspaceSource.projectWorkspaceId; + } + if ( + isolatedWorkspacesEnabled && + !hasExplicitExecutionWorkspaceOverride && + workspaceSource.executionWorkspaceId + ) { + const sourceWorkspace = await tx + .select({ + id: executionWorkspaces.id, + mode: executionWorkspaces.mode, + }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, workspaceSource.executionWorkspaceId)) + .then((rows) => rows[0] ?? null); + if (sourceWorkspace) { + executionWorkspaceId = sourceWorkspace.id; + executionWorkspacePreference = "reuse_existing"; + executionWorkspaceSettings = { + ...((workspaceSource.executionWorkspaceSettings as Record | null | undefined) ?? {}), + mode: issueExecutionWorkspaceModeForPersistedWorkspace(sourceWorkspace.mode), + }; + } + } + } + if ( + executionWorkspaceSettings == null && + executionWorkspaceId == null && + issueData.projectId + ) { const project = await tx .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) @@ -915,7 +987,6 @@ export function issueService(db: Db) { ), ) as Record | null; } - let projectWorkspaceId = issueData.projectWorkspaceId ?? null; if (!projectWorkspaceId && issueData.projectId) { const project = await tx .select({ @@ -935,6 +1006,12 @@ export function issueService(db: Db) { .then((rows) => rows[0]?.id ?? null); } } + if (projectWorkspaceId) { + await assertValidProjectWorkspace(companyId, issueData.projectId, projectWorkspaceId, tx); + } + if (executionWorkspaceId) { + await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx); + } const [company] = await tx .update(companies) .set({ issueCounter: sql`${companies.issueCounter} + 1` }) @@ -954,6 +1031,8 @@ export function issueService(db: Db) { defaultGoalId: defaultCompanyGoal?.id ?? null, }), ...(projectWorkspaceId ? { projectWorkspaceId } : {}), + ...(executionWorkspaceId ? { executionWorkspaceId } : {}), + ...(executionWorkspacePreference ? { executionWorkspacePreference } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), companyId, issueNumber, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 142ee63a..1d319ad3 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -85,7 +85,7 @@ Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. Priority values: `critical`, `high`, `medium`, `low`. Other updatable fields: `title`, `description`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`. -**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. Set `billingCode` for cross-team work. +**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. ## Project Setup Workflow (CEO/Manager Common Path) @@ -147,6 +147,7 @@ If you are asked to install a skill for the company or an agent you MUST read: Resolve requesting user id from the triggering comment thread (`authorUserId`) when available; otherwise use the issue's `createdByUserId` if it matches the requester context. - **Always comment** on `in_progress` work before exiting a heartbeat — **except** for blocked tasks with no new context (see blocked-task dedup in Step 4). - **Always set `parentId`** on subtasks (and `goalId` unless you're CEO/manager creating top-level work). +- **Preserve workspace continuity for follow-ups.** Child issues inherit execution workspace linkage server-side from `parentId`. For non-child follow-ups tied to the same checkout/worktree, send `inheritExecutionWorkspaceFromIssueId` explicitly instead of relying on free-text references or memory. - **Never cancel cross-team tasks.** Reassign to your manager with a comment. - **Always update blocked issues explicitly.** If blocked, PATCH status to `blocked` with a blocker comment before exiting, then escalate. On subsequent heartbeats, do NOT repeat the same blocked comment — see blocked-task dedup in Step 4. - **@-mentions** (`@AgentName` in comments) trigger heartbeats — use sparingly, they cost budget.