mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs keep a finalization row. Its lease owner tells the
control plane that an execution may still be live.
> - Pausing an agent waits for `agentExecutionsHaveStopped`. That check
refuses to finish while any run of the agent has a lease owner, unless
the run was cancelled and has stop proof.
> - A same-run session resume claims that lease before it dispatches the
run. If the run then fails before the native executor takes its own
provider claim, nothing releases the resume owner.
> - The leaked owner blocks pause and resume for good, and the task
keeps looking like it has a live recovery path.
> - This pull request settles those rows at the source, and it adds a
stop-proof-gated release for rows that already leaked.
> - The benefit is that agents can pause and resume after an ordinary
resume failure, without database edits, and the cancelled-run stop-proof
rule stays as it is.
## Linked Issues or Issue Description
**What happened?**
An agent stayed in `pausing` with `hostComplete=false` and climbing
attempts. Its runs had `native_run_finalizations` rows with `phase:
observed`, `recovery_state: resuming_session`, a `lease_owner` like
`default:resume:<uuid>`, and no `result_id`. Each heartbeat run had
ended `failed`: either `setup_failed` (`Failed to acquire lease for
environment … Sandbox creation has failed`,
`native_remote_recovery_lease_mismatch`) or `adapter_failed`
(`runner_state_identity_mismatch: …`). The lease expired about 20
minutes after the run finished. Nothing settled the row. The only fix
was to set `lease_owner` to null by hand.
**Expected behavior**
When a resumed run fails before any provider turn starts, its
finalization row goes to `terminal_failure` and its lease owner is
released, as the native executor does for failures it sees. A row that
leaked before this fix is released in the background once its lease
expires and the run has stop proof. Pause then completes.
**Steps to reproduce**
1. Leave a native run in `retryable_failure` with a persisted
checkpoint, on a task that already has an active recovery incident.
2. Let session resume claim it (`claimNativeSessionResumptions` sets
`phase: observed` and a `…:resume:…` owner).
3. Make the resumed run fail before the executor claims it, for example
when the environment driver cannot create the sandbox.
4. The heartbeat failure path marks the run `failed`. The issue release
path does not settle the row, because it settles only when it opens a
new incident. The owner stays set, and `agentExecutionsHaveStopped`
returns false indefinitely.
**Paperclip version or commit**
`master` at 5ac05871b5.
**Additional context**
Root cause: two writers can end a resumed run without the executor.
`failRun` and the setup-failure path in `heartbeat/run-completion.ts`
never touch the coordinator. `recordNativeTerminalRecoveryIfNeeded` in
the wake-queue adapter settles the coordinator only when no recovery
incident exists yet. Agents that need their computer connection paired
again usually already have an incident, so the owner leaks.
Related, but different: #18905, #18866, #18072, and #17692 change how
the stop check judges runs. This pull request does not change the stop
check.
## What Changed
-
`server/src/services/native-runtime/native-failed-run-owner-release.ts`
(new): one settlement for a result-less `observed` owner on a run that
ended `failed`, `timed_out`, or `interrupted`. It writes
`terminal_failure`, null lease fields, `recoveryState: "blocked"`, the
run's failure code, and a `releasedOwner` record. It sets `nativePhase`,
sends the same finalization and execution work notifications as the
executor's terminal path, and logs `native.failed_run_owner_released`
activity. Locks follow the issue, coordinator, run order.
- `settleUnstartedNativeSessionResume`: runs after a dispatched session
resume returns. It matches only the exact claim owner and the attempt
the claim saw. The executor increments the attempt when it takes
provider ownership, so an unchanged attempt proves that no provider turn
started under this owner. The executor's own failures no longer match.
- `releaseAbandonedNativeOwners`: the background release for rows that
already leaked. It requires an expired lease and all of the stop proof
that `agentExecutionsHaveStopped` checks: settled environment leases, no
live run process or process group, no live controller lease, and no live
controller process. This process answers from its in-memory execution
registries. It skips cancelled runs, pending native Stops, and pinned
workspace restores, because those have their own stop-proof owners.
- `native-finalization-reconciler.ts`: `NativeSessionResumeClaim` now
carries `attempt`.
- `heartbeat.ts` and `heartbeat/recovery.ts`: both session-resume
dispatch sites settle the claim after `executeRun` returns. This is one
`.finally` per site.
- `execution-control-reconciliation.ts` and `execution-control-work.ts`:
the execution-finalization queue runs the background release. Its
deadline includes the earliest lease expiry among eligible rows, so the
release does not poll an idle instance. Rows with unreleased sandboxes
are excluded from the deadline.
- `agent-execution-stop.ts` is unchanged. The cancelled-only release
keeps its stop-proof rule.
## Verification
- `npx vitest run
src/__tests__/heartbeat-native-resume-lease-failure.test.ts` drives the
real heartbeat resume dispatch (`reapOrphanedRuns`). A sandbox driver's
`acquireRunLease` throws `Sandbox creation has failed`, and the task has
a recovery incident. The run fails with `Failed to acquire lease for
environment "Managed computer" (sandbox): Sandbox creation has failed`,
and the coordinator ends `terminal_failure` with no lease owner. I
removed the `.finally` wiring and ran the test again. It then failed,
and the row stayed `observed` with a `default:resume:` owner and a
20-minute expiry, which is the production state.
- `npx vitest run
src/services/native-runtime/native-failed-run-owner-release.test.ts` (10
tests):
- A claimed resume that fails at lease acquisition is settled.
`agentExecutionsHaveStopped` changes from false to true. A repeated
settlement does nothing.
- An owner the executor claimed (incremented attempt) and a run that is
still running are not changed.
- A leaked `runner_state_identity_mismatch` row is released by
`reconcileAbandonedExecutionControl`, and then
`agentExecutionsHaveStopped` returns true.
- An unexpired owner still blocks the stop check. Its expiry becomes the
finalization queue deadline.
- An active environment lease and a live controller process block
release. After both stop, the row is released.
- Cancelled runs and pending native Stops are not changed.
- Added during review:
- Releasing an owner wakes the cost-accounting worker.
- Releasing a run's environment lease wakes the finalization worker, and
the owner is then released.
- A recorded PID and group that now belong to an unrelated process do
not hold the owner.
- Bounded passes rotate past a held row (`[0, 1, 1]` with page size 1),
then wrap back to it.
- The immediate settle keeps the owner while the run's recorded process
is alive.
- Suites that cover the changed files pass together: the two new files,
`native-session-resumption.test.ts` (with the claim shape updated),
`execution-control-reconciliation.test.ts`,
`execution-control-work.test.ts`, and
`agent-lifecycle/__tests__/lifecycle.test.ts`. With
`environment-cleanup-work.test.ts` added, that is 7 files and 156 tests.
I did not run the full `heartbeat-process-recovery.test.ts` suite
locally. CI runs it.
- `pnpm --filter @paperclipai/server typecheck` passes.
## Risks
- Behaviour change: a resumed run that fails during setup now ends with
a terminal coordinator in every case. Before, this happened only when
the issue release path opened a new incident. After a restart, restart
recovery no longer sees such a row as `observed`. This matches what the
release path already does when no incident exists.
- The background release trusts an expired lease on a run that is
already terminal, together with the same process and lease evidence that
the stop check uses. A controller on another host cannot be probed by
PID. The stop check has the same limit today. A live owner still renews
its lease, and lease-gated writes from an expired owner are already
refused.
- If an expired row's process or controller still looks live, the
release retries at the shared 15-second finalization cadence until that
process exits. Each pass handles one bounded page, and a rotating cursor
moves to the next page on each pass.
- A run-scoped environment cleanup notification now also wakes the
execution-finalization queue. Passes are coalesced, and a pass is a
cheap deadline query when nothing is eligible.
- No schema change and no migration.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use
(shell, file edits, local test runs).
## 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
- [x] All Paperclip CI gates are green
- [x] 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>
1707 lines
66 KiB
TypeScript
1707 lines
66 KiB
TypeScript
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 } 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"] 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",
|
|
adapterConfig: { provider: "codex", 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([]);
|
|
// 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") {
|
|
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([]);
|
|
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();
|