mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 12:29:37 +01:00
## Thinking Path > - Paperclip manages agent work and preserves task state. > - Cloud readiness verifies an exact source revision before release. > - Its workspace tests must wait for execution and database cleanup to finish. > - Two attempts hit the same ten-second polling limit and read a running row. > - The second attempt also lost AWS runners and exceeded a fixture cleanup limit. > - This pull request waits for owned execution, bounds client shutdown, and gives verification reruns an independent runner pool. > - Assertions, source identity and required verification gates stay in place. ## Linked Issues or Issue Description **What happened?** [Cloud readiness run 41684102136](https://github.com/paperclipai/paperclip/actions/runs/41684102136) failed twice on master `367f29478d14003cc5c0f161b7ce8f3164993634`. Attempt 1 failed `heartbeat-workspace-busy.test.ts:473`: expected `succeeded`, received `running`. Attempt 2 failed the same helper in the inherited-workspace resume case at line 744. The helper polls for ten seconds, then returns the active row as if the wait finished. Attempt 2 also exceeded the 30-second `workspace-preparation.test.ts` teardown limit. Its client close had no deadline, and database plus directory cleanup shared the default hook budget despite the fixture's existing 90-second startup budget. Other attempt-2 failures show runner shutdown or lost communication. Several shutdown signals arrived at 18:05:17 UTC. Read-only AWS inspection confirms that all eight infrastructure-failed instances carry `runs-on-workflow-job-interrupted=true`. Five have explicit `Server.SpotInstanceTermination` reasons, including the server-1 runner that lost communication. Three were terminated through the fleet's shutdown path. CloudTrail records the Rust runner's interruption tag at 18:03:37 UTC and its termination by the RunsOn fleet role at 18:05:31 UTC, despite EC2 showing a user-initiated termination. Repeating the entire workflow reused the same Spot fleet. **Expected behavior** Wait for owned heartbeat execution before reading final state or scheduled retries. Bound database-client shutdown and allow the fixture's cleanup cost class to finish. Rerun verification on GitHub-hosted runners after an interrupted fleet attempt. Preserve all checks and report prior failures. **Steps to reproduce** 1. Delay the test adapter beyond the helper's ten-second polling window. 2. Invoke the ordinary local shared-workspace run or resume the deferred sandbox run. 3. Observe the old helper return `running` before the execution completes. 4. Evaluate the old runner expressions with `github.run_attempt = 2`; they still choose the interrupted AWS pool. **Paperclip version or commit** Base: `367f29478d14003cc5c0f161b7ce8f3164993634`. **Deployment mode** GitHub Actions Cloud readiness, with disposable PostgreSQL fixtures. Related: #11293 fixed the earlier gap between cancellation and retry-row insertion. This change waits for the complete execution instead. Duplicate searches covered the workspace test, retention fixture and runner shutdown reports. ## What Changed - Replace both workspace-test polling helpers with the existing heartbeat completion barrier. Keep final status, retry and coordination assertions. - Bound workspace-preparation client shutdown to one second. Apply the existing embedded-PostgreSQL fixture budget to its teardown. - Use AWS only for the first exact-source attempt. Route later verification, chaos and cloud-migrator planning/build attempts to GitHub-hosted runners. The publisher keeps its existing routing. The final branch also incorporates upstream #18821's CODEOWNER routing fix. - Add runner-expression coverage for numeric, string, missing and later attempt values. Document retry routing. ## Verification - The original workflow's failed assertions and interrupted jobs remain linked above. A local two-suite run also passed all 157 assertions and failed only the original 30-second teardown hook. - All 341 workflow contracts pass. After the master synchronization, all 347 routing and CI-planner controls pass. Actionlint passes for the three changed workflows. - A matched delayed-adapter comparison uses disposable PostgreSQL 17 databases, the same 11-second adapter delay, and the same 90-second experimental outer budget. The old helper fails with `running`; the completion barrier passes. Production test deadlines stay unchanged. - Earlier experiments hit a 30-second experimental outer limit and the macOS host's shared-memory limit during native database startup. The completed comparison uses task-owned databases on an existing test server. Those earlier attempts are not counted as passes. - All 31 workspace-busy cases pass with normal test deadlines. The 126-case workspace fixture also passes with its 90-second teardown budget. - Final-head cleanup measurements: main client close 7 ms, owned database cleanup 20,938 ms, and workspace-home removal 826 ms. A measured fixture held 124,494 PostgreSQL files (208 MiB) after repeated table resets. The measured database cleanup alone consumes most of the old 30-second hook budget. The separate baseline reproduced a timeout at that hook; the fixture now uses its established 90-second cost class. - Repository typecheck and build pass on synchronized head `2aceb3abd144a569efddabed3b7e0550ea23ceb2`. - All 157 focused cases pass on the synchronized branch, including complete PostgreSQL 18 fixture shutdown. [Full hosted CI](https://github.com/paperclipai/paperclip/actions/runs/41697804471) passes on the final head, including the required verification and E2E aggregates, all server/chat/workspace groups, Runner lanes and all eight browser shards. The separate GitHub quality-review workflow also passes. All 60 reported checks are terminal: 54 pass, three are informational neutral results, and three are expected skips. There are no failures or pending checks. The complete local `pnpm test:run` command was not run; targeted fixtures already encountered this host's shared-memory limit. Hosted CI supplies the full Vitest and browser matrix. - Greptile and Paperclip Review both give synchronized head `2aceb3abd144a569efddabed3b7e0550ea23ceb2` 5/5. There are no open review threads. - Module-boundary and whitespace checks pass. ## Risks - Hosted reruns can have different cache hit rates and queue times. They execute the same source and verification commands. - The first attempt retains the existing AWS Spot fleet policy. AWS evidence establishes several interruption-triggered failures; later verification attempts use an independent pool. This does not change the fleet's external capacity policy. - The completion barrier waits for owned background execution. A hung execution still fails the test's outer deadline. - The larger teardown budget remains finite. PostgreSQL's bounded stop protocol and cleanup errors still fail the hook. - No production execution, database schema or provider contract changes. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository inspection, code edits and test execution. The runtime does not expose the exact serving model ID or context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-Authored-By: Paperclip <noreply@paperclip.ing>
109 lines
6.3 KiB
JavaScript
109 lines
6.3 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { runInNewContext } from "node:vm";
|
|
|
|
const fleet = "runs-on/fleet=paperclip-post-merge-x64/env=public-ci";
|
|
const sha = "a".repeat(40);
|
|
const base = {
|
|
repository: "paperclipai/paperclip", repository_id: "1170821064",
|
|
ref: "refs/heads/master", event_name: "push", sha, run_attempt: 1,
|
|
};
|
|
const expectedJobs = {
|
|
"cloud-readiness.yml": [],
|
|
"cloud-migrator-artifacts.yml": [],
|
|
"release-verify.yml": ["typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"],
|
|
"runner-chaos-evals.yml": ["chaos_and_recovery"],
|
|
"release.yml": ["plan_preview", "package_preview"],
|
|
};
|
|
for (const [file, expectedNames] of Object.entries(expectedJobs)) {
|
|
const workflow = readFileSync(new URL(`../../workflows/${file}`, import.meta.url), "utf8");
|
|
const jobs = [...workflow.matchAll(/^ ([a-z_]+):\n([\s\S]*?)(?=^ [a-z_]+:\n|(?![\s\S]))/gm)];
|
|
const routed = jobs.filter(([, , body]) => body.includes(fleet));
|
|
test(`${file}: all intended jobs carry the post-merge guard`, () => {
|
|
assert.deepEqual(routed.map(([, name]) => name).sort(), [...expectedNames].sort());
|
|
});
|
|
for (const [, job, body] of routed) {
|
|
const expression = body.match(/^ runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
|
|
assert.ok(expression, `${file}/${job} must use an explicit runner expression`);
|
|
const release = file === "release.yml";
|
|
const checkRef = release || file === "release-verify.yml" || file === "runner-chaos-evals.yml";
|
|
const inputs = { ref: sha, source_ref: sha, channel: "cloud-migrator" };
|
|
const defaultContext = { ...base, event_name: release ? "workflow_dispatch" : "push" };
|
|
const cases = [
|
|
{ name: "exact master source", expected: fleet },
|
|
{ name: "manual exact master source", github: { event_name: "workflow_dispatch" }, expected: fleet },
|
|
{ name: "switch disabled", enabled: "false" },
|
|
{ name: "switch absent", enabled: "" },
|
|
{ name: "malformed switch", enabled: "yes" },
|
|
{ name: "fork", github: { repository: "someone/paperclip", repository_id: "123" } },
|
|
{ name: "repository renamed or transferred", github: { repository_id: "123" } },
|
|
{ name: "unapproved PR", github: { event_name: "pull_request", ref: "refs/pull/1/merge" } },
|
|
{ name: "PR event even with master ref", github: { event_name: "pull_request" } },
|
|
{ name: "privileged PR event", github: { event_name: "pull_request_target" } },
|
|
{ name: "workflow completion event", github: { event_name: "workflow_run" } },
|
|
{ name: "repository dispatch", github: { event_name: "repository_dispatch" } },
|
|
{ name: "scheduled caller", github: { event_name: "schedule" } },
|
|
{ name: "branch workflow", github: { ref: "refs/heads/feature" } },
|
|
{ name: "release tag", github: { ref: "refs/tags/v2026.911.0" } },
|
|
];
|
|
if (checkRef) {
|
|
const key = release ? "source_ref" : "ref";
|
|
for (const value of ["b".repeat(40), "refs/pull/1/head", "master", "feature", "v1.0.0", ""]) {
|
|
cases.push({ name: `unverified source ${value || "(empty)"}`, inputs: { [key]: value } });
|
|
}
|
|
cases.push({ name: "missing source identity", github: { sha: "" }, inputs: { [key]: "" } });
|
|
}
|
|
if (release) {
|
|
cases.push({ name: "preview of master", inputs: { channel: "preview" } });
|
|
cases.push({ name: "stable release", inputs: { channel: "stable" } });
|
|
}
|
|
cases.push({ name: "first attempt as a string", github: { run_attempt: "1" }, expected: fleet });
|
|
cases.push({ name: "rerun after runner interruption", github: { run_attempt: 2 } });
|
|
cases.push({ name: "later rerun", github: { run_attempt: 3 } });
|
|
cases.push({ name: "rerun attempt as a string", github: { run_attempt: "2" } });
|
|
cases.push({ name: "missing attempt", github: { run_attempt: undefined } });
|
|
for (const { name, github = {}, inputs: overrides = {}, enabled = "true", expected = "ubuntu-latest" } of cases) {
|
|
test(`${file}/${job}: ${name}`, () => {
|
|
const context = { github: { ...defaultContext, ...github }, inputs: { ...inputs, ...overrides }, vars: { AWS_POST_MERGE_CI_ENABLED: enabled } };
|
|
// These canonical contexts use boolean operators and string comparisons
|
|
// whose results match GitHub's expression evaluation.
|
|
assert.equal(runInNewContext(expression, context), expected);
|
|
const timeout = body.match(/^ timeout-minutes: (.+)$/m)?.[1];
|
|
assert.ok(timeout, "AWS jobs need a timeout below the 45-minute instance lifetime");
|
|
const minutes = timeout.startsWith("${{") ? runInNewContext(timeout.slice(3, -2), context) : Number(timeout);
|
|
if (expected === fleet) assert.ok(minutes > 0 && minutes < 45);
|
|
if (release && job === "plan_preview") assert.equal(minutes, expected === fleet ? 10 : 360);
|
|
});
|
|
}
|
|
}
|
|
if (file === "release.yml") {
|
|
test("npm publisher always uses a GitHub-hosted runner", () => {
|
|
const publisher = jobs.find(([ , job]) => job === "publish_preview")?.[2];
|
|
assert.match(publisher, /^ runs-on: ubuntu-latest$/m);
|
|
assert.match(publisher, /^ environment: npm-canary$/m);
|
|
assert.match(publisher, /^ id-token: write$/m);
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
test("Cloud readiness bookkeeping never waits for the AWS verification fleet", () => {
|
|
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
|
|
const bodies = new Map();
|
|
for (const [name, needs] of [
|
|
["source_verified", "[verify]"],
|
|
]) {
|
|
const body = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z_]+:|(?![\\s\\S]))`, "m"))?.[1];
|
|
assert.ok(body, `missing ${name} job`);
|
|
bodies.set(name, body);
|
|
assert.match(body, /^ runs-on: ubuntu-latest$/m);
|
|
assert.doesNotMatch(body, /^ +continue-on-error:|^ +if:.*always\(\)/m);
|
|
assert.match(body, /^ if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'$/m);
|
|
assert.match(body, /^ +SOURCE_SHA: \$\{\{ github.sha \}\}$/m);
|
|
assert.equal(body.match(/^ needs: (.+)$/m)?.[1] ?? null, needs, `${name} prerequisites`);
|
|
}
|
|
assert.match(bodies.get("source_verified"), /^ run: node --test scripts\/cloud-source-verification.test.mjs$/m);
|
|
assert.match(bodies.get("source_verified"), /echo "Cloud source verified v1: \$SOURCE_SHA"/);
|
|
});
|