fix(worker): make workspace cleanup layout-aware

This commit is contained in:
Gabriel Brown
2026-07-10 15:55:11 -04:00
parent 8aa9140191
commit daec4d6dfa
3 changed files with 156 additions and 14 deletions
+2
View File
@@ -38,3 +38,5 @@ export const releaseUserBox = (username: string) => {
boxes.delete(username); boxes.delete(username);
}, env.boxIdleMs); }, env.boxIdleMs);
}; };
export const runningBoxUsernames = (): Set<string> => new Set(boxes.keys());
+81 -14
View File
@@ -41,7 +41,11 @@ import {
stopWorkspaceContainer, stopWorkspaceContainer,
streamExecInContainer, streamExecInContainer,
} from './runtime/docker'; } from './runtime/docker';
import { acquireUserBox, releaseUserBox } from './user-container'; import {
acquireUserBox,
releaseUserBox,
runningBoxUsernames,
} from './user-container';
import { fetchUserEnvironment, materializeUserHome } from './user-environment'; import { fetchUserEnvironment, materializeUserHome } from './user-environment';
type Claim = { type Claim = {
@@ -1287,6 +1291,29 @@ const slugify = (value: string) =>
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-') || 'x'; .replace(/-{2,}/g, '-') || 'x';
export const planWorkdirCleanup = (args: {
root: string;
rootEntries: { name: string; isDirectory: boolean }[];
homesEntries: Record<string, { name: string; isDirectory: boolean }[]>;
codeLeaves: Record<string, string[]>;
activeWorkdirs: Set<string>;
runningBoxUsernames: Set<string>;
}): { removeDirs: string[] } => {
const removeDirs: string[] = [];
for (const entry of args.rootEntries) {
if (!entry.isDirectory || entry.name.startsWith('.')) continue;
if (entry.name === 'homes') continue;
const abs = path.resolve(args.root, entry.name);
if (args.activeWorkdirs.has(abs)) continue;
removeDirs.push(abs);
}
for (const [username, leaves] of Object.entries(args.codeLeaves)) {
if (args.runningBoxUsernames.has(username)) continue;
for (const leaf of leaves) removeDirs.push(leaf);
}
return { removeDirs };
};
const runClaim = async (claim: Claim) => { const runClaim = async (claim: Claim) => {
const jobId = claim.job._id; const jobId = claim.job._id;
const secretValues = [ const secretValues = [
@@ -1885,7 +1912,8 @@ export const getWorkerHealth = async () => {
workdir: workspace.workdir, workdir: workspace.workdir,
agentTurnActive: Boolean(workspace.agentTurnActive), agentTurnActive: Boolean(workspace.agentTurnActive),
})); }));
const containerNames = await listWorkspaceContainerNames('spoon-agent-job-'); const jobContainers = await listWorkspaceContainerNames('spoon-agent-job-');
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
return { return {
ok: true, ok: true,
buildSha: env.buildSha, buildSha: env.buildSha,
@@ -1904,7 +1932,8 @@ export const getWorkerHealth = async () => {
jobTimeoutMs: env.jobTimeoutMs, jobTimeoutMs: env.jobTimeoutMs,
activeWorkspaceCount: active.length, activeWorkspaceCount: active.length,
activeWorkspaces: active, activeWorkspaces: active,
workspaceContainers: containerNames, workspaceContainers: jobContainers,
boxContainers,
}; };
}; };
@@ -1928,14 +1957,55 @@ export const cleanupOrphanedWorkspaces = async () => {
removedContainers.push(containerName); removedContainers.push(containerName);
} }
const removedWorkdirs: string[] = [];
const root = path.resolve(env.workdir); const root = path.resolve(env.workdir);
const removedWorkdirs: string[] = [];
try { try {
const entries = await readdir(root, { withFileTypes: true }); const rootDirents = await readdir(root, { withFileTypes: true });
for (const entry of entries) { const rootEntries = rootDirents.map((d) => ({
if (!entry.isDirectory() || entry.name.startsWith('.')) continue; name: d.name,
const target = path.resolve(root, entry.name); isDirectory: d.isDirectory(),
if (activeWorkdirs.has(target)) continue; }));
const homesRoot = path.join(root, 'homes');
const homesEntries: Record<
string,
{ name: string; isDirectory: boolean }[]
> = {};
const codeLeaves: Record<string, string[]> = {};
const homesDirents = await readdir(homesRoot, {
withFileTypes: true,
}).catch(() => []);
for (const userDir of homesDirents) {
if (!userDir.isDirectory()) continue;
const username = userDir.name;
const codeRoot = path.join(homesRoot, username, 'Code');
homesEntries[username] = [];
const leaves: string[] = [];
const spoons = await readdir(codeRoot, { withFileTypes: true }).catch(
() => [],
);
for (const spoonDir of spoons) {
if (!spoonDir.isDirectory()) continue;
const spoonRoot = path.join(codeRoot, spoonDir.name);
const branches = await readdir(spoonRoot, {
withFileTypes: true,
}).catch(() => []);
for (const branchDir of branches) {
if (branchDir.isDirectory()) {
leaves.push(path.join(spoonRoot, branchDir.name));
}
}
}
codeLeaves[username] = leaves;
}
const { removeDirs } = planWorkdirCleanup({
root,
rootEntries,
homesEntries,
codeLeaves,
activeWorkdirs,
runningBoxUsernames: runningBoxUsernames(),
});
for (const target of removeDirs) {
await rm(target, { recursive: true, force: true }); await rm(target, { recursive: true, force: true });
removedWorkdirs.push(target); removedWorkdirs.push(target);
} }
@@ -1946,11 +2016,8 @@ export const cleanupOrphanedWorkspaces = async () => {
} }
} }
return { const boxContainers = await listWorkspaceContainerNames('spoon-box-');
success: true, return { success: true, removedContainers, removedWorkdirs, boxContainers };
removedContainers,
removedWorkdirs,
};
}; };
export const startWorker = async () => { export const startWorker = async () => {
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
return await import('../../src/worker');
};
describe('planWorkdirCleanup', () => {
afterEach(() => vi.resetModules());
test('never removes the homes/ root or any home directory', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [{ name: 'homes', isDirectory: true }],
homesEntries: { alice: [{ name: 'Code', isDirectory: true }] },
codeLeaves: {},
activeWorkdirs: new Set(),
runningBoxUsernames: new Set(),
});
expect(plan.removeDirs).not.toContain('/work/homes');
expect(plan.removeDirs).not.toContain('/work/homes/alice');
});
test('removes legacy top-level job dirs not in the active set', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [
{ name: 'homes', isDirectory: true },
{ name: 'legacy-job-123', isDirectory: true },
{ name: 'dev', isDirectory: true },
],
homesEntries: {},
codeLeaves: {},
activeWorkdirs: new Set(['/work/dev']),
runningBoxUsernames: new Set(),
});
expect(plan.removeDirs).toContain('/work/legacy-job-123');
expect(plan.removeDirs).not.toContain('/work/dev');
expect(plan.removeDirs).not.toContain('/work/homes');
});
test('removes per-thread checkouts only for users with no running box', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [{ name: 'homes', isDirectory: true }],
homesEntries: {
alice: [{ name: 'Code', isDirectory: true }],
bob: [{ name: 'Code', isDirectory: true }],
},
codeLeaves: {
alice: ['/work/homes/alice/Code/spoon-a/branch-x'],
bob: ['/work/homes/bob/Code/spoon-b/branch-y'],
},
activeWorkdirs: new Set(),
runningBoxUsernames: new Set(['bob']),
});
expect(plan.removeDirs).toContain(
'/work/homes/alice/Code/spoon-a/branch-x',
);
expect(plan.removeDirs).not.toContain(
'/work/homes/bob/Code/spoon-b/branch-y',
);
expect(plan.removeDirs).not.toContain('/work/homes/alice');
expect(plan.removeDirs).not.toContain('/work/homes/alice/Code');
});
});