import { describe, expect, it, vi } from 'vitest'; import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { Worker } from './worker.js'; import { userPiecesDir } from './user-folder/paths.js'; import type { AppConfig } from './config.js'; import type { Job } from './db/repository.js'; /** * executeJob の pre-flight ゲート(切り出しリファクタ前のピン留めテスト)。 * * 対象: * - MCP 認証ゲート: piece.required_mcp のトークンが無い場合、 * status='waiting_human' + wait_reason='mcp_auth_required' で park する。 * wait_reason の永続化が漏れるとジョブが沈黙して永久停止する既知の * 失敗モードがあるため、パッチ内容を厳密に固定する。 * - Model-mismatch requeue ゲート: piece が pin したモデルをこの worker が * サーブできない場合、status='queued' + workerId=null で再キューする。 * * どちらも executeJob を実際に走らせ、リポジトリモックへの書き込みを検証する * (Given-When-Then)。 */ function makeConfig(overrides: Partial): AppConfig { return { provider: { model: 'test-model', workers: [{ id: 'worker-1', endpoint: 'http://localhost:11434/v1' }], }, worktreeDir: '/tmp/worker-gate-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: [], }, ...overrides, } as AppConfig; } function makeLocalTaskJob(overrides: Partial): Job { return { id: 'job-gate-1', repo: 'local/task-77', issueNumber: 77, prNumber: null, status: 'running', pieceName: 'pin-gate-piece', requiredRole: 'auto', requiredProfile: 'auto', currentMovement: null, instruction: 'do something', 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(), ...overrides, } as unknown as Job; } function makeRepoMock() { 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); }), getLocalTask: vi.fn().mockResolvedValue(null), updateLocalTask: vi.fn().mockResolvedValue(undefined), addLocalTaskComment: vi.fn().mockResolvedValue(undefined), }; return { repo, updateJobCalls }; } /** Given: userFolderRoot 配下の local オーナー custom piece ディレクトリに piece を配置 */ function writeCustomPiece(userFolderRoot: string, name: string, extraTopLevel: string): void { const dir = userPiecesDir(userFolderRoot, 'local'); mkdirSync(dir, { recursive: true }); const yaml = [ `name: ${name}`, 'description: gate pinning test piece', 'initial_movement: respond', extraTopLevel, 'movements:', ' - name: respond', ' persona: assistant', ' instruction: answer the user', ' default_next: COMPLETE', ' rules: []', '', ].join('\n'); writeFileSync(join(dir, `${name}.yaml`), yaml, 'utf-8'); } describe('executeJob pre-flight gates (behaviour pinning)', () => { it('parks the job as waiting_human with wait_reason=mcp_auth_required when a required MCP token is missing', async () => { // Given: required_mcp を持つ custom piece と、MCP トークンを一切持たない worker const root = mkdtempSync(join(tmpdir(), 'worker-gate-mcp-')); const userFolderRoot = join(root, 'users'); writeCustomPiece(userFolderRoot, 'pin-gate-piece', 'required_mcp:\n - github-mcp'); const { repo, updateJobCalls } = makeRepoMock(); const worker = new Worker( 'worker-1', 'http://localhost:11434/v1', 'test-model', repo as never, makeConfig({ worktreeDir: join(root, 'worktree'), userFolderRoot }), ); // When: executeJob を最後まで走らせる await (worker as unknown as { executeJob: (job: Job) => Promise }).executeJob( makeLocalTaskJob({}), ); // Then: wait_reason='mcp_auth_required' を含む park パッチが永続化される //(resume_movement は piece の initial_movement) const parkCall = updateJobCalls.find(([, patch]) => patch.status === 'waiting_human'); expect(parkCall).toBeDefined(); expect(parkCall![1]).toEqual({ status: 'waiting_human', waitReason: 'mcp_auth_required', resumeMovement: 'respond', }); // Then: ユーザーに system イベントコメントで連携を促す expect(repo.addLocalTaskComment).toHaveBeenCalledWith( 77, 'system', expect.stringContaining('github-mcp'), 'event', ); // Then: 失敗扱いにはならず、issue ロックは finally で必ず解放される expect(updateJobCalls.some(([, patch]) => patch.status === 'failed')).toBe(false); expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77); }); it('requeues the job (status=queued, workerId=null) when the piece pins a model this worker cannot serve', async () => { // Given: model を pin した custom piece と、そのモデルを持たない direct worker const root = mkdtempSync(join(tmpdir(), 'worker-gate-model-')); const userFolderRoot = join(root, 'users'); writeCustomPiece(userFolderRoot, 'pin-gate-piece', 'model: exotic-model'); const { repo, updateJobCalls } = makeRepoMock(); const worker = new Worker( 'worker-1', 'http://localhost:11434/v1', 'test-model', repo as never, makeConfig({ worktreeDir: join(root, 'worktree'), userFolderRoot }), ); // initialize() 相当: probed available models(exotic-model を含まない) (worker as unknown as { availableModels: Set }).availableModels = new Set(['test-model']); // When await (worker as unknown as { executeJob: (job: Job) => Promise }).executeJob( makeLocalTaskJob({}), ); // Then: queued に戻し worker を外す(他 worker が拾えるように) const requeueCall = updateJobCalls.find(([, patch]) => patch.status === 'queued'); expect(requeueCall).toBeDefined(); expect(requeueCall![1]).toEqual({ status: 'queued', workerId: null, errorSummary: 'Required model exotic-model is not available on worker-1', }); expect(repo.addAuditLog).toHaveBeenCalledWith('job-gate-1', 'job_requeued_model_mismatch', 'worker', { workerId: 'worker-1', requiredModel: 'exotic-model', availableModels: ['test-model'], }); // Then: park はしない expect(updateJobCalls.some(([, patch]) => patch.status === 'waiting_human')).toBe(false); expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77); }); it('does NOT requeue a model-mismatched job when requiredWorkerId pins it to this worker (pin wins over piece.model)', async () => { // Given: 同じ model-mismatch 条件(worker が exotic-model をサーブできない)だが、 // job が worker-1 に requiredWorkerId でピン留めされている。ピン留めされたジョブは // worker-1 にしか claim されないため(Task 8 claim SQL)、ここで requeue すると // claim → mismatch → requeue の無限ループになる。 // // 実行経路は requeueOnModelMismatch の直後に startPieceRun(実 LLM 呼び出し込みの // ReAct ループ)へ進んでしまい、そこまでモックするのは過剰なため、 // executeJob から切り出し済みの private ゲートメソッドを直接呼ぶ(既存の // 「requeue する」ケースは executeJob 経由で pin-gate-piece 一式ごと検証済みなので、 // ここでは pin による早期リターンだけを焦点にする)。 const { repo, updateJobCalls } = makeRepoMock(); const worker = new Worker( 'worker-1', 'http://localhost:11434/v1', 'test-model', repo as never, makeConfig({ worktreeDir: '/tmp/worker-gate-pin-model' }), ); (worker as unknown as { availableModels: Set }).availableModels = new Set(['test-model']); const piece = { name: 'pin-gate-piece', description: 'gate pinning test piece', max_movements: 30, initial_movement: 'respond', model: 'exotic-model', movements: [ { name: 'respond', persona: 'assistant', instruction: 'answer', rules: [], default_next: 'COMPLETE' }, ], }; // When: requeueOnModelMismatch を requiredWorkerId='worker-1' の job で直接呼ぶ const requeued = await ( worker as unknown as { requeueOnModelMismatch: (job: Job, piece: unknown) => Promise; } ).requeueOnModelMismatch(makeLocalTaskJob({ requiredWorkerId: 'worker-1' }), piece); // Then: 「requeue しない」(false) が返り、queued への書き戻し・監査ログのどちらも発生しない expect(requeued).toBe(false); expect(updateJobCalls.some(([, patch]) => patch.status === 'queued')).toBe(false); expect(repo.addAuditLog).not.toHaveBeenCalled(); }); });