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 > - Each heartbeat run gets a scratch directory in the system temp folder > - Each process attempt makes a new directory with a marker file > - Cleanup removes only the directory of the current attempt > - A restart kills the old attempt, so its directory stays forever > - One restart storm left 83 directories across 78 runs > - This pull request sweeps those directories when the run ends and at startup recovery > - The benefit is that temp space no longer fills with leftover directories ## Linked Issues or Issue Description **What happened?** Run scratch directories made before a restart are never removed. A restart storm left 83 `paperclip-run-*` directories across 78 runs. Each holds only `.paperclip-run-scratch.json`. **Steps to reproduce** 1. Start a run that makes a scratch directory. 2. Kill the server process and start it again. 3. Let the run finish. 4. List `$TMPDIR/paperclip-run-*`. The old directory is still there. **Expected behavior** The old directory is removed after the run ends, or at startup. ## What Changed - Add `sweepOrphanedRunScratch` in `server/src/services/run-scratch.ts`. - The sweep removes a directory only if it has a valid regular-file marker and its run is terminal in this instance database. It keeps a directory whose run it cannot see, because another instance can share the temp root. - The sweep keeps active runs, unmarked directories, symlinks, and directories outside `paperclip-run-*`. - The sweep checks each directory again before removal and bounds the scan (500, with a rotating start so kept entries do not starve later ones) and removals (100). A per-run sweep filters by directory name first. Marker run ids that are not UUIDs never reach the database query. - Call the sweep after the current attempt's cleanup in `heartbeat.ts`, for that run only. - Call the sweep in `reapOrphanedRuns` (startup recovery) with run status from the database. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/services/run-scratch.test.ts` - New tests cover: leftover directories of a terminal run are removed, an active run is kept, an unmarked directory is kept, a symlink is not followed, and removals are bounded. ## Risks - Low risk. The sweep acts only on marked directories of terminal runs. - A directory left by a previous attempt could hold a live grandchild process. The sweep does not check old process groups. The run is already terminal. ## Model Used - Claude, claude-opus-4-5 class model via Claude Code, with tool use and code execution. ## 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>
275 lines
9.6 KiB
TypeScript
275 lines
9.6 KiB
TypeScript
import type { Dirent } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
export const HEARTBEAT_RUN_SCRATCH_MARKER = ".paperclip-run-scratch.json";
|
|
|
|
export interface HeartbeatRunScratchMetadata {
|
|
version: 1;
|
|
companyId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
issueId: string | null;
|
|
issueIdentifier: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface HeartbeatRunScratch {
|
|
dir: string;
|
|
markerPath: string;
|
|
metadata: HeartbeatRunScratchMetadata;
|
|
}
|
|
|
|
export interface HeartbeatRunScratchEnvResult {
|
|
env: Record<string, string>;
|
|
tempKeysApplied: string[];
|
|
}
|
|
|
|
export type HeartbeatRunScratchCleanupResult =
|
|
| { removed: true; dir: string }
|
|
| { removed: false; dir: string; reason: "missing" | "unmarked" | "owner_mismatch" | "process_group_alive" };
|
|
|
|
const TEMP_ENV_KEYS = ["TMPDIR", "TEMP", "TMP"] as const;
|
|
const ISSUE_SEGMENT_MAX_CHARS = 32;
|
|
|
|
function sanitizePathSegment(value: string | null | undefined, fallback: string): string {
|
|
const normalized = (value ?? "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, ISSUE_SEGMENT_MAX_CHARS)
|
|
.replace(/[.-]+$/g, "");
|
|
return normalized || fallback;
|
|
}
|
|
|
|
function isPathInside(parent: string, child: string): boolean {
|
|
const relative = path.relative(parent, child);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
async function readMarker(markerPath: string): Promise<HeartbeatRunScratchMetadata | null> {
|
|
try {
|
|
const parsed = JSON.parse(await fs.readFile(markerPath, "utf8")) as unknown;
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
const rec = parsed as Record<string, unknown>;
|
|
if (
|
|
rec.version !== 1 ||
|
|
typeof rec.companyId !== "string" ||
|
|
typeof rec.agentId !== "string" ||
|
|
typeof rec.runId !== "string" ||
|
|
typeof rec.createdAt !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
version: 1,
|
|
companyId: rec.companyId,
|
|
agentId: rec.agentId,
|
|
runId: rec.runId,
|
|
issueId: typeof rec.issueId === "string" ? rec.issueId : null,
|
|
issueIdentifier: typeof rec.issueIdentifier === "string" ? rec.issueIdentifier : null,
|
|
createdAt: rec.createdAt,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function prepareHeartbeatRunScratch(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
issueId?: string | null;
|
|
issueIdentifier?: string | null;
|
|
now?: Date;
|
|
}): Promise<HeartbeatRunScratch> {
|
|
const issueSegment = sanitizePathSegment(input.issueIdentifier, "unassigned");
|
|
const runSegment = sanitizePathSegment(input.runId.slice(0, 12), "run");
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), `paperclip-run-${issueSegment}-${runSegment}-`));
|
|
const markerPath = path.join(dir, HEARTBEAT_RUN_SCRATCH_MARKER);
|
|
const metadata: HeartbeatRunScratchMetadata = {
|
|
version: 1,
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: input.issueId ?? null,
|
|
issueIdentifier: input.issueIdentifier ?? null,
|
|
createdAt: (input.now ?? new Date()).toISOString(),
|
|
};
|
|
await fs.writeFile(markerPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
return { dir, markerPath, metadata };
|
|
}
|
|
|
|
export function buildHeartbeatRunScratchEnv(
|
|
existingEnv: Record<string, unknown>,
|
|
scratch: HeartbeatRunScratch,
|
|
): HeartbeatRunScratchEnvResult {
|
|
const env: Record<string, string> = {
|
|
PAPERCLIP_RUN_SCRATCH_DIR: scratch.dir,
|
|
PAPERCLIP_TASK_SCRATCH_DIR: scratch.dir,
|
|
PAPERCLIP_SCRATCH_DIR: scratch.dir,
|
|
PAPERCLIP_TMPDIR: scratch.dir,
|
|
};
|
|
const tempKeysApplied: string[] = [];
|
|
for (const key of TEMP_ENV_KEYS) {
|
|
const existing = existingEnv[key];
|
|
if (typeof existing === "string" && existing.trim().length > 0) continue;
|
|
env[key] = scratch.dir;
|
|
tempKeysApplied.push(key);
|
|
}
|
|
return { env, tempKeysApplied };
|
|
}
|
|
|
|
export async function cleanupHeartbeatRunScratch(input: {
|
|
scratch: HeartbeatRunScratch;
|
|
processGroupId?: number | null;
|
|
isProcessGroupAlive?: (processGroupId: number | null | undefined) => boolean;
|
|
/** Persist recovery evidence after ownership/liveness checks, before removal. */
|
|
beforeRemove?: () => Promise<void>;
|
|
}): Promise<HeartbeatRunScratchCleanupResult> {
|
|
const tmpRoot = path.resolve(os.tmpdir());
|
|
const dir = path.resolve(input.scratch.dir);
|
|
if (!isPathInside(tmpRoot, dir) || !path.basename(dir).startsWith("paperclip-run-")) {
|
|
return { removed: false, dir, reason: "unmarked" };
|
|
}
|
|
try {
|
|
const stats = await fs.stat(dir);
|
|
if (!stats.isDirectory()) return { removed: false, dir, reason: "missing" };
|
|
} catch {
|
|
return { removed: false, dir, reason: "missing" };
|
|
}
|
|
|
|
const marker = await readMarker(path.join(dir, HEARTBEAT_RUN_SCRATCH_MARKER));
|
|
if (!marker) return { removed: false, dir, reason: "unmarked" };
|
|
if (
|
|
marker.companyId !== input.scratch.metadata.companyId ||
|
|
marker.agentId !== input.scratch.metadata.agentId ||
|
|
marker.runId !== input.scratch.metadata.runId
|
|
) {
|
|
return { removed: false, dir, reason: "owner_mismatch" };
|
|
}
|
|
if (input.isProcessGroupAlive?.(input.processGroupId) === true) {
|
|
return { removed: false, dir, reason: "process_group_alive" };
|
|
}
|
|
|
|
await input.beforeRemove?.();
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
return { removed: true, dir };
|
|
}
|
|
|
|
/** "unknown" means this instance cannot establish ownership; it is never removed. */
|
|
export type RunScratchSweepRunState = "terminal" | "active" | "unknown";
|
|
|
|
export interface SweepRunScratchResult {
|
|
scanned: number;
|
|
removed: string[];
|
|
kept: number;
|
|
truncated: boolean;
|
|
}
|
|
|
|
export const RUN_SCRATCH_SWEEP_MAX_SCAN = 500;
|
|
export const RUN_SCRATCH_SWEEP_MAX_REMOVE = 100;
|
|
|
|
// Instances with separate databases share the temp root, so a run absent from
|
|
// this instance's database may belong to another live instance. Keep those.
|
|
let sweepCursor = 0;
|
|
|
|
/**
|
|
* Remove leftover run scratch directories whose owning run is terminal (or
|
|
* whose run row is long gone). Earlier process attempts of a run that crossed
|
|
* a restart leave directories that the live attempt's cleanup never sees.
|
|
*
|
|
* Safety: only real (non-symlink) `paperclip-run-*` directories directly under
|
|
* the temp root, with a valid regular-file marker, are considered. Active runs
|
|
* and unmarked directories are always kept. Work per sweep is bounded.
|
|
*/
|
|
export async function sweepOrphanedRunScratch(input: {
|
|
resolveRunStates: (runIds: string[]) => Promise<Map<string, RunScratchSweepRunState>>;
|
|
/** Restrict the sweep to one run's directories (used when that run ends). */
|
|
runId?: string;
|
|
tmpRoot?: string;
|
|
maxScan?: number;
|
|
maxRemove?: number;
|
|
}): Promise<SweepRunScratchResult> {
|
|
const tmpRoot = path.resolve(input.tmpRoot ?? os.tmpdir());
|
|
const maxScan = input.maxScan ?? RUN_SCRATCH_SWEEP_MAX_SCAN;
|
|
const maxRemove = input.maxRemove ?? RUN_SCRATCH_SWEEP_MAX_REMOVE;
|
|
const result: SweepRunScratchResult = { scanned: 0, removed: [], kept: 0, truncated: false };
|
|
|
|
let entries: Dirent[];
|
|
try {
|
|
entries = await fs.readdir(tmpRoot, { withFileTypes: true });
|
|
} catch {
|
|
return result;
|
|
}
|
|
|
|
// A per-run sweep pre-filters by the run segment of the directory name, so it
|
|
// is not limited by unrelated entries. A global sweep rotates its start so
|
|
// entries that are always kept cannot starve later ones.
|
|
const runNeedle = input.runId !== undefined
|
|
? `-${sanitizePathSegment(input.runId.slice(0, 12), "run")}-`
|
|
: null;
|
|
const named = entries
|
|
.filter((entry) =>
|
|
entry.name.startsWith("paperclip-run-") && entry.isDirectory() && !entry.isSymbolicLink() &&
|
|
(runNeedle === null || entry.name.includes(runNeedle)))
|
|
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
const start = runNeedle === null && named.length > 0 ? sweepCursor % named.length : 0;
|
|
const candidates: Array<{ dir: string; marker: HeartbeatRunScratchMetadata }> = [];
|
|
for (let i = 0; i < named.length; i += 1) {
|
|
const entry = named[(start + i) % named.length]!;
|
|
if (runNeedle === null && result.scanned >= maxScan) {
|
|
result.truncated = true;
|
|
sweepCursor = start + i;
|
|
break;
|
|
}
|
|
result.scanned += 1;
|
|
const dir = path.join(tmpRoot, entry.name);
|
|
try {
|
|
const markerPath = path.join(dir, HEARTBEAT_RUN_SCRATCH_MARKER);
|
|
const markerStat = await fs.lstat(markerPath);
|
|
if (!markerStat.isFile()) {
|
|
result.kept += 1;
|
|
continue;
|
|
}
|
|
const marker = await readMarker(markerPath);
|
|
if (!marker || (input.runId !== undefined && marker.runId !== input.runId)) {
|
|
result.kept += 1;
|
|
continue;
|
|
}
|
|
candidates.push({ dir, marker });
|
|
} catch {
|
|
result.kept += 1;
|
|
}
|
|
}
|
|
if (runNeedle === null && !result.truncated) sweepCursor = 0;
|
|
if (candidates.length === 0) return result;
|
|
|
|
const states = await input.resolveRunStates([...new Set(candidates.map((c) => c.marker.runId))]);
|
|
for (const { dir, marker } of candidates) {
|
|
const state = states.get(marker.runId) ?? "active";
|
|
const removable = state === "terminal";
|
|
if (!removable || result.removed.length >= maxRemove) {
|
|
if (removable) result.truncated = true;
|
|
result.kept += 1;
|
|
continue;
|
|
}
|
|
try {
|
|
// Re-check just before removal: never remove through a swapped symlink.
|
|
const stat = await fs.lstat(dir);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
result.kept += 1;
|
|
continue;
|
|
}
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
result.removed.push(dir);
|
|
} catch {
|
|
result.kept += 1;
|
|
}
|
|
}
|
|
return result;
|
|
}
|