From a5bae7012d0141f4b1afd6bcff538a677cccba64 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 12 Jun 2026 17:32:51 +0000 Subject: [PATCH] Scope workspace readiness to blocker issues References PAP-10990. Co-Authored-By: Paperclip --- .../0102_workspace_operations_issue_id.sql | 41 ++++ packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/workspace_operations.ts | 10 + .../shared/src/types/workspace-operation.ts | 1 + server/src/__tests__/issues-service.test.ts | 232 ++++++++++++------ .../src/__tests__/workspace-runtime.test.ts | 1 + server/src/services/heartbeat.ts | 1 + server/src/services/issues.ts | 66 ++++- server/src/services/workspace-operations.ts | 3 + 9 files changed, 287 insertions(+), 75 deletions(-) create mode 100644 packages/db/src/migrations/0102_workspace_operations_issue_id.sql diff --git a/packages/db/src/migrations/0102_workspace_operations_issue_id.sql b/packages/db/src/migrations/0102_workspace_operations_issue_id.sql new file mode 100644 index 00000000000..c485a21ba6f --- /dev/null +++ b/packages/db/src/migrations/0102_workspace_operations_issue_id.sql @@ -0,0 +1,41 @@ +ALTER TABLE "workspace_operations" ADD COLUMN IF NOT EXISTS "issue_id" uuid; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_operations_issue_id_issues_id_fk' + ) THEN + ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +WITH "run_issue_candidates" AS ( + SELECT + "heartbeat_runs"."id" AS "run_id", + "heartbeat_runs"."company_id" AS "company_id", + CASE + WHEN "heartbeat_runs"."context_snapshot" ? 'issueId' + AND "heartbeat_runs"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + THEN ("heartbeat_runs"."context_snapshot"->>'issueId')::uuid + ELSE NULL + END AS "issue_id" + FROM "heartbeat_runs" +), +"run_issue_attribution" AS ( + SELECT + "run_issue_candidates"."run_id", + "issues"."id" AS "issue_id" + FROM "run_issue_candidates" + INNER JOIN "issues" + ON "issues"."id" = "run_issue_candidates"."issue_id" + AND "issues"."company_id" = "run_issue_candidates"."company_id" + WHERE "run_issue_candidates"."issue_id" IS NOT NULL +) +UPDATE "workspace_operations" +SET + "issue_id" = "run_issue_attribution"."issue_id", + "updated_at" = now() +FROM "run_issue_attribution" +WHERE "workspace_operations"."heartbeat_run_id" = "run_issue_attribution"."run_id" + AND "workspace_operations"."issue_id" IS NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workspace_operations_company_workspace_issue_started_idx" ON "workspace_operations" USING btree ("company_id","execution_workspace_id","issue_id","started_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 7e19d3b42fa..b9374e1ffc9 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -715,6 +715,13 @@ "when": 1781490000000, "tag": "0101_plugin_company_id_tenant_isolation", "breakpoints": true + }, + { + "idx": 102, + "version": "7", + "when": 1781490000001, + "tag": "0102_workspace_operations_issue_id", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/workspace_operations.ts b/packages/db/src/schema/workspace_operations.ts index fd9624681bb..8924d5e7b33 100644 --- a/packages/db/src/schema/workspace_operations.ts +++ b/packages/db/src/schema/workspace_operations.ts @@ -12,6 +12,7 @@ import { import { companies } from "./companies.js"; import { executionWorkspaces } from "./execution_workspaces.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issues } from "./issues.js"; export const workspaceOperations = pgTable( "workspace_operations", @@ -24,6 +25,9 @@ export const workspaceOperations = pgTable( heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { onDelete: "set null", }), + issueId: uuid("issue_id").references(() => issues.id, { + onDelete: "set null", + }), phase: text("phase").notNull(), command: text("command"), cwd: text("cwd"), @@ -53,5 +57,11 @@ export const workspaceOperations = pgTable( table.executionWorkspaceId, table.startedAt, ), + companyWorkspaceIssueStartedIdx: index("workspace_operations_company_workspace_issue_started_idx").on( + table.companyId, + table.executionWorkspaceId, + table.issueId, + table.startedAt, + ), }), ); diff --git a/packages/shared/src/types/workspace-operation.ts b/packages/shared/src/types/workspace-operation.ts index 12e329032e7..30043396c98 100644 --- a/packages/shared/src/types/workspace-operation.ts +++ b/packages/shared/src/types/workspace-operation.ts @@ -12,6 +12,7 @@ export interface WorkspaceOperation { companyId: string; executionWorkspaceId: string | null; heartbeatRunId: string | null; + issueId: string | null; phase: WorkspaceOperationPhase; command: string | null; cwd: string | null; diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 1c47f3348bf..3ef21b87c59 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2931,6 +2931,102 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await tempDb?.cleanup(); }); + async function seedSharedWorkspaceDependency() { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const blockerId = randomUUID(); + const dependentId = randomUUID(); + const foreignIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "QA", + role: "qa", + status: "active", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Shared workspace project", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Shared workspace", + sourceType: "local_path", + visibility: "default", + isPrimary: true, + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Shared exec workspace", + status: "active", + providerType: "git_worktree", + }); + await db.insert(issues).values([ + { + id: blockerId, + companyId, + projectId, + title: "Predecessor", + status: "done", + priority: "medium", + executionWorkspaceId, + }, + { + id: dependentId, + companyId, + projectId, + title: "Dependent", + status: "blocked", + priority: "medium", + assigneeAgentId, + }, + { + id: foreignIssueId, + companyId, + projectId, + title: "Foreign in-flight issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + }, + ]); + await svc.update(dependentId, { blockedByIssueIds: [blockerId] }); + + return { + companyId, + assigneeAgentId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + blockerId, + dependentId, + foreignIssueId, + }; + } + it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => { const companyId = randomUUID(); await db.insert(companies).values({ @@ -3088,86 +3184,82 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", ]); }); - it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => { - const companyId = randomUUID(); - const assigneeAgentId = randomUUID(); - const projectId = randomUUID(); - const projectWorkspaceId = randomUUID(); - const executionWorkspaceId = randomUUID(); + it("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => { + const { + companyId, + executionWorkspaceId, + blockerId, + dependentId, + foreignIssueId, + } = await seedSharedWorkspaceDependency(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: assigneeAgentId, + await db.insert(workspaceOperations).values({ companyId, - name: "QA", - role: "qa", - status: "active", - adapterType: "claude_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Shared workspace project", - status: "in_progress", - }); - await db.insert(projectWorkspaces).values({ - id: projectWorkspaceId, - companyId, - projectId, - name: "Shared workspace", - sourceType: "local_path", - visibility: "default", - isPrimary: true, - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "Shared exec workspace", - status: "active", - providerType: "git_worktree", + executionWorkspaceId, + issueId: foreignIssueId, + phase: "worktree_prepare", + status: "succeeded", + startedAt: new Date("2026-05-23T22:00:00.000Z"), }); - const blockerId = randomUUID(); - const dependentId = randomUUID(); - await db.insert(issues).values([ - { - id: blockerId, - companyId, - projectId, - title: "Predecessor", - status: "done", - priority: "medium", - executionWorkspaceId, - }, - { + await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([ + expect.objectContaining({ id: dependentId, - companyId, - projectId, - title: "Dependent", - status: "blocked", - priority: "medium", - assigneeAgentId, - }, + blockerIssueIds: [blockerId], + }), ]); - await svc.update(dependentId, { blockedByIssueIds: [blockerId] }); + await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({ + isDependencyReady: true, + pendingFinalizeBlockerIssueIds: [], + unresolvedBlockerIssueIds: [], + }); + }); - // A run touched the workspace (prepare phase) but has not yet recorded + it("ignores unattributed pre-backfill workspace operations when checking blocker readiness", async () => { + const { + companyId, + executionWorkspaceId, + blockerId, + dependentId, + } = await seedSharedWorkspaceDependency(); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + issueId: null, + phase: "worktree_prepare", + status: "succeeded", + startedAt: new Date("2026-05-23T22:00:00.000Z"), + }); + + await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([ + expect.objectContaining({ + id: dependentId, + blockerIssueIds: [blockerId], + }), + ]); + await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({ + isDependencyReady: true, + pendingFinalizeBlockerIssueIds: [], + unresolvedBlockerIssueIds: [], + }); + }); + + it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => { + const { + companyId, + executionWorkspaceId, + blockerId, + dependentId, + assigneeAgentId, + } = await seedSharedWorkspaceDependency(); + + // The blocker touched its workspace but has not yet recorded // workspace_finalize — the dependent must NOT wake. await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, + issueId: blockerId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), @@ -3184,6 +3276,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, + issueId: blockerId, phase: "workspace_finalize", status: "failed", startedAt: new Date("2026-05-23T22:05:00.000Z"), @@ -3195,6 +3288,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, + issueId: blockerId, phase: "workspace_finalize", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 51bad01f46a..abdf6d0537f 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -149,6 +149,7 @@ function createWorkspaceOperationRecorderDouble() { companyId: "company-1", executionWorkspaceId, heartbeatRunId: "run-1", + issueId: null, phase: input.phase, command: input.command ?? null, cwd: input.cwd ?? null, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0f9f7b182d7..1a408887ee2 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8318,6 +8318,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) companyId: agent.companyId, heartbeatRunId: run.id, executionWorkspaceId: existingExecutionWorkspace?.id ?? null, + issueId, }); const executionWorkspaceBase = { baseCwd: resolvedWorkspace.cwd, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 26c51913fca..c61132e3174 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -677,6 +677,57 @@ export async function listUnfinalizedExecutionWorkspaceIds( return unfinalized; } +async function listPendingFinalizeBlockerIssueIds( + dbOrTx: Pick, + companyId: string, + blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>, +): Promise> { + const pending = new Set(); + const blockerIssueIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId))]; + const executionWorkspaceIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId))]; + if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0) return pending; + + const rows = await dbOrTx + .select({ + issueId: workspaceOperations.issueId, + executionWorkspaceId: workspaceOperations.executionWorkspaceId, + phase: workspaceOperations.phase, + status: workspaceOperations.status, + startedAt: workspaceOperations.startedAt, + }) + .from(workspaceOperations) + .where( + and( + eq(workspaceOperations.companyId, companyId), + inArray(workspaceOperations.issueId, blockerIssueIds), + inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds), + ), + ); + + const latestByBlockerWorkspace = new Map(); + for (const row of rows) { + if (!row.issueId || !row.executionWorkspaceId) continue; + const key = `${row.issueId}:${row.executionWorkspaceId}`; + const current = latestByBlockerWorkspace.get(key); + if (!current || row.startedAt > current.startedAt) { + latestByBlockerWorkspace.set(key, { + phase: row.phase, + status: row.status, + startedAt: row.startedAt, + }); + } + } + + for (const pair of blockerWorkspacePairs) { + const latest = latestByBlockerWorkspace.get(`${pair.blockerIssueId}:${pair.executionWorkspaceId}`); + if (!latest) continue; // no attributed ops recorded -> nothing to finalize for this blocker + if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue; + pending.add(pair.blockerIssueId); + } + + return pending; +} + /** * Returns whether a specific run's operations on a specific execution workspace * reached the workspace_finalize barrier. @@ -743,19 +794,22 @@ async function listIssueDependencyReadinessMap( ), ); - // Collect executionWorkspaceIds of "done" blockers — these are the only ones + // Collect issue/workspace pairs of "done" blockers — these are the only ones // subject to the workspace-finalize barrier. Blockers that aren't done already // mark the dependent as not-ready and don't need a finalize check. - const doneBlockerWorkspaceIds = new Set(); + const doneBlockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }> = []; for (const row of blockerRows) { if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) { - doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId); + doneBlockerWorkspacePairs.push({ + blockerIssueId: row.blockerIssueId, + executionWorkspaceId: row.blockerExecutionWorkspaceId, + }); } } - const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds( + const pendingFinalizeBlockerIssueIds = await listPendingFinalizeBlockerIssueIds( dbOrTx, companyId, - [...doneBlockerWorkspaceIds], + doneBlockerWorkspacePairs, ); for (const row of blockerRows) { @@ -770,7 +824,7 @@ async function listIssueDependencyReadinessMap( current.isDependencyReady = false; } else if ( row.blockerExecutionWorkspaceId && - unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId) + pendingFinalizeBlockerIssueIds.has(row.blockerIssueId) ) { // Workspace-finalize barrier: the blocker's most recent run on its // execution workspace hasn't recorded a successful workspace_finalize. diff --git a/server/src/services/workspace-operations.ts b/server/src/services/workspace-operations.ts index 9509e9824bc..1c0c13a824f 100644 --- a/server/src/services/workspace-operations.ts +++ b/server/src/services/workspace-operations.ts @@ -16,6 +16,7 @@ function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation { companyId: row.companyId, executionWorkspaceId: row.executionWorkspaceId ?? null, heartbeatRunId: row.heartbeatRunId ?? null, + issueId: row.issueId ?? null, phase: row.phase as WorkspaceOperationPhase, command: row.command ?? null, cwd: row.cwd ?? null, @@ -89,6 +90,7 @@ export function workspaceOperationService(db: Db) { companyId: string; heartbeatRunId?: string | null; executionWorkspaceId?: string | null; + issueId?: string | null; }): WorkspaceOperationRecorder { let executionWorkspaceId = input.executionWorkspaceId ?? null; const createdIds: string[] = []; @@ -136,6 +138,7 @@ export function workspaceOperationService(db: Db) { companyId: input.companyId, executionWorkspaceId, heartbeatRunId: input.heartbeatRunId ?? null, + issueId: input.issueId ?? null, phase: recordInput.phase, command: recordInput.command ?? null, cwd: recordInput.cwd ?? null,