From 82fb618c764d72f91b7d00014876be26022ff803 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 00:12:10 +0100 Subject: [PATCH] feat(conflicts): add file overlap conflict detection (S-1 Phase 1) Detects when 2+ agent sessions write the same file within a project. New conflicts.svelte.ts store, shared tool-files.ts utility, dispatcher integration, health attention scoring (SCORE_FILE_CONFLICT=70), and UI indicators in ProjectHeader + StatusBar. 170/170 tests pass. --- v2/src/lib/agent-dispatcher.test.ts | 9 + v2/src/lib/agent-dispatcher.ts | 17 +- .../lib/components/StatusBar/StatusBar.svelte | 13 ++ .../components/Workspace/ContextTab.svelte | 36 +--- .../components/Workspace/ProjectHeader.svelte | 16 ++ v2/src/lib/stores/conflicts.svelte.ts | 128 ++++++++++++++ v2/src/lib/stores/conflicts.test.ts | 157 ++++++++++++++++++ v2/src/lib/stores/health.svelte.ts | 14 +- v2/src/lib/stores/workspace.svelte.ts | 2 + v2/src/lib/stores/workspace.test.ts | 4 + v2/src/lib/utils/tool-files.test.ts | 71 ++++++++ v2/src/lib/utils/tool-files.ts | 53 ++++++ 12 files changed, 483 insertions(+), 37 deletions(-) create mode 100644 v2/src/lib/stores/conflicts.svelte.ts create mode 100644 v2/src/lib/stores/conflicts.test.ts create mode 100644 v2/src/lib/utils/tool-files.test.ts create mode 100644 v2/src/lib/utils/tool-files.ts diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts index 53840a3..e26e884 100644 --- a/v2/src/lib/agent-dispatcher.test.ts +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -171,6 +171,15 @@ vi.mock('./stores/notifications.svelte', () => ({ notify: (...args: unknown[]) => mockNotify(...args), })); +vi.mock('./stores/conflicts.svelte', () => ({ + recordFileWrite: vi.fn().mockReturnValue(false), + clearSessionWrites: vi.fn(), +})); + +vi.mock('./utils/tool-files', () => ({ + extractWritePaths: vi.fn().mockReturnValue([]), +})); + // Use fake timers to control setTimeout in sidecar crash recovery beforeEach(() => { vi.useFakeTimers(); diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 749b45e..f2ccd22 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -25,6 +25,8 @@ import { } from './adapters/groups-bridge'; import { tel } from './adapters/telemetry-bridge'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; +import { recordFileWrite, clearSessionWrites } from './stores/conflicts.svelte'; +import { extractWritePaths } from './utils/tool-files'; let unlistenMsg: (() => void) | null = null; let unlistenExit: (() => void) | null = null; @@ -180,7 +182,18 @@ function handleAgentEvent(sessionId: string, event: Record): vo } // Health: record tool start const projId = sessionProjectMap.get(sessionId); - if (projId) recordActivity(projId, tc.name); + if (projId) { + recordActivity(projId, tc.name); + // Conflict detection: track file writes + const writePaths = extractWritePaths(tc); + for (const filePath of writePaths) { + const isNewConflict = recordFileWrite(projId, sessionId, filePath); + if (isNewConflict) { + const shortName = filePath.split('/').pop() ?? filePath; + notify('warning', `File conflict: ${shortName} — multiple agents writing`); + } + } + } break; } @@ -214,6 +227,8 @@ function handleAgentEvent(sessionId: string, event: Record): vo if (costProjId) { recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd); recordToolDone(costProjId); + // Conflict tracking: clear session writes on completion + clearSessionWrites(costProjId, sessionId); } // Persist session state for project-scoped sessions persistSessionForProject(sessionId); diff --git a/v2/src/lib/components/StatusBar/StatusBar.svelte b/v2/src/lib/components/StatusBar/StatusBar.svelte index 5074e7a..4cd036e 100644 --- a/v2/src/lib/components/StatusBar/StatusBar.svelte +++ b/v2/src/lib/components/StatusBar/StatusBar.svelte @@ -2,6 +2,7 @@ import { getAgentSessions } from '../../stores/agents.svelte'; import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte'; import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte'; + import { getTotalConflictCount } from '../../stores/conflicts.svelte'; let agentSessions = $derived(getAgentSessions()); let activeGroup = $derived(getActiveGroup()); @@ -15,6 +16,7 @@ let health = $derived(getHealthAggregates()); let attentionQueue = $derived(getAttentionQueue(5)); + let totalConflicts = $derived(getTotalConflictCount()); let showAttention = $state(false); function projectName(projectId: string): string { @@ -67,6 +69,12 @@ {/if} + {#if totalConflicts > 0} + + ⚠ {totalConflicts} conflict{totalConflicts > 1 ? 's' : ''} + + + {/if} {#if attentionQueue.length > 0} @@ -173,6 +181,11 @@ font-weight: 600; } + .state-conflict { + color: var(--ctp-red); + font-weight: 600; + } + .pulse { width: 6px; height: 6px; diff --git a/v2/src/lib/components/Workspace/ContextTab.svelte b/v2/src/lib/components/Workspace/ContextTab.svelte index 9bf2943..79d981c 100644 --- a/v2/src/lib/components/Workspace/ContextTab.svelte +++ b/v2/src/lib/components/Workspace/ContextTab.svelte @@ -1,6 +1,7 @@