maestro/src/worker.execute-job-gates.test.ts
oss-sync ed3dc5529a
All checks were successful
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s
sync: update from private repo (9a86f49b)
2026-07-12 23:51:48 +00:00

242 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>): 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>): 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<string, unknown>]> = [];
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<string, unknown>) => {
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<void> }).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 modelsexotic-model を含まない)
(worker as unknown as { availableModels: Set<string> }).availableModels = new Set(['test-model']);
// When
await (worker as unknown as { executeJob: (job: Job) => Promise<void> }).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<string> }).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<boolean>;
}
).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();
});
});