Files
PaperClipAI/scripts/runner-release-crate-cache.mjs
T
DottaandPaperclip 54cc522e86 feat(runner): add gated native Antigravity ACP execution (#18188)
## Thinking Path

> - Paperclip manages agents and their work.
> - Native Runner must bind each provider turn to the selected task,
account, model, and instructions.
> - Antigravity needs native ACP admission and physical process
ownership across continuation.
> - A saved conversation must not reuse an old process owner or silently
switch its effective model.
> - This pull request adds the native candidate behind exact operator
admission.
> - The default release gate remains closed while qualification is
pending.

## Linked Issues or Issue Description

Builds on merged #18187. Product account setup and the candidate
producer are in dependent #18189. The repeatable browser journey is in
#18190. Refs: #12503, #14938; those proposals use legacy CLI adapters.

**Agent or provider**

Google Antigravity ACP 1.4.3.

**Why this adapter is useful**

Users need to set up an Antigravity agent and run real work through
their managed Google subscription.

**How the agent is invoked**

Native Paperclip Runner uses the pinned Antigravity ACP runtime. Exact
operator admission is required while qualification remains pending.

## What Changed

- Add native Antigravity ACP model and instruction admission,
selected-account homes, and fenced refresh ownership.
- Carry exact candidate authority from the control plane through Runner,
Rust, the sidecar, and each current run. Clamp daemon lifetime to the
absolute permission expiry, including replacement owners.
- Preserve a rotated OAuth refresh token before later identity
verification can fail, including failed or cancelled startup admission.
Make private lease-close retries safe.
- Bound native probe retirement and retry the exact original owner; do
not delete credential homes before physical proof.
- Preserve Gemini API-key recovery when shared Google subscription
capability is added.
- Resume the same tagged native conversation with fresh per-turn process
ownership and physical native retirement.
- Include native manifests in isolated Rust image stages, release cache
keys, and dev rebuild detection. The workflow changes only extend
existing cache input lists.
- Keep current master provider behavior and append Grok 13, Pi 35, and
Copilot 84 input identities. Preserve prior identities, including saved
Copilot 82 and 83 runs.

## Verification

- Runner TypeScript main checks, profile generators, Rust formatting,
and offline workspace checks pass. The native backend integration target
passes 28 tests.
- The complete transport and cleanup group passes 387 tests without
unhandled rejections. These checks use local scripted children and
actual process and port ownership.
- Saved-profile decoding, native-execution parsing, installation, and
selected-credential controls pass 208 focused tests. All 109
pre-existing profile identity files remain byte-for-byte unchanged.
Copilot, Grok, and shared TypeScript/Rust generation checks pass.
- The final recovery fixture passes all seven cases. After the master
rebase, the combined recovery and owned-Node-runtime group passes all 10
cases. Each expiry case sets one 45-second absolute grant before
startup. It includes the deliberate six-second cold delay, four existing
eight-second command budgets, and active-turn headroom. The test proves
an active turn before expiry and then checks the daemon, owned process
group, child processes, and all three native port fences. The 60-second
test deadline does not renew provider authority.
- The consolidated model-discovery and pending-release group passes 22
tests. Google credential compatibility and auth recovery pass 25 tests,
including existing Gemini API-key recovery. The full product settings
test group remains intact in #18189.
- Earlier failures remain in their original CI runs and local records.
They exposed the Copilot 82 decoder gap, an expired five-second fixture
bootstrap ticket, and a fixture grant consumed before activation on
loaded CI. Each repair has a separate result. Production ticket limits
and admission behavior are unchanged by the final test commits.
- An unrelated decision-expiry check failed in [the earlier CI
run](https://github.com/paperclipai/paperclip/actions/runs/40941986698/job/123688668997).
Its owning code is unchanged from the base. The unchanged suite passes
20 tests on isolated PostgreSQL. A controlled database rollback
reproduces the earlier wake: clean startup schedules the 120-second
decision expiry, while rollback reconciliation leaves a 30-second
continuation-coalescing wake. The decision stays open, with no wake
delivery or error. The fresh final-head server shard passes; the failed
result is preserved.
- Final native commit `ec6bc7e4d77f5ae0cc4946dbba6c662e95a87f5b` passed
`ci / verify` and `ci / e2e`. Fresh Greptile and Paperclip reviews both
score 5/5, with no open findings. Antigravity retains the recorded
native `.5` closure and exact low-model profile. This PR does not claim
global or cross-platform qualification.

## Risks

- The new transport carries private managed credentials. Exact
ownership, account generation, model, instructions, and native
retirement are fail-closed.
- Only local macOS ARM64 has a verified complete Antigravity runtime.
Default Antigravity task execution remains disabled without current
trusted operator admission. The Google server sign-in lane and
production candidate producer ship in #18189; this native slice does not
expose the subscription catalog choice.
- Shared launch inputs change provider identities. New identities are
append-only; existing recorded identities remain available.

## Model Used

OpenAI Codex (GPT-6). The host did not expose the exact model revision
or context limit. The model used tools for code edits, tests, and
review.

## 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>
2026-10-29 16:36:12 -05:00

213 lines
9.3 KiB
JavaScript

#!/usr/bin/env node
// The Runner's own crate is the one thing the shared Rust dependency cache
// leaves out: rust-cache drops workspace crates when it saves, and a fresh
// checkout would invalidate cargo's mtime fingerprints for them anyway. So
// every CI build compiled `paperclip-runner-core` in release mode from
// scratch (28-49s per run on 2026-10-11) although 221 of the 239 master
// commits of the preceding week left its inputs untouched.
//
// This script backs a second, content-addressed cache that holds only that
// crate's release artifacts (about 70 MB). The master writer saves them
// under a key made from the compiler, the checkout root and a hash of every
// input the build reads; a pull request whose merge tree carries
// byte-identical inputs restores them. Cargo judges a workspace crate fresh
// only while no source is newer than its dep-info file, and GitHub's cache
// keeps the artifacts' original mtimes, so `mark-current` touches the
// restored set after the checkout. Any input change selects a different key,
// the restore misses, and cargo compiles exactly as it did before.
//
// Subcommands:
// paths print the cached paths, one per line (repo-relative globs)
// hashed-inputs print the hashFiles() patterns that make up the key
// scope print the compiler/checkout part of the key
// mark-current make a restored artifact set newer than the checkout, or
// discard an incomplete one so cargo rebuilds instead
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readdirSync, rmSync, statSync, utimesSync, lstatSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const RUNNER_PACKAGE = "packages/paperclip-runner";
const RELEASE_DIR = `${RUNNER_PACKAGE}/runner/target/release`;
// Everything cargo writes for the crate's release lib and bin units:
// fingerprints (both units share the package-named directory prefix), the
// lib's rlib/rmeta and dep-info, the bin and its dep-info under deps, and the
// uplifted executable the staging step copies into dist/bin.
export const CACHED_PATHS = [
`${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*`,
`${RELEASE_DIR}/deps/libpaperclip_runner_core-*`,
`${RELEASE_DIR}/deps/paperclip_runner_core-*.d`,
`${RELEASE_DIR}/deps/paperclip_runnerd-*`,
`${RELEASE_DIR}/paperclip-runnerd`,
`${RELEASE_DIR}/paperclip-runnerd.d`,
];
// Every file the release build of the crate reads, as hashFiles() patterns:
// the toolchain pin, the manifests and lockfile, the crate sources, and the
// protocol schemas and fixtures the sources include with include_str!. The
// three fixture trees are read only by test builds, but hashing them costs
// nothing and keeps the include_str! guard test simple: every include in the
// crate, whatever its form, must land under one of these patterns.
export const HASHED_INPUTS = [
`${RUNNER_PACKAGE}/rust-toolchain.toml`,
`${RUNNER_PACKAGE}/acpx-profiles.json`,
`${RUNNER_PACKAGE}/antigravity-distributions.json`,
`${RUNNER_PACKAGE}/runner/Cargo.toml`,
`${RUNNER_PACKAGE}/runner/Cargo.lock`,
`${RUNNER_PACKAGE}/runner/crates/**`,
`${RUNNER_PACKAGE}/protocol/**`,
`${RUNNER_PACKAGE}/test-fixtures/**`,
`${RUNNER_PACKAGE}/test/fixtures/**`,
"tests/runner-e2e/fixtures/**",
];
// A restored set is usable only when both units are complete: everything
// cargo's freshness check reads for a unit (the fingerprint hash and the
// dep-info it compares source mtimes against) and every output it checks for
// (the lib's rlib and rmeta, the bin under deps). Measured with cargo 1.97.1:
// without any one of these it recompiles the unit; without the `.json`
// fingerprint records, rustc's `.d` files or the uplifted binary and its `.d`
// it reports the crate fresh and recreates the uplifted copy from deps.
// Anything less than this set is discarded, with the missing file named in
// the job log, so cargo sees a plain miss rather than a half-restored crate.
export const REQUIRED = [
{ label: "the lib fingerprint hash", glob: `${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*/lib-paperclip_runner_core` },
{ label: "the lib dep-info", glob: `${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*/dep-lib-paperclip_runner_core` },
{ label: "the lib rlib", glob: `${RELEASE_DIR}/deps/libpaperclip_runner_core-*.rlib` },
{ label: "the lib rmeta", glob: `${RELEASE_DIR}/deps/libpaperclip_runner_core-*.rmeta` },
{ label: "the bin fingerprint hash", glob: `${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*/bin-paperclip-runnerd` },
{ label: "the bin dep-info", glob: `${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*/dep-bin-paperclip-runnerd` },
{ label: "the bin under deps", glob: `${RELEASE_DIR}/deps/paperclip_runnerd-*`, exclude: /\.d$/ },
];
const SCHEMA = "paperclip.runner-release-crate.v1";
function keySafe(value) {
return String(value).replace(/[^A-Za-z0-9._-]+/g, "_");
}
/**
* The compiler and checkout part of the cache key. The compiler decides the
* artifacts' metadata hashes; the checkout root is baked into cargo's
* dep-info for the protocol files the sources include by relative path, so an
* entry written under one root is useless under another (the RunsOn fleets
* check out under /home/runner/_work, GitHub-hosted runners under
* /home/runner/work). Hashing the root into the key turns that into a plain
* miss instead of a restore cargo then throws away.
*/
export function computeScope({ workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(), rustcVersion = readRustcVersion() } = {}) {
const root = createHash("sha256").update(path.resolve(workspace)).digest("hex").slice(0, 12);
return `${keySafe(os.platform())}-${keySafe(os.arch())}-${keySafe(rustcVersion)}-${root}`;
}
function readRustcVersion(env = process.env) {
let output;
try {
output = execFileSync("rustc", ["--version", "--verbose"], { encoding: "utf8", env });
} catch {
return "no-rustc";
}
const field = (name) => output.match(new RegExp(`^${name}: (.+)$`, "m"))?.[1]?.trim() ?? "unknown";
return `${field("release")}-${field("commit-hash").slice(0, 9)}-${field("host")}`;
}
function expandGlob(root, pattern) {
// Patterns only ever use `*` inside a single path segment.
const segments = pattern.split("/");
let current = [root];
for (const segment of segments) {
const next = [];
for (const base of current) {
if (!segment.includes("*")) {
const candidate = path.join(base, segment);
if (existsSync(candidate)) next.push(candidate);
continue;
}
let entries;
try {
entries = readdirSync(base);
} catch {
continue;
}
const matcher = new RegExp(`^${segment.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
for (const entry of entries) {
if (matcher.test(entry)) next.push(path.join(base, entry));
}
}
current = next;
}
return current.sort();
}
function walk(target, visit) {
const info = lstatSync(target);
visit(target, info);
if (info.isDirectory()) {
for (const entry of readdirSync(target)) walk(path.join(target, entry), visit);
}
}
/**
* Touch every restored artifact so cargo's freshness check, which compares
* each source's mtime against the unit's dep-info, sees the checkout as
* older than the build. When the set is incomplete, remove whatever was
* restored and report it: cargo then compiles the crate as on a miss.
*/
export function markCurrent({ root = process.env.GITHUB_WORKSPACE ?? process.cwd(), now = new Date() } = {}) {
const missing = REQUIRED.find(({ glob, exclude }) => expandGlob(root, glob).filter((entry) => !exclude?.test(entry)).length === 0);
const matched = CACHED_PATHS.flatMap((pattern) => expandGlob(root, pattern));
if (missing) {
for (const target of matched) rmSync(target, { recursive: true, force: true });
return { schema: SCHEMA, action: "discarded", reason: `incomplete: ${missing.label} is missing`, removed: matched.length };
}
let files = 0;
let bytes = 0;
const seen = new Set();
for (const target of matched) {
walk(target, (entry, info) => {
if (info.isSymbolicLink()) return;
utimesSync(entry, now, now);
if (info.isFile()) {
files += 1;
// The uplifted binary is a hard link to the one under deps.
const { ino, size } = statSync(entry);
if (!seen.has(ino)) {
seen.add(ino);
bytes += size;
}
}
});
}
return { schema: SCHEMA, action: "marked", files, bytes };
}
function main(argv) {
const [command] = argv;
switch (command) {
case "paths":
process.stdout.write(`${CACHED_PATHS.join("\n")}\n`);
return 0;
case "hashed-inputs":
process.stdout.write(`${HASHED_INPUTS.join("\n")}\n`);
return 0;
case "scope":
process.stdout.write(`${computeScope()}\n`);
return 0;
case "mark-current": {
const result = markCurrent();
process.stdout.write(`${JSON.stringify(result)}\n`);
return 0;
}
default:
process.stderr.write("usage: runner-release-crate-cache.mjs <paths|hashed-inputs|scope|mark-current>\n");
return 2;
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}