Skip to content
Merged
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,3 @@ OPUS_ANALYSIS_AND_IDEAS.md
/shared_docs
logs/security/
Agents.md
packages/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The removal of packages/ from the .gitignore file is a significant change that could have unintended consequences, such as committing node modules or other dependencies into the repository. This change seems unrelated to the PR's goal of adding an expand button. Could you please clarify the reason for this change or revert it if it was unintentional?

10 changes: 4 additions & 6 deletions apps/backend/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@
AUTO_CONTINUE_DELAY_SECONDS = 3
HUMAN_INTERVENTION_FILE = "PAUSE"

# Retry configuration for 400 tool concurrency errors
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
INITIAL_RETRY_DELAY_SECONDS = (
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
)
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
# Concurrency retry constants
MAX_CONCURRENCY_RETRIES = 5
INITIAL_RETRY_DELAY_SECONDS = 2
MAX_RETRY_DELAY_SECONDS = 32
Original file line number Diff line number Diff line change
Expand Up @@ -1116,85 +1116,6 @@ async def review(self, context: PRContext) -> PRReviewResult:
f"{len(findings)} findings from {len(agents_invoked)} agents"
)

# Skip the old orchestrator session code - findings come from parallel specialists
# The code below (structured output parsing, retries, etc.) is no longer needed
# as _run_parallel_specialists handles everything

# NOTE: The following block is kept but skipped via this marker
if False: # DISABLED: Old orchestrator + Task tool approach
# Old code for reference - to be removed after testing
prompt = self._build_orchestrator_prompt(context)
agent_defs = self._define_specialist_agents(project_root)
client = self._create_sdk_client(project_root, model, thinking_budget)

MAX_RETRIES = 3
RETRY_DELAY = 2.0

result_text = ""
structured_output = None
msg_count = 0
last_error = None

for attempt in range(MAX_RETRIES):
if attempt > 0:
logger.info(
f"[ParallelOrchestrator] Retry attempt {attempt}/{MAX_RETRIES - 1} "
f"after tool concurrency error"
)
safe_print(
f"[ParallelOrchestrator] Retry {attempt}/{MAX_RETRIES - 1} "
f"(tool concurrency error detected)"
)
await asyncio.sleep(RETRY_DELAY)
client = self._create_sdk_client(
project_root, model, thinking_budget
)

try:
async with client:
await client.query(prompt)

safe_print(
f"[ParallelOrchestrator] Running orchestrator ({model})...",
flush=True,
)

stream_result = await process_sdk_stream(
client=client,
context_name="ParallelOrchestrator",
model=model,
system_prompt=prompt,
agent_definitions=agent_defs,
)

error = stream_result.get("error")

if (
error == "tool_use_concurrency_error"
and attempt < MAX_RETRIES - 1
):
last_error = error
continue
if error:
raise RuntimeError(
f"SDK stream processing failed: {error}"
)
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
agents_invoked = stream_result["agents_invoked"]
break
except Exception as e:
if attempt < MAX_RETRIES - 1:
last_error = str(e)
continue
raise
else:
raise RuntimeError(
f"Orchestrator failed after {MAX_RETRIES} attempts"
)

# END DISABLED BLOCK

self._report_progress(
"finalizing",
50,
Expand Down
57 changes: 5 additions & 52 deletions apps/frontend/src/renderer/components/AuthStatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
} from './ui/tooltip';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '../stores/settings-store';
import { useClaudeProfileStore } from '../stores/claude-profile-store';
import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection';
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
Expand All @@ -50,13 +49,8 @@ const OAUTH_FALLBACK = {
} as const;

export function AuthStatusIndicator() {
// Subscribe to profile state from settings store (API profiles)
// Subscribe to profile state from settings store
const { profiles, activeProfileId } = useSettingsStore();

// Subscribe to Claude OAuth profile state
const claudeProfiles = useClaudeProfileStore((state) => state.profiles);
const activeClaudeProfileId = useClaudeProfileStore((state) => state.activeProfileId);

const { t } = useTranslation(['common']);

// Track usage data for warning badge
Expand Down Expand Up @@ -108,7 +102,6 @@ export function AuthStatusIndicator() {

// Compute auth status and provider detection using useMemo to avoid unnecessary re-renders
const authStatus = useMemo(() => {
// First check if user is using API profile auth (has active API profile)
if (activeProfileId) {
const activeProfile = profiles.find(p => p.id === activeProfileId);
if (activeProfile) {
Expand All @@ -126,36 +119,12 @@ export function AuthStatusIndicator() {
badgeColor: getProviderBadgeColor(provider)
};
}
// Profile ID set but profile not found - fallback to OAuth
return OAUTH_FALLBACK;
}

// No active API profile - check Claude OAuth profiles directly
if (activeClaudeProfileId && claudeProfiles.length > 0) {
const activeClaudeProfile = claudeProfiles.find(p => p.id === activeClaudeProfileId);
if (activeClaudeProfile) {
return {
type: 'oauth' as const,
name: activeClaudeProfile.email || activeClaudeProfile.name,
provider: 'anthropic' as const,
providerLabel: 'Anthropic',
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
};
}
}

// Fallback to usage data if Claude profiles aren't loaded yet
if (usage && (usage.profileName || usage.profileEmail)) {
return {
type: 'oauth' as const,
name: usage.profileEmail || usage.profileName,
provider: 'anthropic' as const,
providerLabel: 'Anthropic',
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
};
}

// No auth info available - fallback to generic OAuth
// No active profile - using OAuth
return OAUTH_FALLBACK;
}, [activeProfileId, profiles, activeClaudeProfileId, claudeProfiles, usage]);
}, [activeProfileId, profiles]);

// Helper function to truncate ID for display
const truncateId = (id: string): string => {
Expand Down Expand Up @@ -305,22 +274,6 @@ export function AuthStatusIndicator() {
</div>
</>
)}

{/* Account details for OAuth profiles */}
{isOAuth && authStatus.name && authStatus.name !== 'OAuth' && (
<>
<div className="pt-2 border-t space-y-2">
{/* Account name/email with icon */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lock className="h-3 w-3" />
<span className="text-[10px]">{t('common:usage.account')}</span>
</div>
<span className="font-medium text-[10px]">{authStatus.name}</span>
</div>
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
Expand Down
120 changes: 20 additions & 100 deletions apps/frontend/src/renderer/components/KanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import { TaskCard } from './TaskCard';
import { SortableTaskCard } from './SortableTaskCard';
import { QueueSettingsModal } from './QueueSettingsModal';
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
import { debugLog } from '../../shared/utils/debug-logger';
import { cn } from '../lib/utils';
import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore } from '../stores/task-store';
import { updateProjectSettings, useProjectStore } from '../stores/project-store';
Expand Down Expand Up @@ -1068,74 +1067,29 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
isProcessingQueueRef.current = true;

try {
// Track tasks we've already processed in this call to prevent duplicates
// This is critical because store updates happen synchronously but we need to ensure
// we never process the same task twice, even if there are timing issues
const processedTaskIds = new Set<string>();
// Track tasks we've already attempted to promote (to avoid infinite retries)
const attemptedTaskIds = new Set<string>();
let consecutiveFailures = 0;
const MAX_CONSECUTIVE_FAILURES = 10; // Safety limit to prevent infinite loop

// Track promotions in this call to enforce max parallel tasks limit
let promotedInThisCall = 0;

// Log initial state
const initialTasks = useTaskStore.getState().tasks;
const initialInProgress = initialTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const initialQueued = initialTasks.filter((t) => t.status === 'queue' && !t.metadata?.archivedAt);
debugLog(`[Queue] === PROCESS QUEUE START ===`, {
maxParallelTasks,
initialInProgressCount: initialInProgress.length,
initialInProgressIds: initialInProgress.map(t => t.id),
initialQueuedCount: initialQueued.length,
initialQueuedIds: initialQueued.map(t => t.id),
projectId
});

// Loop until capacity is full or queue is empty
let iteration = 0;
while (true) {
iteration++;
// Calculate total in-progress count: tasks that were already in progress + tasks promoted in this call
const totalInProgressCount = initialInProgress.length + promotedInThisCall;

debugLog(`[Queue] --- Iteration ${iteration} ---`, {
initialInProgressCount: initialInProgress.length,
promotedInThisCall,
totalInProgressCount,
capacityCheck: totalInProgressCount >= maxParallelTasks,
processedCount: processedTaskIds.size
});

// Stop if no capacity (initial in-progress + promoted in this call)
if (totalInProgressCount >= maxParallelTasks) {
debugLog(`[Queue] Capacity reached (${totalInProgressCount}/${maxParallelTasks}), stopping queue processing`);
break;
}

// Get CURRENT state from store to find queued tasks
const latestTasks = useTaskStore.getState().tasks;
const latestInProgress = latestTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const queuedTasks = latestTasks.filter((t) =>
t.status === 'queue' && !t.metadata?.archivedAt && !processedTaskIds.has(t.id)
// Get CURRENT state from store to ensure accuracy
const currentTasks = useTaskStore.getState().tasks;
const inProgressCount = currentTasks.filter((t) =>
t.status === 'in_progress' && !t.metadata?.archivedAt
).length;
const queuedTasks = currentTasks.filter((t) =>
t.status === 'queue' && !t.metadata?.archivedAt && !attemptedTaskIds.has(t.id)
);

debugLog(`[Queue] Current store state:`, {
totalTasks: latestTasks.length,
inProgressCount: latestInProgress.length,
inProgressIds: latestInProgress.map(t => t.id),
queuedCount: queuedTasks.length,
queuedIds: queuedTasks.map(t => t.id),
processedIds: Array.from(processedTaskIds)
});

// Stop if no queued tasks or too many consecutive failures
if (queuedTasks.length === 0) {
debugLog('[Queue] No more queued tasks to process');
// Stop if no capacity, no queued tasks, or too many consecutive failures
if (inProgressCount >= maxParallelTasks || queuedTasks.length === 0) {
break;
}

if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
debugLog(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
console.warn(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
break;
}

Expand All @@ -1146,62 +1100,28 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
return dateA - dateB; // Ascending order (oldest first)
})[0];

debugLog(`[Queue] Selected task for promotion:`, {
id: nextTask.id,
currentStatus: nextTask.status,
title: nextTask.title?.substring(0, 50)
});

// Mark task as processed BEFORE attempting promotion to prevent duplicates
processedTaskIds.add(nextTask.id);

debugLog(`[Queue] Promoting task ${nextTask.id} (${promotedInThisCall + 1}/${maxParallelTasks})`);
console.log(`[Queue] Auto-promoting task ${nextTask.id} from Queue to In Progress (${inProgressCount + 1}/${maxParallelTasks})`);
const result = await persistTaskStatus(nextTask.id, 'in_progress');

// Check store state after promotion
const afterPromoteTasks = useTaskStore.getState().tasks;
const afterPromoteInProgress = afterPromoteTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const afterPromoteQueued = afterPromoteTasks.filter((t) => t.status === 'queue' && !t.metadata?.archivedAt);

debugLog(`[Queue] After promotion attempt:`, {
resultSuccess: result.success,
promotedInThisCall,
inProgressCount: afterPromoteInProgress.length,
inProgressIds: afterPromoteInProgress.map(t => t.id),
queuedCount: afterPromoteQueued.length,
queuedIds: afterPromoteQueued.map(t => t.id)
});

if (result.success) {
// Increment our local promotion counter
promotedInThisCall++;
// Reset consecutive failures on success
consecutiveFailures = 0;
} else {
// If promotion failed, log error and continue to next task
// If promotion failed, log error, mark as attempted, and skip to next task
console.error(`[Queue] Failed to promote task ${nextTask.id} to In Progress:`, result.error);
attemptedTaskIds.add(nextTask.id);
consecutiveFailures++;
}
}

// Log summary
debugLog(`[Queue] === PROCESS QUEUE COMPLETE ===`, {
totalIterations: iteration,
tasksProcessed: processedTaskIds.size,
tasksPromoted: promotedInThisCall,
processedIds: Array.from(processedTaskIds)
});

// Trigger UI refresh if tasks were promoted to ensure UI reflects all changes
// This handles the case where store updates are batched/delayed via IPC events
if (promotedInThisCall > 0 && onRefresh) {
debugLog('[Queue] Triggering UI refresh after queue promotion');
onRefresh();
// Log if we had failed tasks
if (attemptedTaskIds.size > 0) {
console.warn(`[Queue] Skipped ${attemptedTaskIds.size} task(s) that failed to promote`);
}
} finally {
isProcessingQueueRef.current = false;
}
}, [maxParallelTasks, projectId, onRefresh]);
}, [maxParallelTasks]);

// Register task status change listener for queue auto-promotion
// This ensures processQueue() is called whenever a task leaves in_progress
Expand All @@ -1210,7 +1130,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
(taskId, oldStatus, newStatus) => {
// When a task leaves in_progress (e.g., goes to human_review), process the queue
if (oldStatus === 'in_progress' && newStatus !== 'in_progress') {
debugLog(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`);
console.log(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`);
processQueue();
}
}
Expand Down
Loading
Loading