mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native Runner keeps one ACPX provider conversation across the per-turn runs of a task. Each run cold-attaches the session that the previous run settled. > - The saved ACPX state binds the digest of the launch profile that wrote it. Every deploy ships a new provider pack, so that digest changes. > - After a deploy, the next run of such a task fails `run.attach` with `ACPX durable launch profile digest does not match runner startup`. Every same-run retry fails the same way. > - On one instance this happened seven times. Six tasks used their retry budget and ended blocked, or in progress with no runner. In the seventh, the scheduled same-run resume never ran at all: the task stayed `in_progress` with nothing running, and its monitor did not fire. > - This pull request re-binds a settled session to the upgraded launch profile, types the remaining mismatches so the server stops retrying them, and makes a scheduled remote resume survive the stale-lock sweep. > - The benefit is that a task continues its conversation after a deploy. When it cannot, it stops with a clear next action instead of stalling. ## Linked Issues or Issue Description Refs #18846, #18847, #18854 and #18860 (nearby native recovery changes). Refs #15795, an open change to the same stale-lock sweep function. It changes run teardown, not lock cleanup, so a second merge needs at most a small rebase. **What happened?** A per-turn native task used provider `acpx` with Claude, driver `acpx_runtime`, on a remote sandbox. 1. Run A succeeded and settled its provider session. The next run with the same build cold-attached it. 2. The instance deployed a new build. 3. A board approval woke run B in a fresh sandbox. Run B failed after about five minutes: `native_session_recovery_failed: Error: PRP command run.attach failed: {... "result":{"code":"command_execution_failed","message":"ACPX durable launch profile digest does not match runner startup"}}`. 4. The coordinator recorded `retryable_failure`, attempt 1, `native_session_interrupted`, recovery mode `exact_checkpoint_resume`, recovery state `resuming_session`, and a retry 30 seconds later. 5. Two seconds after the failure, the stale-lock sweep cleared the task's execution and checkout locks, because run B was `failed`. 6. Forty minutes later the attempt was still 1. The scheduled monitor had not fired. The task stayed `in_progress` with nothing running. **Expected behavior** A session that the previous run settled continues after a deploy. If it cannot continue, the run stops at once with a clear next action. A scheduled resume either runs or fails visibly. **Steps to reproduce** 1. Save a suspended ACPX session under one launch profile. 2. Change the sidecar bytes and the provider-pack authority at the same paths. 3. Cold-attach the next run in a new runner. Restore fails with the digest mismatch. 4. For the stall: leave a remote native run `failed` with a due `retryable_failure` coordinator, run the stale-lock sweep, then run restart recovery for that run. It returns `blocked: issue_execution_lock_changed` and writes nothing. The new Rust and PostgreSQL tests run these steps. **Paperclip version or commit** `master` at `b0c4e345267`. **Deployment mode** Native Runner, per-turn lifecycle, remote sandbox. The re-bind applies to every ACPX agent with an ordinary startup. ## What Changed ### Root causes - **Upgrade failure.** `AcpxLaunchProfile::canonical_digest` hashes the provider-pack authority, the sidecar command and arguments, each artifact path and SHA-256, and any candidate admission. For a remote sandbox the authority is the provider-pack manifest digest, which covers the Runner source revision and dist digest. So every deploy changes it. `AcpxDurableState` stores only the digest, and `validate()` rejects any difference. Only a Kimi candidate run binding had a carve-out. - **Silent stall.** The failure write keeps the task locks for `retryable_failure` and clears them only for `terminal_failure`. The stale-lock sweep treats any `failed` run as cleanable, so it cleared them. A remote retry goes through `claimNativeRestartRecoveries`. That claim requires `issues.execution_run_id` to name the run, so it returned `blocked: issue_execution_lock_changed` with no coordinator write and no deadline. The live recovery queue then dropped the entry. The local claim (`claimNativeSessionResumptions`) already reclaims a free lock, so only remote runs stalled. - **Monitor.** `noActiveNativeMonitorRun` holds a task's monitor while any native run for it has a coordinator that is not `committed` or `terminal_failure`. Run B's `retryable_failure` held it. That is by design; it ends when the resume runs. ### Runner (Rust) - `rebinds_settled_session` in `acpx_provider_backend.rs`. Restore re-binds a saved session to the current launch profile only when all of these are true: - The lifecycle is `suspended` and an identity exists. There is no active turn, pending runtime request, undelivered provider event, or unconfirmed provider exit. - The startup has no candidate admission. - The saved descriptor's sidecar command and arguments equal this startup's. - Any other digest mismatch keeps failing with the same message, plus `classification: "provider_state_incompatible"` in the durable command result. `DurableRunnerError` carries an optional closed classification for this. - A saved descriptor that names an agent release this build does not qualify (ACPX version, agent server package or version, or command digest) also gets the class. `validate_session()` and its message are unchanged; restore only adds the class. ### Runner (TypeScript) - `runnerRecoveryFailureClass` reads the classification from the authenticated `runnerCommandResult`. The Codex app-server driver (which also drives ACPX through runnerd) and `HarnessDriverBackend` pass it on as `failureClass`. - If replacement after a failed resume is not allowed, `executeNativeSession` throws `NativeProviderStateIncompatibleError` (message unchanged: `native_session_recovery_failed: ...`). If replacement is allowed, the existing path opens a fresh session in the same run, as before. ### Server - `nativeSessionFailureSourceCode` maps that error class to `native_provider_state_incompatible` with `instanceof`, not text. The disposition is permanent: `terminal_failure` at once, the task blocks, and the board owns the recovery action. Its next action says that a Retry continues in a fresh provider session with the same workspace. - `findNativeSessionResumeRun` also returns the barrier's `errorCode`. `rebindNativeSessionCheckpoint` does not resume a source with `native_provider_state_incompatible`. The existing fallback in `buildNativeExecutionWithCheckpoint` then starts a fresh normalized session with the full task bootstrap. - `sweepStaleIssueLocks` keeps both locks while a referenced run's coordinator owns a result-less `retryable_failure`, unless the task is done or cancelled. Its update also requires that no referenced run has a live status at the write, because a resume claim can return the run to running after the status read. - `claimNativeRestartRecoveries` reclaims a free execution lock for a `retryable_failure` resume, like the local claim does, when the checkout lock is free or held by this run and no other run for the task is queued, running or scheduled. It records `native.recovery.execution_reclaimed`. A lock held by another run still blocks. - Docs: `packages/paperclip-runner/docs/durable-recovery.md` and `doc/execution-semantics.md`. ### Safety argument for the re-bind What the binding protects, and what this change keeps: - **Candidate admission** carries company, model, platform and expiry authority. A candidate startup never re-binds. The Kimi run-binding carve-out and all candidate rejections are unchanged. - **The agent that loads the conversation** stays exact. `validate()` runs on the saved descriptor before the digest check and requires this build's qualified agent profile: ACPX version, agent server package and version, and `commandDigest`. The saved identity must still match that profile and the provider mode. - **In-flight provider work** is never re-bound. A turn, runtime request, undelivered event or unconfirmed exit keeps the strict binding, so no old provider process or request continues under a new build. The next launch only loads a settled conversation. - **Launch shape.** A changed sidecar command path or arguments still rejects. Only the content at the same paths may change. - **Artifacts** are not trusted from the saved state. The current profile comes from the trusted controller at startup, and session start verifies every artifact's bytes and the restored provider identity, as before. What changes: a new provider-pack authority, or new sidecar or Node bytes, at the same paths no longer invalidates a settled conversation. The saved digest cannot show which part changed, so the rule does not depend on it. I did not add state fields to tell authority changes from artifact changes, because the rule would treat both the same. One existing assertion changes. `released_kimi_cold_restore_rejects_candidate_injection_and_changed_full_profile` expected a settled ordinary Kimi session to reject a changed authority, artifact digest, or saved digest. Those three cases moved to a new positive test. Command, arguments, every candidate grant field, a missing profile and run binding still reject. ### How a task in the reported state recovers after this deploys The task has old-digest saved state, a `retryable_failure` coordinator at attempt 1, and cleared locks. 1. Startup recovery, and the live recovery queue, run restart recovery for the due run. It reclaims the free execution lock and claims the run. The resume runs now. 2. Run B's failed `run.attach` is a durable outcome of B's exact run authority. If the resume replays that outcome instead of executing it again, it fails with the old, untyped text. The remaining attempts then fail the same way, and the run ends `terminal_failure` with `native_session_retry_exhausted`. The task blocks with a board recovery action. This takes about a minute. 3. If the task blocks, press **Retry** once. Retry selects run B as the source (it has process identity and controller evidence, so the #18860 skip does not apply) and rebinds B's checkpoint, which holds A's provider session. The new run cold-attaches under the new Runner, and restore re-binds the old digest because the session is suspended and quiescent. The conversation continues. Run B's failed attach never wrote the state file, so the state is still A's settled state. 4. Tasks that already exhausted their retries (blocked, or in progress after `native_session_retry_exhausted`) need the same single Retry. Their earlier attempts ran under the old Runner. Wait for the task to show blocked before pressing Retry. A Retry pressed while the resume is running joins that run. ## Verification - Rust, failing first: `settled_session_rebinds_to_the_upgraded_runner_launch_profile` failed on `master` with the production message. `unsettled_or_relocated_session_keeps_the_strict_launch_binding` failed on the missing classification. Both pass now. - `cargo test -p paperclip-runner-core --no-fail-fast`: all 41 test binaries pass. The library has 471 passed and 1 ignored. - `cargo fmt --check`, and `pnpm --filter @paperclipai/paperclip-runner typecheck` (TypeScript, generated contracts, `cargo check`): pass. - Server, failing first: `native-scheduled-resume-lock.test.ts` (PostgreSQL). On `master` the sweep cleared the locks and restart recovery returned `issue_execution_lock_changed`. Both negative controls (another run's lock, a queued run) already passed. All four tests pass now. - `pnpm --filter @paperclipai/server typecheck`: pass. - Runner vitest (`src/native-session-runtime.test.ts`, `src/backends`, `src/drivers/codex`, `src/drivers/provider-failure.test.ts`, `src/live/runnerd-codex-transport*.test.ts`, `src/control-plane/acpx-controller-recovery.test.ts`): 31 files, 1,011 tests pass. New: the typed error when same-session recovery is required, and the recovery class reader and pass-through. - Server vitest: the native executor, status corpus and provider-pack admission suites (1,268 tests) pass. New: the classifier makes the typed error permanent, and the same text stays retryable. Checkpoint selection returns `errorCode`, and an incompatible source starts a fresh session with the same workspace (unit and PostgreSQL). - Server vitest over the recovery area: stale-lock sweep, native resumption, process recovery, failed-workspace recovery, restart recovery (integration, diskless, preparation), controller recovery, startup retry, startup cleanup, recovered-owner follow-up, session resume, runtime selection and recovery deadlines. 926 passed. Four `heartbeat-process-recovery` tests failed in that loaded run. Three pass when I run them alone. `redacts opaque environment-bound credentials from Sentry diagnostics: result` fails the same way on unmodified `master` on this host (a local environment value is redacted). - Review round 1: a new sweep test returns the run to running before the clear. It cleared the locks without the write recheck and keeps them with it. The strict Rust restore test covers an older agent server release and an older ACPX release. The ACPX backend and durable runner unit tests, the lock, sweep, resume projection and controller recovery suites, `cargo fmt --check` and the server typecheck pass. In the second full cargo run, the two `stdio_mcp` relay tests timed out waiting 5 s for a child exit on this loaded host. They do not use the changed code and passed in the first full run. - After the rebase onto the current `master`: the new lock test, failed-workspace recovery, stale-lock sweep and session resume suites pass (189 tests). Server typecheck, `pnpm check:module-boundaries` and `git diff --check` pass. - CI on `929ef4508d2`: in the first run, `isolated-repository-rehome` hit its 15 s test timeout, `idle-completed-history` could not reach its fixture runtime service, and e2e shard 3 was cancelled by a runner shutdown. A rerun of the failed jobs passed. All 55 checks pass. - Not verified: a live remote sandbox deploy, and whether run B's same-run resume replays its recorded `run.attach` failure or executes it again. The Retry path is tested at source selection and checkpoint rebind (server) and at cold attach (Runner). It is not tested end to end through a sandbox. ## Risks - A settled session now continues under new sidecar bytes. If a sidecar change made an old ACPX record unreadable, session start fails as any failed resume does. It does not corrupt state, and the run follows its normal recovery policy. - The sweep now keeps locks for a failed native run with a scheduled resume. If that resume can never be claimed (for example the agent stays paused), the task keeps the lock until the coordinator settles or the task closes. Before, the sweep cleared it, and the resume still never ran. - If another run holds the task's lock, a scheduled remote resume still stays blocked and the coordinator stays `retryable_failure`. This change does not settle that case. It needs a separate decision about superseding the resume. - `native_provider_state_incompatible` is a new failure code. No UI or migration change is needed. Older servers never see it. - No schema migration. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use (shell, file edits, test runs). The runtime does not expose the context window or reasoning configuration. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2649 lines
95 KiB
TypeScript
2649 lines
95 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import {
|
|
chmod,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { createServer } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import {
|
|
agents,
|
|
companies,
|
|
completionContracts,
|
|
createDb,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issues,
|
|
nativeRunResults,
|
|
} from "@paperclipai/db";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
CodexAppServerDriver,
|
|
HarnessDriverBackend,
|
|
createCodexTaskEnvelope,
|
|
createRunnerdCodexTransport as createCapabilityRunnerdCodexTransport,
|
|
} from "@paperclipai/paperclip-runner";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase as startEmbeddedDatabase,
|
|
} from "../../__tests__/helpers/embedded-postgres.js";
|
|
import {
|
|
canonicalNativeRuntimeContextDigest,
|
|
buildNativeModelEnvelope,
|
|
defaultCapabilityRunnerdBinary,
|
|
executeNativeSession,
|
|
parseNativeExecutionInput,
|
|
type NativeExecutionInput,
|
|
type NativeSession,
|
|
} from "../../vendor/paperclip-runner/index.js";
|
|
import {
|
|
runnerPrpWebSocketInternals,
|
|
setupRunnerPrpWebSocketServer,
|
|
} from "../../realtime/runner-prp-ws.js";
|
|
import {
|
|
appendRetainedNativeCleanupEvent,
|
|
createRunnerdBackend,
|
|
retainedNativeCleanupJournalMatches,
|
|
} from "./native-session-executor.js";
|
|
import * as publicationSignals from "../chat-publication-reconciliation.js";
|
|
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
|
import {
|
|
LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION,
|
|
REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION,
|
|
} from "./chat-attachment-reuse.js";
|
|
import { buildNativeExecutionInput } from "./native-execution-input.js";
|
|
import {
|
|
buildNativeExecutionWithCheckpoint,
|
|
findNativeSessionResumeRun,
|
|
NATIVE_PROVIDER_STATE_INCOMPATIBLE,
|
|
NATIVE_TOOL_CONTRACT_FINGERPRINT,
|
|
isUnusedLegacyNativeRetryReplacement,
|
|
nativeToolContractFingerprintForTarget,
|
|
nativeSessionIdForBootstrapPersistence,
|
|
nativeSessionProviderEvidence,
|
|
prepareNativeSessionBootstrapPersistence,
|
|
rebindNativeSessionCheckpoint,
|
|
selectNativeSessionResumeRun,
|
|
} from "./native-session-resume.js";
|
|
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
|
|
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
|
|
|
|
const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
|
|
|
|
async function startEmbeddedPostgresTestDatabase(prefix: string) {
|
|
const externalUrl = externalTestDatabaseUrl;
|
|
if (!externalUrl) return startEmbeddedDatabase(prefix);
|
|
const { closeRegisteredClients } = await import("@paperclipai/db");
|
|
return { connectionString: externalUrl, cleanup: async () => {
|
|
const db = createDb(externalUrl);
|
|
try { await db.execute(sql`TRUNCATE TABLE companies CASCADE`); }
|
|
finally { await closeRegisteredClients(externalUrl); }
|
|
} };
|
|
}
|
|
|
|
const companyId = "10000000-0000-4000-8000-000000000001";
|
|
const issueId = "20000000-0000-4000-8000-000000000002";
|
|
const agentId = "30000000-0000-4000-8000-000000000003";
|
|
const previousRunId = "40000000-0000-4000-8000-000000000004";
|
|
const currentRunId = "50000000-0000-4000-8000-000000000005";
|
|
const normalizedSessionId = "60000000-0000-4000-8000-000000000006";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function execution(
|
|
runId: string,
|
|
cwd = "/workspace",
|
|
workMode = "standard",
|
|
workspace: {
|
|
id?: string;
|
|
repoUrl?: string | null;
|
|
repoRef?: string | null;
|
|
branchName?: string | null;
|
|
} = {},
|
|
) {
|
|
return buildNativeExecutionInput({
|
|
companyId,
|
|
runId,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: "DOT-2",
|
|
title: "Test",
|
|
description: null,
|
|
workMode,
|
|
},
|
|
taskPrompt: "Only the current turn",
|
|
agentId,
|
|
workspace: {
|
|
id: workspace.id ?? runId,
|
|
cwd,
|
|
repoUrl: workspace.repoUrl ?? null,
|
|
repoRef: workspace.repoRef ?? null,
|
|
branchName: workspace.branchName ?? null,
|
|
},
|
|
normalizedSessionId,
|
|
provider: "codex",
|
|
completionContract: {
|
|
id: "70000000-0000-4000-8000-000000000007",
|
|
sha256: `sha256:${"a".repeat(64)}`,
|
|
schemaVersion: "paperclip.run-result.v1",
|
|
contract: {
|
|
revision: "1",
|
|
objective: "Test session resumption",
|
|
criteria: [{ id: "objective", requirement: "Answer the current turn" }],
|
|
},
|
|
},
|
|
runtimeContext: nativeRuntimeContextFixture(),
|
|
});
|
|
}
|
|
|
|
function planningExecution(runId: string, revisionId: string) {
|
|
return buildNativeExecutionInput({
|
|
companyId,
|
|
runId,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: "DOT-2",
|
|
title: "Test",
|
|
description: null,
|
|
workMode: "planning",
|
|
},
|
|
taskPrompt: "Revise the plan",
|
|
agentId,
|
|
workspace: {
|
|
id: runId,
|
|
cwd: "/workspace",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
branchName: null,
|
|
},
|
|
normalizedSessionId,
|
|
provider: "codex",
|
|
executionMode: "plan",
|
|
planningContext: {
|
|
documentId: "80000000-0000-4000-8000-000000000008",
|
|
baseRevisionId: revisionId,
|
|
baseRevisionNumber: revisionId.endsWith("9") ? 9 : 8,
|
|
markdown: `Plan at ${revisionId}`,
|
|
sha256: `sha256:${"b".repeat(64)}`,
|
|
reviewContext: {},
|
|
},
|
|
completionContract: execution(runId).completionContract,
|
|
runtimeContext: nativeRuntimeContextFixture(),
|
|
});
|
|
}
|
|
|
|
function previousRun(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: previousRunId,
|
|
companyId,
|
|
agentId,
|
|
nativeSessionId: normalizedSessionId,
|
|
runnerProfileJson: {
|
|
nativeToolContractFingerprint: NATIVE_TOOL_CONTRACT_FINGERPRINT,
|
|
nativeExecutionInput: execution(previousRunId),
|
|
sessionCheckpoint: {
|
|
backendKind: "runner",
|
|
driverKind: "codex_app_server",
|
|
sessionId: "provider-thread-123",
|
|
providerSessionId: "provider-thread-123",
|
|
cursor: "42",
|
|
identity: {
|
|
runId: previousRunId,
|
|
sessionId: normalizedSessionId,
|
|
companyId,
|
|
issueId,
|
|
agentId,
|
|
},
|
|
semanticResult: {
|
|
schema: "paperclip.run-result.v1",
|
|
reportedWorkDisposition: "done",
|
|
summary: "old",
|
|
},
|
|
terminal: {
|
|
schema: "paperclip.prp.terminal.v1",
|
|
turnTerminalState: "completed",
|
|
runTerminalState: "succeeded",
|
|
reportedWorkDisposition: "done",
|
|
},
|
|
activeTurnId: "old-turn",
|
|
terminalTurns: [{ turnId: "old-turn", state: "completed" }],
|
|
pendingRuntimeRequests: [{ requestId: "old-request" }],
|
|
lineage: [{ threadId: "provider-thread-123" }],
|
|
},
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("capability-gated connection tool refresh", () => {
|
|
it("retains the exact provider session for an MCP-only change when the harness supports it", () => {
|
|
const current = execution(currentRunId);
|
|
current.runtimeContext.mcp.digest = "b".repeat(64);
|
|
current.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(current.runtimeContext);
|
|
const rebound = rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, toolRefreshOnResume: true, refreshTools: true });
|
|
expect(rebound).toMatchObject({ sessionId: "provider-thread-123", providerSessionId: "provider-thread-123", identity: { runId: currentRunId }, providerRecoveryPolicy: "allow_replacement_after_resume_failure" });
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current })).toBeNull();
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, refreshTools: true, toolRefreshOnResume: false })).toBeNull();
|
|
});
|
|
|
|
it.each(["add", "edit", "remove"])("replaces sessions when connection instructions %s, even with live tool refresh", (change) => {
|
|
const block = (text: string) => ({ text, digest: createHash("sha256").update(text).digest("hex") });
|
|
const previous = execution(previousRunId);
|
|
const current = execution(currentRunId);
|
|
if (change !== "add") previous.runtimeContext.connectionInstructions = block("Prior connection instructions.");
|
|
if (change !== "remove") current.runtimeContext.connectionInstructions = block("Current connection instructions.");
|
|
previous.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(previous.runtimeContext);
|
|
current.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(current.runtimeContext);
|
|
const prior = previousRun({ nativeExecutionInput: previous });
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: prior, currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).toBeNull();
|
|
current.runtimeContext = previous.runtimeContext;
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: prior, currentExecution: current })).not.toBeNull();
|
|
});
|
|
|
|
it("preserves instruction and identity fences even during a supported refresh", () => {
|
|
const current = execution(currentRunId);
|
|
current.runtimeContext.instructions.bundle.digest = "b".repeat(64);
|
|
current.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(current.runtimeContext);
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).toBeNull();
|
|
current.runtimeContext = nativeRuntimeContextFixture();
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).not.toBeNull();
|
|
current.binding.agentId = randomUUID();
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).toBeNull();
|
|
});
|
|
|
|
it("rebuilds the bootstrap when refresh requires a fresh session", () => {
|
|
const buildExecution = vi.fn(({ normalizedSessionId: sessionId, resumedSession }) => {
|
|
const built = execution(currentRunId);
|
|
built.session.normalizedSessionId = sessionId;
|
|
built.task.prompt = resumedSession ? "wake delta" : "original goal and complete handoff";
|
|
return built;
|
|
});
|
|
const result = buildNativeExecutionWithCheckpoint({ previousRun: previousRun(), normalizedSessionId, refreshTools: true, toolRefreshOnResume: false, buildExecution });
|
|
expect(result.checkpoint).toBeNull();
|
|
expect(result.normalizedSessionId).not.toBe(normalizedSessionId);
|
|
expect(result.execution.task.prompt).toContain("complete handoff");
|
|
expect(buildExecution).toHaveBeenLastCalledWith({ normalizedSessionId: result.normalizedSessionId, resumedSession: false });
|
|
});
|
|
});
|
|
|
|
describe("a source whose saved provider session the runner proved incompatible", () => {
|
|
it("continues the task in a fresh provider session with the same workspace", () => {
|
|
const current = execution(currentRunId);
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: { ...previousRun(), errorCode: "native_session_interrupted" },
|
|
currentExecution: current })).not.toBeNull();
|
|
const source = { ...previousRun(), errorCode: NATIVE_PROVIDER_STATE_INCOMPATIBLE };
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: source, currentExecution: current })).toBeNull();
|
|
const buildExecution = vi.fn(({ normalizedSessionId: sessionId, resumedSession }) => {
|
|
const built = execution(currentRunId);
|
|
built.session.normalizedSessionId = sessionId;
|
|
built.task.prompt = resumedSession ? "wake delta" : "original goal and complete handoff";
|
|
return built;
|
|
});
|
|
const result = buildNativeExecutionWithCheckpoint({ previousRun: source, normalizedSessionId, buildExecution });
|
|
expect(result.checkpoint).toBeNull();
|
|
expect(result.normalizedSessionId).not.toBe(normalizedSessionId);
|
|
expect(result.execution.task.prompt).toContain("complete handoff");
|
|
expect(result.execution.binding.executionWorkspaceId).toBe(current.binding.executionWorkspaceId);
|
|
expect(result.execution.workspace).toEqual(current.workspace);
|
|
});
|
|
});
|
|
|
|
it("wires exact-session recovery and guarded selected identity into heartbeat persistence", () => {
|
|
const source = readFileSync(
|
|
new URL("../heartbeat/runtime-selection.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
expect(source).toContain("findNativeSessionResumeRun(db,");
|
|
expect(source).toContain(
|
|
"prepareNativeSessionBootstrapPersistence(tx,",
|
|
);
|
|
expect(source).toContain("nativeSessionId: persistedNativeSessionId,");
|
|
expect(source).toContain("lockedProfile.sessionCheckpoint != null");
|
|
const build = source.slice(
|
|
source.indexOf("const nativeExecutionWithCheckpoint ="),
|
|
source.indexOf("const nativeSandboxLifecycle ="),
|
|
);
|
|
expect(build).toContain("previousRun: previousNativeRun,");
|
|
});
|
|
|
|
const embeddedSupport = await getEmbeddedPostgresTestSupport();
|
|
const databaseSupported = Boolean(externalTestDatabaseUrl) || embeddedSupport.supported;
|
|
const recoveryFakeCodex = resolve(
|
|
import.meta.dirname,
|
|
"../../../../packages/paperclip-runner/test/fixtures/fake-final-burst-codex-app-server.mjs",
|
|
);
|
|
|
|
(databaseSupported && existsSync(defaultCapabilityRunnerdBinary())
|
|
? it
|
|
: it.skip)(
|
|
"keeps actual normalized driver events separate from raw maintenance receipts",
|
|
async () => {
|
|
const database = await startEmbeddedPostgresTestDatabase(
|
|
"native-cleanup-topology-",
|
|
);
|
|
const db = createDb(database.connectionString);
|
|
const scratch = await mkdtemp(join(tmpdir(), "native-cleanup-topology-"));
|
|
const root = join(scratch, "state");
|
|
const home = join(scratch, "home");
|
|
const workspace = join(scratch, "workspace");
|
|
const companyId = randomUUID(),
|
|
agentId = randomUUID(),
|
|
issueId = randomUUID(),
|
|
runId = randomUUID();
|
|
const runnerInstanceId = randomUUID(),
|
|
normalizedSessionId = randomUUID();
|
|
let session: NativeSession | undefined;
|
|
const signal = vi.spyOn(
|
|
publicationSignals,
|
|
"publishChatPublicationCommitSignal",
|
|
);
|
|
let bundle:
|
|
ReturnType<typeof createCapabilityRunnerdCodexTransport> | undefined;
|
|
try {
|
|
await mkdir(home);
|
|
await mkdir(workspace);
|
|
await db
|
|
.insert(companies)
|
|
.values({
|
|
id: companyId,
|
|
name: "Cleanup topology",
|
|
issuePrefix: "NCT",
|
|
});
|
|
await db
|
|
.insert(agents)
|
|
.values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Fixture",
|
|
status: "active",
|
|
adapterType: "paperclip_runner",
|
|
});
|
|
await db
|
|
.insert(issues)
|
|
.values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Exact topology",
|
|
assigneeAgentId: agentId,
|
|
status: "in_progress",
|
|
});
|
|
await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
nativeSessionId: normalizedSessionId,
|
|
runnerInstanceId,
|
|
});
|
|
bundle = createCapabilityRunnerdCodexTransport({
|
|
stateDirectory: root,
|
|
sourceCodexHome: home,
|
|
// The packaged runnerd does not imply local Rust test binaries exist.
|
|
// Reuse the credential-free provider fixture available in every checkout.
|
|
codexCommand: process.execPath,
|
|
codexArgs: [recoveryFakeCodex, join(scratch, "fake.json"), "16"],
|
|
prpIdentity: {
|
|
runId,
|
|
runnerInstanceId,
|
|
normalizedSessionId,
|
|
environmentLeaseId: runId,
|
|
turnId: `turn-${runId}`,
|
|
itemId: `item-${runId}`,
|
|
},
|
|
});
|
|
const backend = new HarnessDriverBackend(
|
|
new CodexAppServerDriver({
|
|
runnerInstanceId,
|
|
approvalPolicy: "never",
|
|
taskEnvelope: createCodexTaskEnvelope({
|
|
objective: "Open only the fixture session",
|
|
}),
|
|
transportFactory: () => bundle!.transport,
|
|
environment: { HOME: home, CODEX_HOME: home },
|
|
requireProviderSessionIdentity: true,
|
|
}),
|
|
);
|
|
session = await backend.openSession({
|
|
identity: {
|
|
companyId,
|
|
issueId,
|
|
agentId,
|
|
runId,
|
|
sessionId: normalizedSessionId,
|
|
},
|
|
workingDirectory: workspace,
|
|
});
|
|
const stream = session.events()[Symbol.asyncIterator]();
|
|
const normalized = (await stream.next()).value;
|
|
expect(normalized).toMatchObject({
|
|
sourceInstanceId: runnerInstanceId,
|
|
sourceSeq: 1,
|
|
});
|
|
expect(normalized.sourceEventId).toBe(`${runnerInstanceId}:${runId}:1`);
|
|
expect(normalized.payload).not.toHaveProperty("processId");
|
|
const port = new PaperclipControlPlanePort(db, {
|
|
companyId,
|
|
issueId,
|
|
agentId,
|
|
runId,
|
|
sessionId: normalizedSessionId,
|
|
sourceInstanceId: runnerInstanceId,
|
|
controlPlaneSourceInstanceId: `${runnerInstanceId}:control`,
|
|
completionContractId: randomUUID(),
|
|
completionContractSha256: "fixture-contract",
|
|
});
|
|
await port.appendEvent(normalized);
|
|
const original = await db
|
|
.select()
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, runId));
|
|
expect(original).toHaveLength(1);
|
|
const control = JSON.parse(
|
|
await readFile(
|
|
join(root, "control-plane/control-plane-state.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
const raw = control.committedEvents
|
|
.map(
|
|
(entry: { envelope: { payload: Record<string, unknown> } }) =>
|
|
entry.envelope.payload,
|
|
)
|
|
.find((event: Record<string, unknown>) =>
|
|
["session.started", "session.resumed"].includes(
|
|
String(event.eventType),
|
|
),
|
|
);
|
|
expect(raw).toMatchObject({
|
|
sourceInstanceId: runnerInstanceId,
|
|
sourceSeq: 1,
|
|
payload: {
|
|
providerSessionId: normalized.payload.driverSessionId,
|
|
providerAccountSessionId: normalized.payload.providerSessionId,
|
|
},
|
|
});
|
|
expect(raw.payload.processId).toBeGreaterThan(0);
|
|
expect(raw.sourceEventId).not.toBe(normalized.sourceEventId);
|
|
// The original normalized seq=1 is already durable. Reusing that
|
|
// namespace for the raw wire seq=1 must remain an integrity violation.
|
|
await expect(port.appendEvent(raw)).rejects.toMatchObject({
|
|
code: "native_event_replay_conflict",
|
|
});
|
|
signal.mockClear();
|
|
const input = {
|
|
companyId,
|
|
agentId,
|
|
runId,
|
|
nativeSessionId: normalizedSessionId,
|
|
runnerInstanceId,
|
|
requestId: `fixture:${randomUUID()}`,
|
|
event: raw,
|
|
};
|
|
await appendRetainedNativeCleanupEvent(db, input);
|
|
await appendRetainedNativeCleanupEvent(db, input);
|
|
const rows = await db
|
|
.select()
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, runId));
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.find((row) => row.id === original[0]!.id)).toEqual(
|
|
original[0],
|
|
);
|
|
const receipt = rows.find(
|
|
(row) => row.eventType === "native.cleanup.event",
|
|
)!;
|
|
expect(receipt.sourceSeq).toBe(1);
|
|
expect(receipt.sourceInstanceId).not.toBe(runnerInstanceId);
|
|
expect(receipt.payload).not.toHaveProperty("prpEvent");
|
|
expect(
|
|
Object.keys(
|
|
receipt.payload!.nativeCleanupEvent as Record<string, unknown>,
|
|
).sort(),
|
|
).toEqual([
|
|
"rawCanonicalSha256",
|
|
"rawEventType",
|
|
"rawSourceEventId",
|
|
"rawSourceInstanceId",
|
|
"rawSourceSeq",
|
|
"requestId",
|
|
"schema",
|
|
]);
|
|
await expect(
|
|
appendRetainedNativeCleanupEvent(db, {
|
|
...input,
|
|
event: {
|
|
...raw,
|
|
payload: {
|
|
...raw.payload,
|
|
privateData: "MUST_NOT_REACH_MAINTENANCE_RECEIPTS",
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ code: "native_event_replay_conflict" });
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, runId)),
|
|
).toHaveLength(2);
|
|
expect(JSON.stringify(receipt.payload)).not.toContain("MUST_NOT_REACH");
|
|
expect(signal).not.toHaveBeenCalled();
|
|
} finally {
|
|
signal.mockRestore();
|
|
await session
|
|
?.close({ reason: "Dispose exact test session" })
|
|
.catch(() => undefined);
|
|
await bundle?.transport.close().catch(() => undefined);
|
|
await database.cleanup();
|
|
await rm(scratch, { recursive: true, force: true });
|
|
}
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
(databaseSupported &&
|
|
existsSync(defaultCapabilityRunnerdBinary()) &&
|
|
existsSync(recoveryFakeCodex)
|
|
? it
|
|
: it.skip)(
|
|
"archives a damaged prior epoch before completing guarded same-task replacement with real runnerd",
|
|
async () => {
|
|
const database = await startEmbeddedPostgresTestDatabase(
|
|
"native-damaged-resume-",
|
|
);
|
|
const db = createDb(database.connectionString);
|
|
const scratch = await mkdtemp(join(tmpdir(), "native-damaged-resume-"));
|
|
const stateBase = join(scratch, "sessions");
|
|
const bin = join(scratch, "bin");
|
|
const workspace = join(scratch, "workspace");
|
|
const sourceHome = join(scratch, "source-home");
|
|
const previousStateBase = process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
|
const server = createServer();
|
|
let firstSession: NativeSession | undefined;
|
|
const ownedSessions = new Set<NativeSession>();
|
|
const ownedSpawns: Array<{ pid: number; processGroupId: number | null; startedAt: string }> = [];
|
|
const onSpawn = async (meta: typeof ownedSpawns[number]) => { ownedSpawns.push(meta); };
|
|
let executionCloseCompleted = false;
|
|
const runnerDiagnostics: string[] = [];
|
|
const onRunnerLog = async (_stream: "stdout" | "stderr", chunk: string) => {
|
|
runnerDiagnostics.push(chunk.slice(-4_096));
|
|
if (runnerDiagnostics.length > 32) runnerDiagnostics.shift();
|
|
};
|
|
// The production resolver prefers installed Codex over PATH. Explicitly
|
|
// bind this disposable fixture so it can never invoke a real provider.
|
|
const codexCommandResolver = await vi.importActual<{ resolveCodexCommand: () => string }>(
|
|
"../../../../packages/paperclip-runner/src/drivers/codex/codex-command.js",
|
|
);
|
|
const resolveFixtureCodex = vi.spyOn(codexCommandResolver, "resolveCodexCommand")
|
|
.mockReturnValue(join(bin, "codex"));
|
|
process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase;
|
|
try {
|
|
await Promise.all(
|
|
[bin, workspace, sourceHome].map((path) =>
|
|
mkdir(path, { recursive: true }),
|
|
),
|
|
);
|
|
// Only the provider boundary is synthetic; driver, runnerd, authority
|
|
// rotation, prior-owner DB checks and archive/replacement are real.
|
|
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
const command = join(bin, "codex");
|
|
await writeFile(
|
|
command,
|
|
`#!/bin/sh\nexec ${quote(process.execPath)} ${quote(recoveryFakeCodex)} "$CODEX_HOME/fake-state.json" 16\n`,
|
|
);
|
|
await chmod(command, 0o700);
|
|
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string")
|
|
throw new Error("Missing test listener");
|
|
setupRunnerPrpWebSocketServer(server, {
|
|
apiUrl: `http://127.0.0.1:${address.port}`,
|
|
});
|
|
const makeExecution = (runId: string): NativeExecutionInput => {
|
|
const { runtimeContext: _runtimeContext, ...value } = execution(
|
|
runId,
|
|
workspace,
|
|
);
|
|
return {
|
|
...value,
|
|
schema: "paperclip.native-execution-input.v2",
|
|
provider: { kind: "codex", model: "gpt-5.6-luna" },
|
|
completionContract: {
|
|
...value.completionContract,
|
|
contract: {
|
|
revision: "burst-v1",
|
|
objective: "Complete a fresh response after guarded recovery",
|
|
criteria: [
|
|
{ id: "burst", requirement: "Complete the new response" },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
};
|
|
const firstExecution = makeExecution(previousRunId);
|
|
const currentExecution = makeExecution(currentRunId);
|
|
expect(() => parseNativeExecutionInput(firstExecution)).not.toThrow();
|
|
const runnerInstanceId = randomUUID();
|
|
const environment = {
|
|
PATH: `${bin}:${process.env.PATH ?? "/usr/bin:/bin"}`,
|
|
HOME: sourceHome,
|
|
CODEX_HOME: sourceHome,
|
|
};
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Damaged recovery",
|
|
issuePrefix: "DNR",
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Recovery runner",
|
|
status: "active",
|
|
adapterType: "paperclip_runner",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Preserve the same task",
|
|
assigneeAgentId: agentId,
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(completionContracts).values({
|
|
id: currentExecution.completionContract.id,
|
|
companyId,
|
|
issueId,
|
|
revision: 1,
|
|
schemaVersion: "paperclip.completion-contract.v1",
|
|
policyVersion: "damaged-recovery-v1",
|
|
risk: "standard",
|
|
completionAuthority: "server_arbiter",
|
|
incompleteCriteriaPolicy: "preserve_non_terminal",
|
|
contractJson: { ...currentExecution.completionContract.contract },
|
|
canonicalSha256: currentExecution.completionContract.sha256,
|
|
createdByActorType: "system",
|
|
createdByActorId: "test",
|
|
});
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: previousRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
nativeSessionId: normalizedSessionId,
|
|
runnerInstanceId,
|
|
completionContractId: firstExecution.completionContract.id,
|
|
completionContractSha256: firstExecution.completionContract.sha256,
|
|
runnerProfileJson: { nativeExecutionInput: firstExecution },
|
|
},
|
|
{
|
|
id: currentRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
nativeSessionId: normalizedSessionId,
|
|
runnerInstanceId,
|
|
completionContractId: currentExecution.completionContract.id,
|
|
completionContractSha256: currentExecution.completionContract.sha256,
|
|
runnerProfileJson: { nativeExecutionInput: currentExecution },
|
|
},
|
|
]);
|
|
const firstBackend = await createRunnerdBackend({
|
|
db,
|
|
execution: firstExecution,
|
|
runnerInstanceId,
|
|
runnerEnvironment: environment,
|
|
onLog: onRunnerLog,
|
|
onSpawn,
|
|
});
|
|
firstSession = await firstBackend.openSession({
|
|
identity: {
|
|
companyId,
|
|
issueId,
|
|
agentId,
|
|
runId: previousRunId,
|
|
sessionId: normalizedSessionId,
|
|
},
|
|
workingDirectory: workspace,
|
|
});
|
|
ownedSessions.add(firstSession);
|
|
const checkpoint = await firstSession.snapshot();
|
|
expect(checkpoint.identity).toEqual({
|
|
companyId,
|
|
issueId,
|
|
agentId,
|
|
runId: previousRunId,
|
|
sessionId: normalizedSessionId,
|
|
});
|
|
await firstSession.close({
|
|
reason: "Establish a suspended test-only historical root",
|
|
});
|
|
firstSession = undefined;
|
|
const [stateKey] = (await readdir(stateBase)).filter((name) =>
|
|
/^[a-f0-9]{64}$/.test(name),
|
|
);
|
|
expect(stateKey).toBeDefined();
|
|
const root = join(stateBase, stateKey!);
|
|
const runnerPath = join(root, "runner", "runner-state.json");
|
|
const providerPath = join(root, "runner", "codex-provider-state.json");
|
|
const runner = JSON.parse(await readFile(runnerPath, "utf8"));
|
|
const provider = JSON.parse(await readFile(providerPath, "utf8"));
|
|
expect(runner.lifecycle).toBe("suspended");
|
|
|
|
// Reproduce only in a generated disposable root the historical failure:
|
|
// an unacknowledged input whose digest predates bounded sanitization.
|
|
const badPayload = {
|
|
semantic_tool: {
|
|
schema: "paperclip.prp.semantic_tool.v1",
|
|
schemaVersion: 1,
|
|
phase: "input",
|
|
input: { summary: "bounded historical answer" },
|
|
content: { digest: `sha256:${"0".repeat(64)}` },
|
|
},
|
|
};
|
|
const envelope = {
|
|
protocol: "paperclip.runner",
|
|
version: 1,
|
|
kind: "event",
|
|
runnerInstanceId: runner.runnerInstanceId,
|
|
environmentLeaseId: runner.environmentLeaseId,
|
|
runId: previousRunId,
|
|
normalizedSessionId,
|
|
turnId: runner.turnId,
|
|
itemId: runner.itemId,
|
|
payload: {
|
|
schema: "paperclip.prp.event.v1",
|
|
sourceEventId: "historical-bad-input-44",
|
|
sourceSeq: 44,
|
|
sourceInstanceId: runner.runnerInstanceId,
|
|
sourceKind: "runner",
|
|
runId: previousRunId,
|
|
normalizedSessionId,
|
|
turnId: runner.turnId,
|
|
itemId: runner.itemId,
|
|
eventType: "semantic_tool.input",
|
|
schemaVersion: 1,
|
|
priority: 0,
|
|
emittedAt: "2026-09-08T00:00:00.000Z",
|
|
payload: badPayload,
|
|
},
|
|
};
|
|
runner.ackedSourceSeq = 43;
|
|
runner.nextSourceSeq = 45;
|
|
runner.outbox = [
|
|
{
|
|
sourceSeq: 44,
|
|
priority: 0,
|
|
eventType: "semantic_tool.input",
|
|
envelope,
|
|
byteSize: Buffer.byteLength(JSON.stringify(envelope)),
|
|
},
|
|
];
|
|
provider.pendingEvents = [
|
|
{
|
|
executorEventId: "codex_provider_0000000000000044",
|
|
eventType: "semantic_tool.input",
|
|
priority: "p0",
|
|
payload: badPayload,
|
|
},
|
|
];
|
|
provider.nextProviderEventSeq = 45;
|
|
await writeFile(runnerPath, JSON.stringify(runner));
|
|
await writeFile(providerPath, JSON.stringify(provider));
|
|
const damagedRunnerBytes = await readFile(runnerPath);
|
|
const damagedProviderBytes = await readFile(providerPath);
|
|
await expect(
|
|
createRunnerdBackend({
|
|
db,
|
|
execution: currentExecution,
|
|
runnerInstanceId,
|
|
runnerEnvironment: environment,
|
|
}),
|
|
).rejects.toThrow("runner_state_identity_mismatch");
|
|
expect(await readFile(runnerPath, "utf8")).toBe(
|
|
damagedRunnerBytes.toString(),
|
|
);
|
|
expect(await readdir(root)).not.toContain("authority-epochs");
|
|
const source = previousRun({
|
|
nativeExecutionInput: firstExecution,
|
|
sessionCheckpoint: checkpoint,
|
|
});
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "failed", runnerProfileJson: source.runnerProfileJson })
|
|
.where(eq(heartbeatRuns.id, previousRunId));
|
|
const selected = await findNativeSessionResumeRun(db, {
|
|
companyId,
|
|
agentId,
|
|
issueId,
|
|
normalizedSessionId,
|
|
currentRunId,
|
|
beforeCreatedAt: new Date(),
|
|
});
|
|
expect(selected?.id).toBe(previousRunId);
|
|
const rebound = rebindNativeSessionCheckpoint({
|
|
previousRun: selected!,
|
|
currentExecution,
|
|
});
|
|
expect(rebound).toMatchObject({
|
|
cursor: null,
|
|
semanticResult: null,
|
|
activeTurnId: null,
|
|
providerRecoveryPolicy: "allow_replacement_after_resume_failure",
|
|
});
|
|
expect(rebound?.identity).toEqual({
|
|
companyId,
|
|
issueId,
|
|
agentId,
|
|
runId: currentRunId,
|
|
sessionId: normalizedSessionId,
|
|
});
|
|
|
|
const backend = await createRunnerdBackend({
|
|
db,
|
|
execution: currentExecution,
|
|
runnerInstanceId,
|
|
runnerEnvironment: environment,
|
|
onLog: onRunnerLog,
|
|
onSpawn,
|
|
});
|
|
let continuity: Record<string, unknown> | undefined;
|
|
const controlPlaneInstanceId = randomUUID();
|
|
const port = new PaperclipControlPlanePort(db, {
|
|
companyId,
|
|
issueId,
|
|
runId: currentRunId,
|
|
agentId,
|
|
sessionId: normalizedSessionId,
|
|
completionContractId: currentExecution.completionContract.id,
|
|
completionContractSha256: currentExecution.completionContract.sha256,
|
|
sourceInstanceId: runnerInstanceId,
|
|
controlPlaneSourceInstanceId: controlPlaneInstanceId,
|
|
});
|
|
await expect(
|
|
executeNativeSession({
|
|
input: currentExecution,
|
|
backend,
|
|
persistedSession: rebound!,
|
|
runnerInstanceId,
|
|
controlPlaneInstanceId,
|
|
timeoutMs: 20_000,
|
|
// This disposable real-daemon fixture must join full retirement before
|
|
// removing its controller routes and durable state. The production
|
|
// default deliberately permits asynchronous cleanup after a result.
|
|
requireSessionCloseBeforeReturn: true,
|
|
onSession(value) { if (value) ownedSessions.add(value); },
|
|
async onSessionClosed() { executionCloseCompleted = true; },
|
|
controlPlane: port,
|
|
async onContinuityBreak(value) {
|
|
continuity = value;
|
|
},
|
|
}).then((result) => {
|
|
if (result.terminal.runTerminalState !== "succeeded") {
|
|
throw new Error(`Disposable recovery fixture failed: ${JSON.stringify(result)}`);
|
|
}
|
|
return result;
|
|
}).catch(async (error: unknown) => {
|
|
const eventRows = await db
|
|
.select({
|
|
eventType: heartbeatRunEvents.eventType,
|
|
payload: heartbeatRunEvents.payload,
|
|
})
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, currentRunId));
|
|
const eventCodes = eventRows.slice(-32).map((event) => ({
|
|
eventType: event.eventType,
|
|
code: event.payload?.code ?? null,
|
|
}));
|
|
const fakeProvider = await readFile(
|
|
join(root, "codex-home", "fake-state.json"),
|
|
"utf8",
|
|
).then(JSON.parse, () => null);
|
|
throw new Error(
|
|
`${error instanceof Error ? error.message : String(error)}\nDisposable recovery fixture runner diagnostics:\n${runnerDiagnostics.join("").slice(-12_000)}\nPersisted event codes: ${JSON.stringify(eventCodes)}\nFake provider state: ${JSON.stringify(fakeProvider)}`,
|
|
{ cause: error },
|
|
);
|
|
}),
|
|
).resolves.toMatchObject({
|
|
result: {
|
|
summary: "Fixture complete.",
|
|
reportedWorkDisposition: "done",
|
|
},
|
|
terminal: { runTerminalState: "succeeded" },
|
|
normalizedSessionId,
|
|
});
|
|
expect(executionCloseCompleted).toBe(true);
|
|
expect(ownedSpawns.length).toBeGreaterThanOrEqual(2);
|
|
for (const { pid } of ownedSpawns) {
|
|
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
|
}
|
|
console.info("Recovery fixture retired owned daemons", ownedSpawns);
|
|
expect(continuity).toMatchObject({
|
|
// The daemon can reject the damaged retained input during startup,
|
|
// before attach gets a chance to reject the unsettled provider session.
|
|
// Both refusal paths must preserve the exact archived evidence below.
|
|
reason: expect.stringMatching(
|
|
/run\.attach requires a settled Codex provider session|semantic tool input content digest does not match its transmitted input/,
|
|
),
|
|
previousDriverSessionId: checkpoint.sessionId,
|
|
});
|
|
const [epoch] = await readdir(join(root, "authority-epochs"));
|
|
const digest = (value: Buffer) =>
|
|
createHash("sha256").update(value).digest("hex");
|
|
expect(
|
|
digest(
|
|
await readFile(
|
|
join(root, "authority-epochs", epoch!, "runner-state.json"),
|
|
),
|
|
),
|
|
).toBe(digest(damagedRunnerBytes));
|
|
const [continuityArchive] = await readdir(
|
|
join(root, "continuity-breaks"),
|
|
);
|
|
const archivedProvider = JSON.parse(
|
|
await readFile(
|
|
join(
|
|
root,
|
|
"continuity-breaks",
|
|
continuityArchive!,
|
|
"runner",
|
|
"codex-provider-state.json",
|
|
),
|
|
"utf8",
|
|
),
|
|
);
|
|
// Restoration may append a lifecycle notice, but must neither consume
|
|
// nor rewrite the old semantic input to make attachment succeed.
|
|
expect(
|
|
archivedProvider.pendingEvents.filter(
|
|
(event: { eventType: string }) =>
|
|
event.eventType === "semantic_tool.input",
|
|
),
|
|
).toEqual(JSON.parse(damagedProviderBytes.toString()).pendingEvents);
|
|
const completedProvider = JSON.parse(
|
|
await readFile(join(root, "codex-home", "fake-state.json"), "utf8"),
|
|
);
|
|
expect(completedProvider.nextTurn).toBe(1);
|
|
expect(completedProvider.turns["final-burst-turn-1"]).toMatchObject({
|
|
status: "completed",
|
|
deltaCount: 16,
|
|
});
|
|
const priorProvider = JSON.parse(
|
|
await readFile(
|
|
join(
|
|
root,
|
|
"continuity-breaks",
|
|
continuityArchive!,
|
|
"codex-home",
|
|
"fake-state.json",
|
|
),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(priorProvider.nextTurn).toBe(0);
|
|
const results = await db
|
|
.select()
|
|
.from(nativeRunResults)
|
|
.where(eq(nativeRunResults.runId, currentRunId));
|
|
expect(results).toHaveLength(1);
|
|
// Prove the maintenance anchor against an actual completed composition:
|
|
// the DB owns normalized driver events and the accepted result, whereas
|
|
// the retained controller owns distinct raw provider/tool event IDs.
|
|
const [completedRun] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, currentRunId));
|
|
const anchorEvents = await db
|
|
.select()
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, currentRunId));
|
|
const control = JSON.parse(
|
|
await readFile(
|
|
join(root, "control-plane", "control-plane-state.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
const rawIdentity = control.committedEvents
|
|
.map(
|
|
(entry: { envelope: { payload: Record<string, unknown> } }) =>
|
|
entry.envelope.payload,
|
|
)
|
|
.find((event: Record<string, unknown>) =>
|
|
["session.started", "session.resumed"].includes(
|
|
String(event.eventType),
|
|
),
|
|
);
|
|
expect(rawIdentity).toBeDefined();
|
|
expect(
|
|
retainedNativeCleanupJournalMatches({
|
|
run: completedRun!,
|
|
execution: currentExecution,
|
|
accepted: results[0]!,
|
|
control,
|
|
providerSessionId: rawIdentity.payload.providerSessionId,
|
|
providerAccountSessionId:
|
|
rawIdentity.payload.providerAccountSessionId,
|
|
persistedEvents: anchorEvents,
|
|
}),
|
|
).toBe(true);
|
|
expect(results[0]).toMatchObject({
|
|
companyId,
|
|
issueId,
|
|
runId: currentRunId,
|
|
schemaStatus: "accepted",
|
|
resultJson: {
|
|
result: { summary: "Fixture complete." },
|
|
terminal: { runTerminalState: "succeeded" },
|
|
},
|
|
});
|
|
const sourceResults = await db
|
|
.select({ id: nativeRunResults.id })
|
|
.from(nativeRunResults)
|
|
.where(eq(nativeRunResults.runId, previousRunId));
|
|
expect(sourceResults).toHaveLength(0);
|
|
const persistedEvents = await db
|
|
.select({ sourceEventId: heartbeatRunEvents.sourceEventId })
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, currentRunId));
|
|
expect(persistedEvents.length).toBeGreaterThan(0);
|
|
expect(persistedEvents.map((event) => event.sourceEventId)).not.toContain(
|
|
"historical-bad-input-44",
|
|
);
|
|
const [task] = await db
|
|
.select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId));
|
|
expect(task).toEqual({ id: issueId, assigneeAgentId: agentId });
|
|
} finally {
|
|
// onSession(null) quarantines the runtime owner before close finishes;
|
|
// keep every published handle so an assertion/error cannot lose cleanup.
|
|
if (firstSession) ownedSessions.add(firstSession);
|
|
const closed = await Promise.allSettled([...ownedSessions].map((session) =>
|
|
Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })),
|
|
));
|
|
const closeFailures = closed.filter((result) => result.status === "rejected");
|
|
resolveFixtureCodex.mockRestore();
|
|
runnerPrpWebSocketInternals.resetForTests();
|
|
server.closeAllConnections();
|
|
await new Promise<void>((done) => server.close(() => done()));
|
|
if (previousStateBase === undefined)
|
|
delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
|
else process.env.PAPERCLIP_RUNNER_STATE_DIR = previousStateBase;
|
|
await database.cleanup();
|
|
if (closeFailures.length) {
|
|
// Retain exact fixture state if owned retirement cannot be proven.
|
|
throw new AggregateError(closeFailures.map((result) => result.reason), "Recovery fixture session cleanup failed");
|
|
}
|
|
await rm(scratch, { recursive: true, force: true });
|
|
}
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
(databaseSupported ? describe : describe.skip)(
|
|
"native session recovery database orchestration",
|
|
() => {
|
|
it("uses scoped latest checkpoints, preserves progress barriers, and guards bootstrap persistence after the row lock", async () => {
|
|
const database = await startEmbeddedPostgresTestDatabase(
|
|
"paperclip-native-resume-selection-",
|
|
);
|
|
const db = createDb(database.connectionString);
|
|
try {
|
|
const otherCompany = randomUUID(),
|
|
otherAgent = randomUUID(),
|
|
otherIssue = randomUUID();
|
|
await db.insert(companies).values([
|
|
{ id: companyId, name: "Session recovery", issuePrefix: "NRS" },
|
|
{ id: otherCompany, name: "Other company", issuePrefix: "OTHER" },
|
|
]);
|
|
await db.insert(agents).values([
|
|
{ id: agentId, companyId, name: "Native runner" },
|
|
{ id: otherAgent, companyId: otherCompany, name: "Other agent" },
|
|
]);
|
|
await db.insert(issues).values([
|
|
{ id: issueId, companyId, title: "Retain this task" },
|
|
{ id: otherIssue, companyId, title: "Other task" },
|
|
]);
|
|
const at = (second: number) =>
|
|
new Date(Date.UTC(2026, 8, 8, 0, 0, second));
|
|
const source = {
|
|
...previousRun(),
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
status: "failed",
|
|
createdAt: at(0),
|
|
runnerInstanceId: randomUUID(),
|
|
};
|
|
const unused = {
|
|
companyId,
|
|
agentId,
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
nativeSessionId: normalizedSessionId,
|
|
status: "failed",
|
|
runnerProfileJson: {},
|
|
};
|
|
const bootstrapId = randomUUID();
|
|
const futureId = randomUUID();
|
|
await db.insert(heartbeatRuns).values([
|
|
source,
|
|
{ ...unused, id: bootstrapId, createdAt: at(2) },
|
|
{
|
|
...unused,
|
|
id: currentRunId,
|
|
status: "running",
|
|
createdAt: at(10),
|
|
},
|
|
{
|
|
...unused,
|
|
id: randomUUID(),
|
|
companyId: otherCompany,
|
|
createdAt: at(3),
|
|
status: "running",
|
|
},
|
|
{
|
|
...unused,
|
|
id: randomUUID(),
|
|
agentId: otherAgent,
|
|
createdAt: at(4),
|
|
status: "running",
|
|
},
|
|
{
|
|
...unused,
|
|
id: randomUUID(),
|
|
nativeIssueId: otherIssue,
|
|
createdAt: at(5),
|
|
status: "running",
|
|
},
|
|
{
|
|
...unused,
|
|
id: randomUUID(),
|
|
nativeSessionId: randomUUID(),
|
|
createdAt: at(6),
|
|
status: "running",
|
|
},
|
|
{
|
|
...unused,
|
|
id: futureId,
|
|
createdAt: at(11),
|
|
},
|
|
]);
|
|
const selection = {
|
|
companyId,
|
|
agentId,
|
|
issueId,
|
|
normalizedSessionId,
|
|
currentRunId,
|
|
beforeCreatedAt: at(10),
|
|
};
|
|
expect((await findNativeSessionResumeRun(db, selection))?.id).toBe(
|
|
previousRunId,
|
|
);
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ runnerProfileJson: source.runnerProfileJson })
|
|
.where(eq(heartbeatRuns.id, futureId));
|
|
expect(await findNativeSessionResumeRun(db, selection)).toBeNull();
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ runnerProfileJson: {}, status: "running" })
|
|
.where(eq(heartbeatRuns.id, futureId));
|
|
expect(await findNativeSessionResumeRun(db, selection)).toBeNull();
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "failed" })
|
|
.where(eq(heartbeatRuns.id, futureId));
|
|
expect((await findNativeSessionResumeRun(db, selection))?.id).toBe(
|
|
previousRunId,
|
|
);
|
|
await db.insert(heartbeatRunEvents).values({
|
|
companyId,
|
|
agentId,
|
|
runId: bootstrapId,
|
|
seq: 1,
|
|
eventType: "session.started",
|
|
});
|
|
expect(await findNativeSessionResumeRun(db, selection)).toBeNull();
|
|
const incompatibleId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
...unused,
|
|
id: incompatibleId,
|
|
createdAt: at(7),
|
|
runnerProfileJson: {
|
|
...source.runnerProfileJson,
|
|
nativeToolContractFingerprint: "stale",
|
|
},
|
|
});
|
|
const incompatible = await findNativeSessionResumeRun(db, selection);
|
|
expect(incompatible?.id).toBe(incompatibleId);
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: incompatible!,
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).toBeNull();
|
|
// A run whose saved provider session the runner proved unrestorable
|
|
// stays the barrier, and its checkpoint is not resumed.
|
|
const unrestorableId = randomUUID();
|
|
const sourceCheckpoint = source.runnerProfileJson.sessionCheckpoint;
|
|
await db.insert(heartbeatRuns).values({
|
|
...unused,
|
|
id: unrestorableId,
|
|
createdAt: at(8),
|
|
errorCode: NATIVE_PROVIDER_STATE_INCOMPATIBLE,
|
|
runnerProfileJson: {
|
|
...source.runnerProfileJson,
|
|
nativeExecutionInput: execution(unrestorableId),
|
|
sessionCheckpoint: {
|
|
...sourceCheckpoint,
|
|
identity: { ...sourceCheckpoint.identity, runId: unrestorableId },
|
|
},
|
|
},
|
|
});
|
|
const unrestorable = await findNativeSessionResumeRun(db, selection);
|
|
expect(unrestorable).toMatchObject({
|
|
id: unrestorableId,
|
|
errorCode: NATIVE_PROVIDER_STATE_INCOMPATIBLE,
|
|
});
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: { ...unrestorable!, errorCode: null },
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).not.toBeNull();
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: unrestorable!,
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).toBeNull();
|
|
|
|
const fresh = buildNativeExecutionWithCheckpoint({
|
|
previousRun: null,
|
|
normalizedSessionId,
|
|
buildExecution: ({ normalizedSessionId: selected }) => {
|
|
const current = execution(currentRunId);
|
|
return {
|
|
...current,
|
|
session: { ...current.session, normalizedSessionId: selected },
|
|
};
|
|
},
|
|
});
|
|
await db.transaction(async (tx) => {
|
|
const [locked] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, currentRunId))
|
|
.for("update");
|
|
const selected = await prepareNativeSessionBootstrapPersistence(tx, {
|
|
run: locked,
|
|
selectedSessionId: fresh.normalizedSessionId,
|
|
execution: fresh.execution,
|
|
restoringCheckpoint: false,
|
|
});
|
|
await tx
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
nativeSessionId: selected,
|
|
runnerProfileJson: { nativeExecutionInput: fresh.execution },
|
|
})
|
|
.where(eq(heartbeatRuns.id, locked.id));
|
|
});
|
|
const [persisted] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, currentRunId));
|
|
expect(persisted.nativeSessionId).toBe(fresh.normalizedSessionId);
|
|
expect(persisted.nativeSessionId).not.toBe(normalizedSessionId);
|
|
await expect(
|
|
db.transaction(async (tx) => {
|
|
const [locked] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, currentRunId))
|
|
.for("update");
|
|
return prepareNativeSessionBootstrapPersistence(tx, {
|
|
run: locked,
|
|
selectedSessionId: fresh.normalizedSessionId,
|
|
execution: fresh.execution,
|
|
restoringCheckpoint: false,
|
|
});
|
|
}),
|
|
).resolves.toBe(fresh.normalizedSessionId);
|
|
await expect(
|
|
db.transaction(async (tx) => {
|
|
const [locked] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, currentRunId))
|
|
.for("update");
|
|
const changedExecution = structuredClone(fresh.execution);
|
|
changedExecution.task.prompt = "Changed admission";
|
|
return prepareNativeSessionBootstrapPersistence(tx, {
|
|
run: locked,
|
|
selectedSessionId: fresh.normalizedSessionId,
|
|
execution: changedExecution,
|
|
restoringCheckpoint: false,
|
|
});
|
|
}),
|
|
).rejects.toThrow("native_execution_input_persisted_binding_mismatch");
|
|
const [preserved] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, previousRunId));
|
|
expect(preserved.runnerProfileJson).toEqual(source.runnerProfileJson);
|
|
|
|
const lateId = randomUUID();
|
|
await db
|
|
.insert(heartbeatRuns)
|
|
.values({ ...unused, id: lateId, createdAt: at(11) });
|
|
expect((await nativeSessionProviderEvidence(db, [lateId])).size).toBe(
|
|
0,
|
|
);
|
|
let unlock!: () => void;
|
|
let locked!: () => void;
|
|
const lockAcquired = new Promise<void>((resolve) => {
|
|
locked = resolve;
|
|
});
|
|
const release = new Promise<void>((resolve) => {
|
|
unlock = resolve;
|
|
});
|
|
const writer = db.transaction(async (tx) => {
|
|
await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, lateId))
|
|
.for("update");
|
|
locked();
|
|
await release;
|
|
await tx.insert(heartbeatRunEvents).values({
|
|
companyId,
|
|
agentId,
|
|
runId: lateId,
|
|
seq: 1,
|
|
eventType: "session.started",
|
|
});
|
|
});
|
|
let attempt: Promise<unknown> | undefined;
|
|
try {
|
|
await lockAcquired;
|
|
const applicationName = `native-resume-lock-${randomUUID()}`;
|
|
attempt = db
|
|
.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select set_config('application_name', ${applicationName}, true)`,
|
|
);
|
|
const [row] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, lateId))
|
|
.for("update");
|
|
return prepareNativeSessionBootstrapPersistence(tx, {
|
|
run: row,
|
|
selectedSessionId: randomUUID(),
|
|
execution: execution(lateId),
|
|
restoringCheckpoint: false,
|
|
});
|
|
})
|
|
.then(
|
|
() => null,
|
|
(error: unknown) => error,
|
|
);
|
|
await expect
|
|
.poll(async () => {
|
|
const rows = await db.execute<{ blocked: boolean }>(sql`
|
|
select exists (
|
|
select 1 from pg_stat_activity
|
|
where application_name = ${applicationName}
|
|
and wait_event_type = 'Lock'
|
|
and cardinality(pg_blocking_pids(pid)) > 0
|
|
) as blocked
|
|
`);
|
|
return rows[0]?.blocked;
|
|
})
|
|
.toBe(true);
|
|
unlock();
|
|
await writer;
|
|
expect(await attempt).toMatchObject({
|
|
message: "native_session_bootstrap_identity_conflict",
|
|
});
|
|
const [unchanged] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, lateId));
|
|
expect(unchanged.nativeSessionId).toBe(normalizedSessionId);
|
|
} finally {
|
|
unlock();
|
|
await Promise.all([writer, attempt]);
|
|
}
|
|
} finally {
|
|
await database.cleanup();
|
|
}
|
|
}, 60_000);
|
|
},
|
|
);
|
|
|
|
describe("rebindNativeSessionCheckpoint", () => {
|
|
it("rotates a carried session id when no checkpoint source exists", () => {
|
|
const calls: boolean[] = [];
|
|
const result = buildNativeExecutionWithCheckpoint({
|
|
previousRun: null,
|
|
normalizedSessionId,
|
|
buildExecution: (options) => {
|
|
calls.push(options.resumedSession);
|
|
const current = execution(currentRunId);
|
|
return {
|
|
...current,
|
|
session: {
|
|
...current.session,
|
|
normalizedSessionId: options.normalizedSessionId,
|
|
},
|
|
task: {
|
|
...current.task,
|
|
prompt: options.resumedSession
|
|
? "Compact delta"
|
|
: "Full task context",
|
|
},
|
|
};
|
|
},
|
|
});
|
|
expect(calls.every((resumed) => !resumed)).toBe(true);
|
|
expect(result.checkpoint).toBeNull();
|
|
expect(result.normalizedSessionId).not.toBe(normalizedSessionId);
|
|
expect(result.execution.session.normalizedSessionId).toBe(
|
|
result.normalizedSessionId,
|
|
);
|
|
expect(result.execution.task.prompt).toBe("Full task context");
|
|
expect(
|
|
nativeSessionIdForBootstrapPersistence({
|
|
run: {
|
|
nativeSessionId: normalizedSessionId,
|
|
processPid: null,
|
|
processGroupId: null,
|
|
processStartedAt: null,
|
|
runnerProfileJson: {},
|
|
},
|
|
selectedSessionId: result.normalizedSessionId,
|
|
hasProviderEvents: false,
|
|
}),
|
|
).toBe(result.normalizedSessionId);
|
|
});
|
|
|
|
it("does not rotate an admitted or authority-bearing session id", () => {
|
|
const unused = {
|
|
nativeSessionId: normalizedSessionId,
|
|
processPid: null,
|
|
processGroupId: null,
|
|
processStartedAt: null,
|
|
runnerProfileJson: {},
|
|
};
|
|
for (const run of [
|
|
{
|
|
...unused,
|
|
runnerProfileJson: { nativeExecutionInput: execution(currentRunId) },
|
|
},
|
|
{ ...unused, runnerProfileJson: { sessionCheckpoint: {} } },
|
|
{ ...unused, processPid: 123 },
|
|
{ ...unused, processGroupId: 123 },
|
|
{ ...unused, processStartedAt: new Date() },
|
|
]) {
|
|
expect(() =>
|
|
nativeSessionIdForBootstrapPersistence({
|
|
run,
|
|
selectedSessionId: previousRunId,
|
|
hasProviderEvents: false,
|
|
}),
|
|
).toThrow("native_session_bootstrap_identity_conflict");
|
|
}
|
|
expect(() =>
|
|
nativeSessionIdForBootstrapPersistence({
|
|
run: unused,
|
|
selectedSessionId: previousRunId,
|
|
hasProviderEvents: true,
|
|
}),
|
|
).toThrow("native_session_bootstrap_identity_conflict");
|
|
expect(
|
|
nativeSessionIdForBootstrapPersistence({
|
|
run: {
|
|
...unused,
|
|
runnerProfileJson: { nativeExecutionInput: execution(currentRunId) },
|
|
},
|
|
selectedSessionId: normalizedSessionId,
|
|
hasProviderEvents: true,
|
|
}),
|
|
).toBe(normalizedSessionId);
|
|
});
|
|
|
|
it("recovers the latest exact checkpoint past only unused bootstrap failures", () => {
|
|
const source = {
|
|
...previousRun(),
|
|
nativeIssueId: issueId,
|
|
runtimeMode: "native",
|
|
status: "failed",
|
|
createdAt: new Date(0),
|
|
processPid: null,
|
|
processGroupId: null,
|
|
processStartedAt: null,
|
|
};
|
|
const unusedFailure = {
|
|
...source,
|
|
id: "failed-bootstrap",
|
|
runnerProfileJson: {
|
|
nativeExecutionInput: execution("failed-bootstrap"),
|
|
},
|
|
};
|
|
const original = structuredClone(source);
|
|
const selected = selectNativeSessionResumeRun({
|
|
runs: [unusedFailure, source],
|
|
companyId,
|
|
agentId,
|
|
issueId,
|
|
normalizedSessionId,
|
|
currentRunId,
|
|
beforeCreatedAt: new Date(100),
|
|
providerEvidenceRunIds: new Set(),
|
|
});
|
|
expect(selected).toBe(source);
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: selected!,
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).toMatchObject({
|
|
sessionId: "provider-thread-123",
|
|
identity: { runId: currentRunId, sessionId: normalizedSessionId },
|
|
});
|
|
expect(source).toEqual(original);
|
|
});
|
|
|
|
it("never skips a newer provider authority or incompatible checkpoint", () => {
|
|
const source = {
|
|
...previousRun(),
|
|
nativeIssueId: issueId,
|
|
runtimeMode: "native",
|
|
status: "succeeded",
|
|
createdAt: new Date(0),
|
|
processPid: null as number | null,
|
|
processGroupId: null,
|
|
processStartedAt: null,
|
|
};
|
|
const newer = { ...source, id: "newer", runnerProfileJson: {} };
|
|
const select = (
|
|
candidate: typeof source | typeof newer,
|
|
hasEvents = false,
|
|
) =>
|
|
selectNativeSessionResumeRun({
|
|
runs: [candidate, source],
|
|
companyId,
|
|
agentId,
|
|
issueId,
|
|
normalizedSessionId,
|
|
currentRunId,
|
|
beforeCreatedAt: new Date(100),
|
|
providerEvidenceRunIds: new Set(hasEvents ? [candidate.id] : []),
|
|
});
|
|
expect(select(newer, true)).toBeNull();
|
|
expect(select({ ...newer, processPid: 123 })).toBeNull();
|
|
expect(select({ ...newer, status: "running" })).toBeNull();
|
|
const incompatible = {
|
|
...source,
|
|
id: "newer",
|
|
runnerProfileJson: {
|
|
...source.runnerProfileJson,
|
|
nativeToolContractFingerprint: "stale",
|
|
},
|
|
};
|
|
expect(select(incompatible)).toBe(incompatible);
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: select(incompatible)!,
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("does not borrow checkpoints across company, agent, issue, or normalized session", () => {
|
|
const source = {
|
|
...previousRun(),
|
|
nativeIssueId: issueId,
|
|
runtimeMode: "native",
|
|
status: "succeeded",
|
|
createdAt: new Date(0),
|
|
processPid: null,
|
|
processGroupId: null,
|
|
processStartedAt: null,
|
|
};
|
|
for (const changed of [
|
|
{ companyId: currentRunId },
|
|
{ agentId: currentRunId },
|
|
{ nativeIssueId: currentRunId },
|
|
{ nativeSessionId: currentRunId },
|
|
{ id: currentRunId },
|
|
]) {
|
|
expect(
|
|
selectNativeSessionResumeRun({
|
|
runs: [{ ...source, ...changed }],
|
|
companyId,
|
|
agentId,
|
|
issueId,
|
|
normalizedSessionId,
|
|
currentRunId,
|
|
beforeCreatedAt: new Date(100),
|
|
providerEvidenceRunIds: new Set(),
|
|
}),
|
|
).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("does not resume across a work-mode tool-surface change", () => {
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: previousRun(),
|
|
currentExecution: execution(currentRunId, "/workspace", "ask"),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
it.each(["External chat message received", "issue_comment_added"])(
|
|
"rebuilds full task context when a %s checkpoint is corrupt",
|
|
(reason) => {
|
|
const calls: boolean[] = [];
|
|
const result = buildNativeExecutionWithCheckpoint({
|
|
previousRun: previousRun({ sessionCheckpoint: {} }),
|
|
normalizedSessionId,
|
|
buildExecution: (options) => {
|
|
calls.push(options.resumedSession);
|
|
return buildNativeExecutionInput({
|
|
companyId,
|
|
runId: currentRunId,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: "DOT-2",
|
|
title: "Current task",
|
|
description: "Full task instructions",
|
|
workMode: "standard",
|
|
},
|
|
taskPrompt: options.resumedSession
|
|
? "Compact context"
|
|
: "Full task instructions",
|
|
wakePayload: {
|
|
reason,
|
|
issue: {
|
|
id: issueId,
|
|
title: "Current task",
|
|
workMode: "standard",
|
|
},
|
|
},
|
|
resumedSession: options.resumedSession,
|
|
agentId,
|
|
workspace: {
|
|
id: currentRunId,
|
|
cwd: "/workspace",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
branchName: null,
|
|
},
|
|
normalizedSessionId: options.normalizedSessionId,
|
|
provider: "codex",
|
|
completionContract: execution(currentRunId).completionContract,
|
|
runtimeContext: nativeRuntimeContextFixture(),
|
|
});
|
|
},
|
|
});
|
|
expect(calls).toEqual([true, false]);
|
|
expect(result.checkpoint).toBeNull();
|
|
expect(result.execution.session.normalizedSessionId).not.toBe(
|
|
normalizedSessionId,
|
|
);
|
|
expect(result.execution.task.prompt).toContain("Full task instructions");
|
|
expect(result.execution.task.prompt).not.toContain(
|
|
"Paperclip Resume Delta",
|
|
);
|
|
expect(result.execution.task.prompt).not.toContain("Compact context");
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["paperclip.native-execution-input.v4", "paperclip.native-execution-input.v5"],
|
|
["paperclip.native-execution-input.v5", "paperclip.native-execution-input.v4"],
|
|
] as const)("retains a recoverable %s session when the constructor defaults to %s", (priorSchema, currentSchema) => {
|
|
const prior = previousRun();
|
|
const profile = prior.runnerProfileJson as Record<string, unknown>;
|
|
profile.nativeExecutionInput = { ...execution(previousRunId), schema: priorSchema };
|
|
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
|
|
checkpoint.goal = { status: "paused", objective: "Preserve the existing durable work" };
|
|
const before = structuredClone(prior);
|
|
const result = buildNativeExecutionWithCheckpoint({
|
|
previousRun: prior,
|
|
normalizedSessionId,
|
|
buildExecution: ({ normalizedSessionId: sessionId, resumedSession }) => parseNativeExecutionInput({
|
|
...execution(currentRunId), schema: currentSchema,
|
|
session: { ...execution(currentRunId).session, normalizedSessionId: sessionId },
|
|
continuationPrompt: resumedSession ? "New direction only; do not replay completed actions" : null,
|
|
}),
|
|
});
|
|
expect(result.execution.schema).toBe(priorSchema);
|
|
expect(result.normalizedSessionId).toBe(normalizedSessionId);
|
|
expect(result.checkpoint).toMatchObject({
|
|
providerSessionId: "provider-thread-123",
|
|
providerRecoveryPolicy: "same_session_only",
|
|
goal: { status: "paused", objective: "Preserve the existing durable work" },
|
|
});
|
|
expect(prior).toEqual(before);
|
|
});
|
|
|
|
it("keeps a valid checkpoint and does not rebuild the resumed task", () => {
|
|
const calls: boolean[] = [];
|
|
const result = buildNativeExecutionWithCheckpoint({
|
|
previousRun: previousRun(),
|
|
normalizedSessionId,
|
|
buildExecution: (options) => {
|
|
calls.push(options.resumedSession);
|
|
return execution(currentRunId);
|
|
},
|
|
});
|
|
expect(calls).toEqual([true]);
|
|
expect(result.checkpoint?.sessionId).toBe("provider-thread-123");
|
|
expect(result.execution.session.normalizedSessionId).toBe(
|
|
normalizedSessionId,
|
|
);
|
|
});
|
|
|
|
it("rebuilds full context when the persisted provider tool contract is stale", () => {
|
|
const calls: boolean[] = [];
|
|
const result = buildNativeExecutionWithCheckpoint({
|
|
previousRun: previousRun({
|
|
nativeToolContractFingerprint: "sha256:stale",
|
|
}),
|
|
normalizedSessionId,
|
|
buildExecution: (options) => {
|
|
calls.push(options.resumedSession);
|
|
const current = execution(currentRunId);
|
|
return {
|
|
...current,
|
|
session: {
|
|
...current.session,
|
|
normalizedSessionId: options.normalizedSessionId,
|
|
},
|
|
task: {
|
|
...current.task,
|
|
prompt: options.resumedSession
|
|
? "Paperclip Resume Delta"
|
|
: "Full task instructions",
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
expect(calls).toEqual([true, false]);
|
|
expect(result.checkpoint).toBeNull();
|
|
expect(result.execution.task.prompt).toBe("Full task instructions");
|
|
expect(result.normalizedSessionId).not.toBe(normalizedSessionId);
|
|
});
|
|
|
|
it.each([
|
|
{ contract: "conditional current-wake reader", retainedFingerprint: "sha256:fe7a2e17ca825228ea2f51f23acdc82db65c91da5ec6add330fefa39b65e1c85" },
|
|
{ contract: "same-conversation attachment reuse", retainedFingerprint: "sha256:2b84f2105c2cea10e0e5f37cf4a6ba9bf7fec90d8c8c19c655a0e21d3a8dab5f" },
|
|
{ contract: "historical file inspection", retainedFingerprint: "sha256:9b558a575dfa4e3dfaf97b7139b47d1676ab3e0307953ec85f2af0b3f443567c" },
|
|
{ contract: "structured human questions", retainedFingerprint: "sha256:1c3cd7e3223f6ed28e1c44a0ba3c961dd4c0c52e52c9c1100a4e41a83384259c" },
|
|
{ contract: "response-wake completion yield", retainedFingerprint: "sha256:09887bbf1f6a3674fcf529d872fe2853cc1d6554376019dd1a9b8d607d7eb7fb" },
|
|
{ contract: "explicit chat-attachment invocation guidance", retainedFingerprint: "sha256:d797c8bc1baef8407a8c80f2c4b28da63b2a4258a47cf52ee9b1fca45535f5e7" },
|
|
{
|
|
contract: "completion descriptions",
|
|
// Deployed v8 / finish_response_wake_concrete_object.v2 local catalog.
|
|
retainedFingerprint:
|
|
"sha256:5b7b302db36f7ed6686f9ea1ba70bbf79ebd7fabf86953b548d966a2bc38b648",
|
|
},
|
|
{
|
|
contract: "native completion tool guidance",
|
|
// Deployed v13 local catalog before canonical finish/block descriptions.
|
|
retainedFingerprint:
|
|
"sha256:68a51d34e091c55ee5d0d2b563153454dd727d72db16e6a27c358d342ae489c9",
|
|
},
|
|
{
|
|
contract: "GitHub working-comment tools",
|
|
// Deployed v14 threads retain the catalog without update_comment.
|
|
retainedFingerprint:
|
|
"sha256:134a7dbd526179aff57f91c261bb653e83db51c20492efab5c26a5ce618792c8",
|
|
},
|
|
{
|
|
contract: "GitHub instruction skill selection",
|
|
// Deployed v15 remote threads omitted configured skills from native turns.
|
|
retainedFingerprint:
|
|
"sha256:f12fe3b1bf5d63b7954d8ab16f57764874806ab49acff7c6582c4de007f4ed4d",
|
|
},
|
|
{
|
|
contract: "regressed conditional run-scoped readers",
|
|
// Deployed v17 threads were opened without read_current_wake_comments
|
|
// and read_chat_attachment on ordinary turns; resume cannot add them.
|
|
retainedFingerprint:
|
|
"sha256:dc495475fb6a4bd034c6dbb2bbe0cf52e7ffc1635838889dc5f7d7251273f119",
|
|
},
|
|
{
|
|
contract: "task-bound human-input description",
|
|
// Deployed v9 still advertises the generic mock-task question description.
|
|
retainedFingerprint:
|
|
"sha256:b64efcd063a575925aa95dbd2a20953386eaa760f05b0e0e73b4ae04a97679b0",
|
|
},
|
|
])(
|
|
"refreshes retained $contract without changing the Paperclip task or prior history",
|
|
({ contract, retainedFingerprint }) => {
|
|
if (contract === "explicit chat-attachment invocation guidance") {
|
|
expect(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION.description).toContain("Call with {}");
|
|
expect(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION.description).toContain("1 through 50");
|
|
expect(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION.description).toContain("All four arguments are required");
|
|
expect(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION.description).toContain("same key unchanged on every retry");
|
|
}
|
|
expect(NATIVE_TOOL_CONTRACT_FINGERPRINT).not.toBe(retainedFingerprint);
|
|
const prior = previousRun({
|
|
nativeToolContractFingerprint: retainedFingerprint,
|
|
});
|
|
const priorSnapshot = structuredClone(prior);
|
|
const modes: boolean[] = [];
|
|
const result = buildNativeExecutionWithCheckpoint({
|
|
previousRun: prior,
|
|
normalizedSessionId,
|
|
buildExecution: (options) => {
|
|
modes.push(options.resumedSession);
|
|
const current = execution(currentRunId);
|
|
return {
|
|
...current,
|
|
session: {
|
|
...current.session,
|
|
normalizedSessionId: options.normalizedSessionId,
|
|
},
|
|
task: {
|
|
...current.task,
|
|
prompt: options.resumedSession
|
|
? "Paperclip Resume Delta"
|
|
: "Full context for the same Paperclip task",
|
|
},
|
|
};
|
|
},
|
|
});
|
|
expect(modes).toEqual([true, false]);
|
|
expect(result.checkpoint).toBeNull();
|
|
expect(result.normalizedSessionId).not.toBe(normalizedSessionId);
|
|
expect(result.execution.binding).toMatchObject({
|
|
companyId,
|
|
issueId,
|
|
agentId,
|
|
runId: currentRunId,
|
|
});
|
|
expect(result.execution.workspace).toEqual(
|
|
execution(currentRunId).workspace,
|
|
);
|
|
expect(result.execution.task.prompt).toBe(
|
|
"Full context for the same Paperclip task",
|
|
);
|
|
// No deletion/reset of the existing provider checkpoint or Paperclip history.
|
|
expect(prior).toEqual(priorSnapshot);
|
|
expect(prior.runnerProfileJson.sessionCheckpoint.sessionId).toBe(
|
|
"provider-thread-123",
|
|
);
|
|
},
|
|
);
|
|
|
|
|
|
|
|
it.each(["local", "remote"] as const)("refreshes only OpenCode's former direct MCP catalog on %s", target => {
|
|
const legacy = nativeToolContractFingerprintForTarget(target);
|
|
expect(nativeToolContractFingerprintForTarget(target, "codex")).toBe(legacy);
|
|
expect(nativeToolContractFingerprintForTarget(target, "acpx")).toBe(legacy);
|
|
const current = nativeToolContractFingerprintForTarget(target, "opencode");
|
|
expect(current).not.toBe(legacy);
|
|
const shortenedCatalog = target === "local"
|
|
? "sha256:2ae35faccbb63cfe009d94170ef1ef13f0d57e120af6160540598a3ec72001d7"
|
|
: "sha256:ee5285ca5118b8468c0afd1df4ef9c05d85b0934e882bccd0b193dc8722dbb7a";
|
|
expect(current).not.toBe(shortenedCatalog);
|
|
const currentExecution = { ...execution(currentRunId), provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" }, session: { ...execution(currentRunId).session, driverKind: "opencode_server" } } as NativeExecutionInput;
|
|
const previousExecution = { ...execution(previousRunId), provider: currentExecution.provider, session: currentExecution.session };
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: shortenedCatalog, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: legacy, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
|
|
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: current, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).not.toBeNull();
|
|
});
|
|
|
|
it("binds the provider tool catalog to the local or remote execution target", () => {
|
|
const remoteFingerprint = nativeToolContractFingerprintForTarget("remote");
|
|
expect(remoteFingerprint).not.toBe(NATIVE_TOOL_CONTRACT_FINGERPRINT);
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: previousRun(),
|
|
currentExecution: execution(currentRunId),
|
|
executionTargetKind: "remote",
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: previousRun({
|
|
nativeToolContractFingerprint: remoteFingerprint,
|
|
}),
|
|
currentExecution: execution(currentRunId),
|
|
executionTargetKind: "remote",
|
|
}),
|
|
).not.toBeNull();
|
|
// Deployed v17 remote threads were opened without the run-scoped readers.
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: previousRun({
|
|
nativeToolContractFingerprint:
|
|
"sha256:96dde421aab01f847468d1a8f12863d32dfdb3038a9aca61e1e6bdc81022d5a1",
|
|
}),
|
|
currentExecution: execution(currentRunId),
|
|
executionTargetKind: "remote",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
it("permits legacy retry rebinding only before the replacement acquired authority", () => {
|
|
const source = {
|
|
runtimeMode: "native",
|
|
status: "interrupted",
|
|
nativeSessionId: normalizedSessionId,
|
|
};
|
|
const replacement = {
|
|
processPid: null,
|
|
processGroupId: null,
|
|
processStartedAt: null,
|
|
runnerProfileJson: {},
|
|
};
|
|
expect(
|
|
isUnusedLegacyNativeRetryReplacement({
|
|
source,
|
|
replacement,
|
|
hasProviderEvents: false,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
isUnusedLegacyNativeRetryReplacement({
|
|
source,
|
|
replacement: { ...replacement, processPid: 123 },
|
|
hasProviderEvents: false,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isUnusedLegacyNativeRetryReplacement({
|
|
source,
|
|
replacement,
|
|
hasProviderEvents: true,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isUnusedLegacyNativeRetryReplacement({
|
|
source,
|
|
replacement: {
|
|
...replacement,
|
|
runnerProfileJson: {
|
|
sessionCheckpoint: { providerSessionId: "claimed" },
|
|
},
|
|
},
|
|
hasProviderEvents: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("retains provider identity but clears prior turn and event state", () => {
|
|
const prior = previousRun();
|
|
const profile = prior.runnerProfileJson as Record<string, unknown>;
|
|
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
|
|
checkpoint.governedWait = { sourceEvent: { runId: previousRunId, turnId: "old-turn" }, result: { reportedWorkDisposition: "yielded" } };
|
|
const before = structuredClone(prior);
|
|
const rebound = rebindNativeSessionCheckpoint({
|
|
previousRun: prior,
|
|
currentExecution: execution(currentRunId),
|
|
});
|
|
expect(rebound?.governedWait).toBeUndefined();
|
|
expect(prior).toEqual(before);
|
|
expect(rebound).toMatchObject({
|
|
sessionId: "provider-thread-123",
|
|
providerSessionId: "provider-thread-123",
|
|
driverKind: "codex_app_server",
|
|
cursor: null,
|
|
semanticResult: null,
|
|
terminal: null,
|
|
activeTurnId: null,
|
|
terminalTurns: [],
|
|
pendingRuntimeRequests: [],
|
|
providerRecoveryPolicy: "allow_replacement_after_resume_failure",
|
|
identity: {
|
|
runId: currentRunId,
|
|
sessionId: normalizedSessionId,
|
|
companyId,
|
|
issueId,
|
|
agentId,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("allows a provider replacement only after a durable response wake", () => {
|
|
const source = previousRun();
|
|
const profile = source.runnerProfileJson as Record<string, unknown>;
|
|
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
|
|
checkpoint.semanticResult = {
|
|
schema: "paperclip.run_result.v1",
|
|
reportedWorkDisposition: "yielded",
|
|
summary: "Waiting for a response.",
|
|
continuation: {
|
|
kind: "response_wake",
|
|
idempotencyKey: "interaction-response:one",
|
|
},
|
|
};
|
|
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).toMatchObject({
|
|
providerRecoveryPolicy: "allow_replacement_after_governed_wait",
|
|
semanticResult: null,
|
|
activeTurnId: null,
|
|
});
|
|
});
|
|
|
|
it("requires exact provider continuity for an unfinished session goal", () => {
|
|
const source = previousRun();
|
|
const profile = source.runnerProfileJson as Record<string, unknown>;
|
|
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
|
|
checkpoint.goal = {
|
|
threadId: "provider-thread-123",
|
|
objective: "Keep this paused goal on its original provider session.",
|
|
status: "paused",
|
|
tokenBudget: null,
|
|
tokensUsed: 321,
|
|
timeUsedSeconds: 12,
|
|
};
|
|
checkpoint.semanticResult = {
|
|
schema: "paperclip.run_result.v1",
|
|
reportedWorkDisposition: "yielded",
|
|
summary: "Paused at a quiescent boundary.",
|
|
continuation: {
|
|
kind: "same_agent",
|
|
idempotencyKey: "goal:paused:one",
|
|
},
|
|
};
|
|
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).toMatchObject({
|
|
providerRecoveryPolicy: "same_session_only",
|
|
semanticResult: null,
|
|
activeTurnId: null,
|
|
goal: {
|
|
objective: "Keep this paused goal on its original provider session.",
|
|
status: "paused",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("refuses to resume when the workspace changes", () => {
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: previousRun(),
|
|
currentExecution: execution(currentRunId, "/different-workspace"),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("requires the exact managed execution-workspace identity", () => {
|
|
const managedWorkspaceId = "90000000-0000-4000-8000-000000000009";
|
|
const source = previousRun({
|
|
nativeExecutionInput: execution(
|
|
previousRunId,
|
|
"/shared-workspace",
|
|
"standard",
|
|
{ id: managedWorkspaceId },
|
|
),
|
|
});
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: execution(
|
|
currentRunId,
|
|
"/shared-workspace",
|
|
"standard",
|
|
{ id: managedWorkspaceId },
|
|
),
|
|
}),
|
|
).not.toBeNull();
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: execution(
|
|
currentRunId,
|
|
"/shared-workspace",
|
|
"standard",
|
|
{ id: "a0000000-0000-4000-8000-00000000000a" },
|
|
),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("permits per-run projectless placeholders only for an identical workspace descriptor", () => {
|
|
const source = previousRun({
|
|
nativeExecutionInput: execution(
|
|
previousRunId,
|
|
"/projectless",
|
|
"standard",
|
|
{
|
|
repoUrl: "https://example.test/repo.git",
|
|
repoRef: "refs/heads/main",
|
|
branchName: "main",
|
|
},
|
|
),
|
|
});
|
|
const matching = execution(currentRunId, "/projectless", "standard", {
|
|
repoUrl: "https://example.test/repo.git",
|
|
repoRef: "refs/heads/main",
|
|
branchName: "main",
|
|
});
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: matching,
|
|
}),
|
|
).not.toBeNull();
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: {
|
|
...matching,
|
|
workspace: { ...matching.workspace, repoRef: "refs/heads/next" },
|
|
},
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: execution(currentRunId, "/projectless", "standard", {
|
|
id: "b0000000-0000-4000-8000-00000000000b",
|
|
repoUrl: "https://example.test/repo.git",
|
|
repoRef: "refs/heads/main",
|
|
branchName: "main",
|
|
}),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rotates when assigned context changes but permits a fresh run-scoped MCP binding", () => {
|
|
const reboundCredential = execution(currentRunId);
|
|
reboundCredential.runtimeContext.mcp.bindingId = "native-mcp:fresh-run";
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: previousRun(),
|
|
currentExecution: reboundCredential,
|
|
}),
|
|
).not.toBeNull();
|
|
|
|
const changedAssignment = execution(currentRunId);
|
|
const withoutAggregate = {
|
|
prompt: changedAssignment.runtimeContext.prompt,
|
|
instructions: changedAssignment.runtimeContext.instructions,
|
|
skills: changedAssignment.runtimeContext.skills,
|
|
mcp: {
|
|
assignmentSetId: `sha256:${"1".repeat(64)}`,
|
|
digest: "1".repeat(64),
|
|
bindingId: "native-mcp:fresh-run",
|
|
},
|
|
};
|
|
changedAssignment.runtimeContext = {
|
|
...withoutAggregate,
|
|
aggregateDigest: canonicalNativeRuntimeContextDigest(withoutAggregate),
|
|
};
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: previousRun(),
|
|
currentExecution: changedAssignment,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("refuses a checkpoint whose prior-run binding was rewritten", () => {
|
|
const source = previousRun();
|
|
const profile = source.runnerProfileJson as Record<string, unknown>;
|
|
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
|
|
checkpoint.identity = {
|
|
...(checkpoint.identity as Record<string, unknown>),
|
|
runId: currentRunId,
|
|
};
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("reuses plan mode across canonical revisions but never across a mode change", () => {
|
|
const source = previousRun({
|
|
nativeExecutionInput: planningExecution(previousRunId, "revision-8"),
|
|
});
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: planningExecution(currentRunId, "revision-9"),
|
|
}),
|
|
).not.toBeNull();
|
|
expect(
|
|
rebindNativeSessionCheckpoint({
|
|
previousRun: source,
|
|
currentExecution: execution(currentRunId),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("buildNativeExecutionInput wake projection", () => {
|
|
it.each(["slack", "github"])("uses compact %s input only after provider resume, preserving fresh bootstrap fallback", (externalChatProvider) => {
|
|
const base = execution(currentRunId);
|
|
const issue = { id: issueId, identifier: "DOT-2", title: "Old greeting", description: null, workMode: "standard" };
|
|
const message = { id: "wake", body: "NEW_SLACK_MESSAGE", authorType: "user", authorId: "board", sourceTrust: "user", createdByRunId: null };
|
|
const input = buildNativeExecutionInput({
|
|
companyId, runId: currentRunId, agentId, issue,
|
|
taskPrompt: "FULL_BOOTSTRAP_WITH_FILE_AND_AUTHORIZATION_INSTRUCTIONS",
|
|
resumedSession: true,
|
|
previousTurn: { runId: previousRunId, task: issue },
|
|
wakePayload: {
|
|
reason: "External chat message received", issue,
|
|
externalChatProvider, checkedOutByHarness: true,
|
|
comments: [{ ...message, author: { type: "user", id: "board" } }], commentIds: [message.id], latestCommentId: message.id,
|
|
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
|
|
executionContinuation: { version: 1, objective: message.body, messages: [message], resumeDelta: { baseRunId: previousRunId, messages: [message] } },
|
|
},
|
|
workspace: { id: currentRunId, ...base.workspace }, normalizedSessionId,
|
|
provider: "codex", completionContract: base.completionContract,
|
|
runtimeContext: nativeRuntimeContextFixture(),
|
|
});
|
|
expect(input.continuationPrompt).toContain(message.body);
|
|
expect(input.continuationPrompt).not.toContain("FULL_BOOTSTRAP");
|
|
expect(input.task.prompt).toContain("FULL_BOOTSTRAP");
|
|
expect(input.task.prompt).toContain(message.body);
|
|
});
|
|
|
|
it.each(["github", null])("restores GitHub configuration on fresh fallback and sends changes on %s resume", (externalChatProvider) => {
|
|
const base = execution(currentRunId);
|
|
const issue = { id: issueId, identifier: "REVIEW-1", title: "PR #1", description: null, workMode: "standard" };
|
|
const config = { key: "a".repeat(64), instructions: "STABLE_REVIEW_POLICY" };
|
|
const message = { id: "new-push", body: "Review new head abc123", authorType: "user", authorId: "board", createdByRunId: null };
|
|
const args = {
|
|
companyId, runId: currentRunId, agentId, issue, taskPrompt: "Canonical task",
|
|
resumedSession: true, previousTurn: { runId: previousRunId, task: issue, githubReviewConfigurationKeys: [config.key] },
|
|
wakePayload: { reason: externalChatProvider ? "External chat message received" : "issue_commented", issue, externalChatProvider, checkedOutByHarness: true,
|
|
githubReviewConfigurations: [config],
|
|
comments: [{ ...message, author: { type: "user", id: "board" } }], commentIds: [message.id], latestCommentId: message.id,
|
|
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
|
|
executionContinuation: { version: 1, objective: message.body, messages: [{ ...message, id: "old", body: "OLD_REQUEST" }, message],
|
|
resumeDelta: { baseRunId: previousRunId, messages: [message] } },
|
|
},
|
|
workspace: { id: currentRunId, ...base.workspace }, normalizedSessionId,
|
|
provider: "codex" as const, completionContract: base.completionContract, runtimeContext: nativeRuntimeContextFixture(),
|
|
};
|
|
const prepared = buildNativeExecutionInput(args);
|
|
const resumed = JSON.stringify(buildNativeModelEnvelope(prepared, { resumedSession: true }));
|
|
expect(resumed).toContain("paperclip.native-continuation.v1");
|
|
expect(resumed).toContain(message.body);
|
|
expect(resumed).not.toContain(config.instructions);
|
|
expect(resumed).not.toContain("OLD_REQUEST");
|
|
const fresh = JSON.stringify(buildNativeModelEnvelope(prepared));
|
|
expect(fresh.split(config.instructions)).toHaveLength(2);
|
|
expect(fresh).toContain("OLD_REQUEST");
|
|
const changed = buildNativeExecutionInput({ ...args, previousTurn: { ...args.previousTurn, githubReviewConfigurationKeys: [] } });
|
|
expect(JSON.stringify(buildNativeModelEnvelope(changed, { resumedSession: true })).split(config.instructions)).toHaveLength(2);
|
|
const reset = buildNativeExecutionInput({ ...args, resumedSession: false });
|
|
expect(reset.continuationPrompt).toBeUndefined();
|
|
expect(JSON.stringify(buildNativeModelEnvelope(reset))).toContain(config.instructions);
|
|
});
|
|
|
|
it("uses a neutral turn title for authenticated external-chat follow-ups", () => {
|
|
const staleRootTitle = "Reply with exactly STALE-ROOT-MARKER";
|
|
const input = buildNativeExecutionInput({
|
|
companyId,
|
|
runId: currentRunId,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: "CHAT-4",
|
|
title: staleRootTitle,
|
|
description: "Started from Telegram.",
|
|
workMode: "standard",
|
|
},
|
|
taskPrompt: [
|
|
"Paperclip task context:",
|
|
`- Title: ${JSON.stringify(staleRootTitle)}`,
|
|
"Latest wake comment:",
|
|
"```text",
|
|
"Quick question: what is 55 + 8?",
|
|
"```",
|
|
].join("\n"),
|
|
wakePayload: {
|
|
reason: "External chat message received",
|
|
externalChatProvider: "telegram",
|
|
checkedOutByHarness: true,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: "CHAT-4",
|
|
title: staleRootTitle,
|
|
description: "Started from Telegram.",
|
|
descriptionTruncated: false,
|
|
status: "in_progress",
|
|
workMode: "standard",
|
|
},
|
|
commentWindow: {
|
|
requestedCount: 1,
|
|
includedCount: 1,
|
|
missingCount: 0,
|
|
},
|
|
commentIds: ["comment-current"],
|
|
latestCommentId: "comment-current",
|
|
comments: [
|
|
{
|
|
id: "comment-current",
|
|
issueId,
|
|
body: "Quick question: what is 55 + 8?",
|
|
bodyTruncated: false,
|
|
authorType: "user",
|
|
},
|
|
],
|
|
fallbackFetchNeeded: false,
|
|
},
|
|
agentId,
|
|
workspace: {
|
|
id: currentRunId,
|
|
cwd: "/workspace",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
branchName: null,
|
|
},
|
|
normalizedSessionId,
|
|
provider: "codex",
|
|
completionContract: {
|
|
id: "70000000-0000-4000-8000-000000000007",
|
|
sha256: `sha256:${"a".repeat(64)}`,
|
|
schemaVersion: "paperclip.run-result.v1",
|
|
contract: {
|
|
revision: "2",
|
|
objective: "Respond to the latest comment",
|
|
criteria: [
|
|
{
|
|
id: "objective",
|
|
requirement: "Quick question: what is 55 + 8?",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
runtimeContext: nativeRuntimeContextFixture(),
|
|
});
|
|
|
|
expect(input.task.title).toBe("External chat follow-up");
|
|
expect(input.task.description).toBeNull();
|
|
expect(input.task.prompt).toContain(staleRootTitle);
|
|
expect(input.task.prompt).toContain("Quick question: what is 55 + 8?");
|
|
expect(input.completionContract.contract).toMatchObject({
|
|
objective: "Respond to the latest comment",
|
|
criteria: [
|
|
{
|
|
id: "objective",
|
|
requirement: "Quick question: what is 55 + 8?",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("preserves the canonical task title outside the authenticated chat shortcut", () => {
|
|
const input = execution(currentRunId);
|
|
|
|
expect(input.task.title).toBe("Test");
|
|
});
|
|
|
|
it("writes native v4 and pins every provider's complete effective configuration", () => {
|
|
const common = {
|
|
companyId,
|
|
runId: currentRunId,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: "DOT-4",
|
|
title: "Permissions",
|
|
description: null,
|
|
workMode: "standard",
|
|
},
|
|
taskPrompt: "Verify permissions",
|
|
agentId,
|
|
workspace: {
|
|
id: currentRunId,
|
|
cwd: "/workspace",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
branchName: null,
|
|
},
|
|
normalizedSessionId,
|
|
completionContract: execution(currentRunId).completionContract,
|
|
runtimeContext: nativeRuntimeContextFixture(),
|
|
} as const;
|
|
const codex = buildNativeExecutionInput({
|
|
...common,
|
|
provider: "codex",
|
|
codexApprovalPolicy: "on-request",
|
|
});
|
|
const opencode = buildNativeExecutionInput({
|
|
...common,
|
|
provider: "opencode",
|
|
model: "openrouter/z-ai/glm-5.2",
|
|
opencodePermissionMode: "ask",
|
|
});
|
|
const acpx = buildNativeExecutionInput({
|
|
...common,
|
|
provider: "acpx",
|
|
acpxAgent: "claude",
|
|
model: "claude-sonnet-5",
|
|
acpxPermissionMode: "deny-all",
|
|
});
|
|
const claudeManaged = buildNativeExecutionInput({
|
|
...common,
|
|
provider: "claude_managed",
|
|
model: "claude-sonnet-5",
|
|
managedProfile: {
|
|
profileId: "managed-profile",
|
|
anthropicAgentId: "agent-remote",
|
|
agentVersion: "7",
|
|
environmentId: "environment-remote",
|
|
betaVersion: "managed-agents-2026-04-01",
|
|
},
|
|
maxSessionListCostUsd: 0.75,
|
|
});
|
|
const agentCore = buildNativeExecutionInput({
|
|
...common,
|
|
provider: "aws_agentcore",
|
|
model: "global.anthropic.claude-sonnet-4-6",
|
|
agentCoreProfile: {
|
|
profileId: "agentcore-profile",
|
|
region: "us-east-1",
|
|
accountId: "123456789012",
|
|
harnessArn:
|
|
"arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-1",
|
|
harnessVersion: "3",
|
|
endpointArn:
|
|
"arn:aws:bedrock-agentcore:us-east-1:123456789012:endpoint/e-1",
|
|
endpointQualifier: "prod",
|
|
agentRuntimeArn:
|
|
"arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/r-1",
|
|
memoryArn:
|
|
"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/m-1",
|
|
memoryId: "m-1",
|
|
invocationRoleArn: "arn:aws:iam::123456789012:role/invoke",
|
|
contextBucket: "paperclip-context",
|
|
contextPrefix: "runner/",
|
|
contextKmsKeyArn: "arn:aws:kms:us-east-1:123456789012:key/key-1",
|
|
qualificationRevision: "aws-agentcore-harness-context-v2",
|
|
eventExpiryDays: 90,
|
|
},
|
|
maxEstimatedSessionCostUsd: 1.25,
|
|
invocationLimits: {
|
|
maxIterations: 8,
|
|
maxOutputTokens: 4_096,
|
|
timeoutSeconds: 300,
|
|
},
|
|
});
|
|
const defaultOpenCode = buildNativeExecutionInput({
|
|
...common,
|
|
provider: "opencode",
|
|
model: "openrouter/z-ai/glm-5.2",
|
|
});
|
|
const defaultAcpx = buildNativeExecutionInput({
|
|
...common,
|
|
provider: "acpx",
|
|
model: "gpt-5.6-sol",
|
|
});
|
|
|
|
expect(codex).toMatchObject({
|
|
schema: "paperclip.native-execution-input.v5",
|
|
provider: { kind: "codex", approvalPolicy: "on-request" },
|
|
});
|
|
expect(opencode).toMatchObject({
|
|
schema: "paperclip.native-execution-input.v5",
|
|
provider: { kind: "opencode", permissionMode: "ask" },
|
|
});
|
|
expect(acpx).toMatchObject({
|
|
schema: "paperclip.native-execution-input.v5",
|
|
provider: { kind: "acpx", permissionMode: "deny-all" },
|
|
});
|
|
expect(claudeManaged).toMatchObject({
|
|
session: { driverKind: "claude_managed_agents_api" },
|
|
provider: {
|
|
kind: "claude_managed",
|
|
managedProfile: { profileId: "managed-profile" },
|
|
maxSessionListCostUsd: 0.75,
|
|
},
|
|
});
|
|
expect(agentCore).toMatchObject({
|
|
session: { driverKind: "aws_agentcore_harness_api" },
|
|
provider: {
|
|
kind: "aws_agentcore",
|
|
agentCoreProfile: {
|
|
profileId: "agentcore-profile",
|
|
eventExpiryDays: 90,
|
|
},
|
|
maxEstimatedSessionCostUsd: 1.25,
|
|
},
|
|
});
|
|
expect(defaultOpenCode).toMatchObject({
|
|
provider: { kind: "opencode", permissionMode: "allow" },
|
|
});
|
|
expect(defaultAcpx).toMatchObject({
|
|
provider: {
|
|
kind: "acpx",
|
|
agent: "codex",
|
|
permissionMode: "approve-all",
|
|
},
|
|
});
|
|
expect(
|
|
JSON.stringify([codex, opencode, claudeManaged, agentCore, acpx]),
|
|
).not.toMatch(
|
|
/OPENAI_API_KEY|ANTHROPIC_API_KEY|AWS_SECRET_ACCESS_KEY|PAPERCLIP_API_KEY/,
|
|
);
|
|
});
|
|
|
|
it.each([false, true])("projects wake context with the appropriate execution contract (conversation=%s)", (conversationMode) => {
|
|
const input = buildNativeExecutionInput({
|
|
companyId,
|
|
runId: currentRunId,
|
|
issue: {
|
|
id: issueId,
|
|
identifier: "DOT-146",
|
|
title: "Finish after child handoff",
|
|
description: "Use the child result.",
|
|
workMode: "standard",
|
|
},
|
|
taskPrompt: "Paperclip task context:\n- Issue: DOT-146",
|
|
wakePayload: {
|
|
reason: "issue_children_completed",
|
|
issue: {
|
|
id: issueId,
|
|
identifier: "DOT-146",
|
|
title: "Finish after child handoff",
|
|
description: "Use the child result.",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
workMode: "standard",
|
|
},
|
|
childIssueSummaries: [
|
|
{
|
|
id: "child-147",
|
|
identifier: "DOT-147",
|
|
title: "Build utility",
|
|
status: "done",
|
|
summary: "Created three files and passed 7/7 tests.",
|
|
},
|
|
],
|
|
childIssueSummaryTruncated: false,
|
|
checkedOutByHarness: true,
|
|
},
|
|
resumedSession: true,
|
|
conversationMode,
|
|
agentId,
|
|
workspace: {
|
|
id: currentRunId,
|
|
cwd: "/workspace",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
branchName: null,
|
|
},
|
|
normalizedSessionId,
|
|
provider: "opencode",
|
|
model: "openrouter/z-ai/glm-5.2",
|
|
completionContract: {
|
|
id: "70000000-0000-4000-8000-000000000007",
|
|
sha256: `sha256:${"a".repeat(64)}`,
|
|
schemaVersion: "paperclip.run-result.v1",
|
|
contract: {
|
|
revision: "1",
|
|
objective: "Finish after the child",
|
|
criteria: [
|
|
{ id: "objective", requirement: "Report the child result" },
|
|
],
|
|
},
|
|
},
|
|
runtimeContext: nativeRuntimeContextFixture(),
|
|
});
|
|
|
|
expect(input.task.prompt).not.toContain("Execution contract:");
|
|
expect(input.task.prompt).not.toContain("Use child issues");
|
|
// Full bootstrap stays available if provider recovery fails after admission.
|
|
expect(input.task.prompt).toContain("## Paperclip Wake Payload");
|
|
expect(input.task.prompt).toContain("reason: issue_children_completed");
|
|
expect(input.task.prompt).toContain("DOT-147 Build utility (done)");
|
|
expect(input.task.prompt).toContain(
|
|
"Created three files and passed 7/7 tests.",
|
|
);
|
|
expect(input.task.prompt).toContain(
|
|
"Paperclip task context:\n- Issue: DOT-146",
|
|
);
|
|
expect(input.task.prompt).not.toContain("Use the child result.");
|
|
});
|
|
});
|