mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - The runner executes agent turns on local and remote providers. > - A remote per-turn session must save its state before Paperclip releases its sandbox. > - The session runtime returned after 100 milliseconds while the remote checkpoint still ran. > - The next turn also checked the local state path instead of the verified remote backup. > - This pull request waits for the bounded remote close and accepts only a verified suspended backup. > - The benefit is reliable multi-turn execution without weaker identity checks. ## Linked Issues or Issue Description **What happened?** A successful remote agent turn released its sandbox before the runner saved the verified continuation backup. The next turn failed with `runner_state_identity_mismatch`. **Expected behavior** Paperclip must finish the bounded remote checkpoint before it releases the sandbox. A later turn must validate and restore the digest-matched suspended backup. **Steps to reproduce** 1. Run a native ACPX Claude Plan test in a non-reusable Daytona sandbox. 2. Reject the first plan to start a second turn. 3. Observe that the second turn fails before provider execution. **Paperclip version or commit** The failure reproduced at `13775a90b078ff64872f50961ea1b83d575e7bc6`. **Deployment mode** GitHub Actions with a Daytona sandbox. ## What Changed - Wait for the internally bounded remote runner close and checkpoint before the host returns. - Preserve the existing short cleanup bound for other providers. - Validate remote continuation lifecycle from a complete digest-verified backup when local runner state is absent. - Keep corrupt, non-suspended, mismatched, and unverified state fail-closed. - Make native Plan completion and accepted-Plan wake prompts deterministic. ## Verification - A prior 45-cell local campaign passed 44 cells. The only failure was the OpenCode Plan prompt variance fixed here. - A focused OpenCode local Plan rerun passed. - ACPX Claude Daytona message and question cells passed. - Focused regressions cover delayed checkpoint close and verified remote backup lifecycle. - GitHub Build and the focused ACPX Claude Daytona Plan cell will validate this exact head. ## Risks Remote runnerd sessions now wait for their internally bounded close/checkpoint path before returning; generic provider cleanup retains the existing 100 millisecond bound. Durable run success still cannot be reversed. The environment release guard still blocks sandbox destruction when no verified backup stamp exists. ## Model Used OpenAI Codex, GPT-5.6, extended reasoning, with code execution and GitHub Actions inspection. ## 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 and contains no internal task id - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] 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 findings - [ ] I will address all Greptile and reviewer comments before requesting merge
206 lines
6.2 KiB
TypeScript
206 lines
6.2 KiB
TypeScript
import path from "node:path";
|
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
import { runnerMatrix } from "./catalog.js";
|
|
import { renderRunnerE2EDashboard } from "./dashboard.js";
|
|
import {
|
|
buildRunnerCampaign,
|
|
canonicalExecutionId,
|
|
upgradeRunnerResult,
|
|
} from "./history.js";
|
|
import type {
|
|
RunnerE2ECampaign,
|
|
RunnerE2EHistoryIndex,
|
|
RunnerE2EResult,
|
|
} from "./types.js";
|
|
|
|
interface PublishedResult extends RunnerE2EResult {
|
|
evidenceValid?: boolean;
|
|
evidenceErrors?: string[];
|
|
}
|
|
|
|
interface PublishedCampaign {
|
|
schema?: string;
|
|
campaignId?: string;
|
|
generatedAt: string;
|
|
expected: string[];
|
|
results: PublishedResult[];
|
|
}
|
|
|
|
async function relativeFiles(root: string, current = root): Promise<string[]> {
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(
|
|
() => [],
|
|
);
|
|
const files: string[] = [];
|
|
for (const entry of entries) {
|
|
const absolute = path.join(current, entry.name);
|
|
if (entry.isDirectory())
|
|
files.push(...(await relativeFiles(root, absolute)));
|
|
if (entry.isFile()) {
|
|
files.push(path.relative(root, absolute).split(path.sep).join("/"));
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
async function readOptionalHistory(file: string) {
|
|
return readFile(file, "utf8")
|
|
.then((value) => JSON.parse(value) as RunnerE2EHistoryIndex)
|
|
.then((value) =>
|
|
value.schema === "paperclip.runner-e2e.history/v1" ? value : undefined,
|
|
)
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
export async function regenerateRunnerDashboard(input: {
|
|
bundle: string;
|
|
historyFile?: string | null;
|
|
outputDirectory?: string;
|
|
evidenceHrefPrefix?: string;
|
|
publicSummaryImageHref?: string;
|
|
}) {
|
|
const bundle = path.resolve(input.bundle);
|
|
const outputDirectory = path.resolve(input.outputDirectory ?? bundle);
|
|
const evidenceHrefPrefix = input.evidenceHrefPrefix?.replace(
|
|
/^\/+|\/+$/g,
|
|
"",
|
|
);
|
|
if (
|
|
evidenceHrefPrefix &&
|
|
(evidenceHrefPrefix.includes("\\") ||
|
|
evidenceHrefPrefix
|
|
.split("/")
|
|
.some((segment) => !segment || segment === "." || segment === ".."))
|
|
) {
|
|
throw new Error("evidenceHrefPrefix must be a safe relative URL path");
|
|
}
|
|
const normalized = JSON.parse(
|
|
await readFile(path.join(bundle, "normalized-results.json"), "utf8"),
|
|
) as PublishedCampaign;
|
|
if (
|
|
!Array.isArray(normalized.expected) ||
|
|
!Array.isArray(normalized.results) ||
|
|
typeof normalized.generatedAt !== "string"
|
|
) {
|
|
throw new Error("Published bundle has an invalid normalized-results.json");
|
|
}
|
|
|
|
const results = normalized.results.map(upgradeRunnerResult);
|
|
const expected = normalized.expected.map(canonicalExecutionId);
|
|
const campaign: RunnerE2ECampaign = buildRunnerCampaign({
|
|
campaignId:
|
|
normalized.campaignId ??
|
|
`legacy-${normalized.generatedAt.replace(/[:.]/g, "-")}`,
|
|
generatedAt: normalized.generatedAt,
|
|
expected,
|
|
results,
|
|
});
|
|
const history =
|
|
input.historyFile === null
|
|
? undefined
|
|
: await readOptionalHistory(
|
|
input.historyFile ?? path.join(bundle, "history.json"),
|
|
);
|
|
const entries = await Promise.all(
|
|
normalized.results.map(async (publishedResult, index) => {
|
|
const result = results[index]!;
|
|
const originalExecutionId = publishedResult.executionId;
|
|
const evidenceIds = [
|
|
originalExecutionId,
|
|
...(originalExecutionId.startsWith("core-compatibility.")
|
|
? [originalExecutionId.slice("core-compatibility.".length)]
|
|
: []),
|
|
];
|
|
let evidenceBaseHref = "";
|
|
let evidenceFiles: string[] = [];
|
|
for (const evidenceId of evidenceIds) {
|
|
const candidate = [
|
|
"evidence",
|
|
evidenceId,
|
|
`attempt-${result.attempt}`,
|
|
].join("/");
|
|
const files = await relativeFiles(
|
|
path.join(bundle, ...candidate.split("/")),
|
|
);
|
|
if (files.length === 0) continue;
|
|
evidenceBaseHref = [evidenceHrefPrefix, candidate]
|
|
.filter(Boolean)
|
|
.join("/");
|
|
evidenceFiles = files;
|
|
break;
|
|
}
|
|
return {
|
|
result,
|
|
valid:
|
|
publishedResult.evidenceValid ??
|
|
(result.status === "passed" && result.cleanup === "passed"),
|
|
errors: publishedResult.evidenceErrors ?? [],
|
|
evidenceBaseHref,
|
|
evidenceFiles,
|
|
};
|
|
}),
|
|
);
|
|
const dashboard = renderRunnerE2EDashboard({
|
|
title: "Runner Full-Stack E2E",
|
|
generatedAt: normalized.generatedAt,
|
|
expected,
|
|
catalog: runnerMatrix,
|
|
entries,
|
|
campaign,
|
|
history,
|
|
publicSummaryImageHref: input.publicSummaryImageHref,
|
|
});
|
|
const upgraded = {
|
|
...campaign,
|
|
results: campaign.results.map((result, index) => ({
|
|
...result,
|
|
evidenceValid:
|
|
normalized.results[index]?.evidenceValid ??
|
|
(result.status === "passed" && result.cleanup === "passed"),
|
|
evidenceErrors: normalized.results[index]?.evidenceErrors ?? [],
|
|
})),
|
|
};
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
await Promise.all([
|
|
writeFile(path.join(outputDirectory, "index.html"), dashboard, "utf8"),
|
|
writeFile(path.join(outputDirectory, "dashboard.html"), dashboard, "utf8"),
|
|
writeFile(
|
|
path.join(outputDirectory, "normalized-results.json"),
|
|
`${JSON.stringify(upgraded, null, 2)}\n`,
|
|
"utf8",
|
|
),
|
|
]);
|
|
console.log(
|
|
`Regenerated dashboard from ${normalized.results.length} retained result(s) in ${outputDirectory}`,
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const arguments_ = process.argv
|
|
.slice(2)
|
|
.filter((argument) => argument !== "--");
|
|
const bundleArgument = arguments_.find(
|
|
(argument) => !argument.startsWith("--"),
|
|
);
|
|
if (!bundleArgument) {
|
|
throw new Error(
|
|
"Usage: pnpm test:e2e:runner:dashboard -- <published-bundle-directory> [--history <history.json>]",
|
|
);
|
|
}
|
|
const historyIndex = arguments_.indexOf("--history");
|
|
const historyFile =
|
|
historyIndex >= 0 ? arguments_[historyIndex + 1] : undefined;
|
|
if (historyIndex >= 0 && !historyFile)
|
|
throw new Error("--history requires a path");
|
|
await regenerateRunnerDashboard({ bundle: bundleArgument, historyFile });
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
|
|
) {
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|