import { getFunctionName } from 'convex/server'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; const mocks = vi.hoisted(() => ({ getWorktreeDiff: vi.fn(() => Promise.resolve({ exitCode: 0, output: 'unexpected diff' }), ), mutation: vi.fn(), stopWorkspaceContainer: vi.fn(() => Promise.resolve()), streamExecInContainer: vi.fn(), })); vi.mock('convex/browser', () => ({ ConvexHttpClient: vi.fn( class ConvexHttpClient { mutation = mocks.mutation; }, ), })); vi.mock('../../src/env', () => ({ env: { buildCreatedAt: 'test', buildSha: 'test', containerAccess: 'network', convexUrl: 'http://convex.test', jobTimeoutMs: 1000, runtime: 'docker', workerId: 'worker-1', workerToken: 'token', }, })); vi.mock('../../src/git', () => ({ cloneRepository: vi.fn(), commitAndPush: vi.fn(), getStatus: vi.fn(), getWorktreeDiff: mocks.getWorktreeDiff, run: vi.fn(), })); vi.mock('../../src/github', () => ({ getInstallationToken: vi.fn(), openDraftPullRequest: vi.fn(), })); vi.mock('../../src/opencode-session', () => ({ abortOpenCodeSession: vi.fn(), createOpenCodeSession: vi.fn(), promptOpenCodeSession: vi.fn(), replyOpenCodePermission: vi.fn(), })); vi.mock('../../src/runtime/docker', () => ({ listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])), runExecInContainer: vi.fn(), startWorkspaceContainer: vi.fn(), stopWorkspaceContainer: mocks.stopWorkspaceContainer, streamExecInContainer: mocks.streamExecInContainer, })); vi.mock('../../src/user-container', () => ({ acquireUserBox: vi.fn(), runningBoxUsernames: vi.fn(() => new Set()), })); vi.mock('../../src/user-environment', () => ({ fetchUserEnvironment: vi.fn(), materializeUserHome: vi.fn(), })); const decision = { decision: 'sync', risk: 'low', summary: 'Safe to sync.', ignoredCommitShas: [], ignoredReason: '', recommendedAction: 'Sync upstream.', requiresUserApproval: false, }; const load = async () => await import('../../src/worker'); afterEach(async () => { const worker = await load(); worker._resetActiveWorkspacesForTests(); vi.restoreAllMocks(); vi.clearAllMocks(); }); describe('maintenance workspace cleanup', () => { test.each(['mark', 'container'] as const)( 'releases and removes the workspace when %s teardown fails', async (failingStep) => { const worker = await load(); const release = vi.fn(); const close = vi.fn(); const jobId = `maintenance-${failingStep}`; mocks.mutation.mockImplementation((reference) => { if ( failingStep === 'mark' && getFunctionName(reference) === 'agentJobs:markWorkspaceStopped' ) { return Promise.reject(new Error('status teardown failed')); } return Promise.resolve('message-1'); }); mocks.stopWorkspaceContainer.mockImplementation(() => failingStep === 'container' ? Promise.reject(new Error('container teardown failed')) : Promise.resolve(), ); mocks.streamExecInContainer.mockImplementation(async (args) => { await args.onStdoutLine( JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: JSON.stringify(decision), }, }), ); return { exitCode: 0, output: '' }; }); worker._setActiveWorkspaceForTests(jobId, { claim: { job: { _id: jobId as Id<'agentJobs'>, prompt: 'Review maintenance changes', jobType: 'maintenance_review', baseBranch: 'main', workBranch: 'spoon/maintenance', forkOwner: 'team', forkRepo: 'spoon', upstreamOwner: 'upstream', upstreamRepo: 'spoon', }, spoon: { name: 'Spoon' }, openai: { model: 'gpt-5', reasoningEffort: 'medium', }, aiProviderProfile: { id: 'profile-1', name: 'Codex', provider: 'opencode_openai_login', authType: 'opencode_auth_json', model: 'gpt-5', reasoningEffort: 'medium', }, github: {}, secrets: [], }, workdir: '/tmp/workspace', homeDir: '/tmp/workspace', username: 'tester', containerHome: '/home/tester', containerRepo: '/home/tester/Code/spoon', repoDir: '/tmp/workspace/Code/spoon', boxName: 'spoon-box-tester', boxHandle: { boxName: 'spoon-box-tester', release }, githubToken: 'github-token', redact: (value: string) => value, runtime: 'codex', containerName: 'spoon-agent-job-maintenance', opencodeSession: { client: {} as never, sessionId: 'session-1', close, }, }); const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => undefined); await expect( worker.sendWorkspaceMessage(jobId, 'Review maintenance changes'), ).resolves.toBeUndefined(); expect(release).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledTimes(1); expect(mocks.stopWorkspaceContainer).toHaveBeenCalledTimes(1); expect(mocks.getWorktreeDiff).not.toHaveBeenCalled(); expect( mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'), ).toBe(false); await expect(worker.stopWorkspace(jobId)).rejects.toThrow( 'Agent workspace is not active on this worker.', ); expect(consoleError).toHaveBeenCalledTimes(1); }, ); });