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
|
|
@ -171,6 +171,15 @@ vi.mock('./stores/notifications.svelte', () => ({
|
||||||
notify: (...args: unknown[]) => mockNotify(...args),
|
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
|
// Use fake timers to control setTimeout in sidecar crash recovery
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import {
|
||||||
} from './adapters/groups-bridge';
|
} from './adapters/groups-bridge';
|
||||||
import { tel } from './adapters/telemetry-bridge';
|
import { tel } from './adapters/telemetry-bridge';
|
||||||
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
|
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 unlistenMsg: (() => void) | null = null;
|
||||||
let unlistenExit: (() => void) | null = null;
|
let unlistenExit: (() => void) | null = null;
|
||||||
|
|
@ -180,7 +182,18 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
||||||
}
|
}
|
||||||
// Health: record tool start
|
// Health: record tool start
|
||||||
const projId = sessionProjectMap.get(sessionId);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,6 +227,8 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
||||||
if (costProjId) {
|
if (costProjId) {
|
||||||
recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd);
|
recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd);
|
||||||
recordToolDone(costProjId);
|
recordToolDone(costProjId);
|
||||||
|
// Conflict tracking: clear session writes on completion
|
||||||
|
clearSessionWrites(costProjId, sessionId);
|
||||||
}
|
}
|
||||||
// Persist session state for project-scoped sessions
|
// Persist session state for project-scoped sessions
|
||||||
persistSessionForProject(sessionId);
|
persistSessionForProject(sessionId);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { getAgentSessions } from '../../stores/agents.svelte';
|
import { getAgentSessions } from '../../stores/agents.svelte';
|
||||||
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
|
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
|
||||||
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
|
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
|
||||||
|
import { getTotalConflictCount } from '../../stores/conflicts.svelte';
|
||||||
|
|
||||||
let agentSessions = $derived(getAgentSessions());
|
let agentSessions = $derived(getAgentSessions());
|
||||||
let activeGroup = $derived(getActiveGroup());
|
let activeGroup = $derived(getActiveGroup());
|
||||||
|
|
@ -15,6 +16,7 @@
|
||||||
let health = $derived(getHealthAggregates());
|
let health = $derived(getHealthAggregates());
|
||||||
let attentionQueue = $derived(getAttentionQueue(5));
|
let attentionQueue = $derived(getAttentionQueue(5));
|
||||||
|
|
||||||
|
let totalConflicts = $derived(getTotalConflictCount());
|
||||||
let showAttention = $state(false);
|
let showAttention = $state(false);
|
||||||
|
|
||||||
function projectName(projectId: string): string {
|
function projectName(projectId: string): string {
|
||||||
|
|
@ -67,6 +69,12 @@
|
||||||
</span>
|
</span>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
{/if}
|
{/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 -->
|
<!-- Attention queue toggle -->
|
||||||
{#if attentionQueue.length > 0}
|
{#if attentionQueue.length > 0}
|
||||||
|
|
@ -173,6 +181,11 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state-conflict {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.pulse {
|
.pulse {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getAgentSession, getTotalCost, type AgentSession } from '../../stores/agents.svelte';
|
import { getAgentSession, getTotalCost, type AgentSession } from '../../stores/agents.svelte';
|
||||||
import type { AgentMessage, ToolCallContent, CostContent, CompactionContent } from '../../adapters/sdk-messages';
|
import type { AgentMessage, ToolCallContent, CostContent, CompactionContent } from '../../adapters/sdk-messages';
|
||||||
|
import { extractFilePaths } from '../../utils/tool-files';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
|
|
@ -195,41 +196,6 @@
|
||||||
return Math.ceil(text.length / 4);
|
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 {
|
function formatTokens(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,12 @@
|
||||||
<span class="project-id">({project.identifier})</span>
|
<span class="project-id">({project.identifier})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-info">
|
<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}
|
{#if contextPct !== null && contextPct > 0}
|
||||||
<span class="info-ctx" style="color: {ctxColor()}" title="Context window usage">ctx {contextPct}%</span>
|
<span class="info-ctx" style="color: {ctxColor()}" title="Context window usage">ctx {contextPct}%</span>
|
||||||
<span class="info-sep">·</span>
|
<span class="info-sep">·</span>
|
||||||
|
|
@ -224,6 +230,16 @@
|
||||||
flex-shrink: 0;
|
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 {
|
.info-profile {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--ctp-blue);
|
color: var(--ctp-blue);
|
||||||
|
|
|
||||||
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
|
// Tracks per-project activity state, burn rate, context pressure, and attention scoring
|
||||||
|
|
||||||
import { getAgentSession, type AgentSession } from './agents.svelte';
|
import { getAgentSession, type AgentSession } from './agents.svelte';
|
||||||
|
import { getProjectConflicts } from './conflicts.svelte';
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
|
|
@ -20,6 +21,8 @@ export interface ProjectHealth {
|
||||||
burnRatePerHour: number;
|
burnRatePerHour: number;
|
||||||
/** Context pressure as fraction 0..1 (null if unknown) */
|
/** Context pressure as fraction 0..1 (null if unknown) */
|
||||||
contextPressure: number | null;
|
contextPressure: number | null;
|
||||||
|
/** Number of file conflicts (2+ agents writing same file) */
|
||||||
|
fileConflictCount: number;
|
||||||
/** Attention urgency score (higher = more urgent, 0 = no attention needed) */
|
/** Attention urgency score (higher = more urgent, 0 = no attention needed) */
|
||||||
attentionScore: number;
|
attentionScore: number;
|
||||||
/** Human-readable attention reason */
|
/** Human-readable attention reason */
|
||||||
|
|
@ -51,6 +54,7 @@ const SCORE_STALLED = 100;
|
||||||
const SCORE_CONTEXT_CRITICAL = 80; // >90% context
|
const SCORE_CONTEXT_CRITICAL = 80; // >90% context
|
||||||
const SCORE_CONTEXT_HIGH = 40; // >75% context
|
const SCORE_CONTEXT_HIGH = 40; // >75% context
|
||||||
const SCORE_ERROR = 90;
|
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)
|
const SCORE_IDLE_LONG = 20; // >2x stall threshold but not stalled (shouldn't happen, safety)
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
@ -228,7 +232,11 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
// Burn rate
|
// Burn rate
|
||||||
const burnRatePerHour = computeBurnRate(tracker.costSnapshots);
|
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 attentionScore = 0;
|
||||||
let attentionReason: string | null = null;
|
let attentionReason: string | null = null;
|
||||||
|
|
||||||
|
|
@ -242,6 +250,9 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
} else if (contextPressure !== null && contextPressure > 0.9) {
|
} else if (contextPressure !== null && contextPressure > 0.9) {
|
||||||
attentionScore = SCORE_CONTEXT_CRITICAL;
|
attentionScore = SCORE_CONTEXT_CRITICAL;
|
||||||
attentionReason = `Context ${Math.round(contextPressure * 100)}% — near limit`;
|
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) {
|
} else if (contextPressure !== null && contextPressure > 0.75) {
|
||||||
attentionScore = SCORE_CONTEXT_HIGH;
|
attentionScore = SCORE_CONTEXT_HIGH;
|
||||||
attentionReason = `Context ${Math.round(contextPressure * 100)}%`;
|
attentionReason = `Context ${Math.round(contextPressure * 100)}%`;
|
||||||
|
|
@ -255,6 +266,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
idleDurationMs,
|
idleDurationMs,
|
||||||
burnRatePerHour,
|
burnRatePerHour,
|
||||||
contextPressure,
|
contextPressure,
|
||||||
|
fileConflictCount,
|
||||||
attentionScore,
|
attentionScore,
|
||||||
attentionReason,
|
attentionReason,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
||||||
import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups';
|
import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups';
|
||||||
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
||||||
import { clearHealthTracking } from '../stores/health.svelte';
|
import { clearHealthTracking } from '../stores/health.svelte';
|
||||||
|
import { clearAllConflicts } from '../stores/conflicts.svelte';
|
||||||
import { waitForPendingPersistence } from '../agent-dispatcher';
|
import { waitForPendingPersistence } from '../agent-dispatcher';
|
||||||
|
|
||||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
|
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
|
||||||
|
|
@ -78,6 +79,7 @@ export async function switchGroup(groupId: string): Promise<void> {
|
||||||
projectTerminals = {};
|
projectTerminals = {};
|
||||||
clearAllAgentSessions();
|
clearAllAgentSessions();
|
||||||
clearHealthTracking();
|
clearHealthTracking();
|
||||||
|
clearAllConflicts();
|
||||||
|
|
||||||
activeGroupId = groupId;
|
activeGroupId = groupId;
|
||||||
activeProjectId = null;
|
activeProjectId = null;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ vi.mock('../stores/agents.svelte', () => ({
|
||||||
clearAllAgentSessions: vi.fn(),
|
clearAllAgentSessions: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../stores/conflicts.svelte', () => ({
|
||||||
|
clearAllConflicts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../agent-dispatcher', () => ({
|
vi.mock('../agent-dispatcher', () => ({
|
||||||
waitForPendingPersistence: vi.fn().mockResolvedValue(undefined),
|
waitForPendingPersistence: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
71
v2/src/lib/utils/tool-files.test.ts
Normal file
71
v2/src/lib/utils/tool-files.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractFilePaths, extractWritePaths } from './tool-files';
|
||||||
|
import type { ToolCallContent } from '../adapters/sdk-messages';
|
||||||
|
|
||||||
|
function makeTc(name: string, input: unknown): ToolCallContent {
|
||||||
|
return { toolUseId: `tu-${Math.random()}`, name, input };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('extractFilePaths', () => {
|
||||||
|
it('extracts Read file_path', () => {
|
||||||
|
const result = extractFilePaths(makeTc('Read', { file_path: '/src/main.ts' }));
|
||||||
|
expect(result).toEqual([{ path: '/src/main.ts', op: 'read' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Write file_path as write op', () => {
|
||||||
|
const result = extractFilePaths(makeTc('Write', { file_path: '/src/out.ts' }));
|
||||||
|
expect(result).toEqual([{ path: '/src/out.ts', op: 'write' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Edit file_path as write op', () => {
|
||||||
|
const result = extractFilePaths(makeTc('Edit', { file_path: '/src/edit.ts' }));
|
||||||
|
expect(result).toEqual([{ path: '/src/edit.ts', op: 'write' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Glob pattern', () => {
|
||||||
|
const result = extractFilePaths(makeTc('Glob', { pattern: '**/*.ts' }));
|
||||||
|
expect(result).toEqual([{ path: '**/*.ts', op: 'glob' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Grep path', () => {
|
||||||
|
const result = extractFilePaths(makeTc('Grep', { path: '/src', pattern: 'TODO' }));
|
||||||
|
expect(result).toEqual([{ path: '/src', op: 'grep' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Bash file paths from common commands', () => {
|
||||||
|
const result = extractFilePaths(makeTc('Bash', { command: 'cat /etc/hosts' }));
|
||||||
|
expect(result).toEqual([{ path: '/etc/hosts', op: 'bash' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles lowercase tool names', () => {
|
||||||
|
const result = extractFilePaths(makeTc('read', { file_path: '/foo' }));
|
||||||
|
expect(result).toEqual([{ path: '/foo', op: 'read' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for unknown tool', () => {
|
||||||
|
const result = extractFilePaths(makeTc('Agent', { prompt: 'do stuff' }));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty when input has no file_path', () => {
|
||||||
|
const result = extractFilePaths(makeTc('Read', {}));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractWritePaths', () => {
|
||||||
|
it('returns only write-op paths', () => {
|
||||||
|
expect(extractWritePaths(makeTc('Write', { file_path: '/a.ts' }))).toEqual(['/a.ts']);
|
||||||
|
expect(extractWritePaths(makeTc('Edit', { file_path: '/b.ts' }))).toEqual(['/b.ts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for read-only tools', () => {
|
||||||
|
expect(extractWritePaths(makeTc('Read', { file_path: '/c.ts' }))).toEqual([]);
|
||||||
|
expect(extractWritePaths(makeTc('Glob', { pattern: '*.ts' }))).toEqual([]);
|
||||||
|
expect(extractWritePaths(makeTc('Grep', { path: '/src' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for bash commands', () => {
|
||||||
|
expect(extractWritePaths(makeTc('Bash', { command: 'cat /foo' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
v2/src/lib/utils/tool-files.ts
Normal file
53
v2/src/lib/utils/tool-files.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// Extracts file paths from agent tool_call inputs
|
||||||
|
// Used by ContextTab (all file ops) and conflicts store (write ops only)
|
||||||
|
|
||||||
|
import type { ToolCallContent } from '../adapters/sdk-messages';
|
||||||
|
|
||||||
|
export interface ToolFileRef {
|
||||||
|
path: string;
|
||||||
|
op: 'read' | 'write' | 'glob' | 'grep' | 'bash';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract file paths referenced by a tool call */
|
||||||
|
export function extractFilePaths(tc: ToolCallContent): ToolFileRef[] {
|
||||||
|
const results: ToolFileRef[] = [];
|
||||||
|
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': {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract only write-operation file paths (Write, Edit) */
|
||||||
|
export function extractWritePaths(tc: ToolCallContent): string[] {
|
||||||
|
return extractFilePaths(tc)
|
||||||
|
.filter(r => r.op === 'write')
|
||||||
|
.map(r => r.path);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue