mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The runner package is useful only when the application can start, observe, and recover a native Codex run safely. > - Existing direct adapters must keep their current execution and finalization paths. > - The application boundary therefore needs additive persistence, authorization, coordination, and recovery behind an explicit experimental adapter. > - This pull request adds that Codex-only boundary without activating generalized providers, remote environments, or the later task/SDK surfaces. ## Linked Issues or Issue Description **Subsystem affected** Shared contracts, database persistence, adapter utilities, server native-runtime services, and the experimental Paperclip Runner adapter. **Problem or motivation** The already-landed runner package has a qualified Codex path, but the application needs durable native-run state, guarded runtime selection, authenticated coordination, tool security, finalization, and recovery before the experimental adapter can be exercised safely. **Proposed solution** Add a Codex-only `paperclip_runner` application path behind the existing default-off native-runner setting. Bind native state and coordination to company/run identity, preserve persisted-run recovery, and leave every direct adapter on its existing legacy execution path. **Alternatives considered** The earlier stack boundary introduced a generalized executor and remote-environment lifecycle here. That made this PR depend on implementations in higher PRs and changed reusable sandbox behavior globally. Those pieces are now deferred together to #12592. **Roadmap alignment** ROADMAP.md does not list a conflicting native-runner integration project. This change adds the application boundary for the existing Runner architecture. ## What Changed - Added native run/result/finalization/provider-trace persistence, shared validators, and idempotent migration/replay coverage. - Added guarded Codex-only runtime selection, authenticated PRP coordination, recovery, finalization, and interaction services. - Added run/company-bound tool-gateway authorization, credential redaction, SSRF protections, and replay-safe behavior. - Added the explicit `paperclip_runner` adapter behind the default-off rollout setting. - Preserved legacy answered-question wake projection and direct-adapter execution/finalization paths. - Hardened cancellation so only owned in-memory child processes are signaled; persisted recycled PIDs/process groups are never trusted. - Retained the narrow Claude ACPX isolated-context security follow-up discovered after #12590. - Deferred the generalized executor, provider ingress, remote lifecycle, SDK/lab/eval work, release-process changes, and lockfile. ## Verification - Changed-file delta against `master`: 133 files. - GitHub Actions is the authoritative verification environment for this PR. - Full CI, security, and Greptile review will run on this lowest unmerged stack PR. - Local tests/build/typecheck were not run because this checkout is resource constrained. - Static diff/reference checks pass, and `pnpm-lock.yaml` is unchanged. ## Risks - This touches central heartbeat and agent-route code, so legacy compatibility is the primary risk. - Runtime selection remains Codex-only and explicit; direct Codex, Claude, OpenCode, process, HTTP, and plugin adapters remain on their existing paths. - Fresh native starts fail closed while the rollout flag is off; persisted native records remain readable and recoverable. - Cancellation, company/run binding, tool calls, status decisions, and completion writes are guarded or replay-safe. > For core feature work, check [ROADMAP.md](ROADMAP.md) first and discuss it in #dev before opening the PR. Feature PRs that overlap with planned core work may need to be redirected. ## Model Used OpenAI Codex, GPT-5.6, with repository tools, code execution, and parallel agent review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [ ] I have run tests locally and they pass — GitHub Actions is authoritative for this resource-constrained checkout - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [ ] All Paperclip CI and security gates are green - [ ] Greptile is 5/5 with no open actionable findings - [x] I will address all Greptile and reviewer comments before merge ## Stack - Position: 3 of 5 overall; lowest of 3 currently unmerged - Base: `master` - Previous: [#12590](https://github.com/paperclipai/paperclip/pull/12590), qualified Claude ACPX runtime — merged - Next: [#12592](https://github.com/paperclipai/paperclip/pull/12592), generalized Codex executor, task experience, and developer SDKs --------- Co-authored-by: Dev Agent <dev@paperclip.ing>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
agents,
|
|
companies,
|
|
completionContracts,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueRecoveryActions,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
nativeRunResults,
|
|
statusDecisions,
|
|
workAssessments,
|
|
workspaceOperations,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
CONTROL_PLANE_CONFORMANCE_OPEN,
|
|
CONTROL_PLANE_CONFORMANCE_RESULT,
|
|
CONTROL_PLANE_CONFORMANCE_TERMINAL,
|
|
} from "../vendor/paperclip-runner/testing.js";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { reconcileNativeFinalizations } from "../services/native-runtime/native-finalization-reconciler.js";
|
|
import { PaperclipControlPlanePort } from "../services/native-runtime/paperclip-control-plane-port.js";
|
|
|
|
describe("P6-16/P6-25/P6-28 native finalization recovery", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
const companyId = "72000000-0000-4000-8000-000000000001";
|
|
const agentId = "72000000-0000-4000-8000-000000000002";
|
|
const issueId = "72000000-0000-4000-8000-000000000003";
|
|
const contractId = "72000000-0000-4000-8000-000000000004";
|
|
const runId = "72000000-0000-4000-8000-000000000005";
|
|
const staleIssueId = "72000000-0000-4000-8000-000000000013";
|
|
const staleContractId = "72000000-0000-4000-8000-000000000014";
|
|
const staleRunId = "72000000-0000-4000-8000-000000000015";
|
|
|
|
beforeAll(async () => {
|
|
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-recovery-");
|
|
db = createDb(temporary.connectionString);
|
|
await db.insert(companies).values({ id: companyId, name: "Native recovery", issuePrefix: "NRC" });
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Recovery agent",
|
|
adapterType: "codex_local",
|
|
status: "running",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Recover invalid native finalization",
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
workMode: "standard",
|
|
});
|
|
await db.insert(completionContracts).values({
|
|
id: contractId,
|
|
companyId,
|
|
issueId,
|
|
revision: 1,
|
|
schemaVersion: "paperclip.completion-contract.v1",
|
|
policyVersion: "phase6-v1",
|
|
risk: "standard",
|
|
completionAuthority: "server_arbiter",
|
|
incompleteCriteriaPolicy: "preserve_non_terminal",
|
|
contractJson: {
|
|
revision: "phase6-v1",
|
|
objective: "Recover invalid native finalization",
|
|
criteria: [{ id: "objective", requirement: "Recovery remains live" }],
|
|
},
|
|
canonicalSha256: "native-recovery-contract",
|
|
createdByActorType: "system",
|
|
createdByActorId: "test",
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "persisted_before_kill_switch",
|
|
nativeIssueId: issueId,
|
|
nativeSessionId: runId,
|
|
runnerInstanceId: contractId,
|
|
completionContractId: contractId,
|
|
completionContractSha256: "native-recovery-contract",
|
|
contextSnapshot: { issueId },
|
|
});
|
|
const port = new PaperclipControlPlanePort(db, {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
sessionId: runId,
|
|
completionContractId: contractId,
|
|
completionContractSha256: "native-recovery-contract",
|
|
sourceInstanceId: contractId,
|
|
controlPlaneSourceInstanceId: "recovery-control",
|
|
});
|
|
await port.openRun({
|
|
...CONTROL_PLANE_CONFORMANCE_OPEN,
|
|
identity: { companyId, issueId, runId, agentId, sessionId: runId },
|
|
sourceInstanceId: contractId,
|
|
});
|
|
await port.completeRun({
|
|
result: CONTROL_PLANE_CONFORMANCE_RESULT,
|
|
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
|
|
callerResultId: "recovery-result",
|
|
});
|
|
const stored = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))
|
|
.limit(1).then((rows) => rows[0]!);
|
|
await db.update(nativeRunResults).set({
|
|
resultJson: {
|
|
...(stored.resultJson as Record<string, unknown>),
|
|
terminal: { ...CONTROL_PLANE_CONFORMANCE_TERMINAL, runTerminalState: "unknown" },
|
|
},
|
|
}).where(eq(nativeRunResults.id, stored.id));
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
heartbeatRunId: runId,
|
|
issueId,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: staleIssueId,
|
|
companyId,
|
|
title: "Retire a stale invalid finalizer",
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
workMode: "standard",
|
|
});
|
|
await db.insert(completionContracts).values({
|
|
id: staleContractId,
|
|
companyId,
|
|
issueId: staleIssueId,
|
|
revision: 1,
|
|
schemaVersion: "paperclip.completion-contract.v1",
|
|
policyVersion: "phase6-v1",
|
|
risk: "standard",
|
|
completionAuthority: "server_arbiter",
|
|
incompleteCriteriaPolicy: "preserve_non_terminal",
|
|
contractJson: {
|
|
revision: "phase6-v1",
|
|
objective: "Retire a stale invalid finalizer",
|
|
criteria: [{ id: "objective", requirement: "Keep the newer decision" }],
|
|
},
|
|
canonicalSha256: "stale-finalizer-contract",
|
|
createdByActorType: "system",
|
|
createdByActorId: "test",
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: staleRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "persisted_before_kill_switch",
|
|
nativeIssueId: staleIssueId,
|
|
nativeSessionId: staleRunId,
|
|
runnerInstanceId: staleContractId,
|
|
completionContractId: staleContractId,
|
|
completionContractSha256: "stale-finalizer-contract",
|
|
contextSnapshot: { issueId: staleIssueId },
|
|
});
|
|
const stalePort = new PaperclipControlPlanePort(db, {
|
|
companyId,
|
|
issueId: staleIssueId,
|
|
runId: staleRunId,
|
|
agentId,
|
|
sessionId: staleRunId,
|
|
completionContractId: staleContractId,
|
|
completionContractSha256: "stale-finalizer-contract",
|
|
sourceInstanceId: staleContractId,
|
|
controlPlaneSourceInstanceId: "stale-control",
|
|
});
|
|
await stalePort.openRun({
|
|
...CONTROL_PLANE_CONFORMANCE_OPEN,
|
|
identity: { companyId, issueId: staleIssueId, runId: staleRunId, agentId, sessionId: staleRunId },
|
|
sourceInstanceId: staleContractId,
|
|
});
|
|
await stalePort.completeRun({
|
|
result: CONTROL_PLANE_CONFORMANCE_RESULT,
|
|
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
|
|
callerResultId: "stale-result",
|
|
});
|
|
const staleStored = await db.select().from(nativeRunResults)
|
|
.where(eq(nativeRunResults.runId, staleRunId))
|
|
.limit(1).then((rows) => rows[0]!);
|
|
await db.update(nativeRunResults).set({
|
|
resultJson: {
|
|
...(staleStored.resultJson as Record<string, unknown>),
|
|
terminal: { ...CONTROL_PLANE_CONFORMANCE_TERMINAL, runTerminalState: "unknown" },
|
|
},
|
|
}).where(eq(nativeRunResults.id, staleStored.id));
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
heartbeatRunId: staleRunId,
|
|
issueId: staleIssueId,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
});
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await temporary.cleanup();
|
|
});
|
|
|
|
it("fails closed into bounded named recovery without consulting the live flag or falling back", async () => {
|
|
await expect(reconcileNativeFinalizations(db, [runId])).resolves.toEqual([
|
|
expect.objectContaining({ phase: "retryable_failure", failureCode: "native_finalization_invalid" }),
|
|
]);
|
|
await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toEqual([
|
|
expect.objectContaining({ status: "in_progress", statusVersion: 0, lastStatusDecisionId: null }),
|
|
]);
|
|
await expect(db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).resolves.toEqual([
|
|
expect.objectContaining({ status: "active", ownerAgentId: agentId, cause: "native_finalization_invalid" }),
|
|
]);
|
|
await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, issueId))).resolves.toHaveLength(0);
|
|
|
|
for (let retry = 0; retry < 2; retry += 1) {
|
|
await db.update(nativeRunFinalizations).set({ nextAttemptAt: new Date(0) })
|
|
.where(eq(nativeRunFinalizations.runId, runId));
|
|
await reconcileNativeFinalizations(db, [runId]);
|
|
}
|
|
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId))).resolves.toEqual([
|
|
expect.objectContaining({
|
|
phase: "terminal_failure",
|
|
attempt: 3,
|
|
failureCode: "native_finalization_retry_exhausted",
|
|
nextAttemptAt: null,
|
|
}),
|
|
]);
|
|
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toEqual([
|
|
expect.objectContaining({
|
|
runtimeMode: "native",
|
|
status: "succeeded",
|
|
nativePhase: "terminal_failure",
|
|
resultJson: expect.objectContaining({ prpRunTerminalState: "succeeded" }),
|
|
}),
|
|
]);
|
|
await expect(reconcileNativeFinalizations(db, [runId])).resolves.toEqual([]);
|
|
});
|
|
|
|
it("retires an older failed finalizer when a newer run already committed the issue", async () => {
|
|
await expect(reconcileNativeFinalizations(db, [staleRunId])).resolves.toEqual([
|
|
expect.objectContaining({ phase: "retryable_failure", failureCode: "native_finalization_invalid" }),
|
|
]);
|
|
|
|
const newerRunId = "72000000-0000-4000-8000-000000000016";
|
|
await db.insert(heartbeatRuns).values({
|
|
id: newerRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "succeeded",
|
|
runtimeMode: "native",
|
|
nativeIssueId: staleIssueId,
|
|
completionContractId: staleContractId,
|
|
completionContractSha256: "stale-finalizer-contract",
|
|
contextSnapshot: { issueId: staleIssueId },
|
|
});
|
|
const [newerResult] = await db.insert(nativeRunResults).values({
|
|
companyId,
|
|
issueId: staleIssueId,
|
|
runId: newerRunId,
|
|
completionContractId: staleContractId,
|
|
callerResultId: "newer-result",
|
|
serverFingerprint: "newer-result-fingerprint",
|
|
schemaStatus: "accepted",
|
|
resultJson: {},
|
|
canonicalSha256: "newer-result-sha",
|
|
}).returning();
|
|
const [newerAssessment] = await db.insert(workAssessments).values({
|
|
companyId,
|
|
issueId: staleIssueId,
|
|
runId: newerRunId,
|
|
contractId: staleContractId,
|
|
resultId: newerResult!.id,
|
|
triggerKind: "native_result",
|
|
triggerRef: newerResult!.id,
|
|
triggerCapability: "server_native_finalizer",
|
|
triggerActorCompanyId: companyId,
|
|
priorIssueStatus: "in_progress",
|
|
priorStatusVersion: 0,
|
|
policyVersion: "phase6-v3",
|
|
assessmentJson: {},
|
|
inputDigest: "newer-assessment-digest",
|
|
}).returning();
|
|
const [newerDecision] = await db.insert(statusDecisions).values({
|
|
companyId,
|
|
issueId: staleIssueId,
|
|
runId: newerRunId,
|
|
assessmentId: newerAssessment!.id,
|
|
decisionVersion: 1,
|
|
policyVersion: "phase6-v3",
|
|
fromStatus: "in_progress",
|
|
toStatus: "done",
|
|
reasonCode: "completion_claim_policy_accepted",
|
|
decisionJson: {},
|
|
decisionDigest: "newer-decision-digest",
|
|
applicationState: "applied",
|
|
appliedAt: new Date(),
|
|
}).returning();
|
|
await db.update(issues).set({
|
|
status: "done",
|
|
statusVersion: 1,
|
|
lastStatusDecisionId: newerDecision!.id,
|
|
}).where(eq(issues.id, staleIssueId));
|
|
await db.update(nativeRunFinalizations).set({ nextAttemptAt: new Date(0) })
|
|
.where(eq(nativeRunFinalizations.runId, staleRunId));
|
|
|
|
await expect(reconcileNativeFinalizations(db, [staleRunId])).resolves.toEqual([
|
|
expect.objectContaining({ phase: "terminal_failure", failureCode: "native_finalization_superseded" }),
|
|
]);
|
|
await expect(db.select().from(issues).where(eq(issues.id, staleIssueId))).resolves.toEqual([
|
|
expect.objectContaining({ status: "done", lastStatusDecisionId: newerDecision!.id }),
|
|
]);
|
|
await expect(db.select().from(issueRecoveryActions)
|
|
.where(eq(issueRecoveryActions.sourceIssueId, staleIssueId))).resolves.toEqual([
|
|
expect.objectContaining({ status: "resolved", outcome: "false_positive" }),
|
|
]);
|
|
});
|
|
});
|