fix(runner): preserve pending governance gates

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-09 03:14:01 +00:00
1 parent 37c219b297
commit eba56a19e9
2 files changed
+117 -11

No files matched your search

@@ -4,6 +4,7 @@ import {
approvals,
heartbeatRuns,
issueApprovals,
issueThreadInteractions,
issues,
nativeRunFinalizations,
nativeRunResults,
@@ -19,6 +20,41 @@ function record(value: unknown): Record<string, unknown> {
: {};
}
async function hasPendingNativeGovernance(input: {
db: Db;
companyId: string;
issueId: string;
executionState: Record<string, unknown> | 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({
@@ -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, {