Fix: Dev server detection bug fixes. Settings sync bug fixes. Cli provider fixes. Terminal background/foreground colors (#791)

* Changes from fix/dev-server-state-bug

* feat: Add configurable max turns setting with user overrides. Address pr comments

* fix: Update default behaviors and improve state management across server and UI

* feat: Extract branch sync logic to separate service. Fix settings sync bug. Address pr comments

* refactor: Extract magic numbers to named constants and improve branch tracking logic

- Add DEFAULT_MAX_TURNS (1000) and MAX_ALLOWED_TURNS (2000) constants to settings-helpers
- Replace hardcoded 1000 values with DEFAULT_MAX_TURNS constant throughout codebase
- Improve max turns validation with explicit Number.isFinite check
- Update getTrackingBranch to split on first slash instead of last for better remote parsing
- Change isBranchCheckedOut return type from boolean to string|null to return worktree path
- Add comments explaining skipFetch parameter in worktree creation
- Fix cleanup order in AgentExecutor finally block to run before logging
```

* feat: Add comment refresh and improve model sync in PR dialog
This commit is contained in:
gsxdsm
2026-02-21 08:57:04 -08:00
committed by GitHub
parent c81ea768a7
commit 3ddf26f666
41 changed files with 2705 additions and 274 deletions

View File

@@ -367,6 +367,11 @@ export interface CreateSdkOptionsConfig {
/** Extended thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
/** Optional user-configured max turns override (from settings).
* When provided, overrides the preset MAX_TURNS for the use case.
* Range: 1-2000. */
maxTurns?: number;
}
// Re-export MCP types from @automaker/types for convenience
@@ -403,7 +408,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
// See: https://github.com/AutoMaker-Org/automaker/issues/149
permissionMode: 'default',
model: getModelForUseCase('spec', config.model),
maxTurns: MAX_TURNS.maximum,
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions,
@@ -437,7 +442,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
// Override permissionMode - feature generation only needs read-only tools
permissionMode: 'default',
model: getModelForUseCase('features', config.model),
maxTurns: MAX_TURNS.quick,
maxTurns: config.maxTurns ?? MAX_TURNS.quick,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
@@ -468,7 +473,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
return {
...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model),
maxTurns: MAX_TURNS.extended,
maxTurns: config.maxTurns ?? MAX_TURNS.extended,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
@@ -506,7 +511,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
return {
...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard,
maxTurns: config.maxTurns ?? MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
...claudeMdOptions,
@@ -541,7 +546,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
return {
...getBaseOptions(),
model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum,
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
...claudeMdOptions,

View File

@@ -33,9 +33,16 @@ import {
const logger = createLogger('SettingsHelper');
/** Default number of agent turns used when no value is configured. */
export const DEFAULT_MAX_TURNS = 1000;
/** Upper bound for the max-turns clamp; values above this are capped here. */
export const MAX_ALLOWED_TURNS = 2000;
/**
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
* Returns false if settings service is not available.
* Falls back to global settings and defaults to true when unset.
* Returns true if settings service is not available.
*
* @param projectPath - Path to the project
* @param settingsService - Optional settings service instance
@@ -48,8 +55,8 @@ export async function getAutoLoadClaudeMdSetting(
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
return false;
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`);
return true;
}
try {
@@ -64,7 +71,7 @@ export async function getAutoLoadClaudeMdSetting(
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.autoLoadClaudeMd ?? false;
const result = globalSettings.autoLoadClaudeMd ?? true;
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
return result;
} catch (error) {
@@ -73,6 +80,41 @@ export async function getAutoLoadClaudeMdSetting(
}
}
/**
* Get the default max turns setting from global settings.
*
* Reads the user's configured `defaultMaxTurns` setting, which controls the maximum
* number of agent turns (tool-call round-trips) for feature execution.
*
* @param settingsService - Settings service instance (may be null)
* @param logPrefix - Logging prefix for debugging
* @returns The user's configured max turns, or {@link DEFAULT_MAX_TURNS} as default
*/
export async function getDefaultMaxTurnsSetting(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<number> {
if (!settingsService) {
logger.info(
`${logPrefix} SettingsService not available, using default maxTurns=${DEFAULT_MAX_TURNS}`
);
return DEFAULT_MAX_TURNS;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const raw = globalSettings.defaultMaxTurns;
const result = Number.isFinite(raw) ? (raw as number) : DEFAULT_MAX_TURNS;
// Clamp to valid range
const clamped = Math.max(1, Math.min(MAX_ALLOWED_TURNS, Math.floor(result)));
logger.debug(`${logPrefix} defaultMaxTurns from global settings: ${clamped}`);
return clamped;
} catch (error) {
logger.error(`${logPrefix} Failed to load defaultMaxTurns setting:`, error);
return DEFAULT_MAX_TURNS;
}
}
/**
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
* and rebuilds the formatted prompt without it.

View File

@@ -180,7 +180,7 @@ export class ClaudeProvider extends BaseProvider {
model,
cwd,
systemPrompt,
maxTurns = 100,
maxTurns = 1000,
allowedTools,
abortController,
conversationHistory,

View File

@@ -738,6 +738,16 @@ export class CodexProvider extends BaseProvider {
);
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
if (resolvedMaxTurns === null && options.maxTurns === undefined) {
logger.warn(
`[executeQuery] maxTurns not provided — Codex CLI will use its internal default. ` +
`This may cause premature completion. Model: ${options.model}`
);
} else {
logger.info(
`[executeQuery] maxTurns: requested=${options.maxTurns}, resolved=${resolvedMaxTurns}, model=${options.model}`
);
}
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
const wantsOutputSchema = Boolean(

View File

@@ -4,7 +4,8 @@
* This endpoint handles worktree creation with proper checks:
* 1. First checks if git already has a worktree for the branch (anywhere)
* 2. If found, returns the existing worktree (no error)
* 3. Only creates a new worktree if none exists for the branch
* 3. Syncs the base branch from its remote tracking branch (fast-forward only)
* 4. Only creates a new worktree if none exists for the branch
*/
import type { Request, Response } from 'express';
@@ -27,6 +28,10 @@ import { execGitCommand } from '../../../lib/git.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
import { runInitScript } from '../../../services/init-script-service.js';
import {
syncBaseBranch,
type BaseBranchSyncResult,
} from '../../../services/branch-sync-service.js';
const logger = createLogger('Worktree');
@@ -193,6 +198,52 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`);
}
// Sync the base branch with its remote tracking branch (fast-forward only).
// This ensures the new worktree starts from an up-to-date state rather than
// a potentially stale local copy. If the sync fails or the branch has diverged,
// we proceed with the local copy and inform the user.
const effectiveBase = baseBranch || 'HEAD';
let syncResult: BaseBranchSyncResult = { attempted: false, synced: false };
// Only sync if the base is a real branch (not 'HEAD')
// Pass skipFetch=true because we already fetched all remotes above.
if (effectiveBase !== 'HEAD') {
logger.info(`Syncing base branch '${effectiveBase}' before creating worktree`);
syncResult = await syncBaseBranch(projectPath, effectiveBase, true);
if (syncResult.attempted) {
if (syncResult.synced) {
logger.info(`Base branch sync result: ${syncResult.message}`);
} else {
logger.warn(`Base branch sync result: ${syncResult.message}`);
}
}
} else {
// When using HEAD, try to sync the currently checked-out branch
// Pass skipFetch=true because we already fetched all remotes above.
try {
const currentBranch = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
projectPath
);
const trimmedBranch = currentBranch.trim();
if (trimmedBranch && trimmedBranch !== 'HEAD') {
logger.info(
`Syncing current branch '${trimmedBranch}' (HEAD) before creating worktree`
);
syncResult = await syncBaseBranch(projectPath, trimmedBranch, true);
if (syncResult.attempted) {
if (syncResult.synced) {
logger.info(`HEAD branch sync result: ${syncResult.message}`);
} else {
logger.warn(`HEAD branch sync result: ${syncResult.message}`);
}
}
}
} catch {
// Could not determine HEAD branch — skip sync
}
}
// Check if branch exists (using array arguments to prevent injection)
let branchExists = false;
try {
@@ -226,6 +277,19 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
// Get the commit hash the new worktree is based on for logging
let baseCommitHash: string | undefined;
try {
const hash = await execGitCommand(['rev-parse', '--short', 'HEAD'], absoluteWorktreePath);
baseCommitHash = hash.trim();
} catch {
// Non-critical — just for logging
}
if (baseCommitHash) {
logger.info(`New worktree for '${branchName}' based on commit ${baseCommitHash}`);
}
// Copy configured files into the new worktree before responding
// This runs synchronously to ensure files are in place before any init script
try {
@@ -247,6 +311,17 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
path: normalizePath(absoluteWorktreePath),
branch: branchName,
isNew: !branchExists,
baseCommitHash,
...(syncResult.attempted
? {
syncResult: {
synced: syncResult.synced,
remote: syncResult.remote,
message: syncResult.message,
diverged: syncResult.diverged,
},
}
: {}),
},
});

View File

@@ -38,6 +38,8 @@ export type {
const logger = createLogger('AgentExecutor');
const DEFAULT_MAX_TURNS = 1000;
export class AgentExecutor {
private static readonly WRITE_DEBOUNCE_MS = 500;
private static readonly STREAM_HEARTBEAT_MS = 15_000;
@@ -99,10 +101,22 @@ export class AgentExecutor {
workDir,
false
);
const resolvedMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS;
if (sdkOptions?.maxTurns == null) {
logger.info(
`[execute] Feature ${featureId}: sdkOptions.maxTurns is not set, defaulting to ${resolvedMaxTurns}. ` +
`Model: ${effectiveBareModel}`
);
} else {
logger.info(
`[execute] Feature ${featureId}: maxTurns=${resolvedMaxTurns}, model=${effectiveBareModel}`
);
}
const executeOptions: ExecuteOptions = {
prompt: promptContent,
model: effectiveBareModel,
maxTurns: sdkOptions?.maxTurns,
maxTurns: resolvedMaxTurns,
cwd: workDir,
allowedTools: sdkOptions?.allowedTools as string[] | undefined,
abortController,
@@ -279,6 +293,17 @@ export class AgentExecutor {
throw new Error(AgentExecutor.sanitizeProviderError(msg.error));
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
}
} finally {
clearInterval(streamHeartbeat);
if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
const streamElapsedMs = Date.now() - streamStartTime;
logger.info(
`[execute] Stream ended for feature ${featureId} after ${Math.round(streamElapsedMs / 1000)}s. ` +
`aborted=${aborted}, specDetected=${specDetected}, responseLength=${responseText.length}`
);
await writeToFile();
if (enableRawOutput && rawOutputLines.length > 0) {
try {
@@ -288,10 +313,6 @@ export class AgentExecutor {
/* ignore */
}
}
} finally {
clearInterval(streamHeartbeat);
if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
}
return { responseText, specDetected, tasksCompleted, aborted };
}
@@ -351,8 +372,13 @@ export class AgentExecutor {
taskPrompts.taskExecution.taskPromptTemplate,
userFeedback
);
const taskMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS;
logger.info(
`[executeTasksLoop] Feature ${featureId}, task ${task.id} (${taskIndex + 1}/${tasks.length}): ` +
`maxTurns=${taskMaxTurns} (sdkOptions.maxTurns=${sdkOptions?.maxTurns ?? 'undefined'})`
);
const taskStream = provider.executeQuery(
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 100, 100))
this.buildExecOpts(options, taskPrompt, taskMaxTurns)
);
let taskOutput = '',
taskStartDetected = false,
@@ -571,7 +597,7 @@ export class AgentExecutor {
});
let revText = '';
for await (const msg of provider.executeQuery(
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? 100)
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
)) {
if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content)
@@ -657,7 +683,7 @@ export class AgentExecutor {
return { responseText, tasksCompleted };
}
private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns?: number) {
private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns: number) {
return {
prompt,
model: o.effectiveBareModel,
@@ -689,7 +715,7 @@ export class AgentExecutor {
.replace(/\{\{approvedPlan\}\}/g, planContent);
let responseText = initialResponseText;
for await (const msg of provider.executeQuery(
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns)
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
)) {
if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content) {

View File

@@ -28,6 +28,7 @@ import {
getSubagentsConfiguration,
getCustomSubagents,
getProviderByModelId,
getDefaultMaxTurnsSetting,
} from '../lib/settings-helpers.js';
interface Message {
@@ -437,6 +438,9 @@ export class AgentService {
const modelForSdk = providerResolvedModel || model;
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
// Read user-configured max turns from settings
const userMaxTurns = await getDefaultMaxTurnsSetting(this.settingsService, '[AgentService]');
const sdkOptions = createChatOptions({
cwd: effectiveWorkDir,
model: modelForSdk,
@@ -445,6 +449,7 @@ export class AgentService {
abortController: session.abortController!,
autoLoadClaudeMd,
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
maxTurns: userMaxTurns, // User-configured max turns from settings
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
});

View File

@@ -20,8 +20,13 @@ import { resolveModelString } from '@automaker/model-resolver';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js';
import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js';
import {
getPromptCustomization,
getProviderByModelId,
getMCPServersFromSettings,
getDefaultMaxTurnsSetting,
} from '../../lib/settings-helpers.js';
import { execGitCommand } from '@automaker/git-utils';
import { TypedEventBus } from '../typed-event-bus.js';
import { ConcurrencyManager } from '../concurrency-manager.js';
@@ -234,6 +239,45 @@ export class AutoModeServiceFacade {
}
}
// Build sdkOptions with proper maxTurns and allowedTools for auto-mode.
// Without this, maxTurns would be undefined, causing providers to use their
// internal defaults which may be much lower than intended (e.g., Codex CLI's
// default turn limit can cause feature runs to stop prematurely).
const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false;
let mcpServers: Record<string, unknown> | undefined;
try {
if (settingsService) {
const servers = await getMCPServersFromSettings(settingsService, '[AutoModeFacade]');
if (Object.keys(servers).length > 0) {
mcpServers = servers;
}
}
} catch {
// MCP servers are optional - continue without them
}
// Read user-configured max turns from settings
const userMaxTurns = await getDefaultMaxTurnsSetting(settingsService, '[AutoModeFacade]');
const sdkOpts = createAutoModeOptions({
cwd: workDir,
model: resolvedModel,
systemPrompt: opts?.systemPrompt,
abortController,
autoLoadClaudeMd,
thinkingLevel: opts?.thinkingLevel,
maxTurns: userMaxTurns,
mcpServers: mcpServers as
| Record<string, import('@automaker/types').McpServerConfig>
| undefined,
});
logger.info(
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel}, ` +
`maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` +
`provider=${provider.getName()}`
);
await agentExecutor.execute(
{
workDir,
@@ -254,6 +298,15 @@ export class AutoModeServiceFacade {
effectiveBareModel,
credentials,
claudeCompatibleProvider,
mcpServers,
sdkOptions: {
maxTurns: sdkOpts.maxTurns,
allowedTools: sdkOpts.allowedTools as string[] | undefined,
systemPrompt: sdkOpts.systemPrompt,
settingSources: sdkOpts.settingSources as
| Array<'user' | 'project' | 'local'>
| undefined,
},
},
{
waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath),
@@ -702,16 +755,19 @@ export class AutoModeServiceFacade {
}
}
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: allPassed,
message: allPassed
? 'All verification checks passed'
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
projectPath: this.projectPath,
});
const runningEntryForVerify = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForVerify?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: allPassed,
message: allPassed
? 'All verification checks passed'
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
projectPath: this.projectPath,
});
}
return allPassed;
}
@@ -761,14 +817,17 @@ export class AutoModeServiceFacade {
await execGitCommand(['commit', '-m', commitMessage], workDir);
const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath: this.projectPath,
});
const runningEntryForCommit = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForCommit?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath: this.projectPath,
});
}
return hash.trim();
} catch (error) {

View File

@@ -0,0 +1,426 @@
/**
* branch-sync-service - Sync a local base branch with its remote tracking branch
*
* Provides logic to detect remote tracking branches, check whether a branch
* is checked out in any worktree, and fast-forward a local branch to match
* its remote counterpart. Extracted from the worktree create route so
* the git logic is decoupled from HTTP request/response handling.
*/
import { createLogger, getErrorMessage } from '@automaker/utils';
import { execGitCommand } from '../lib/git.js';
const logger = createLogger('BranchSyncService');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
// ============================================================================
// Types
// ============================================================================
/**
* Result of attempting to sync a base branch with its remote.
*/
export interface BaseBranchSyncResult {
/** Whether the sync was attempted */
attempted: boolean;
/** Whether the sync succeeded */
synced: boolean;
/** Whether the ref was resolved (but not synced, e.g. remote ref, tag, or commit hash) */
resolved?: boolean;
/** The remote that was synced from (e.g. 'origin') */
remote?: string;
/** The commit hash the base branch points to after sync */
commitHash?: string;
/** Human-readable message about the sync result */
message?: string;
/** Whether the branch had diverged (local commits ahead of remote) */
diverged?: boolean;
/** Whether the user can proceed with a stale local copy */
canProceedWithStale?: boolean;
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Detect the remote tracking branch for a given local branch.
*
* @param projectPath - Path to the git repository
* @param branchName - Local branch name to check (e.g. 'main')
* @returns Object with remote name and remote branch, or null if no tracking branch
*/
export async function getTrackingBranch(
projectPath: string,
branchName: string
): Promise<{ remote: string; remoteBranch: string } | null> {
try {
// git rev-parse --abbrev-ref <branch>@{upstream} returns e.g. "origin/main"
const upstream = await execGitCommand(
['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`],
projectPath
);
const trimmed = upstream.trim();
if (!trimmed) return null;
// First, attempt to determine the remote name explicitly via git config
// so that remotes whose names contain slashes are handled correctly.
let remote: string | null = null;
try {
const configRemote = await execGitCommand(
['config', '--get', `branch.${branchName}.remote`],
projectPath
);
const configRemoteTrimmed = configRemote.trim();
if (configRemoteTrimmed) {
remote = configRemoteTrimmed;
}
} catch {
// git config lookup failed — will fall back to string splitting below
}
if (remote) {
// Strip the known remote prefix (plus the separating '/') to get the remote branch.
// The upstream string is expected to be "<remote>/<remoteBranch>".
const prefix = `${remote}/`;
if (trimmed.startsWith(prefix)) {
return {
remote,
remoteBranch: trimmed.substring(prefix.length),
};
}
// Upstream doesn't start with the expected prefix — fall through to split
}
// Fall back: split on the FIRST slash, which favors the common case of
// single-name remotes with slash-containing branch names (e.g.
// "origin/feature/foo" → remote="origin", remoteBranch="feature/foo").
// Remotes with slashes in their names are uncommon and are already handled
// by the git-config lookup above; this fallback only runs when that lookup
// fails, so optimizing for single-name remotes is the safer default.
const slashIndex = trimmed.indexOf('/');
if (slashIndex > 0) {
return {
remote: trimmed.substring(0, slashIndex),
remoteBranch: trimmed.substring(slashIndex + 1),
};
}
return null;
} catch {
// No upstream tracking branch configured
return null;
}
}
/**
* Check whether a branch is checked out in ANY worktree (main or linked).
* Uses `git worktree list --porcelain` to enumerate all worktrees and
* checks if any of them has the given branch as their HEAD.
*
* Returns the absolute path of the worktree where the branch is checked out,
* or null if the branch is not checked out anywhere. Callers can use the
* returned path to run commands (e.g. `git merge`) inside the correct worktree.
*
* This prevents using `git update-ref` on a branch that is checked out in
* a linked worktree, which would desync that worktree's HEAD.
*/
export async function isBranchCheckedOut(
projectPath: string,
branchName: string
): Promise<string | null> {
try {
const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath);
const lines = stdout.split('\n');
let currentWorktreePath: string | null = null;
let currentBranch: string | null = null;
for (const line of lines) {
if (line.startsWith('worktree ')) {
currentWorktreePath = line.slice(9);
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
} else if (line === '') {
// End of a worktree entry — check for match, then reset for the next
if (currentBranch === branchName && currentWorktreePath) {
return currentWorktreePath;
}
currentWorktreePath = null;
currentBranch = null;
}
}
// Check the last entry (if output doesn't end with a blank line)
if (currentBranch === branchName && currentWorktreePath) {
return currentWorktreePath;
}
return null;
} catch {
return null;
}
}
/**
* Build a BaseBranchSyncResult for cases where we proceed with a stale local copy.
* Extracts the repeated pattern of getting the short commit hash with a fallback.
*/
export async function buildStaleResult(
projectPath: string,
branchName: string,
remote: string | undefined,
message: string,
extra?: Partial<BaseBranchSyncResult>
): Promise<BaseBranchSyncResult> {
let commitHash: string | undefined;
try {
const hash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
commitHash = hash.trim();
} catch {
/* ignore — commit hash is non-critical */
}
return {
attempted: true,
synced: false,
remote,
commitHash,
message,
canProceedWithStale: true,
...extra,
};
}
// ============================================================================
// Main Sync Function
// ============================================================================
/**
* Sync a local base branch with its remote tracking branch using fast-forward only.
*
* This function:
* 1. Detects the remote tracking branch for the given local branch
* 2. Fetches latest from that remote (unless skipFetch is true)
* 3. Attempts a fast-forward-only update of the local branch
* 4. If the branch has diverged, reports the divergence and allows proceeding with stale copy
* 5. If no remote tracking branch exists, skips silently
*
* @param projectPath - Path to the git repository
* @param branchName - The local branch name to sync (e.g. 'main')
* @param skipFetch - When true, skip the internal git fetch (caller has already fetched)
* @returns Sync result with status information
*/
export async function syncBaseBranch(
projectPath: string,
branchName: string,
skipFetch = false
): Promise<BaseBranchSyncResult> {
// Check if the branch exists as a local branch (under refs/heads/).
// This correctly handles branch names containing slashes (e.g. "feature/abc",
// "fix/issue-123") which are valid local branch names, not remote refs.
let existsLocally = false;
try {
await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], projectPath);
existsLocally = true;
} catch {
existsLocally = false;
}
if (!existsLocally) {
// Not a local branch — check if it's a valid ref (remote ref, tag, or commit hash).
// No synchronization is performed here; we only resolve the ref to a commit hash.
try {
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
return {
attempted: false,
synced: false,
resolved: true,
commitHash: commitHash.trim(),
message: `Ref '${branchName}' resolved (not a local branch; no sync performed)`,
};
} catch {
return {
attempted: false,
synced: false,
message: `Ref '${branchName}' not found`,
};
}
}
// Detect remote tracking branch
const tracking = await getTrackingBranch(projectPath, branchName);
if (!tracking) {
// No remote tracking branch — skip silently
logger.info(`Branch '${branchName}' has no remote tracking branch, skipping sync`);
try {
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
return {
attempted: false,
synced: false,
commitHash: commitHash.trim(),
message: `Branch '${branchName}' has no remote tracking branch`,
};
} catch {
return {
attempted: false,
synced: false,
message: `Branch '${branchName}' has no remote tracking branch`,
};
}
}
logger.info(
`Syncing base branch '${branchName}' from ${tracking.remote}/${tracking.remoteBranch}`
);
// Fetch the specific remote unless the caller has already performed a fetch
// (e.g. via `git fetch --all`) and passed skipFetch=true to avoid redundant work.
if (!skipFetch) {
try {
const fetchController = new AbortController();
const fetchTimer = setTimeout(() => fetchController.abort(), FETCH_TIMEOUT_MS);
try {
await execGitCommand(
['fetch', tracking.remote, tracking.remoteBranch, '--quiet'],
projectPath,
undefined,
fetchController
);
} finally {
clearTimeout(fetchTimer);
}
} catch (fetchErr) {
// Fetch failed — network error, auth error, etc.
// Allow proceeding with stale local copy
const errMsg = getErrorMessage(fetchErr);
logger.warn(`Failed to fetch ${tracking.remote}/${tracking.remoteBranch}: ${errMsg}`);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Failed to fetch from remote: ${errMsg}. Proceeding with local copy.`
);
}
} else {
logger.info(`Skipping fetch for '${branchName}' (caller already fetched from remotes)`);
}
// Check if the local branch is behind, ahead, or diverged from the remote
const remoteRef = `${tracking.remote}/${tracking.remoteBranch}`;
try {
// Count commits ahead and behind
const revListOutput = await execGitCommand(
['rev-list', '--left-right', '--count', `${branchName}...${remoteRef}`],
projectPath
);
const parts = revListOutput.trim().split(/\s+/);
const ahead = parseInt(parts[0], 10) || 0;
const behind = parseInt(parts[1], 10) || 0;
if (ahead === 0 && behind === 0) {
// Already up to date
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
logger.info(`Branch '${branchName}' is already up to date with ${remoteRef}`);
return {
attempted: true,
synced: true,
remote: tracking.remote,
commitHash: commitHash.trim(),
message: `Branch '${branchName}' is already up to date`,
};
}
if (ahead > 0 && behind > 0) {
// Branch has diverged — cannot fast-forward
logger.warn(
`Branch '${branchName}' has diverged from ${remoteRef} (${ahead} ahead, ${behind} behind)`
);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Branch '${branchName}' has diverged from ${remoteRef} (${ahead} commit(s) ahead, ${behind} behind). Using local copy to avoid overwriting local commits.`,
{ diverged: true }
);
}
if (ahead > 0 && behind === 0) {
// Local is ahead — nothing to pull, already has everything from remote plus more
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
logger.info(`Branch '${branchName}' is ${ahead} commit(s) ahead of ${remoteRef}`);
return {
attempted: true,
synced: true,
remote: tracking.remote,
commitHash: commitHash.trim(),
message: `Branch '${branchName}' is ${ahead} commit(s) ahead of remote`,
};
}
// behind > 0 && ahead === 0 — can fast-forward
logger.info(
`Branch '${branchName}' is ${behind} commit(s) behind ${remoteRef}, fast-forwarding`
);
// Determine whether the branch is currently checked out (returns the
// worktree path where it is checked out, or null if not checked out)
const worktreePath = await isBranchCheckedOut(projectPath, branchName);
if (worktreePath) {
// Branch is checked out in a worktree — use git merge --ff-only
// Run the merge inside the worktree that has the branch checked out
try {
await execGitCommand(['merge', '--ff-only', remoteRef], worktreePath);
} catch (mergeErr) {
const errMsg = getErrorMessage(mergeErr);
logger.warn(`Fast-forward merge failed for '${branchName}': ${errMsg}`);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Fast-forward merge failed: ${errMsg}. Proceeding with local copy.`
);
}
} else {
// Branch is NOT checked out — use git update-ref to fast-forward without checkout
// This is safe because we already verified the branch is strictly behind (ahead === 0)
try {
const remoteCommit = await execGitCommand(['rev-parse', remoteRef], projectPath);
await execGitCommand(
['update-ref', `refs/heads/${branchName}`, remoteCommit.trim()],
projectPath
);
} catch (updateErr) {
const errMsg = getErrorMessage(updateErr);
logger.warn(`update-ref failed for '${branchName}': ${errMsg}`);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Failed to fast-forward branch: ${errMsg}. Proceeding with local copy.`
);
}
}
// Successfully fast-forwarded
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
logger.info(`Successfully synced '${branchName}' to ${commitHash.trim()} from ${remoteRef}`);
return {
attempted: true,
synced: true,
remote: tracking.remote,
commitHash: commitHash.trim(),
message: `Fast-forwarded '${branchName}' by ${behind} commit(s) from ${remoteRef}`,
};
} catch (err) {
// Unexpected error during rev-list or merge — proceed with stale
const errMsg = getErrorMessage(err);
logger.warn(`Unexpected error syncing '${branchName}': ${errMsg}`);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Sync failed: ${errMsg}. Proceeding with local copy.`
);
}
}

View File

@@ -19,6 +19,10 @@ const logger = createLogger('DevServerService');
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
// Timeout (ms) before falling back to the allocated port if URL detection hasn't succeeded.
// This handles cases where the dev server output format is not recognized by any pattern.
const URL_DETECTION_TIMEOUT_MS = 30_000;
// URL patterns for detecting full URLs from dev server output.
// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput.
// Ordered from most specific (framework-specific) to least specific.
@@ -88,6 +92,8 @@ const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
export interface DevServerInfo {
worktreePath: string;
/** The port originally reserved by findAvailablePort() never mutated after startDevServer sets it */
allocatedPort: number;
port: number;
url: string;
process: ChildProcess | null;
@@ -102,6 +108,8 @@ export interface DevServerInfo {
stopping: boolean;
// Flag to indicate if URL has been detected from output
urlDetected: boolean;
// Timer for URL detection timeout fallback
urlDetectionTimeout: NodeJS.Timeout | null;
}
// Port allocation starts at 3001 to avoid conflicts with common dev ports
@@ -124,6 +132,32 @@ class DevServerService {
this.emitter = emitter;
}
/**
* Prune a stale server entry whose process has exited without cleanup.
* Clears any pending timers, removes the port from allocatedPorts, deletes
* the entry from runningServers, and emits the "dev-server:stopped" event
* so all callers consistently notify the frontend when pruning entries.
*
* @param worktreePath - The key used in runningServers
* @param server - The DevServerInfo entry to prune
*/
private pruneStaleServer(worktreePath: string, server: DevServerInfo): void {
if (server.flushTimeout) clearTimeout(server.flushTimeout);
if (server.urlDetectionTimeout) clearTimeout(server.urlDetectionTimeout);
// Use allocatedPort (immutable) to free the reserved slot; server.port may have
// been mutated by detectUrlFromOutput to reflect the actual detected port.
this.allocatedPorts.delete(server.allocatedPort);
this.runningServers.delete(worktreePath);
if (this.emitter) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
port: server.port, // Report the externally-visible (detected) port
exitCode: server.process?.exitCode ?? null,
timestamp: new Date().toISOString(),
});
}
}
/**
* Append data to scrollback buffer with size limit enforcement
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
@@ -253,6 +287,12 @@ class DevServerService {
server.url = detectedUrl;
server.urlDetected = true;
// Clear the URL detection timeout since we found the URL
if (server.urlDetectionTimeout) {
clearTimeout(server.urlDetectionTimeout);
server.urlDetectionTimeout = null;
}
// Update the port to match the detected URL's actual port
const detectedPort = this.extractPortFromUrl(detectedUrl);
if (detectedPort && detectedPort !== server.port) {
@@ -291,6 +331,12 @@ class DevServerService {
server.url = detectedUrl;
server.urlDetected = true;
// Clear the URL detection timeout since we found the port
if (server.urlDetectionTimeout) {
clearTimeout(server.urlDetectionTimeout);
server.urlDetectionTimeout = null;
}
if (detectedPort !== server.port) {
logger.info(
`Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}`
@@ -660,6 +706,7 @@ class DevServerService {
const hostname = process.env.HOSTNAME || 'localhost';
const serverInfo: DevServerInfo = {
worktreePath,
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
port,
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
process: devProcess,
@@ -669,6 +716,7 @@ class DevServerService {
flushTimeout: null,
stopping: false,
urlDetected: false, // Will be set to true when actual URL is detected from output
urlDetectionTimeout: null, // Will be set after server starts successfully
};
// Capture stdout with buffer management and event emission
@@ -692,18 +740,24 @@ class DevServerService {
serverInfo.flushTimeout = null;
}
// Clear URL detection timeout to prevent stale fallback emission
if (serverInfo.urlDetectionTimeout) {
clearTimeout(serverInfo.urlDetectionTimeout);
serverInfo.urlDetectionTimeout = null;
}
// Emit stopped event (only if not already stopping - prevents duplicate events)
if (this.emitter && !serverInfo.stopping) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
port,
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
exitCode,
error: errorMessage,
timestamp: new Date().toISOString(),
});
}
this.allocatedPorts.delete(port);
this.allocatedPorts.delete(serverInfo.allocatedPort);
this.runningServers.delete(worktreePath);
};
@@ -749,6 +803,43 @@ class DevServerService {
});
}
// Set up URL detection timeout fallback.
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
// the allocated port is actually in use (server probably started successfully)
// and emit a url-detected event with the allocated port as fallback.
// Also re-scan the scrollback buffer in case the URL was printed before
// our patterns could match (e.g., it was split across multiple data chunks).
serverInfo.urlDetectionTimeout = setTimeout(() => {
serverInfo.urlDetectionTimeout = null;
// Only run fallback if server is still running and URL wasn't detected
if (serverInfo.stopping || serverInfo.urlDetected || !this.runningServers.has(worktreePath)) {
return;
}
// Re-scan the entire scrollback buffer for URL patterns
// This catches cases where the URL was split across multiple output chunks
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer);
// If still not detected after full rescan, use the allocated port as fallback
if (!serverInfo.urlDetected) {
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
const fallbackUrl = `http://${hostname}:${port}`;
serverInfo.url = fallbackUrl;
serverInfo.urlDetected = true;
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
worktreePath,
url: fallbackUrl,
port,
timestamp: new Date().toISOString(),
});
}
}
}, URL_DETECTION_TIMEOUT_MS);
return {
success: true,
result: {
@@ -794,6 +885,12 @@ class DevServerService {
server.flushTimeout = null;
}
// Clean up URL detection timeout
if (server.urlDetectionTimeout) {
clearTimeout(server.urlDetectionTimeout);
server.urlDetectionTimeout = null;
}
// Clear any pending output buffer
server.outputBuffer = '';
@@ -812,8 +909,10 @@ class DevServerService {
server.process.kill('SIGTERM');
}
// Free the port
this.allocatedPorts.delete(server.port);
// Free the originally-reserved port slot (allocatedPort is immutable and always
// matches what was added to allocatedPorts in startDevServer; server.port may
// have been updated by detectUrlFromOutput to the actual detected port).
this.allocatedPorts.delete(server.allocatedPort);
this.runningServers.delete(worktreePath);
return {
@@ -827,6 +926,7 @@ class DevServerService {
/**
* List all running dev servers
* Also verifies that each server's process is still alive, removing stale entries
*/
listDevServers(): {
success: boolean;
@@ -836,14 +936,37 @@ class DevServerService {
port: number;
url: string;
urlDetected: boolean;
startedAt: string;
}>;
};
} {
// Prune any servers whose process has died without us being notified
// This handles edge cases where the process exited but the 'exit' event was missed
const stalePaths: string[] = [];
for (const [worktreePath, server] of this.runningServers) {
// Check if exitCode is a number (not null/undefined) - indicates process has exited
if (server.process && typeof server.process.exitCode === 'number') {
logger.info(
`Pruning stale server entry for ${worktreePath} (process exited with code ${server.process.exitCode})`
);
stalePaths.push(worktreePath);
}
}
for (const stalePath of stalePaths) {
const server = this.runningServers.get(stalePath);
if (server) {
// Delegate to the shared helper so timers, ports, and the stopped event
// are all handled consistently with isRunning and getServerInfo.
this.pruneStaleServer(stalePath, server);
}
}
const servers = Array.from(this.runningServers.values()).map((s) => ({
worktreePath: s.worktreePath,
port: s.port,
url: s.url,
urlDetected: s.urlDetected,
startedAt: s.startedAt.toISOString(),
}));
return {
@@ -853,17 +976,33 @@ class DevServerService {
}
/**
* Check if a worktree has a running dev server
* Check if a worktree has a running dev server.
* Also prunes stale entries where the process has exited.
*/
isRunning(worktreePath: string): boolean {
return this.runningServers.has(worktreePath);
const server = this.runningServers.get(worktreePath);
if (!server) return false;
// Prune stale entry if the process has exited
if (server.process && typeof server.process.exitCode === 'number') {
this.pruneStaleServer(worktreePath, server);
return false;
}
return true;
}
/**
* Get info for a specific worktree's dev server
* Get info for a specific worktree's dev server.
* Also prunes stale entries where the process has exited.
*/
getServerInfo(worktreePath: string): DevServerInfo | undefined {
return this.runningServers.get(worktreePath);
const server = this.runningServers.get(worktreePath);
if (!server) return undefined;
// Prune stale entry if the process has exited
if (server.process && typeof server.process.exitCode === 'number') {
this.pruneStaleServer(worktreePath, server);
return undefined;
}
return server;
}
/**
@@ -891,6 +1030,15 @@ class DevServerService {
};
}
// Prune stale entry if the process has been killed or has exited
if (server.process && (server.process.killed || server.process.exitCode != null)) {
this.pruneStaleServer(worktreePath, server);
return {
success: false,
error: `No dev server running for worktree: ${worktreePath}`,
};
}
return {
success: true,
result: {

View File

@@ -170,13 +170,15 @@ export class EventHookService {
// Build context for variable substitution
// Use loaded featureName (from feature.title) or fall back to payload.featureName
// Only populate error/errorType for error triggers - don't leak success messages into error fields
const isErrorTrigger = trigger === 'feature_error' || trigger === 'auto_mode_error';
const context: HookContext = {
featureId: payload.featureId,
featureName: featureName || payload.featureName,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,
errorType: payload.errorType,
error: isErrorTrigger ? payload.error || payload.message : undefined,
errorType: isErrorTrigger ? payload.errorType : undefined,
timestamp: new Date().toISOString(),
eventType: trigger,
};

View File

@@ -441,28 +441,32 @@ Please continue from where you left off and complete all remaining tasks. Use th
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: completionMessage,
projectPath,
model: tempRunningFeature.model,
provider: tempRunningFeature.provider,
});
if (isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
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');
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: false,
message: 'Feature stopped by user',
projectPath,
});
if (isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: false,
message: 'Feature stopped by user',
projectPath,
});
}
} else {
logger.error(`Feature ${featureId} failed:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');

View File

@@ -226,14 +226,17 @@ export class PipelineOrchestrator {
logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`);
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline step no longer exists',
projectPath,
});
const runningEntryForStep = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForStep?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline step no longer exists',
projectPath,
});
}
return;
}
@@ -272,14 +275,17 @@ export class PipelineOrchestrator {
);
if (!pipelineService.isPipelineStatus(nextStatus)) {
await this.updateFeatureStatusFn(projectPath, featureId, nextStatus);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline completed (remaining steps excluded)',
projectPath,
});
const runningEntryForExcluded = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForExcluded?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline completed (remaining steps excluded)',
projectPath,
});
}
return;
}
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
@@ -294,14 +300,17 @@ export class PipelineOrchestrator {
if (stepsToExecute.length === 0) {
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline completed (all steps excluded)',
projectPath,
});
const runningEntryForAllExcluded = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForAllExcluded?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline completed (all steps excluded)',
projectPath,
});
}
return;
}
@@ -370,25 +379,29 @@ export class PipelineOrchestrator {
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
}
logger.info(`Pipeline resume completed for feature ${featureId}`);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline resumed successfully',
projectPath,
});
} catch (error) {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
if (runningEntry.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: false,
message: 'Pipeline stopped by user',
passes: true,
message: 'Pipeline resumed successfully',
projectPath,
});
}
} catch (error) {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
if (runningEntry.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: false,
message: 'Pipeline stopped by user',
projectPath,
});
}
} else {
logger.error(`Pipeline resume failed for ${featureId}:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
@@ -537,14 +550,17 @@ export class PipelineOrchestrator {
}
logger.info(`Auto-merge successful for feature ${featureId}`);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName,
passes: true,
message: 'Pipeline completed and merged',
projectPath,
});
const runningEntryForMerge = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForMerge?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName,
passes: true,
message: 'Pipeline completed and merged',
projectPath,
});
}
return { success: true };
} catch (error) {
logger.error(`Merge failed for ${featureId}:`, error);