Files
PaperClipAI/server/src/services/run-scratch.ts
T
DottaandClaude Opus 5.5 29e05f7e06 fix(heartbeat): sweep orphaned run scratch directories (#19671)
## 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>
2026-11-06 18:51:31 -06:00

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;
}