Files
PaperClipAI/server/src/__tests__/isolated-task-directory-retention.test.ts
T
Devin FoleyandPaperclip a7006a9506 Reclaim finished isolated task directories with restore points (#17472)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents do task work in execution workspaces on the instance disk
> - A task without a project repository runs in a plain (non-Git) task
directory under `<instance>/isolated-workspaces/<company>/<issue>/`
> - Nothing removes these directories after the work is done, so the
disk fills up
> - The directories can hold the only copy of agent work, so plain
deletion is not safe
> - This pull request saves each finished task directory in an encrypted
restore point, removes it, and restores it when the work reopens. It
also removes orphaned directories after a grace period
> - The benefit is that finished task directories stop using disk, and
no agent work is lost silently

## Linked Issues or Issue Description

**What happened?**
On one instance, `instances/<id>/isolated-workspaces/` held 251 task
directories and 12 GB, and it grew every day. 61 directories belonged to
open workspace rows whose issues closed more than 7 days before (3.9
GB). 12 belonged to issues closed 3 to 7 days before (0.7 GB). 4.0 GB
were directories with no open workspace row (the row was archived or
missing).

The cause has two parts:
1. The terminal workspace reaper archives a workspace only when it
proves delivery through Git (a merged pull request or a merged branch).
A task directory is not a Git checkout, so the Git status inspection
fails and the reaper always skips the row as "undelivered". Git cache
expiration handles only `git_worktree` workspaces.
2. When a person archives such a workspace by hand, artifact cleanup
removes a `local_fs` directory only when `metadata.createdByRuntime` is
true. Task directory rows never set it, so the directory stays and the
row becomes `cleanup_failed`.

**Expected behavior**
Paperclip reclaims the task directories of finished work by itself. It
must not lose files that only exist in those directories.

**Steps to reproduce**
1. Run an agent on an issue without a project repository (for example a
low-trust sandbox task, or an issue with no project), so it works in
`isolated-workspaces/<company>/<issue>/`.
2. Let the agent write files there, then mark the issue `done`.
3. Wait any number of days. The directory and its workspace row stay.
The reaper logs the row as skipped (undelivered).
4. Archive the workspace by hand. The directory still stays.

**Paperclip version or commit**
`master` at fe85fd7ac4

**Deployment mode**
Self-hosted, local disk storage.

**Agent adapter(s) involved**
Any adapter that runs in a task directory (seen with local and sandbox
runs).

## What Changed

- Added `isolated-task-directory-snapshot.ts`: a deterministic snapshot
of a plain directory (files with modes, directories, symbolic links as
links). It never follows symbolic links, refuses special files, and
enforces the restore point limits (20,000 files, 64 MiB per file, 256
MiB in total). A `node_modules` directory next to a `package.json` is
left out. Restore writes to a staging directory, verifies the digest,
then renames it into place.
- Added `isolated-task-directory-retention.ts`:
- `expireIsolatedTaskDirectory` saves the directory in a restore point,
archives the workspace with the `isolatedTaskDirectoryRetention` state
in one transaction, checks again that the directory did not change,
renames it to trash and deletes it. A change after the capture rolls the
workspace back to active and drops the restore point. An interrupted
removal finishes on a later sweep.
- It uses the git cache expiration consumer checks
(`isGitWorkspaceExpirationEligible`): no live run, unsettled
finalization, live lease, live runtime service, unresolved recovery
action or reopen fence, and no other open workspace or project workspace
in the directory. It also requires the source issue to be bound to the
workspace, because reopen restores through that binding.
- `restoreIsolatedTaskDirectory` restores the directory on reopen. It
replaces an empty directory and fails closed on a non-empty directory
that does not match. When the restore point expired, it reopens an empty
directory and posts a system comment.
- `sweepIsolatedTaskDirectories` walks `isolated-workspaces/` in bounded
batches (8 per sweep, with a cursor). A closed workspace that its issue
is still bound to is saved and removed like an open one. Other folders
are orphans and are removed after a grace period when no live row,
project workspace, runtime service, run, finalization, lease or recovery
action refers to them.
- Reused the git restore point ledger, storage and encryption.
`storeSealedRestorePoint` and `readRestorePointObject` were extracted
from `storeGitRestorePoint`. `markGitRestorePointExpired` now also marks
a task directory restore point as expired.
- `isGitWorkspaceExpirationEligible` takes `closedSiblingsAllowed`, so
closed rows for the same issue directory do not block a task directory
forever. Git expiration keeps its old behavior.
- Terminal workspace reaper (`execution-workspaces.ts`): handles open
task directory rows after the terminal delay and registers that moment
as a deadline. It finishes pending removals, runs the folder walk at the
start of a rotation (once per server start, then only at real
deadlines), and saves at most 8 directories per sweep.
- Reopen accepts a closed `shared_workspace` row that holds task
directory state. The run path also reopens (and restores) a closed
workspace with that state before preparing the run.
- Settings: `PAPERCLIP_TASK_DIRECTORY_RETENTION_ENABLED` (default
`false`), `PAPERCLIP_TASK_DIRECTORY_RETENTION_DRY_RUN` (default `true`),
`PAPERCLIP_TASK_DIRECTORY_ORPHAN_GRACE_DAYS` (default `7`, minimum `1`).
The delay is `PAPERCLIP_GIT_RESTORE_POINT_TERMINAL_DELAY_DAYS`. Removal
needs `PAPERCLIP_GIT_RESTORE_POINTS_ENABLED=true`.
- Logs: `execution_workspace.task_directory_retention`,
`execution_workspace.task_directory_cleanup` and
`execution_workspace.task_directory_restore`, with outcome, skip reason,
candidate bytes and reclaimed bytes.
- Documented the behavior in `doc/recoverable-disk-cleanup.md` and the
settings in `doc/DEVELOPING.md`.

**Preservation decision.** The code shows that these directories are not
scratch space. Nothing pushes or copies their contents, and the next run
of the same issue reuses the same path. So the files are saved before
removal, with the same ledger, encryption and retention as git restore
points, and restored on reopen. Orphans are not saved: no workspace row
can reopen them. The docs and the Risks section state this.

**Scheduling.** The work follows
`rules/sleep-aware-background-work.mdc`. It adds no timer and no
polling. Open rows use the existing reaper and its per-workspace
deadlines (the end of the terminal delay, or one hour after a retryable
skip). The folder walk runs once at startup. After that, workspace
archive and issue close notifications (the existing terminal workspace
hints) point the reaper at the affected folder, and a kept folder is
checked again only at its own deadline (end of a grace period or
terminal delay, or one hour after a busy lock or live consumer). When
the tracked state is full, the reaper schedules another walk instead of
dropping work. With nothing pending, it registers nothing.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/isolated-task-directory-retention.test.ts
src/__tests__/isolated-task-directory-retention-config.test.ts
src/__tests__/heartbeat-task-directory-restore.test.ts` (embedded
Postgres): 48 tests pass. They cover:
- a closed issue's task directory is archived and removed after the
delay by the reaper, and reopen restores files, modes and symbolic links
exactly;
- the same through reopen for a `shared_workspace` row, and through a
real heartbeat run (the run restores the files and runs in them);
- the directory stays before the delay (and the deadline is the end of
the delay), while the issue is open, and when retention is off;
- the directory stays while each consumer check fails: live run, live
runtime service, reopen fence, unsettled finalization, live lease,
unresolved recovery action, issue bound elsewhere, live sibling
workspace in the directory;
- no removal without restore points, with a non-durable key, before
`notBefore`, or over the limits;
- dry run changes nothing; a change after the capture rolls back; an
interrupted removal finishes on the next sweep; an expired restore point
reopens empty with a warning; a diverged directory fails the reopen and
stays untouched;
- folder sweep: an orphan is removed after the grace period, kept when
recently modified, in dry run, when a live row points into it, and when
its issue is open; an archived unbound row's folder is removed; an
archived bound row's folder is saved, removed and restored on reopen;
the walk is bounded and continues from its cursor;
- review follow-ups: a file name with a backslash saves and restores;
after the startup walk, a workspace archived later is found through its
change hint; an orphan used by a live run is retried; skipped rows do
not starve later rows of the per-sweep budget; overflowing tracked
deadlines schedule another walk instead of being dropped; a closing
child sends the reaper back to its archived parent's folder; a pending
lease cleanup on a closed workspace keeps an orphan; a deep path at the
platform path limit restores; an expiry recorded during removal survives
and the archive and removal are recorded as activity; a task closed
beyond retention keeps its new restore point for the full period;
- symbolic links: a task directory that is a link, an orphan that is a
link, and a link inside a directory are never followed; the link targets
stay untouched.
- Mutation checks. Each change below made at least one of the tests
above fail, and the test passed again after revert:
- drop the run-path reopen condition; drop the skipped-row cache; drop
hint tracking; reject backslashes again; drop the live-consumer retry
for folders; evict overflowing folder deadlines; skip ancestor hints;
ignore workspace-attached leases for orphans; stage restores under the
old longer name; overwrite the whole metadata on the final write; retain
task directory restore points from the tree close only; drop the removal
activity; force `closedSiblingsAllowed` off; make live siblings not
block; skip the digest recheck before rename; skip the issue binding
check; remove both symbolic link refusals (task directory); remove the
orphan symbolic link refusal; ignore live rows in the folder walk; skip
the orphan modification grace; ignore dry run for orphans; skip the
restore on reopen; skip the terminal delay; skip the restore points
requirement; skip the orphan open-issue check.
- Removing only one of the two task directory symbolic link checks did
not fail a test, because the other check still refuses the link.
Removing both fails the test.
- Related suites: `pnpm exec vitest run execution-workspaces
isolated-task-directory workspace-runtime git-restore-point
git-workspace-expiration terminal-workspace workspace-reaper
heartbeat-git-restore heartbeat-task-directory
isolated-repository-rehome nested-clone-migration environment-selection`
in `server/`: 34 files, 749 tests pass, 4 skipped. Two
`workspace-runtime.test.ts` tests fail (`does not diagnose a missing
remote branch as a rejected credential`, `rejects explicit main on a
master-only remote ...`). They fail the same way on unchanged `master`
on the local machine, so they are not caused by this change.
- `pnpm run typecheck` in `server/`: passes.
- Not run: the full server suite, a live instance rollout, and a check
that the host disk usage drops after removal.

## Risks

- The feature is off by default, and it starts as a dry run when
enabled. It needs restore points to remove anything. Rollback: set
`PAPERCLIP_TASK_DIRECTORY_RETENTION_ENABLED=false`. Saved restore points
and restore on reopen keep working.
- Orphan folders are removed without a restore point. This applies only
when no workspace row is bound to the issue at that path. If such an
issue is reopened later without another workspace, its next run starts
in an empty directory. The grace period (default 7 days of a closed tree
and no changes) and the dry run limit this risk.
- A directory over the restore point limits stays on disk (logged with
its reason). Special files keep a directory too.
- `node_modules` next to a `package.json` is not saved. A reopened task
must install its packages again.
- The restore point lives as long as git restore points (90 days by
default), counted from the later of the tree close and the removal. Only
task directory restore points use the removal time; git restore points
keep their retention. After that, reopen starts with an empty directory
and posts a warning comment.
- Reopen now accepts a closed `shared_workspace` row, but only when it
holds task directory state written by this feature.
- No schema migration. The ledger table and its columns are unchanged.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use

## 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>
2026-10-24 14:16:58 -07:00

525 lines
31 KiB
TypeScript

import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
activityLog,
agents,
companies,
createDb,
environmentLeases,
executionWorkspaceRestorePoints,
executionWorkspaces,
heartbeatRuns,
issueComments,
issueRecoveryActions,
issues,
nativeRunFinalizations,
workspaceRuntimeServices,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { materializeIsolatedTaskDirectory } from "../services/isolated-task-directory.js";
import {
expireIsolatedTaskDirectory,
readTaskDirectoryRetention,
sweepIsolatedTaskDirectories,
TASK_DIRECTORY_RESTORE_POINT_EXPIRED_WARNING,
} from "../services/isolated-task-directory-retention.js";
import { markGitRestorePointExpired } from "../services/git-restore-point-storage.js";
import { gitRestorePointRetentionService } from "../services/git-restore-point-ledger.js";
import { getStorageService } from "../storage/index.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const exists = (target: string) => fs.lstat(target).then(() => true, () => false);
async function age(target: string, at: Date) {
const stat = await fs.lstat(target);
if (stat.isDirectory()) for (const name of await fs.readdir(target)) await age(path.join(target, name), at);
await (stat.isSymbolicLink() ? fs.lutimes(target, at, at) : fs.utimes(target, at, at));
}
const support = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = support.supported ? describe : describe.skip;
describeEmbeddedPostgres("isolated task directory retention", () => {
let db: ReturnType<typeof createDb>;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let root: string;
let outside: string;
const previousHome = process.env.PAPERCLIP_HOME;
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("isolated-task-directory-retention-");
db = createDb(database.connectionString);
root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-task-directory-retention-")));
process.env.PAPERCLIP_HOME = path.join(root, "home");
process.env.PAPERCLIP_INSTANCE_ID = "default";
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "31".repeat(32));
outside = path.join(root, "outside");
await fs.mkdir(outside);
await fs.writeFile(path.join(outside, "keep.txt"), "outside\n");
});
afterEach(async () => {
await db.delete(executionWorkspaceRestorePoints);
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
await fs.rm(path.join(root, "home", "instances", "default", "isolated-workspaces"), { recursive: true, force: true });
});
afterAll(async () => {
vi.unstubAllEnvs();
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
await db.$client.end({ timeout: 1 }); await database.cleanup(); await fs.rm(root, { recursive: true, force: true });
});
const settings = { dryRun: false, terminalDelayMs: 3 * DAY_MS, orphanGraceMs: 7 * DAY_MS };
const restorePoints = (overrides: Partial<{ keyDurable: boolean }> = {}) => ({ storage: getStorageService(), terminalDelayMs: 3 * DAY_MS, keyDurable: true, ...overrides });
async function fixture(options: { closedDaysAgo?: number | null; mode?: string; status?: string } = {}) {
const companyId = randomUUID(), issueId = randomUUID(), workspaceId = randomUUID(), agentId = randomUUID();
const closedDaysAgo = options.closedDaysAgo === undefined ? 4 : options.closedDaysAgo;
await db.insert(companies).values({ id: companyId, name: "Task directory fixture", issuePrefix: `TD${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture agent" });
await db.insert(issues).values({ id: issueId, companyId, title: "Finished task",
status: closedDaysAgo === null ? "in_progress" : "done", completedAt: closedDaysAgo === null ? null : new Date(Date.now() - closedDaysAgo * DAY_MS) });
const cwd = await materializeIsolatedTaskDirectory({ companyId, issueId });
await fs.writeFile(path.join(cwd, "report.md"), "final report\n");
await fs.mkdir(path.join(cwd, "nested", "deeper"), { recursive: true });
await fs.writeFile(path.join(cwd, "nested", "deeper", "data.json"), "{\"value\":1}\n");
await fs.writeFile(path.join(cwd, "run.sh"), "#!/bin/sh\necho ok\n", { mode: 0o755 });
await fs.symlink(outside, path.join(cwd, "outside-link"));
await fs.writeFile(path.join(cwd, "package.json"), "{\"name\":\"fixture\"}\n");
await fs.mkdir(path.join(cwd, "node_modules", "dep"), { recursive: true });
await fs.writeFile(path.join(cwd, "node_modules", "dep", "index.js"), "module.exports = 1;\n");
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, sourceIssueId: issueId, mode: options.mode ?? "isolated_workspace",
strategyType: "project_primary", name: "Fixture", status: options.status ?? "active", providerType: "local_fs", cwd,
closedAt: options.status === "archived" ? new Date(Date.now() - closedDaysAgo! * DAY_MS) : null,
metadata: { source: "task_session", createdByRuntime: false } });
await db.update(issues).set({ executionWorkspaceId: workspaceId }).where(eq(issues.id, issueId));
const row = () => db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)).then(rows => rows[0]!);
const ledger = () => db.select().from(executionWorkspaceRestorePoints).where(eq(executionWorkspaceRestorePoints.executionWorkspaceId, workspaceId));
const service = (overrides: Parameters<typeof executionWorkspaceService>[1] = {}) => executionWorkspaceService(db, {
taskDirectoryRetention: settings, gitRestorePoints: restorePoints(), ...overrides });
const expire = async (overrides: Partial<Parameters<typeof expireIsolatedTaskDirectory>[2]> = {}) => expireIsolatedTaskDirectory(db, await row(), {
dryRun: false, now: new Date(), notBefore: new Date(Date.now() - DAY_MS), restorePoint: { storage: getStorageService(), keyDurable: true }, ...overrides });
const reopen = () => executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId,
issue: { id: issueId, companyId, projectId: null }, actor: { actorType: "user", agentId: null },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true } });
return { companyId, issueId, workspaceId, agentId, cwd, row, ledger, service, expire, reopen };
}
async function expectRestored(cwd: string) {
expect(await fs.readFile(path.join(cwd, "report.md"), "utf8")).toBe("final report\n");
expect(await fs.readFile(path.join(cwd, "nested", "deeper", "data.json"), "utf8")).toBe("{\"value\":1}\n");
expect((await fs.stat(path.join(cwd, "run.sh"))).mode & 0o777).toBe(0o755);
expect((await fs.lstat(path.join(cwd, "outside-link"))).isSymbolicLink()).toBe(true);
expect(await fs.readlink(path.join(cwd, "outside-link"))).toBe(outside);
expect(await exists(path.join(cwd, "package.json"))).toBe(true);
expect(await exists(path.join(cwd, "node_modules"))).toBe(false);
}
it("archives and removes a closed issue's task directory after the delay and restores it on reopen", async () => {
const f = await fixture();
const result = await f.service().sweepTerminalWorkspaces();
expect(result).toMatchObject({ archived: 1 });
expect(await exists(f.cwd)).toBe(false);
expect(await fs.readFile(path.join(outside, "keep.txt"), "utf8")).toBe("outside\n");
const archived = await f.row();
expect(archived).toMatchObject({ status: "archived", cleanupReason: "isolated task directory retention" });
const state = readTaskDirectoryRetention(archived)!;
expect(state).toMatchObject({ removed: true, restorePoint: { files: 4, excluded: 1 } });
expect(await f.ledger()).toMatchObject([{ status: "active", objectKey: state.restorePoint.objectKey }]);
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
await expectRestored(f.cwd);
const reopened = await f.row();
expect(reopened.status).toBe("active");
expect(readTaskDirectoryRetention(reopened)).toBeNull();
expect(await f.ledger()).toMatchObject([{ status: "superseded" }]);
});
it("restores a shared-workspace task directory through reopen", async () => {
const f = await fixture({ mode: "shared_workspace" });
expect(await f.expire()).toMatchObject({ expired: true, restorePoint: true });
expect(await exists(f.cwd)).toBe(false);
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(issue.executionWorkspaceId).toBe(f.workspaceId);
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
await expectRestored(f.cwd);
});
it("keeps the directory until the terminal delay and registers that deadline", async () => {
const f = await fixture({ closedDaysAgo: 1 });
const service = f.service();
expect(await service.sweepTerminalWorkspaces()).toMatchObject({ archived: 0 });
expect(await exists(f.cwd)).toBe(true);
expect((await f.row()).status).toBe("active");
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
const deadline = await service.nextTerminalWorkspaceSweepAt();
expect(deadline).toBe(issue.completedAt!.getTime() + 3 * DAY_MS);
expect(await f.ledger()).toEqual([]);
});
it("keeps the directory while the issue is open", async () => {
const f = await fixture({ closedDaysAgo: null });
expect(await f.service().sweepTerminalWorkspaces()).toMatchObject({ archived: 0, skippedNonTerminalTree: 1 });
expect(await exists(f.cwd)).toBe(true);
expect(await f.ledger()).toEqual([]);
});
it("does nothing when retention is not configured", async () => {
const f = await fixture();
await executionWorkspaceService(db, { gitRestorePoints: restorePoints() }).sweepTerminalWorkspaces();
expect(await exists(f.cwd)).toBe(true);
expect((await f.row()).status).toBe("active");
});
type Consumer = (f: Awaited<ReturnType<typeof fixture>>) => Promise<unknown>;
const consumers: Array<[string, Consumer, string]> = [
["a live run", f => db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running", contextSnapshot: { issueId: f.issueId } }),
"live_or_unresolved_consumer"],
["a live runtime service", f => db.insert(workspaceRuntimeServices).values({ id: randomUUID(), companyId: f.companyId, scopeType: "execution_workspace",
serviceName: "web", status: "running", lifecycle: "shared", provider: "local_process", executionWorkspaceId: f.workspaceId, cwd: f.cwd }),
"live_or_unresolved_consumer"],
["a reopen fence", f => db.update(executionWorkspaces).set({ metadata: { source: "task_session", reopenPendingConsumption: true } })
.where(eq(executionWorkspaces.id, f.workspaceId)), "live_or_unresolved_consumer"],
["an unsettled finalization", async f => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "succeeded",
finishedAt: new Date(Date.now() - 4 * DAY_MS), nativeIssueId: f.issueId });
await db.insert(nativeRunFinalizations).values({ runId, companyId: f.companyId, issueId: f.issueId, phase: "observed", leaseOwner: "native-finalizer:live" });
}, "live_or_unresolved_consumer"],
["a live environment lease", f => db.insert(environmentLeases).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId, status: "active" }),
"live_or_unresolved_consumer"],
["an unresolved recovery action", f => db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId,
kind: "active_run_watchdog", status: "active", ownerType: "board", ownerAgentId: null, cause: "native_workspace_finalization_owner_unverified",
fingerprint: randomUUID(), evidence: {}, nextAction: "Verify the owner." }), "live_or_unresolved_consumer"],
["an issue bound to another workspace", f => db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, f.issueId)), "issue_not_bound"],
["a sibling live workspace in the directory", f => db.insert(executionWorkspaces).values({ companyId: f.companyId, mode: "shared_workspace",
strategyType: "project_primary", name: "Sibling", status: "active", providerType: "local_fs", cwd: path.join(f.cwd, "nested") }),
"live_or_unresolved_consumer"],
];
it.each(consumers)("keeps the directory while %s exists", async (_label, add, reason) => {
const f = await fixture();
await add(f);
expect(await f.expire()).toMatchObject({ expired: false, skipReason: reason });
expect(await exists(f.cwd)).toBe(true);
expect((await f.row()).status).toBe("active");
expect(await f.ledger()).toEqual([]);
});
it("removes the directory despite closed sibling rows at the same path", async () => {
const f = await fixture();
await db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId, mode: "isolated_workspace",
strategyType: "project_primary", name: "Old", status: "archived", closedAt: new Date(Date.now() - 10 * DAY_MS), providerType: "local_fs", cwd: f.cwd });
expect(await f.expire()).toMatchObject({ expired: true });
});
it("refuses to remove without restore points or with a non-durable key", async () => {
const f = await fixture();
expect(await f.expire({ restorePoint: undefined })).toMatchObject({ expired: false, skipReason: "restore_points_disabled" });
expect(await f.expire({ restorePoint: { storage: getStorageService(), keyDurable: false } }))
.toMatchObject({ expired: false, skipReason: "restore_point_key_not_durable" });
expect(await f.expire({ notBefore: new Date(Date.now() + DAY_MS) })).toMatchObject({ expired: false, skipReason: "not_due" });
expect(await exists(f.cwd)).toBe(true);
expect(await f.ledger()).toEqual([]);
});
it("keeps files it cannot save", async () => {
const f = await fixture();
expect(await f.expire({ restorePoint: { storage: getStorageService(), keyDurable: true, limits: { maxFiles: 2, maxFileBytes: 1024, maxTotalBytes: 4096 } } }))
.toMatchObject({ expired: false, skipReason: "task_directory_too_many_files" });
expect(await exists(f.cwd)).toBe(true);
expect((await f.row()).status).toBe("active");
expect(await f.ledger()).toEqual([]);
});
it("reports candidates in dry run without changing anything", async () => {
const f = await fixture();
const result = await f.service({ taskDirectoryRetention: { ...settings, dryRun: true }, gitRestorePoints: undefined }).sweepTerminalWorkspaces();
expect(result).toMatchObject({ eligible: 1, archived: 0 });
expect(await exists(f.cwd)).toBe(true);
expect((await f.row()).status).toBe("active");
expect(await f.ledger()).toEqual([]);
});
it("rolls back when the directory changes after it was saved", async () => {
const f = await fixture();
const result = await f.expire({ beforeDelete: async () => { await fs.writeFile(path.join(f.cwd, "late.txt"), "late work\n"); } });
expect(result, JSON.stringify(result.failure)).toMatchObject({ expired: false, skipReason: "task_directory_changed" });
expect(await fs.readFile(path.join(f.cwd, "late.txt"), "utf8")).toBe("late work\n");
const row = await f.row();
expect(row.status).toBe("active");
expect(readTaskDirectoryRetention(row)).toBeNull();
expect(await f.ledger()).toMatchObject([{ status: "superseded" }]);
});
it("finishes an interrupted removal on the next sweep", async () => {
const f = await fixture();
await expect(f.expire({ beforeDelete: async () => { throw new Error("simulated_crash"); } }))
.resolves.toMatchObject({ expired: false, skipReason: "simulated_crash" });
expect(await exists(f.cwd)).toBe(true);
expect(readTaskDirectoryRetention(await f.row())).toMatchObject({ removed: false });
expect(await f.service().sweepTerminalWorkspaces()).toMatchObject({ archived: 1 });
expect(await exists(f.cwd)).toBe(false);
expect(readTaskDirectoryRetention(await f.row())).toMatchObject({ removed: true });
});
it("never follows a task directory that is a symbolic link", async () => {
const f = await fixture();
const moved = path.join(root, `moved-${f.issueId}`);
await fs.rename(f.cwd, moved);
await fs.symlink(moved, f.cwd);
expect(await f.expire()).toMatchObject({ expired: false, skipReason: "unsafe_task_directory" });
expect(await fs.readFile(path.join(moved, "report.md"), "utf8")).toBe("final report\n");
expect(await f.ledger()).toEqual([]);
await fs.rm(f.cwd);
await fs.rm(moved, { recursive: true });
});
it("reopens an empty directory with a warning when the saved files expired", async () => {
const f = await fixture();
expect(await f.expire()).toMatchObject({ expired: true });
const [ledger] = await f.ledger();
await markGitRestorePointExpired(db, ledger!);
expect(readTaskDirectoryRetention(await f.row())?.restorePoint.expiredAt).toEqual(expect.any(String));
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true, warning: TASK_DIRECTORY_RESTORE_POINT_EXPIRED_WARNING });
expect(await fs.readdir(f.cwd)).toEqual([]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId));
expect(comments.map(comment => comment.body)).toContain(TASK_DIRECTORY_RESTORE_POINT_EXPIRED_WARNING);
});
it("refuses to restore over a directory that diverged after removal", async () => {
const f = await fixture();
expect(await f.expire()).toMatchObject({ expired: true });
await fs.mkdir(f.cwd);
await fs.writeFile(path.join(f.cwd, "other.txt"), "unrelated\n");
expect(await f.reopen()).toMatchObject({ ok: false, code: "rebuild_failed" });
expect(await fs.readFile(path.join(f.cwd, "other.txt"), "utf8")).toBe("unrelated\n");
expect((await f.row()).status).toBe("archived");
});
it("saves and restores file names that contain a backslash", async () => {
const f = await fixture();
await fs.writeFile(path.join(f.cwd, "report\\final.txt"), "backslash name\n");
expect(await f.expire()).toMatchObject({ expired: true });
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
expect(await fs.readFile(path.join(f.cwd, "report\\final.txt"), "utf8")).toBe("backslash name\n");
});
it("restores a deep path that fills the platform path length", async () => {
const f = await fixture();
let deep = f.cwd;
while (deep.length < 1000) deep = path.join(deep, "d".repeat(Math.min(60, 1000 - deep.length)));
await fs.mkdir(deep, { recursive: true });
const file = path.join(deep, "z".repeat(Math.max(1, 1015 - deep.length)));
await fs.writeFile(file, "deep\n");
expect(await f.expire()).toMatchObject({ expired: true });
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
expect(await fs.readFile(file, "utf8")).toBe("deep\n");
});
it("keeps an expiry recorded while the directory was being removed, and records activity", async () => {
const f = await fixture();
const result = await f.expire({ beforeDelete: async () => {
const [ledger] = await f.ledger();
await markGitRestorePointExpired(db, ledger!);
} });
expect(result).toMatchObject({ expired: true });
expect(readTaskDirectoryRetention(await f.row())).toMatchObject({ removed: true, restorePoint: { expiredAt: expect.any(String) } });
const actions = await db.select({ action: activityLog.action }).from(activityLog).where(eq(activityLog.entityId, f.workspaceId));
expect(actions.map(row => row.action).sort()).toEqual(["execution_workspace.task_directory_archived", "execution_workspace.task_directory_removed"]);
});
it("keeps a new restore point for the full retention even when the task closed long ago", async () => {
const f = await fixture({ closedDaysAgo: 120 });
expect(await f.expire()).toMatchObject({ expired: true });
const retention = gitRestorePointRetentionService(db, { storage: getStorageService(), retentionDays: 90 });
await retention.tick();
expect(await f.ledger()).toMatchObject([{ status: "active" }]);
expect(readTaskDirectoryRetention(await f.row())?.restorePoint.expiredAt).toBeUndefined();
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
await expectRestored(f.cwd);
});
it("does not let skipped rows starve later rows of the per-sweep budget", async () => {
const blocked = await fixture();
execFileSync("mkfifo", [path.join(blocked.cwd, "pipe")]);
const ready = await fixture();
await db.update(executionWorkspaces).set({ updatedAt: new Date(Date.now() - 60_000) }).where(eq(executionWorkspaces.id, blocked.workspaceId));
const service = blocked.service({ taskDirectoryRetention: { ...settings, perSweep: 1 } });
expect(await service.sweepTerminalWorkspaces()).toMatchObject({ archived: 0 });
expect(await exists(ready.cwd)).toBe(true);
expect(await service.sweepTerminalWorkspaces()).toMatchObject({ archived: 1 });
expect(await exists(ready.cwd)).toBe(false);
expect(await exists(blocked.cwd)).toBe(true);
});
describe("folder sweep", () => {
const sweep = (overrides: Partial<Parameters<typeof sweepIsolatedTaskDirectories>[1]> = {}) => sweepIsolatedTaskDirectories(db, {
dryRun: false, now: new Date(), orphanGraceMs: 7 * DAY_MS, terminalDelayMs: 3 * DAY_MS,
restorePoint: { storage: getStorageService(), keyDurable: true }, ...overrides });
async function orphan(options: { modifiedDaysAgo?: number } = {}) {
const companyId = randomUUID(), issueId = randomUUID();
const cwd = await materializeIsolatedTaskDirectory({ companyId, issueId });
await fs.writeFile(path.join(cwd, "left.txt"), "left behind\n");
await fs.symlink(outside, path.join(cwd, "outside-link"));
await age(cwd, new Date(Date.now() - (options.modifiedDaysAgo ?? 10) * DAY_MS));
return { companyId, issueId, cwd };
}
it("removes an orphan whose issue is gone after the grace period", async () => {
const o = await orphan();
const batch = await sweep();
expect(batch.results).toMatchObject([{ issueId: o.issueId, outcome: "removed", reason: null }]);
expect(batch.results[0]!.reclaimedBytes).toBeGreaterThan(0);
expect(await exists(o.cwd)).toBe(false);
expect(await fs.readFile(path.join(outside, "keep.txt"), "utf8")).toBe("outside\n");
});
it("keeps a recently modified orphan and schedules its deadline", async () => {
const o = await orphan({ modifiedDaysAgo: 2 });
const batch = await sweep();
expect(batch.results).toMatchObject([{ issueId: o.issueId, outcome: "skipped", reason: "recently_modified" }]);
expect(batch.results[0]!.retryAt).toBeGreaterThan(Date.now() + 4 * DAY_MS);
expect(await exists(o.cwd)).toBe(true);
});
it("keeps an orphan in dry run", async () => {
const o = await orphan();
const batch = await sweep({ dryRun: true });
expect(batch.results).toMatchObject([{ issueId: o.issueId, outcome: "dry_run" }]);
expect(await exists(o.cwd)).toBe(true);
});
it("keeps a folder that a live workspace points into", async () => {
const o = await orphan();
await db.insert(companies).values({ id: o.companyId, name: "Live", issuePrefix: `LV${o.companyId.slice(0, 6)}` });
await db.insert(executionWorkspaces).values({ companyId: o.companyId, mode: "shared_workspace", strategyType: "project_primary",
name: "Live", status: "active", providerType: "local_fs", cwd: path.join(o.cwd, "sub") });
const batch = await sweep();
expect(batch.results).toEqual([]);
expect(await exists(o.cwd)).toBe(true);
});
it("removes a folder whose archived row is unbound and whose issue closed long ago", async () => {
const o = await orphan();
await db.insert(companies).values({ id: o.companyId, name: "Archived", issuePrefix: `AR${o.companyId.slice(0, 6)}` });
await db.insert(issues).values({ id: o.issueId, companyId: o.companyId, title: "Old", status: "done", completedAt: new Date(Date.now() - 10 * DAY_MS) });
await db.insert(executionWorkspaces).values({ companyId: o.companyId, sourceIssueId: o.issueId, mode: "shared_workspace", strategyType: "project_primary",
name: "Archived", status: "archived", closedAt: new Date(Date.now() - 10 * DAY_MS), providerType: "local_fs", cwd: o.cwd });
expect((await sweep()).results).toMatchObject([{ outcome: "removed" }]);
expect(await exists(o.cwd)).toBe(false);
});
it("keeps an orphan whose issue is still open", async () => {
const o = await orphan();
await db.insert(companies).values({ id: o.companyId, name: "Open", issuePrefix: `OP${o.companyId.slice(0, 6)}` });
await db.insert(issues).values({ id: o.issueId, companyId: o.companyId, title: "Open", status: "todo" });
expect((await sweep()).results).toMatchObject([{ outcome: "skipped", reason: "issue_open" }]);
expect(await exists(o.cwd)).toBe(true);
});
it("never follows an orphan that is a symbolic link", async () => {
const companyId = randomUUID(), issueId = randomUUID();
const parent = path.dirname(await materializeIsolatedTaskDirectory({ companyId, issueId }));
await fs.rmdir(path.join(parent, issueId));
await fs.symlink(outside, path.join(parent, issueId));
expect((await sweep()).results).toMatchObject([{ outcome: "skipped", reason: "unsafe_task_directory" }]);
expect(await fs.readFile(path.join(outside, "keep.txt"), "utf8")).toBe("outside\n");
});
it("preserves an archived bound workspace's surviving folder before removing it, and reopen restores it", async () => {
const f = await fixture({ status: "archived" });
const batch = await sweep();
expect(batch.results).toMatchObject([{ issueId: f.issueId, outcome: "preserved_and_removed", executionWorkspaceId: f.workspaceId }]);
expect(await exists(f.cwd)).toBe(false);
expect(await f.ledger()).toMatchObject([{ status: "active" }]);
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
await expectRestored(f.cwd);
});
it("retries an orphan that a live run still uses", async () => {
const o = await orphan();
const agentId = randomUUID();
await db.insert(companies).values({ id: o.companyId, name: "Busy", issuePrefix: `BS${o.companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId: o.companyId, name: "Busy agent" });
await db.insert(issues).values({ id: o.issueId, companyId: o.companyId, title: "Old", status: "done", completedAt: new Date(Date.now() - 10 * DAY_MS) });
await db.insert(heartbeatRuns).values({ companyId: o.companyId, agentId, status: "running", contextSnapshot: { issueId: o.issueId } });
const batch = await sweep();
expect(batch.results).toMatchObject([{ outcome: "skipped", reason: "live_run" }]);
expect(batch.results[0]!.retryAt).toBeGreaterThan(Date.now());
expect(await exists(o.cwd)).toBe(true);
});
it("finds a folder left by a later archive through workspace change hints", async () => {
const service = executionWorkspaceService(db, { taskDirectoryRetention: settings, gitRestorePoints: restorePoints() });
await service.sweepTerminalWorkspaces();
expect(await service.nextTerminalWorkspaceSweepAt()).toBeNull();
const f = await fixture({ status: "archived" });
await service.sweepTerminalWorkspaces();
expect(await exists(f.cwd)).toBe(true);
service.terminalWorkspaceWorkSettled(new Set([`workspace:${f.workspaceId}`]));
expect(await service.nextTerminalWorkspaceSweepAt()).toBeLessThanOrEqual(Date.now());
await service.sweepTerminalWorkspaces();
expect(await exists(f.cwd)).toBe(false);
expect(readTaskDirectoryRetention(await f.row())).toMatchObject({ removed: true });
expect(await service.nextTerminalWorkspaceSweepAt()).toBeNull();
});
it("keeps overflowing folder deadlines discoverable through another walk", async () => {
const folders = [await orphan({ modifiedDaysAgo: 1 }), await orphan({ modifiedDaysAgo: 1 })];
let clock = Date.now();
const service = executionWorkspaceService(db, { taskDirectoryRetention: { ...settings, trackedLimit: 1 },
gitRestorePoints: restorePoints(), now: () => new Date(clock) });
await service.sweepTerminalWorkspaces();
for (const folder of folders) expect(await exists(folder.cwd)).toBe(true);
const deadline = await service.nextTerminalWorkspaceSweepAt();
expect(deadline).toBeGreaterThan(clock);
clock = deadline! + 1000;
await service.sweepTerminalWorkspaces();
for (const folder of folders) expect(await exists(folder.cwd)).toBe(false);
});
it("returns to a parent's folder when its last open child closes", async () => {
const service = executionWorkspaceService(db, { taskDirectoryRetention: settings, gitRestorePoints: restorePoints() });
const f = await fixture({ status: "archived" });
const childId = randomUUID();
await db.insert(issues).values({ id: childId, companyId: f.companyId, parentId: f.issueId, title: "Open child", status: "todo" });
await service.sweepTerminalWorkspaces();
expect(await exists(f.cwd)).toBe(true);
await db.update(issues).set({ status: "done", completedAt: new Date(Date.now() - 4 * DAY_MS) }).where(eq(issues.id, childId));
service.terminalWorkspaceWorkSettled(new Set([`issue:${childId}`]));
await service.sweepTerminalWorkspaces();
expect(await exists(f.cwd)).toBe(false);
expect(readTaskDirectoryRetention(await f.row())).toMatchObject({ removed: true });
});
it("keeps an orphan whose closed workspace still has a lease cleanup pending", async () => {
const o = await orphan();
await db.insert(companies).values({ id: o.companyId, name: "Lease", issuePrefix: `LS${o.companyId.slice(0, 6)}` });
const [row] = await db.insert(executionWorkspaces).values({ companyId: o.companyId, mode: "shared_workspace", strategyType: "project_primary",
name: "Closed", status: "archived", closedAt: new Date(Date.now() - 10 * DAY_MS), providerType: "local_fs", cwd: o.cwd }).returning();
await db.insert(environmentLeases).values({ companyId: o.companyId, executionWorkspaceId: row!.id, status: "pending_cleanup",
releasedAt: new Date(Date.now() - 10 * DAY_MS) });
expect((await sweep()).results).toMatchObject([{ outcome: "skipped", reason: "live_lease" }]);
expect(await exists(o.cwd)).toBe(true);
});
it("bounds the work per sweep and continues from its cursor", async () => {
const folders = [await orphan(), await orphan(), await orphan()];
const first = await sweep({ limit: 2 });
expect(first.results).toHaveLength(2);
expect(first.next).not.toBeNull();
const second = await sweep({ limit: 2, after: first.next });
expect(second.results).toHaveLength(1);
expect(second.next).toBeNull();
for (const folder of folders) expect(await exists(folder.cwd)).toBe(false);
});
});
});