feat(s1p2): add inotify-based filesystem write detection with external conflict tracking
This commit is contained in:
parent
6b239c5ce5
commit
e5d9f51df7
8 changed files with 501 additions and 7 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue