Scope workspace readiness to blocker issues

References PAP-10990.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-16 10:02:04 +00:00
1 parent 43edabe37d
commit a5bae7012d
9 files changed
+287 -75

No files matched your search

@@ -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");
@@ -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
}
]
}
@@ -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,
),
}),
);
@@ -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;
+163 -69
View File
@@ -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"),
@@ -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,
+1
View File
@@ -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,
+60 -6
View File
@@ -677,6 +677,57 @@ export async function listUnfinalizedExecutionWorkspaceIds(
return unfinalized;
}
async function listPendingFinalizeBlockerIssueIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>,
): Promise<Set<string>> {
const pending = new Set<string>();
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<string, { phase: string; status: string; startedAt: Date }>();
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<string>();
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.
@@ -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,