mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
test: verify first-admission context and prepare source dependencies
This commit is contained in:
1 parent
de14edc606
commit
12b2a8eff9
7 files changed
+101
-11
No files matched your search
@@ -6,7 +6,12 @@ The explicit-only `automatic-repository` suite is an early repair check for
|
||||
native Codex and Claude on local and Daytona execution. One browser request
|
||||
must stage its managed repository, continue without a timer or another user
|
||||
message, and deliver a registered download. The oracle compares downloaded
|
||||
JSON with the actual checkout and the original task file. Both admissions
|
||||
JSON with the actual checkout and the original task file. The source admission
|
||||
creates an unpredictable reference and registers its bytes before setup. The
|
||||
observer must download that source-attributed artifact while only the first
|
||||
admission is running, before the successor starts. A missing original, late
|
||||
observation, or different successor-created reference fails qualification. The
|
||||
successor is instructed to stop on a missing file and never recreate it. Both admissions
|
||||
must retain one workspace and commit their native results. The fixture uses
|
||||
production hire instructions and records the selected catalog model. These four
|
||||
development cells do not qualify Slack, staging configurations, migration,
|
||||
@@ -16,6 +21,9 @@ Select it explicitly with `--suite automatic-repository`. Every execution has
|
||||
one attempt, two expected provider admissions, and 1,000-cent company and agent
|
||||
hard stops. Automatic infrastructure retries are disabled. Existing campaign
|
||||
reports retain failures, timings, usage, runtime costs, and source identity.
|
||||
Source-server startup prepares the existing shared/plugin SDK build dependencies
|
||||
before importing native tool authority. Installed-package checks never execute
|
||||
checkout build helpers.
|
||||
|
||||
## Live provider connection journeys
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ export async function runAutomaticRepositoryFlow(input: {
|
||||
const files = automaticRepositoryFiles(nonce);
|
||||
let issue: Row | undefined;
|
||||
let runs: Row[] = [];
|
||||
const observation: AutomaticRepositoryObservation = { reference: files.reference, issueDone: false, runCount: 0,
|
||||
const observation: AutomaticRepositoryObservation = { reference: "", issueDone: false, runCount: 0,
|
||||
successfulCommittedRuns: 0, sourceContinuation: null, successorSource: null, workspaceIds: [], readyRepository: false,
|
||||
savedContext: null, actualReadme: null, actualCommit: null, downloaded: null, downloadVerified: false,
|
||||
savedContext: null, firstSavedContext: null, firstContextAttributed: false,
|
||||
firstContextObservedAt: null, successorStartedAt: null,
|
||||
actualReadme: null, actualCommit: null, downloaded: null, downloadVerified: false,
|
||||
visibleDelivery: false, pendingInteractions: -1, monitorScheduled: false };
|
||||
async function refresh() {
|
||||
if (!issue) return;
|
||||
@@ -41,6 +43,17 @@ export async function runAutomaticRepositoryFlow(input: {
|
||||
run.nativePhase === "committed" && run.resultJson?.finalizationPhase === "committed").length;
|
||||
observation.sourceContinuation = runs[0]?.resultJson?.nativeResult?.continuation?.kind ?? null;
|
||||
observation.successorSource = runs[1]?.contextSnapshot?.source ?? null;
|
||||
observation.successorStartedAt = runs[1]?.startedAt ? Date.parse(runs[1].startedAt) : null;
|
||||
if (runs.length === 1 && runs[0]?.status === "running" && observation.firstContextObservedAt === null) {
|
||||
// Observe the source artifact while its admission is still running. A
|
||||
// successor-created file must never become the baseline for retention.
|
||||
const original = await readRegisteredArtifacts(api, issue.id, runs[0].id, [files.context]).catch(() => null);
|
||||
if (original?.[0]) {
|
||||
observation.firstSavedContext = original[0].content;
|
||||
observation.firstContextAttributed = original[0].contentVerified;
|
||||
observation.firstContextObservedAt = Date.now();
|
||||
}
|
||||
}
|
||||
observation.workspaceIds = runs.map(run => run.runnerProfileJson?.nativeExecutionInput?.binding?.executionWorkspaceId ?? null);
|
||||
observation.monitorScheduled ||= Boolean(issue.monitorNextCheckAt || runs.some(run => String(run.resultJson?.finalizationReasonCode).startsWith("scheduled_monitor_")));
|
||||
}
|
||||
@@ -86,6 +99,9 @@ export async function runAutomaticRepositoryFlow(input: {
|
||||
observation.actualCommit = (await git(["rev-parse", "HEAD"])).trim();
|
||||
observation.actualReadme = await git(["show", "HEAD:README"]);
|
||||
observation.readyRepository = repositories[0].pinnedCommit === observation.actualCommit;
|
||||
const [original] = await readRegisteredArtifacts(api, issue.id, runs[0]!.id, [files.context]);
|
||||
observation.firstContextAttributed &&= original.contentVerified && original.content === observation.firstSavedContext;
|
||||
if (/^[a-f0-9]{64}\n$/.test(original.content)) observation.reference = original.content.trimEnd();
|
||||
const context = await api.get<Row>(`/api/issues/${issue.id}/file-resources/content?workspace=execution&path=${encodeURIComponent(files.context)}`);
|
||||
observation.savedContext = context.resource?.workspaceKind === "execution_workspace" && typeof context.content?.data === "string" ? context.content.data : null;
|
||||
const [artifact] = await readRegisteredArtifacts(api, issue.id, runs[1]!.id, [files.result]);
|
||||
|
||||
@@ -2,10 +2,13 @@ import { expect, it } from "vitest";
|
||||
import { gradeAutomaticRepository, type AutomaticRepositoryObservation } from "./automatic-repository.js";
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
const valid: AutomaticRepositoryObservation = { reference: "REFERENCE-fresh", issueDone: true, runCount: 2,
|
||||
const reference = "c".repeat(64);
|
||||
const valid: AutomaticRepositoryObservation = { reference, issueDone: true, runCount: 2,
|
||||
successfulCommittedRuns: 2, sourceContinuation: "same_agent", successorSource: "native_status_decision", workspaceIds: ["workspace", "workspace"],
|
||||
readyRepository: true, savedContext: "REFERENCE-fresh\n", actualReadme: "Hello World!\n", actualCommit: sha,
|
||||
downloaded: { reference: "REFERENCE-fresh", readme: "Hello World!\n", commit: sha }, downloadVerified: true,
|
||||
readyRepository: true, savedContext: `${reference}\n`, firstSavedContext: `${reference}\n`, firstContextAttributed: true,
|
||||
firstContextObservedAt: 1000, successorStartedAt: 2000,
|
||||
actualReadme: "Hello World!\n", actualCommit: sha,
|
||||
downloaded: { reference, readme: "Hello World!\n", commit: sha }, downloadVerified: true,
|
||||
visibleDelivery: true, pendingInteractions: 0, monitorScheduled: false };
|
||||
|
||||
it("accepts independent checkout, retained context, and accessible registered output", () => {
|
||||
@@ -19,7 +22,10 @@ it("rejects unexpected result fields rather than trusting a model's extra claims
|
||||
|
||||
it.each<Partial<AutomaticRepositoryObservation>>([
|
||||
{ runCount: 1 }, { successfulCommittedRuns: 1 }, { sourceContinuation: "monitor" }, { successorSource: "issue.comment" },
|
||||
{ workspaceIds: ["old", "new"] }, { savedContext: null }, { readyRepository: false }, { downloaded: null },
|
||||
{ workspaceIds: ["old", "new"] }, { savedContext: null }, { firstSavedContext: null }, { firstContextAttributed: false },
|
||||
{ firstSavedContext: `${"d".repeat(64)}\n` }, { firstSavedContext: "REFERENCE-fresh\n" },
|
||||
{ firstContextObservedAt: null }, { firstContextObservedAt: 2000 }, { firstContextObservedAt: 3000 }, { successorStartedAt: null },
|
||||
{ readyRepository: false }, { downloaded: null },
|
||||
{ downloaded: { ...valid.downloaded, reference: "REFERENCE-stale" } },
|
||||
{ downloaded: { ...valid.downloaded, readme: "I inspected it and all checks pass" } },
|
||||
{ downloaded: { ...valid.downloaded, commit: "b".repeat(40) } },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import type { RunnerTaskFixture } from "./types.js";
|
||||
|
||||
export function automaticRepositoryFiles(nonce: string) {
|
||||
return { context: `setup-context-${nonce}.txt`, result: `repository-result-${nonce}.json`, reference: `REFERENCE-${nonce}` };
|
||||
return { context: `setup-context-${nonce}.txt`, result: `repository-result-${nonce}.json` };
|
||||
}
|
||||
|
||||
export const automaticRepositoryTask: RunnerTaskFixture = {
|
||||
@@ -17,9 +17,10 @@ export const automaticRepositoryTask: RunnerTaskFixture = {
|
||||
const files = automaticRepositoryFiles(nonce);
|
||||
return [
|
||||
"Verify a repository in this projectless task and deliver the result. Do not create a project or another task.",
|
||||
`Before repository setup, save ${files.context} in the task directory with exactly ${JSON.stringify(`${files.reference}\n`)}. The final newline is an actual LF.`,
|
||||
`Before repository setup, generate a fresh unpredictable 32-byte hexadecimal reference using the operating system random source. Save that reference followed by one LF in ${files.context} in the task directory. Do not include its value in comments or the final answer.`,
|
||||
`Before requesting repository preparation, register ${files.context} as a downloadable artifact with that exact title. This records the first admission's original bytes.`,
|
||||
"Use the task's managed repository capability to prepare https://github.com/octocat/Hello-World. Keep the task directory as the root.",
|
||||
"When the checkout is ready, read its committed README and HEAD commit. Read the saved context file again; preserve its original reference.",
|
||||
"When the checkout is ready, read its committed README and HEAD commit. Read the saved context file again; preserve its original reference. If the file is missing or invalid, report the failure and stop. Never recreate, overwrite, or recover it from an attachment or earlier conversation.",
|
||||
`Write ${files.result} as JSON with exactly these fields: reference (the saved reference without its trailing LF), readme (the complete committed README bytes as text), and commit (the full HEAD SHA).`,
|
||||
`Register ${files.result} as a downloadable artifact work product with that exact title. Deliver its accessible link and finish the verification. Repository setup alone does not complete this request.`,
|
||||
].join("\n");
|
||||
@@ -37,6 +38,10 @@ export interface AutomaticRepositoryObservation {
|
||||
workspaceIds: Array<string | null>;
|
||||
readyRepository: boolean;
|
||||
savedContext: string | null;
|
||||
firstSavedContext: string | null;
|
||||
firstContextAttributed: boolean;
|
||||
firstContextObservedAt: number | null;
|
||||
successorStartedAt: number | null;
|
||||
actualReadme: string | null;
|
||||
actualCommit: string | null;
|
||||
downloaded: { reference?: unknown; readme?: unknown; commit?: unknown } | null;
|
||||
@@ -55,7 +60,10 @@ export function gradeAutomaticRepository(value: AutomaticRepositoryObservation)
|
||||
"Repository setup must continue through the saved same-agent wake without a timer or user message."),
|
||||
check("retained-workspace", value.workspaceIds.length === 2 && Boolean(value.workspaceIds[0]) && value.workspaceIds.every(id => id === value.workspaceIds[0]) && value.readyRepository,
|
||||
"Both admissions must retain the same task workspace and its prepared repository."),
|
||||
check("retained-context", value.savedContext === `${value.reference}\n`, "The original task file must survive setup byte for byte."),
|
||||
check("retained-context", value.firstContextAttributed && value.firstContextObservedAt !== null && value.successorStartedAt !== null &&
|
||||
value.firstContextObservedAt < value.successorStartedAt && /^[a-f0-9]{64}\n$/.test(value.firstSavedContext ?? "") &&
|
||||
value.firstSavedContext === value.savedContext && value.savedContext === `${value.reference}\n`,
|
||||
"The first admission's independently downloaded unpredictable reference must survive setup byte for byte."),
|
||||
check("independent-result", value.downloadVerified && value.downloaded !== null &&
|
||||
Object.keys(value.downloaded).sort().join(",") === "commit,readme,reference" && value.downloaded.reference === value.reference &&
|
||||
typeof value.actualReadme === "string" && value.actualReadme.length > 0 && value.downloaded?.readme === value.actualReadme &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { runnerMatrix } from "./catalog.js";
|
||||
import { createRunnerE2EServerStopper, runnerE2EServerDetached } from "./server-stop.js";
|
||||
import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js";
|
||||
import { qualifyLegacyClaudeCli } from "./legacy-claude-cli.js";
|
||||
import { prepareSourceServerDependencies } from "./source-startup.js";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
@@ -98,6 +99,8 @@ const definedServerEnvironment = Object.fromEntries(
|
||||
),
|
||||
);
|
||||
|
||||
await prepareSourceServerDependencies(repositoryRoot, definedServerEnvironment, usesInstalledCli(process.env));
|
||||
|
||||
await Promise.all([
|
||||
mkdir(path.dirname(logPath), { recursive: true }),
|
||||
mkdir(controlDirectory, { recursive: true, mode: 0o700 }),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { prepareSourceServerDependencies } from "./source-startup.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
async function fixture(source: string) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-source-startup-")); roots.push(root);
|
||||
await mkdir(path.join(root, "scripts"));
|
||||
await writeFile(path.join(root, "scripts/ensure-plugin-build-deps.mjs"), source);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("prepares missing source dependencies before the entry can import them", async () => {
|
||||
const root = await fixture(`import {writeFileSync} from 'node:fs'; writeFileSync('dependency.mjs', 'export default 42');`);
|
||||
await expect(readFile(path.join(root, "dependency.mjs"))).rejects.toThrow();
|
||||
await prepareSourceServerDependencies(root, {}, false);
|
||||
expect(await readFile(path.join(root, "dependency.mjs"), "utf8")).toBe("export default 42");
|
||||
});
|
||||
|
||||
it("fails before server admission when dependency preparation fails", async () => {
|
||||
const root = await fixture("process.exit(7)");
|
||||
await expect(prepareSourceServerDependencies(root, {}, false)).rejects.toMatchObject({ code: 7 });
|
||||
});
|
||||
|
||||
it("does not execute checkout helpers for an installed package", async () => {
|
||||
const root = await fixture("throw new Error('must not execute checkout code')");
|
||||
await expect(prepareSourceServerDependencies(root, {}, true)).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
/** The source entry imports tool authority before the CLI can prepare these packages. */
|
||||
export async function prepareSourceServerDependencies(
|
||||
repositoryRoot: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
installed: boolean,
|
||||
) {
|
||||
if (installed) return;
|
||||
await promisify(execFile)(process.execPath, [path.join(repositoryRoot, "scripts/ensure-plugin-build-deps.mjs")], {
|
||||
cwd: repositoryRoot,
|
||||
env: environment,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user