mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native tasks save a provider session for later comments and monitor checks. > - The goal projection also records the run that owns the session event sequence. > - A fresh retry uses a new native session, but the projection accepted only the old session. > - The accepted checkpoint then rejected session publication because the goal source still named the old run. > - This pull request lets the current task executor advance that source after a terminal predecessor with no unfinished goal. > - Later turns can then use the fresh retry's saved session. ## Linked Issues or Issue Description Related: #12669, #18343. I searched open and closed PRs for source epochs and native task-session publication. I found no duplicate repair. **What happened?** A successful fresh native retry could retain its predecessor's task-session pointer. A later comment or monitor selected the old provider session. The goal source transition required both runs to have the same native session. The terminal checkpoint then saved a `suppressed_newer` publication result. **Expected behavior** The current task executor can publish its accepted fresh session. A later turn resumes that session. Delayed events cannot restore the old source or revive a cleared goal. **Steps to reproduce** 1. Complete a native turn and retain its task-session row. 2. Admit a fresh session on the same task without deleting the row. 3. Complete that turn, then send an ordinary follow-up. 4. Before this change, the saved pointer can still name the first session. **Paperclip version or commit** Reproduced on source commit `36df8933f0` with a PostgreSQL regression before the production change. **Deployment mode** Source checkout. The affected producer runs for local and remote native sessions. ## What Changed - Permit a different native session to advance the goal source only when its run holds the task execution lock and assignee, and the task is in progress. - Keep the terminal predecessor, native runtime, runner identity, absent or completed goal, source sequence, and conversation reset checks. - Test accepted session publication, a fresh heartbeat followed by an ordinary resumed turn, stale events, current-owner loss, and unfinished or unreadable goals. - Add an opt-in live Codex file-persistence check. Update execution documentation and both recovery maps. ## Verification - Red: the new PostgreSQL publication regression failed before the producer change. The saved session, display ID and goal source still named the predecessor. - Green: 72 distinct tests passed across the final focused runs of `runner-goals.test.ts`, `native-task-session-publication.test.ts`, `native-session-resumption.test.ts`, and `remote-warm-session-recovery.test.ts`. - The assembled heartbeat test admits three turns, opens two provider sessions, resumes the second session once, and stores three results. It uses a deterministic provider backend. - `node scripts/check-maps.mjs` and `git diff --check` passed. - Real local Codex acceptance passed on macOS arm64 with `gpt-6.1-sol` and explicit warm mode. Three heartbeat turns saved three exact file lines. The fresh second turn published its new normalized and provider session IDs. The ordinary third turn retained both IDs. The fixture closed its warm owners and removed its private credential copy and workspace. The production code was at `450e15e`; the final test-only commit selects explicit warm mode and uses the existing owned-home cleanup helper. The production change is identical in the final head. - The earlier per-turn fixture preserved all three file steps and selected the correct normalized session, but provider-thread reuse failed. `run.attach` reported that the Codex provider had pending events; the safe fallback used another provider thread. This remains a separate failure. The first fixture cleanup also failed on immutable instruction assets. The owned-home helper fixed cleanup, and the second per-turn attempt exposed the provider identity assertion directly. - Full current-head CI passes, including typecheck, build, general and serialized tests, Rust/Runner checks, all eight E2E shards, and the `ci / e2e` and `ci / verify` aggregate gates. Greptile is 5/5 on the current commit, with no unresolved review threads. Broad local workspace checks were not duplicated; the focused regressions and actual provider acceptance above were run locally. No merge, deployment, or recovery of an original task is included. ## Risks - Source epoch changes affect saved goal state. The fresh-session path requires current task authority and refuses unfinished or unreadable saved goals. - The change starts no provider work and changes no prior-owner recovery guard. Unknown external outcomes retain their reconciliation hold. - A prior session remains held when it lacks the required authority or state proof. - Per-turn provider-thread reuse remains an open coverage gap described above. This change does not alter that attachment guard. ## Model Used OpenAI Codex, GPT-6 family. The exact model ID and context window are unavailable in this session. The agent used code inspection, tools, and executable tests. ## 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 #` 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1704 lines
66 KiB
TypeScript
1704 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:"),
|
|
},
|
|
]);
|
|
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 }> = [];
|
|
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:") }]);
|
|
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:") },
|
|
]);
|
|
});
|
|
|
|
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,
|
|
workMode: "standard",
|
|
},
|
|
workspace: {
|
|
cwd: repoRoot,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
branchName: null,
|
|
},
|
|
session: {
|
|
normalizedSessionId: sessionId,
|
|
driverKind: "codex_app_server",
|
|
protocolVersion: 1,
|
|
},
|
|
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();
|