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:
parent
8e00e0ef8c
commit
82fb618c76
12 changed files with 483 additions and 37 deletions
128
v2/src/lib/stores/conflicts.svelte.ts
Normal file
128
v2/src/lib/stores/conflicts.svelte.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// File overlap conflict detection — Svelte 5 runes
|
||||
// Tracks which files each agent session writes to per project.
|
||||
// Detects when two or more sessions write to the same file (file overlap conflict).
|
||||
|
||||
export interface FileConflict {
|
||||
/** Absolute file path */
|
||||
filePath: string;
|
||||
/** Short display name (last path segment) */
|
||||
shortName: string;
|
||||
/** Session IDs that have written to this file */
|
||||
sessionIds: string[];
|
||||
/** Timestamp of most recent write */
|
||||
lastWriteTs: number;
|
||||
}
|
||||
|
||||
export interface ProjectConflicts {
|
||||
projectId: string;
|
||||
/** Active file conflicts (2+ sessions writing same file) */
|
||||
conflicts: FileConflict[];
|
||||
/** Total conflicting files */
|
||||
conflictCount: number;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
interface FileWriteEntry {
|
||||
sessionIds: Set<string>;
|
||||
lastWriteTs: number;
|
||||
}
|
||||
|
||||
// projectId -> filePath -> FileWriteEntry
|
||||
let projectFileWrites = $state<Map<string, Map<string, FileWriteEntry>>>(new Map());
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/** Record that a session wrote to a file. Returns true if this creates a new conflict. */
|
||||
export function recordFileWrite(projectId: string, sessionId: string, filePath: string): boolean {
|
||||
let projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) {
|
||||
projectMap = new Map();
|
||||
projectFileWrites.set(projectId, projectMap);
|
||||
}
|
||||
|
||||
let entry = projectMap.get(filePath);
|
||||
const hadConflict = entry ? entry.sessionIds.size >= 2 : false;
|
||||
|
||||
if (!entry) {
|
||||
entry = { sessionIds: new Set([sessionId]), lastWriteTs: Date.now() };
|
||||
projectMap.set(filePath, entry);
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.sessionIds.add(sessionId);
|
||||
entry.lastWriteTs = Date.now();
|
||||
|
||||
// New conflict = we just went from 1 session to 2+
|
||||
return !hadConflict && entry.sessionIds.size >= 2;
|
||||
}
|
||||
|
||||
/** Get all conflicts for a project */
|
||||
export function getProjectConflicts(projectId: string): ProjectConflicts {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return { projectId, conflicts: [], conflictCount: 0 };
|
||||
|
||||
const conflicts: FileConflict[] = [];
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (entry.sessionIds.size >= 2) {
|
||||
conflicts.push({
|
||||
filePath,
|
||||
shortName: filePath.split('/').pop() ?? filePath,
|
||||
sessionIds: Array.from(entry.sessionIds),
|
||||
lastWriteTs: entry.lastWriteTs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Most recent conflicts first
|
||||
conflicts.sort((a, b) => b.lastWriteTs - a.lastWriteTs);
|
||||
return { projectId, conflicts, conflictCount: conflicts.length };
|
||||
}
|
||||
|
||||
/** Check if a project has any file conflicts */
|
||||
export function hasConflicts(projectId: string): boolean {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return false;
|
||||
for (const entry of projectMap.values()) {
|
||||
if (entry.sessionIds.size >= 2) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Get total conflict count across all projects */
|
||||
export function getTotalConflictCount(): number {
|
||||
let total = 0;
|
||||
for (const projectMap of projectFileWrites.values()) {
|
||||
for (const entry of projectMap.values()) {
|
||||
if (entry.sessionIds.size >= 2) total++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Remove a session from all file write tracking (call on session end) */
|
||||
export function clearSessionWrites(projectId: string, sessionId: string): void {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return;
|
||||
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
entry.sessionIds.delete(sessionId);
|
||||
if (entry.sessionIds.size === 0) {
|
||||
projectMap.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (projectMap.size === 0) {
|
||||
projectFileWrites.delete(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all conflict tracking for a project */
|
||||
export function clearProjectConflicts(projectId: string): void {
|
||||
projectFileWrites.delete(projectId);
|
||||
}
|
||||
|
||||
/** Clear all conflict state */
|
||||
export function clearAllConflicts(): void {
|
||||
projectFileWrites = new Map();
|
||||
}
|
||||
157
v2/src/lib/stores/conflicts.test.ts
Normal file
157
v2/src/lib/stores/conflicts.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
recordFileWrite,
|
||||
getProjectConflicts,
|
||||
hasConflicts,
|
||||
getTotalConflictCount,
|
||||
clearSessionWrites,
|
||||
clearProjectConflicts,
|
||||
clearAllConflicts,
|
||||
} from './conflicts.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllConflicts();
|
||||
});
|
||||
|
||||
describe('conflicts store', () => {
|
||||
describe('recordFileWrite', () => {
|
||||
it('returns false for first write to a file', () => {
|
||||
expect(recordFileWrite('proj-1', 'sess-a', '/src/main.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for same session writing same file again', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
|
||||
expect(recordFileWrite('proj-1', 'sess-a', '/src/main.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a second session writes same file (new conflict)', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
|
||||
expect(recordFileWrite('proj-1', 'sess-b', '/src/main.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when third session writes already-conflicted file', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/src/main.ts');
|
||||
expect(recordFileWrite('proj-1', 'sess-c', '/src/main.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks writes per project independently', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
|
||||
expect(recordFileWrite('proj-2', 'sess-b', '/src/main.ts')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectConflicts', () => {
|
||||
it('returns empty for unknown project', () => {
|
||||
const result = getProjectConflicts('nonexistent');
|
||||
expect(result.conflicts).toEqual([]);
|
||||
expect(result.conflictCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty when no overlapping writes', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/src/b.ts');
|
||||
const result = getProjectConflicts('proj-1');
|
||||
expect(result.conflicts).toEqual([]);
|
||||
expect(result.conflictCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns conflict when two sessions write same file', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/src/main.ts');
|
||||
const result = getProjectConflicts('proj-1');
|
||||
expect(result.conflictCount).toBe(1);
|
||||
expect(result.conflicts[0].filePath).toBe('/src/main.ts');
|
||||
expect(result.conflicts[0].shortName).toBe('main.ts');
|
||||
expect(result.conflicts[0].sessionIds).toContain('sess-a');
|
||||
expect(result.conflicts[0].sessionIds).toContain('sess-b');
|
||||
});
|
||||
|
||||
it('returns multiple conflicts sorted by recency', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/old.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/src/old.ts');
|
||||
vi.setSystemTime(2000);
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/new.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/src/new.ts');
|
||||
const result = getProjectConflicts('proj-1');
|
||||
expect(result.conflictCount).toBe(2);
|
||||
// Most recent first
|
||||
expect(result.conflicts[0].filePath).toBe('/src/new.ts');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasConflicts', () => {
|
||||
it('returns false for unknown project', () => {
|
||||
expect(hasConflicts('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false with no overlapping writes', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true with overlapping writes', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/src/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/src/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalConflictCount', () => {
|
||||
it('returns 0 with no conflicts', () => {
|
||||
expect(getTotalConflictCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('counts conflicts across projects', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
recordFileWrite('proj-2', 'sess-c', '/b.ts');
|
||||
recordFileWrite('proj-2', 'sess-d', '/b.ts');
|
||||
expect(getTotalConflictCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSessionWrites', () => {
|
||||
it('removes session from file write tracking', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
clearSessionWrites('proj-1', 'sess-b');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans up empty entries', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
clearSessionWrites('proj-1', 'sess-a');
|
||||
expect(getProjectConflicts('proj-1').conflictCount).toBe(0);
|
||||
});
|
||||
|
||||
it('no-ops for unknown project', () => {
|
||||
clearSessionWrites('nonexistent', 'sess-a'); // Should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearProjectConflicts', () => {
|
||||
it('clears all tracking for a project', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
clearProjectConflicts('proj-1');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
expect(getTotalConflictCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllConflicts', () => {
|
||||
it('clears everything', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
recordFileWrite('proj-2', 'sess-c', '/b.ts');
|
||||
recordFileWrite('proj-2', 'sess-d', '/b.ts');
|
||||
clearAllConflicts();
|
||||
expect(getTotalConflictCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Tracks per-project activity state, burn rate, context pressure, and attention scoring
|
||||
|
||||
import { getAgentSession, type AgentSession } from './agents.svelte';
|
||||
import { getProjectConflicts } from './conflicts.svelte';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
|
|
@ -20,6 +21,8 @@ export interface ProjectHealth {
|
|||
burnRatePerHour: number;
|
||||
/** Context pressure as fraction 0..1 (null if unknown) */
|
||||
contextPressure: number | null;
|
||||
/** Number of file conflicts (2+ agents writing same file) */
|
||||
fileConflictCount: number;
|
||||
/** Attention urgency score (higher = more urgent, 0 = no attention needed) */
|
||||
attentionScore: number;
|
||||
/** Human-readable attention reason */
|
||||
|
|
@ -51,6 +54,7 @@ const SCORE_STALLED = 100;
|
|||
const SCORE_CONTEXT_CRITICAL = 80; // >90% context
|
||||
const SCORE_CONTEXT_HIGH = 40; // >75% context
|
||||
const SCORE_ERROR = 90;
|
||||
const SCORE_FILE_CONFLICT = 70; // 2+ agents writing same file
|
||||
const SCORE_IDLE_LONG = 20; // >2x stall threshold but not stalled (shouldn't happen, safety)
|
||||
|
||||
// --- State ---
|
||||
|
|
@ -228,7 +232,11 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
|||
// Burn rate
|
||||
const burnRatePerHour = computeBurnRate(tracker.costSnapshots);
|
||||
|
||||
// Attention scoring
|
||||
// File conflicts
|
||||
const conflicts = getProjectConflicts(tracker.projectId);
|
||||
const fileConflictCount = conflicts.conflictCount;
|
||||
|
||||
// Attention scoring — highest-priority signal wins
|
||||
let attentionScore = 0;
|
||||
let attentionReason: string | null = null;
|
||||
|
||||
|
|
@ -242,6 +250,9 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
|||
} else if (contextPressure !== null && contextPressure > 0.9) {
|
||||
attentionScore = SCORE_CONTEXT_CRITICAL;
|
||||
attentionReason = `Context ${Math.round(contextPressure * 100)}% — near limit`;
|
||||
} else if (fileConflictCount > 0) {
|
||||
attentionScore = SCORE_FILE_CONFLICT;
|
||||
attentionReason = `${fileConflictCount} file conflict${fileConflictCount > 1 ? 's' : ''} — agents writing same file`;
|
||||
} else if (contextPressure !== null && contextPressure > 0.75) {
|
||||
attentionScore = SCORE_CONTEXT_HIGH;
|
||||
attentionReason = `Context ${Math.round(contextPressure * 100)}%`;
|
||||
|
|
@ -255,6 +266,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
|||
idleDurationMs,
|
||||
burnRatePerHour,
|
||||
contextPressure,
|
||||
fileConflictCount,
|
||||
attentionScore,
|
||||
attentionReason,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
|||
import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups';
|
||||
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
||||
import { clearHealthTracking } from '../stores/health.svelte';
|
||||
import { clearAllConflicts } from '../stores/conflicts.svelte';
|
||||
import { waitForPendingPersistence } from '../agent-dispatcher';
|
||||
|
||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
|
||||
|
|
@ -78,6 +79,7 @@ export async function switchGroup(groupId: string): Promise<void> {
|
|||
projectTerminals = {};
|
||||
clearAllAgentSessions();
|
||||
clearHealthTracking();
|
||||
clearAllConflicts();
|
||||
|
||||
activeGroupId = groupId;
|
||||
activeProjectId = null;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ vi.mock('../stores/agents.svelte', () => ({
|
|||
clearAllAgentSessions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../stores/conflicts.svelte', () => ({
|
||||
clearAllConflicts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../agent-dispatcher', () => ({
|
||||
waitForPendingPersistence: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue