mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Each task can run in its own isolated Git worktree, and each worktree installs its own dependencies > - The terminal-workspace reaper removes a worktree only when the task tree is finished, the delivery is merged, the files are clean, and a 7-day cooldown has passed > - A recurring task that runs every hour keeps a week of worktrees, and each worktree holds gigabytes of `node_modules`. A finished task with no merged pull request, or with leftover agent files, keeps its worktree forever > - On a busy instance this fills the disk in days, and a larger volume only delays the next failure > - This pull request adds compaction: one hour after a task tree finishes, the reaper removes regenerable directories from the worktree and keeps the workspace, the branch, and all source changes > - The benefit is that finished tasks stop holding gigabytes of disk, and no work is lost because a resumed task regenerates the files when its workspace is provisioned again ## Linked Issues or Issue Description Refs #2022 (retention for workspace cleanup). Related: #11936 and #14608 add external reclaim scripts (a scheduled tool and a disk-guard step). This pull request does the reclaim inside the server's terminal-workspace reaper instead. That way it uses the reaper's lifecycle lock, issue-tree checks, and live-run checks, and needs no extra scheduler. **What existing behavior does this improve?** Disk retention of execution workspaces after their tasks finish. **Current behavior** A finished task keeps its full worktree, including installed dependencies and build output, for at least 7 days. If the delivery is not merged, or the worktree has uncommitted or untracked files, the reaper never removes it. On one instance, an hourly recurring task created about 22 worktrees per day at about 3 GB each (about 2.8 GB of that was `node_modules`). Together with finished tasks that had no merged pull request, they filled a 100 GB volume in about two days. **Proposed behavior** One hour after a task tree becomes terminal, the reaper removes regenerable directories (installed dependencies, build output, tool caches) from the worktree. It keeps the worktree, the branch, the commits, and every tracked, untracked, or other ignored file. When the task resumes, the existing reuse path runs the provision step, which reinstalls the dependencies. Clean, merged worktrees are archived after 1 day instead of 7. **Reason and benefit** Most of a finished worktree's size is regenerable. Compaction removes that part without the risk of deleting work, so it can apply to workspaces that the reaper must keep (no merged pull request, leftover files). The shorter cooldown limits the cost of the source checkouts that stay. **Breaking changes** None. The default archive cooldown changes from 7 days to 1 day. `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS` still overrides it. `PAPERCLIP_WORKSPACE_COMPACTION_ENABLED=false` turns compaction off. ## What Changed - New `server/src/services/workspace-compaction.ts`: - Finds directories with a regenerable name (`node_modules`, `dist`, `build`, `coverage`, `storybook-static`, `target`, `.cache`, `.next`, `.nuxt`, `.parcel-cache`, `.svelte-kit`, `.turbo`, `.vite`). - Keeps only the directories that the repository's own `.gitignore` files ignore and that hold no tracked file. A negated pattern (`!build/`), a global excludes file, and `.git/info/exclude` do not count. Each candidate is checked per path, so dependencies below an ignored parent (`vendor/tool/node_modules`) are found. - Searches inside a regenerable-named directory that does not qualify (for example a tracked `build/`), so its ignored dependencies still go. - Never enters `.git` or `.paperclip` (the worktree instance data), and never follows a symlink. - Each Git read has a 60-second deadline and stops when the sweep is aborted. - Moves the directories into `.paperclip-compaction-trash` beside the worktree with a same-filesystem rename, and can restore them. - `sweepTerminalWorkspaces` compacts each terminal workspace once per terminal cycle, after the compaction delay and before the archive cooldown check: - Only isolated linked Git worktrees qualify. A shared workspace, a project's main checkout, or a row whose `.git` is a directory is never compacted. - The move runs inside a transaction that holds the workspace lifecycle lock. The guard checks a fresh row and the same lifecycle generation. It also checks that the issue tree is terminal, that no queued or running run works on the tree or the workspace (by checkout or execution run, or by run context), and that no runtime service runs from the workspace. - The guard runs before the move and again after it. If the second check fails (for example, the task reopened or a run started), the directories move back. Otherwise the trash is deleted. - The worker wakes at the compaction deadline, not only at the archive deadline. - Stale trash from an interrupted compaction or a failed delete is purged by later sweeps. The sweep checks each worktree parent at most every 30 minutes, whether or not anything is left to compact. - New counters `compacted` and `compactionFailed` in the sweep result. The activity log gets an `execution_workspace.regenerable_files_removed` entry with the removed paths. - Config: new `PAPERCLIP_WORKSPACE_COMPACTION_DELAY_HOURS` (default `1`) and `PAPERCLIP_WORKSPACE_COMPACTION_ENABLED`. The default for `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS` changes from `7` to `1`. Both new keys are added to the run-failure diagnostics allowlists. - Docs: `doc/DEVELOPING.md` documents the new variables and has a new "Terminal workspace compaction" section. `doc/SPEC-implementation.md` describes compaction. - `execution-workspaces.ts` was already above 1,000 lines. The file-system logic is in the new module. Only the guarded orchestration, which uses the existing lifecycle lock and run predicates, is in the service. ## Verification - `pnpm exec vitest run src/__tests__/workspace-compaction.test.ts src/__tests__/execution-workspace-compaction.test.ts` (in `server/`): 27 passed. `workspace-reaper-cooldown-config.test.ts`: passed. - Unit tests: directory selection (ignored, untracked, regenerable name only, `.env` and local data kept, tracked `coverage` kept, `.paperclip` skipped, symlinks not followed), nested candidates under a source `build/` and under an ignored `vendor/`, a negated `!build/` kept, global and `info/exclude` rules ignored, glob characters in names, abort, main checkout vs linked worktree, move and restore, restore that keeps a recreated directory, and stale-trash purge. - Embedded Postgres tests: - A no-PR, unmerged, dirty workspace is compacted. HEAD, the branch ref, `git status --porcelain --untracked-files=all`, untracked notes, `.env`, local data, and `.paperclip` data stay the same, and the row stays active. - A delivered workspace inside the cooldown is compacted. - The delay is respected, and the worker wakes at the delay. - An active checkout run, a queued run found through its context, an unfinished child issue, and a running runtime service all block compaction. - The task reopens during the move: the files are restored and unchanged. - A run starts during the move: the files are restored. - A shared workspace and a main checkout are never compacted. - Compaction runs once per terminal cycle and again after a later cycle. - Disabled compaction does nothing. - End to end: `realizeExecutionWorkspace` creates a worktree, the sweep compacts it, and a second `realizeExecutionWorkspace` reuses the same worktree. The provision command regenerates `node_modules`, and the untracked notes and the unmerged commit are still present. - Leftover trash is purged when the workspace has nothing left to compact. - Mutation checks: when the post-move guard is disabled, both race tests fail. Without the negation fix or the repository-only source fix, their tests fail. - `pnpm exec vitest run` for `execution-workspaces-service.test.ts`, `terminal-workspace-work.test.ts`, `execution-workspace-reopen.test.ts`, `server-startup-feedback-export.test.ts`, and `workspace-runtime.test.ts`: 335 passed. Two `workspace-runtime.test.ts` cases fail locally with and without this change (`does not diagnose a missing remote branch as a rejected credential`, `rejects explicit main on a master-only remote ...`). They also fail on unmodified `master` on the same machine, so the cause is the local Git environment. - `pnpm typecheck` in `server/`: passed. `pnpm check:module-boundaries`: passed. ## Risks - A repository can keep something that is not regenerable in an ignored `dist`, `build`, `target`, or `.cache` directory. Compaction removes it. The fixed name list, the ignore check, and the tracked-file check limit this. `PAPERCLIP_WORKSPACE_COMPACTION_ENABLED=false` turns compaction off. - A repository without a provision command does not get dependencies back automatically. This is the same state as a new worktree for that repository, where the agent already installs them. - A resumed task pays for a dependency install again. This happens only for tasks that finished more than an hour earlier. - The shorter default cooldown removes clean, merged worktrees after 1 day instead of 7. An operator who wants the old hold can set `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS=7`. - Self-hosted instances get the same behavior. There is no schema change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code with tool use (shell, file edits, test runs). A separate Claude agent read the reaper code during the diagnosis. ## 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 - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
233 lines
9.8 KiB
TypeScript
233 lines
9.8 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
// Directory names that hold regenerable output: installed dependencies, build
|
|
// output, and tool caches. Compaction removes a directory only when its name is
|
|
// in this set, Git ignores it, and it holds no tracked file. Removing one leaves
|
|
// the worktree in the same state as a freshly created worktree, so whatever
|
|
// produced it the first time (the workspace provision command, or the agent)
|
|
// produces it again when the task resumes.
|
|
export const REGENERABLE_WORKSPACE_DIR_NAMES: ReadonlySet<string> = new Set([
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
"coverage",
|
|
"storybook-static",
|
|
"target",
|
|
".cache",
|
|
".next",
|
|
".nuxt",
|
|
".parcel-cache",
|
|
".svelte-kit",
|
|
".turbo",
|
|
".vite",
|
|
]);
|
|
|
|
// Compaction never descends into these directories. `.paperclip` holds the
|
|
// worktree's own Paperclip instance data, which is not regenerable.
|
|
const PROTECTED_WORKSPACE_DIR_NAMES: ReadonlySet<string> = new Set([".git", ".paperclip"]);
|
|
|
|
// Moved directories wait here before deletion. The trash sits beside the
|
|
// worktree so the move is a same-filesystem rename, which is atomic and fast.
|
|
// A branch name cannot start with ".", so the name never collides with a
|
|
// worktree directory.
|
|
export const WORKSPACE_COMPACTION_TRASH_DIR = ".paperclip-compaction-trash";
|
|
|
|
const MAX_WALK_DEPTH = 12;
|
|
|
|
// Each Git read has a deadline and stops when the sweep is aborted, so a stalled
|
|
// Git process cannot hold the reaper.
|
|
const GIT_READ_TIMEOUT_MS = 60_000;
|
|
|
|
async function runGitRead(
|
|
worktreePath: string,
|
|
args: string[],
|
|
options: { signal?: AbortSignal; input?: string; okExitCodes?: number[] } = {},
|
|
) {
|
|
return await new Promise<string>((resolve, reject) => {
|
|
const child = execFile(
|
|
"git",
|
|
["-C", worktreePath, ...args],
|
|
{ cwd: worktreePath, timeout: GIT_READ_TIMEOUT_MS, signal: options.signal, maxBuffer: 16 * 1024 * 1024 },
|
|
(error, stdout) => {
|
|
const exitCode = (error as { code?: unknown } | null)?.code;
|
|
if (!error || (typeof exitCode === "number" && options.okExitCodes?.includes(exitCode))) {
|
|
resolve(stdout);
|
|
return;
|
|
}
|
|
reject(error);
|
|
},
|
|
);
|
|
// Git can exit before it reads stdin (or never reads it). A write to the
|
|
// closed pipe then fails with EPIPE; the exit status above already reports
|
|
// any real failure, so the stream error is not a second outcome.
|
|
child.stdin?.on("error", () => {});
|
|
if (options.input === undefined) child.stdin?.end();
|
|
else child.stdin?.end(options.input);
|
|
});
|
|
}
|
|
|
|
function splitNul(output: string) {
|
|
return output.split("\0");
|
|
}
|
|
|
|
function isRepositoryGitignore(source: string) {
|
|
return !path.isAbsolute(source)
|
|
&& !source.split("/").includes("..")
|
|
&& path.posix.basename(source) === ".gitignore";
|
|
}
|
|
|
|
// Keeps the candidates that Git ignores and that hold no tracked file.
|
|
// `check-ignore` answers per path, also for a path below an ignored parent.
|
|
// It exits 1 when no path is ignored, which is a normal answer.
|
|
async function filterRemovableCandidates(worktreePath: string, candidates: string[], signal?: AbortSignal) {
|
|
// With --verbose --non-matching -z, each path yields four fields: source,
|
|
// line number, pattern, and path. The source is empty for a path that no
|
|
// pattern matches. Verbose output also reports a negating pattern such as
|
|
// `!build/`, which means Git does NOT ignore the path, so only a match by a
|
|
// non-negated pattern counts. Only the repository's own `.gitignore` files
|
|
// count as evidence: a user's global excludes file or `.git/info/exclude`
|
|
// says nothing about whether this repository's directory is regenerable.
|
|
const fields = splitNul(await runGitRead(
|
|
worktreePath,
|
|
["check-ignore", "--stdin", "-z", "--verbose", "--non-matching"],
|
|
{ signal, input: candidates.map((candidate) => `${candidate}\0`).join(""), okExitCodes: [1] },
|
|
));
|
|
const ignored = new Set<string>();
|
|
for (let index = 0; index + 3 < fields.length; index += 4) {
|
|
const [source, , pattern, candidate] = fields.slice(index, index + 4);
|
|
if (source && pattern && !pattern.startsWith("!") && isRepositoryGitignore(source)) ignored.add(candidate!);
|
|
}
|
|
const ignoredCandidates = candidates.filter((candidate) => ignored.has(candidate));
|
|
if (ignoredCandidates.length === 0) return [];
|
|
// A directory that holds a tracked file is source, even when Git ignores it.
|
|
// Literal pathspecs: a candidate is a path, never a pattern.
|
|
const tracked = splitNul(await runGitRead(
|
|
worktreePath,
|
|
["--literal-pathspecs", "ls-files", "-z", "--", ...ignoredCandidates],
|
|
{ signal },
|
|
))
|
|
.filter((entry) => entry.length > 0);
|
|
return ignoredCandidates.filter((candidate) =>
|
|
!tracked.some((trackedPath) => trackedPath === candidate || trackedPath.startsWith(`${candidate}/`)));
|
|
}
|
|
|
|
// Returns true only for a linked Git worktree: its `.git` entry is a file that
|
|
// points at the main repository. A main checkout has a `.git` directory, and
|
|
// compaction never touches one.
|
|
export async function isLinkedGitWorktree(worktreePath: string) {
|
|
const stat = await fs.lstat(path.join(worktreePath, ".git")).catch(() => null);
|
|
return stat?.isFile() === true;
|
|
}
|
|
|
|
// Lists the regenerable directories in a worktree, as paths relative to the
|
|
// worktree root. Symlinks are never followed or returned. A directory with a
|
|
// regenerable name that does not qualify (for example a tracked `build/`) is
|
|
// searched in turn, so eligible directories inside it are still found.
|
|
export async function findRegenerableWorkspaceDirs(
|
|
worktreePath: string,
|
|
options: { signal?: AbortSignal } = {},
|
|
): Promise<string[]> {
|
|
const removable: string[] = [];
|
|
|
|
async function collect(relativeDir: string, depth: number, candidates: string[]) {
|
|
options.signal?.throwIfAborted();
|
|
if (depth > MAX_WALK_DEPTH) return;
|
|
const entries = await fs.readdir(path.join(worktreePath, relativeDir), { withFileTypes: true })
|
|
.catch(() => []);
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (PROTECTED_WORKSPACE_DIR_NAMES.has(entry.name)) continue;
|
|
const relativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name;
|
|
if (REGENERABLE_WORKSPACE_DIR_NAMES.has(entry.name)) {
|
|
candidates.push(relativePath);
|
|
continue;
|
|
}
|
|
await collect(relativePath, depth + 1, candidates);
|
|
}
|
|
}
|
|
|
|
let roots = [""];
|
|
while (roots.length > 0) {
|
|
const candidates: string[] = [];
|
|
for (const root of roots) {
|
|
await collect(root, root ? root.split("/").length : 0, candidates);
|
|
}
|
|
if (candidates.length === 0) break;
|
|
const eligible = new Set(await filterRemovableCandidates(worktreePath, candidates, options.signal));
|
|
removable.push(...candidates.filter((candidate) => eligible.has(candidate)));
|
|
roots = candidates.filter((candidate) => !eligible.has(candidate));
|
|
}
|
|
return removable;
|
|
}
|
|
|
|
export type MovedWorkspaceDir = { relativePath: string; trashPath: string };
|
|
|
|
export type WorkspaceCompactionMove = {
|
|
trashDir: string;
|
|
moved: MovedWorkspaceDir[];
|
|
};
|
|
|
|
// Moves each directory into a fresh trash directory beside the worktree. Each
|
|
// move is one rename, so the whole step is fast enough to run while the caller
|
|
// holds the workspace lifecycle lock. A path that is no longer a real
|
|
// directory, or that sits on another filesystem, is left in place.
|
|
export async function moveWorkspaceDirsToTrash(
|
|
worktreePath: string,
|
|
relativePaths: string[],
|
|
): Promise<WorkspaceCompactionMove> {
|
|
const trashDir = path.join(
|
|
path.dirname(worktreePath),
|
|
WORKSPACE_COMPACTION_TRASH_DIR,
|
|
`${path.basename(worktreePath)}-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
|
);
|
|
await fs.mkdir(trashDir, { recursive: true });
|
|
const moved: MovedWorkspaceDir[] = [];
|
|
for (const [index, relativePath] of relativePaths.entries()) {
|
|
const sourcePath = path.join(worktreePath, relativePath);
|
|
const stat = await fs.lstat(sourcePath).catch(() => null);
|
|
if (!stat?.isDirectory()) continue;
|
|
const trashPath = path.join(trashDir, String(index));
|
|
try {
|
|
await fs.rename(sourcePath, trashPath);
|
|
moved.push({ relativePath, trashPath });
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "EXDEV") continue;
|
|
await restoreWorkspaceDirsFromTrash(worktreePath, { trashDir, moved });
|
|
throw error;
|
|
}
|
|
}
|
|
return { trashDir, moved };
|
|
}
|
|
|
|
// Puts moved directories back. A path that something recreated in the meantime
|
|
// keeps the new copy, and the moved copy stays in the trash for deletion.
|
|
export async function restoreWorkspaceDirsFromTrash(worktreePath: string, move: WorkspaceCompactionMove) {
|
|
for (const entry of [...move.moved].reverse()) {
|
|
const targetPath = path.join(worktreePath, entry.relativePath);
|
|
const occupied = await fs.lstat(targetPath).then(() => true, () => false);
|
|
if (occupied) continue;
|
|
await fs.rename(entry.trashPath, targetPath);
|
|
}
|
|
}
|
|
|
|
export async function deleteWorkspaceCompactionTrash(trashDir: string) {
|
|
await fs.rm(trashDir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Deletes trash directories that a crash or restart left behind. A trash entry
|
|
// younger than `olderThanMs` may belong to a compaction in progress, so it stays.
|
|
export async function purgeStaleWorkspaceCompactionTrash(worktreeParentDir: string, olderThanMs: number) {
|
|
const trashRoot = path.join(worktreeParentDir, WORKSPACE_COMPACTION_TRASH_DIR);
|
|
const entries = await fs.readdir(trashRoot, { withFileTypes: true }).catch(() => []);
|
|
const cutoff = Date.now() - olderThanMs;
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(trashRoot, entry.name);
|
|
const stat = await fs.lstat(entryPath).catch(() => null);
|
|
if (!stat || stat.mtimeMs > cutoff) continue;
|
|
await fs.rm(entryPath, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|