mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The local runner (`paperclip-runnerd`) supervises each provider process, such as the ACPX sidecar for Claude, in its own process group > - Run teardown stops that process group, and nothing else > - Claude Code starts each Bash tool command in a new session (`setsid`). A command such as `nohup ./bench.sh & echo $!` leaves a job in that session, and the wrapper exits at once. The job is then a child of init and outside the owned group > - In a restart chaos storm, a run failed after a restart resume. Its tool job kept running beside the task's next run, in the same workspace, for more than four minutes > - This pull request makes the supervisor record every process that its provider starts, including processes that leave the group, and stop them all before teardown reports success > - The benefit is that a run's tools cannot outlive the run and cause duplicate side effects in the next run ## Linked Issues or Issue Description **What happened?** A restart chaos storm ran a local ACPX Claude task. After a restart resume, the agent ran `nohup ./bench.sh <nonce> > log 2>&1 & echo "started pid $!"` and then ended its turn without a result. The run failed with `native_finalization_missing`. The runner tore down the sidecar group, and the guardian killed the provider group. `bench.sh` was in the session that Claude Code made for the tool (pgid and sid = the exited wrapper, ppid = 1). It was never signalled. It kept forking `sleep 1` while the task's next run started and finished in the same workspace. **Steps to reproduce** 1. Run a local ACPX Claude task whose agent runs a Bash tool command such as `nohup ./bench.sh x > log 2>&1 & echo $!`. 2. End the run (any outcome, including failure after a restart resume). 3. Run `ps -axo pid,ppid,pgid,command | grep bench.sh`. The job is still running with ppid 1. **Agent adapter(s) involved** Claude Code (local ACPX). The gap is in the shared supervisor, so any provider whose tools leave the group is affected. **Root cause** `SupervisedProcess` (`packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs`) spawns with `process_group(0)` and stops only that group in `terminate_group`, `terminate_group_immediately`, `wait` and `Drop`. Its own comment said that a caller that must exclude escaped or re-parented descendants needs a separate lifetime fence. No caller had one. The ACPX sidecar transport (`acpx_sidecar_transport.rs`, `shutdown` and `poison`) and the ACPX provider guardian (`installation-integrity.ts`, which kills only its own group) therefore left every `setsid` tool job behind. The failure path after the resume was not special: the same gap applies to every teardown. **Expected behavior** When a run ends for any reason, every process that its provider or tools started stops, and teardown proves that they are gone. Identity comes from PID, group, session and start time, never from a command name. ## What Changed - Add the `paperclip-process-table` crate. It reads PID, parent, process group and start time for every process (libproc on macOS, `/proc` on Linux). `runner-core` forbids unsafe code, so the two checked libproc calls live in this small crate. - Add `process_lineage.rs` to `runner-core`. A sampler thread (every 50 ms) records each descendant of the supervised process by PID and start time. It also records each group and session that a descendant leads, with the leader's start time. A process belongs to the lineage when its parent belongs, or when it is in a recorded group or session and started no earlier than its leader. Groups and sessions that the supervised process only inherited (runnerd's session) are never recorded. A recorded ID whose PID now names a different process is dropped. - `SupervisedProcess` samples the lineage just before it signals, and after the leader is reaped it stops every live member: TERM, then KILL after the shutdown grace. It fails with an error until it proves that no member is alive (grace plus 5 s). This applies to `terminate_group`, `terminate_group_immediately`, `wait` and `Drop`. - If the lineage sampler cannot start, the supervisor stops the process it just started and reports the failure, so a provider never runs without tool cleanup. - Table reads and state updates happen under one lock, so an older read cannot erase members that a newer read recorded. - The process table reports processes that exist but cannot be read. A recorded member that cannot be read stays unresolved, and teardown fails until its exit is proven. - The Runner workspace now has two members. `scripts/runner-release-crate-cache.mjs` and `scripts/runner-debug-crate-cache.mjs` cache and mark current the process-table lib unit with the Runner crate (otherwise Cargo rebuilds the Runner crate after every restore). The debug contract accepts only this library-only member and still refuses any other workspace change. The workflow cache path lists and the debug clean step are updated to match. - `terminate_group_immediately` now accepts `EPERM` from the group signal when the leader has already exited. macOS returns `EPERM` for a group whose only member is the unreaped leader. The lineage pass then proves that no other member remains. ## Verification - `cargo test -p paperclip-runner-core --lib -- process_lineage tool_job immediate_group_fence` (macOS, local). - New real-process tests in `process_supervisor.rs`: a supervised shell starts a wrapper with `setsid` (Perl `POSIX::setsid`), the wrapper runs `nohup <loop> &`, writes the job PID and exits. The test asserts that the job is outside the owned group and re-parented, then calls `terminate_group`, `terminate_group_immediately` or drops the process, and asserts that the job is gone when the call returns. - Red: with the tracker disabled, all three fail with `tool job <pid> outlived its provider`. - Green: all three pass. - Unit tests in `process_lineage.rs` cover a re-parented job, a job that left its group but stayed in the recorded session, a session made after the first sighting, PID reuse, and no claim through the supervisor's inherited session. - `node --test scripts/__tests__/runner-release-crate-cache.test.mjs scripts/__tests__/runner-debug-crate-cache.test.mjs` (new case: the process-table library is cached with the crate, and any other member still needs review). - `cargo test -p paperclip-process-table` (includes an exited process reported as gone), and `cargo test -p paperclip-runner-core --lib` (570 passed). `acpx_provider_backend::tests::unconfirmed_suspension_state_becomes_recoverable_only_after_cleanup_and_suspend` fails sometimes under full-suite load. It fails at the same rate with the tracker disabled, so it is a flake that already existed. - Sampler cost: one table read takes about 0.4 ms for 1,200 processes on macOS (about 0.8% of one core for each supervised process). - The restart-chaos replay (`--replay w-20261107T023921-s7106/t67`) did not run. The local session was not permitted to load provider credentials. It is not proven against a live Claude run. ## Risks - Teardown now stops all the processes that a provider started, also intentional background jobs. This is the intended contract: nothing outlives the run. - A wrapper that lives less than one sample (50 ms) and orphans a job before the sampler sees it is not recorded. Claude Code's wrapper sources a shell snapshot and lives for seconds. On Linux, a child-subreaper would close this gap. That is not in this change. - When `runnerd` itself is killed with SIGKILL, this code does not run. The provider guardian still kills its own group, but escaped tool sessions are not stopped in that case. - Teardown can return a new error when it cannot prove the lineage stopped. Callers already handle `terminate_group` errors as provider shutdown failures. - The server does not yet gate the next run on the tool lineage of the prior run directly. It relies on the runner finishing its teardown before the runner exits. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## 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 - [ ] 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
10 KiB
JavaScript
232 lines
10 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`,
|
|
// The process-table library is the crate's one workspace dependency. Cargo
|
|
// rebuilds a dependent whose workspace dependency is newer than it, so the
|
|
// library's lib unit is cached and marked current with the crate.
|
|
`${RELEASE_DIR}/.fingerprint/paperclip-process-table-*`,
|
|
`${RELEASE_DIR}/deps/libpaperclip_process_table-*`,
|
|
`${RELEASE_DIR}/deps/paperclip_process_table-*.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$/ },
|
|
...processTableRequired(RELEASE_DIR),
|
|
];
|
|
|
|
/** The lib unit of the process-table workspace dependency, in `directory`. */
|
|
export function processTableRequired(directory) {
|
|
const fingerprint = `${directory}/.fingerprint/paperclip-process-table-*`;
|
|
return [
|
|
{ label: "the process-table lib fingerprint hash", glob: `${fingerprint}/lib-paperclip_process_table` },
|
|
{ label: "the process-table lib dep-info", glob: `${fingerprint}/dep-lib-paperclip_process_table` },
|
|
{ label: "the process-table lib rlib", glob: `${directory}/deps/libpaperclip_process_table-*.rlib` },
|
|
{ label: "the process-table lib rmeta", glob: `${directory}/deps/libpaperclip_process_table-*.rmeta` },
|
|
];
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
export function readRustcVersion(env = process.env, cwd = process.cwd()) {
|
|
let output;
|
|
try {
|
|
output = execFileSync("rustc", ["--version", "--verbose"], { encoding: "utf8", env, cwd });
|
|
} 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(),
|
|
required = REQUIRED, cachedPaths = CACHED_PATHS, schema = SCHEMA } = {}) {
|
|
const missing = required.find(({ glob, exclude }) => expandGlob(root, glob).filter((entry) => !exclude?.test(entry)).length === 0);
|
|
const matched = cachedPaths.flatMap((pattern) => expandGlob(root, pattern));
|
|
if (missing) {
|
|
for (const target of matched) rmSync(target, { recursive: true, force: true });
|
|
return { 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, 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));
|
|
}
|