Files
PaperClipAI/server/src/__tests__/native-session-resumption.test.ts
T
DottaandClaude Opus 5.5 fc6e1b0346 perf(native): keep local Codex and OpenCode runners warm by default (#19888)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - When someone messages an agent, the turn must start fast. Time to
first token (TTFT) is a first-class metric.
> - The Phase 0 baseline (#19846) found that every local turn launches a
new runner and loads the provider session again, because `per_turn` is
the default lifecycle. That made a follow-up message slower than a new
task.
> - Warm runners keep the runner and provider session alive between
runs. #19874 fixed the defect that broke warm OpenCode follow-ups, and
#19879 bounds an idle warm runner whose controller dies.
> - This pull request makes warm the default for local Codex and
OpenCode agents, and migrates existing agents that only carried the old
creation default.
> - The benefit: follow-up TTFT p50 drops from 12.0 s to 4.7 s (Codex)
and from 21.7 s to 4.2 s (OpenCode), with no new restart-chaos failures.

## Linked Issues or Issue Description

Refs #19846 (TTFT baseline), #19852 (startup sub-spans), #19874 (warm
completion contract), #19879 (warm reconnect grace).

**What existing behavior does this improve?**
The default runner lifecycle for native agents, and how a follow-up
message starts.

**Current behavior**
Agent creation writes `lifecycleMode: "per_turn"` into every native
agent. Every follow-up launches a new runner and restores the provider
session: 4.2 s (Codex) and 16.3 s (OpenCode) at p50 before the turn
starts.

**Proposed behavior**
New native agents store no lifecycle. On a local target, an unset
lifecycle resolves to `warm` with the default 5-minute idle window for
Codex and OpenCode. Other harnesses, remote targets and managed
providers without warm support stay `per_turn`. An explicit choice
always wins. Migration 0443 clears the stored creation default from
existing agents, except where an edit set "Turn by turn" explicitly.

**Reason and benefit**
Measured locally (`doc/plans/2026-11-07-warm-follow-up-ttft.md`). Warm
follow-ups are 2.6–5× faster for Codex and OpenCode, and restart
recovery latency is equal or lower.

## What Changed

- `native-lifecycle.ts`: an unset `agentMode` resolves to warm (5-minute
idle window) for local `codex_app_server` and `opencode_server` agents.
`runtime-selection.ts` passes `localTarget`. Explicit `per_turn` or
`warm` wins. An admitted session keeps its persisted policy. ACPX Claude
stays `per_turn`: it relaunches its provider process on every follow-up
by design, so warm did not shorten its follow-up (10.7 s warm vs 10.5 s
per_turn).
- Creation and configuration writes no longer store `lifecycleMode:
"per_turn"` for Codex, Claude or OpenCode. This covers
`normalizePaperclipRunnerAdapterConfig` (runs on every create and
update), `paperclipRunnerTransitionConfig` (switching to the native
runner), `agent-runner-selection.ts`, `codex-local` `build-config.ts`,
and the simplified new-agent setup (`paperclipRunnerCreationLifecycle`
in `@paperclipai/shared`). Grok, Pi and Cursor keep their explicit
value.
- Agent form: the Runner lifecycle field shows the unset state as
"Automatic". "Turn by turn" and "Warm session" remain.
- Migration `0443_native_warm_follow_up_default.sql`: clears
`lifecycleMode = 'per_turn'` from native Codex, OpenCode and Claude
agents unless `agent_config_revisions` shows an edit that set it to
`per_turn`. Each migrated agent gets an `agent.updated` activity entry
with the old value (`actor_id =
migration:native-warm-follow-up-default`). Creation records no revision,
so a "Turn by turn" pick made at creation cannot be told apart from the
old default. The product owner approved migrating those agents. Claude
agents follow the default, which is `per_turn` today.
- Restart chaos harness and TTFT driver: `--lifecycle
per_turn|warm|default` (saved in the wave plan). `default` removes the
catalog's pinned lifecycle, so a wave exercises the real default path.
- Docs: `doc/DEVELOPING.md` "Runner lifecycle default", and the results
in `doc/plans/2026-11-07-warm-follow-up-ttft.md`, which also documents
baseline finding 4 (Claude's resumed turn-start gap is ACPX's per-turn
process generation, not the provider).

## Verification

- `server`: `native-lifecycle.test.ts` (new default cases: local Codex
and OpenCode warm, Claude, remote, unknown target, unmeasured harness,
managed Claude, admitted per-turn session, explicit choices, non-native
adapter), `agent-runner-selection.test.ts`, `runtime-selection.test.ts`
(335 pass).
- `packages/db`: `native-warm-follow-up-default-migration.test.ts` on
embedded Postgres. Default agents are cleared, the explicit edit is
kept, other harnesses and legacy adapters are untouched, audit rows are
written once, and a rerun is a no-op. Without the explicit-edit filter
the test fails. `check:migrations` passes.
- `codex-local` `build-config.test.ts` (65), `ui`
`config-fields.test.tsx` (40), restart-chaos unit tests (134), `tsc
--noEmit` for `server`.
- `adapter-utils` `paperclip-runner-lifecycle-default.test.ts`:
configuration writes and adapter conversion store no lifecycle for
Codex, OpenCode and Claude, keep `per_turn` for the others, and preserve
explicit choices. It fails without the fix.
- End-to-end default check on this branch (`ttft-baseline.ts --lifecycle
default`, codex and opencode, 2 samples): the agents read back with no
stored lifecycle, and every follow-up reused the runner and provider
session (no `runner.process.launch`). Follow-up TTFT was 3.6–4.8 s. This
check found the per-write `per_turn` injection fixed here.
- Restart chaos (codex and opencode, A1/B1/B5, K1/K2/K6, seed 4242), 8/8
PASS each with no open signatures: forced warm, forced per_turn, and
`--lifecycle default` on this branch (agents created without a
lifecycle).
- `pnpm verify` selects broader CI (harness config is a global input),
so required CI supplies the broad coverage.

## Risks

- Behavior change: local Codex and OpenCode agents keep a runner and
provider process for up to 5 minutes after a run. Instance sleep waits
for that idle window. The process is reaped by the idle timer, by
graceful shutdown, or by the #19879 reconnect grace if its controller
dies.
- Migration: agents whose "Turn by turn" was chosen at creation become
Automatic. The activity entry records the old value; choosing "Turn by
turn" restores it.
- Previous-release story (`rules/upgrade-safe-run-state.mdc`): a task
session keeps the lifecycle policy it was admitted with, so sessions
saved before this change stay `per_turn`. New sessions get the default.
No run, checkpoint or launch state changes shape.

> 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 — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use (shell, file edit, test execution).

## 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 (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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 any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:37:36 -06:00

1721 lines
67 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
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";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, inArray, sql } from "drizzle-orm";
import {
activityLog,
agentWakeupRequests,
agentTaskSessions,
agents,
companies,
authUsers,
companyMemberships,
completionContracts,
createDb,
closeRegisteredClients,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueRecoveryActions,
issueComments,
issueWorkProducts,
issues,
nativeRunFinalizations,
nativeRunResults,
projectWorkspaces,
projects,
statusDecisionEffects,
statusDecisions,
workAssessments,
} from "@paperclipai/db";
import {
type NativeExecutionInputV1,
type NativeExecutionInput,
type NativeSession,
type NativeSessionBackend,
type PersistedNativeSession,
type PrpEvent,
} from "@paperclipai/paperclip-runner";
import {
CONTROL_PLANE_CONFORMANCE_RESULT,
CONTROL_PLANE_CONFORMANCE_TERMINAL,
} from "../vendor/paperclip-runner/testing.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
import {
claimNativeSessionResumptions,
dispatchNativeSessionResumptions,
} from "../services/native-runtime/native-finalization-reconciler.js";
const legacyAdapterExecute = vi.hoisted(() => vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
summary: "Fresh flag-off run completed through legacy.",
resultJson: { summary: "fresh legacy after persisted native recovery" },
provider: "test",
model: "legacy-test",
})));
vi.mock("../adapters/index.js", async () => {
const actual = await vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js");
return {
...actual,
getServerAdapter: vi.fn(() => ({
type: "codex_local",
execute: legacyAdapterExecute,
supportsLocalAgentJwt: false,
})),
};
});
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
vi.mock("../sentry.js", async () => {
const actual = await vi.importActual<typeof import("../sentry.js")>("../sentry.js");
return { ...actual, captureRunFailure: mockCaptureRunFailure };
});
import { createIsolatedTestHome } from "./helpers/isolated-test-home.js";
import { issueService } from "../services/issues.js";
import * as heartbeatWorkspaces from "../services/heartbeat/workspaces.js";
import { heartbeatService } from "../services/heartbeat.js";
import { persistHeartbeatRunProcessMetadata } from "../services/heartbeat/run-lifecycle.js";
import { instanceSettingsService } from "../services/instance-settings.js";
describe("P6-25 pre-result native session recovery", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
const companyId = "79000000-0000-4000-8000-000000000001";
const agentId = "79000000-0000-4000-8000-000000000002";
const issueId = "79000000-0000-4000-8000-000000000003";
const runId = "79000000-0000-4000-8000-000000000004";
const cancelledRunId = "79000000-0000-4000-8000-000000000005";
const exhaustedRunId = "79000000-0000-4000-8000-000000000006";
const missingCheckpointRunId = "79000000-0000-4000-8000-000000000007";
const initialRunId = "79000000-0000-4000-8000-000000000008";
const bootstrapRetryRunId = "79000000-0000-4000-8000-000000000009";
const observedExpiredRunId = "79000000-0000-4000-8000-000000000010";
const observedLivePidRunId = "79000000-0000-4000-8000-000000000011";
const fixtureRunIds = [runId, cancelledRunId, exhaustedRunId, missingCheckpointRunId,
initialRunId, bootstrapRetryRunId, observedExpiredRunId, observedLivePidRunId];
const issueForRun = (id: string) => id === runId ? issueId : id;
const persistedProfile = {
mode: "native",
nativeExecutionInput: { schema: "paperclip.native-execution-input.v1", binding: { runId } },
sessionCheckpoint: {
backendKind: "codex_app_server",
sessionId: "persisted-session",
identity: { runId },
providerSessionId: "provider-session",
activeTurnId: "active-turn",
},
};
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-resume-");
db = createDb(temporary.connectionString);
await db.insert(companies).values({ id: companyId, name: "Native resume", issuePrefix: "NRR" });
await db.insert(agents).values({
id: agentId,
companyId,
name: "Native resume agent",
adapterType: "codex_local",
status: "running",
});
await db.insert(issues).values(fixtureRunIds.map(id => ({
id: issueForRun(id), companyId, title: "Resume the same native run",
status: "in_progress", assigneeAgentId: agentId, workMode: "standard" as const,
})));
await db.insert(heartbeatRuns).values([
{
id: runId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: persistedProfile,
contextSnapshot: { issueId },
},
{
id: cancelledRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "cancelled",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: persistedProfile,
contextSnapshot: { issueId },
},
{
id: exhaustedRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "failed",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: persistedProfile,
contextSnapshot: { issueId },
},
{
id: missingCheckpointRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: missingCheckpointRunId },
},
},
contextSnapshot: { issueId },
},
{
id: initialRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: initialRunId },
},
},
contextSnapshot: { issueId },
},
{
id: bootstrapRetryRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "failed",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: bootstrapRetryRunId },
},
},
errorCode: "provider_initialize_timeout",
contextSnapshot: { issueId },
},
...[observedExpiredRunId, observedLivePidRunId].map((observedRunId) => ({
id: observedRunId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running" as const,
runtimeMode: "native" as const,
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
...persistedProfile,
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: observedRunId },
},
sessionCheckpoint: {
...persistedProfile.sessionCheckpoint,
identity: { runId: observedRunId },
},
},
contextSnapshot: { issueId },
})),
].map(run => ({ ...run, runtimeMode: "native" as const,
nativeIssueId: issueForRun(run.id), contextSnapshot: { issueId: issueForRun(run.id) },
})));
await db.insert(nativeRunFinalizations).values([
{ runId, companyId, issueId, phase: "retryable_failure", attempt: 1, nextAttemptAt: new Date(0) },
{ runId: cancelledRunId, companyId, issueId, phase: "retryable_failure", attempt: 1, nextAttemptAt: new Date(0) },
{ runId: exhaustedRunId, companyId, issueId, phase: "terminal_failure", attempt: 3 },
{ runId: missingCheckpointRunId, companyId, issueId, phase: "retryable_failure", attempt: 1 },
{ runId: initialRunId, companyId, issueId, phase: "observed", attempt: 0 },
{
runId: bootstrapRetryRunId,
companyId,
issueId,
phase: "retryable_failure",
attempt: 1,
nextAttemptAt: new Date(0),
failureCode: "native_session_interrupted",
failureDetail: {
message: "provider_initialize_timeout: provider=opencode stage=health",
originalFailureCode: "provider_initialize_timeout",
recoveryMode: "bootstrap_retry",
providerSessionEstablished: false,
providerEventsExist: false,
checkpointExists: false,
},
},
...[observedExpiredRunId, observedLivePidRunId].map((observedRunId) => ({
runId: observedRunId,
companyId,
issueId,
phase: "observed" as const,
attempt: 2,
leaseOwner: "prior-native-owner",
leaseExpiresAt: new Date(0),
})),
].map(value => ({ ...value, issueId: issueForRun(value.runId),
phase: value.phase as typeof nativeRunFinalizations.$inferInsert.phase,
})));
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => temporary?.cleanup());
it("filters durable ownership holds before the bounded resume candidate limit", async () => {
const heldRunIds = Array.from(
{ length: 26 },
(_, index) =>
`79100000-0000-4000-8000-${String(index).padStart(12, "0")}`,
);
const eligibleRunId = "79100000-0000-4000-8000-000000000099";
const candidateRunIds = [...heldRunIds, eligibleRunId];
await db.insert(issues).values(candidateRunIds.map(id => ({
id, companyId, title: "Independent recovery candidate", status: "in_progress", assigneeAgentId: agentId,
})));
await db.insert(heartbeatRuns).values(
candidateRunIds.map((candidateRunId) => ({
id: candidateRunId,
companyId,
agentId,
nativeIssueId: candidateRunId,
status: "running",
runtimeMode: "native",
nativePhase:
candidateRunId === eligibleRunId
? "retryable_failure"
: "terminal_failure",
errorCode:
candidateRunId === eligibleRunId
? null
: "native_execution_ownership_unverified",
runnerProfileJson: {
...persistedProfile,
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: candidateRunId },
},
sessionCheckpoint: {
...persistedProfile.sessionCheckpoint,
identity: { runId: candidateRunId },
},
},
})),
);
await db.insert(nativeRunFinalizations).values(
candidateRunIds.map((candidateRunId) => ({
runId: candidateRunId,
companyId,
issueId: candidateRunId,
phase: "retryable_failure",
attempt: 1,
nextAttemptAt: new Date(0),
leaseExpiresAt: new Date(0),
})),
);
try {
expect(
await claimNativeSessionResumptions({
db,
runnerInstanceId: "bounded-reaper",
runIds: candidateRunIds,
limit: 1,
}),
).toEqual([
{
runId: eligibleRunId,
leaseOwner: expect.stringContaining("bounded-reaper:resume:"),
attempt: expect.any(Number),
},
]);
const held = await db
.select()
.from(nativeRunFinalizations)
.where(inArray(nativeRunFinalizations.runId, heldRunIds));
expect(held).toHaveLength(26);
expect(
held.every(
(row) => row.phase === "retryable_failure" && row.leaseOwner === null,
),
).toBe(true);
} finally {
await db.delete(heartbeatRunEvents).where(inArray(heartbeatRunEvents.runId, candidateRunIds));
await db
.delete(nativeRunFinalizations)
.where(inArray(nativeRunFinalizations.runId, candidateRunIds));
await db
.delete(heartbeatRuns)
.where(inArray(heartbeatRuns.id, candidateRunIds));
await db.delete(issues).where(inArray(issues.id, candidateRunIds));
}
});
it("wins one database lease for the original result-less run without consulting the flag", async () => {
const results = await Promise.all([
claimNativeSessionResumptions({ db, runnerInstanceId: "reaper-a", runIds: [runId] }),
claimNativeSessionResumptions({ db, runnerInstanceId: "reaper-b", runIds: [runId] }),
]);
expect(results.flat()).toHaveLength(1);
expect(results.flat()[0]).toMatchObject({ runId });
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toEqual([
expect.objectContaining({ id: runId, status: "running", runtimeMode: "native" }),
]);
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId))).resolves.toEqual([
expect.objectContaining({ runId, phase: "observed", resultId: null, attempt: 1 }),
]);
});
it("dispatches the persisted run id and lease to the live same-run resume consumer", async () => {
await db.update(nativeRunFinalizations).set({
phase: "retryable_failure",
leaseOwner: null,
leaseExpiresAt: null,
nextAttemptAt: new Date(0),
}).where(eq(nativeRunFinalizations.runId, runId));
const dispatched: Array<{ runId: string; leaseOwner: string; attempt: number }> = [];
await expect(dispatchNativeSessionResumptions({
db,
runnerInstanceId: "heartbeat-reaper",
runIds: [runId],
dispatch: (claim) => dispatched.push(claim),
})).resolves.toHaveLength(1);
expect(dispatched).toEqual([{ runId, leaseOwner: expect.stringContaining("heartbeat-reaper:resume:"), attempt: 1 }]);
await expect(db.select().from(heartbeatRuns)).resolves.toHaveLength(8);
await expect(db.select().from(nativeRunFinalizations)).resolves.toHaveLength(8);
});
it("uses provider-neutral checkpoint-free bootstrap retry only when durable evidence proves no session existed", async () => {
await expect(claimNativeSessionResumptions({
db,
runnerInstanceId: "reaper",
runIds: [bootstrapRetryRunId],
})).resolves.toEqual([
{ runId: bootstrapRetryRunId, leaseOwner: expect.stringContaining("reaper:resume:"), attempt: 1 },
]);
});
it("never claims an expired observed coordinator without explicit retryable failure", async () => {
const dispatched: Array<{ runId: string; leaseOwner: string }> = [];
await expect(dispatchNativeSessionResumptions({
db,
runnerInstanceId: "replacement-reaper",
runIds: [observedExpiredRunId],
dispatch: (claim) => dispatched.push(claim),
})).resolves.toEqual([]);
expect(dispatched).toEqual([]);
await expect(db.select().from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, observedExpiredRunId))).resolves.toEqual([
expect.objectContaining({
phase: "observed",
attempt: 2,
leaseOwner: "prior-native-owner",
leaseExpiresAt: new Date(0),
}),
]);
});
it("blocks ambiguous observed ownership and a live unrelated persisted PID without replacement effects", async () => {
const unrelatedProcess = spawn(
process.execPath,
["-e", "setInterval(() => {}, 1_000)"],
{ stdio: "ignore" },
);
await once(unrelatedProcess, "spawn");
try {
await db.update(heartbeatRuns).set({
processPid: unrelatedProcess.pid!,
processStartedAt: new Date("2026-08-09T04:00:00.000Z"),
}).where(eq(heartbeatRuns.id, observedLivePidRunId));
const backendFactory = vi.fn((): NativeSessionBackend => ({
async descriptor() {
return {
kind: "mock",
name: "unexpected-observed-recovery",
version: "1",
capabilities: {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
},
};
},
async openSession() {
throw new Error("observed ownership must not open a provider session");
},
async recoverSession() {
throw new Error("observed ownership must not recover a provider session");
},
}));
const heartbeat = heartbeatService(db, {
runtimeEnv: { PAPERCLIP_INSTANCE_ID: "observed-owner-test" },
nativeSessionBackendFactory: backendFactory,
});
const reaped = await heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 });
expect(reaped.runIds).not.toContain(observedExpiredRunId);
expect(reaped.runIds).not.toContain(observedLivePidRunId);
await heartbeat.drainActiveRunExecutions();
expect(backendFactory).not.toHaveBeenCalled();
expect(() => process.kill(unrelatedProcess.pid!, 0)).not.toThrow();
await expect(db.select().from(heartbeatRuns).where(eq(
heartbeatRuns.id,
observedExpiredRunId,
))).resolves.toEqual([
expect.objectContaining({
status: "running",
errorCode: "native_execution_ownership_unverified",
}),
]);
await expect(db.select().from(heartbeatRuns).where(eq(
heartbeatRuns.id,
observedLivePidRunId,
))).resolves.toEqual([
expect.objectContaining({
status: "running",
processPid: unrelatedProcess.pid,
errorCode: "native_execution_ownership_unverified",
}),
]);
for (const observedRunId of [observedExpiredRunId, observedLivePidRunId]) {
await expect(db.select().from(nativeRunFinalizations).where(eq(
nativeRunFinalizations.runId,
observedRunId,
))).resolves.toEqual([
expect.objectContaining({
phase: "observed",
attempt: 2,
leaseOwner: "prior-native-owner",
leaseExpiresAt: new Date(0),
}),
]);
await expect(db.select().from(nativeRunResults).where(eq(
nativeRunResults.runId,
observedRunId,
))).resolves.toHaveLength(0);
await expect(db.select().from(workAssessments).where(eq(
workAssessments.runId,
observedRunId,
))).resolves.toHaveLength(0);
}
} finally {
if (
unrelatedProcess.exitCode === null &&
unrelatedProcess.signalCode === null
) {
const exited = once(unrelatedProcess, "exit");
unrelatedProcess.kill("SIGKILL");
await exited;
}
}
});
it("does not resume cancelled or exhausted runs and fails closed without a checkpoint", async () => {
await expect(claimNativeSessionResumptions({
db,
runnerInstanceId: "reaper",
runIds: [cancelledRunId, exhaustedRunId, missingCheckpointRunId],
})).resolves.toEqual([]);
await expect(db.select().from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, missingCheckpointRunId))).resolves.toEqual([
expect.objectContaining({ phase: "terminal_failure", failureCode: "native_session_interrupted" }),
]);
await expect(db.select().from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, missingCheckpointRunId))).resolves.toEqual([
expect.objectContaining({ cause: "native_session_interrupted", wakePolicy: null }),
]);
});
it("does not mistake the pre-first-attempt observed coordinator for an orphan", async () => {
await expect(claimNativeSessionResumptions({
db,
runnerInstanceId: "reaper",
runIds: [initialRunId],
})).resolves.toEqual([]);
await expect(db.select().from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, initialRunId))).resolves.toEqual([
expect.objectContaining({ phase: "observed", attempt: 0, failureCode: null }),
]);
});
it("sends exactly one Sentry event when a sweep resolves a run from running to failed", async () => {
const freshRunId = "79000000-0000-4000-8000-000000000101";
await db.insert(issues).values({ id: freshRunId, companyId, title: "Independent failure report",
status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({
id: freshRunId,
companyId,
agentId,
nativeIssueId: freshRunId,
status: "running",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: freshRunId },
},
},
contextSnapshot: { issueId: freshRunId },
});
await db.insert(nativeRunFinalizations).values({
runId: freshRunId,
companyId,
issueId: freshRunId,
phase: "retryable_failure",
attempt: 1,
});
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
await claimNativeSessionResumptions({ db, runnerInstanceId: "reaper", runIds: [freshRunId] });
// The reconciler reports asynchronously; an unrelated database round trip
// does not guarantee that callback has completed.
await waitForPendingRunFailureReports();
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(1);
const newCaptures = mockCaptureRunFailure.mock.calls.slice(captureCallsBefore);
expect(newCaptures[0]?.[0]).toMatchObject({ runId: freshRunId, runStatus: "failed" });
});
it("sends no Sentry event when a sweep finds a run that is already failed", async () => {
const freshRunId = "79000000-0000-4000-8000-000000000102";
await db.insert(issues).values({ id: freshRunId, companyId, title: "Independent failure report",
status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({
id: freshRunId,
companyId,
agentId,
nativeIssueId: freshRunId,
status: "failed",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: freshRunId },
},
},
contextSnapshot: { issueId: freshRunId },
});
await db.insert(nativeRunFinalizations).values({
runId: freshRunId,
companyId,
issueId: freshRunId,
phase: "retryable_failure",
attempt: 1,
});
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
await claimNativeSessionResumptions({ db, runnerInstanceId: "reaper", runIds: [freshRunId] });
await waitForPendingRunFailureReports();
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(0);
});
it("sends one Sentry event when a sweep ends an unreported pending retry", async () => {
const freshRunId = "79000000-0000-4000-8000-000000000103";
await db.insert(issues).values({ id: freshRunId, companyId, title: "Independent failure report",
status: "in_progress", assigneeAgentId: agentId });
// The executor stores a scheduled same-run retry as failed + retryable_failure,
// and reportRunFailure skips that write. The sweep's terminal write reports it.
await db.insert(heartbeatRuns).values({
id: freshRunId,
companyId,
agentId,
nativeIssueId: freshRunId,
status: "failed",
nativePhase: "retryable_failure",
runtimeMode: "native",
runtimeModeResolvedAt: new Date(),
runnerProfileJson: {
nativeExecutionInput: {
...persistedProfile.nativeExecutionInput,
binding: { runId: freshRunId },
},
},
contextSnapshot: { issueId: freshRunId },
});
await db.insert(nativeRunFinalizations).values({
runId: freshRunId,
companyId,
issueId: freshRunId,
phase: "retryable_failure",
attempt: 1,
});
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
await claimNativeSessionResumptions({ db, runnerInstanceId: "reaper", runIds: [freshRunId] });
await waitForPendingRunFailureReports();
const newCaptures = mockCaptureRunFailure.mock.calls.slice(captureCallsBefore);
expect(newCaptures).toHaveLength(1);
expect(newCaptures[0]?.[0]).toMatchObject({ runId: freshRunId, runStatus: "failed" });
});
});
describe.each(["unchanged", "newer_active", "stale_idle"] as const)(
"P6-25 persisted reaper-to-finalization recovery (%s)",
(variant) => {
const newerRequest = variant !== "unchanged";
const staleIdleCheckpoint = variant === "stale_idle";
let temporary: Awaited<
ReturnType<typeof startEmbeddedPostgresTestDatabase>
> | null = null;
let db: ReturnType<typeof createDb>;
let staleProviderProcess: ReturnType<typeof spawn> | null = null;
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const newerExecutionWorkspaceId = randomUUID();
const issueId = randomUUID();
const freshIssueId = randomUUID();
const runId = randomUUID();
const contractId = randomUUID();
const workProductId = randomUUID();
const sessionId = randomUUID();
const runnerInstanceId = randomUUID();
const newerCommentId = randomUUID();
const newerWakeKey = `newer-comment:${newerCommentId}`;
const turnId = "provider-active-turn";
const providerSessionId = "provider-existing-session";
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
const contract = {
revision: "phase6-recovery-v1",
objective: "Recover the persisted provider turn",
criteria: [
{ id: "objective", requirement: "Complete through same-run recovery" },
],
};
const contractSha = "phase6-recovery-contract";
const evidenceRef = `work_product:${workProductId}`;
const result = structuredClone(CONTROL_PLANE_CONFORMANCE_RESULT);
result.completionClaim.contractRevision = contract.revision;
result.completionClaim.criteria[0]!.evidenceRefs = [evidenceRef];
result.evidence = [{ kind: "work_product", ref: evidenceRef }];
result.verification[0]!.artifactRef = evidenceRef;
result.summary = "Recovered the already-active provider turn.";
const terminal = {
...CONTROL_PLANE_CONFORMANCE_TERMINAL,
reportedWorkDisposition: result.reportedWorkDisposition,
};
const execution: NativeExecutionInputV1 = {
schema: "paperclip.native-execution-input.v1",
binding: { companyId, runId, issueId, agentId, executionWorkspaceId },
task: {
identifier: "NRR-1",
title: "Recover one native heartbeat",
description: null,
prompt: "Recover one native heartbeat",
workMode: "standard",
},
workspace: {
cwd: repoRoot,
repoUrl: null,
repoRef: null,
branchName: null,
},
session: {
normalizedSessionId: sessionId,
driverKind: "codex_app_server",
protocolVersion: 1,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
},
provider: { kind: "codex", model: null },
completionContract: {
id: contractId,
sha256: contractSha,
schemaVersion: "paperclip.completion-contract.v1",
contract,
},
interactionResponses: [],
credentialBindings: [],
};
const checkpoint: PersistedNativeSession = {
backendKind: "mock",
sessionId: "driver-existing-session",
identity: { companyId, runId, issueId, agentId, sessionId },
providerSessionId,
cursor: "1",
activeTurnId: staleIdleCheckpoint ? null : turnId,
pendingRuntimeRequests: [],
lineage: [],
};
const providerTerminalEvent: PrpEvent = {
schema: "paperclip.prp.event.v1",
sourceEventId: `${runnerInstanceId}:provider-terminal`,
sourceSeq: 1,
sourceInstanceId: runnerInstanceId,
sourceKind: "runner",
runId,
normalizedSessionId: sessionId,
turnId,
eventType: "turn.completed",
schemaVersion: 1,
priority: 0,
emittedAt: "2026-08-09T04:30:00.000Z",
payload: {},
};
const openSession = vi.fn(async () => {
throw new Error(
"same-run recovery must not open a second provider session",
);
});
const startTurn = vi.fn<NativeSession["startTurn"]>(async () => {
throw new Error(
"recovering old work must not start a replacement provider turn",
);
});
const close = vi.fn(async () => undefined);
const recoverSession = vi.fn(async (persisted: PersistedNativeSession) => {
expect(persisted).toMatchObject({
providerSessionId,
activeTurnId: staleIdleCheckpoint ? null : turnId,
});
expect(staleProviderProcess).not.toBeNull();
expect(
staleProviderProcess!.exitCode !== null ||
staleProviderProcess!.signalCode !== null,
).toBe(true);
// Model the real launch/checkpoint gap: the durable DB snapshot may still
// look idle, while the recovered provider has already started the old turn.
const recoveredSnapshot: PersistedNativeSession = {
...structuredClone(checkpoint),
cursor: "2",
activeTurnId: turnId,
};
const session: NativeSession = {
identity: () => structuredClone(checkpoint.identity),
async capabilities() {
return {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
};
},
async *events() {
yield providerTerminalEvent;
},
startTurn,
async result() {
return { result, terminal, turnId };
},
async snapshot() {
return structuredClone(recoveredSnapshot);
},
close,
};
return { recovered: true, session };
});
const backend: NativeSessionBackend = {
async descriptor() {
return {
kind: "mock",
name: "persisted-recovery-backend",
version: "1",
capabilities: {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
},
};
},
openSession,
recoverSession,
};
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase(
"paperclip-native-reaper-e2e-",
);
db = createDb(temporary.connectionString);
await instanceSettingsService(db).updateExperimental({
enableNativeRunner: newerRequest,
});
await db.insert(companies).values({
id: companyId,
name: "Native same-run recovery",
issuePrefix: "NRR",
status: "active",
defaultResponsibleUserId: "responsible-user",
});
await db.insert(authUsers).values({ id: "responsible-user", name: "Fixture user", email: "responsible-user@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
await db
.insert(projects)
.values({
id: projectId,
companyId,
name: "Recovery project",
status: "active",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Recovery workspace",
cwd: repoRoot,
isPrimary: true,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Native recovery agent",
adapterType: "paperclip_runner",
// Match the persisted fixture workspace's strategy explicitly. A
// metadata-free workspace is not proof of the current config; an actual
// strategy change correctly refuses immutable native-input rebinding.
adapterConfig: { workspaceStrategy: { type: "project_primary" } },
status: "active",
runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
nativeRunner: {
mode: "native",
backend: "codex_app_server",
protocolVersion: 1,
},
},
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
issueNumber: 1,
identifier: "NRR-1",
title: "Recover one native heartbeat",
status: "in_progress",
assigneeAgentId: agentId,
workMode: "standard",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Persisted recovery workspace",
status: "active",
cwd: repoRoot,
providerType: "local_fs",
});
await db.insert(executionWorkspaces).values({
id: newerExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Newer issue workspace",
status: "active",
cwd: repoRoot,
providerType: "local_fs",
});
await db
.update(issues)
.set({
// Simulate a newer run moving the issue-level pointer before the older native run is
// recovered. The older run must still restore its own immutable workspace binding.
executionWorkspaceId: newerExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "shared_workspace" },
})
.where(eq(issues.id, issueId));
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: contract,
canonicalSha256: contractSha,
createdByActorType: "system",
createdByActorId: "test",
});
await db.insert(issueWorkProducts).values({
id: workProductId,
companyId,
issueId,
type: "artifact",
provider: "paperclip",
title: "Recovered result evidence",
status: "ready_for_review",
reviewState: "approved",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
nativeIssueId: issueId,
status: "running",
runtimeMode: "native",
runtimeModeResolverVersion: "phase6-v1",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date("2026-08-09T04:00:00.000Z"),
runnerProfileJson: {
mode: "native",
backend: "codex_app_server",
protocolVersion: 1,
nativeExecutionInput: execution,
sessionCheckpoint: checkpoint,
},
runnerInstanceId,
nativeSessionId: sessionId,
driverKind: "codex_app_server",
driverVersion: "phase6-v1",
completionContractId: contractId,
completionContractSha256: contractSha,
nativePhase: "retryable_failure",
nativePhaseUpdatedAt: new Date("2026-08-09T04:00:00.000Z"),
startedAt: new Date("2026-08-09T04:00:00.000Z"),
contextSnapshot: { issueId, taskId: issueId, skipIssueComment: true },
});
await db
.update(issues)
.set({ executionRunId: runId })
.where(eq(issues.id, issueId));
await db.insert(nativeRunFinalizations).values({
runId,
companyId,
issueId,
phase: "retryable_failure",
attempt: 1,
failureCode: "native_session_interrupted",
nextAttemptAt: new Date(0),
});
if (newerRequest)
await db.insert(issueComments).values({
id: newerCommentId,
companyId,
issueId,
authorUserId: "responsible-user",
body: "Verify the newest user instruction before completing this task.",
});
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
if (
staleProviderProcess &&
staleProviderProcess.exitCode === null &&
staleProviderProcess.signalCode === null
) {
staleProviderProcess.kill("SIGKILL");
}
if (temporary) {
await drainHeartbeatRunsToQuiescence(
db,
heartbeatService(db, {
runtimeEnv: { PAPERCLIP_INSTANCE_ID: "phase6-recovery-test" },
nativeSessionBackendFactory: () => backend,
}),
);
await temporary.cleanup();
}
});
it("preserves the old provider contract and admits newer direction only in a separate turn", async () => {
legacyAdapterExecute.mockClear();
staleProviderProcess = spawn(
process.execPath,
["-e", "setInterval(() => {}, 1_000)"],
{ stdio: "ignore" },
);
await once(staleProviderProcess, "spawn");
expect(staleProviderProcess.pid).toEqual(expect.any(Number));
await persistHeartbeatRunProcessMetadata(db, runId, {
pid: staleProviderProcess.pid!, processGroupId: null,
startedAt: new Date("2026-08-09T04:00:00.000Z").toISOString(), targetKind: "local",
});
const factoryInputs: NativeExecutionInput[] = [];
const freshStartTurn = vi.fn<NativeSession["startTurn"]>();
const freshBackend = (
executionInput: NativeExecutionInput,
): NativeSessionBackend => {
const freshRunId = executionInput.binding.runId;
const freshTurnId = `new-direction:${freshRunId}`;
const freshIdentity = {
...checkpoint.identity,
runId: freshRunId,
sessionId: executionInput.session.normalizedSessionId!,
};
let releaseStart!: () => void;
const started = new Promise<void>((resolve) => {
releaseStart = resolve;
});
let freshResult = structuredClone(result);
const session: NativeSession = {
identity: () => freshIdentity,
capabilities: async () => ({
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
}),
async *events() {
await started;
const [freshRun] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, freshRunId));
yield {
...providerTerminalEvent,
sourceEventId: `${freshRun.runnerInstanceId}:fresh-terminal`,
sourceInstanceId: freshRun.runnerInstanceId!,
runId: freshRunId,
normalizedSessionId: freshIdentity.sessionId,
turnId: freshTurnId,
};
},
async startTurn(input) {
freshStartTurn(input);
const delivered = JSON.parse(input.message.text);
expect(delivered.task.prompt).toContain(
"Verify the newest user instruction",
);
const currentContract =
delivered.completionContract as typeof contract;
freshResult.completionClaim.contractRevision =
currentContract.revision;
freshResult.completionClaim.criteria = currentContract.criteria.map(
(criterion) => ({
...freshResult.completionClaim.criteria[0]!,
criterionId: criterion.id,
}),
);
freshResult.summary =
"Completed the separately delivered newer instruction.";
releaseStart();
return { turnId: freshTurnId };
},
async result() {
return { result: freshResult, terminal, turnId: freshTurnId };
},
async snapshot() {
return {
...checkpoint,
identity: freshIdentity,
providerSessionId: `fresh:${freshRunId}`,
activeTurnId: null,
cursor: "0",
};
},
async close() {},
};
return {
async descriptor() {
return {
...(await backend.descriptor()),
runtimeContextCapabilities: {
instructions: "native",
skills: "native",
mcp: "native",
},
};
},
async openSession(input) {
expect(input.identity).toEqual(freshIdentity);
return session;
},
async recoverSession() {
throw new Error(
"newer user cause must not reuse the prior active turn",
);
},
};
};
const backendFactory = vi.fn((input: NativeExecutionInput) => {
factoryInputs.push(structuredClone(input));
return input.binding.runId === runId ? backend : freshBackend(input);
});
const heartbeat = heartbeatService(db, {
runtimeEnv: { PAPERCLIP_INSTANCE_ID: "phase6-recovery-test" },
nativeSessionBackendFactory: backendFactory,
});
if (newerRequest) {
// Restore the independently admitted receipt that was deferred while the
// original provider was running, before this simulated process loss.
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
status: "deferred_issue_execution",
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
idempotencyKey: newerWakeKey,
requestedByActorType: "user",
requestedByActorId: "responsible-user",
payload: {
issueId,
commentId: newerCommentId,
_paperclipWakeContext: {
issueId,
taskId: issueId,
commentId: newerCommentId,
wakeCommentIds: [newerCommentId],
skipIssueComment: true,
},
},
});
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.idempotencyKey, newerWakeKey));
expect(receipt.status).toBe("deferred_issue_execution");
}
await expect(
heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 }),
).resolves.not.toContain(runId);
await heartbeat.drainActiveRunExecutions();
expect(backendFactory).not.toHaveBeenCalled();
expect(recoverSession).not.toHaveBeenCalled();
expect(() => process.kill(staleProviderProcess!.pid!, 0)).not.toThrow();
await expect(
db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)),
).resolves.toEqual([
expect.objectContaining({
id: runId,
status: "running",
processPid: staleProviderProcess.pid,
errorCode: "native_execution_ownership_unverified",
}),
]);
await expect(
db
.select()
.from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, runId)),
).resolves.toEqual([
expect.objectContaining({
phase: "retryable_failure",
attempt: 1,
leaseOwner: null,
}),
]);
const unrelatedProcessExit = once(staleProviderProcess, "exit");
staleProviderProcess.kill("SIGKILL");
await unrelatedProcessExit;
await db
.update(nativeRunFinalizations)
.set({
leaseOwner: null,
leaseExpiresAt: null,
})
.where(eq(nativeRunFinalizations.runId, runId));
await expect(
heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 }),
).resolves.not.toContain(runId);
await heartbeat.drainActiveRunExecutions();
if (newerRequest) {
await drainHeartbeatRunsToQuiescence(db, heartbeat);
const original = factoryInputs.find(
(input) => input.binding.runId === runId,
);
expect(original?.completionContract).toEqual(
execution.completionContract,
);
expect(original?.task.prompt).toBe(execution.task.title);
expect(startTurn).not.toHaveBeenCalled();
const [accepted] = await db
.select()
.from(nativeRunResults)
.where(eq(nativeRunResults.runId, runId));
expect(accepted?.completionContractId).toBe(contractId);
const [receipt] = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.idempotencyKey, newerWakeKey));
expect(receipt.payload).toMatchObject({ commentId: newerCommentId });
expect(receipt.status).toBe("completed");
expect(receipt.runId).not.toBe(runId);
expect(freshStartTurn).toHaveBeenCalledOnce();
const [freshRun] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, receipt.runId!));
expect(freshRun).toMatchObject({
status: "succeeded",
nativePhase: "committed",
});
expect(
await db
.select()
.from(nativeRunResults)
.where(eq(nativeRunResults.runId, receipt.runId!)),
).toHaveLength(1);
expect(
factoryInputs.filter((input) => input.binding.runId === runId),
).toHaveLength(1);
await heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 });
await drainHeartbeatRunsToQuiescence(db, heartbeat);
expect(freshStartTurn).toHaveBeenCalledOnce();
expect(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, companyId)),
).toHaveLength(2);
return;
}
const recoveryState = {
run: await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId)),
coordinator: await db
.select()
.from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, runId)),
};
expect(
backendFactory.mock.calls.length,
JSON.stringify(recoveryState),
).toBe(1);
expect(recoverSession).toHaveBeenCalledOnce();
expect(openSession).not.toHaveBeenCalled();
expect(startTurn).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledOnce();
expect(legacyAdapterExecute).not.toHaveBeenCalled();
await expect(
db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, companyId)),
).resolves.toEqual([
expect.objectContaining({
id: runId,
runtimeMode: "native",
status: "succeeded",
nativePhase: "committed",
processPid: null,
processGroupId: null,
processStartedAt: null,
}),
]);
await expect(
db
.select()
.from(nativeRunResults)
.where(eq(nativeRunResults.runId, runId)),
).resolves.toHaveLength(1);
await expect(
db
.select()
.from(workAssessments)
.where(eq(workAssessments.runId, runId)),
).resolves.toHaveLength(1);
const decisions = await db
.select()
.from(statusDecisions)
.where(eq(statusDecisions.issueId, issueId));
expect(decisions).toEqual([
expect.objectContaining({
reasonCode: "completion_contract_satisfied",
toStatus: "done",
applicationState: "applied",
}),
]);
const effects = await db
.select()
.from(statusDecisionEffects)
.where(eq(statusDecisionEffects.issueId, issueId));
expect(new Set(effects.map((effect) => effect.decisionId))).toEqual(
new Set([decisions[0]!.id]),
);
expect(effects.map((effect) => effect.effectKind).sort()).toEqual([
"issue_status_projection",
"release_checkout",
]);
await expect(
db.select().from(issues).where(eq(issues.id, issueId)),
).resolves.toEqual([
expect.objectContaining({
status: "done",
statusVersion: 1,
lastStatusDecisionId: decisions[0]!.id,
executionWorkspaceId: newerExecutionWorkspaceId,
}),
]);
await expect(
db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.companyId, companyId)),
).resolves.toHaveLength(2);
await expect(
db
.select()
.from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, runId)),
).resolves.toEqual([
expect.objectContaining({
phase: "committed",
resultId: expect.any(String),
assessmentId: expect.any(String),
decisionId: decisions[0]!.id,
}),
]);
await expect(
db
.select()
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId)),
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ eventType: "turn.completed" }),
expect.objectContaining({ eventType: "run.result.accepted" }),
expect.objectContaining({ eventType: "run.terminal" }),
]),
);
await expect(
db.select().from(activityLog).where(eq(activityLog.entityId, issueId)),
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ action: "issue.updated" }),
]),
);
await heartbeat.reapOrphanedRuns({ staleThresholdMs: 0 });
await heartbeat.drainActiveRunExecutions();
expect(backendFactory).toHaveBeenCalledOnce();
await expect(
db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, companyId)),
).resolves.toHaveLength(1);
await expect(
db
.select()
.from(nativeRunResults)
.where(eq(nativeRunResults.runId, runId)),
).resolves.toHaveLength(1);
await expect(
db
.select()
.from(workAssessments)
.where(eq(workAssessments.runId, runId)),
).resolves.toHaveLength(1);
await expect(
db
.select()
.from(statusDecisions)
.where(eq(statusDecisions.issueId, issueId)),
).resolves.toHaveLength(1);
// The persisted Paperclip Runner run above remains recoverable while the
// flag is off. Switching the agent back to a direct adapter now proves a
// fresh run ignores the stale native profile and stays on the legacy path.
await db
.update(agents)
.set({ adapterType: "codex_local" })
.where(eq(agents.id, agentId));
await db.insert(issues).values({
id: freshIssueId,
companyId,
projectId,
projectWorkspaceId,
issueNumber: 2,
identifier: "NRR-2",
title: "Start only after the native kill switch is off",
status: "in_progress",
assigneeAgentId: agentId,
workMode: "standard",
});
const directExecute = legacyAdapterExecute.getMockImplementation()!;
legacyAdapterExecute.mockImplementationOnce(async () => {
await db.update(issues).set({ status: "done" }).where(eq(issues.id, freshIssueId));
return directExecute();
});
const fresh = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId: freshIssueId },
contextSnapshot: {
issueId: freshIssueId,
taskId: freshIssueId,
skipIssueComment: true,
},
});
expect(fresh).not.toBeNull();
await drainHeartbeatRunsToQuiescence(db, heartbeat);
expect(legacyAdapterExecute).toHaveBeenCalledOnce();
await expect(
db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fresh!.id)),
).resolves.toEqual([
expect.objectContaining({
agentId,
runtimeMode: "legacy",
runtimeModeReason: "direct_adapter",
status: "succeeded",
}),
]);
await expect(
db
.select()
.from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, fresh!.id)),
).resolves.toHaveLength(0);
await expect(
db
.select()
.from(nativeRunResults)
.where(eq(nativeRunResults.runId, fresh!.id)),
).resolves.toHaveLength(0);
await expect(
db
.select({ runtimeConfig: agents.runtimeConfig })
.from(agents)
.where(eq(agents.id, agentId)),
).resolves.toEqual([
expect.objectContaining({
runtimeConfig: expect.objectContaining({
nativeRunner: {
mode: "native",
backend: "codex_app_server",
protocolVersion: 1,
},
}),
}),
]);
}, 30_000);
},
);
describe("goal-less native task-session publication", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
if (external) db = createDb(external);
else {
database = await startEmbeddedPostgresTestDatabase("paperclip-native-pointer-");
db = createDb(database.connectionString);
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
await closeRegisteredClients(process.env.PAPERCLIP_TEST_DATABASE_URL ?? database!.connectionString, { scope: "database" });
await database?.cleanup();
});
it.each(["retained", "reset", "fresh", "migrated"] as const)("publishes the completed task session before provider cleanup and resumes an ordinary comment (%s)", async mode => {
const home = createIsolatedTestHome();
const previousHome = process.env.PAPERCLIP_HOME;
process.env.PAPERCLIP_HOME = home.home;
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const issuePrefix = `NPT${companyId.slice(0, 6).toUpperCase()}`;
const projectId = randomUUID(), projectWorkspaceId = randomUUID(), workProductId = randomUUID();
let providerSessionId: string | undefined;
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
const evidenceRef = `work_product:${workProductId}`;
const inputs: NativeExecutionInput[] = [];
const configs: Record<string, unknown>[] = [];
const buildMetadata = heartbeatWorkspaces.buildEffectiveRunSessionConfigMetadata;
const configuration = vi.spyOn(heartbeatWorkspaces, "buildEffectiveRunSessionConfigMetadata").mockImplementation(input => {
configs.push(structuredClone(input.effectiveAdapterConfig));
return buildMetadata(input);
});
const openSession = vi.fn(), recoverSession = vi.fn(), startTurn = vi.fn();
let releaseClose!: () => void, notifyClose!: () => void;
const closeHeld = new Promise<void>(resolve => { releaseClose = resolve; });
const closeEntered = new Promise<void>(resolve => { notifyClose = resolve; });
let firstRunId: string | undefined;
const backendFactory = (execution: NativeExecutionInput): NativeSessionBackend => {
inputs.push(structuredClone(execution));
firstRunId ??= execution.binding.runId;
const currentRunId = execution.binding.runId;
const currentProviderSessionId = `provider-${execution.session.normalizedSessionId}`;
providerSessionId ??= currentProviderSessionId;
const turnId = `turn-${currentRunId}`;
const identity = { companyId, agentId, issueId, runId: currentRunId, sessionId: execution.session.normalizedSessionId! };
const result = structuredClone(CONTROL_PLANE_CONFORMANCE_RESULT);
result.completionClaim.contractRevision = execution.completionContract.contract.revision;
result.completionClaim.criteria = execution.completionContract.contract.criteria.map(criterion => ({
...result.completionClaim.criteria[0]!, criterionId: criterion.id, evidenceRefs: [evidenceRef],
}));
result.evidence = [{ kind: "work_product", ref: evidenceRef }];
result.verification[0]!.artifactRef = evidenceRef;
let started!: () => void;
const turnStarted = new Promise<void>(resolve => { started = resolve; });
const session: NativeSession = {
identity: () => identity,
async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; },
async *events() {
await turnStarted;
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, currentRunId));
yield { schema: "paperclip.prp.event.v1", sourceEventId: `${currentRunId}:terminal`, sourceSeq: 1,
sourceInstanceId: run.runnerInstanceId!, sourceKind: "runner", runId: currentRunId,
normalizedSessionId: identity.sessionId, turnId, eventType: "turn.completed", schemaVersion: 1,
priority: 0, emittedAt: new Date().toISOString(), payload: {} } satisfies PrpEvent;
},
async startTurn(input) { startTurn(input); started(); return { turnId }; },
async result() { await turnStarted; return { result, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, turnId }; },
async snapshot() { return { backendKind: "mock", sessionId: "driver-retained", identity, providerSessionId: currentProviderSessionId,
activeTurnId: null, pendingRuntimeRequests: [], lineage: [], cursor: "1" }; },
async close() { if (currentRunId === firstRunId) { notifyClose(); await closeHeld; } },
};
return {
async descriptor() { return { kind: "mock", name: "task-pointer-backend", version: "1",
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } }; },
async openSession(input) { openSession(input); return session; },
async recoverSession(snapshot) { recoverSession(snapshot); expect(snapshot.providerSessionId).toBe(currentProviderSessionId);
return { recovered: true, session }; },
};
};
const heartbeat = heartbeatService(db, { nativeSessionBackendFactory: backendFactory });
try {
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true, enableIsolatedWorkspaces: true });
await db.insert(authUsers).values({ id: "pointer-user", name: "Pointer user", email: "pointer@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await db.insert(companies).values({ id: companyId, name: "Native pointer", issuePrefix, status: "active", defaultResponsibleUserId: "pointer-user" });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "pointer-user", status: "active", membershipRole: "member" });
await db.insert(projects).values({ id: projectId, companyId, name: "Pointer project", status: "active" });
await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId, projectId, name: "Pointer workspace", cwd: repoRoot, isPrimary: true });
await db.insert(agents).values({ id: agentId, companyId, name: "Pointer agent", adapterType: "paperclip_runner", status: "active",
// This scenario is per-turn publication: each run closes its session.
// An unset lifecycle now resolves to warm for local Codex.
adapterConfig: { provider: "codex", lifecycleMode: "per_turn", workspaceStrategy: { type: "project_primary" } },
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } });
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId, issueNumber: 1,
identifier: `${issuePrefix}-1`, title: "Deliver the saved artifact", status: "in_progress", assigneeAgentId: agentId, workMode: "standard",
// Declare the shared mode up front, as the normal profile does. The
// first workspace otherwise materializes this setting and legitimately
// changes the effective adapter strategy before the next admission.
executionWorkspaceSettings: { mode: "shared_workspace" } });
await db.insert(issueWorkProducts).values({ id: workProductId, companyId, issueId, type: "artifact", provider: "paperclip",
title: "Saved artifact", status: "ready_for_review", reviewState: "approved" });
const first = await heartbeat.wakeup(agentId, { source: "automation", triggerDetail: "system", reason: "issue_commented",
requestedByActorType: "user", requestedByActorId: "pointer-user", payload: { issueId }, contextSnapshot: { issueId, taskId: issueId, skipIssueComment: true } });
expect(first).not.toBeNull();
await closeEntered;
const [persisted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, first!.id));
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, first!.id))).toHaveLength(1);
expect(persisted.runnerProfileJson?.sessionCheckpoint).toMatchObject({ identity: { sessionId: persisted.nativeSessionId },
providerSessionId, semanticResult: expect.any(Object), terminal: { runTerminalState: "succeeded" } });
const pointer = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.agentId, agentId));
expect(pointer).toEqual([expect.objectContaining({ lastRunId: first!.id,
sessionParamsJson: expect.objectContaining({ sessionId: persisted.nativeSessionId }), sessionDisplayId: providerSessionId })]);
if (mode === "reset") {
expect(await heartbeat.resetRuntimeSession(agentId, { taskKey: issueId })).toMatchObject({ clearedTaskSessions: 1 });
expect(await heartbeat.listTaskSessions(agentId)).toEqual([]);
}
releaseClose();
await heartbeat.drainActiveRunExecutions();
if (mode === "reset") expect(await heartbeat.listTaskSessions(agentId)).toEqual([]);
if (mode === "migrated") {
// Migration 0443 clears the stored per_turn creation default between
// turns. The saved task session and its provider history must survive.
const migration = await readFile(new URL("../../../packages/db/src/migrations/0443_native_warm_follow_up_default.sql", import.meta.url), "utf8");
await db.execute(sql.raw(migration));
const [migratedAgent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(migratedAgent.adapterConfig).not.toHaveProperty("lifecycleMode");
const audit = (await db.select().from(activityLog).where(eq(activityLog.entityId, agentId)))
.filter(row => row.actorId === "migration:native-warm-follow-up-default");
expect(audit).toEqual([expect.objectContaining({ action: "agent.updated" })]);
}
// The normal comment route reopens a completed task before its user wake.
// Use those public services; a raw comment on a still-done task is stale.
const issuesSvc = issueService(db);
await issuesSvc.update(issueId, { status: "todo", actorUserId: "pointer-user" });
const comment = await issuesSvc.addComment(issueId, "Continue using the saved artifact.", { userId: "pointer-user" });
const second = await heartbeat.wakeup(agentId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment",
requestedByActorType: "user", requestedByActorId: "pointer-user", payload: { issueId, commentId: comment.id, mutation: "comment", reopenedFrom: "done" },
contextSnapshot: { issueId, taskId: issueId, wakeCommentId: comment.id, reopenedFrom: "done", skipIssueComment: true,
...(mode === "fresh" ? { forceFreshSession: true } : {}) } });
expect(second).not.toBeNull();
await heartbeat.drainActiveRunExecutions();
const after = await heartbeat.getRun(second!.id);
expect(after?.status).toBe("succeeded");
expect(inputs).toHaveLength(2);
if (mode === "retained" || mode === "migrated") {
const changedKeys = [...new Set(configs.flatMap(config => Object.keys(config)))].filter(key =>
JSON.stringify(configs[0]?.[key]) !== JSON.stringify(configs.at(-1)?.[key]));
expect(changedKeys).toEqual(mode === "migrated" ? ["lifecycleMode"] : []);
expect(after).toMatchObject({ nativeSessionId: persisted.nativeSessionId, sessionIdAfter: providerSessionId });
expect(openSession).toHaveBeenCalledOnce();
expect(recoverSession).toHaveBeenCalledOnce();
} else {
expect(after?.nativeSessionId).not.toBe(persisted.nativeSessionId);
expect(after?.sessionIdAfter).not.toBe(providerSessionId);
expect(openSession).toHaveBeenCalledTimes(2);
expect(recoverSession).not.toHaveBeenCalled();
}
expect(startTurn).toHaveBeenCalledTimes(2);
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, second!.id))).toHaveLength(1);
if (mode === "fresh") {
expect(await heartbeat.listTaskSessions(agentId)).toEqual([expect.objectContaining({
lastRunId: second!.id, sessionParamsJson: expect.objectContaining({ sessionId: after!.nativeSessionId }),
sessionDisplayId: after!.sessionIdAfter,
})]);
await issuesSvc.update(issueId, { status: "todo", actorUserId: "pointer-user" });
const followup = await issuesSvc.addComment(issueId, "Continue the fresh session's saved work.", { userId: "pointer-user" });
const third = await heartbeat.wakeup(agentId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment",
requestedByActorType: "user", requestedByActorId: "pointer-user",
payload: { issueId, commentId: followup.id, mutation: "comment", reopenedFrom: "done" },
contextSnapshot: { issueId, taskId: issueId, wakeCommentId: followup.id, reopenedFrom: "done", skipIssueComment: true } });
expect(third).not.toBeNull();
await heartbeat.drainActiveRunExecutions();
expect(await heartbeat.getRun(third!.id)).toMatchObject({ status: "succeeded",
nativeSessionId: after!.nativeSessionId, sessionIdAfter: after!.sessionIdAfter });
expect(inputs).toHaveLength(3);
expect(openSession).toHaveBeenCalledTimes(2);
expect(recoverSession).toHaveBeenCalledOnce();
expect(startTurn).toHaveBeenCalledTimes(3);
expect(await db.select().from(nativeRunResults).where(inArray(nativeRunResults.runId, [first!.id, second!.id, third!.id]))).toHaveLength(3);
}
} finally {
releaseClose();
try {
await heartbeat.drainActiveRunExecutions();
} finally {
configuration.mockRestore();
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
home.cleanup();
}
}
}, 30_000);
});
enableRunFailureReporting();