import { describe, it, expect, vi } from 'vitest'; import { Worker } from './worker.js'; import type { AppConfig } from './config.js'; import type { Job } from './db/repository.js'; function makeConfig(): AppConfig { return { provider: { model: 'test-model', workers: [{ id: 'worker-1', endpoint: 'http://localhost:11434/v1', roles: ['auto', 'fast', 'quality', 'reflection'] }], }, worktreeDir: '/tmp/worker-reflection-dispatch-test', concurrency: 1, maxMovements: 30, retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] }, ask: { maxPerJob: 2 }, subtasks: { maxDepth: 2 }, tools: { searxngUrl: 'http://localhost:8080', visionModel: 'vision', visionTimeout: 60, visionMaxTokens: 1024, webfetchTimeout: 30, websearchTimeout: 15, webfetchAllowedHosts: [], }, }; } function makeReflectionJob(): Job { return { id: 'job-reflect-1', repo: 'local/reflection-job-orig-1', issueNumber: 0, prNumber: null, status: 'running', pieceName: 'reflection', requiredRole: 'reflection', requiredProfile: 'reflection', currentMovement: null, instruction: '', branchName: null, worktreePath: null, attempt: 1, maxAttempts: 3, nextRetryAt: null, errorSummary: null, resumeMovement: null, askCount: 0, workerId: 'worker-1', parentJobId: null, subtaskDepth: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), taskKind: 'reflection', payload: JSON.stringify({ originalJobId: 'job-orig-1', userId: 'u-1', pieceName: 'chat', outcome: 'succeeded', }), ownerId: 'u-1', visibility: 'private', visibilityScopeOrgId: null, browserSessionProfileId: null, } as unknown as Job; } describe('worker reflection dispatch', () => { it('a task_kind=reflection job reaches a terminal state without going through the agent loop', async () => { // Track all updateJob calls so we can verify a terminal status is set. const updateJobCalls: Array<[string, Record]> = []; const repo = { lockIssue: vi.fn().mockResolvedValue(true), unlockIssue: vi.fn().mockResolvedValue(undefined), addAuditLog: vi.fn().mockResolvedValue(undefined), updateWorkerNodeHealth: vi.fn().mockResolvedValue(undefined), updateJob: vi.fn().mockImplementation((id: string, patch: Record) => { updateJobCalls.push([id, patch]); return Promise.resolve(undefined); }), }; const worker = new Worker( 'worker-1', 'http://localhost:11434/v1', 'test-model', repo as never, makeConfig(), ); const job = makeReflectionJob(); await (worker as unknown as { executeJob: (job: Job) => Promise }).executeJob(job); // handleReflectionJob must have called updateJob with a terminal status. // Note: the reflection-runner skeleton returns 'failed', so the status will be // 'failed' until Phase 3.2+ implements real logic. We accept either terminal status // to avoid coupling the test to the stub's return value. const terminalCall = updateJobCalls.find( ([id, patch]) => id === 'job-reflect-1' && (patch.status === 'succeeded' || patch.status === 'failed'), ); expect(terminalCall).toBeDefined(); // The agent-loop path (loadPiece) must NOT have been called. // We verify this indirectly: lockIssue was called (got past acquireJobOrRequeue), // but no 'Piece not found' / loadPiece side-effects occurred // (the mock repo has no loadPiece, so if it were called it would throw). expect(repo.lockIssue).toHaveBeenCalledWith('local/reflection-job-orig-1', 0, 'job-reflect-1'); expect(repo.unlockIssue).toHaveBeenCalledWith('local/reflection-job-orig-1', 0); }); });