Add benchmark results for fast3 and fast4, implement KeepAliveLspBridge, and add tests for staged strategies

- Added new benchmark result files: compare_2026-02-09_score_fast3.json and compare_2026-02-09_score_fast4.json.
- Implemented KeepAliveLspBridge to maintain a persistent LSP connection across multiple queries, improving performance.
- Created unit tests for staged clustering strategies in test_staged_stage3_fast_strategies.py, ensuring correct behavior of score and dir_rr strategies.
This commit is contained in:
catlog22
2026-02-09 20:45:29 +08:00
parent c62d26183b
commit 4344e79e68
64 changed files with 6154 additions and 123 deletions

View File

@@ -33,6 +33,7 @@ import type { IssueQueue, QueueItem } from '@/lib/api';
export interface QueueActionsProps {
queue: IssueQueue;
queueId?: string;
isActive?: boolean;
onActivate?: (queueId: string) => void;
onDeactivate?: () => void;
@@ -50,6 +51,7 @@ export interface QueueActionsProps {
export function QueueActions({
queue,
queueId: queueIdProp,
isActive = false,
onActivate,
onDeactivate,
@@ -69,20 +71,20 @@ export function QueueActions({
const [mergeTargetId, setMergeTargetId] = useState('');
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
// Use "current" as the queue ID for single-queue model
// This matches the API pattern where deactivate works on the current queue
const queueId = 'current';
const queueId = queueIdProp;
// Get all items from grouped_items for split dialog
const allItems: QueueItem[] = Object.values(queue.grouped_items || {}).flat();
const handleDelete = () => {
if (!queueId) return;
onDelete?.(queueId);
setIsDeleteOpen(false);
};
const handleMerge = () => {
if (mergeTargetId.trim()) {
if (!queueId) return;
onMerge?.(queueId, mergeTargetId.trim());
setIsMergeOpen(false);
setMergeTargetId('');
@@ -91,6 +93,7 @@ export function QueueActions({
const handleSplit = () => {
if (selectedItemIds.length > 0 && selectedItemIds.length < allItems.length) {
if (!queueId) return;
onSplit?.(queueId, selectedItemIds);
setIsSplitOpen(false);
setSelectedItemIds([]);
@@ -128,7 +131,7 @@ export function QueueActions({
size="sm"
className="h-8 w-8 p-0"
onClick={() => onActivate(queueId)}
disabled={isActivating}
disabled={isActivating || !queueId}
title={formatMessage({ id: 'issues.queue.actions.activate' })}
>
{isActivating ? (
@@ -161,7 +164,7 @@ export function QueueActions({
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsMergeOpen(true)}
disabled={isMerging}
disabled={isMerging || !queueId}
title={formatMessage({ id: 'issues.queue.actions.merge' })}
>
{isMerging ? (
@@ -178,7 +181,7 @@ export function QueueActions({
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsSplitOpen(true)}
disabled={isSplitting}
disabled={isSplitting || !queueId}
title={formatMessage({ id: 'issues.queue.actions.split' })}
>
{isSplitting ? (
@@ -195,7 +198,7 @@ export function QueueActions({
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
disabled={isDeleting || !queueId}
title={formatMessage({ id: 'issues.queue.actions.delete' })}
>
{isDeleting ? (

View File

@@ -0,0 +1,171 @@
// ========================================
// QueueBoard
// ========================================
// Kanban-style view of queue execution groups with DnD reordering/moving.
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { DropResult } from '@hello-pangea/dnd';
import { useIntl } from 'react-intl';
import { LayoutGrid } from 'lucide-react';
import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import { useQueueMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import type { IssueQueue, QueueItem } from '@/lib/api';
type QueueBoardItem = QueueItem & KanbanItem;
function groupSortKey(groupId: string): [number, string] {
const n = parseInt(groupId.match(/\d+/)?.[0] || '999');
return [Number.isFinite(n) ? n : 999, groupId];
}
function buildColumns(queue: IssueQueue): KanbanColumn<QueueBoardItem>[] {
const entries = Object.entries(queue.grouped_items || {});
entries.sort(([a], [b]) => {
const [an, aid] = groupSortKey(a);
const [bn, bid] = groupSortKey(b);
if (an !== bn) return an - bn;
return aid.localeCompare(bid);
});
return entries.map(([groupId, items]) => {
const sorted = [...(items || [])].sort((a, b) => (a.execution_order || 0) - (b.execution_order || 0));
const mapped = sorted.map((it) => ({
...it,
id: it.item_id,
title: `${it.issue_id} · ${it.solution_id}`,
status: it.status,
}));
return {
id: groupId,
title: groupId,
items: mapped,
icon: <LayoutGrid className="w-4 h-4" />,
};
});
}
function applyDrag(columns: KanbanColumn<QueueBoardItem>[], result: DropResult): KanbanColumn<QueueBoardItem>[] {
if (!result.destination) return columns;
const { source, destination, draggableId } = result;
const next = columns.map((c) => ({ ...c, items: [...c.items] }));
const src = next.find((c) => c.id === source.droppableId);
const dst = next.find((c) => c.id === destination.droppableId);
if (!src || !dst) return columns;
const srcIndex = src.items.findIndex((i) => i.id === draggableId);
if (srcIndex === -1) return columns;
const [moved] = src.items.splice(srcIndex, 1);
if (!moved) return columns;
dst.items.splice(destination.index, 0, moved);
return next;
}
export function QueueBoard({
queue,
onItemClick,
className,
}: {
queue: IssueQueue;
onItemClick?: (item: QueueItem) => void;
className?: string;
}) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { reorderQueueGroup, moveQueueItem, isReordering, isMoving } = useQueueMutations();
const baseColumns = useMemo(() => buildColumns(queue), [queue]);
const [columns, setColumns] = useState<KanbanColumn<QueueBoardItem>[]>(baseColumns);
useEffect(() => {
setColumns(baseColumns);
}, [baseColumns]);
const handleDragEnd = useCallback(
async (result: DropResult, sourceColumn: string, destColumn: string) => {
if (!projectPath) return;
if (!result.destination) return;
if (sourceColumn === destColumn && result.source.index === result.destination.index) return;
try {
const nextColumns = applyDrag(columns, result);
setColumns(nextColumns);
const itemId = result.draggableId;
if (sourceColumn === destColumn) {
const column = nextColumns.find((c) => c.id === sourceColumn);
const nextOrder = (column?.items ?? []).map((i) => i.item_id);
await reorderQueueGroup(sourceColumn, nextOrder);
} else {
await moveQueueItem(itemId, destColumn, result.destination.index);
}
} catch (e) {
// Revert by resetting to server-derived columns
setColumns(baseColumns);
}
},
[baseColumns, columns, moveQueueItem, projectPath, reorderQueueGroup]
);
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="gap-1">
{formatMessage({ id: 'issues.queue.stats.executionGroups' })}
</Badge>
{(isReordering || isMoving) && (
<span>
{formatMessage({ id: 'common.status.running' })}
</span>
)}
</div>
<KanbanBoard<QueueBoardItem>
columns={columns}
onDragEnd={handleDragEnd}
onItemClick={(item) => onItemClick?.(item as unknown as QueueItem)}
emptyColumnMessage={formatMessage({ id: 'issues.queue.empty' })}
renderItem={(item, provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onClick={() => onItemClick?.(item as unknown as QueueItem)}
className={cn(
'p-3 bg-card border border-border rounded-lg shadow-sm cursor-pointer',
'hover:shadow-md hover:border-primary/50 transition-all',
item.status === 'blocked' && 'border-destructive/50 bg-destructive/5',
item.status === 'failed' && 'border-destructive/50 bg-destructive/5',
item.status === 'executing' && 'border-primary/40'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-mono text-muted-foreground">{item.item_id}</div>
<div className="text-sm font-medium text-foreground truncate">
{item.issue_id} · {item.solution_id}
</div>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{formatMessage({ id: `issues.queue.status.${item.status}` })}
</Badge>
</div>
{item.depends_on?.length ? (
<div className="mt-2 text-xs text-muted-foreground">
{formatMessage({ id: 'issues.solution.overview.dependencies' })}: {item.depends_on.length}
</div>
) : null}
</div>
)}
/>
</div>
);
}
export default QueueBoard;

View File

@@ -51,8 +51,7 @@ export function QueueCard({
}: QueueCardProps) {
const { formatMessage } = useIntl();
// Use "current" for queue ID display
const queueId = 'current';
const queueId = queue.id;
// Calculate item counts
const taskCount = queue.tasks?.length || 0;
@@ -88,7 +87,7 @@ export function QueueCard({
{formatMessage({ id: 'issues.queue.title' })}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{queueId.substring(0, 20)}{queueId.length > 20 ? '...' : ''}
{(queueId || 'legacy').substring(0, 20)}{(queueId || 'legacy').length > 20 ? '...' : ''}
</p>
</div>
</div>
@@ -102,6 +101,7 @@ export function QueueCard({
<QueueActions
queue={queue}
queueId={queueId}
isActive={isActive}
onActivate={onActivate}
onDeactivate={onDeactivate}

View File

@@ -0,0 +1,290 @@
// ========================================
// QueueExecuteInSession
// ========================================
// Minimal “execution plane” for queue items:
// pick/create a PTY session and submit a generated prompt to it.
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { Plus, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { useIssues } from '@/hooks';
import {
createCliSession,
executeInCliSession,
fetchCliSessions,
type CliSession,
type QueueItem,
} from '@/lib/api';
import { useCliSessionStore } from '@/stores/cliSessionStore';
type ToolName = 'claude' | 'codex' | 'gemini';
type ResumeStrategy = 'nativeResume' | 'promptConcat';
function buildQueueItemPrompt(item: QueueItem, issue: any | undefined): string {
const lines: string[] = [];
lines.push(`Queue Item: ${item.item_id}`);
lines.push(`Issue: ${item.issue_id}`);
lines.push(`Solution: ${item.solution_id}`);
if (item.task_id) lines.push(`Task: ${item.task_id}`);
lines.push('');
if (issue) {
if (issue.title) lines.push(`Title: ${issue.title}`);
if (issue.context) {
lines.push('');
lines.push('Context:');
lines.push(String(issue.context));
}
const solution = Array.isArray(issue.solutions)
? issue.solutions.find((s: any) => s?.id === item.solution_id)
: undefined;
if (solution) {
lines.push('');
lines.push('Solution Description:');
if (solution.description) lines.push(String(solution.description));
if (solution.approach) {
lines.push('');
lines.push('Approach:');
lines.push(String(solution.approach));
}
// Best-effort: if the solution has embedded tasks, include the matched task.
const tasks = Array.isArray(solution.tasks) ? solution.tasks : [];
const task = item.task_id ? tasks.find((t: any) => t?.id === item.task_id) : undefined;
if (task) {
lines.push('');
lines.push('Task:');
if (task.title) lines.push(`- ${task.title}`);
if (task.description) lines.push(String(task.description));
}
}
}
lines.push('');
lines.push('Instruction:');
lines.push(
'Implement the above queue item in this repository. Prefer small, testable changes; run relevant tests; report blockers if any.'
);
return lines.join('\n');
}
export function QueueExecuteInSession({ item, className }: { item: QueueItem; className?: string }) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { issues } = useIssues();
const issue = useMemo(() => issues.find((i) => i.id === item.issue_id) as any, [issues, item.issue_id]);
const sessionsByKey = useCliSessionStore((s) => s.sessions);
const setSessions = useCliSessionStore((s) => s.setSessions);
const upsertSession = useCliSessionStore((s) => s.upsertSession);
const sessions = useMemo(
() => Object.values(sessionsByKey).sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
[sessionsByKey]
);
const [selectedSessionKey, setSelectedSessionKey] = useState<string>('');
const [tool, setTool] = useState<ToolName>('claude');
const [mode, setMode] = useState<'analysis' | 'write'>('write');
const [resumeStrategy, setResumeStrategy] = useState<ResumeStrategy>('nativeResume');
const [isLoading, setIsLoading] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastExecution, setLastExecution] = useState<{ executionId: string; command: string } | null>(null);
const refreshSessions = async () => {
setIsLoading(true);
setError(null);
try {
const r = await fetchCliSessions();
setSessions(r.sessions as unknown as CliSession[]);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void refreshSessions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (selectedSessionKey) return;
if (sessions.length === 0) return;
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
}, [sessions, selectedSessionKey]);
const ensureSession = async (): Promise<string> => {
if (selectedSessionKey) return selectedSessionKey;
if (!projectPath) throw new Error('No project path selected');
const created = await createCliSession({
workingDir: projectPath,
preferredShell: 'bash',
resumeKey: item.issue_id,
});
upsertSession(created.session as unknown as CliSession);
setSelectedSessionKey(created.session.sessionKey);
return created.session.sessionKey;
};
const handleCreateSession = async () => {
setError(null);
try {
if (!projectPath) throw new Error('No project path selected');
const created = await createCliSession({
workingDir: projectPath,
preferredShell: 'bash',
resumeKey: item.issue_id,
});
upsertSession(created.session as unknown as CliSession);
setSelectedSessionKey(created.session.sessionKey);
await refreshSessions();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handleExecute = async () => {
setIsExecuting(true);
setError(null);
setLastExecution(null);
try {
const sessionKey = await ensureSession();
const prompt = buildQueueItemPrompt(item, issue);
const result = await executeInCliSession(sessionKey, {
tool,
prompt,
mode,
workingDir: projectPath,
category: 'user',
resumeKey: item.issue_id,
resumeStrategy,
});
setLastExecution({ executionId: result.executionId, command: result.command });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsExecuting(false);
}
};
return (
<div className={cn('space-y-3', className)}>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'issues.queue.exec.title' })}
</h3>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refreshSessions}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
{formatMessage({ id: 'issues.terminal.session.refresh' })}
</Button>
<Button variant="outline" size="sm" onClick={handleCreateSession} className="gap-2">
<Plus className="h-4 w-4" />
{formatMessage({ id: 'issues.terminal.session.new' })}
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.session.select' })}
</label>
<Select value={selectedSessionKey} onValueChange={(v) => setSelectedSessionKey(v)}>
<SelectTrigger>
<SelectValue placeholder={formatMessage({ id: 'issues.terminal.session.none' })} />
</SelectTrigger>
<SelectContent>
{sessions.length === 0 ? (
<SelectItem value="" disabled>
{formatMessage({ id: 'issues.terminal.session.none' })}
</SelectItem>
) : (
sessions.map((s) => (
<SelectItem key={s.sessionKey} value={s.sessionKey}>
{(s.tool || 'cli') + ' · ' + s.sessionKey}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.tool' })}
</label>
<Select value={tool} onValueChange={(v) => setTool(v as ToolName)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">claude</SelectItem>
<SelectItem value="codex">codex</SelectItem>
<SelectItem value="gemini">gemini</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.mode' })}
</label>
<Select value={mode} onValueChange={(v) => setMode(v as 'analysis' | 'write')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="analysis">analysis</SelectItem>
<SelectItem value="write">write</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.resumeStrategy' })}
</label>
<Select value={resumeStrategy} onValueChange={(v) => setResumeStrategy(v as ResumeStrategy)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nativeResume">nativeResume</SelectItem>
<SelectItem value="promptConcat">promptConcat</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{lastExecution && (
<div className="text-xs text-muted-foreground font-mono break-all">
{lastExecution.executionId}
</div>
)}
<div className="flex items-center justify-end">
<Button onClick={handleExecute} disabled={isExecuting || !projectPath} className="gap-2">
{formatMessage({ id: 'issues.terminal.exec.run' })}
</Button>
</div>
</div>
);
}

View File

@@ -5,10 +5,12 @@
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle } from 'lucide-react';
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle, Terminal } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { QueueExecuteInSession } from '@/components/issue/queue/QueueExecuteInSession';
import { IssueTerminalTab } from '@/components/issue/hub/IssueTerminalTab';
import { useIssueQueue } from '@/hooks';
import { cn } from '@/lib/utils';
import type { QueueItem } from '@/lib/api';
@@ -20,7 +22,7 @@ export interface SolutionDrawerProps {
onClose: () => void;
}
type TabValue = 'overview' | 'tasks' | 'json';
type TabValue = 'overview' | 'tasks' | 'terminal' | 'json';
// ========== Status Configuration ==========
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
@@ -134,6 +136,10 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
<CheckCircle className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.solution.tabs.tasks' })}
</TabsTrigger>
<TabsTrigger value="terminal" className="flex-1">
<Terminal className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.solution.tabs.terminal' })}
</TabsTrigger>
<TabsTrigger value="json" className="flex-1">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.solution.tabs.json' })}
@@ -170,6 +176,9 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
</div>
</div>
{/* Execute in Session */}
<QueueExecuteInSession item={item} />
{/* Dependencies */}
{item.depends_on && item.depends_on.length > 0 && (
<div>
@@ -244,6 +253,11 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
)}
</TabsContent>
{/* Terminal Tab */}
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
<IssueTerminalTab issueId={issueId} />
</TabsContent>
{/* JSON Tab */}
<TabsContent value="json" className="mt-4 pb-6 focus-visible:outline-none">
<pre className="p-4 bg-muted rounded-md overflow-x-auto text-xs">