mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Scope workspace readiness to blocker issues
References PAP-10990. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user