mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
## Thinking Path > - Paperclip manages AI agents that perform work. > - Paperclip Runner connects durable task runs to local provider processes. > - The full-stack paid matrix exposed failures after the runner integrity repair. > - Verified JavaScript entrypoints lost their relative module graph when Linux executed them through descriptor paths. > - Returned provider startup errors also remained pending and became indeterminate after recovery. > - Sparse Codex tool lifecycle events lost the `write_document` identity before task transcript projection. > - This pull request repairs those three boundaries and makes the structured-question fixture deterministic. > - The benefit is repeatable provider startup, exact failure replay, and correct inline Plan placement. ## Linked Issues or Issue Description Refs #12721 and #12700. **What happened?** The paid runner matrix failed ACPX and OpenCode startup before provider session creation. The runner journal then replaced the original startup error with an indeterminate recovery result. Native Codex saved a Plan but rendered it only as a fallback card. A legacy Claude waiting reply could also echo the reserved terminal marker before the answer arrived. **Expected behavior** Verified JavaScript providers must start from immutable descriptor-backed artifacts. Returned startup failures must persist as terminal failed command results. Native tool lifecycle updates must preserve the `write_document` boundary. Pre-answer fixture output must not contain the reserved terminal marker. **Steps to reproduce** 1. Run the local provider cells in the Runner Full-Stack E2E workflow. 2. Observe ACPX and OpenCode fail during `session.open` before provider execution. 3. Observe recovery report `execution_indeterminate` instead of the original startup error. 4. Run the native Codex Plan cell and observe the fallback Plan card after the tool activity row. 5. Run the legacy Claude structured-question resume cell and observe an early marker echo in waiting prose. **Paperclip version or commit** `0f9452101740835ce0b1488a204bf48acd5bafc3` **Deployment mode** Local development with the paid GitHub Actions acceptance workflow. ## What Changed - Bundle the ACPX sidecar and OpenCode proxy as self-contained Node ESM entrypoints before hashing and verified descriptor launch. - Anchor ACPX dynamic provider package resolution at a controller-derived provider-pack root and keep that root out of the provider child environment. - Persist executor-returned startup errors as redacted durable failed command results while retaining indeterminate recovery for true process death. - Coalesce sparse native tool items by stable ID so a late `write_document` name, input, and result reach the transcript boundary once. - Forbid the structured-question fixture from spelling or announcing its reserved terminal marker before the user answers. ## Verification - Rust and TypeScript regression tests cover durable failed replay, true crash ambiguity, bundle closure, package-root derivation, environment filtering, exact Codex tool lifecycle coalescing, and prompt determinism. - Local execution is intentionally limited to formatters and static diff checks. GitHub Actions will run tests, type checks, builds, and security checks. - After ordinary CI is green, scoped paid cells will validate one ACPX launch, one OpenCode launch, native Codex Plan projection, and legacy Claude structured resume before a complete matrix rerun. - Prior failing matrix: https://github.com/paperclipai/paperclip/actions/runs/33682434315 ## Risks - Bundling changes the bytes covered by provider launch hashes. Provider-pack generation already hashes the final built files. - ACPX still loads qualified provider packages dynamically. The controller supplies a normalized package root, while existing version, digest, path, and descriptor checks remain active. - Durable `failed` is terminal. Replays return the same redacted result and do not execute the provider effect twice. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex based on GPT-5 with agentic reasoning, repository inspection, code editing, Git, parallel subagents, and GitHub Actions coordination. The exact deployed snapshot and context-window size are not exposed to this task. ## 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 linked related public work or described the bug in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [ ] I have run tests locally and they pass (intentionally deferred to GitHub Actions) - [x] I have added or updated tests where applicable - [x] No documentation change is required for this runtime repair - [x] I have considered and documented the risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
298 lines
8.0 KiB
TypeScript
298 lines
8.0 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { selectRerunArtifacts } from "./select-rerun-artifacts.js";
|
|
import type { RunnerE2EResult } from "./types.js";
|
|
|
|
const RUN_ID = "33843305626";
|
|
const SOURCE_SHA = "0123456789abcdef0123456789abcdef01234567";
|
|
const SOURCE_REF = "refs/heads/fix/runner-paid-matrix-integrity-v2";
|
|
const WORKFLOW_RUN_URL =
|
|
"https://github.com/paperclipai/paperclip/actions/runs/33843305626";
|
|
const RETAINED = "core-compatibility.legacy-codex.local.message-marker";
|
|
const RERUN = "core-compatibility.runner-codex.local.plan-revise-accept";
|
|
const cleanupDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
cleanupDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
function result(executionId: string, status: "passed" | "failed") {
|
|
const [suiteId, profileId, environmentId, caseId] = executionId.split(".");
|
|
return {
|
|
schema: "paperclip.runner-e2e.result/v2",
|
|
executionId,
|
|
suiteId,
|
|
source: {
|
|
sha: SOURCE_SHA,
|
|
ref: SOURCE_REF,
|
|
workflowRunUrl: WORKFLOW_RUN_URL,
|
|
},
|
|
attempt: 1,
|
|
status,
|
|
...(status === "failed"
|
|
? { failureClass: "candidate_failure" as const, error: "failed" }
|
|
: {}),
|
|
profileId: profileId!,
|
|
environmentId: environmentId as RunnerE2EResult["environmentId"],
|
|
caseId: caseId!,
|
|
provider: "codex",
|
|
model: "fixture-model",
|
|
runtimeMode: "native",
|
|
startedAt: "2026-09-04T00:00:00.000Z",
|
|
finishedAt: "2026-09-04T00:00:01.000Z",
|
|
durationMs: 1_000,
|
|
cleanup: status === "passed" ? "passed" : "not_started",
|
|
} satisfies RunnerE2EResult;
|
|
}
|
|
|
|
async function addArtifact(input: {
|
|
root: string;
|
|
executionId: string;
|
|
workflowAttempt: number;
|
|
status: "passed" | "failed";
|
|
sourceSha?: string;
|
|
campaignName?: string;
|
|
}) {
|
|
const artifactName = `runner-e2e-${RUN_ID}-${input.workflowAttempt}-${input.executionId}`;
|
|
const campaignName =
|
|
input.campaignName ??
|
|
`gha-${RUN_ID}-${input.workflowAttempt}-${input.executionId}`;
|
|
const directory = path.join(
|
|
input.root,
|
|
artifactName,
|
|
campaignName,
|
|
"results",
|
|
"attempt-1",
|
|
);
|
|
await mkdir(directory, { recursive: true });
|
|
const value = result(input.executionId, input.status);
|
|
await writeFile(
|
|
path.join(directory, "result.json"),
|
|
JSON.stringify({
|
|
...value,
|
|
source: { ...value.source, sha: input.sourceSha ?? value.source.sha },
|
|
}),
|
|
);
|
|
return { artifactName, campaignName };
|
|
}
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(
|
|
path.join(os.tmpdir(), "runner-e2e-rerun-artifacts-"),
|
|
);
|
|
cleanupDirectories.push(root);
|
|
return {
|
|
root,
|
|
artifactRoot: path.join(root, "downloaded"),
|
|
selectedRoot: path.join(root, "selected"),
|
|
};
|
|
}
|
|
|
|
function selectionInput(paths: Awaited<ReturnType<typeof fixture>>) {
|
|
return {
|
|
...paths,
|
|
jobs: {
|
|
jobs: [
|
|
{
|
|
name: RETAINED,
|
|
run_attempt: 1,
|
|
started_at: "2026-09-04T00:00:01.000Z",
|
|
},
|
|
{
|
|
name: RERUN,
|
|
run_attempt: 1,
|
|
started_at: "2026-09-04T00:00:02.000Z",
|
|
},
|
|
// filter=all synthesizes this attempt-2 row even though GitHub retained
|
|
// the successful attempt-1 job. Its original start time exposes it.
|
|
{
|
|
name: RETAINED,
|
|
run_attempt: 2,
|
|
started_at: "2026-09-04T00:00:01.000Z",
|
|
},
|
|
{
|
|
name: RERUN,
|
|
run_attempt: 2,
|
|
started_at: "2026-09-04T01:00:01.000Z",
|
|
},
|
|
],
|
|
attempts: [
|
|
{
|
|
run_attempt: 1,
|
|
run_started_at: "2026-09-04T00:00:00.000Z",
|
|
},
|
|
{
|
|
run_attempt: 2,
|
|
run_started_at: "2026-09-04T01:00:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
expectedExecutionIds: [RETAINED, RERUN],
|
|
workflowRunId: RUN_ID,
|
|
workflowRunAttempt: 2,
|
|
sourceSha: SOURCE_SHA,
|
|
sourceRef: SOURCE_REF,
|
|
workflowRunUrl: WORKFLOW_RUN_URL,
|
|
};
|
|
}
|
|
|
|
describe("runner E2E workflow rerun artifact selection", () => {
|
|
it("combines retained successes with the latest rerun artifact", async () => {
|
|
const paths = await fixture();
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RETAINED,
|
|
workflowAttempt: 1,
|
|
status: "passed",
|
|
});
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RERUN,
|
|
workflowAttempt: 1,
|
|
status: "failed",
|
|
});
|
|
const latest = await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RERUN,
|
|
workflowAttempt: 2,
|
|
status: "passed",
|
|
});
|
|
|
|
const selected = await selectRerunArtifacts(selectionInput(paths));
|
|
|
|
expect(selected).toEqual([
|
|
{
|
|
executionId: RETAINED,
|
|
workflowAttempt: 1,
|
|
artifactName: `runner-e2e-${RUN_ID}-1-${RETAINED}`,
|
|
},
|
|
{
|
|
executionId: RERUN,
|
|
workflowAttempt: 2,
|
|
artifactName: latest.artifactName,
|
|
},
|
|
]);
|
|
const selectedResult = JSON.parse(
|
|
await readFile(
|
|
path.join(
|
|
paths.selectedRoot,
|
|
latest.artifactName,
|
|
latest.campaignName,
|
|
"results",
|
|
"attempt-1",
|
|
"result.json",
|
|
),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(selectedResult.status).toBe("passed");
|
|
});
|
|
|
|
it("never falls back when the latest workflow attempt has no artifact", async () => {
|
|
const paths = await fixture();
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RETAINED,
|
|
workflowAttempt: 1,
|
|
status: "passed",
|
|
});
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RERUN,
|
|
workflowAttempt: 1,
|
|
status: "passed",
|
|
});
|
|
|
|
const selected = await selectRerunArtifacts(selectionInput(paths));
|
|
|
|
expect(selected.map((entry) => entry.executionId)).toEqual([RETAINED]);
|
|
});
|
|
|
|
it("does not let an older pass mask a latest failed artifact", async () => {
|
|
const paths = await fixture();
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RETAINED,
|
|
workflowAttempt: 1,
|
|
status: "passed",
|
|
});
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RERUN,
|
|
workflowAttempt: 1,
|
|
status: "passed",
|
|
});
|
|
const latest = await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RERUN,
|
|
workflowAttempt: 2,
|
|
status: "failed",
|
|
});
|
|
|
|
await selectRerunArtifacts(selectionInput(paths));
|
|
|
|
const selectedResult = JSON.parse(
|
|
await readFile(
|
|
path.join(
|
|
paths.selectedRoot,
|
|
latest.artifactName,
|
|
latest.campaignName,
|
|
"results",
|
|
"attempt-1",
|
|
"result.json",
|
|
),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(selectedResult.status).toBe("failed");
|
|
});
|
|
|
|
it("rejects artifacts from another source", async () => {
|
|
const paths = await fixture();
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RETAINED,
|
|
workflowAttempt: 1,
|
|
status: "passed",
|
|
sourceSha: "ffffffffffffffffffffffffffffffffffffffff",
|
|
});
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RERUN,
|
|
workflowAttempt: 2,
|
|
status: "passed",
|
|
});
|
|
|
|
await expect(selectRerunArtifacts(selectionInput(paths))).rejects.toThrow(
|
|
"contains result from another source",
|
|
);
|
|
});
|
|
|
|
it("rejects an artifact carrying another campaign", async () => {
|
|
const paths = await fixture();
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RETAINED,
|
|
workflowAttempt: 1,
|
|
status: "passed",
|
|
});
|
|
await addArtifact({
|
|
root: paths.artifactRoot,
|
|
executionId: RERUN,
|
|
workflowAttempt: 2,
|
|
status: "passed",
|
|
campaignName: `gha-another-run-2-${RERUN}`,
|
|
});
|
|
|
|
await expect(selectRerunArtifacts(selectionInput(paths))).rejects.toThrow(
|
|
/must contain only its exact campaign/u,
|
|
);
|
|
});
|
|
});
|