apply the patches

This commit is contained in:
webdevcody
2026-01-20 10:24:38 -05:00
parent 179c5ae9c2
commit 76eb3a2ac2
42 changed files with 2679 additions and 757 deletions

View File

@@ -248,7 +248,8 @@ interface AutoModeConfig {
* @param branchName - The branch name, or null for main worktree
*/
function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
return `${projectPath}::${branchName ?? '__main__'}`;
const normalizedBranch = branchName === 'main' ? null : branchName;
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
}
/**
@@ -514,14 +515,11 @@ export class AutoModeService {
? settings.maxConcurrency
: DEFAULT_MAX_CONCURRENCY;
const projectId = settings.projects?.find((project) => project.path === projectPath)?.id;
const autoModeByWorktree = (settings as unknown as Record<string, unknown>)
.autoModeByWorktree;
const autoModeByWorktree = settings.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
const key = `${projectId}::${branchName ?? '__main__'}`;
const entry = (autoModeByWorktree as Record<string, unknown>)[key] as
| { maxConcurrency?: number }
| undefined;
const entry = autoModeByWorktree[key];
if (entry && typeof entry.maxConcurrency === 'number') {
return entry.maxConcurrency;
}
@@ -592,6 +590,7 @@ export class AutoModeService {
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
projectPath,
branchName,
maxConcurrency: resolvedMaxConcurrency,
});
// Save execution state for recovery after restart
@@ -677,8 +676,10 @@ export class AutoModeService {
continue;
}
// Find a feature not currently running
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
// Find a feature not currently running and not yet finished
const nextFeature = pendingFeatures.find(
(f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f)
);
if (nextFeature) {
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
@@ -730,11 +731,12 @@ export class AutoModeService {
* @param branchName - The branch name, or null for main worktree (features without branchName or with "main")
*/
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
const normalizedBranch = branchName === 'main' ? null : branchName;
let count = 0;
for (const [, feature] of this.runningFeatures) {
// Filter by project path AND branchName to get accurate worktree-specific count
const featureBranch = feature.branchName ?? null;
if (branchName === null) {
if (normalizedBranch === null) {
// Main worktree: match features with branchName === null OR branchName === "main"
if (
feature.projectPath === projectPath &&
@@ -998,6 +1000,41 @@ export class AutoModeService {
return this.runningFeatures.size;
}
/**
* Check if there's capacity to start a feature on a worktree.
* This respects per-worktree agent limits from autoModeByWorktree settings.
*
* @param projectPath - The main project path
* @param featureId - The feature ID to check capacity for
* @returns Object with hasCapacity boolean and details about current/max agents
*/
async checkWorktreeCapacity(
projectPath: string,
featureId: string
): Promise<{
hasCapacity: boolean;
currentAgents: number;
maxAgents: number;
branchName: string | null;
}> {
// Load feature to get branchName
const feature = await this.loadFeature(projectPath, featureId);
const branchName = feature?.branchName ?? null;
// Get per-worktree limit
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
// Get current running count for this worktree
const currentAgents = this.getRunningCountForWorktree(projectPath, branchName);
return {
hasCapacity: currentAgents < maxAgents,
currentAgents,
maxAgents,
branchName,
};
}
/**
* Execute a single feature
* @param projectPath - The main project path
@@ -1036,7 +1073,6 @@ export class AutoModeService {
if (isAutoMode) {
await this.saveExecutionState(projectPath);
}
// Declare feature outside try block so it's available in catch for error reporting
let feature: Awaited<ReturnType<typeof this.loadFeature>> | null = null;
@@ -1044,9 +1080,44 @@ export class AutoModeService {
// Validate that project path is allowed using centralized validation
validateWorkingDirectory(projectPath);
// Load feature details FIRST to get status and plan info
feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Check if feature has existing context - if so, resume instead of starting fresh
// Skip this check if we're already being called with a continuation prompt (from resumeFeature)
if (!options?.continuationPrompt) {
// If feature has an approved plan but we don't have a continuation prompt yet,
// we should build one to ensure it proceeds with multi-agent execution
if (feature.planSpec?.status === 'approved') {
logger.info(`Feature ${featureId} has approved plan, building continuation prompt`);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const planContent = feature.planSpec.content || '';
// Build continuation prompt using centralized template
let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, '');
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
// Recursively call executeFeature with the continuation prompt
// Remove from running features temporarily, it will be added back
this.runningFeatures.delete(featureId);
return this.executeFeature(
projectPath,
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
{
continuationPrompt,
}
);
}
const hasExistingContext = await this.contextExists(projectPath, featureId);
if (hasExistingContext) {
logger.info(
@@ -1058,12 +1129,6 @@ export class AutoModeService {
}
}
// Load feature details FIRST to get branchName
feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Derive workDir from feature.branchName
// Worktrees should already be created when the feature is added/edited
let worktreePath: string | null = null;
@@ -1190,6 +1255,7 @@ export class AutoModeService {
systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
branchName: feature.branchName ?? null,
}
);
@@ -1361,6 +1427,7 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName: feature.branchName ?? null,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
projectPath,
});
@@ -2805,6 +2872,21 @@ Format your response as a structured markdown document.`;
}
}
private isFeatureFinished(feature: Feature): boolean {
const isCompleted = feature.status === 'completed' || feature.status === 'verified';
// Even if marked as completed, if it has an approved plan with pending tasks, it's not finished
if (feature.planSpec?.status === 'approved') {
const tasksCompleted = feature.planSpec.tasksCompleted ?? 0;
const tasksTotal = feature.planSpec.tasksTotal ?? 0;
if (tasksCompleted < tasksTotal) {
return false;
}
}
return isCompleted;
}
/**
* Update the planSpec of a feature
*/
@@ -2899,10 +2981,14 @@ Format your response as a structured markdown document.`;
allFeatures.push(feature);
// Track pending features separately, filtered by worktree/branch
// Note: waiting_approval is NOT included - those features have completed execution
// and are waiting for user review, they should not be picked up again
if (
feature.status === 'pending' ||
feature.status === 'ready' ||
feature.status === 'backlog'
feature.status === 'backlog' ||
(feature.planSpec?.status === 'approved' &&
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
) {
// Filter by branchName:
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
@@ -2934,7 +3020,7 @@ Format your response as a structured markdown document.`;
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status for ${worktreeDesc}`
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/approved_with_pending_tasks) for ${worktreeDesc}`
);
if (pendingFeatures.length === 0) {
@@ -2943,7 +3029,12 @@ Format your response as a structured markdown document.`;
);
// Log all backlog features to help debug branchName matching
const allBacklogFeatures = allFeatures.filter(
(f) => f.status === 'backlog' || f.status === 'pending' || f.status === 'ready'
(f) =>
f.status === 'backlog' ||
f.status === 'pending' ||
f.status === 'ready' ||
(f.planSpec?.status === 'approved' &&
(f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0))
);
if (allBacklogFeatures.length > 0) {
logger.info(
@@ -2953,7 +3044,43 @@ Format your response as a structured markdown document.`;
}
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures);
// Remove missing dependencies from features and save them
// This allows features to proceed when their dependencies have been deleted or don't exist
if (missingDependencies.size > 0) {
for (const [featureId, missingDepIds] of missingDependencies) {
const feature = pendingFeatures.find((f) => f.id === featureId);
if (feature && feature.dependencies) {
// Filter out the missing dependency IDs
const validDependencies = feature.dependencies.filter(
(depId) => !missingDepIds.includes(depId)
);
logger.warn(
`[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.`
);
// Update the feature in memory
feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined;
// Save the updated feature to disk
try {
await this.featureLoader.update(projectPath, featureId, {
dependencies: feature.dependencies,
});
logger.info(
`[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies`
);
} catch (error) {
logger.error(
`[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`,
error
);
}
}
}
}
// Get skipVerificationInAutoMode setting
const settings = await this.settingsService?.getGlobalSettings();
@@ -3129,9 +3256,11 @@ You can use the Read tool to view these images at any time during implementation
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel;
branchName?: string | null;
}
): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath;
const branchName = options?.branchName ?? null;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
@@ -3496,6 +3625,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_approval_required', {
featureId,
projectPath,
branchName,
planContent: currentPlanContent,
planningMode,
planVersion,
@@ -3527,6 +3657,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_approved', {
featureId,
projectPath,
branchName,
hasEdits: !!approvalResult.editedPlan,
planVersion,
});
@@ -3555,6 +3686,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_revision_requested', {
featureId,
projectPath,
branchName,
feedback: approvalResult.feedback,
hasEdits: !!hasEdits,
planVersion,
@@ -3658,6 +3790,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('plan_auto_approved', {
featureId,
projectPath,
branchName,
planContent,
planningMode,
});
@@ -3708,6 +3841,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_task_started', {
featureId,
projectPath,
branchName,
taskId: task.id,
taskDescription: task.description,
taskIndex,
@@ -3753,11 +3887,13 @@ After generating the revised spec, output:
responseText += block.text || '';
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: block.text,
});
} else if (block.type === 'tool_use') {
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: block.name,
input: block.input,
});
@@ -3776,6 +3912,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_task_complete', {
featureId,
projectPath,
branchName,
taskId: task.id,
tasksCompleted: taskIndex + 1,
tasksTotal: parsedTasks.length,
@@ -3796,6 +3933,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_phase_complete', {
featureId,
projectPath,
branchName,
phaseNumber: parseInt(phaseMatch[1], 10),
});
}
@@ -3845,11 +3983,13 @@ After generating the revised spec, output:
responseText += block.text || '';
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: block.text,
});
} else if (block.type === 'tool_use') {
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: block.name,
input: block.input,
});
@@ -3875,6 +4015,7 @@ After generating the revised spec, output:
);
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: block.text,
});
}
@@ -3882,6 +4023,7 @@ After generating the revised spec, output:
// Emit event for real-time UI
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: block.name,
input: block.input,
});
@@ -4287,6 +4429,7 @@ After generating the revised spec, output:
id: f.id,
title: f.title,
status: f.status,
branchName: f.branchName ?? null,
})),
});

View File

@@ -21,6 +21,7 @@ import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
import type { EventHistoryService } from './event-history-service.js';
import type { FeatureLoader } from './feature-loader.js';
import type {
EventHook,
EventHookTrigger,
@@ -84,19 +85,22 @@ export class EventHookService {
private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null;
private featureLoader: FeatureLoader | null = null;
private unsubscribe: (() => void) | null = null;
/**
* Initialize the service with event emitter, settings service, and event history service
* Initialize the service with event emitter, settings service, event history service, and feature loader
*/
initialize(
emitter: EventEmitter,
settingsService: SettingsService,
eventHistoryService?: EventHistoryService
eventHistoryService?: EventHistoryService,
featureLoader?: FeatureLoader
): void {
this.emitter = emitter;
this.settingsService = settingsService;
this.eventHistoryService = eventHistoryService || null;
this.featureLoader = featureLoader || null;
// Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => {
@@ -121,6 +125,7 @@ export class EventHookService {
this.emitter = null;
this.settingsService = null;
this.eventHistoryService = null;
this.featureLoader = null;
}
/**
@@ -150,6 +155,19 @@ export class EventHookService {
if (!trigger) return;
// Load feature name if we have featureId but no featureName
let featureName: string | undefined = undefined;
if (payload.featureId && payload.projectPath && this.featureLoader) {
try {
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
if (feature?.title) {
featureName = feature.title;
}
} catch (error) {
logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error);
}
}
// Build context for variable substitution
const context: HookContext = {
featureId: payload.featureId,
@@ -315,6 +333,7 @@ export class EventHookService {
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
featureName: context.featureName,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,

View File

@@ -415,16 +415,25 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('claudeApiProfiles');
// Empty object overwrite guard
if (
sanitizedUpdates.lastSelectedSessionByProject &&
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
current.lastSelectedSessionByProject &&
Object.keys(current.lastSelectedSessionByProject).length > 0
) {
delete sanitizedUpdates.lastSelectedSessionByProject;
}
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
const curVal = current[key] as unknown;
if (
nextVal &&
typeof nextVal === 'object' &&
!Array.isArray(nextVal) &&
Object.keys(nextVal).length === 0 &&
curVal &&
typeof curVal === 'object' &&
!Array.isArray(curVal) &&
Object.keys(curVal).length > 0
) {
delete sanitizedUpdates[key];
}
};
ignoreEmptyObjectOverwrite('lastSelectedSessionByProject');
ignoreEmptyObjectOverwrite('autoModeByWorktree');
// If a request attempted to wipe projects, also ignore theme changes in that same request.
if (attemptedProjectWipe) {