refactor(agent-dispatcher): split into 4 focused modules (SOLID Phase 2)
This commit is contained in:
parent
54b1c60810
commit
450756f540
7 changed files with 326 additions and 252 deletions
|
|
@ -205,7 +205,6 @@ import {
|
|||
isSidecarAlive,
|
||||
setSidecarAlive,
|
||||
waitForPendingPersistence,
|
||||
detectWorktreeFromCwd,
|
||||
} from './agent-dispatcher';
|
||||
|
||||
// Stop any previous dispatcher between tests so `unlistenMsg` is null and start works
|
||||
|
|
@ -624,32 +623,6 @@ describe('agent-dispatcher', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('detectWorktreeFromCwd', () => {
|
||||
it('detects Claude Code worktree path', () => {
|
||||
const result = detectWorktreeFromCwd('/home/user/project/.claude/worktrees/my-session');
|
||||
expect(result).toBe('/.claude/worktrees/my-session');
|
||||
});
|
||||
|
||||
it('detects Codex worktree path', () => {
|
||||
const result = detectWorktreeFromCwd('/home/user/project/.codex/worktrees/task-1');
|
||||
expect(result).toBe('/.codex/worktrees/task-1');
|
||||
});
|
||||
|
||||
it('detects Cursor worktree path', () => {
|
||||
const result = detectWorktreeFromCwd('/home/user/project/.cursor/worktrees/feature-x');
|
||||
expect(result).toBe('/.cursor/worktrees/feature-x');
|
||||
});
|
||||
|
||||
it('returns null for non-worktree CWD', () => {
|
||||
expect(detectWorktreeFromCwd('/home/user/project')).toBeNull();
|
||||
expect(detectWorktreeFromCwd('/tmp/work')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(detectWorktreeFromCwd('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('init event CWD worktree detection', () => {
|
||||
beforeEach(async () => {
|
||||
await startAgentDispatcher();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// Agent Dispatcher — connects sidecar bridge events to agent store
|
||||
// Single listener that routes sidecar messages to the correct agent session
|
||||
// Thin coordinator that routes sidecar messages to specialized modules
|
||||
|
||||
import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge';
|
||||
import { adaptMessage } from './adapters/message-adapters';
|
||||
import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages';
|
||||
import type { ProviderId } from './providers/types';
|
||||
import {
|
||||
updateAgentStatus,
|
||||
setAgentSdkSessionId,
|
||||
|
|
@ -13,46 +12,36 @@ import {
|
|||
updateAgentCost,
|
||||
getAgentSessions,
|
||||
getAgentSession,
|
||||
createAgentSession,
|
||||
findChildByToolUseId,
|
||||
} from './stores/agents.svelte';
|
||||
import { addPane, getPanes } from './stores/layout.svelte';
|
||||
import { notify } from './stores/notifications.svelte';
|
||||
import {
|
||||
saveProjectAgentState,
|
||||
saveAgentMessages,
|
||||
saveSessionMetric,
|
||||
type AgentMessageRecord,
|
||||
} from './adapters/groups-bridge';
|
||||
import { tel } from './adapters/telemetry-bridge';
|
||||
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
|
||||
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
|
||||
import { extractWritePaths, extractWorktreePath } from './utils/tool-files';
|
||||
import { hasAutoAnchored, markAutoAnchored, addAnchors, getAnchorSettings } from './stores/anchors.svelte';
|
||||
import { selectAutoAnchors, serializeAnchorsForInjection } from './utils/anchor-serializer';
|
||||
import type { SessionAnchor } from './types/anchors';
|
||||
import { getEnabledProjects } from './stores/workspace.svelte';
|
||||
import { hasAutoAnchored, markAutoAnchored } from './stores/anchors.svelte';
|
||||
import { detectWorktreeFromCwd } from './utils/worktree-detection';
|
||||
import {
|
||||
getSessionProjectId,
|
||||
getSessionProvider,
|
||||
recordSessionStart,
|
||||
persistSessionForProject,
|
||||
clearSessionMaps,
|
||||
} from './utils/session-persistence';
|
||||
import { triggerAutoAnchor } from './utils/auto-anchoring';
|
||||
import {
|
||||
isSubagentToolCall,
|
||||
getChildPaneId,
|
||||
spawnSubagentPane,
|
||||
clearSubagentRoutes,
|
||||
} from './utils/subagent-router';
|
||||
|
||||
// Re-export public API consumed by other modules
|
||||
export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence';
|
||||
export { detectWorktreeFromCwd } from './utils/worktree-detection';
|
||||
|
||||
let unlistenMsg: (() => void) | null = null;
|
||||
let unlistenExit: (() => void) | null = null;
|
||||
|
||||
// Map sessionId -> projectId for persistence routing
|
||||
const sessionProjectMap = new Map<string, string>();
|
||||
|
||||
// Map sessionId -> provider for message adapter routing
|
||||
const sessionProviderMap = new Map<string, ProviderId>();
|
||||
|
||||
// Map sessionId -> start timestamp for metrics
|
||||
const sessionStartTimes = new Map<string, number>();
|
||||
|
||||
// In-flight persistence counter — prevents teardown from racing with async saves
|
||||
let pendingPersistCount = 0;
|
||||
|
||||
export function registerSessionProject(sessionId: string, projectId: string, provider: ProviderId = 'claude'): void {
|
||||
sessionProjectMap.set(sessionId, projectId);
|
||||
sessionProviderMap.set(sessionId, provider);
|
||||
}
|
||||
|
||||
// Sidecar liveness — checked by UI components
|
||||
let sidecarAlive = true;
|
||||
|
||||
|
|
@ -86,7 +75,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
switch (msg.type) {
|
||||
case 'agent_started':
|
||||
updateAgentStatus(sessionId, 'running');
|
||||
sessionStartTimes.set(sessionId, Date.now());
|
||||
recordSessionStart(sessionId);
|
||||
tel.info('agent_started', { sessionId });
|
||||
break;
|
||||
|
||||
|
|
@ -151,14 +140,8 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
// Tool names that indicate a subagent spawn
|
||||
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']);
|
||||
|
||||
// Map toolUseId -> child session pane id for routing
|
||||
const toolUseToChildPane = new Map<string, string>();
|
||||
|
||||
function handleAgentEvent(sessionId: string, event: Record<string, unknown>): void {
|
||||
const provider = sessionProviderMap.get(sessionId) ?? 'claude';
|
||||
const provider = getSessionProvider(sessionId);
|
||||
const messages = adaptMessage(provider, event);
|
||||
|
||||
// Route messages with parentId to the appropriate child pane
|
||||
|
|
@ -166,8 +149,8 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
const childBuckets = new Map<string, typeof messages>();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.parentId && toolUseToChildPane.has(msg.parentId)) {
|
||||
const childPaneId = toolUseToChildPane.get(msg.parentId)!;
|
||||
const childPaneId = msg.parentId ? getChildPaneId(msg.parentId) : undefined;
|
||||
if (childPaneId) {
|
||||
if (!childBuckets.has(childPaneId)) childBuckets.set(childPaneId, []);
|
||||
childBuckets.get(childPaneId)!.push(msg);
|
||||
} else {
|
||||
|
|
@ -182,8 +165,7 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
const init = msg.content as InitContent;
|
||||
setAgentSdkSessionId(sessionId, init.sessionId);
|
||||
setAgentModel(sessionId, init.model);
|
||||
// CWD-based worktree detection: if init CWD contains a worktree path pattern,
|
||||
// register it for conflict suppression (agents in different worktrees don't conflict)
|
||||
// CWD-based worktree detection for conflict suppression
|
||||
if (init.cwd) {
|
||||
const wtPath = detectWorktreeFromCwd(init.cwd);
|
||||
if (wtPath) {
|
||||
|
|
@ -195,17 +177,16 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
|
||||
case 'tool_call': {
|
||||
const tc = msg.content as ToolCallContent;
|
||||
if (SUBAGENT_TOOL_NAMES.has(tc.name)) {
|
||||
if (isSubagentToolCall(tc.name)) {
|
||||
spawnSubagentPane(sessionId, tc);
|
||||
}
|
||||
// Health: record tool start
|
||||
const projId = sessionProjectMap.get(sessionId);
|
||||
const projId = getSessionProjectId(sessionId);
|
||||
if (projId) {
|
||||
recordActivity(projId, tc.name);
|
||||
// Worktree tracking: detect worktree isolation on Agent/Task calls or EnterWorktree
|
||||
// Worktree tracking
|
||||
const wtPath = extractWorktreePath(tc);
|
||||
if (wtPath) {
|
||||
// The child session (or this session) is entering a worktree
|
||||
setSessionWorktree(sessionId, wtPath);
|
||||
}
|
||||
// Conflict detection: track file writes
|
||||
|
|
@ -223,8 +204,9 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
|
||||
case 'compaction': {
|
||||
// Auto-anchor on first compaction for this project
|
||||
const compactProjId = sessionProjectMap.get(sessionId);
|
||||
const compactProjId = getSessionProjectId(sessionId);
|
||||
if (compactProjId && !hasAutoAnchored(compactProjId)) {
|
||||
markAutoAnchored(compactProjId);
|
||||
const session = getAgentSession(sessionId);
|
||||
if (session) {
|
||||
triggerAutoAnchor(compactProjId, session.messages, session.prompt);
|
||||
|
|
@ -259,7 +241,7 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`);
|
||||
}
|
||||
// Health: record token snapshot + tool done
|
||||
const costProjId = sessionProjectMap.get(sessionId);
|
||||
const costProjId = getSessionProjectId(sessionId);
|
||||
if (costProjId) {
|
||||
recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd);
|
||||
recordToolDone(costProjId);
|
||||
|
|
@ -275,7 +257,7 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
|
||||
// Health: record general activity for non-tool messages (text, thinking)
|
||||
if (mainMessages.length > 0) {
|
||||
const actProjId = sessionProjectMap.get(sessionId);
|
||||
const actProjId = getSessionProjectId(sessionId);
|
||||
if (actProjId) {
|
||||
const hasToolResult = mainMessages.some(m => m.type === 'tool_result');
|
||||
if (hasToolResult) recordToolDone(actProjId);
|
||||
|
|
@ -308,176 +290,6 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
}
|
||||
}
|
||||
|
||||
function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void {
|
||||
// Don't create duplicate pane for same tool_use
|
||||
if (toolUseToChildPane.has(tc.toolUseId)) return;
|
||||
const existing = findChildByToolUseId(parentSessionId, tc.toolUseId);
|
||||
if (existing) {
|
||||
toolUseToChildPane.set(tc.toolUseId, existing.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const childId = crypto.randomUUID();
|
||||
const prompt = typeof tc.input === 'object' && tc.input !== null
|
||||
? (tc.input as Record<string, unknown>).prompt as string ?? tc.name
|
||||
: tc.name;
|
||||
const label = typeof tc.input === 'object' && tc.input !== null
|
||||
? (tc.input as Record<string, unknown>).name as string ?? tc.name
|
||||
: tc.name;
|
||||
|
||||
// Register routing
|
||||
toolUseToChildPane.set(tc.toolUseId, childId);
|
||||
|
||||
// Create agent session with parent link
|
||||
createAgentSession(childId, prompt, {
|
||||
sessionId: parentSessionId,
|
||||
toolUseId: tc.toolUseId,
|
||||
});
|
||||
updateAgentStatus(childId, 'running');
|
||||
|
||||
// For project-scoped sessions, subagents render in TeamAgentsPanel (no layout pane)
|
||||
// For non-project sessions (detached mode), create a layout pane
|
||||
if (!sessionProjectMap.has(parentSessionId)) {
|
||||
const parentPane = getPanes().find(p => p.id === parentSessionId);
|
||||
const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`;
|
||||
addPane({
|
||||
id: childId,
|
||||
type: 'agent',
|
||||
title: `Sub: ${label}`,
|
||||
group: groupName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until all in-flight persistence operations complete */
|
||||
export async function waitForPendingPersistence(): Promise<void> {
|
||||
while (pendingPersistCount > 0) {
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist session state + messages to SQLite for the project that owns this session */
|
||||
async function persistSessionForProject(sessionId: string): Promise<void> {
|
||||
const projectId = sessionProjectMap.get(sessionId);
|
||||
if (!projectId) return; // Not a project-scoped session
|
||||
|
||||
const session = getAgentSession(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
pendingPersistCount++;
|
||||
try {
|
||||
// Save agent state
|
||||
await saveProjectAgentState({
|
||||
project_id: projectId,
|
||||
last_session_id: sessionId,
|
||||
sdk_session_id: session.sdkSessionId ?? null,
|
||||
status: session.status,
|
||||
cost_usd: session.costUsd,
|
||||
input_tokens: session.inputTokens,
|
||||
output_tokens: session.outputTokens,
|
||||
last_prompt: session.prompt,
|
||||
updated_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
// Save messages (use seconds to match session.rs convention)
|
||||
const nowSecs = Math.floor(Date.now() / 1000);
|
||||
const records: AgentMessageRecord[] = session.messages.map((m, i) => ({
|
||||
id: i,
|
||||
session_id: sessionId,
|
||||
project_id: projectId,
|
||||
sdk_session_id: session.sdkSessionId ?? null,
|
||||
message_type: m.type,
|
||||
content: JSON.stringify(m.content),
|
||||
parent_id: m.parentId ?? null,
|
||||
created_at: nowSecs,
|
||||
}));
|
||||
|
||||
if (records.length > 0) {
|
||||
await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records);
|
||||
}
|
||||
|
||||
// Persist session metric for historical tracking
|
||||
const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length;
|
||||
const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000);
|
||||
await saveSessionMetric({
|
||||
project_id: projectId,
|
||||
session_id: sessionId,
|
||||
start_time: Math.floor(startTime / 1000),
|
||||
end_time: nowSecs,
|
||||
peak_tokens: session.inputTokens + session.outputTokens,
|
||||
turn_count: session.numTurns,
|
||||
tool_call_count: toolCallCount,
|
||||
cost_usd: session.costUsd,
|
||||
model: session.model ?? null,
|
||||
status: session.status,
|
||||
error_message: session.error ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to persist agent session:', e);
|
||||
} finally {
|
||||
pendingPersistCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-anchor first N turns on first compaction event for a project */
|
||||
function triggerAutoAnchor(
|
||||
projectId: string,
|
||||
messages: import('./adapters/claude-messages').AgentMessage[],
|
||||
sessionPrompt: string,
|
||||
): void {
|
||||
markAutoAnchored(projectId);
|
||||
|
||||
const project = getEnabledProjects().find(p => p.id === projectId);
|
||||
const settings = getAnchorSettings(project?.anchorBudgetScale);
|
||||
const { turns, totalTokens } = selectAutoAnchors(
|
||||
messages,
|
||||
sessionPrompt,
|
||||
settings.anchorTurns,
|
||||
settings.anchorTokenBudget,
|
||||
);
|
||||
|
||||
if (turns.length === 0) return;
|
||||
|
||||
const nowSecs = Math.floor(Date.now() / 1000);
|
||||
const anchors: SessionAnchor[] = turns.map((turn, i) => {
|
||||
const content = serializeAnchorsForInjection([turn], settings.anchorTokenBudget);
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
messageId: `turn-${turn.index}`,
|
||||
anchorType: 'auto' as const,
|
||||
content: content,
|
||||
estimatedTokens: turn.estimatedTokens,
|
||||
turnIndex: turn.index,
|
||||
createdAt: nowSecs,
|
||||
};
|
||||
});
|
||||
|
||||
addAnchors(projectId, anchors);
|
||||
tel.info('auto_anchor_created', {
|
||||
projectId,
|
||||
anchorCount: anchors.length,
|
||||
totalTokens,
|
||||
});
|
||||
notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`);
|
||||
}
|
||||
|
||||
// Worktree path patterns for various providers
|
||||
const WORKTREE_CWD_PATTERNS = [
|
||||
/\/\.claude\/worktrees\/([^/]+)/, // Claude Code: <repo>/.claude/worktrees/<name>/
|
||||
/\/\.codex\/worktrees\/([^/]+)/, // Codex
|
||||
/\/\.cursor\/worktrees\/([^/]+)/, // Cursor
|
||||
];
|
||||
|
||||
/** Extract worktree path from CWD if it matches a known worktree pattern */
|
||||
export function detectWorktreeFromCwd(cwd: string): string | null {
|
||||
for (const pattern of WORKTREE_CWD_PATTERNS) {
|
||||
const match = cwd.match(pattern);
|
||||
if (match) return match[0]; // Return the full worktree path segment
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function stopAgentDispatcher(): void {
|
||||
if (unlistenMsg) {
|
||||
unlistenMsg();
|
||||
|
|
@ -488,8 +300,6 @@ export function stopAgentDispatcher(): void {
|
|||
unlistenExit = null;
|
||||
}
|
||||
// Clear routing maps to prevent unbounded memory growth
|
||||
toolUseToChildPane.clear();
|
||||
sessionProjectMap.clear();
|
||||
sessionProviderMap.clear();
|
||||
sessionStartTimes.clear();
|
||||
clearSubagentRoutes();
|
||||
clearSessionMaps();
|
||||
}
|
||||
|
|
|
|||
51
v2/src/lib/utils/auto-anchoring.ts
Normal file
51
v2/src/lib/utils/auto-anchoring.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Auto-anchoring — creates session anchors on first compaction event
|
||||
// Extracted from agent-dispatcher.ts (SRP: anchor creation concern)
|
||||
|
||||
import type { AgentMessage } from '../adapters/claude-messages';
|
||||
import type { SessionAnchor } from '../types/anchors';
|
||||
import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte';
|
||||
import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer';
|
||||
import { getEnabledProjects } from '../stores/workspace.svelte';
|
||||
import { tel } from '../adapters/telemetry-bridge';
|
||||
import { notify } from '../stores/notifications.svelte';
|
||||
|
||||
/** Auto-anchor first N turns on first compaction event for a project */
|
||||
export function triggerAutoAnchor(
|
||||
projectId: string,
|
||||
messages: AgentMessage[],
|
||||
sessionPrompt: string,
|
||||
): void {
|
||||
const project = getEnabledProjects().find(p => p.id === projectId);
|
||||
const settings = getAnchorSettings(project?.anchorBudgetScale);
|
||||
const { turns, totalTokens } = selectAutoAnchors(
|
||||
messages,
|
||||
sessionPrompt,
|
||||
settings.anchorTurns,
|
||||
settings.anchorTokenBudget,
|
||||
);
|
||||
|
||||
if (turns.length === 0) return;
|
||||
|
||||
const nowSecs = Math.floor(Date.now() / 1000);
|
||||
const anchors: SessionAnchor[] = turns.map((turn) => {
|
||||
const content = serializeAnchorsForInjection([turn], settings.anchorTokenBudget);
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
messageId: `turn-${turn.index}`,
|
||||
anchorType: 'auto' as const,
|
||||
content: content,
|
||||
estimatedTokens: turn.estimatedTokens,
|
||||
turnIndex: turn.index,
|
||||
createdAt: nowSecs,
|
||||
};
|
||||
});
|
||||
|
||||
addAnchors(projectId, anchors);
|
||||
tel.info('auto_anchor_created', {
|
||||
projectId,
|
||||
anchorCount: anchors.length,
|
||||
totalTokens,
|
||||
});
|
||||
notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`);
|
||||
}
|
||||
117
v2/src/lib/utils/session-persistence.ts
Normal file
117
v2/src/lib/utils/session-persistence.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Session persistence — maps session IDs to projects/providers and persists state to SQLite
|
||||
// Extracted from agent-dispatcher.ts (SRP: persistence concern)
|
||||
|
||||
import type { ProviderId } from '../providers/types';
|
||||
import { getAgentSession } from '../stores/agents.svelte';
|
||||
import {
|
||||
saveProjectAgentState,
|
||||
saveAgentMessages,
|
||||
saveSessionMetric,
|
||||
type AgentMessageRecord,
|
||||
} from '../adapters/groups-bridge';
|
||||
|
||||
// Map sessionId -> projectId for persistence routing
|
||||
const sessionProjectMap = new Map<string, string>();
|
||||
|
||||
// Map sessionId -> provider for message adapter routing
|
||||
const sessionProviderMap = new Map<string, ProviderId>();
|
||||
|
||||
// Map sessionId -> start timestamp for metrics
|
||||
const sessionStartTimes = new Map<string, number>();
|
||||
|
||||
// In-flight persistence counter — prevents teardown from racing with async saves
|
||||
let pendingPersistCount = 0;
|
||||
|
||||
export function registerSessionProject(sessionId: string, projectId: string, provider: ProviderId = 'claude'): void {
|
||||
sessionProjectMap.set(sessionId, projectId);
|
||||
sessionProviderMap.set(sessionId, provider);
|
||||
}
|
||||
|
||||
export function getSessionProjectId(sessionId: string): string | undefined {
|
||||
return sessionProjectMap.get(sessionId);
|
||||
}
|
||||
|
||||
export function getSessionProvider(sessionId: string): ProviderId {
|
||||
return sessionProviderMap.get(sessionId) ?? 'claude';
|
||||
}
|
||||
|
||||
export function recordSessionStart(sessionId: string): void {
|
||||
sessionStartTimes.set(sessionId, Date.now());
|
||||
}
|
||||
|
||||
/** Wait until all in-flight persistence operations complete */
|
||||
export async function waitForPendingPersistence(): Promise<void> {
|
||||
while (pendingPersistCount > 0) {
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist session state + messages to SQLite for the project that owns this session */
|
||||
export async function persistSessionForProject(sessionId: string): Promise<void> {
|
||||
const projectId = sessionProjectMap.get(sessionId);
|
||||
if (!projectId) return; // Not a project-scoped session
|
||||
|
||||
const session = getAgentSession(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
pendingPersistCount++;
|
||||
try {
|
||||
// Save agent state
|
||||
await saveProjectAgentState({
|
||||
project_id: projectId,
|
||||
last_session_id: sessionId,
|
||||
sdk_session_id: session.sdkSessionId ?? null,
|
||||
status: session.status,
|
||||
cost_usd: session.costUsd,
|
||||
input_tokens: session.inputTokens,
|
||||
output_tokens: session.outputTokens,
|
||||
last_prompt: session.prompt,
|
||||
updated_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
// Save messages (use seconds to match session.rs convention)
|
||||
const nowSecs = Math.floor(Date.now() / 1000);
|
||||
const records: AgentMessageRecord[] = session.messages.map((m, i) => ({
|
||||
id: i,
|
||||
session_id: sessionId,
|
||||
project_id: projectId,
|
||||
sdk_session_id: session.sdkSessionId ?? null,
|
||||
message_type: m.type,
|
||||
content: JSON.stringify(m.content),
|
||||
parent_id: m.parentId ?? null,
|
||||
created_at: nowSecs,
|
||||
}));
|
||||
|
||||
if (records.length > 0) {
|
||||
await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records);
|
||||
}
|
||||
|
||||
// Persist session metric for historical tracking
|
||||
const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length;
|
||||
const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000);
|
||||
await saveSessionMetric({
|
||||
project_id: projectId,
|
||||
session_id: sessionId,
|
||||
start_time: Math.floor(startTime / 1000),
|
||||
end_time: nowSecs,
|
||||
peak_tokens: session.inputTokens + session.outputTokens,
|
||||
turn_count: session.numTurns,
|
||||
tool_call_count: toolCallCount,
|
||||
cost_usd: session.costUsd,
|
||||
model: session.model ?? null,
|
||||
status: session.status,
|
||||
error_message: session.error ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to persist agent session:', e);
|
||||
} finally {
|
||||
pendingPersistCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all session maps — called on dispatcher shutdown */
|
||||
export function clearSessionMaps(): void {
|
||||
sessionProjectMap.clear();
|
||||
sessionProviderMap.clear();
|
||||
sessionStartTimes.clear();
|
||||
}
|
||||
78
v2/src/lib/utils/subagent-router.ts
Normal file
78
v2/src/lib/utils/subagent-router.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Subagent routing — manages subagent pane creation and message routing
|
||||
// Extracted from agent-dispatcher.ts (SRP: subagent lifecycle concern)
|
||||
|
||||
import type { ToolCallContent } from '../adapters/claude-messages';
|
||||
import {
|
||||
createAgentSession,
|
||||
updateAgentStatus,
|
||||
findChildByToolUseId,
|
||||
} from '../stores/agents.svelte';
|
||||
import { addPane, getPanes } from '../stores/layout.svelte';
|
||||
import { getSessionProjectId } from './session-persistence';
|
||||
|
||||
// Tool names that indicate a subagent spawn
|
||||
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']);
|
||||
|
||||
// Map toolUseId -> child session pane id for routing
|
||||
const toolUseToChildPane = new Map<string, string>();
|
||||
|
||||
/** Check if a tool call is a subagent spawn */
|
||||
export function isSubagentToolCall(toolName: string): boolean {
|
||||
return SUBAGENT_TOOL_NAMES.has(toolName);
|
||||
}
|
||||
|
||||
/** Get the child pane ID for a given toolUseId */
|
||||
export function getChildPaneId(toolUseId: string): string | undefined {
|
||||
return toolUseToChildPane.get(toolUseId);
|
||||
}
|
||||
|
||||
/** Check if a toolUseId has been mapped to a child pane */
|
||||
export function hasChildPane(toolUseId: string): boolean {
|
||||
return toolUseToChildPane.has(toolUseId);
|
||||
}
|
||||
|
||||
export function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void {
|
||||
// Don't create duplicate pane for same tool_use
|
||||
if (toolUseToChildPane.has(tc.toolUseId)) return;
|
||||
const existing = findChildByToolUseId(parentSessionId, tc.toolUseId);
|
||||
if (existing) {
|
||||
toolUseToChildPane.set(tc.toolUseId, existing.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const childId = crypto.randomUUID();
|
||||
const prompt = typeof tc.input === 'object' && tc.input !== null
|
||||
? (tc.input as Record<string, unknown>).prompt as string ?? tc.name
|
||||
: tc.name;
|
||||
const label = typeof tc.input === 'object' && tc.input !== null
|
||||
? (tc.input as Record<string, unknown>).name as string ?? tc.name
|
||||
: tc.name;
|
||||
|
||||
// Register routing
|
||||
toolUseToChildPane.set(tc.toolUseId, childId);
|
||||
|
||||
// Create agent session with parent link
|
||||
createAgentSession(childId, prompt, {
|
||||
sessionId: parentSessionId,
|
||||
toolUseId: tc.toolUseId,
|
||||
});
|
||||
updateAgentStatus(childId, 'running');
|
||||
|
||||
// For project-scoped sessions, subagents render in TeamAgentsPanel (no layout pane)
|
||||
// For non-project sessions (detached mode), create a layout pane
|
||||
if (!getSessionProjectId(parentSessionId)) {
|
||||
const parentPane = getPanes().find(p => p.id === parentSessionId);
|
||||
const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`;
|
||||
addPane({
|
||||
id: childId,
|
||||
type: 'agent',
|
||||
title: `Sub: ${label}`,
|
||||
group: groupName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear subagent routing maps — called on dispatcher shutdown */
|
||||
export function clearSubagentRoutes(): void {
|
||||
toolUseToChildPane.clear();
|
||||
}
|
||||
28
v2/src/lib/utils/worktree-detection.test.ts
Normal file
28
v2/src/lib/utils/worktree-detection.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { detectWorktreeFromCwd } from './worktree-detection';
|
||||
|
||||
describe('detectWorktreeFromCwd', () => {
|
||||
it('detects Claude Code worktree path', () => {
|
||||
const result = detectWorktreeFromCwd('/home/user/project/.claude/worktrees/my-session');
|
||||
expect(result).toBe('/.claude/worktrees/my-session');
|
||||
});
|
||||
|
||||
it('detects Codex worktree path', () => {
|
||||
const result = detectWorktreeFromCwd('/home/user/project/.codex/worktrees/task-1');
|
||||
expect(result).toBe('/.codex/worktrees/task-1');
|
||||
});
|
||||
|
||||
it('detects Cursor worktree path', () => {
|
||||
const result = detectWorktreeFromCwd('/home/user/project/.cursor/worktrees/feature-x');
|
||||
expect(result).toBe('/.cursor/worktrees/feature-x');
|
||||
});
|
||||
|
||||
it('returns null for non-worktree CWD', () => {
|
||||
expect(detectWorktreeFromCwd('/home/user/project')).toBeNull();
|
||||
expect(detectWorktreeFromCwd('/tmp/work')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(detectWorktreeFromCwd('')).toBeNull();
|
||||
});
|
||||
});
|
||||
17
v2/src/lib/utils/worktree-detection.ts
Normal file
17
v2/src/lib/utils/worktree-detection.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Worktree path detection — extracts worktree paths from CWD strings
|
||||
// Used by agent-dispatcher for conflict suppression (agents in different worktrees don't conflict)
|
||||
|
||||
const WORKTREE_CWD_PATTERNS = [
|
||||
/\/\.claude\/worktrees\/([^/]+)/, // Claude Code: <repo>/.claude/worktrees/<name>/
|
||||
/\/\.codex\/worktrees\/([^/]+)/, // Codex
|
||||
/\/\.cursor\/worktrees\/([^/]+)/, // Cursor
|
||||
];
|
||||
|
||||
/** Extract worktree path from CWD if it matches a known worktree pattern */
|
||||
export function detectWorktreeFromCwd(cwd: string): string | null {
|
||||
for (const pattern of WORKTREE_CWD_PATTERNS) {
|
||||
const match = cwd.match(pattern);
|
||||
if (match) return match[0]; // Return the full worktree path segment
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue