feat(s1p2): add inotify-based filesystem write detection with external conflict tracking

This commit is contained in:
Hibryda 2026-03-11 00:56:27 +01:00
parent 6b239c5ce5
commit e5d9f51df7
8 changed files with 501 additions and 7 deletions

View file

@ -1,6 +1,10 @@
// 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).
// Also detects external filesystem writes (S-1 Phase 2) via inotify events.
/** Sentinel session ID for external (non-agent) writes */
export const EXTERNAL_SESSION_ID = '__external__';
export interface FileConflict {
/** Absolute file path */
@ -11,6 +15,8 @@ export interface FileConflict {
sessionIds: string[];
/** Timestamp of most recent write */
lastWriteTs: number;
/** True if this conflict involves an external (non-agent) writer */
isExternal: boolean;
}
export interface ProjectConflicts {
@ -19,6 +25,8 @@ export interface ProjectConflicts {
conflicts: FileConflict[];
/** Total conflicting files */
conflictCount: number;
/** Number of files with external write conflicts */
externalConflictCount: number;
}
// --- State ---
@ -37,6 +45,13 @@ 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());
// projectId -> filePath -> timestamp of most recent agent write (for external write heuristic)
let agentWriteTimestamps = $state<Map<string, Map<string, number>>>(new Map());
// Time window: if an fs event arrives within this window after an agent tool_call write,
// it's attributed to the agent (suppressed). Otherwise it's external.
const AGENT_WRITE_GRACE_MS = 2000;
// --- Public API ---
/** Register the worktree path for a session (null = main working tree) */
@ -62,6 +77,16 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
projectFileWrites.set(projectId, projectMap);
}
// Track agent write timestamp for external write heuristic
if (sessionId !== EXTERNAL_SESSION_ID) {
let tsMap = agentWriteTimestamps.get(projectId);
if (!tsMap) {
tsMap = new Map();
agentWriteTimestamps.set(projectId, tsMap);
}
tsMap.set(filePath, Date.now());
}
let entry = projectMap.get(filePath);
const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false;
@ -88,6 +113,47 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
return isNewConflict;
}
/**
* Record an external filesystem write detected via inotify.
* Uses timing heuristic: if an agent wrote this file within AGENT_WRITE_GRACE_MS,
* the write is attributed to the agent and suppressed.
* Returns true if this creates a new external write conflict.
*/
export function recordExternalWrite(projectId: string, filePath: string, timestampMs: number): boolean {
// Timing heuristic: check if any agent recently wrote this file
const tsMap = agentWriteTimestamps.get(projectId);
if (tsMap) {
const lastAgentWrite = tsMap.get(filePath);
if (lastAgentWrite && (timestampMs - lastAgentWrite) < AGENT_WRITE_GRACE_MS) {
// This is likely our agent's write — suppress
return false;
}
}
// Check if any agent session has written this file (for conflict to be meaningful)
const projectMap = projectFileWrites.get(projectId);
if (!projectMap) return false; // No agent writes at all — not a conflict
const entry = projectMap.get(filePath);
if (!entry || entry.sessionIds.size === 0) return false; // No agent wrote this file
// Record external write as a conflict
return recordFileWrite(projectId, EXTERNAL_SESSION_ID, filePath);
}
/** Get the count of external write conflicts for a project */
export function getExternalConflictCount(projectId: string): number {
const projectMap = projectFileWrites.get(projectId);
if (!projectMap) return 0;
const ackSet = acknowledgedFiles.get(projectId);
let count = 0;
for (const [filePath, entry] of projectMap) {
if (entry.sessionIds.has(EXTERNAL_SESSION_ID) && !(ackSet?.has(filePath))) {
count++;
}
}
return count;
}
/**
* 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.
@ -105,24 +171,28 @@ function countRealConflictSessions(entry: FileWriteEntry, forSessionId: string):
/** 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 };
if (!projectMap) return { projectId, conflicts: [], conflictCount: 0, externalConflictCount: 0 };
const ackSet = acknowledgedFiles.get(projectId);
const conflicts: FileConflict[] = [];
let externalConflictCount = 0;
for (const [filePath, entry] of projectMap) {
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) {
const isExternal = entry.sessionIds.has(EXTERNAL_SESSION_ID);
if (isExternal) externalConflictCount++;
conflicts.push({
filePath,
shortName: filePath.split('/').pop() ?? filePath,
sessionIds: Array.from(entry.sessionIds),
lastWriteTs: entry.lastWriteTs,
isExternal,
});
}
}
// Most recent conflicts first
conflicts.sort((a, b) => b.lastWriteTs - a.lastWriteTs);
return { projectId, conflicts, conflictCount: conflicts.length };
return { projectId, conflicts, conflictCount: conflicts.length, externalConflictCount };
}
/** Check if a project has any unacknowledged real conflicts */
@ -200,6 +270,7 @@ export function clearSessionWrites(projectId: string, sessionId: string): void {
export function clearProjectConflicts(projectId: string): void {
projectFileWrites.delete(projectId);
acknowledgedFiles.delete(projectId);
agentWriteTimestamps.delete(projectId);
}
/** Clear all conflict state */
@ -207,4 +278,5 @@ export function clearAllConflicts(): void {
projectFileWrites = new Map();
acknowledgedFiles = new Map();
sessionWorktrees = new Map();
agentWriteTimestamps = new Map();
}

View file

@ -1,7 +1,9 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
recordFileWrite,
recordExternalWrite,
getProjectConflicts,
getExternalConflictCount,
hasConflicts,
getTotalConflictCount,
clearSessionWrites,
@ -9,6 +11,7 @@ import {
clearAllConflicts,
acknowledgeConflicts,
setSessionWorktree,
EXTERNAL_SESSION_ID,
} from './conflicts.svelte';
beforeEach(() => {
@ -235,4 +238,98 @@ describe('conflicts store', () => {
expect(hasConflicts('proj-1')).toBe(true);
});
});
describe('external write detection (S-1 Phase 2)', () => {
it('suppresses external write within grace period after agent write', () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
// External write arrives 500ms later — within 2s grace period
vi.setSystemTime(1500);
const result = recordExternalWrite('proj-1', '/src/main.ts', 1500);
expect(result).toBe(false);
expect(getExternalConflictCount('proj-1')).toBe(0);
vi.useRealTimers();
});
it('detects external write outside grace period', () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
// External write arrives 3s later — outside 2s grace period
vi.setSystemTime(4000);
const result = recordExternalWrite('proj-1', '/src/main.ts', 4000);
expect(result).toBe(true);
expect(getExternalConflictCount('proj-1')).toBe(1);
vi.useRealTimers();
});
it('ignores external write to file no agent has written', () => {
recordFileWrite('proj-1', 'sess-a', '/src/other.ts');
const result = recordExternalWrite('proj-1', '/src/unrelated.ts', Date.now());
expect(result).toBe(false);
});
it('ignores external write for project with no agent writes', () => {
const result = recordExternalWrite('proj-1', '/src/main.ts', Date.now());
expect(result).toBe(false);
});
it('marks conflict as external in getProjectConflicts', () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
vi.setSystemTime(4000);
recordExternalWrite('proj-1', '/src/main.ts', 4000);
const result = getProjectConflicts('proj-1');
expect(result.conflictCount).toBe(1);
expect(result.externalConflictCount).toBe(1);
expect(result.conflicts[0].isExternal).toBe(true);
expect(result.conflicts[0].sessionIds).toContain(EXTERNAL_SESSION_ID);
vi.useRealTimers();
});
it('external conflicts can be acknowledged', () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
vi.setSystemTime(4000);
recordExternalWrite('proj-1', '/src/main.ts', 4000);
expect(hasConflicts('proj-1')).toBe(true);
acknowledgeConflicts('proj-1');
expect(hasConflicts('proj-1')).toBe(false);
expect(getExternalConflictCount('proj-1')).toBe(0);
vi.useRealTimers();
});
it('clearAllConflicts clears external write timestamps', () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
recordFileWrite('proj-1', 'sess-a', '/src/main.ts');
clearAllConflicts();
// After clearing, external writes should not create conflicts (no agent writes tracked)
vi.setSystemTime(4000);
const result = recordExternalWrite('proj-1', '/src/main.ts', 4000);
expect(result).toBe(false);
vi.useRealTimers();
});
it('external conflict coexists with agent-agent conflict', () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
recordFileWrite('proj-1', 'sess-a', '/src/agent.ts');
recordFileWrite('proj-1', 'sess-b', '/src/agent.ts');
recordFileWrite('proj-1', 'sess-a', '/src/ext.ts');
vi.setSystemTime(4000);
recordExternalWrite('proj-1', '/src/ext.ts', 4000);
const result = getProjectConflicts('proj-1');
expect(result.conflictCount).toBe(2);
expect(result.externalConflictCount).toBe(1);
const extConflict = result.conflicts.find(c => c.isExternal);
const agentConflict = result.conflicts.find(c => !c.isExternal);
expect(extConflict?.filePath).toBe('/src/ext.ts');
expect(agentConflict?.filePath).toBe('/src/agent.ts');
vi.useRealTimers();
});
});
});

View file

@ -23,6 +23,8 @@ export interface ProjectHealth {
contextPressure: number | null;
/** Number of file conflicts (2+ agents writing same file) */
fileConflictCount: number;
/** Number of external write conflicts (filesystem writes by non-agent processes) */
externalConflictCount: number;
/** Attention urgency score (higher = more urgent, 0 = no attention needed) */
attentionScore: number;
/** Human-readable attention reason */
@ -235,6 +237,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
// File conflicts
const conflicts = getProjectConflicts(tracker.projectId);
const fileConflictCount = conflicts.conflictCount;
const externalConflictCount = conflicts.externalConflictCount;
// Attention scoring — highest-priority signal wins
let attentionScore = 0;
@ -252,7 +255,8 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
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`;
const extNote = externalConflictCount > 0 ? ` (${externalConflictCount} external)` : '';
attentionReason = `${fileConflictCount} file conflict${fileConflictCount > 1 ? 's' : ''}${extNote}`;
} else if (contextPressure !== null && contextPressure > 0.75) {
attentionScore = SCORE_CONTEXT_HIGH;
attentionReason = `Context ${Math.round(contextPressure * 100)}%`;
@ -267,6 +271,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
burnRatePerHour,
contextPressure,
fileConflictCount,
externalConflictCount,
attentionScore,
attentionReason,
};