mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +00:00
feat(04-01): create PipelineOrchestrator with step execution and auto-merge
- Extract pipeline orchestration logic from AutoModeService - executePipeline: Sequential step execution with context continuity - buildPipelineStepPrompt: Builds prompts with feature context and previous output - detectPipelineStatus: Identifies pipeline status for resumption - resumePipeline/resumeFromStep: Handle excluded steps and missing context - executeTestStep: 5-attempt agent fix loop (REQ-F07) - attemptMerge: Auto-merge with conflict detection (REQ-F05) - buildTestFailureSummary: Concise test failure summary for agent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
662
apps/server/src/services/pipeline-orchestrator.ts
Normal file
662
apps/server/src/services/pipeline-orchestrator.ts
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
/**
|
||||||
|
* PipelineOrchestrator - Pipeline step execution and coordination
|
||||||
|
*
|
||||||
|
* Coordinates existing services (AgentExecutor, TestRunnerService, merge endpoint)
|
||||||
|
* for pipeline step execution, test runner integration (5-attempt fix loop),
|
||||||
|
* and automatic merging on completion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import type {
|
||||||
|
Feature,
|
||||||
|
PipelineStep,
|
||||||
|
PipelineConfig,
|
||||||
|
FeatureStatusWithPipeline,
|
||||||
|
} from '@automaker/types';
|
||||||
|
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
|
||||||
|
import { getFeatureDir } from '@automaker/platform';
|
||||||
|
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||||
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
|
import {
|
||||||
|
getPromptCustomization,
|
||||||
|
getAutoLoadClaudeMdSetting,
|
||||||
|
filterClaudeMdFromContext,
|
||||||
|
} from '../lib/settings-helpers.js';
|
||||||
|
import { validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
|
import type { TypedEventBus } from './typed-event-bus.js';
|
||||||
|
import type { FeatureStateManager } from './feature-state-manager.js';
|
||||||
|
import type { AgentExecutor } from './agent-executor.js';
|
||||||
|
import type { WorktreeResolver } from './worktree-resolver.js';
|
||||||
|
import type { SettingsService } from './settings-service.js';
|
||||||
|
import type { ConcurrencyManager } from './concurrency-manager.js';
|
||||||
|
import { pipelineService } from './pipeline-service.js';
|
||||||
|
import type { TestRunnerService, TestRunStatus } from './test-runner-service.js';
|
||||||
|
|
||||||
|
const logger = createLogger('PipelineOrchestrator');
|
||||||
|
|
||||||
|
/** Context object shared across pipeline execution */
|
||||||
|
export interface PipelineContext {
|
||||||
|
projectPath: string;
|
||||||
|
featureId: string;
|
||||||
|
feature: Feature;
|
||||||
|
steps: PipelineStep[];
|
||||||
|
workDir: string;
|
||||||
|
worktreePath: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
abortController: AbortController;
|
||||||
|
autoLoadClaudeMd: boolean;
|
||||||
|
testAttempts: number;
|
||||||
|
maxTestAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Information about pipeline status for resume operations */
|
||||||
|
export interface PipelineStatusInfo {
|
||||||
|
isPipeline: boolean;
|
||||||
|
stepId: string | null;
|
||||||
|
stepIndex: number;
|
||||||
|
totalSteps: number;
|
||||||
|
step: PipelineStep | null;
|
||||||
|
config: PipelineConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result types */
|
||||||
|
export interface StepResult {
|
||||||
|
success: boolean;
|
||||||
|
testsPassed?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
export interface MergeResult {
|
||||||
|
success: boolean;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
needsAgentResolution?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callback types for AutoModeService integration */
|
||||||
|
export type UpdateFeatureStatusFn = (
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
status: string
|
||||||
|
) => Promise<void>;
|
||||||
|
export type BuildFeaturePromptFn = (
|
||||||
|
feature: Feature,
|
||||||
|
prompts: { implementationInstructions: string; playwrightVerificationInstructions: string }
|
||||||
|
) => string;
|
||||||
|
export type ExecuteFeatureFn = (
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
useWorktrees: boolean,
|
||||||
|
useScreenshots: boolean,
|
||||||
|
model?: string,
|
||||||
|
options?: { _calledInternally?: boolean }
|
||||||
|
) => Promise<void>;
|
||||||
|
export type RunAgentFn = (
|
||||||
|
workDir: string,
|
||||||
|
featureId: string,
|
||||||
|
prompt: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
projectPath: string,
|
||||||
|
imagePaths?: string[],
|
||||||
|
model?: string,
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PipelineOrchestrator - Coordinates pipeline step execution
|
||||||
|
*/
|
||||||
|
export class PipelineOrchestrator {
|
||||||
|
private serverPort: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private eventBus: TypedEventBus,
|
||||||
|
private featureStateManager: FeatureStateManager,
|
||||||
|
private agentExecutor: AgentExecutor,
|
||||||
|
private testRunnerService: TestRunnerService,
|
||||||
|
private worktreeResolver: WorktreeResolver,
|
||||||
|
private concurrencyManager: ConcurrencyManager,
|
||||||
|
private settingsService: SettingsService | null,
|
||||||
|
private updateFeatureStatusFn: UpdateFeatureStatusFn,
|
||||||
|
private loadContextFilesFn: typeof loadContextFiles,
|
||||||
|
private buildFeaturePromptFn: BuildFeaturePromptFn,
|
||||||
|
private executeFeatureFn: ExecuteFeatureFn,
|
||||||
|
private runAgentFn: RunAgentFn,
|
||||||
|
serverPort = 3008
|
||||||
|
) {
|
||||||
|
this.serverPort = serverPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute pipeline steps sequentially */
|
||||||
|
async executePipeline(context: PipelineContext): Promise<void> {
|
||||||
|
const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } =
|
||||||
|
context;
|
||||||
|
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
||||||
|
|
||||||
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||||
|
const contextResult = await this.loadContextFilesFn({
|
||||||
|
projectPath,
|
||||||
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||||
|
taskContext: { title: feature.title ?? '', description: feature.description ?? '' },
|
||||||
|
});
|
||||||
|
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||||
|
|
||||||
|
const featureDir = getFeatureDir(projectPath, featureId);
|
||||||
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||||
|
let previousContext = '';
|
||||||
|
try {
|
||||||
|
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||||
|
} catch {
|
||||||
|
/* No context */
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const step = steps[i];
|
||||||
|
if (abortController.signal.aborted) throw new Error('Pipeline execution aborted');
|
||||||
|
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, `pipeline_${step.id}`);
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
||||||
|
featureId,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
this.eventBus.emitAutoModeEvent('pipeline_step_started', {
|
||||||
|
featureId,
|
||||||
|
stepId: step.id,
|
||||||
|
stepName: step.name,
|
||||||
|
stepIndex: i,
|
||||||
|
totalSteps: steps.length,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = this.buildPipelineStepPrompt(
|
||||||
|
step,
|
||||||
|
feature,
|
||||||
|
previousContext,
|
||||||
|
prompts.taskExecution
|
||||||
|
);
|
||||||
|
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||||
|
|
||||||
|
await this.runAgentFn(
|
||||||
|
workDir,
|
||||||
|
featureId,
|
||||||
|
prompt,
|
||||||
|
abortController,
|
||||||
|
projectPath,
|
||||||
|
undefined,
|
||||||
|
model,
|
||||||
|
{
|
||||||
|
projectPath,
|
||||||
|
planningMode: 'skip',
|
||||||
|
requirePlanApproval: false,
|
||||||
|
previousContent: previousContext,
|
||||||
|
systemPrompt: contextFilesPrompt || undefined,
|
||||||
|
autoLoadClaudeMd,
|
||||||
|
thinkingLevel: feature.thinkingLevel,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||||
|
} catch {
|
||||||
|
/* No update */
|
||||||
|
}
|
||||||
|
this.eventBus.emitAutoModeEvent('pipeline_step_complete', {
|
||||||
|
featureId,
|
||||||
|
stepId: step.id,
|
||||||
|
stepName: step.name,
|
||||||
|
stepIndex: i,
|
||||||
|
totalSteps: steps.length,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
`Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`All pipeline steps completed for feature ${featureId}`);
|
||||||
|
if (context.branchName) {
|
||||||
|
const mergeResult = await this.attemptMerge(context);
|
||||||
|
if (!mergeResult.success && mergeResult.hasConflicts) {
|
||||||
|
logger.info(`Feature ${featureId} has merge conflicts`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the prompt for a pipeline step */
|
||||||
|
buildPipelineStepPrompt(
|
||||||
|
step: PipelineStep,
|
||||||
|
feature: Feature,
|
||||||
|
previousContext: string,
|
||||||
|
taskPrompts: { implementationInstructions: string; playwrightVerificationInstructions: string }
|
||||||
|
): string {
|
||||||
|
let prompt = `## Pipeline Step: ${step.name}\n\nThis is an automated pipeline step.\n\n### Feature Context\n${this.buildFeaturePromptFn(feature, taskPrompts)}\n\n`;
|
||||||
|
if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`;
|
||||||
|
prompt += `### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.`;
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect if a feature is stuck in a pipeline step */
|
||||||
|
async detectPipelineStatus(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
currentStatus: FeatureStatusWithPipeline
|
||||||
|
): Promise<PipelineStatusInfo> {
|
||||||
|
const isPipeline = pipelineService.isPipelineStatus(currentStatus);
|
||||||
|
if (!isPipeline)
|
||||||
|
return {
|
||||||
|
isPipeline: false,
|
||||||
|
stepId: null,
|
||||||
|
stepIndex: -1,
|
||||||
|
totalSteps: 0,
|
||||||
|
step: null,
|
||||||
|
config: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepId = pipelineService.getStepIdFromStatus(currentStatus);
|
||||||
|
if (!stepId) {
|
||||||
|
logger.warn(`Feature ${featureId} has invalid pipeline status: ${currentStatus}`);
|
||||||
|
return {
|
||||||
|
isPipeline: true,
|
||||||
|
stepId: null,
|
||||||
|
stepIndex: -1,
|
||||||
|
totalSteps: 0,
|
||||||
|
step: null,
|
||||||
|
config: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await pipelineService.getPipelineConfig(projectPath);
|
||||||
|
if (!config || config.steps.length === 0) {
|
||||||
|
logger.warn(`Feature ${featureId} has pipeline status but no config exists`);
|
||||||
|
return { isPipeline: true, stepId, stepIndex: -1, totalSteps: 0, step: null, config: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order);
|
||||||
|
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
|
||||||
|
const step = stepIndex === -1 ? null : sortedSteps[stepIndex];
|
||||||
|
|
||||||
|
if (!step) logger.warn(`Feature ${featureId} stuck in step ${stepId} which no longer exists`);
|
||||||
|
else
|
||||||
|
logger.info(
|
||||||
|
`Detected pipeline status: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isPipeline: true, stepId, stepIndex, totalSteps: sortedSteps.length, step, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume pipeline execution from detected status */
|
||||||
|
async resumePipeline(
|
||||||
|
projectPath: string,
|
||||||
|
feature: Feature,
|
||||||
|
useWorktrees: boolean,
|
||||||
|
pipelineInfo: PipelineStatusInfo
|
||||||
|
): Promise<void> {
|
||||||
|
const featureId = feature.id;
|
||||||
|
logger.info(`Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}`);
|
||||||
|
|
||||||
|
const featureDir = getFeatureDir(projectPath, featureId);
|
||||||
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||||
|
let hasContext = false;
|
||||||
|
try {
|
||||||
|
await secureFs.access(contextPath);
|
||||||
|
hasContext = true;
|
||||||
|
} catch {
|
||||||
|
/* No context */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasContext) {
|
||||||
|
logger.warn(`No context for feature ${featureId}, restarting pipeline`);
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
||||||
|
return this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, {
|
||||||
|
_calledInternally: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pipelineInfo.stepIndex === -1) {
|
||||||
|
logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`);
|
||||||
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline step no longer exists',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pipelineInfo.config) throw new Error('Pipeline config is null but stepIndex is valid');
|
||||||
|
return this.resumeFromStep(
|
||||||
|
projectPath,
|
||||||
|
feature,
|
||||||
|
useWorktrees,
|
||||||
|
pipelineInfo.stepIndex,
|
||||||
|
pipelineInfo.config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume from a specific step index */
|
||||||
|
async resumeFromStep(
|
||||||
|
projectPath: string,
|
||||||
|
feature: Feature,
|
||||||
|
useWorktrees: boolean,
|
||||||
|
startFromStepIndex: number,
|
||||||
|
pipelineConfig: PipelineConfig
|
||||||
|
): Promise<void> {
|
||||||
|
const featureId = feature.id;
|
||||||
|
const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
||||||
|
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length)
|
||||||
|
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
||||||
|
|
||||||
|
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||||
|
let currentStep = allSortedSteps[startFromStepIndex];
|
||||||
|
|
||||||
|
if (excludedStepIds.has(currentStep.id)) {
|
||||||
|
const nextStatus = pipelineService.getNextStatus(
|
||||||
|
`pipeline_${currentStep.id}`,
|
||||||
|
pipelineConfig,
|
||||||
|
feature.skipTests ?? false,
|
||||||
|
feature.excludedPipelineSteps
|
||||||
|
);
|
||||||
|
if (!pipelineService.isPipelineStatus(nextStatus)) {
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, nextStatus);
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline completed (remaining steps excluded)',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
|
||||||
|
const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId);
|
||||||
|
if (nextStepIndex === -1) throw new Error(`Next step ${nextStepId} not found`);
|
||||||
|
startFromStepIndex = nextStepIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsToExecute = allSortedSteps
|
||||||
|
.slice(startFromStepIndex)
|
||||||
|
.filter((step) => !excludedStepIds.has(step.id));
|
||||||
|
if (stepsToExecute.length === 0) {
|
||||||
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline completed (all steps excluded)',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningEntry = this.concurrencyManager.acquire({
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
isAutoMode: false,
|
||||||
|
allowReuse: true,
|
||||||
|
});
|
||||||
|
const abortController = runningEntry.abortController;
|
||||||
|
runningEntry.branchName = feature.branchName ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateWorkingDirectory(projectPath);
|
||||||
|
let worktreePath: string | null = null;
|
||||||
|
const branchName = feature.branchName;
|
||||||
|
|
||||||
|
if (useWorktrees && branchName) {
|
||||||
|
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
|
||||||
|
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
||||||
|
validateWorkingDirectory(workDir);
|
||||||
|
runningEntry.worktreePath = worktreePath;
|
||||||
|
runningEntry.branchName = branchName ?? null;
|
||||||
|
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
branchName: branchName ?? null,
|
||||||
|
feature: {
|
||||||
|
id: featureId,
|
||||||
|
title: feature.title || 'Resuming Pipeline',
|
||||||
|
description: feature.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||||
|
projectPath,
|
||||||
|
this.settingsService,
|
||||||
|
'[AutoMode]'
|
||||||
|
);
|
||||||
|
const context: PipelineContext = {
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
feature,
|
||||||
|
steps: stepsToExecute,
|
||||||
|
workDir,
|
||||||
|
worktreePath,
|
||||||
|
branchName: branchName ?? null,
|
||||||
|
abortController,
|
||||||
|
autoLoadClaudeMd,
|
||||||
|
testAttempts: 0,
|
||||||
|
maxTestAttempts: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.executePipeline(context);
|
||||||
|
|
||||||
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||||
|
logger.info(`Pipeline resume completed for feature ${featureId}`);
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline resumed successfully',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorInfo = classifyError(error);
|
||||||
|
if (errorInfo.isAbort) {
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: false,
|
||||||
|
message: 'Pipeline stopped by user',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error(`Pipeline resume failed for ${featureId}:`, error);
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
error: errorInfo.message,
|
||||||
|
errorType: errorInfo.type,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.concurrencyManager.release(featureId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute test step with agent fix loop (REQ-F07) */
|
||||||
|
async executeTestStep(context: PipelineContext, testCommand: string): Promise<StepResult> {
|
||||||
|
const { featureId, projectPath, workDir, abortController, maxTestAttempts } = context;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxTestAttempts; attempt++) {
|
||||||
|
if (abortController.signal.aborted)
|
||||||
|
return { success: false, message: 'Test execution aborted' };
|
||||||
|
logger.info(`Running tests for ${featureId} (attempt ${attempt}/${maxTestAttempts})`);
|
||||||
|
|
||||||
|
const testResult = await this.testRunnerService.startTests(workDir, { command: testCommand });
|
||||||
|
if (!testResult.success || !testResult.result?.sessionId)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
testsPassed: false,
|
||||||
|
message: testResult.error || 'Failed to start tests',
|
||||||
|
};
|
||||||
|
|
||||||
|
const completionResult = await this.waitForTestCompletion(testResult.result.sessionId);
|
||||||
|
if (completionResult.status === 'passed') return { success: true, testsPassed: true };
|
||||||
|
|
||||||
|
const sessionOutput = this.testRunnerService.getSessionOutput(testResult.result.sessionId);
|
||||||
|
const scrollback = sessionOutput.result?.output || '';
|
||||||
|
this.eventBus.emitAutoModeEvent('pipeline_test_failed', {
|
||||||
|
featureId,
|
||||||
|
attempt,
|
||||||
|
maxAttempts: maxTestAttempts,
|
||||||
|
failedTests: this.extractFailedTestNames(scrollback),
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attempt < maxTestAttempts) {
|
||||||
|
const fixPrompt = `## Test Failures - Please Fix\n\n${this.buildTestFailureSummary(scrollback)}\n\nFix the failing tests without modifying test code unless clearly wrong.`;
|
||||||
|
await this.runAgentFn(
|
||||||
|
workDir,
|
||||||
|
featureId,
|
||||||
|
fixPrompt,
|
||||||
|
abortController,
|
||||||
|
projectPath,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ projectPath, planningMode: 'skip', requirePlanApproval: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
testsPassed: false,
|
||||||
|
message: `Tests failed after ${maxTestAttempts} attempts`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for test completion */
|
||||||
|
private async waitForTestCompletion(
|
||||||
|
sessionId: string
|
||||||
|
): Promise<{ status: TestRunStatus; exitCode: number | null; duration: number }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
const session = this.testRunnerService.getSession(sessionId);
|
||||||
|
if (session && session.status !== 'running' && session.status !== 'pending') {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve({
|
||||||
|
status: session.status,
|
||||||
|
exitCode: session.exitCode,
|
||||||
|
duration: session.finishedAt
|
||||||
|
? session.finishedAt.getTime() - session.startedAt.getTime()
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve({ status: 'failed', exitCode: null, duration: 600000 });
|
||||||
|
}, 600000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attempt to merge feature branch (REQ-F05) */
|
||||||
|
async attemptMerge(context: PipelineContext): Promise<MergeResult> {
|
||||||
|
const { projectPath, featureId, branchName, worktreePath, feature } = context;
|
||||||
|
if (!branchName) return { success: false, error: 'No branch name for merge' };
|
||||||
|
|
||||||
|
logger.info(`Attempting auto-merge for feature ${featureId} (branch: ${branchName})`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:${this.serverPort}/api/worktree/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectPath,
|
||||||
|
branchName,
|
||||||
|
worktreePath,
|
||||||
|
targetBranch: 'main',
|
||||||
|
options: { deleteWorktreeAndBranch: false },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (!response.ok) {
|
||||||
|
if (data.hasConflicts) {
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, 'merge_conflict');
|
||||||
|
this.eventBus.emitAutoModeEvent('pipeline_merge_conflict', {
|
||||||
|
featureId,
|
||||||
|
branchName,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return { success: false, hasConflicts: true, needsAgentResolution: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: data.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Auto-merge successful for feature ${featureId}`);
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline completed and merged',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Merge failed for ${featureId}:`, error);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a concise test failure summary for the agent */
|
||||||
|
buildTestFailureSummary(scrollback: string): string {
|
||||||
|
const lines = scrollback.split('\n');
|
||||||
|
const failedTests: string[] = [];
|
||||||
|
let passCount = 0,
|
||||||
|
failCount = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) {
|
||||||
|
const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/);
|
||||||
|
if (match) failedTests.push(match[1].trim());
|
||||||
|
failCount++;
|
||||||
|
} else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) passCount++;
|
||||||
|
if (trimmed.match(/^>\s+.*\.(test|spec)\./)) failedTests.push(trimmed.replace(/^>\s+/, ''));
|
||||||
|
if (
|
||||||
|
trimmed.includes('AssertionError') ||
|
||||||
|
trimmed.includes('toBe') ||
|
||||||
|
trimmed.includes('toEqual')
|
||||||
|
)
|
||||||
|
failedTests.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = [...new Set(failedTests)].slice(0, 10);
|
||||||
|
return `Test Results: ${passCount} passed, ${failCount} failed.\n\nFailed tests:\n${unique.map((t) => `- ${t}`).join('\n')}\n\nOutput (last 2000 chars):\n${scrollback.slice(-2000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract failed test names from scrollback */
|
||||||
|
private extractFailedTestNames(scrollback: string): string[] {
|
||||||
|
const failedTests: string[] = [];
|
||||||
|
for (const line of scrollback.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) {
|
||||||
|
const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/);
|
||||||
|
if (match) failedTests.push(match[1].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(failedTests)].slice(0, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user