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.
This commit is contained in:
Hibryda 2026-03-11 00:12:10 +01:00
parent 8e00e0ef8c
commit 82fb618c76
12 changed files with 483 additions and 37 deletions

View file

@ -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 @@
</span>
<span class="sep"></span>
{/if}
{#if totalConflicts > 0}
<span class="item state-conflict" title="{totalConflicts} file conflict{totalConflicts > 1 ? 's' : ''} — multiple agents writing same file">
{totalConflicts} conflict{totalConflicts > 1 ? 's' : ''}
</span>
<span class="sep"></span>
{/if}
<!-- Attention queue toggle -->
{#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;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { getAgentSession, getTotalCost, type AgentSession } from '../../stores/agents.svelte';
import type { AgentMessage, ToolCallContent, CostContent, CompactionContent } from '../../adapters/sdk-messages';
import { extractFilePaths } from '../../utils/tool-files';
interface Props {
sessionId: string | null;
@ -195,41 +196,6 @@
return Math.ceil(text.length / 4);
}
function extractFilePaths(tc: ToolCallContent): { path: string; op: string }[] {
const results: { path: string; op: string }[] = [];
const input = tc.input as Record<string, unknown>;
switch (tc.name) {
case 'Read':
case 'read':
if (input?.file_path) results.push({ path: String(input.file_path), op: 'read' });
break;
case 'Write':
case 'write':
if (input?.file_path) results.push({ path: String(input.file_path), op: 'write' });
break;
case 'Edit':
case 'edit':
if (input?.file_path) results.push({ path: String(input.file_path), op: 'write' });
break;
case 'Glob':
case 'glob':
if (input?.pattern) results.push({ path: String(input.pattern), op: 'glob' });
break;
case 'Grep':
case 'grep':
if (input?.path) results.push({ path: String(input.path), op: 'grep' });
break;
case 'Bash':
case 'bash':
// Try to extract file paths from bash commands
const cmd = String(input?.command ?? '');
const fileMatch = cmd.match(/(?:cat|head|tail|less|vim|nano|code)\s+["']?([^\s"'|;&]+)/);
if (fileMatch) results.push({ path: fileMatch[1], op: 'bash' });
break;
}
return results;
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;

View file

@ -80,6 +80,12 @@
<span class="project-id">({project.identifier})</span>
</div>
<div class="header-info">
{#if health && health.fileConflictCount > 0}
<span class="info-conflict" title="{health.fileConflictCount} file conflict{health.fileConflictCount > 1 ? 's' : ''} — multiple agents writing same file">
{health.fileConflictCount} conflict{health.fileConflictCount > 1 ? 's' : ''}
</span>
<span class="info-sep">·</span>
{/if}
{#if contextPct !== null && contextPct > 0}
<span class="info-ctx" style="color: {ctxColor()}" title="Context window usage">ctx {contextPct}%</span>
<span class="info-sep">·</span>
@ -224,6 +230,16 @@
flex-shrink: 0;
}
.info-conflict {
font-size: 0.6rem;
color: var(--ctp-red);
font-weight: 600;
white-space: nowrap;
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
padding: 0.0625rem 0.375rem;
border-radius: 0.1875rem;
}
.info-profile {
font-size: 0.65rem;
color: var(--ctp-blue);