mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
fix: address PR review issues for auto-mode refactor
- agent-executor: move executeQuery into try block for proper heartbeat cleanup, re-parse tasks when edited plan is approved - auto-loop-coordinator: handle feature execution failures with proper logging and failure tracking, support backward-compatible method signatures - facade: delegate getActiveAutoLoopProjects/Worktrees to coordinator, always create own AutoLoopCoordinator (not shared), pass projectPath to approval methods and branchName to failure tracking - global-service: document shared autoLoopCoordinator is for monitoring only - execution-types: fix ExecuteFeatureFn type to match implementation - feature-state-manager: use readJsonWithRecovery for loadFeature - pipeline-orchestrator: add defensive null check and try/catch for merge response parsing - plan-approval-service: use project-scoped keys to prevent cross-project collisions, maintain backward compatibility for featureId-only lookups Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -180,9 +180,9 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Starting stream for feature ${featureId}...`);
|
logger.info(`Starting stream for feature ${featureId}...`);
|
||||||
const stream = provider.executeQuery(executeOptions);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const stream = provider.executeQuery(executeOptions);
|
||||||
streamLoop: for await (const msg of stream) {
|
streamLoop: for await (const msg of stream) {
|
||||||
receivedAnyStreamMessage = true;
|
receivedAnyStreamMessage = true;
|
||||||
appendRawEvent(msg);
|
appendRawEvent(msg);
|
||||||
@@ -502,10 +502,17 @@ export class AgentExecutor {
|
|||||||
planApproved = true;
|
planApproved = true;
|
||||||
userFeedback = approvalResult.feedback;
|
userFeedback = approvalResult.feedback;
|
||||||
approvedPlanContent = approvalResult.editedPlan || currentPlanContent;
|
approvedPlanContent = approvalResult.editedPlan || currentPlanContent;
|
||||||
if (approvalResult.editedPlan)
|
if (approvalResult.editedPlan) {
|
||||||
|
// Re-parse tasks from edited plan to ensure we execute the updated tasks
|
||||||
|
const editedTasks = parseTasksFromSpec(approvalResult.editedPlan);
|
||||||
|
parsedTasks = editedTasks;
|
||||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||||
content: approvalResult.editedPlan,
|
content: approvalResult.editedPlan,
|
||||||
|
tasks: editedTasks,
|
||||||
|
tasksTotal: editedTasks.length,
|
||||||
|
tasksCompleted: 0,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
this.eventBus.emitAutoModeEvent('plan_approved', {
|
this.eventBus.emitAutoModeEvent('plan_approved', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
|||||||
@@ -183,7 +183,13 @@ export class AutoLoopCoordinator {
|
|||||||
nextFeature.id,
|
nextFeature.id,
|
||||||
projectState.config.useWorktrees,
|
projectState.config.useWorktrees,
|
||||||
true
|
true
|
||||||
).catch(() => {});
|
).catch((error) => {
|
||||||
|
const errorInfo = classifyError(error);
|
||||||
|
logger.error(`Auto-loop feature ${nextFeature.id} failed:`, errorInfo.message);
|
||||||
|
if (this.trackFailureAndCheckPauseForProject(projectPath, branchName, errorInfo)) {
|
||||||
|
this.signalShouldPauseForProject(projectPath, branchName, errorInfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await this.sleep(2000, projectState.abortController.signal);
|
await this.sleep(2000, projectState.abortController.signal);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -268,27 +274,64 @@ export class AutoLoopCoordinator {
|
|||||||
|
|
||||||
trackFailureAndCheckPauseForProject(
|
trackFailureAndCheckPauseForProject(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
errorInfo: { type: string; message: string }
|
branchNameOrError: string | null | { type: string; message: string },
|
||||||
|
errorInfo?: { type: string; message: string }
|
||||||
): boolean {
|
): boolean {
|
||||||
const projectState = this.autoLoopsByProject.get(getWorktreeAutoLoopKey(projectPath, null));
|
// Support both old (projectPath, errorInfo) and new (projectPath, branchName, errorInfo) signatures
|
||||||
|
let branchName: string | null;
|
||||||
|
let actualErrorInfo: { type: string; message: string };
|
||||||
|
if (
|
||||||
|
typeof branchNameOrError === 'object' &&
|
||||||
|
branchNameOrError !== null &&
|
||||||
|
'type' in branchNameOrError
|
||||||
|
) {
|
||||||
|
// Old signature: (projectPath, errorInfo)
|
||||||
|
branchName = null;
|
||||||
|
actualErrorInfo = branchNameOrError;
|
||||||
|
} else {
|
||||||
|
// New signature: (projectPath, branchName, errorInfo)
|
||||||
|
branchName = branchNameOrError;
|
||||||
|
actualErrorInfo = errorInfo!;
|
||||||
|
}
|
||||||
|
const projectState = this.autoLoopsByProject.get(
|
||||||
|
getWorktreeAutoLoopKey(projectPath, branchName)
|
||||||
|
);
|
||||||
if (!projectState) return false;
|
if (!projectState) return false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
projectState.consecutiveFailures.push({ timestamp: now, error: actualErrorInfo.message });
|
||||||
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
|
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
|
||||||
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD ||
|
projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD ||
|
||||||
errorInfo.type === 'quota_exhausted' ||
|
actualErrorInfo.type === 'quota_exhausted' ||
|
||||||
errorInfo.type === 'rate_limit'
|
actualErrorInfo.type === 'rate_limit'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
signalShouldPauseForProject(
|
signalShouldPauseForProject(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
errorInfo: { type: string; message: string }
|
branchNameOrError: string | null | { type: string; message: string },
|
||||||
|
errorInfo?: { type: string; message: string }
|
||||||
): void {
|
): void {
|
||||||
const projectState = this.autoLoopsByProject.get(getWorktreeAutoLoopKey(projectPath, null));
|
// Support both old (projectPath, errorInfo) and new (projectPath, branchName, errorInfo) signatures
|
||||||
|
let branchName: string | null;
|
||||||
|
let actualErrorInfo: { type: string; message: string };
|
||||||
|
if (
|
||||||
|
typeof branchNameOrError === 'object' &&
|
||||||
|
branchNameOrError !== null &&
|
||||||
|
'type' in branchNameOrError
|
||||||
|
) {
|
||||||
|
branchName = null;
|
||||||
|
actualErrorInfo = branchNameOrError;
|
||||||
|
} else {
|
||||||
|
branchName = branchNameOrError;
|
||||||
|
actualErrorInfo = errorInfo!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectState = this.autoLoopsByProject.get(
|
||||||
|
getWorktreeAutoLoopKey(projectPath, branchName)
|
||||||
|
);
|
||||||
if (!projectState || projectState.pausedDueToFailures) return;
|
if (!projectState || projectState.pausedDueToFailures) return;
|
||||||
projectState.pausedDueToFailures = true;
|
projectState.pausedDueToFailures = true;
|
||||||
const failureCount = projectState.consecutiveFailures.length;
|
const failureCount = projectState.consecutiveFailures.length;
|
||||||
@@ -297,24 +340,29 @@ export class AutoLoopCoordinator {
|
|||||||
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
||||||
? `Auto Mode paused: ${failureCount} consecutive failures detected.`
|
? `Auto Mode paused: ${failureCount} consecutive failures detected.`
|
||||||
: 'Auto Mode paused: Usage limit or API error detected.',
|
: 'Auto Mode paused: Usage limit or API error detected.',
|
||||||
errorType: errorInfo.type,
|
errorType: actualErrorInfo.type,
|
||||||
originalError: errorInfo.message,
|
originalError: actualErrorInfo.message,
|
||||||
failureCount,
|
failureCount,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName,
|
||||||
});
|
});
|
||||||
this.stopAutoLoopForProject(projectPath);
|
this.stopAutoLoopForProject(projectPath, branchName);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFailureTrackingForProject(projectPath: string): void {
|
resetFailureTrackingForProject(projectPath: string, branchName: string | null = null): void {
|
||||||
const projectState = this.autoLoopsByProject.get(getWorktreeAutoLoopKey(projectPath, null));
|
const projectState = this.autoLoopsByProject.get(
|
||||||
|
getWorktreeAutoLoopKey(projectPath, branchName)
|
||||||
|
);
|
||||||
if (projectState) {
|
if (projectState) {
|
||||||
projectState.consecutiveFailures = [];
|
projectState.consecutiveFailures = [];
|
||||||
projectState.pausedDueToFailures = false;
|
projectState.pausedDueToFailures = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recordSuccessForProject(projectPath: string): void {
|
recordSuccessForProject(projectPath: string, branchName: string | null = null): void {
|
||||||
const projectState = this.autoLoopsByProject.get(getWorktreeAutoLoopKey(projectPath, null));
|
const projectState = this.autoLoopsByProject.get(
|
||||||
|
getWorktreeAutoLoopKey(projectPath, branchName)
|
||||||
|
);
|
||||||
if (projectState) projectState.consecutiveFailures = [];
|
if (projectState) projectState.consecutiveFailures = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,11 +210,11 @@ export class AutoModeServiceFacade {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// AutoLoopCoordinator - use shared if provided, otherwise create new
|
// AutoLoopCoordinator - ALWAYS create new with proper execution callbacks
|
||||||
// Note: When using shared autoLoopCoordinator, callbacks are already set up by the global service
|
// NOTE: We don't use sharedServices.autoLoopCoordinator because it doesn't have
|
||||||
const autoLoopCoordinator =
|
// execution callbacks. Each facade needs its own coordinator to execute features.
|
||||||
sharedServices?.autoLoopCoordinator ??
|
// The shared coordinator in GlobalAutoModeService is for monitoring only.
|
||||||
new AutoLoopCoordinator(
|
const autoLoopCoordinator = new AutoLoopCoordinator(
|
||||||
eventBus,
|
eventBus,
|
||||||
concurrencyManager,
|
concurrencyManager,
|
||||||
settingsService,
|
settingsService,
|
||||||
@@ -328,8 +328,8 @@ export class AutoModeServiceFacade {
|
|||||||
(pPath, featureId, useWorktrees, _calledInternally) =>
|
(pPath, featureId, useWorktrees, _calledInternally) =>
|
||||||
facadeInstance!.resumeFeature(featureId, useWorktrees, _calledInternally),
|
facadeInstance!.resumeFeature(featureId, useWorktrees, _calledInternally),
|
||||||
(errorInfo) =>
|
(errorInfo) =>
|
||||||
autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, errorInfo),
|
autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, null, errorInfo),
|
||||||
(errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, errorInfo),
|
(errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, null, errorInfo),
|
||||||
() => {
|
() => {
|
||||||
/* recordSuccess - no-op */
|
/* recordSuccess - no-op */
|
||||||
},
|
},
|
||||||
@@ -746,18 +746,14 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
* Get all active auto loop projects (unique project paths)
|
* Get all active auto loop projects (unique project paths)
|
||||||
*/
|
*/
|
||||||
getActiveAutoLoopProjects(): string[] {
|
getActiveAutoLoopProjects(): string[] {
|
||||||
// This needs access to internal state - for now return empty
|
return this.autoLoopCoordinator.getActiveProjects();
|
||||||
// Routes should migrate to getActiveAutoLoopWorktrees
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all active auto loop worktrees
|
* Get all active auto loop worktrees
|
||||||
*/
|
*/
|
||||||
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
|
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
|
||||||
// This needs access to internal state - for now return empty
|
return this.autoLoopCoordinator.getActiveWorktrees();
|
||||||
// Will be properly implemented when routes migrate
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -891,7 +887,7 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
* @param featureId - The feature ID
|
* @param featureId - The feature ID
|
||||||
*/
|
*/
|
||||||
hasPendingApproval(featureId: string): boolean {
|
hasPendingApproval(featureId: string): boolean {
|
||||||
return this.planApprovalService.hasPendingApproval(featureId);
|
return this.planApprovalService.hasPendingApproval(featureId, this.projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -899,7 +895,7 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
* @param featureId - The feature ID
|
* @param featureId - The feature ID
|
||||||
*/
|
*/
|
||||||
cancelPlanApproval(featureId: string): void {
|
cancelPlanApproval(featureId: string): void {
|
||||||
this.planApprovalService.cancelApproval(featureId);
|
this.planApprovalService.cancelApproval(featureId, this.projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -51,15 +51,19 @@ export class GlobalAutoModeService {
|
|||||||
this.featureStateManager = new FeatureStateManager(events, featureLoader);
|
this.featureStateManager = new FeatureStateManager(events, featureLoader);
|
||||||
|
|
||||||
// Create AutoLoopCoordinator with callbacks
|
// Create AutoLoopCoordinator with callbacks
|
||||||
// These callbacks use placeholders since GlobalAutoModeService doesn't execute features
|
// IMPORTANT: This coordinator is for MONITORING ONLY (getActiveProjects, getActiveWorktrees).
|
||||||
// Feature execution is done via facades
|
// Facades MUST create their own AutoLoopCoordinator for actual execution.
|
||||||
|
// The executeFeatureFn here is a safety guard - it should never be called.
|
||||||
this.autoLoopCoordinator = new AutoLoopCoordinator(
|
this.autoLoopCoordinator = new AutoLoopCoordinator(
|
||||||
this.eventBus,
|
this.eventBus,
|
||||||
this.concurrencyManager,
|
this.concurrencyManager,
|
||||||
settingsService,
|
settingsService,
|
||||||
// executeFeatureFn - not used by global service, routes handle execution
|
// executeFeatureFn - throws because facades must use their own coordinator for execution
|
||||||
async () => {
|
async () => {
|
||||||
throw new Error('executeFeatureFn not available in GlobalAutoModeService');
|
throw new Error(
|
||||||
|
'executeFeatureFn not available in GlobalAutoModeService. ' +
|
||||||
|
'Facades must create their own AutoLoopCoordinator for execution.'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
// getBacklogFeaturesFn
|
// getBacklogFeaturesFn
|
||||||
(pPath, branchName) =>
|
(pPath, branchName) =>
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ export type ExecuteFeatureFn = (
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees: boolean,
|
useWorktrees: boolean,
|
||||||
useScreenshots: boolean,
|
isAutoMode: boolean,
|
||||||
model?: string,
|
providedWorktreePath?: string,
|
||||||
options?: { _calledInternally?: boolean }
|
options?: { continuationPrompt?: string; _calledInternally?: boolean }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -60,8 +60,12 @@ export class FeatureStateManager {
|
|||||||
const featurePath = path.join(featureDir, 'feature.json');
|
const featurePath = path.join(featureDir, 'feature.json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||||
return JSON.parse(data);
|
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||||
|
autoRestore: true,
|
||||||
|
});
|
||||||
|
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||||
|
return result.data;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,11 +495,23 @@ export class PipelineOrchestrator {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
if (!response) {
|
||||||
|
return { success: false, error: 'No response from merge endpoint' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensively parse JSON response
|
||||||
|
let data: { success: boolean; hasConflicts?: boolean; error?: string };
|
||||||
|
try {
|
||||||
|
data = (await response.json()) as {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
hasConflicts?: boolean;
|
hasConflicts?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error(`Failed to parse merge response:`, parseError);
|
||||||
|
return { success: false, error: 'Invalid response from merge endpoint' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (data.hasConflicts) {
|
if (data.hasConflicts) {
|
||||||
await this.updateFeatureStatusFn(projectPath, featureId, 'merge_conflict');
|
await this.updateFeatureStatusFn(projectPath, featureId, 'merge_conflict');
|
||||||
|
|||||||
@@ -66,12 +66,18 @@ export class PlanApprovalService {
|
|||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate project-scoped key to prevent collisions across projects */
|
||||||
|
private approvalKey(projectPath: string, featureId: string): string {
|
||||||
|
return `${projectPath}::${featureId}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Wait for plan approval with timeout (default 30 min). Rejects on timeout/cancellation. */
|
/** Wait for plan approval with timeout (default 30 min). Rejects on timeout/cancellation. */
|
||||||
async waitForApproval(featureId: string, projectPath: string): Promise<PlanApprovalResult> {
|
async waitForApproval(featureId: string, projectPath: string): Promise<PlanApprovalResult> {
|
||||||
const timeoutMs = await this.getTimeoutMs(projectPath);
|
const timeoutMs = await this.getTimeoutMs(projectPath);
|
||||||
const timeoutMinutes = Math.round(timeoutMs / 60000);
|
const timeoutMinutes = Math.round(timeoutMs / 60000);
|
||||||
|
const key = this.approvalKey(projectPath, featureId);
|
||||||
|
|
||||||
logger.info(`Registering pending approval for feature ${featureId}`);
|
logger.info(`Registering pending approval for feature ${featureId} in project ${projectPath}`);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||||
);
|
);
|
||||||
@@ -80,12 +86,12 @@ export class PlanApprovalService {
|
|||||||
// Set up timeout to prevent indefinite waiting and memory leaks
|
// Set up timeout to prevent indefinite waiting and memory leaks
|
||||||
// timeoutId stored in closure, NOT in PendingApproval object
|
// timeoutId stored in closure, NOT in PendingApproval object
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
const pending = this.pendingApprovals.get(featureId);
|
const pending = this.pendingApprovals.get(key);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes`
|
`Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes`
|
||||||
);
|
);
|
||||||
this.pendingApprovals.delete(featureId);
|
this.pendingApprovals.delete(key);
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
`Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled`
|
`Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled`
|
||||||
@@ -106,7 +112,7 @@ export class PlanApprovalService {
|
|||||||
reject(error);
|
reject(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.pendingApprovals.set(featureId, {
|
this.pendingApprovals.set(key, {
|
||||||
resolve: wrappedResolve,
|
resolve: wrappedResolve,
|
||||||
reject: wrappedReject,
|
reject: wrappedReject,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -132,7 +138,23 @@ export class PlanApprovalService {
|
|||||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const pending = this.pendingApprovals.get(featureId);
|
// Try to find pending approval using project-scoped key if projectPath is available
|
||||||
|
let foundKey: string | undefined;
|
||||||
|
let pending: PendingApproval | undefined;
|
||||||
|
|
||||||
|
if (projectPathFromClient) {
|
||||||
|
foundKey = this.approvalKey(projectPathFromClient, featureId);
|
||||||
|
pending = this.pendingApprovals.get(foundKey);
|
||||||
|
} else {
|
||||||
|
// Fallback: search by featureId (backward compatibility)
|
||||||
|
for (const [key, approval] of this.pendingApprovals) {
|
||||||
|
if (approval.featureId === featureId) {
|
||||||
|
foundKey = key;
|
||||||
|
pending = approval;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
logger.info(`No pending approval in Map for feature ${featureId}`);
|
logger.info(`No pending approval in Map for feature ${featureId}`);
|
||||||
@@ -219,32 +241,60 @@ export class PlanApprovalService {
|
|||||||
// Resolve the promise with all data including feedback
|
// Resolve the promise with all data including feedback
|
||||||
// This triggers the wrapped resolve which clears the timeout
|
// This triggers the wrapped resolve which clears the timeout
|
||||||
pending.resolve({ approved, editedPlan, feedback });
|
pending.resolve({ approved, editedPlan, feedback });
|
||||||
this.pendingApprovals.delete(featureId);
|
if (foundKey) {
|
||||||
|
this.pendingApprovals.delete(foundKey);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel approval (e.g., when feature stopped). Timeout cleared via wrapped reject. */
|
/** Cancel approval (e.g., when feature stopped). Timeout cleared via wrapped reject. */
|
||||||
cancelApproval(featureId: string): void {
|
cancelApproval(featureId: string, projectPath?: string): void {
|
||||||
logger.info(`cancelApproval called for feature ${featureId}`);
|
logger.info(`cancelApproval called for feature ${featureId}`);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const pending = this.pendingApprovals.get(featureId);
|
// If projectPath provided, use project-scoped key; otherwise search by featureId
|
||||||
if (pending) {
|
let foundKey: string | undefined;
|
||||||
|
let pending: PendingApproval | undefined;
|
||||||
|
|
||||||
|
if (projectPath) {
|
||||||
|
foundKey = this.approvalKey(projectPath, featureId);
|
||||||
|
pending = this.pendingApprovals.get(foundKey);
|
||||||
|
} else {
|
||||||
|
// Fallback: search for any approval with this featureId (backward compatibility)
|
||||||
|
for (const [key, approval] of this.pendingApprovals) {
|
||||||
|
if (approval.featureId === featureId) {
|
||||||
|
foundKey = key;
|
||||||
|
pending = approval;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending && foundKey) {
|
||||||
logger.info(`Found and cancelling pending approval for feature ${featureId}`);
|
logger.info(`Found and cancelling pending approval for feature ${featureId}`);
|
||||||
// Wrapped reject clears timeout automatically
|
// Wrapped reject clears timeout automatically
|
||||||
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
|
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
|
||||||
this.pendingApprovals.delete(featureId);
|
this.pendingApprovals.delete(foundKey);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`No pending approval to cancel for feature ${featureId}`);
|
logger.info(`No pending approval to cancel for feature ${featureId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a feature has a pending plan approval. */
|
/** Check if a feature has a pending plan approval. */
|
||||||
hasPendingApproval(featureId: string): boolean {
|
hasPendingApproval(featureId: string, projectPath?: string): boolean {
|
||||||
return this.pendingApprovals.has(featureId);
|
if (projectPath) {
|
||||||
|
return this.pendingApprovals.has(this.approvalKey(projectPath, featureId));
|
||||||
|
}
|
||||||
|
// Fallback: search by featureId (backward compatibility)
|
||||||
|
for (const approval of this.pendingApprovals.values()) {
|
||||||
|
if (approval.featureId === featureId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get timeout from project settings or default (30 min). */
|
/** Get timeout from project settings or default (30 min). */
|
||||||
|
|||||||
@@ -71,20 +71,21 @@ describe('FeatureStateManager', () => {
|
|||||||
|
|
||||||
describe('loadFeature', () => {
|
describe('loadFeature', () => {
|
||||||
it('should load feature from disk', async () => {
|
it('should load feature from disk', async () => {
|
||||||
(secureFs.readFile as Mock).mockResolvedValue(JSON.stringify(mockFeature));
|
(readJsonWithRecovery as Mock).mockResolvedValue({ data: mockFeature, recovered: false });
|
||||||
|
|
||||||
const feature = await manager.loadFeature('/project', 'feature-123');
|
const feature = await manager.loadFeature('/project', 'feature-123');
|
||||||
|
|
||||||
expect(feature).toEqual(mockFeature);
|
expect(feature).toEqual(mockFeature);
|
||||||
expect(getFeatureDir).toHaveBeenCalledWith('/project', 'feature-123');
|
expect(getFeatureDir).toHaveBeenCalledWith('/project', 'feature-123');
|
||||||
expect(secureFs.readFile).toHaveBeenCalledWith(
|
expect(readJsonWithRecovery).toHaveBeenCalledWith(
|
||||||
'/project/.automaker/features/feature-123/feature.json',
|
'/project/.automaker/features/feature-123/feature.json',
|
||||||
'utf-8'
|
null,
|
||||||
|
expect.objectContaining({ autoRestore: true })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if feature does not exist', async () => {
|
it('should return null if feature does not exist', async () => {
|
||||||
(secureFs.readFile as Mock).mockRejectedValue(new Error('ENOENT'));
|
(readJsonWithRecovery as Mock).mockRejectedValue(new Error('ENOENT'));
|
||||||
|
|
||||||
const feature = await manager.loadFeature('/project', 'non-existent');
|
const feature = await manager.loadFeature('/project', 'non-existent');
|
||||||
|
|
||||||
@@ -92,7 +93,8 @@ describe('FeatureStateManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if feature JSON is invalid', async () => {
|
it('should return null if feature JSON is invalid', async () => {
|
||||||
(secureFs.readFile as Mock).mockResolvedValue('not valid json');
|
// readJsonWithRecovery returns null as the default value when JSON is invalid
|
||||||
|
(readJsonWithRecovery as Mock).mockResolvedValue({ data: null, recovered: false });
|
||||||
|
|
||||||
const feature = await manager.loadFeature('/project', 'feature-123');
|
const feature = await manager.loadFeature('/project', 'feature-123');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user