From 4244cdb238d92c1c782ff16d3139b2f3231faf06 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 17:18:09 -0500 Subject: [PATCH] test: align workspace fixtures with retained source authorization Seed active memberships for reused task workspaces, respect retained-source deletion order, and remove a provider-speed race from the E2E oracle while preserving deferred-preparation checks. Co-Authored-By: Paperclip --- .../src/__tests__/chat-project-tools.test.ts | 4 +-- .../src/__tests__/durable-chat-wakeup.test.ts | 5 +++- .../heartbeat-comment-wake-batching.test.ts | 16 +++++++++++ ...eartbeat-issue-liveness-escalation.test.ts | 4 ++- ...heartbeat-native-cleanup-admission.test.ts | 6 ++-- .../heartbeat-process-recovery.test.ts | 21 ++++++++------ ...artbeat-responsible-user-invariant.test.ts | 5 +++- .../heartbeat-slack-invitation.test.ts | 3 +- ...tbeat-workspace-branch-containment.test.ts | 28 +++++++++++++------ ...eartbeat-workspace-finalize-branch.test.ts | 8 ++++-- .../heartbeat-workspace-session.test.ts | 2 ++ .../native-session-resumption.test.ts | 7 +++-- tests/runner-e2e/task-workspaces-scoring.ts | 6 ++-- tests/runner-e2e/task-workspaces.test.ts | 7 +++++ 14 files changed, 90 insertions(+), 32 deletions(-) diff --git a/server/src/__tests__/chat-project-tools.test.ts b/server/src/__tests__/chat-project-tools.test.ts index b75f5ed6609..0b5a6d085b3 100644 --- a/server/src/__tests__/chat-project-tools.test.ts +++ b/server/src/__tests__/chat-project-tools.test.ts @@ -246,7 +246,7 @@ const support = await getEmbeddedPostgresTestSupport(); await expect(call(f, "create_task", { title: "Exact scope is not a subtree", idempotencyKey: "exact-child" })).rejects.toThrow(/outside.*boundary/); }); - it("authorizes workspace-derived projects before creating children of a projectless root", async () => { + it("authorizes workspace sources without assigning projects to children of a projectless root", async () => { const f = await server.fixture({ disableWakeOnDemand: true }); const settings = instanceSettingsService(server.db); const previous = await settings.getExperimental(); @@ -287,7 +287,7 @@ const support = await getEmbeddedPostgresTestSupport(); if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue; const response = await create(path, selection); expect(response.status, JSON.stringify(response.body)).toBe(201); - expect(response.body.projectId).toBe(f.projectId); + expect(response.body.projectId).toBe(selection.inheritExecutionWorkspaceFromIssueId ? f.projectId : null); expect(response.body.executionPolicy.authorizationPolicy.trustBoundary.projectIds).toEqual([f.projectId]); } } diff --git a/server/src/__tests__/durable-chat-wakeup.test.ts b/server/src/__tests__/durable-chat-wakeup.test.ts index f2f0f3f042b..4f89e30e572 100644 --- a/server/src/__tests__/durable-chat-wakeup.test.ts +++ b/server/src/__tests__/durable-chat-wakeup.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { @@ -12,6 +13,7 @@ import { import { agents, companies, + companyMemberships, createDb, heartbeatRuns, agentWakeupRequests, @@ -87,7 +89,7 @@ describe("durable inbound chat scheduler receipts", () => { testedAt: new Date(0).toISOString(), }), }); - }, 30_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(() => { execute.mockClear(); for (const unregister of unregisterAuthorities.splice(0)) unregister(); @@ -111,6 +113,7 @@ describe("durable inbound chat scheduler receipts", () => { defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false, }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board-user", status: "active", membershipRole: "member" }); await db.insert(agents).values({ id: agentId, companyId, diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 1c1d0fbc5ec..e3e28d02fb3 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -217,6 +217,14 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { runningProcesses.clear(); }); + async function grantFixtureWorkspaceAccess(companyId: string, userIds: string[]) { + // Later admissions reauthorize the retained workspace for the responsible + // user, so synthetic human commenters need real company membership too. + await db.insert(companyMemberships).values(userIds.map(principalId => ({ + companyId, principalId, principalType: "user", status: "active", membershipRole: "member", + }))); + } + async function readGatewayWakePayload( payload: Record, ): Promise> { @@ -504,6 +512,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user", }); + await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]); await db.insert(agents).values({ id: agentId, @@ -1042,6 +1051,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user", }); + await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]); await db.insert(agents).values({ id: agentId, @@ -1252,6 +1262,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user", }); + await grantFixtureWorkspaceAccess(companyId, ["responsible-user"]); await db.insert(agents).values([ { @@ -1690,6 +1701,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user", }); + await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "external-user"]); await db.insert(agents).values({ id: agentId, @@ -2390,6 +2402,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user", }); + await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "user-1"]); await db.insert(agents).values({ id: agentId, @@ -2983,6 +2996,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user", }); + await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "user-1"]); await db.insert(agents).values({ id: agentId, @@ -3148,6 +3162,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user", }); + await grantFixtureWorkspaceAccess(companyId, ["responsible-user"]); await db.insert(agents).values({ id: agentId, @@ -3313,6 +3328,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user", }); + await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]); await db.insert(agents).values([ { diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index ad30fa4034f..77403d7f582 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; @@ -93,7 +94,7 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", () beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-issue-liveness-"); db = createDb(tempDb.connectionString); - }, 30_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(async () => { // Dependency reconciliation heals missing wakes by enqueuing an @@ -355,6 +356,7 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", () defaultResponsibleUserId: "responsible-user", requireBoardApprovalForNewAgents: false, }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db.insert(agents).values({ id: agentId, companyId, diff --git a/server/src/__tests__/heartbeat-native-cleanup-admission.test.ts b/server/src/__tests__/heartbeat-native-cleanup-admission.test.ts index 5660976284c..d918c623180 100644 --- a/server/src/__tests__/heartbeat-native-cleanup-admission.test.ts +++ b/server/src/__tests__/heartbeat-native-cleanup-admission.test.ts @@ -1,7 +1,8 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db"; +import { agents, companies, companyMemberships, createDb, heartbeatRuns, issues } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { heartbeatService } from "../services/heartbeat.js"; @@ -19,7 +20,7 @@ suite("native terminal-run cleanup admission", () => { beforeAll(async () => { temporary = await startEmbeddedPostgresTestDatabase("native-cleanup-admission-"); db = createDb(temporary.connectionString); - }, 60_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => { await db?.$client.end({ timeout: 0 }); await temporary?.cleanup(); @@ -28,6 +29,7 @@ suite("native terminal-run cleanup admission", () => { it("holds the same task until its native executor settles while allowing unrelated tasks", async () => { const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), otherIssueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Cleanup admission", issuePrefix: "CLN", defaultResponsibleUserId: "owner" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "owner", status: "active", membershipRole: "member" }); await db.insert(agents).values({ id: agentId, companyId, name: "Worker", role: "engineer", status: "idle", adapterType: "process", adapterConfig: {}, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true, maxConcurrentRuns: 20 } } }); await db.insert(issues).values([issueId, otherIssueId].map(id => ({ id, companyId, title: "Recovery task", status: "todo", assigneeAgentId: agentId, responsibleUserId: "owner" }))); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 5517583e662..0afcb27db9a 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import * as conversationContinuation from "../services/conversation-continuation.js"; import { environmentRuntimeService } from "../services/environment-runtime.js"; import * as workspaceRuntime from "../services/workspace-runtime.js"; @@ -515,7 +516,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { createdAt: now, updatedAt: now, }); - }, 20_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(async () => { vi.clearAllMocks(); @@ -682,6 +683,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await db.delete(companySecretBindings); await db.delete(companySecrets); try { + await db.delete(companyMemberships); await db.delete(companies); break; } catch (error) { @@ -1176,6 +1178,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { requireBoardApprovalForNewAgents: false, }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db.insert(agents).values({ id: agentId, companyId, @@ -1462,6 +1465,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { requireBoardApprovalForNewAgents: false, }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db.insert(agents).values({ id: agentId, companyId, @@ -1611,7 +1615,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { it.each(["missing_default", "credential_not_shared", "database_error", "unmarked_http_error", "unmarked_forbidden", "provider_error"] as const)( "preserves AI configuration recovery and reports only unexpected causes: %s", async (cause) => { const { companyId, agentId, issueId, runId, wakeupRequestId } = await seedQueuedIssueRunFixture(); - await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }).onConflictDoNothing(); await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 }, aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" }, @@ -3378,9 +3382,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(input.workspace.cwd).toBe( path.join( await fs.realpath(resolvePaperclipInstanceRoot()), - "chat-workspaces", + "isolated-workspaces", companyId, - agentId, issueId, ), ); @@ -5803,7 +5806,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { mockAdapterExecute.mockClear(); }); - it("blocks a git-sensitive local adapter before launch when a project-workspace-linked issue is missing its project id", async () => { + it("blocks a projectless task before launch when its configured source directory is unavailable", async () => { mockAdapterExecute.mockClear(); const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture(); @@ -5853,7 +5856,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { errorCode: "workspace_validation_failed", }); expect(failedRun?.error).toContain( - "linked to a project workspace but has no project id", + "Configured project workspace is unavailable", ); // The adapter process never started, so no agent could post an issue // comment. The comment policy is not_applicable and no missing-comment @@ -5872,8 +5875,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(missingCommentWakeups).toHaveLength(0); expect(failedRun?.resultJson).toMatchObject({ workspaceValidation: { - reason: "missing_project_id", - adapterType: "codex_local", + reason: "configured_workspace_unavailable", issueId, issueProjectId: null, issueProjectWorkspaceId: projectWorkspaceId, @@ -6358,7 +6360,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { legacyDispositionEpisode: { id: runId, attempt: 1, maxAttempts: 2 }, dispositionRepairInstruction: LEGACY_DISPOSITION_REPAIR_INSTRUCTION, }); - expect(repair?.status).toBe("succeeded"); + expect({ status: repair?.status, errorCode: repair?.errorCode, error: repair?.error }) + .toEqual({ status: "succeeded", errorCode: null, error: null }); expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done"); expect(mockAdapterExecute).toHaveBeenCalledTimes(2); expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); diff --git a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts index 59c3cb2c419..6e4e1f862ca 100644 --- a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts +++ b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; @@ -97,7 +98,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => { } return baseExecute(); }); - }, 20_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(async () => { mockAdapterExecute.mockClear(); @@ -299,6 +300,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => { const { companyId, agentId } = await seedCompany(); const issueResponsibleUserId = `issue-owner-${randomUUID()}`; const commenterUserId = `commenter-${randomUUID()}`; + await db.insert(companyMemberships).values([issueResponsibleUserId, commenterUserId].map(principalId => ({ companyId, principalType: "user" as const, principalId, status: "active", membershipRole: "member" }))); const issueId = randomUUID(); await db.insert(issues).values({ id: issueId, @@ -355,6 +357,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => { const { companyId, agentId } = await seedCompany(); const issueResponsibleUserId = `issue-owner-${randomUUID()}`; const commenterUserId = `commenter-${randomUUID()}`; + await db.insert(companyMemberships).values([issueResponsibleUserId, commenterUserId].map(principalId => ({ companyId, principalType: "user" as const, principalId, status: "active", membershipRole: "member" }))); const issueId = randomUUID(); const commentId = randomUUID(); await db.insert(issues).values({ diff --git a/server/src/__tests__/heartbeat-slack-invitation.test.ts b/server/src/__tests__/heartbeat-slack-invitation.test.ts index 2e168b1ec63..a4c7dd07c72 100644 --- a/server/src/__tests__/heartbeat-slack-invitation.test.ts +++ b/server/src/__tests__/heartbeat-slack-invitation.test.ts @@ -105,7 +105,8 @@ describePostgres("saved Slack invitation command in heartbeat prompts", () => { const f = await seed(); for (let index = 0; index < 2; index++) { const input = await turn(f); - expect(input.runtime.sessionId).toBe(index === 0 ? null : "invitation-session"); + const completed = await heartbeat.getRun(input.runId); + expect(input.runtime.sessionId, JSON.stringify(completed?.resultJson?.configFreshness)).toBe(index === 0 ? null : "invitation-session"); const context = input.context; for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownCompact", "paperclipTaskMarkdownAssignment", "paperclipTaskMarkdownAssignmentCompact"]) { const prompt = String(context[key]); diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index 9759f5a995e..0ccc2b23419 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; @@ -14,6 +15,7 @@ import { agentWakeupRequests, agents, companies, + companyMemberships, companySkills, createDb, closeRegisteredClients, @@ -342,6 +344,7 @@ async function seedBranchContainmentRun( createdAt: now, updatedAt: now, }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db.insert(projects).values({ id: projectId, companyId, @@ -880,7 +883,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-branch-containment-"); db = createDb(tempDb.connectionString); } - }, 20_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(async () => { // Await every in-flight background heartbeat run to quiescence before the @@ -918,15 +921,16 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { await deleteHeartbeatRunsForCleanup(db); await db.delete(issueComments); await db.delete(issues); + await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); await db.delete(agents); await db.delete(workspaceOperations); - await db.delete(executionWorkspaces); await db.delete(environments); await db.delete(companySkills); + await db.delete(companyMemberships); await db.delete(companies); vi.unstubAllEnvs(); }); @@ -972,6 +976,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { }; await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(companies).values({ id: companyId, name: "Retained source", issuePrefix, status: "active", defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db.insert(projects).values({ id: projectId, companyId, name: "Repository replacement", status: "active", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "HEAD" } } }); await db.insert(projectWorkspaces).values([ @@ -1039,19 +1044,23 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { await heartbeat.resumeQueuedRuns(); const run = await waitForRunToFinish(heartbeat, seeded.runId); await heartbeat.waitForRunExecutionDrain(seeded.runId); - expect(run).toMatchObject({ status: "failed", errorCode: "workspace_validation_failed" }); - expect(run?.resultJson).toMatchObject({ workspaceValidation: { reason: "persisted_workspace_source_conflict", - reasonCode: kind === "explicit_conflict" ? "explicit_project_workspace_conflict" - : kind === "unproven" ? "source_registration_unproven" : "source_scope_mismatch" } }); + if (kind === "foreign_company") { + // Company access is checked before inspecting any retained source. + expect(run).toMatchObject({ status: "failed", errorCode: "setup_failed", error: "Task workspace access is no longer available" }); + } else { + expect(run).toMatchObject({ status: "failed", errorCode: "workspace_validation_failed" }); + expect(run?.resultJson).toMatchObject({ workspaceValidation: { reason: "persisted_workspace_source_conflict", + reasonCode: kind === "explicit_conflict" ? "explicit_project_workspace_conflict" : "source_registration_unproven" } }); + } expect(adapterExecute).not.toHaveBeenCalled(); const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); expect(workspace).toMatchObject({ projectWorkspaceId: null, cwd: seeded.worktree, providerRef: seeded.worktree, branchName: seeded.branch, metadata: seeded.metadata }); const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId)); expect(issue).toMatchObject({ executionWorkspaceId: seeded.executionWorkspaceId, projectWorkspaceId: kind === "explicit_conflict" ? seeded.newWorkspaceId : null }); - // Intentionally malformed cross-company/project FKs also prevent the - // existing issue-update service from changing status. The source gate - // still must fail before dispatch and may never silently repair that FK. + // A different organizational project alone is legal; an unproven source + // registration or a cross-company binding must still fail before dispatch + // without silently replacing the retained files or binding. if (kind === "explicit_conflict" || kind === "unproven") expect(issue.status).toBe("blocked"); expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n"); expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n"); @@ -1073,6 +1082,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { status: "active", defaultResponsibleUserId: "responsible-user", }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db.insert(agents).values({ id: agentId, companyId, diff --git a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts index 545b3a16305..2e7d244a415 100644 --- a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts +++ b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -14,6 +15,7 @@ import { agentWakeupRequests, agents, companies, + companyMemberships, companySkills, createDb, documentRevisions, @@ -166,6 +168,7 @@ async function seedRunTarget(db: Db, repoRoot: string) { createdAt: new Date(), updatedAt: new Date(), }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db.insert(projects).values({ id: projectId, companyId, @@ -265,7 +268,7 @@ describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () => beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-finalize-branch-"); db = createDb(tempDb.connectionString); - }, 20_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(async () => { // Await every in-flight background heartbeat run to quiescence before the @@ -299,14 +302,15 @@ describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () => await deleteHeartbeatRowsAfterActivityLogDrains(db); await db.delete(issueComments); await db.delete(issues); + await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); await db.delete(agents); - await db.delete(executionWorkspaces); await db.delete(environments); await db.delete(companySkills); + await db.delete(companyMemberships); await db.delete(companies); }); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index bea971bc2fa..399548df7c8 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -2468,6 +2468,7 @@ describe("effective run session config freshness", () => { workspaceConfig: { requestedMode: "shared_workspace", effectiveMode: "shared_workspace", + issueSettings: null, reusableExecutionWorkspaceConfig: null, existingExecutionWorkspace: null, }, @@ -2476,6 +2477,7 @@ describe("effective run session config freshness", () => { workspaceConfig: { requestedMode: "shared_workspace", effectiveMode: "shared_workspace", + issueSettings: { mode: "shared_workspace" }, reusableExecutionWorkspaceConfig: { strategyType: "project_primary", workspaceGeneration: 1, diff --git a/server/src/__tests__/native-session-resumption.test.ts b/server/src/__tests__/native-session-resumption.test.ts index 1283b29a463..f62796873bd 100644 --- a/server/src/__tests__/native-session-resumption.test.ts +++ b/server/src/__tests__/native-session-resumption.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { once } from "node:events"; @@ -9,6 +10,7 @@ import { agentWakeupRequests, agents, companies, + companyMemberships, completionContracts, createDb, executionWorkspaces, @@ -260,7 +262,7 @@ describe("P6-25 pre-result native session recovery", () => { leaseExpiresAt: new Date(0), })), ]); - }, 30_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => temporary?.cleanup()); @@ -795,6 +797,7 @@ describe.each(["unchanged", "newer_active", "stale_idle"] as const)( status: "active", defaultResponsibleUserId: "responsible-user", }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db .insert(projects) .values({ @@ -952,7 +955,7 @@ describe.each(["unchanged", "newer_active", "stale_idle"] as const)( authorUserId: "responsible-user", body: "Verify the newest user instruction before completing this task.", }); - }, 30_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => { if ( diff --git a/tests/runner-e2e/task-workspaces-scoring.ts b/tests/runner-e2e/task-workspaces-scoring.ts index 4d133b7d890..8d1740f2175 100644 --- a/tests/runner-e2e/task-workspaces-scoring.ts +++ b/tests/runner-e2e/task-workspaces-scoring.ts @@ -37,9 +37,11 @@ export function gradeTaskWorkspaces(value: TaskWorkspaceObservation) { check("projectless-task-owned-root", complete && value.projectCount === 0 && value.checkpoints.every(p => p.issue.projectId === null && p.workspace.projectId === null && p.confinedTaskRoot && p.issue.executionWorkspaceId === p.workspace.id), "Task and execution workspace remain projectless, with a confined company/task directory rather than agent home."); - check("immutable-root-and-binding", complete && value.initial !== null && value.initial.repositories === 0 && + // The provider may enqueue its receipt before the first browser observation. + // Deferred acquisition is checked at the settled first-turn checkpoint below. + check("immutable-root-and-binding", complete && value.initial !== null && value.checkpoints.every(p => p.workspace.id === value.initial!.workspaceId && p.workspace.cwd === value.initial!.cwd), - "The independently observed pre-preparation root and binding survive all three admissions."); + "The independently observed initial root and binding survive all three admissions."); check("task-file-continuity", complete && value.checkpoints.every(p => p.note === `task-files-turn-${p.turn}-${value.nonce}\n`), "Public execution-workspace file reads match the exact expected bytes after every turn."); const a = first?.repositories[0], b = second?.repositories[0], c = third?.repositories[0]; diff --git a/tests/runner-e2e/task-workspaces.test.ts b/tests/runner-e2e/task-workspaces.test.ts index e24c0f3e2d0..f66e48e6a9d 100644 --- a/tests/runner-e2e/task-workspaces.test.ts +++ b/tests/runner-e2e/task-workspaces.test.ts @@ -49,6 +49,13 @@ describe("task workspace product E2E", () => { legacy.checkpoints.forEach(point => { point.run.runtimeMode = "legacy"; delete point.prepareCalls; }); expect(gradeTaskWorkspaces(legacy).filter(check => !check.passed)).toEqual([]); }); + it("accepts a pending receipt queued before the first browser observation", () => { + const observation = valid(); + observation.initial!.repositories = 1; + expect(gradeTaskWorkspaces(observation).filter(check => !check.passed)).toEqual([]); + observation.checkpoints[0]!.repositories[0]!.state = "ready"; + expect(gradeTaskWorkspaces(observation).find(check => check.id === "deferred-idempotent-acquisition")?.passed).toBe(false); + }); it.each([ ["missing evidence", (v: TaskWorkspaceObservation) => { v.checkpoints = []; }], ["agent home", (v: TaskWorkspaceObservation) => { v.checkpoints[0]!.confinedTaskRoot = false; }],