Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/providers/claude-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export class ClaudeProvider extends BaseProvider {
model,
cwd,
systemPrompt,
maxTurns = 20,
maxTurns = 100,
allowedTools,
abortController,
conversationHistory,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/services/agent-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ export class AgentExecutor {
userFeedback
);
const taskStream = provider.executeQuery(
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 50, 50))
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 100, 100))
);
let taskOutput = '',
taskStartDetected = false,
Expand Down
93 changes: 92 additions & 1 deletion apps/server/src/services/execution-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,84 @@ ${feature.spec}
}
);

// Check for incomplete tasks after agent execution.
// The agent may have finished early (hit max turns, decided it was done, etc.)
// while tasks are still pending. If so, re-run the agent to complete remaining tasks.
const MAX_TASK_RETRY_ATTEMPTS = 3;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The value 3 is used here as a magic number for the maximum number of retry attempts. It would be better to define this as a constant at the top of the file (e.g., const DEFAULT_MAX_TASK_RETRY_ATTEMPTS = 3;). This improves readability and makes the value easier to find and change if needed.

let taskRetryAttempts = 0;
while (!abortController.signal.aborted && taskRetryAttempts < MAX_TASK_RETRY_ATTEMPTS) {
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
if (!currentFeature?.planSpec?.tasks) break;

const pendingTasks = currentFeature.planSpec.tasks.filter(
(t) => t.status === 'pending' || t.status === 'in_progress'
);
if (pendingTasks.length === 0) break;

taskRetryAttempts++;
const totalTasks = currentFeature.planSpec.tasks.length;
const completedTasks = currentFeature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
logger.info(
`[executeFeature] Feature ${featureId} has ${pendingTasks.length} incomplete tasks (${completedTasks}/${totalTasks} completed). Re-running agent (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})`
);

this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName: feature.branchName ?? null,
content: `Agent finished with ${pendingTasks.length} tasks remaining. Re-running to complete tasks (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})...`,
projectPath,
});

// Build a continuation prompt that tells the agent to finish remaining tasks
const remainingTasksList = pendingTasks
.map((t) => `- ${t.id}: ${t.description} (${t.status})`)
.join('\n');

const continuationPrompt = `## Continue Implementation - Incomplete Tasks

The previous agent session ended before all tasks were completed. Please continue implementing the remaining tasks.

**Completed:** ${completedTasks}/${totalTasks} tasks
**Remaining tasks:**
${remainingTasksList}

Please continue from where you left off and complete all remaining tasks. Use the same [TASK_START:ID] and [TASK_COMPLETE:ID] markers for each task.`;

await this.runAgentFn(
workDir,
featureId,
continuationPrompt,
abortController,
projectPath,
undefined,
model,
{
projectPath,
planningMode: 'skip',
requirePlanApproval: false,
systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
branchName: feature.branchName ?? null,
}
);
}

// Log if tasks are still incomplete after retry attempts
if (taskRetryAttempts >= MAX_TASK_RETRY_ATTEMPTS) {
const finalFeature = await this.loadFeatureFn(projectPath, featureId);
const stillPending = finalFeature?.planSpec?.tasks?.filter(
(t) => t.status === 'pending' || t.status === 'in_progress'
);
if (stillPending && stillPending.length > 0) {
logger.warn(
`[executeFeature] Feature ${featureId} still has ${stillPending.length} incomplete tasks after ${MAX_TASK_RETRY_ATTEMPTS} retry attempts. Moving to final status.`
);
}
}

const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
const sortedSteps = [...(pipelineConfig?.steps || [])]
Expand Down Expand Up @@ -300,6 +378,13 @@ ${feature.spec}
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
this.recordSuccessFn();

// Check final task completion state for accurate reporting
const completedFeature = await this.loadFeatureFn(projectPath, featureId);
const totalTasks = completedFeature?.planSpec?.tasks?.length ?? 0;
const completedTasks =
completedFeature?.planSpec?.tasks?.filter((t) => t.status === 'completed').length ?? 0;
const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks;

try {
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
let agentOutput = '';
Expand All @@ -326,12 +411,18 @@ ${feature.spec}
/* learnings recording failed */
}

const elapsedSeconds = Math.round((Date.now() - tempRunningFeature.startTime) / 1000);
let completionMessage = `Feature completed in ${elapsedSeconds}s`;
if (finalStatus === 'verified') completionMessage += ' - auto-verified';
if (hasIncompleteTasks)
completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`;

this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: `Feature completed in ${Math.round((Date.now() - tempRunningFeature.startTime) / 1000)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
message: completionMessage,
projectPath,
model: tempRunningFeature.model,
provider: tempRunningFeature.provider,
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/services/feature-state-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,25 @@ export class FeatureStateManager {
// This prevents cards in "waiting for review" from appearing to still have running tasks
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
// Update tasksCompleted count to reflect actual completed tasks
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
Expand All @@ -136,11 +144,26 @@ export class FeatureStateManager {
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
// Do NOT mark pending tasks as completed - they were never started
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
Comment on lines +147 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block of code is nearly identical to the one for the waiting_approval status (lines 117-136). To improve maintainability and follow the DRY (Don't Repeat Yourself) principle, consider refactoring this logic into a private helper function. This function could take the feature and the status string as parameters to handle task finalization and logging.

feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
Expand Down
4 changes: 2 additions & 2 deletions apps/server/tests/unit/providers/claude-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ describe('claude-provider.ts', () => {
expect(typeof callArgs.prompt).not.toBe('string');
});

it('should use maxTurns default of 20', async () => {
it('should use maxTurns default of 100', async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
Expand All @@ -205,7 +205,7 @@ describe('claude-provider.ts', () => {
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.objectContaining({
maxTurns: 20,
maxTurns: 100,
}),
});
});
Expand Down
Loading