fix(worker): make workspace cleanup layout-aware
This commit is contained in:
@@ -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());
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user