takt: refactor-status-handling (#477)

This commit is contained in:
nrs 2026-03-06 01:40:25 +09:00 committed by GitHub
parent bc5e1fd860
commit 16596eff09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 293 additions and 62 deletions

View File

@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockExecuteClaudeCli } = vi.hoisted(() => ({
mockExecuteClaudeCli: vi.fn(),
}));
vi.mock('../infra/claude/process.js', () => ({
executeClaudeCli: mockExecuteClaudeCli,
}));
vi.mock('../shared/utils/index.js', () => ({
createLogger: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
})),
}));
vi.mock('../shared/prompts/index.js', () => ({
loadTemplate: vi.fn(() => 'system prompt'),
}));
import { ClaudeClient } from '../infra/claude/client.js';
import type { ClaudeCallOptions } from '../infra/claude/client.js';
describe('ClaudeClient status normalization', () => {
const options: ClaudeCallOptions = {
cwd: '/tmp/takt-test',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should return error status when call() receives an interrupted failure', async () => {
mockExecuteClaudeCli.mockResolvedValue({
success: false,
interrupted: true,
content: 'Interrupted by signal',
error: 'SIGINT',
sessionId: 'session-1',
});
const client = new ClaudeClient();
const response = await client.call('coder', 'Implement feature', options);
expect(response.status).toBe('error');
expect(response.error).toBe('SIGINT');
expect(response.content).toBe('Interrupted by signal');
});
it('should return error status when callCustom() receives an interrupted failure', async () => {
mockExecuteClaudeCli.mockResolvedValue({
success: false,
interrupted: true,
content: 'Interrupted by signal',
error: 'SIGINT',
sessionId: 'session-2',
});
const client = new ClaudeClient();
const response = await client.callCustom('custom-coder', 'Implement feature', 'system prompt', options);
expect(response.status).toBe('error');
expect(response.error).toBe('SIGINT');
expect(response.content).toBe('Interrupted by signal');
});
});

View File

@ -155,8 +155,8 @@ describe('PieceEngine Integration: Error Handling', () => {
// ===================================================== // =====================================================
// 3. Interrupted status routing // 3. Interrupted status routing
// ===================================================== // =====================================================
describe('Interrupted status', () => { describe('Error status', () => {
it('should continue with normal rule routing and skip report phase when movement returns interrupted', async () => { it('should abort immediately and skip report phase when movement returns error', async () => {
const config = buildDefaultPieceConfig({ const config = buildDefaultPieceConfig({
initialMovement: 'plan', initialMovement: 'plan',
movements: [ movements: [
@ -169,11 +169,12 @@ describe('PieceEngine Integration: Error Handling', () => {
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ persona: 'plan', status: 'interrupted', content: 'Partial response' }), makeResponse({
]); persona: 'plan',
status: 'error',
mockDetectMatchedRuleSequence([ content: 'Partial response',
{ index: 0, method: 'phase1_tag' }, error: 'interrupted by signal',
}),
]); ]);
const abortFn = vi.fn(); const abortFn = vi.fn();
@ -181,10 +182,107 @@ describe('PieceEngine Integration: Error Handling', () => {
const state = await engine.run(); const state = await engine.run();
expect(state.status).toBe('completed'); expect(state.status).toBe('aborted');
expect(abortFn).not.toHaveBeenCalled(); expect(abortFn).toHaveBeenCalledOnce();
expect(runReportPhase).not.toHaveBeenCalled(); expect(runReportPhase).not.toHaveBeenCalled();
}); });
it('should abort when movement returns an unhandled status and skip report phase', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
makeMovement('plan', {
outputContracts: [{ name: '01-plan.md', format: '# Plan' }],
rules: [makeRule('continue', 'COMPLETE')],
}),
],
});
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({
persona: 'plan',
status: 'pending' as never,
content: 'pending response',
}),
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const state = await engine.run();
expect(state.status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
const reason = abortFn.mock.calls[0]![1] as string;
expect(reason).toContain('Unhandled response status: pending');
expect(runReportPhase).not.toHaveBeenCalled();
});
});
describe('runSingleIteration status routing', () => {
it('should abort without rule resolution when movement returns blocked', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('continue', 'COMPLETE')],
}),
],
});
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({
persona: 'plan',
status: 'blocked',
content: 'need input',
}),
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const result = await engine.runSingleIteration();
expect(result.nextMovement).toBe('ABORT');
expect(result.isComplete).toBe(true);
expect(engine.getState().status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
});
it('should abort without rule resolution when movement returns error', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('continue', 'COMPLETE')],
}),
],
});
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({
persona: 'plan',
status: 'error',
content: 'failed',
error: 'request failed',
}),
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const result = await engine.runSingleIteration();
expect(result.nextMovement).toBe('ABORT');
expect(result.isComplete).toBe(true);
expect(engine.getState().status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
const reason = abortFn.mock.calls[0]![1] as string;
expect(reason).toContain('Movement "plan" failed: request failed');
});
}); });
// ===================================================== // =====================================================

View File

@ -39,6 +39,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
import { PieceEngine } from '../core/piece/index.js'; import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js'; import { runAgent } from '../agents/runner.js';
import { runReportPhase } from '../core/piece/phase-runner.js';
import { import {
makeResponse, makeResponse,
makeMovement, makeMovement,
@ -208,6 +209,40 @@ describe('PieceEngine Integration: Loop Monitors', () => {
// 8 iterations: impl + ai_review*3 + ai_fix*2 + judge + reviewers // 8 iterations: impl + ai_review*3 + ai_fix*2 + judge + reviewers
expect(state.iteration).toBe(8); expect(state.iteration).toBe(8);
}); });
it('should abort when judge returns non-done status', async () => {
const config = buildConfigWithLoopMonitor(1);
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'implement', content: 'Implementation done' }),
makeResponse({ persona: 'ai_review', content: 'Issues found: X' }),
makeResponse({ persona: 'ai_fix', content: 'Fixed X' }),
makeResponse({
persona: 'supervisor',
status: 'error',
content: 'judge failed',
error: 'judge interrupted',
}),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 1, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const state = await engine.run();
expect(state.status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
const reason = abortFn.mock.calls[0]![1] as string;
expect(reason).toContain('Unhandled response status: error');
expect(runReportPhase).not.toHaveBeenCalled();
});
}); });
// ===================================================== // =====================================================

View File

@ -14,6 +14,7 @@ import {
resetScenario, resetScenario,
type ScenarioEntry, type ScenarioEntry,
} from '../infra/mock/index.js'; } from '../infra/mock/index.js';
import { STATUS_VALUES } from '../core/models/status.js';
describe('ScenarioQueue', () => { describe('ScenarioQueue', () => {
it('should consume entries in order when no agent specified', () => { it('should consume entries in order when no agent specified', () => {
@ -130,6 +131,16 @@ describe('loadScenarioFile', () => {
expect(entries[1]).toEqual({ persona: undefined, status: 'blocked', content: 'Blocked' }); expect(entries[1]).toEqual({ persona: undefined, status: 'blocked', content: 'Blocked' });
}); });
it('should accept all statuses from shared status contract', () => {
const scenario = STATUS_VALUES.map((status, i) => ({ status, content: `entry-${i}` }));
const filePath = join(tempDir, 'all-statuses.json');
writeFileSync(filePath, JSON.stringify(scenario));
const entries = loadScenarioFile(filePath);
expect(entries.map((entry) => entry.status)).toEqual([...STATUS_VALUES]);
});
it('should default status to "done" if omitted', () => { it('should default status to "done" if omitted', () => {
const scenario = [{ content: 'Simple response' }]; const scenario = [{ content: 'Simple response' }];
const filePath = join(tempDir, 'scenario.json'); const filePath = join(tempDir, 'scenario.json');
@ -167,7 +178,21 @@ describe('loadScenarioFile', () => {
it('should throw for invalid status', () => { it('should throw for invalid status', () => {
const filePath = join(tempDir, 'bad-status.json'); const filePath = join(tempDir, 'bad-status.json');
writeFileSync(filePath, '[{"content": "test", "status": "invalid"}]'); writeFileSync(filePath, '[{"content": "test", "status": "approved"}]');
expect(() => loadScenarioFile(filePath)).toThrow('invalid status');
});
it('should throw for rejected status', () => {
const filePath = join(tempDir, 'rejected-status.json');
writeFileSync(filePath, '[{"content": "test", "status": "rejected"}]');
expect(() => loadScenarioFile(filePath)).toThrow('invalid status');
});
it('should throw for improve status', () => {
const filePath = join(tempDir, 'improve-status.json');
writeFileSync(filePath, '[{"content": "test", "status": "improve"}]');
expect(() => loadScenarioFile(filePath)).toThrow('invalid status'); expect(() => loadScenarioFile(filePath)).toThrow('invalid status');
}); });

View File

@ -14,6 +14,7 @@ import {
GlobalConfigSchema, GlobalConfigSchema,
ProjectConfigSchema, ProjectConfigSchema,
} from '../core/models/index.js'; } from '../core/models/index.js';
import { STATUS_VALUES } from '../core/models/status.js';
describe('AgentTypeSchema', () => { describe('AgentTypeSchema', () => {
it('should accept valid agent types', () => { it('should accept valid agent types', () => {
@ -30,18 +31,25 @@ describe('AgentTypeSchema', () => {
describe('StatusSchema', () => { describe('StatusSchema', () => {
it('should accept valid statuses', () => { it('should accept valid statuses', () => {
expect(StatusSchema.parse('pending')).toBe('pending');
expect(StatusSchema.parse('done')).toBe('done'); expect(StatusSchema.parse('done')).toBe('done');
expect(StatusSchema.parse('approved')).toBe('approved');
expect(StatusSchema.parse('rejected')).toBe('rejected');
expect(StatusSchema.parse('blocked')).toBe('blocked'); expect(StatusSchema.parse('blocked')).toBe('blocked');
expect(StatusSchema.parse('error')).toBe('error'); expect(StatusSchema.parse('error')).toBe('error');
expect(StatusSchema.parse('answer')).toBe('answer'); });
it('should align with the shared status contract values', () => {
expect(StatusSchema.options).toEqual([...STATUS_VALUES]);
}); });
it('should reject invalid statuses', () => { it('should reject invalid statuses', () => {
expect(() => StatusSchema.parse('unknown')).toThrow(); expect(() => StatusSchema.parse('unknown')).toThrow();
expect(() => StatusSchema.parse('conditional')).toThrow(); expect(() => StatusSchema.parse('conditional')).toThrow();
expect(() => StatusSchema.parse('pending')).toThrow();
expect(() => StatusSchema.parse('approved')).toThrow();
expect(() => StatusSchema.parse('rejected')).toThrow();
expect(() => StatusSchema.parse('improve')).toThrow();
expect(() => StatusSchema.parse('cancelled')).toThrow();
expect(() => StatusSchema.parse('interrupted')).toThrow();
expect(() => StatusSchema.parse('answer')).toThrow();
}); });
}); });

View File

@ -8,6 +8,7 @@ import { z } from 'zod/v4';
import { DEFAULT_LANGUAGE } from '../../shared/constants.js'; import { DEFAULT_LANGUAGE } from '../../shared/constants.js';
import { McpServersSchema } from './mcp-schemas.js'; import { McpServersSchema } from './mcp-schemas.js';
import { INTERACTIVE_MODES } from './interactive-mode.js'; import { INTERACTIVE_MODES } from './interactive-mode.js';
import { STATUS_VALUES } from './status.js';
export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js'; export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js';
@ -44,18 +45,7 @@ export const TaktConfigSchema = z.object({
export const AgentTypeSchema = z.enum(['coder', 'architect', 'supervisor', 'custom']); export const AgentTypeSchema = z.enum(['coder', 'architect', 'supervisor', 'custom']);
/** Status schema */ /** Status schema */
export const StatusSchema = z.enum([ export const StatusSchema = z.enum(STATUS_VALUES);
'pending',
'done',
'blocked',
'error',
'approved',
'rejected',
'improve',
'cancelled',
'interrupted',
'answer',
]);
/** Permission mode schema for tool execution */ /** Permission mode schema for tool execution */
export const PermissionModeSchema = z.enum(['readonly', 'edit', 'full']); export const PermissionModeSchema = z.enum(['readonly', 'edit', 'full']);

View File

@ -5,6 +5,8 @@
import type { AgentResponse } from './response.js'; import type { AgentResponse } from './response.js';
import type { Status } from './status.js'; import type { Status } from './status.js';
type SessionAgentStatus = 'pending' | Status;
/** /**
* Session state for piece execution * Session state for piece execution
*/ */
@ -13,9 +15,9 @@ export interface SessionState {
projectDir: string; projectDir: string;
iteration: number; iteration: number;
maxMovements: number; maxMovements: number;
coderStatus: Status; coderStatus: SessionAgentStatus;
architectStatus: Status; architectStatus: SessionAgentStatus;
supervisorStatus: Status; supervisorStatus: SessionAgentStatus;
history: AgentResponse[]; history: AgentResponse[];
context: string; context: string;
} }

View File

@ -6,17 +6,10 @@
export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom'; export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom';
/** Execution status for agents and pieces */ /** Execution status for agents and pieces */
export type Status = export const STATUS_VALUES = ['done', 'blocked', 'error'] as const;
| 'pending'
| 'done' /** Execution status for agents and pieces */
| 'blocked' export type Status = typeof STATUS_VALUES[number];
| 'error'
| 'approved'
| 'rejected'
| 'improve'
| 'cancelled'
| 'interrupted'
| 'answer';
/** How a rule match was detected */ /** How a rule match was detected */
export type RuleMatchMethod = export type RuleMatchMethod =

View File

@ -446,6 +446,14 @@ export class PieceEngine extends EventEmitter {
throw new Error(`No matching rule found for movement "${step.name}" (status: ${response.status})`); throw new Error(`No matching rule found for movement "${step.name}" (status: ${response.status})`);
} }
private resolveNextMovementFromDone(step: PieceMovement, response: AgentResponse): string {
if (response.status !== 'done') {
throw new Error(`Unhandled response status: ${response.status}`);
}
return this.resolveNextMovement(step, response);
}
/** Build instruction (public, used by pieceExecution.ts for logging) */ /** Build instruction (public, used by pieceExecution.ts for logging) */
buildInstruction(step: PieceMovement, movementIteration: number): string { buildInstruction(step: PieceMovement, movementIteration: number): string {
return this.movementExecutor.buildInstruction( return this.movementExecutor.buildInstruction(
@ -557,7 +565,7 @@ export class PieceEngine extends EventEmitter {
this.emit('movement:complete', judgeMovement, response, instruction); this.emit('movement:complete', judgeMovement, response, instruction);
// Resolve next movement from the judge's rules // Resolve next movement from the judge's rules
const nextMovement = this.resolveNextMovement(judgeMovement, response); const nextMovement = this.resolveNextMovementFromDone(judgeMovement, response);
log.info('Loop monitor judge decision', { log.info('Loop monitor judge decision', {
cycle: monitor.cycle, cycle: monitor.cycle,
@ -658,7 +666,7 @@ export class PieceEngine extends EventEmitter {
break; break;
} }
let nextMovement = this.resolveNextMovement(movement, response); let nextMovement = this.resolveNextMovementFromDone(movement, response);
log.debug('Movement transition', { log.debug('Movement transition', {
from: movement.name, from: movement.name,
status: response.status, status: response.status,
@ -764,7 +772,21 @@ export class PieceEngine extends EventEmitter {
this.state.iteration++; this.state.iteration++;
const { response } = await this.runMovement(movement); const { response } = await this.runMovement(movement);
const nextMovement = this.resolveNextMovement(movement, response);
if (response.status === 'blocked') {
this.state.status = 'aborted';
this.emit('piece:abort', this.state, 'Piece blocked and no user input provided');
return { response, nextMovement: ABORT_MOVEMENT, isComplete: true, loopDetected: loopCheck.isLoop };
}
if (response.status === 'error') {
const detail = response.error ?? response.content;
this.state.status = 'aborted';
this.emit('piece:abort', this.state, `Movement "${movement.name}" failed: ${detail}`);
return { response, nextMovement: ABORT_MOVEMENT, isComplete: true, loopDetected: loopCheck.isLoop };
}
const nextMovement = this.resolveNextMovementFromDone(movement, response);
const isComplete = nextMovement === COMPLETE_MOVEMENT || nextMovement === ABORT_MOVEMENT; const isComplete = nextMovement === COMPLETE_MOVEMENT || nextMovement === ABORT_MOVEMENT;
if (response.matchedRuleIndex != null && movement.rules) { if (response.matchedRuleIndex != null && movement.rules) {

View File

@ -25,9 +25,6 @@ export class ClaudeClient {
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string }, result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
): Status { ): Status {
if (!result.success) { if (!result.success) {
if (result.interrupted) {
return 'interrupted';
}
return 'error'; return 'error';
} }
return 'done'; return 'done';

View File

@ -64,7 +64,6 @@ export async function executeClaudeCli(
export class ClaudeProcess { export class ClaudeProcess {
private options: ClaudeSpawnOptions; private options: ClaudeSpawnOptions;
private currentSessionId?: string; private currentSessionId?: string;
private interrupted = false;
constructor(options: ClaudeSpawnOptions) { constructor(options: ClaudeSpawnOptions) {
this.options = options; this.options = options;
@ -72,18 +71,13 @@ export class ClaudeProcess {
/** Execute a prompt */ /** Execute a prompt */
async execute(prompt: string): Promise<ClaudeResult> { async execute(prompt: string): Promise<ClaudeResult> {
this.interrupted = false;
const result = await executeClaudeCli(prompt, this.options); const result = await executeClaudeCli(prompt, this.options);
this.currentSessionId = result.sessionId; this.currentSessionId = result.sessionId;
if (result.interrupted) {
this.interrupted = true;
}
return result; return result;
} }
/** Interrupt the running query */ /** Interrupt the running query */
kill(): void { kill(): void {
this.interrupted = true;
interruptCurrentProcess(); interruptCurrentProcess();
} }
@ -96,9 +90,4 @@ export class ClaudeProcess {
getSessionId(): string | undefined { getSessionId(): string | undefined {
return this.currentSessionId; return this.currentSessionId;
} }
/** Check if query was interrupted */
wasInterrupted(): boolean {
return this.interrupted;
}
} }

View File

@ -8,6 +8,7 @@
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import type { ScenarioEntry } from './types.js'; import type { ScenarioEntry } from './types.js';
import { STATUS_VALUES } from '../../core/models/status.js';
export type { ScenarioEntry }; export type { ScenarioEntry };
@ -130,11 +131,10 @@ function validateEntry(entry: unknown, index: number): ScenarioEntry {
} }
// status defaults to 'done' // status defaults to 'done'
const validStatuses = ['done', 'blocked', 'error', 'approved', 'rejected', 'improve'] as const;
const status = obj.status ?? 'done'; const status = obj.status ?? 'done';
if (typeof status !== 'string' || !validStatuses.includes(status as typeof validStatuses[number])) { if (typeof status !== 'string' || !STATUS_VALUES.includes(status as typeof STATUS_VALUES[number])) {
throw new Error( throw new Error(
`Scenario entry [${index}] has invalid status "${String(status)}". Valid: ${validStatuses.join(', ')}`, `Scenario entry [${index}] has invalid status "${String(status)}". Valid: ${STATUS_VALUES.join(', ')}`,
); );
} }

View File

@ -3,6 +3,7 @@
*/ */
import type { StreamCallback } from '../claude/index.js'; import type { StreamCallback } from '../claude/index.js';
import type { Status } from '../../core/models/status.js';
/** Options for mock calls */ /** Options for mock calls */
export interface MockCallOptions { export interface MockCallOptions {
@ -12,7 +13,7 @@ export interface MockCallOptions {
/** Fixed response content (optional, defaults to generic mock response) */ /** Fixed response content (optional, defaults to generic mock response) */
mockResponse?: string; mockResponse?: string;
/** Fixed status to return (optional, defaults to 'done') */ /** Fixed status to return (optional, defaults to 'done') */
mockStatus?: 'done' | 'blocked' | 'error' | 'approved' | 'rejected' | 'improve'; mockStatus?: Status;
/** Structured output payload returned as-is */ /** Structured output payload returned as-is */
structuredOutput?: Record<string, unknown>; structuredOutput?: Record<string, unknown>;
} }
@ -22,7 +23,7 @@ export interface ScenarioEntry {
/** Persona name to match (optional — if omitted, consumed by call order) */ /** Persona name to match (optional — if omitted, consumed by call order) */
persona?: string; persona?: string;
/** Response status */ /** Response status */
status: 'done' | 'blocked' | 'error' | 'approved' | 'rejected' | 'improve'; status: Status;
/** Response content body */ /** Response content body */
content: string; content: string;
/** Optional structured output payload (for outputSchema-driven flows) */ /** Optional structured output payload (for outputSchema-driven flows) */