mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 22:53:08 +00:00
Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps * feat: Optimize pipeline summary extraction and fix regex vulnerability * fix: Use fallback summary for pipeline steps when extraction fails * fix: Strip follow-up session scaffold from pipeline step fallback summaries
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature, ParsedTask, PlanSpec } from '@automaker/types';
|
||||
import type { Feature, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types';
|
||||
import { isPipelineStatus } from '@automaker/types';
|
||||
import {
|
||||
atomicWriteJson,
|
||||
readJsonWithRecovery,
|
||||
@@ -28,6 +29,7 @@ import type { EventEmitter } from '../lib/events.js';
|
||||
import type { AutoModeEventType } from './typed-event-bus.js';
|
||||
import { getNotificationService } from './notification-service.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
import { pipelineService } from './pipeline-service.js';
|
||||
|
||||
const logger = createLogger('FeatureStateManager');
|
||||
|
||||
@@ -252,7 +254,7 @@ export class FeatureStateManager {
|
||||
const currentStatus = feature?.status;
|
||||
|
||||
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
|
||||
if (currentStatus && currentStatus.startsWith('pipeline_')) {
|
||||
if (isPipelineStatus(currentStatus)) {
|
||||
logger.info(
|
||||
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
|
||||
);
|
||||
@@ -270,7 +272,8 @@ export class FeatureStateManager {
|
||||
|
||||
/**
|
||||
* Shared helper that scans features in a project directory and resets any stuck
|
||||
* in transient states (in_progress, interrupted, pipeline_*) back to resting states.
|
||||
* in transient states (in_progress, interrupted) back to resting states.
|
||||
* Pipeline_* statuses are preserved so they can be resumed.
|
||||
*
|
||||
* Also resets:
|
||||
* - generating planSpec status back to pending
|
||||
@@ -324,10 +327,7 @@ export class FeatureStateManager {
|
||||
|
||||
// Reset features in active execution states back to a resting state
|
||||
// After a server restart, no processes are actually running
|
||||
const isActiveState =
|
||||
originalStatus === 'in_progress' ||
|
||||
originalStatus === 'interrupted' ||
|
||||
(originalStatus != null && originalStatus.startsWith('pipeline_'));
|
||||
const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted';
|
||||
|
||||
if (isActiveState) {
|
||||
const hasApprovedPlan = feature.planSpec?.status === 'approved';
|
||||
@@ -338,6 +338,17 @@ export class FeatureStateManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle pipeline_* statuses separately: preserve them so they can be resumed
|
||||
// but still count them as needing attention if they were stuck.
|
||||
if (isPipelineStatus(originalStatus)) {
|
||||
// We don't change the status, but we still want to reset planSpec/task states
|
||||
// if they were stuck in transient generation/execution modes.
|
||||
// No feature.status change here.
|
||||
logger.debug(
|
||||
`[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}`
|
||||
);
|
||||
}
|
||||
|
||||
// Reset generating planSpec status back to pending (spec generation was interrupted)
|
||||
if (feature.planSpec?.status === 'generating') {
|
||||
feature.planSpec.status = 'pending';
|
||||
@@ -396,10 +407,12 @@ export class FeatureStateManager {
|
||||
* Resets:
|
||||
* - in_progress features back to ready (if has plan) or backlog (if no plan)
|
||||
* - interrupted features back to ready (if has plan) or backlog (if no plan)
|
||||
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
|
||||
* - generating planSpec status back to pending
|
||||
* - in_progress tasks back to pending
|
||||
*
|
||||
* Preserves:
|
||||
* - pipeline_* statuses (so resumePipelineFeature can resume from correct step)
|
||||
*
|
||||
* @param projectPath - The project path to reset features for
|
||||
*/
|
||||
async resetStuckFeatures(projectPath: string): Promise<void> {
|
||||
@@ -530,6 +543,10 @@ export class FeatureStateManager {
|
||||
* This is called after agent execution completes to save a summary
|
||||
* extracted from the agent's output using <summary> tags.
|
||||
*
|
||||
* For pipeline features (status starts with pipeline_), summaries are accumulated
|
||||
* across steps with a header identifying each step. For non-pipeline features,
|
||||
* the summary is replaced entirely.
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param featureId - The feature ID
|
||||
* @param summary - The summary text to save
|
||||
@@ -537,6 +554,7 @@ export class FeatureStateManager {
|
||||
async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
const normalizedSummary = summary.trim();
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
@@ -552,7 +570,63 @@ export class FeatureStateManager {
|
||||
return;
|
||||
}
|
||||
|
||||
feature.summary = summary;
|
||||
if (!normalizedSummary) {
|
||||
logger.debug(
|
||||
`[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For pipeline features, accumulate summaries across steps
|
||||
if (isPipelineStatus(feature.status)) {
|
||||
// If we already have a non-phase summary (typically the initial implementation
|
||||
// summary from in_progress), normalize it into a named phase before appending
|
||||
// pipeline step summaries. This keeps the format consistent for UI phase parsing.
|
||||
const implementationHeader = '### Implementation';
|
||||
if (feature.summary && !feature.summary.trimStart().startsWith('### ')) {
|
||||
feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`;
|
||||
}
|
||||
|
||||
const stepName = await this.getPipelineStepName(projectPath, feature.status);
|
||||
const stepHeader = `### ${stepName}`;
|
||||
const stepSection = `${stepHeader}\n\n${normalizedSummary}`;
|
||||
|
||||
if (feature.summary) {
|
||||
// Check if this step already exists in the summary (e.g., if retried)
|
||||
// Use section splitting to only match real section boundaries, not text in body content
|
||||
const separator = '\n\n---\n\n';
|
||||
const sections = feature.summary.split(separator);
|
||||
let replaced = false;
|
||||
const updatedSections = sections.map((section) => {
|
||||
if (section.startsWith(`${stepHeader}\n\n`)) {
|
||||
replaced = true;
|
||||
return stepSection;
|
||||
}
|
||||
return section;
|
||||
});
|
||||
|
||||
if (replaced) {
|
||||
feature.summary = updatedSections.join(separator);
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
} else {
|
||||
// Append as a new section
|
||||
feature.summary = `${feature.summary}${separator}${stepSection}`;
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
feature.summary = stepSection;
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
feature.summary = normalizedSummary;
|
||||
}
|
||||
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
@@ -562,13 +636,42 @@ export class FeatureStateManager {
|
||||
this.emitAutoModeEvent('auto_mode_summary', {
|
||||
featureId,
|
||||
projectPath,
|
||||
summary,
|
||||
summary: feature.summary,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save summary for ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the pipeline step name from the current pipeline status.
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param status - The current pipeline status (e.g., 'pipeline_abc123')
|
||||
* @returns The step name, or a fallback based on the step ID
|
||||
*/
|
||||
private async getPipelineStepName(projectPath: string, status: string): Promise<string> {
|
||||
try {
|
||||
const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline);
|
||||
if (stepId) {
|
||||
const step = await pipelineService.getStep(projectPath, stepId);
|
||||
if (step) return step.name;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
// Fallback: derive a human-readable name from the status suffix
|
||||
// e.g., 'pipeline_code_review' → 'Code Review'
|
||||
const suffix = status.replace('pipeline_', '');
|
||||
return suffix
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of a specific task within planSpec.tasks
|
||||
*
|
||||
@@ -581,7 +684,8 @@ export class FeatureStateManager {
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
taskId: string,
|
||||
status: ParsedTask['status']
|
||||
status: ParsedTask['status'],
|
||||
summary?: string
|
||||
): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
@@ -604,6 +708,9 @@ export class FeatureStateManager {
|
||||
const task = feature.planSpec.tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
task.status = status;
|
||||
if (summary) {
|
||||
task.summary = summary;
|
||||
}
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
@@ -615,6 +722,7 @@ export class FeatureStateManager {
|
||||
projectPath,
|
||||
taskId,
|
||||
status,
|
||||
summary,
|
||||
tasks: feature.planSpec.tasks,
|
||||
});
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user