Files
PaperClipAI/tests/runner-e2e/dashboard-regenerate.ts
T
Dotta bcc6fe7a44 fix(runner): restore multi-turn remote sessions (#12840)
## 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
2026-09-05 06:25:06 -05:00

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;
});
}