debugを強化

This commit is contained in:
nrslib 2026-02-03 22:01:17 +09:00
parent 49c780465b
commit cae770cef4
17 changed files with 452 additions and 9 deletions

View File

@ -384,7 +384,90 @@ describe('WorkflowEngine Integration: Happy Path', () => {
}); });
// ===================================================== // =====================================================
// 7. Config validation // 7. Phase events
// =====================================================
describe('Phase events', () => {
it('should emit phase:start and phase:complete events for Phase 1', async () => {
const simpleConfig: WorkflowConfig = {
name: 'test',
maxIterations: 10,
initialStep: 'plan',
steps: [
makeStep('plan', {
rules: [makeRule('done', 'COMPLETE')],
}),
],
};
const engine = new WorkflowEngine(simpleConfig, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan done' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
]);
const phaseStartFn = vi.fn();
const phaseCompleteFn = vi.fn();
engine.on('phase:start', phaseStartFn);
engine.on('phase:complete', phaseCompleteFn);
await engine.run();
expect(phaseStartFn).toHaveBeenCalledWith(
expect.objectContaining({ name: 'plan' }),
1, 'execute', expect.any(String)
);
expect(phaseCompleteFn).toHaveBeenCalledWith(
expect.objectContaining({ name: 'plan' }),
1, 'execute', expect.any(String), 'done', undefined
);
});
it('should emit phase events for all steps in happy path', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan' }),
makeResponse({ agent: 'implement', content: 'Impl' }),
makeResponse({ agent: 'ai_review', content: 'OK' }),
makeResponse({ agent: 'arch-review', content: 'OK' }),
makeResponse({ agent: 'security-review', content: 'OK' }),
makeResponse({ agent: 'supervise', content: 'Pass' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const phaseStartFn = vi.fn();
const phaseCompleteFn = vi.fn();
engine.on('phase:start', phaseStartFn);
engine.on('phase:complete', phaseCompleteFn);
await engine.run();
// 4 normal steps + 2 parallel sub-steps = 6 Phase 1 invocations
expect(phaseStartFn).toHaveBeenCalledTimes(6);
expect(phaseCompleteFn).toHaveBeenCalledTimes(6);
// All calls should be Phase 1 (execute) since report/judgment are mocked off
for (const call of phaseStartFn.mock.calls) {
expect(call[1]).toBe(1);
expect(call[2]).toBe('execute');
}
});
});
// =====================================================
// 8. Config validation
// ===================================================== // =====================================================
describe('Config validation', () => { describe('Config validation', () => {
it('should throw when initial step does not exist', () => { it('should throw when initial step does not exist', () => {

View File

@ -19,6 +19,10 @@ import {
type NdjsonStepComplete, type NdjsonStepComplete,
type NdjsonWorkflowComplete, type NdjsonWorkflowComplete,
type NdjsonWorkflowAbort, type NdjsonWorkflowAbort,
type NdjsonPhaseStart,
type NdjsonPhaseComplete,
type NdjsonInteractiveStart,
type NdjsonInteractiveEnd,
} from '../infra/fs/session.js'; } from '../infra/fs/session.js';
/** Create a temp project directory with .takt/logs structure */ /** Create a temp project directory with .takt/logs structure */
@ -445,4 +449,193 @@ describe('NDJSON log', () => {
} }
}); });
}); });
describe('phase NDJSON records', () => {
it('should serialize and append phase_start records', () => {
const filepath = initNdjsonLog('sess-phase-001', 'task', 'wf', projectDir);
const record: NdjsonPhaseStart = {
type: 'phase_start',
step: 'plan',
phase: 1,
phaseName: 'execute',
timestamp: '2025-01-01T00:00:01.000Z',
instruction: 'Do the planning',
};
appendNdjsonLine(filepath, record);
const content = readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n');
expect(lines).toHaveLength(2); // workflow_start + phase_start
const parsed = JSON.parse(lines[1]!) as NdjsonRecord;
expect(parsed.type).toBe('phase_start');
if (parsed.type === 'phase_start') {
expect(parsed.step).toBe('plan');
expect(parsed.phase).toBe(1);
expect(parsed.phaseName).toBe('execute');
expect(parsed.instruction).toBe('Do the planning');
}
});
it('should serialize and append phase_complete records', () => {
const filepath = initNdjsonLog('sess-phase-002', 'task', 'wf', projectDir);
const record: NdjsonPhaseComplete = {
type: 'phase_complete',
step: 'plan',
phase: 2,
phaseName: 'report',
status: 'done',
content: 'Report output',
timestamp: '2025-01-01T00:00:02.000Z',
};
appendNdjsonLine(filepath, record);
const content = readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n');
expect(lines).toHaveLength(2);
const parsed = JSON.parse(lines[1]!) as NdjsonRecord;
expect(parsed.type).toBe('phase_complete');
if (parsed.type === 'phase_complete') {
expect(parsed.step).toBe('plan');
expect(parsed.phase).toBe(2);
expect(parsed.phaseName).toBe('report');
expect(parsed.status).toBe('done');
expect(parsed.content).toBe('Report output');
}
});
it('should serialize phase_complete with error', () => {
const filepath = initNdjsonLog('sess-phase-003', 'task', 'wf', projectDir);
const record: NdjsonPhaseComplete = {
type: 'phase_complete',
step: 'impl',
phase: 3,
phaseName: 'judge',
status: 'error',
timestamp: '2025-01-01T00:00:03.000Z',
error: 'Status judgment phase failed',
};
appendNdjsonLine(filepath, record);
const content = readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n');
const parsed = JSON.parse(lines[1]!) as NdjsonRecord;
expect(parsed.type).toBe('phase_complete');
if (parsed.type === 'phase_complete') {
expect(parsed.error).toBe('Status judgment phase failed');
expect(parsed.phase).toBe(3);
expect(parsed.phaseName).toBe('judge');
}
});
it('should be skipped by loadNdjsonLog (default case)', () => {
const filepath = initNdjsonLog('sess-phase-004', 'task', 'wf', projectDir);
// Add phase records
appendNdjsonLine(filepath, {
type: 'phase_start',
step: 'plan',
phase: 1,
phaseName: 'execute',
timestamp: '2025-01-01T00:00:01.000Z',
instruction: 'Plan it',
} satisfies NdjsonPhaseStart);
appendNdjsonLine(filepath, {
type: 'phase_complete',
step: 'plan',
phase: 1,
phaseName: 'execute',
status: 'done',
content: 'Planned',
timestamp: '2025-01-01T00:00:02.000Z',
} satisfies NdjsonPhaseComplete);
// Add a step_complete so we can verify history
appendNdjsonLine(filepath, {
type: 'step_complete',
step: 'plan',
agent: 'planner',
status: 'done',
content: 'Plan completed',
instruction: 'Plan it',
timestamp: '2025-01-01T00:00:03.000Z',
} satisfies NdjsonStepComplete);
const log = loadNdjsonLog(filepath);
expect(log).not.toBeNull();
// Only step_complete should contribute to history
expect(log!.history).toHaveLength(1);
expect(log!.iterations).toBe(1);
});
});
describe('interactive NDJSON records', () => {
it('should serialize and append interactive_start records', () => {
const filepath = initNdjsonLog('sess-interactive-001', 'task', 'wf', projectDir);
const record: NdjsonInteractiveStart = {
type: 'interactive_start',
timestamp: '2025-01-01T00:00:01.000Z',
};
appendNdjsonLine(filepath, record);
const content = readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n');
expect(lines).toHaveLength(2);
const parsed = JSON.parse(lines[1]!) as NdjsonRecord;
expect(parsed.type).toBe('interactive_start');
if (parsed.type === 'interactive_start') {
expect(parsed.timestamp).toBe('2025-01-01T00:00:01.000Z');
}
});
it('should serialize and append interactive_end records', () => {
const filepath = initNdjsonLog('sess-interactive-002', 'task', 'wf', projectDir);
const record: NdjsonInteractiveEnd = {
type: 'interactive_end',
confirmed: true,
task: 'Build a feature',
timestamp: '2025-01-01T00:00:02.000Z',
};
appendNdjsonLine(filepath, record);
const content = readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n');
expect(lines).toHaveLength(2);
const parsed = JSON.parse(lines[1]!) as NdjsonRecord;
expect(parsed.type).toBe('interactive_end');
if (parsed.type === 'interactive_end') {
expect(parsed.confirmed).toBe(true);
expect(parsed.task).toBe('Build a feature');
}
});
it('should be skipped by loadNdjsonLog (default case)', () => {
const filepath = initNdjsonLog('sess-interactive-003', 'task', 'wf', projectDir);
appendNdjsonLine(filepath, {
type: 'interactive_start',
timestamp: '2025-01-01T00:00:01.000Z',
} satisfies NdjsonInteractiveStart);
appendNdjsonLine(filepath, {
type: 'interactive_end',
confirmed: true,
task: 'Some task',
timestamp: '2025-01-01T00:00:02.000Z',
} satisfies NdjsonInteractiveEnd);
const log = loadNdjsonLog(filepath);
expect(log).not.toBeNull();
expect(log!.history).toHaveLength(0);
});
});
}); });

View File

@ -104,5 +104,6 @@ program
selectOptions.interactiveUserInput = true; selectOptions.interactiveUserInput = true;
selectOptions.workflow = workflowId; selectOptions.workflow = workflowId;
selectOptions.interactiveMetadata = { confirmed: result.confirmed, task: result.task };
await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides);
}); });

View File

@ -9,7 +9,7 @@ import { join } from 'node:path';
import type { WorkflowStep, WorkflowState, Language } from '../../models/types.js'; import type { WorkflowStep, WorkflowState, Language } from '../../models/types.js';
import type { RunAgentOptions } from '../../../agents/runner.js'; import type { RunAgentOptions } from '../../../agents/runner.js';
import type { PhaseRunnerContext } from '../phase-runner.js'; import type { PhaseRunnerContext } from '../phase-runner.js';
import type { WorkflowEngineOptions } from '../types.js'; import type { WorkflowEngineOptions, PhaseName } from '../types.js';
export class OptionsBuilder { export class OptionsBuilder {
constructor( constructor(
@ -75,6 +75,8 @@ export class OptionsBuilder {
buildPhaseRunnerContext( buildPhaseRunnerContext(
state: WorkflowState, state: WorkflowState,
updateAgentSession: (agent: string, sessionId: string | undefined) => void, updateAgentSession: (agent: string, sessionId: string | undefined) => void,
onPhaseStart?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void,
onPhaseComplete?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void,
): PhaseRunnerContext { ): PhaseRunnerContext {
return { return {
cwd: this.getCwd(), cwd: this.getCwd(),
@ -84,6 +86,8 @@ export class OptionsBuilder {
getSessionId: (agent: string) => state.agentSessions.get(agent), getSessionId: (agent: string) => state.agentSessions.get(agent),
buildResumeOptions: this.buildResumeOptions.bind(this), buildResumeOptions: this.buildResumeOptions.bind(this),
updateAgentSession, updateAgentSession,
onPhaseStart,
onPhaseComplete,
}; };
} }
} }

View File

@ -18,7 +18,7 @@ import { incrementStepIteration } from './state-manager.js';
import { createLogger } from '../../../shared/utils/index.js'; import { createLogger } from '../../../shared/utils/index.js';
import type { OptionsBuilder } from './OptionsBuilder.js'; import type { OptionsBuilder } from './OptionsBuilder.js';
import type { StepExecutor } from './StepExecutor.js'; import type { StepExecutor } from './StepExecutor.js';
import type { WorkflowEngineOptions } from '../types.js'; import type { WorkflowEngineOptions, PhaseName } from '../types.js';
const log = createLogger('parallel-runner'); const log = createLogger('parallel-runner');
@ -35,6 +35,8 @@ export interface ParallelRunnerDeps {
conditions: Array<{ index: number; text: string }>, conditions: Array<{ index: number; text: string }>,
options: { cwd: string } options: { cwd: string }
) => Promise<number>; ) => Promise<number>;
readonly onPhaseStart?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
readonly onPhaseComplete?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
} }
export class ParallelRunner { export class ParallelRunner {
@ -69,7 +71,7 @@ export class ParallelRunner {
}) })
: undefined; : undefined;
const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession); const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession, this.deps.onPhaseStart, this.deps.onPhaseComplete);
const ruleCtx = { const ruleCtx = {
state, state,
cwd: this.deps.getCwd(), cwd: this.deps.getCwd(),
@ -93,8 +95,10 @@ export class ParallelRunner {
: baseOptions; : baseOptions;
const subSessionKey = subStep.agent ?? subStep.name; const subSessionKey = subStep.agent ?? subStep.name;
this.deps.onPhaseStart?.(subStep, 1, 'execute', subInstruction);
const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions); const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions);
updateAgentSession(subSessionKey, subResponse.sessionId); updateAgentSession(subSessionKey, subResponse.sessionId);
this.deps.onPhaseComplete?.(subStep, 1, 'execute', subResponse.content, subResponse.status, subResponse.error);
// Phase 2: report output for sub-step // Phase 2: report output for sub-step
if (subStep.report) { if (subStep.report) {

View File

@ -14,6 +14,7 @@ import type {
AgentResponse, AgentResponse,
Language, Language,
} from '../../models/types.js'; } from '../../models/types.js';
import type { PhaseName } from '../types.js';
import { runAgent } from '../../../agents/runner.js'; import { runAgent } from '../../../agents/runner.js';
import { InstructionBuilder, isReportObjectConfig } from '../instruction/InstructionBuilder.js'; import { InstructionBuilder, isReportObjectConfig } from '../instruction/InstructionBuilder.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js'; import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js';
@ -38,6 +39,8 @@ export interface StepExecutorDeps {
conditions: Array<{ index: number; text: string }>, conditions: Array<{ index: number; text: string }>,
options: { cwd: string } options: { cwd: string }
) => Promise<number>; ) => Promise<number>;
readonly onPhaseStart?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
readonly onPhaseComplete?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
} }
export class StepExecutor { export class StepExecutor {
@ -99,11 +102,13 @@ export class StepExecutor {
}); });
// Phase 1: main execution (Write excluded if step has report) // Phase 1: main execution (Write excluded if step has report)
this.deps.onPhaseStart?.(step, 1, 'execute', instruction);
const agentOptions = this.deps.optionsBuilder.buildAgentOptions(step); const agentOptions = this.deps.optionsBuilder.buildAgentOptions(step);
let response = await runAgent(step.agent, instruction, agentOptions); let response = await runAgent(step.agent, instruction, agentOptions);
updateAgentSession(sessionKey, response.sessionId); updateAgentSession(sessionKey, response.sessionId);
this.deps.onPhaseComplete?.(step, 1, 'execute', response.content, response.status, response.error);
const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession); const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession, this.deps.onPhaseStart, this.deps.onPhaseComplete);
// Phase 2: report output (resume same session, Write only) // Phase 2: report output (resume same session, Write only)
if (step.report) { if (step.report) {

View File

@ -104,6 +104,12 @@ export class WorkflowEngine extends EventEmitter {
getWorkflowSteps: () => this.config.steps.map(s => ({ name: s.name, description: s.description })), getWorkflowSteps: () => this.config.steps.map(s => ({ name: s.name, description: s.description })),
detectRuleIndex: this.detectRuleIndex, detectRuleIndex: this.detectRuleIndex,
callAiJudge: this.callAiJudge, callAiJudge: this.callAiJudge,
onPhaseStart: (step, phase, phaseName, instruction) => {
this.emit('phase:start', step, phase, phaseName, instruction);
},
onPhaseComplete: (step, phase, phaseName, content, phaseStatus, error) => {
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error);
},
}); });
this.parallelRunner = new ParallelRunner({ this.parallelRunner = new ParallelRunner({
@ -115,6 +121,12 @@ export class WorkflowEngine extends EventEmitter {
getInteractive: () => this.options.interactive === true, getInteractive: () => this.options.interactive === true,
detectRuleIndex: this.detectRuleIndex, detectRuleIndex: this.detectRuleIndex,
callAiJudge: this.callAiJudge, callAiJudge: this.callAiJudge,
onPhaseStart: (step, phase, phaseName, instruction) => {
this.emit('phase:start', step, phase, phaseName, instruction);
},
onPhaseComplete: (step, phase, phaseName, content, phaseStatus, error) => {
this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error);
},
}); });
log.debug('WorkflowEngine initialized', { log.debug('WorkflowEngine initialized', {

View File

@ -14,6 +14,7 @@ export { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js';
// Types // Types
export type { export type {
WorkflowEvents, WorkflowEvents,
PhaseName,
UserInputRequest, UserInputRequest,
IterationLimitRequest, IterationLimitRequest,
SessionUpdateCallback, SessionUpdateCallback,

View File

@ -8,6 +8,7 @@
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve, sep } from 'node:path'; import { dirname, resolve, sep } from 'node:path';
import type { WorkflowStep, Language } from '../models/types.js'; import type { WorkflowStep, Language } from '../models/types.js';
import type { PhaseName } from './types.js';
import { runAgent, type RunAgentOptions } from '../../agents/runner.js'; import { runAgent, type RunAgentOptions } from '../../agents/runner.js';
import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js'; import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js';
import { StatusJudgmentBuilder } from './instruction/StatusJudgmentBuilder.js'; import { StatusJudgmentBuilder } from './instruction/StatusJudgmentBuilder.js';
@ -32,6 +33,10 @@ export interface PhaseRunnerContext {
buildResumeOptions: (step: WorkflowStep, sessionId: string, overrides: Pick<RunAgentOptions, 'allowedTools' | 'maxTurns'>) => RunAgentOptions; buildResumeOptions: (step: WorkflowStep, sessionId: string, overrides: Pick<RunAgentOptions, 'allowedTools' | 'maxTurns'>) => RunAgentOptions;
/** Update agent session after a phase run */ /** Update agent session after a phase run */
updateAgentSession: (agent: string, sessionId: string | undefined) => void; updateAgentSession: (agent: string, sessionId: string | undefined) => void;
/** Callback for phase lifecycle logging */
onPhaseStart?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
/** Callback for phase completion logging */
onPhaseComplete?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
} }
/** /**
@ -145,16 +150,26 @@ export async function runReportPhase(
language: ctx.language, language: ctx.language,
}).build(); }).build();
ctx.onPhaseStart?.(step, 2, 'report', reportInstruction);
const reportOptions = ctx.buildResumeOptions(step, sessionId, { const reportOptions = ctx.buildResumeOptions(step, sessionId, {
allowedTools: [], allowedTools: [],
maxTurns: 3, maxTurns: 3,
}); });
const reportResponse = await runAgent(step.agent, reportInstruction, reportOptions); let reportResponse;
try {
reportResponse = await runAgent(step.agent, reportInstruction, reportOptions);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
ctx.onPhaseComplete?.(step, 2, 'report', '', 'error', errorMsg);
throw error;
}
// Check for errors in report phase // Check for errors in report phase
if (reportResponse.status !== 'done') { if (reportResponse.status !== 'done') {
const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error'; const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error';
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status, errorMsg);
throw new Error(`Report phase failed: ${errorMsg}`); throw new Error(`Report phase failed: ${errorMsg}`);
} }
@ -166,6 +181,7 @@ export async function runReportPhase(
// Update session (phase 2 may update it) // Update session (phase 2 may update it)
ctx.updateAgentSession(sessionKey, reportResponse.sessionId); ctx.updateAgentSession(sessionKey, reportResponse.sessionId);
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status);
log.debug('Report phase complete', { step: step.name, status: reportResponse.status }); log.debug('Report phase complete', { step: step.name, status: reportResponse.status });
} }
@ -191,22 +207,33 @@ export async function runStatusJudgmentPhase(
interactive: ctx.interactive, interactive: ctx.interactive,
}).build(); }).build();
ctx.onPhaseStart?.(step, 3, 'judge', judgmentInstruction);
const judgmentOptions = ctx.buildResumeOptions(step, sessionId, { const judgmentOptions = ctx.buildResumeOptions(step, sessionId, {
allowedTools: [], allowedTools: [],
maxTurns: 3, maxTurns: 3,
}); });
const judgmentResponse = await runAgent(step.agent, judgmentInstruction, judgmentOptions); let judgmentResponse;
try {
judgmentResponse = await runAgent(step.agent, judgmentInstruction, judgmentOptions);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
ctx.onPhaseComplete?.(step, 3, 'judge', '', 'error', errorMsg);
throw error;
}
// Check for errors in status judgment phase // Check for errors in status judgment phase
if (judgmentResponse.status !== 'done') { if (judgmentResponse.status !== 'done') {
const errorMsg = judgmentResponse.error || judgmentResponse.content || 'Unknown error'; const errorMsg = judgmentResponse.error || judgmentResponse.content || 'Unknown error';
ctx.onPhaseComplete?.(step, 3, 'judge', judgmentResponse.content, judgmentResponse.status, errorMsg);
throw new Error(`Status judgment phase failed: ${errorMsg}`); throw new Error(`Status judgment phase failed: ${errorMsg}`);
} }
// Update session (phase 3 may update it) // Update session (phase 3 may update it)
ctx.updateAgentSession(sessionKey, judgmentResponse.sessionId); ctx.updateAgentSession(sessionKey, judgmentResponse.sessionId);
ctx.onPhaseComplete?.(step, 3, 'judge', judgmentResponse.content, judgmentResponse.status);
log.debug('Status judgment phase complete', { step: step.name, status: judgmentResponse.status }); log.debug('Status judgment phase complete', { step: step.name, status: judgmentResponse.status });
return judgmentResponse.content; return judgmentResponse.content;
} }

View File

@ -104,6 +104,8 @@ export type AiJudgeCaller = (
options: { cwd: string } options: { cwd: string }
) => Promise<number>; ) => Promise<number>;
export type PhaseName = 'execute' | 'report' | 'judge';
/** Events emitted by workflow engine */ /** Events emitted by workflow engine */
export interface WorkflowEvents { export interface WorkflowEvents {
'step:start': (step: WorkflowStep, iteration: number, instruction: string) => void; 'step:start': (step: WorkflowStep, iteration: number, instruction: string) => void;
@ -111,6 +113,8 @@ export interface WorkflowEvents {
'step:report': (step: WorkflowStep, filePath: string, fileName: string) => void; 'step:report': (step: WorkflowStep, filePath: string, fileName: string) => void;
'step:blocked': (step: WorkflowStep, response: AgentResponse) => void; 'step:blocked': (step: WorkflowStep, response: AgentResponse) => void;
'step:user_input': (step: WorkflowStep, userInput: string) => void; 'step:user_input': (step: WorkflowStep, userInput: string) => void;
'phase:start': (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
'phase:complete': (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
'workflow:complete': (state: WorkflowState) => void; 'workflow:complete': (state: WorkflowState) => void;
'workflow:abort': (state: WorkflowState, reason: string) => void; 'workflow:abort': (state: WorkflowState, reason: string) => void;
'iteration:limit': (iteration: number, maxIterations: number) => void; 'iteration:limit': (iteration: number, maxIterations: number) => void;

View File

@ -153,6 +153,7 @@ export async function selectAndExecuteTask(
projectCwd: cwd, projectCwd: cwd,
agentOverrides, agentOverrides,
interactiveUserInput: options?.interactiveUserInput === true, interactiveUserInput: options?.interactiveUserInput === true,
interactiveMetadata: options?.interactiveMetadata,
}); });
if (taskSuccess && isWorktree) { if (taskSuccess && isWorktree) {

View File

@ -25,7 +25,7 @@ const log = createLogger('task');
* Execute a single task with workflow. * Execute a single task with workflow.
*/ */
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> { export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
const { task, cwd, workflowIdentifier, projectCwd, agentOverrides, interactiveUserInput } = options; const { task, cwd, workflowIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata } = options;
const workflowConfig = loadWorkflowByIdentifier(workflowIdentifier, projectCwd); const workflowConfig = loadWorkflowByIdentifier(workflowIdentifier, projectCwd);
if (!workflowConfig) { if (!workflowConfig) {
@ -51,6 +51,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
provider: agentOverrides?.provider, provider: agentOverrides?.provider,
model: agentOverrides?.model, model: agentOverrides?.model,
interactiveUserInput, interactiveUserInput,
interactiveMetadata,
}); });
return result.success; return result.success;
} }

View File

@ -11,6 +11,14 @@ export interface WorkflowExecutionResult {
reason?: string; reason?: string;
} }
/** Metadata from interactive mode, passed through to NDJSON logging */
export interface InteractiveMetadata {
/** Whether the user confirmed with /go */
confirmed: boolean;
/** The assembled task text (only meaningful when confirmed=true) */
task?: string;
}
/** Options for workflow execution */ /** Options for workflow execution */
export interface WorkflowExecutionOptions { export interface WorkflowExecutionOptions {
/** Header prefix for display */ /** Header prefix for display */
@ -23,6 +31,8 @@ export interface WorkflowExecutionOptions {
model?: string; model?: string;
/** Enable interactive user input during step transitions */ /** Enable interactive user input during step transitions */
interactiveUserInput?: boolean; interactiveUserInput?: boolean;
/** Interactive mode result metadata for NDJSON logging */
interactiveMetadata?: InteractiveMetadata;
} }
export interface TaskExecutionOptions { export interface TaskExecutionOptions {
@ -43,6 +53,8 @@ export interface ExecuteTaskOptions {
agentOverrides?: TaskExecutionOptions; agentOverrides?: TaskExecutionOptions;
/** Enable interactive user input during step transitions */ /** Enable interactive user input during step transitions */
interactiveUserInput?: boolean; interactiveUserInput?: boolean;
/** Interactive mode result metadata for NDJSON logging */
interactiveMetadata?: InteractiveMetadata;
} }
export interface PipelineExecutionOptions { export interface PipelineExecutionOptions {
@ -79,4 +91,6 @@ export interface SelectAndExecuteOptions {
createWorktree?: boolean | undefined; createWorktree?: boolean | undefined;
/** Enable interactive user input during step transitions */ /** Enable interactive user input during step transitions */
interactiveUserInput?: boolean; interactiveUserInput?: boolean;
/** Interactive mode result metadata for NDJSON logging */
interactiveMetadata?: InteractiveMetadata;
} }

View File

@ -39,6 +39,10 @@ import {
type NdjsonStepComplete, type NdjsonStepComplete,
type NdjsonWorkflowComplete, type NdjsonWorkflowComplete,
type NdjsonWorkflowAbort, type NdjsonWorkflowAbort,
type NdjsonPhaseStart,
type NdjsonPhaseComplete,
type NdjsonInteractiveStart,
type NdjsonInteractiveEnd,
} from '../../../infra/fs/index.js'; } from '../../../infra/fs/index.js';
import { createLogger, notifySuccess, notifyError } from '../../../shared/utils/index.js'; import { createLogger, notifySuccess, notifyError } from '../../../shared/utils/index.js';
import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js';
@ -94,6 +98,23 @@ export async function executeWorkflow(
const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd); const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd);
updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true }); updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true });
// Write interactive mode records if interactive mode was used before this workflow
if (options.interactiveMetadata) {
const startRecord: NdjsonInteractiveStart = {
type: 'interactive_start',
timestamp: new Date().toISOString(),
};
appendNdjsonLine(ndjsonLogPath, startRecord);
const endRecord: NdjsonInteractiveEnd = {
type: 'interactive_end',
confirmed: options.interactiveMetadata.confirmed,
...(options.interactiveMetadata.task ? { task: options.interactiveMetadata.task } : {}),
timestamp: new Date().toISOString(),
};
appendNdjsonLine(ndjsonLogPath, endRecord);
}
// Track current display for streaming // Track current display for streaming
const displayRef: { current: StreamDisplay | null } = { current: null }; const displayRef: { current: StreamDisplay | null } = { current: null };
@ -199,6 +220,34 @@ export async function executeWorkflow(
let abortReason: string | undefined; let abortReason: string | undefined;
engine.on('phase:start', (step, phase, phaseName, instruction) => {
log.debug('Phase starting', { step: step.name, phase, phaseName });
const record: NdjsonPhaseStart = {
type: 'phase_start',
step: step.name,
phase,
phaseName,
timestamp: new Date().toISOString(),
...(instruction ? { instruction } : {}),
};
appendNdjsonLine(ndjsonLogPath, record);
});
engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError) => {
log.debug('Phase completed', { step: step.name, phase, phaseName, status: phaseStatus });
const record: NdjsonPhaseComplete = {
type: 'phase_complete',
step: step.name,
phase,
phaseName,
status: phaseStatus,
...(content ? { content } : {}),
timestamp: new Date().toISOString(),
...(phaseError ? { error: phaseError } : {}),
};
appendNdjsonLine(ndjsonLogPath, record);
});
engine.on('step:start', (step, iteration, instruction) => { engine.on('step:start', (step, iteration, instruction) => {
log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration }); log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration });
info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`); info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`);

View File

@ -9,6 +9,10 @@ export type {
NdjsonStepComplete, NdjsonStepComplete,
NdjsonWorkflowComplete, NdjsonWorkflowComplete,
NdjsonWorkflowAbort, NdjsonWorkflowAbort,
NdjsonPhaseStart,
NdjsonPhaseComplete,
NdjsonInteractiveStart,
NdjsonInteractiveEnd,
NdjsonRecord, NdjsonRecord,
LatestLogPointer, LatestLogPointer,
} from './session.js'; } from './session.js';

View File

@ -21,6 +21,10 @@ export type {
NdjsonStepComplete, NdjsonStepComplete,
NdjsonWorkflowComplete, NdjsonWorkflowComplete,
NdjsonWorkflowAbort, NdjsonWorkflowAbort,
NdjsonPhaseStart,
NdjsonPhaseComplete,
NdjsonInteractiveStart,
NdjsonInteractiveEnd,
NdjsonRecord, NdjsonRecord,
LatestLogPointer, LatestLogPointer,
} from '../../shared/utils/index.js'; } from '../../shared/utils/index.js';

View File

@ -73,12 +73,48 @@ export interface NdjsonWorkflowAbort {
endTime: string; endTime: string;
} }
export interface NdjsonPhaseStart {
type: 'phase_start';
step: string;
phase: 1 | 2 | 3;
phaseName: 'execute' | 'report' | 'judge';
timestamp: string;
instruction?: string;
}
export interface NdjsonPhaseComplete {
type: 'phase_complete';
step: string;
phase: 1 | 2 | 3;
phaseName: 'execute' | 'report' | 'judge';
status: string;
content?: string;
timestamp: string;
error?: string;
}
export interface NdjsonInteractiveStart {
type: 'interactive_start';
timestamp: string;
}
export interface NdjsonInteractiveEnd {
type: 'interactive_end';
confirmed: boolean;
task?: string;
timestamp: string;
}
export type NdjsonRecord = export type NdjsonRecord =
| NdjsonWorkflowStart | NdjsonWorkflowStart
| NdjsonStepStart | NdjsonStepStart
| NdjsonStepComplete | NdjsonStepComplete
| NdjsonWorkflowComplete | NdjsonWorkflowComplete
| NdjsonWorkflowAbort; | NdjsonWorkflowAbort
| NdjsonPhaseStart
| NdjsonPhaseComplete
| NdjsonInteractiveStart
| NdjsonInteractiveEnd;
// --- Conversation log types --- // --- Conversation log types ---