diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 95c9041c1ec..4c6e4129fc1 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -4,6 +4,7 @@ import { approvals, heartbeatRuns, issueApprovals, + issueThreadInteractions, issues, nativeRunFinalizations, nativeRunResults, @@ -19,6 +20,41 @@ function record(value: unknown): Record { : {}; } +async function hasPendingNativeGovernance(input: { + db: Db; + companyId: string; + issueId: string; + executionState: Record | null; +}) { + // Issue-thread interactions and execution-policy stages are independent + // governance authorities from formal approvals. Native completion must + // mediate all three before it may project an authoritative terminal status. + if (record(input.executionState).status === "pending") return true; + const [pendingInteraction, pendingApproval] = await Promise.all([ + input.db.select({ id: issueThreadInteractions.id }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, input.companyId), + eq(issueThreadInteractions.issueId, input.issueId), + eq(issueThreadInteractions.status, "pending"), + )) + .limit(1) + .then((rows) => rows[0] ?? null), + input.db.select({ id: approvals.id }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where(and( + eq(issueApprovals.companyId, input.companyId), + eq(issueApprovals.issueId, input.issueId), + eq(approvals.companyId, input.companyId), + inArray(approvals.status, ["pending", "revision_requested"]), + )) + .limit(1) + .then((rows) => rows[0] ?? null), + ]); + return Boolean(pendingInteraction || pendingApproval); +} + export async function finalizeNativeRun(input: { db: Db; runId: string; @@ -81,17 +117,12 @@ export async function finalizeNativeRun(input: { assessment, terminalState: terminalState as "succeeded" | "failed" | "cancelled", workspaceFinalizeStatus: input.workspaceFinalizeStatus, - approvalGateSatisfied: !await input.db.select({ id: approvals.id }) - .from(issueApprovals) - .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) - .where(and( - eq(issueApprovals.companyId, run.companyId), - eq(issueApprovals.issueId, authoritativeIssue.id), - eq(approvals.companyId, run.companyId), - inArray(approvals.status, ["pending", "revision_requested"]), - )) - .limit(1) - .then((rows) => rows.length > 0), + approvalGateSatisfied: !await hasPendingNativeGovernance({ + db: input.db, + companyId: run.companyId, + issueId: authoritativeIssue.id, + executionState: authoritativeIssue.executionState, + }), agentId: run.agentId, }); const assessmentRow = await recordNativeWorkAssessment({ diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index 232e74bb4a6..ec015f5179e 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -8,6 +8,7 @@ import { createDb, heartbeatRunEvents, heartbeatRuns, + issueThreadInteractions, issues, nativeRunFinalizations, nativeRunResults, @@ -46,6 +47,9 @@ describeDatabase("PaperclipControlPlanePort conformance", () => { const workspaceFailureIssueId = "00000000-0000-4000-8000-000000000011"; const workspaceFailureContractId = "00000000-0000-4000-8000-000000000012"; const workspaceFailureRunId = "00000000-0000-4000-8000-000000000013"; + const governanceIssueId = "00000000-0000-4000-8000-000000000014"; + const governanceContractId = "00000000-0000-4000-8000-000000000015"; + const governanceRunId = "00000000-0000-4000-8000-000000000016"; beforeAll(async () => { temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-port-"); @@ -157,6 +161,45 @@ describeDatabase("PaperclipControlPlanePort conformance", () => { completionContractSha256: "phase6-workspace-failure-contract", contextSnapshot: { issueId: workspaceFailureIssueId }, }); + await db.insert(issues).values({ + id: governanceIssueId, + companyId: identity.companyId, + title: "Respect pending governance", + status: "in_review", + workMode: "standard", + }); + await db.insert(completionContracts).values({ + id: governanceContractId, + companyId: identity.companyId, + issueId: governanceIssueId, + revision: 1, + schemaVersion: "paperclip.completion-contract.v1", + policyVersion: "phase6-v1", + risk: "standard", + completionAuthority: "server_arbiter", + incompleteCriteriaPolicy: "preserve_non_terminal", + contractJson: { objective: "Respect pending governance" }, + canonicalSha256: "phase6-governance-contract", + createdByActorType: "system", + createdByActorId: "test", + }); + await db.insert(heartbeatRuns).values({ + id: governanceRunId, + companyId: identity.companyId, + agentId: identity.agentId, + status: "running", + runtimeMode: "native", + completionContractId: governanceContractId, + completionContractSha256: "phase6-governance-contract", + contextSnapshot: { issueId: governanceIssueId }, + }); + await db.insert(issueThreadInteractions).values({ + companyId: identity.companyId, + issueId: governanceIssueId, + kind: "request_confirmation", + status: "pending", + payload: { version: 1, prompt: "Approve completion?" }, + }); }, 30_000); afterAll(async () => { @@ -167,6 +210,7 @@ describeDatabase("PaperclipControlPlanePort conformance", () => { await db.delete(workAssessments); await db.delete(nativeRunFinalizations); await db.delete(nativeRunResults); + await db.delete(issueThreadInteractions); await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(completionContracts); @@ -308,6 +352,37 @@ describeDatabase("PaperclipControlPlanePort conformance", () => { await expect(port.openRun(CONTROL_PLANE_CONFORMANCE_OPEN)).rejects.toThrow("binding_mismatch"); }); + it("does not let native completion bypass a pending issue interaction", async () => { + const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity; + const port = new PaperclipControlPlanePort(db, { + companyId: identity.companyId, + issueId: governanceIssueId, + runId: governanceRunId, + agentId: identity.agentId, + completionContractId: governanceContractId, + completionContractSha256: "phase6-governance-contract", + sourceInstanceId: "runner-phase6-governance", + controlPlaneSourceInstanceId: "control-phase6-governance", + }); + await port.openRun({ + identity: { ...identity, runId: governanceRunId, issueId: governanceIssueId }, + backendKind: "mock", + sourceInstanceId: "runner-phase6-governance", + }); + await port.completeRun({ + result: CONTROL_PLANE_CONFORMANCE_RESULT, + terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, + callerResultId: "phase6-governance-result", + }); + await finalizeNativeRun({ db, runId: governanceRunId, workspaceFinalizeStatus: "succeeded" }); + await expect(db.select().from(issues).where(eq(issues.id, governanceIssueId))).resolves.toEqual([ + expect.objectContaining({ status: "in_progress" }), + ]); + await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, governanceIssueId))).resolves.toEqual([ + expect.objectContaining({ toStatus: "in_progress", reasonCode: "approval_gate_pending" }), + ]); + }); + it("preserves the result and issue status when workspace finalization fails", async () => { const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity; const port = new PaperclipControlPlanePort(db, {