File size: 24,545 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
/**
 * ExecutionService - Feature execution lifecycle coordination
 */

import path from 'path';
import type { Feature } from '@automaker/types';
import { createLogger, classifyError, loadContextFiles, recordMemoryUsage } from '@automaker/utils';
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
import { getFeatureDir } from '@automaker/platform';
import { ProviderFactory } from '../providers/provider-factory.js';
import * as secureFs from '../lib/secure-fs.js';
import {
  getPromptCustomization,
  getAutoLoadClaudeMdSetting,
  getUseClaudeCodeSystemPromptSetting,
  filterClaudeMdFromContext,
} from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js';
import { extractSummary } from './spec-parser.js';
import type { TypedEventBus } from './typed-event-bus.js';
import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js';
import type { WorktreeResolver } from './worktree-resolver.js';
import type { SettingsService } from './settings-service.js';
import { pipelineService } from './pipeline-service.js';

// Re-export callback types from execution-types.ts for backward compatibility
export type {
  RunAgentFn,
  ExecutePipelineFn,
  UpdateFeatureStatusFn,
  LoadFeatureFn,
  GetPlanningPromptPrefixFn,
  SaveFeatureSummaryFn,
  RecordLearningsFn,
  ContextExistsFn,
  ResumeFeatureFn,
  TrackFailureFn,
  SignalPauseFn,
  RecordSuccessFn,
  SaveExecutionStateFn,
  LoadContextFilesFn,
} from './execution-types.js';

import type {
  RunAgentFn,
  ExecutePipelineFn,
  UpdateFeatureStatusFn,
  LoadFeatureFn,
  GetPlanningPromptPrefixFn,
  SaveFeatureSummaryFn,
  RecordLearningsFn,
  ContextExistsFn,
  ResumeFeatureFn,
  TrackFailureFn,
  SignalPauseFn,
  RecordSuccessFn,
  SaveExecutionStateFn,
  LoadContextFilesFn,
} from './execution-types.js';

const logger = createLogger('ExecutionService');

/** Marker written by agent-executor for each tool invocation. */
const TOOL_USE_MARKER = '🔧 Tool:';

/** Minimum trimmed output length to consider agent work meaningful. */
const MIN_MEANINGFUL_OUTPUT_LENGTH = 200;

export class ExecutionService {
  constructor(
    private eventBus: TypedEventBus,
    private concurrencyManager: ConcurrencyManager,
    private worktreeResolver: WorktreeResolver,
    private settingsService: SettingsService | null,
    // Callback dependencies for delegation
    private runAgentFn: RunAgentFn,
    private executePipelineFn: ExecutePipelineFn,
    private updateFeatureStatusFn: UpdateFeatureStatusFn,
    private loadFeatureFn: LoadFeatureFn,
    private getPlanningPromptPrefixFn: GetPlanningPromptPrefixFn,
    private saveFeatureSummaryFn: SaveFeatureSummaryFn,
    private recordLearningsFn: RecordLearningsFn,
    private contextExistsFn: ContextExistsFn,
    private resumeFeatureFn: ResumeFeatureFn,
    private trackFailureFn: TrackFailureFn,
    private signalPauseFn: SignalPauseFn,
    private recordSuccessFn: RecordSuccessFn,
    private saveExecutionStateFn: SaveExecutionStateFn,
    private loadContextFilesFn: LoadContextFilesFn
  ) {}

  private acquireRunningFeature(options: {
    featureId: string;
    projectPath: string;
    isAutoMode: boolean;
    allowReuse?: boolean;
  }): RunningFeature {
    return this.concurrencyManager.acquire(options);
  }

  private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
    this.concurrencyManager.release(featureId, options);
  }

  private extractTitleFromDescription(description: string | undefined): string {
    if (!description?.trim()) return 'Untitled Feature';
    const firstLine = description.split('\n')[0].trim();
    return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
  }

  /**
   * Build feature description section (without implementation instructions).
   * Used when planning mode is active — the planning prompt provides its own instructions.
   */
  buildFeatureDescription(feature: Feature): string {
    const title = this.extractTitleFromDescription(feature.description);

    let prompt = `## Feature Task

**Feature ID:** ${feature.id}
**Title:** ${title}
**Description:** ${feature.description}
`;

    if (feature.spec) {
      prompt += `
**Specification:**
${feature.spec}
`;
    }

    if (feature.imagePaths && feature.imagePaths.length > 0) {
      const imagesList = feature.imagePaths
        .map((img, idx) => {
          const imgPath = typeof img === 'string' ? img : img.path;
          const filename =
            typeof img === 'string'
              ? imgPath.split('/').pop()
              : img.filename || imgPath.split('/').pop();
          const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*';
          return `   ${idx + 1}. ${filename} (${mimeType})\n      Path: ${imgPath}`;
        })
        .join('\n');
      prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
    }

    return prompt;
  }

  buildFeaturePrompt(
    feature: Feature,
    taskExecutionPrompts: {
      implementationInstructions: string;
      playwrightVerificationInstructions: string;
    }
  ): string {
    let prompt = this.buildFeatureDescription(feature);

    prompt += feature.skipTests
      ? `\n${taskExecutionPrompts.implementationInstructions}`
      : `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
    return prompt;
  }

  async executeFeature(
    projectPath: string,
    featureId: string,
    useWorktrees = false,
    isAutoMode = false,
    providedWorktreePath?: string,
    options?: { continuationPrompt?: string; _calledInternally?: boolean }
  ): Promise<void> {
    const tempRunningFeature = this.acquireRunningFeature({
      featureId,
      projectPath,
      isAutoMode,
      allowReuse: options?._calledInternally,
    });
    const abortController = tempRunningFeature.abortController;
    if (isAutoMode) await this.saveExecutionStateFn(projectPath);
    let feature: Feature | null = null;
    let pipelineCompleted = false;

    try {
      validateWorkingDirectory(projectPath);
      feature = await this.loadFeatureFn(projectPath, featureId);
      if (!feature) throw new Error(`Feature ${featureId} not found`);

      // Update status to in_progress immediately after acquiring the feature.
      // This prevents a race condition where the UI reloads features and sees the
      // feature still in 'backlog' status while it's actually being executed.
      // Only do this for the initial call (not internal/recursive calls which would
      // redundantly update the status).
      if (
        !options?._calledInternally &&
        (feature.status === 'backlog' ||
          feature.status === 'ready' ||
          feature.status === 'interrupted')
      ) {
        await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
      }

      if (!options?.continuationPrompt) {
        if (feature.planSpec?.status === 'approved') {
          const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
          let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
          continuationPrompt = continuationPrompt
            .replace(/\{\{userFeedback\}\}/g, '')
            .replace(/\{\{approvedPlan\}\}/g, feature.planSpec.content || '');
          return await this.executeFeature(
            projectPath,
            featureId,
            useWorktrees,
            isAutoMode,
            providedWorktreePath,
            { continuationPrompt, _calledInternally: true }
          );
        }
        if (await this.contextExistsFn(projectPath, featureId)) {
          return await this.resumeFeatureFn(projectPath, featureId, useWorktrees, true);
        }
      }

      let worktreePath: string | null = providedWorktreePath ?? null;
      const branchName = feature.branchName;
      if (!worktreePath && useWorktrees && branchName) {
        worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
        if (!worktreePath) {
          throw new Error(
            `Worktree enabled but no worktree found for feature branch "${branchName}".`
          );
        }
        logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
      }
      const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
      validateWorkingDirectory(workDir);
      tempRunningFeature.worktreePath = worktreePath;
      tempRunningFeature.branchName = branchName ?? null;
      // Ensure status is in_progress (may already be set from the early update above,
      // but internal/recursive calls skip the early update and need it here).
      // Mirror the external guard: only transition when the feature is still in
      // backlog, ready, or interrupted to avoid overwriting a concurrent terminal status.
      if (
        options?._calledInternally &&
        (feature.status === 'backlog' ||
          feature.status === 'ready' ||
          feature.status === 'interrupted')
      ) {
        await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
      }
      this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
        featureId,
        projectPath,
        branchName: feature.branchName ?? null,
        feature: {
          id: featureId,
          title: feature.title || 'Loading...',
          description: feature.description || 'Feature is starting',
        },
      });

      const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
        projectPath,
        this.settingsService,
        '[ExecutionService]'
      );
      const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
        projectPath,
        this.settingsService,
        '[ExecutionService]'
      );
      const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
      let prompt: string;
      const contextResult = await this.loadContextFilesFn({
        projectPath,
        fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
        taskContext: {
          title: feature.title ?? '',
          description: feature.description ?? '',
        },
      });
      const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);

      if (options?.continuationPrompt) {
        prompt = options.continuationPrompt;
      } else {
        const planningPrefix = await this.getPlanningPromptPrefixFn(feature);
        if (planningPrefix) {
          // Planning mode active: use planning instructions + feature description only.
          // Do NOT include implementationInstructions — they conflict with the planning
          // prompt's "DO NOT proceed with implementation until approval" directive.
          prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature);
        } else {
          prompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
        }
        if (feature.planningMode && feature.planningMode !== 'skip') {
          this.eventBus.emitAutoModeEvent('planning_started', {
            featureId: feature.id,
            mode: feature.planningMode,
            message: `Starting ${feature.planningMode} planning phase`,
          });
        }
      }

      const imagePaths = feature.imagePaths?.map((img) =>
        typeof img === 'string' ? img : img.path
      );
      const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
      tempRunningFeature.model = model;
      tempRunningFeature.provider = ProviderFactory.getProviderNameForModel(model);

      await this.runAgentFn(
        workDir,
        featureId,
        prompt,
        abortController,
        projectPath,
        imagePaths,
        model,
        {
          projectPath,
          planningMode: feature.planningMode,
          requirePlanApproval: feature.requirePlanApproval,
          systemPrompt: combinedSystemPrompt || undefined,
          autoLoadClaudeMd,
          useClaudeCodeSystemPrompt,
          thinkingLevel: feature.thinkingLevel,
          reasoningEffort: feature.reasoningEffort,
          providerId: feature.providerId,
          branchName: feature.branchName ?? null,
        }
      );

      // 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;
      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,
            useClaudeCodeSystemPrompt,
            thinkingLevel: feature.thinkingLevel,
            reasoningEffort: feature.reasoningEffort,
            providerId: feature.providerId,
            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 || [])]
        .sort((a, b) => a.order - b.order)
        .filter((step) => !excludedStepIds.has(step.id));
      if (sortedSteps.length > 0) {
        await this.executePipelineFn({
          projectPath,
          featureId,
          feature,
          steps: sortedSteps,
          workDir,
          worktreePath,
          branchName: feature.branchName ?? null,
          abortController,
          autoLoadClaudeMd,
          useClaudeCodeSystemPrompt,
          testAttempts: 0,
          maxTestAttempts: 5,
        });
        pipelineCompleted = true;
        // Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
        const refreshed = await this.loadFeatureFn(projectPath, featureId);
        if (refreshed?.status === 'merge_conflict') {
          return;
        }
      }

      // Read agent output before determining final status.
      // CLI-based providers (Cursor, Codex, etc.) may exit quickly without doing
      // meaningful work. Check output to avoid prematurely marking as 'verified'.
      const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
      let agentOutput = '';
      try {
        agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string;
      } catch {
        /* */
      }

      // Determine if the agent did meaningful work by checking for tool usage
      // indicators in the output. The agent executor writes "🔧 Tool:" markers
      // each time a tool is invoked. No tool usage suggests the CLI exited
      // without performing implementation work.
      const hasToolUsage = agentOutput.includes(TOOL_USE_MARKER);
      const isOutputTooShort = agentOutput.trim().length < MIN_MEANINGFUL_OUTPUT_LENGTH;
      const agentDidWork = hasToolUsage && !isOutputTooShort;

      let finalStatus: 'verified' | 'waiting_approval';
      if (feature.skipTests) {
        finalStatus = 'waiting_approval';
      } else if (!agentDidWork) {
        // Agent didn't produce meaningful output (e.g., CLI exited quickly).
        // Route to waiting_approval so the user can review and re-run.
        finalStatus = 'waiting_approval';
        logger.warn(
          `[executeFeature] Feature ${featureId}: agent produced insufficient output ` +
            `(${agentOutput.trim().length}/${MIN_MEANINGFUL_OUTPUT_LENGTH} chars, toolUsage=${hasToolUsage}). ` +
            `Setting status to waiting_approval instead of verified.`
        );
      } else {
        finalStatus = 'verified';
      }

      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 {
        // Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps)
        // This prevents overwriting accumulated summaries with just the last step's output
        // The agent-executor already extracts and saves summaries during execution
        if (agentOutput && !completedFeature?.summary) {
          const summary = extractSummary(agentOutput);
          if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
        }
        if (contextResult.memoryFiles.length > 0 && agentOutput) {
          await recordMemoryUsage(
            projectPath,
            contextResult.memoryFiles,
            agentOutput,
            true,
            secureFs as Parameters<typeof recordMemoryUsage>[4]
          );
        }
        await this.recordLearningsFn(projectPath, feature, agentOutput);
      } catch {
        /* 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)`;

      if (isAutoMode) {
        this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
          featureId,
          featureName: feature.title,
          branchName: feature.branchName ?? null,
          executionMode: 'auto',
          passes: true,
          message: completionMessage,
          projectPath,
          model: tempRunningFeature.model,
          provider: tempRunningFeature.provider,
        });
      }
    } catch (error) {
      const errorInfo = classifyError(error);
      if (errorInfo.isAbort) {
        await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
        if (isAutoMode) {
          this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
            featureId,
            featureName: feature?.title,
            branchName: feature?.branchName ?? null,
            executionMode: 'auto',
            passes: false,
            message: 'Feature stopped by user',
            projectPath,
          });
        }
      } else {
        logger.error(`Feature ${featureId} failed:`, error);
        // If pipeline steps completed successfully, don't send the feature back to backlog.
        // The pipeline work is done — set to waiting_approval so the user can review.
        const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
        if (pipelineCompleted) {
          logger.info(
            `[executeFeature] Feature ${featureId} failed after pipeline completed. ` +
              `Setting status to waiting_approval instead of backlog to preserve pipeline work.`
          );
        }
        // Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
        let currentStatus: string | undefined;
        try {
          const currentFeature = await this.loadFeatureFn(projectPath, featureId);
          currentStatus = currentFeature?.status;
        } catch (loadErr) {
          // If loading fails, log it and proceed with the status update anyway
          logger.warn(
            `[executeFeature] Failed to reload feature ${featureId} for status check:`,
            loadErr
          );
        }
        if (currentStatus !== 'merge_conflict') {
          await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
        }
        this.eventBus.emitAutoModeEvent('auto_mode_error', {
          featureId,
          featureName: feature?.title,
          branchName: feature?.branchName ?? null,
          error: errorInfo.message,
          errorType: errorInfo.type,
          projectPath,
        });
        if (this.trackFailureFn({ type: errorInfo.type, message: errorInfo.message })) {
          this.signalPauseFn({ type: errorInfo.type, message: errorInfo.message });
        }
      }
    } finally {
      this.releaseRunningFeature(featureId);
      if (isAutoMode && projectPath) await this.saveExecutionStateFn(projectPath);
    }
  }

  async stopFeature(featureId: string): Promise<boolean> {
    const running = this.concurrencyManager.getRunningFeature(featureId);
    if (!running) return false;
    const { projectPath } = running;

    // Immediately update feature status to 'interrupted' so the UI reflects
    // the stop right away. CLI-based providers can take seconds to terminate
    // their subprocess after the abort signal fires, leaving the feature stuck
    // in 'in_progress' on the Kanban board until the executeFeature catch block
    // eventually runs. By persisting and emitting the status change here, the
    // board updates immediately regardless of how long the subprocess takes to stop.
    try {
      await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
    } catch (err) {
      // Non-fatal: the abort still proceeds and executeFeature's catch block
      // will attempt the same update once the subprocess terminates.
      logger.warn(`stopFeature: failed to immediately update status for ${featureId}:`, err);
    }

    running.abortController.abort();
    this.releaseRunningFeature(featureId, { force: true });
    return true;
  }
}