Feature: worktree view customization and stability fixes (#805)

* Changes from feature/worktree-view-customization

* Feature: Git sync, set-tracking, and push divergence handling (#796)

* Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.

* Changes from feature/worktree-view-customization

* refactor: Remove unused worktree swap and highlight props

* refactor: Consolidate feature completion logic and improve thinking level defaults

* feat: Increase max turn limit to 10000

- Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts
- Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts
- Update UI clamping logic from 2000 to 10000 in app-store.ts
- Update fallback values from 1000 to 10000 in use-settings-sync.ts
- Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS
- Update documentation to reflect new range: 1-10000

Allows agents to perform up to 10000 turns for complex feature execution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat: Add model resolution, improve session handling, and enhance UI stability

* refactor: Remove unused sync and tracking branch props from worktree components

* feat: Add PR number update functionality to worktrees. Address pr feedback

* feat: Optimize Gemini CLI startup and add tool result tracking

* refactor: Improve error handling and simplify worktree task cleanup

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-02-23 20:31:25 -08:00
committed by GitHub
parent e7504b247f
commit 0330c70261
72 changed files with 3667 additions and 1173 deletions

View File

@@ -38,7 +38,7 @@ export type {
const logger = createLogger('AgentExecutor');
const DEFAULT_MAX_TURNS = 1000;
const DEFAULT_MAX_TURNS = 10000;
export class AgentExecutor {
private static readonly WRITE_DEBOUNCE_MS = 500;

View File

@@ -487,7 +487,19 @@ export class AgentService {
Object.keys(customSubagents).length > 0;
// Base tools that match the provider's default set
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
const baseTools = [
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
];
if (allowedTools) {
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
@@ -572,6 +584,7 @@ export class AgentService {
let currentAssistantMessage: Message | null = null;
let responseText = '';
const toolUses: Array<{ name: string; input: unknown }> = [];
const toolNamesById = new Map<string, string>();
for await (const msg of stream) {
// Capture SDK session ID from any message and persist it.
@@ -616,11 +629,50 @@ export class AgentService {
input: block.input,
};
toolUses.push(toolUse);
if (block.tool_use_id) {
toolNamesById.set(block.tool_use_id, toolUse.name);
}
this.emitAgentEvent(sessionId, {
type: 'tool_use',
tool: toolUse,
});
} else if (block.type === 'tool_result') {
const toolUseId = block.tool_use_id;
const toolName = toolUseId ? toolNamesById.get(toolUseId) : undefined;
// Normalize block.content to a string for the emitted event
const rawContent: unknown = block.content;
let contentString: string;
if (typeof rawContent === 'string') {
contentString = rawContent;
} else if (Array.isArray(rawContent)) {
// Extract text from content blocks (TextBlock, ImageBlock, etc.)
contentString = rawContent
.map((part: { text?: string; type?: string }) => {
if (typeof part === 'string') return part;
if (part.text) return part.text;
// For non-text blocks (e.g., images), represent as type indicator
if (part.type) return `[${part.type}]`;
return JSON.stringify(part);
})
.join('\n');
} else if (rawContent !== undefined && rawContent !== null) {
contentString = JSON.stringify(rawContent);
} else {
contentString = '';
}
this.emitAgentEvent(sessionId, {
type: 'tool_result',
tool: {
name: toolName || 'unknown',
input: {
toolUseId,
content: contentString,
},
},
});
}
}
}

View File

@@ -767,6 +767,7 @@ export class AutoModeServiceFacade {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: allPassed,
message: allPassed
? 'All verification checks passed'
@@ -829,6 +830,7 @@ export class AutoModeServiceFacade {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath: this.projectPath,

View File

@@ -60,6 +60,7 @@ interface AutoModeEventPayload {
featureId?: string;
featureName?: string;
passes?: boolean;
executionMode?: 'auto' | 'manual';
message?: string;
error?: string;
errorType?: string;
@@ -99,6 +100,18 @@ function isFeatureStatusChangedPayload(
);
}
/**
* Feature completed event payload structure
*/
interface FeatureCompletedPayload {
featureId: string;
featureName?: string;
projectPath: string;
passes?: boolean;
message?: string;
executionMode?: 'auto' | 'manual';
}
/**
* Event Hook Service
*
@@ -150,6 +163,8 @@ export class EventHookService {
this.handleAutoModeEvent(payload as AutoModeEventPayload);
} else if (type === 'feature:created') {
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
} else if (type === 'feature:completed') {
this.handleFeatureCompletedEvent(payload as FeatureCompletedPayload);
}
});
@@ -187,6 +202,9 @@ export class EventHookService {
switch (payload.type) {
case 'auto_mode_feature_complete':
// Only map explicit auto-mode completion events.
// Manual feature completions are emitted as feature:completed.
if (payload.executionMode !== 'auto') return;
trigger = payload.passes ? 'feature_success' : 'feature_error';
// Track this feature so feature_status_changed doesn't double-fire hooks
if (payload.featureId) {
@@ -248,6 +266,46 @@ export class EventHookService {
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
}
/**
* Handle feature:completed events and trigger matching hooks
*/
private async handleFeatureCompletedEvent(payload: FeatureCompletedPayload): Promise<void> {
if (!payload.featureId || !payload.projectPath) return;
// Mark as handled to prevent duplicate firing if feature_status_changed also fires
this.markFeatureHandled(payload.featureId);
const passes = payload.passes ?? true;
const trigger: EventHookTrigger = passes ? 'feature_success' : 'feature_error';
// Load feature name if we have featureId but no featureName
let featureName: string | undefined = undefined;
if (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);
}
}
const isErrorTrigger = trigger === 'feature_error';
const context: HookContext = {
featureId: payload.featureId,
featureName: featureName || payload.featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
error: isErrorTrigger ? payload.message : undefined,
errorType: undefined,
timestamp: new Date().toISOString(),
eventType: trigger,
};
await this.executeHooksForTrigger(trigger, context, { passes });
}
/**
* Handle feature:created events and trigger matching hooks
*/

View File

@@ -457,6 +457,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: completionMessage,
projectPath,
@@ -473,6 +474,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: false,
message: 'Feature stopped by user',
projectPath,
@@ -502,6 +504,22 @@ Please continue from where you left off and complete all remaining tasks. Use th
async stopFeature(featureId: string): Promise<boolean> {
const running = this.concurrencyManager.getRunningFeature(featureId);
if (!running) return false;
const { projectPath } = running;
// Immediately update feature status to 'interrupted' so the UI reflects
// the stop right away. CLI-based providers can take seconds to terminate
// their subprocess after the abort signal fires, leaving the feature stuck
// in 'in_progress' on the Kanban board until the executeFeature catch block
// eventually runs. By persisting and emitting the status change here, the
// board updates immediately regardless of how long the subprocess takes to stop.
try {
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
} catch (err) {
// Non-fatal: the abort still proceeds and executeFeature's catch block
// will attempt the same update once the subprocess terminates.
logger.warn(`stopFeature: failed to immediately update status for ${featureId}:`, err);
}
running.abortController.abort();
this.releaseRunningFeature(featureId, { force: true });
return true;

View File

@@ -243,6 +243,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: 'Pipeline step no longer exists',
projectPath,
@@ -292,6 +293,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: 'Pipeline completed (remaining steps excluded)',
projectPath,
@@ -317,6 +319,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: 'Pipeline completed (all steps excluded)',
projectPath,
@@ -401,6 +404,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: 'Pipeline resumed successfully',
projectPath,
@@ -414,6 +418,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: false,
message: 'Pipeline stopped by user',
projectPath,
@@ -580,6 +585,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName,
executionMode: 'auto',
passes: true,
message: 'Pipeline completed and merged',
projectPath,

View File

@@ -8,13 +8,10 @@
import path from 'path';
import fs from 'fs/promises';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { execGitCommand } from '@automaker/git-utils';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
const execFileAsync = promisify(execFile);
/**
* Get the list of remote names that have a branch matching the given branch name.
*
@@ -41,10 +38,9 @@ export async function getRemotesWithBranch(
}
try {
const { stdout: remoteRefsOutput } = await execFileAsync(
'git',
const remoteRefsOutput = await execGitCommand(
['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${currentBranch}`],
{ cwd: worktreePath }
worktreePath
);
if (!remoteRefsOutput.trim()) {