feat(conflicts): add bash write detection, dismiss/acknowledge, and worktree suppression

This commit is contained in:
Hibryda 2026-03-11 00:25:26 +01:00
parent 05191127ea
commit 38b8447ae6
8 changed files with 354 additions and 24 deletions

View file

@ -174,10 +174,12 @@ vi.mock('./stores/notifications.svelte', () => ({
vi.mock('./stores/conflicts.svelte', () => ({
recordFileWrite: vi.fn().mockReturnValue(false),
clearSessionWrites: vi.fn(),
setSessionWorktree: vi.fn(),
}));
vi.mock('./utils/tool-files', () => ({
extractWritePaths: vi.fn().mockReturnValue([]),
extractWorktreePath: vi.fn().mockReturnValue(null),
}));
// Use fake timers to control setTimeout in sidecar crash recovery

View file

@ -25,8 +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';
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
import { extractWritePaths, extractWorktreePath } from './utils/tool-files';
let unlistenMsg: (() => void) | null = null;
let unlistenExit: (() => void) | null = null;
@ -184,6 +184,12 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
const projId = sessionProjectMap.get(sessionId);
if (projId) {
recordActivity(projId, tc.name);
// Worktree tracking: detect worktree isolation on Agent/Task calls or EnterWorktree
const wtPath = extractWorktreePath(tc);
if (wtPath) {
// The child session (or this session) is entering a worktree
setSessionWorktree(sessionId, wtPath);
}
// Conflict detection: track file writes
const writePaths = extractWritePaths(tc);
for (const filePath of writePaths) {

View file

@ -2,6 +2,7 @@
import type { ProjectConfig } from '../../types/groups';
import { PROJECT_ACCENTS } from '../../types/groups';
import type { ProjectHealth } from '../../stores/health.svelte';
import { acknowledgeConflicts } from '../../stores/conflicts.svelte';
interface Props {
project: ProjectConfig;
@ -81,9 +82,13 @@
</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>
<button
class="info-conflict"
title="{health.fileConflictCount} file conflict{health.fileConflictCount > 1 ? 's' : ''} — click to dismiss"
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(project.id); }}
>
{health.fileConflictCount} conflict{health.fileConflictCount > 1 ? 's' : ''}
</button>
<span class="info-sep">·</span>
{/if}
{#if contextPct !== null && contextPct > 0}
@ -238,6 +243,14 @@
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
padding: 0.0625rem 0.375rem;
border-radius: 0.1875rem;
border: none;
cursor: pointer;
font-family: inherit;
line-height: inherit;
}
.info-conflict:hover {
background: color-mix(in srgb, var(--ctp-red) 25%, transparent);
}
.info-profile {

View file

@ -31,8 +31,29 @@ interface FileWriteEntry {
// projectId -> filePath -> FileWriteEntry
let projectFileWrites = $state<Map<string, Map<string, FileWriteEntry>>>(new Map());
// projectId -> set of acknowledged file paths (suppresses badge until new conflict on that file)
let acknowledgedFiles = $state<Map<string, Set<string>>>(new Map());
// sessionId -> worktree path (null = main working tree)
let sessionWorktrees = $state<Map<string, string | null>>(new Map());
// --- Public API ---
/** Register the worktree path for a session (null = main working tree) */
export function setSessionWorktree(sessionId: string, worktreePath: string | null): void {
sessionWorktrees.set(sessionId, worktreePath ?? null);
}
/** Check if two sessions are in different worktrees (conflict suppression) */
function areInDifferentWorktrees(sessionIdA: string, sessionIdB: string): boolean {
const wtA = sessionWorktrees.get(sessionIdA) ?? null;
const wtB = sessionWorktrees.get(sessionIdB) ?? null;
// Both null = same main tree, both same string = same worktree → not different
if (wtA === wtB) return false;
// One or both non-null and different → different worktrees
return true;
}
/** 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);
@ -42,7 +63,7 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
}
let entry = projectMap.get(filePath);
const hadConflict = entry ? entry.sessionIds.size >= 2 : false;
const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false;
if (!entry) {
entry = { sessionIds: new Set([sessionId]), lastWriteTs: Date.now() };
@ -50,21 +71,46 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
return false;
}
const isNewSession = !entry.sessionIds.has(sessionId);
entry.sessionIds.add(sessionId);
entry.lastWriteTs = Date.now();
// New conflict = we just went from 1 session to 2+
return !hadConflict && entry.sessionIds.size >= 2;
// Check if this is a real conflict (not suppressed by worktrees)
const realConflictCount = countRealConflictSessions(entry, sessionId);
const isNewConflict = !hadConflict && realConflictCount >= 2;
// Clear acknowledgement when a new session writes to a previously-acknowledged file
if (isNewSession && realConflictCount >= 2) {
const ackSet = acknowledgedFiles.get(projectId);
if (ackSet) ackSet.delete(filePath);
}
return isNewConflict;
}
/** Get all conflicts for a project */
/**
* Count sessions that are in a real conflict with the given session
* (same worktree or both in main tree). Returns total including the session itself.
*/
function countRealConflictSessions(entry: FileWriteEntry, forSessionId: string): number {
let count = 0;
for (const sid of entry.sessionIds) {
if (sid === forSessionId || !areInDifferentWorktrees(sid, forSessionId)) {
count++;
}
}
return count;
}
/** Get all conflicts for a project (excludes acknowledged and worktree-suppressed) */
export function getProjectConflicts(projectId: string): ProjectConflicts {
const projectMap = projectFileWrites.get(projectId);
if (!projectMap) return { projectId, conflicts: [], conflictCount: 0 };
const ackSet = acknowledgedFiles.get(projectId);
const conflicts: FileConflict[] = [];
for (const [filePath, entry] of projectMap) {
if (entry.sessionIds.size >= 2) {
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) {
conflicts.push({
filePath,
shortName: filePath.split('/').pop() ?? filePath,
@ -79,27 +125,56 @@ export function getProjectConflicts(projectId: string): ProjectConflicts {
return { projectId, conflicts, conflictCount: conflicts.length };
}
/** Check if a project has any file conflicts */
/** Check if a project has any unacknowledged real 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;
const ackSet = acknowledgedFiles.get(projectId);
for (const [filePath, entry] of projectMap) {
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) return true;
}
return false;
}
/** Get total conflict count across all projects */
/** Get total unacknowledged 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++;
for (const [projectId, projectMap] of projectFileWrites) {
const ackSet = acknowledgedFiles.get(projectId);
for (const [filePath, entry] of projectMap) {
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) total++;
}
}
return total;
}
/** Check if a file write entry has a real conflict (2+ sessions in same worktree) */
function hasRealConflict(entry: FileWriteEntry): boolean {
if (entry.sessionIds.size < 2) return false;
// Check all pairs for same-worktree conflict
const sids = Array.from(entry.sessionIds);
for (let i = 0; i < sids.length; i++) {
for (let j = i + 1; j < sids.length; j++) {
if (!areInDifferentWorktrees(sids[i], sids[j])) return true;
}
}
return false;
}
/** Acknowledge all current conflicts for a project (suppresses badge until new conflict) */
export function acknowledgeConflicts(projectId: string): void {
const projectMap = projectFileWrites.get(projectId);
if (!projectMap) return;
const ackSet = acknowledgedFiles.get(projectId) ?? new Set();
for (const [filePath, entry] of projectMap) {
if (hasRealConflict(entry)) {
ackSet.add(filePath);
}
}
acknowledgedFiles.set(projectId, ackSet);
}
/** 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);
@ -114,15 +189,22 @@ export function clearSessionWrites(projectId: string, sessionId: string): void {
if (projectMap.size === 0) {
projectFileWrites.delete(projectId);
acknowledgedFiles.delete(projectId);
}
// Clean up worktree tracking
sessionWorktrees.delete(sessionId);
}
/** Clear all conflict tracking for a project */
export function clearProjectConflicts(projectId: string): void {
projectFileWrites.delete(projectId);
acknowledgedFiles.delete(projectId);
}
/** Clear all conflict state */
export function clearAllConflicts(): void {
projectFileWrites = new Map();
acknowledgedFiles = new Map();
sessionWorktrees = new Map();
}

View file

@ -7,6 +7,8 @@ import {
clearSessionWrites,
clearProjectConflicts,
clearAllConflicts,
acknowledgeConflicts,
setSessionWorktree,
} from './conflicts.svelte';
beforeEach(() => {
@ -154,4 +156,83 @@ describe('conflicts store', () => {
expect(getTotalConflictCount()).toBe(0);
});
});
describe('acknowledgeConflicts', () => {
it('suppresses conflict from counts after acknowledge', () => {
recordFileWrite('proj-1', 'sess-a', '/a.ts');
recordFileWrite('proj-1', 'sess-b', '/a.ts');
expect(hasConflicts('proj-1')).toBe(true);
acknowledgeConflicts('proj-1');
expect(hasConflicts('proj-1')).toBe(false);
expect(getTotalConflictCount()).toBe(0);
expect(getProjectConflicts('proj-1').conflictCount).toBe(0);
});
it('resurfaces conflict when new write arrives on acknowledged file', () => {
recordFileWrite('proj-1', 'sess-a', '/a.ts');
recordFileWrite('proj-1', 'sess-b', '/a.ts');
acknowledgeConflicts('proj-1');
expect(hasConflicts('proj-1')).toBe(false);
// Third session writes same file — should resurface
recordFileWrite('proj-1', 'sess-c', '/a.ts');
// recordFileWrite returns false for already-conflicted file, but the ack should be cleared
expect(hasConflicts('proj-1')).toBe(true);
});
it('no-ops for unknown project', () => {
acknowledgeConflicts('nonexistent'); // Should not throw
});
});
describe('worktree suppression', () => {
it('suppresses conflict between sessions in different worktrees', () => {
setSessionWorktree('sess-a', null); // main tree
setSessionWorktree('sess-b', '/tmp/wt-1'); // worktree
recordFileWrite('proj-1', 'sess-a', '/a.ts');
recordFileWrite('proj-1', 'sess-b', '/a.ts');
expect(hasConflicts('proj-1')).toBe(false);
expect(getTotalConflictCount()).toBe(0);
});
it('detects conflict between sessions in same worktree', () => {
setSessionWorktree('sess-a', '/tmp/wt-1');
setSessionWorktree('sess-b', '/tmp/wt-1');
recordFileWrite('proj-1', 'sess-a', '/a.ts');
recordFileWrite('proj-1', 'sess-b', '/a.ts');
expect(hasConflicts('proj-1')).toBe(true);
});
it('detects conflict between sessions both in main tree', () => {
setSessionWorktree('sess-a', null);
setSessionWorktree('sess-b', null);
recordFileWrite('proj-1', 'sess-a', '/a.ts');
recordFileWrite('proj-1', 'sess-b', '/a.ts');
expect(hasConflicts('proj-1')).toBe(true);
});
it('suppresses conflict when two worktrees differ', () => {
setSessionWorktree('sess-a', '/tmp/wt-1');
setSessionWorktree('sess-b', '/tmp/wt-2');
recordFileWrite('proj-1', 'sess-a', '/a.ts');
recordFileWrite('proj-1', 'sess-b', '/a.ts');
expect(hasConflicts('proj-1')).toBe(false);
});
it('sessions without worktree info conflict normally (backward compat)', () => {
// No setSessionWorktree calls — both default to null (main tree)
recordFileWrite('proj-1', 'sess-a', '/a.ts');
recordFileWrite('proj-1', 'sess-b', '/a.ts');
expect(hasConflicts('proj-1')).toBe(true);
});
it('clearSessionWrites cleans up worktree tracking', () => {
setSessionWorktree('sess-a', '/tmp/wt-1');
recordFileWrite('proj-1', 'sess-a', '/a.ts');
clearSessionWrites('proj-1', 'sess-a');
// Subsequent session in main tree should not be compared against stale wt data
recordFileWrite('proj-1', 'sess-b', '/a.ts');
recordFileWrite('proj-1', 'sess-c', '/a.ts');
expect(hasConflicts('proj-1')).toBe(true);
});
});
});

View file

@ -7,6 +7,8 @@ export interface ProjectConfig {
cwd: string;
profile: string;
enabled: boolean;
/** When true, agents for this project use git worktrees for isolation */
useWorktrees?: boolean;
}
export interface GroupConfig {

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { extractFilePaths, extractWritePaths } from './tool-files';
import { extractFilePaths, extractWritePaths, extractWorktreePath } from './tool-files';
import type { ToolCallContent } from '../adapters/sdk-messages';
function makeTc(name: string, input: unknown): ToolCallContent {
@ -32,7 +32,7 @@ describe('extractFilePaths', () => {
expect(result).toEqual([{ path: '/src', op: 'grep' }]);
});
it('extracts Bash file paths from common commands', () => {
it('extracts Bash read paths from common commands', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'cat /etc/hosts' }));
expect(result).toEqual([{ path: '/etc/hosts', op: 'bash' }]);
});
@ -51,10 +51,57 @@ describe('extractFilePaths', () => {
const result = extractFilePaths(makeTc('Read', {}));
expect(result).toEqual([]);
});
// Bash write detection
it('detects echo > redirect as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "hello" > /tmp/out.txt' }));
expect(result).toEqual([{ path: '/tmp/out.txt', op: 'write' }]);
});
it('detects >> append redirect as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "data" >> /tmp/log.txt' }));
expect(result).toEqual([{ path: '/tmp/log.txt', op: 'write' }]);
});
it('detects sed -i as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: "sed -i 's/foo/bar/g' /src/config.ts" }));
expect(result).toEqual([{ path: '/src/config.ts', op: 'write' }]);
});
it('detects tee as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "content" | tee /tmp/output.log' }));
expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]);
});
it('detects tee -a as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "append" | tee -a /tmp/output.log' }));
expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]);
});
it('detects cp destination as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'cp /src/a.ts /src/b.ts' }));
expect(result).toEqual([{ path: '/src/b.ts', op: 'write' }]);
});
it('detects mv destination as write', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'mv /old/file.ts /new/file.ts' }));
expect(result).toEqual([{ path: '/new/file.ts', op: 'write' }]);
});
it('ignores /dev/null redirects', () => {
const result = extractFilePaths(makeTc('Bash', { command: 'echo "test" > /dev/null' }));
expect(result).toEqual([]);
});
it('prefers write detection over read for ambiguous commands', () => {
// "cat file > out" should detect the write target, not the read source
const result = extractFilePaths(makeTc('Bash', { command: 'cat /src/input.ts > /tmp/output.ts' }));
expect(result).toEqual([{ path: '/tmp/output.ts', op: 'write' }]);
});
});
describe('extractWritePaths', () => {
it('returns only write-op paths', () => {
it('returns only write-op paths for Write/Edit tools', () => {
expect(extractWritePaths(makeTc('Write', { file_path: '/a.ts' }))).toEqual(['/a.ts']);
expect(extractWritePaths(makeTc('Edit', { file_path: '/b.ts' }))).toEqual(['/b.ts']);
});
@ -65,7 +112,37 @@ describe('extractWritePaths', () => {
expect(extractWritePaths(makeTc('Grep', { path: '/src' }))).toEqual([]);
});
it('returns empty for bash commands', () => {
it('returns empty for bash read commands', () => {
expect(extractWritePaths(makeTc('Bash', { command: 'cat /foo' }))).toEqual([]);
});
it('detects bash write commands', () => {
expect(extractWritePaths(makeTc('Bash', { command: 'echo "x" > /tmp/out.ts' }))).toEqual(['/tmp/out.ts']);
expect(extractWritePaths(makeTc('Bash', { command: "sed -i 's/a/b/' /src/file.ts" }))).toEqual(['/src/file.ts']);
expect(extractWritePaths(makeTc('Bash', { command: 'cp /a.ts /b.ts' }))).toEqual(['/b.ts']);
});
});
describe('extractWorktreePath', () => {
it('detects Agent tool with isolation: worktree', () => {
const result = extractWorktreePath(makeTc('Agent', { prompt: 'do stuff', isolation: 'worktree' }));
expect(result).toMatch(/^worktree:/);
});
it('detects Task tool with isolation: worktree', () => {
const result = extractWorktreePath(makeTc('Task', { prompt: 'do stuff', isolation: 'worktree' }));
expect(result).toMatch(/^worktree:/);
});
it('returns null for Agent without isolation', () => {
expect(extractWorktreePath(makeTc('Agent', { prompt: 'do stuff' }))).toBeNull();
});
it('detects EnterWorktree with path', () => {
expect(extractWorktreePath(makeTc('EnterWorktree', { path: '/tmp/wt-1' }))).toBe('/tmp/wt-1');
});
it('returns null for unrelated tool', () => {
expect(extractWorktreePath(makeTc('Read', { file_path: '/foo' }))).toBeNull();
});
});

View file

@ -8,6 +8,25 @@ export interface ToolFileRef {
op: 'read' | 'write' | 'glob' | 'grep' | 'bash';
}
// Patterns for read-like bash commands
const BASH_READ_RE = /(?:cat|head|tail|less|vim|nano|code)\s+["']?([^\s"'|;&]+)/;
// Patterns for bash commands that write to files
const BASH_WRITE_PATTERNS: RegExp[] = [
// Redirection: echo/printf/cat ... > file or >> file
/(?:>>?)\s*["']?([^\s"'|;&]+)/,
// sed -i (in-place edit)
/\bsed\s+(?:-[^i\s]*)?-i[^-]?\s*(?:'[^']*'|"[^"]*"|[^\s]+\s+)["']?([^\s"'|;&]+)/,
// tee file
/\btee\s+(?:-a\s+)?["']?([^\s"'|;&]+)/,
// cp source dest — last arg is destination
/\bcp\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/,
// mv source dest — last arg is destination
/\bmv\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/,
// chmod/chown — modifies file metadata
/\b(?:chmod|chown)\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/,
];
/** Extract file paths referenced by a tool call */
export function extractFilePaths(tc: ToolCallContent): ToolFileRef[] {
const results: ToolFileRef[] = [];
@ -37,17 +56,65 @@ export function extractFilePaths(tc: ToolCallContent): ToolFileRef[] {
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' });
// Check for write patterns first
const writeRefs = extractBashWritePaths(cmd);
for (const path of writeRefs) {
results.push({ path, op: 'write' });
}
// Check for read patterns (only if no write detected to avoid double-counting)
if (writeRefs.length === 0) {
const readMatch = cmd.match(BASH_READ_RE);
if (readMatch) results.push({ path: readMatch[1], op: 'bash' });
}
break;
}
}
return results;
}
/** Extract only write-operation file paths (Write, Edit) */
/** Extract write-target file paths from a bash command string */
function extractBashWritePaths(cmd: string): string[] {
const paths: string[] = [];
const seen = new Set<string>();
for (const pattern of BASH_WRITE_PATTERNS) {
const match = cmd.match(pattern);
if (match && match[1] && !seen.has(match[1])) {
// Filter out obvious non-file targets (flags, -, /dev/null)
const target = match[1];
if (target === '-' || target.startsWith('-') || target === '/dev/null') continue;
seen.add(target);
paths.push(target);
}
}
return paths;
}
/** Extract only write-operation file paths (Write, Edit, and Bash writes) */
export function extractWritePaths(tc: ToolCallContent): string[] {
return extractFilePaths(tc)
.filter(r => r.op === 'write')
.map(r => r.path);
}
/** Extract worktree path from an Agent/Task tool call with isolation: "worktree", or EnterWorktree */
export function extractWorktreePath(tc: ToolCallContent): string | null {
const input = tc.input as Record<string, unknown> | null;
if (!input) return null;
const name = tc.name;
// Agent/Task tool with isolation: "worktree"
if ((name === 'Agent' || name === 'Task' || name === 'dispatch_agent') && input.isolation === 'worktree') {
// The worktree path comes from the tool_result, not the tool_call.
// But we can flag this session as "worktree-isolated" with a synthetic marker.
return `worktree:${tc.toolUseId}`;
}
// EnterWorktree tool call carries the path directly
if (name === 'EnterWorktree' && typeof input.path === 'string') {
return input.path;
}
return null;
}