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
216
v2/src-tauri/src/fs_watcher.rs
Normal file
216
v2/src-tauri/src/fs_watcher.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
// Filesystem write detection for project directories
|
||||||
|
// Uses notify crate (inotify on Linux) to detect file modifications.
|
||||||
|
// Emits Tauri events so frontend can detect external writes vs agent-managed writes.
|
||||||
|
|
||||||
|
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
/// Payload emitted on fs-write-detected events
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct FsWritePayload {
|
||||||
|
pub project_id: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub timestamp_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directories to skip when watching recursively
|
||||||
|
const IGNORED_DIRS: &[&str] = &[
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"target",
|
||||||
|
".svelte-kit",
|
||||||
|
"dist",
|
||||||
|
"__pycache__",
|
||||||
|
".next",
|
||||||
|
".nuxt",
|
||||||
|
".cache",
|
||||||
|
"build",
|
||||||
|
];
|
||||||
|
|
||||||
|
struct ProjectWatch {
|
||||||
|
_watcher: RecommendedWatcher,
|
||||||
|
_cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProjectFsWatcher {
|
||||||
|
watches: Mutex<HashMap<String, ProjectWatch>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectFsWatcher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
watches: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start watching a project's CWD for file writes (Create, Modify, Rename).
|
||||||
|
/// Debounces events per-file (100ms) to avoid flooding on rapid writes.
|
||||||
|
pub fn watch_project(
|
||||||
|
&self,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
cwd: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let cwd_path = Path::new(cwd);
|
||||||
|
if !cwd_path.is_dir() {
|
||||||
|
return Err(format!("Not a directory: {cwd}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut watches = self.watches.lock().unwrap();
|
||||||
|
|
||||||
|
// Don't duplicate — unwatch first if already watching
|
||||||
|
if watches.contains_key(project_id) {
|
||||||
|
drop(watches);
|
||||||
|
self.unwatch_project(project_id);
|
||||||
|
watches = self.watches.lock().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let project_id_owned = project_id.to_string();
|
||||||
|
let cwd_owned = cwd.to_string();
|
||||||
|
|
||||||
|
// Per-file debounce state
|
||||||
|
let debounce: std::sync::Arc<Mutex<HashMap<String, Instant>>> =
|
||||||
|
std::sync::Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let debounce_duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
let mut watcher = RecommendedWatcher::new(
|
||||||
|
move |res: Result<Event, notify::Error>| {
|
||||||
|
let event = match res {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only care about file writes (create, modify, rename-to)
|
||||||
|
let is_write = matches!(
|
||||||
|
event.kind,
|
||||||
|
EventKind::Create(_) | EventKind::Modify(_)
|
||||||
|
);
|
||||||
|
if !is_write {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in &event.paths {
|
||||||
|
// Skip directories
|
||||||
|
if path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_str = path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Skip ignored directories
|
||||||
|
if should_ignore_path(&path_str) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: skip if same file was emitted within debounce window
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut db = debounce.lock().unwrap();
|
||||||
|
if let Some(last) = db.get(&path_str) {
|
||||||
|
if now.duration_since(*last) < debounce_duration {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.insert(path_str.clone(), now);
|
||||||
|
// Prune old debounce entries (keep map from growing unbounded)
|
||||||
|
if db.len() > 1000 {
|
||||||
|
let max_age = debounce_duration * 10;
|
||||||
|
db.retain(|_, v| now.duration_since(*v) < max_age);
|
||||||
|
}
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
let timestamp_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
|
||||||
|
let _ = app_handle.emit(
|
||||||
|
"fs-write-detected",
|
||||||
|
FsWritePayload {
|
||||||
|
project_id: project_id_owned.clone(),
|
||||||
|
file_path: path_str,
|
||||||
|
timestamp_ms,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Config::default(),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to create fs watcher: {e}"))?;
|
||||||
|
|
||||||
|
watcher
|
||||||
|
.watch(cwd_path, RecursiveMode::Recursive)
|
||||||
|
.map_err(|e| format!("Failed to watch directory: {e}"))?;
|
||||||
|
|
||||||
|
log::info!("Started fs watcher for project {project_id} at {cwd}");
|
||||||
|
|
||||||
|
watches.insert(
|
||||||
|
project_id.to_string(),
|
||||||
|
ProjectWatch {
|
||||||
|
_watcher: watcher,
|
||||||
|
_cwd: cwd_owned,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop watching a project's CWD
|
||||||
|
pub fn unwatch_project(&self, project_id: &str) {
|
||||||
|
let mut watches = self.watches.lock().unwrap();
|
||||||
|
if watches.remove(project_id).is_some() {
|
||||||
|
log::info!("Stopped fs watcher for project {project_id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a path contains any ignored directory component
|
||||||
|
fn should_ignore_path(path: &str) -> bool {
|
||||||
|
for component in Path::new(path).components() {
|
||||||
|
if let std::path::Component::Normal(name) = component {
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
if IGNORED_DIRS.contains(&name_str.as_ref()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_ignore_git() {
|
||||||
|
assert!(should_ignore_path("/home/user/project/.git/objects/abc"));
|
||||||
|
assert!(should_ignore_path("/home/user/project/.git/HEAD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_ignore_node_modules() {
|
||||||
|
assert!(should_ignore_path("/project/node_modules/pkg/index.js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_ignore_target() {
|
||||||
|
assert!(should_ignore_path("/project/target/debug/build/foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_not_ignore_src() {
|
||||||
|
assert!(!should_ignore_path("/project/src/main.rs"));
|
||||||
|
assert!(!should_ignore_path("/project/src/lib/stores/health.svelte.ts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_not_ignore_root_file() {
|
||||||
|
assert!(!should_ignore_path("/project/Cargo.toml"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
mod ctx;
|
mod ctx;
|
||||||
mod event_sink;
|
mod event_sink;
|
||||||
|
mod fs_watcher;
|
||||||
mod groups;
|
mod groups;
|
||||||
mod pty;
|
mod pty;
|
||||||
mod remote;
|
mod remote;
|
||||||
|
|
@ -15,6 +16,7 @@ use pty::{PtyManager, PtyOptions};
|
||||||
use remote::{RemoteManager, RemoteMachineConfig};
|
use remote::{RemoteManager, RemoteMachineConfig};
|
||||||
use session::{Session, SessionDb, LayoutState, SshSession, AgentMessageRecord, ProjectAgentState};
|
use session::{Session, SessionDb, LayoutState, SshSession, AgentMessageRecord, ProjectAgentState};
|
||||||
use sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
|
use sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
|
||||||
|
use fs_watcher::ProjectFsWatcher;
|
||||||
use watcher::FileWatcherManager;
|
use watcher::FileWatcherManager;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{Manager, State};
|
use tauri::{Manager, State};
|
||||||
|
|
@ -24,6 +26,7 @@ struct AppState {
|
||||||
sidecar_manager: Arc<SidecarManager>,
|
sidecar_manager: Arc<SidecarManager>,
|
||||||
session_db: Arc<SessionDb>,
|
session_db: Arc<SessionDb>,
|
||||||
file_watcher: Arc<FileWatcherManager>,
|
file_watcher: Arc<FileWatcherManager>,
|
||||||
|
fs_watcher: Arc<ProjectFsWatcher>,
|
||||||
ctx_db: Arc<CtxDb>,
|
ctx_db: Arc<CtxDb>,
|
||||||
remote_manager: Arc<RemoteManager>,
|
remote_manager: Arc<RemoteManager>,
|
||||||
_telemetry: telemetry::TelemetryGuard,
|
_telemetry: telemetry::TelemetryGuard,
|
||||||
|
|
@ -111,6 +114,23 @@ fn file_read(state: State<'_, AppState>, path: String) -> Result<String, String>
|
||||||
state.file_watcher.read_file(&path)
|
state.file_watcher.read_file(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Project filesystem watcher commands (S-1 Phase 2) ---
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn fs_watch_project(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
project_id: String,
|
||||||
|
cwd: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.fs_watcher.watch_project(&app, &project_id, &cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn fs_unwatch_project(state: State<'_, AppState>, project_id: String) {
|
||||||
|
state.fs_watcher.unwatch_project(&project_id);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Session persistence commands ---
|
// --- Session persistence commands ---
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -736,6 +756,8 @@ pub fn run() {
|
||||||
file_watch,
|
file_watch,
|
||||||
file_unwatch,
|
file_unwatch,
|
||||||
file_read,
|
file_read,
|
||||||
|
fs_watch_project,
|
||||||
|
fs_unwatch_project,
|
||||||
session_list,
|
session_list,
|
||||||
session_save,
|
session_save,
|
||||||
session_delete,
|
session_delete,
|
||||||
|
|
@ -831,6 +853,7 @@ pub fn run() {
|
||||||
);
|
);
|
||||||
|
|
||||||
let file_watcher = Arc::new(FileWatcherManager::new());
|
let file_watcher = Arc::new(FileWatcherManager::new());
|
||||||
|
let fs_watcher = Arc::new(ProjectFsWatcher::new());
|
||||||
let ctx_db = Arc::new(CtxDb::new());
|
let ctx_db = Arc::new(CtxDb::new());
|
||||||
let remote_manager = Arc::new(RemoteManager::new());
|
let remote_manager = Arc::new(RemoteManager::new());
|
||||||
|
|
||||||
|
|
@ -845,6 +868,7 @@ pub fn run() {
|
||||||
sidecar_manager,
|
sidecar_manager,
|
||||||
session_db,
|
session_db,
|
||||||
file_watcher,
|
file_watcher,
|
||||||
|
fs_watcher,
|
||||||
ctx_db,
|
ctx_db,
|
||||||
remote_manager,
|
remote_manager,
|
||||||
_telemetry: telemetry_guard,
|
_telemetry: telemetry_guard,
|
||||||
|
|
|
||||||
28
v2/src/lib/adapters/fs-watcher-bridge.ts
Normal file
28
v2/src/lib/adapters/fs-watcher-bridge.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Filesystem watcher bridge — listens for inotify-based write events from Rust
|
||||||
|
// Part of S-1 Phase 2: real-time filesystem write detection
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
export interface FsWriteEvent {
|
||||||
|
project_id: string;
|
||||||
|
file_path: string;
|
||||||
|
timestamp_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start watching a project's CWD for filesystem writes */
|
||||||
|
export function fsWatchProject(projectId: string, cwd: string): Promise<void> {
|
||||||
|
return invoke('fs_watch_project', { projectId, cwd });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop watching a project's CWD */
|
||||||
|
export function fsUnwatchProject(projectId: string): Promise<void> {
|
||||||
|
return invoke('fs_unwatch_project', { projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listen for filesystem write events from all watched projects */
|
||||||
|
export function onFsWriteDetected(
|
||||||
|
callback: (event: FsWriteEvent) => void,
|
||||||
|
): Promise<UnlistenFn> {
|
||||||
|
return listen<FsWriteEvent>('fs-write-detected', (e) => callback(e.payload));
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import type { ProjectConfig } from '../../types/groups';
|
import type { ProjectConfig } from '../../types/groups';
|
||||||
import { PROJECT_ACCENTS } from '../../types/groups';
|
import { PROJECT_ACCENTS } from '../../types/groups';
|
||||||
import ProjectHeader from './ProjectHeader.svelte';
|
import ProjectHeader from './ProjectHeader.svelte';
|
||||||
|
|
@ -12,6 +13,9 @@
|
||||||
import MemoriesTab from './MemoriesTab.svelte';
|
import MemoriesTab from './MemoriesTab.svelte';
|
||||||
import { getTerminalTabs } from '../../stores/workspace.svelte';
|
import { getTerminalTabs } from '../../stores/workspace.svelte';
|
||||||
import { getProjectHealth } from '../../stores/health.svelte';
|
import { getProjectHealth } from '../../stores/health.svelte';
|
||||||
|
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected } from '../../adapters/fs-watcher-bridge';
|
||||||
|
import { recordExternalWrite } from '../../stores/conflicts.svelte';
|
||||||
|
import { notify } from '../../stores/notifications.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: ProjectConfig;
|
project: ProjectConfig;
|
||||||
|
|
@ -47,6 +51,35 @@
|
||||||
function toggleTerminal() {
|
function toggleTerminal() {
|
||||||
terminalExpanded = !terminalExpanded;
|
terminalExpanded = !terminalExpanded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S-1 Phase 2: start filesystem watcher for this project's CWD
|
||||||
|
$effect(() => {
|
||||||
|
const cwd = project.cwd;
|
||||||
|
const projectId = project.id;
|
||||||
|
if (!cwd) return;
|
||||||
|
|
||||||
|
// Start watching
|
||||||
|
fsWatchProject(projectId, cwd).catch(e =>
|
||||||
|
console.warn(`Failed to start fs watcher for ${projectId}:`, e)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for fs write events (filter to this project)
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
onFsWriteDetected((event) => {
|
||||||
|
if (event.project_id !== projectId) return;
|
||||||
|
const isNew = recordExternalWrite(projectId, event.file_path, event.timestamp_ms);
|
||||||
|
if (isNew) {
|
||||||
|
const shortName = event.file_path.split('/').pop() ?? event.file_path;
|
||||||
|
notify('warning', `External write: ${shortName} — file also modified by agent`);
|
||||||
|
}
|
||||||
|
}).then(fn => { unlisten = fn; });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup: stop watching on unmount or project change
|
||||||
|
fsUnwatchProject(projectId).catch(() => {});
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -81,13 +81,23 @@
|
||||||
<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}
|
{#if health && health.externalConflictCount > 0}
|
||||||
<button
|
<button
|
||||||
class="info-conflict"
|
class="info-conflict info-conflict-external"
|
||||||
title="{health.fileConflictCount} file conflict{health.fileConflictCount > 1 ? 's' : ''} — click to dismiss"
|
title="{health.externalConflictCount} external write{health.externalConflictCount > 1 ? 's' : ''} — files modified outside agent — click to dismiss"
|
||||||
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(project.id); }}
|
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(project.id); }}
|
||||||
>
|
>
|
||||||
⚠ {health.fileConflictCount} conflict{health.fileConflictCount > 1 ? 's' : ''} ✕
|
⚡ {health.externalConflictCount} ext write{health.externalConflictCount > 1 ? 's' : ''} ✕
|
||||||
|
</button>
|
||||||
|
<span class="info-sep">·</span>
|
||||||
|
{/if}
|
||||||
|
{#if health && health.fileConflictCount - (health.externalConflictCount ?? 0) > 0}
|
||||||
|
<button
|
||||||
|
class="info-conflict"
|
||||||
|
title="{health.fileConflictCount - (health.externalConflictCount ?? 0)} agent conflict{health.fileConflictCount - (health.externalConflictCount ?? 0) > 1 ? 's' : ''} — click to dismiss"
|
||||||
|
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(project.id); }}
|
||||||
|
>
|
||||||
|
⚠ {health.fileConflictCount - (health.externalConflictCount ?? 0)} conflict{health.fileConflictCount - (health.externalConflictCount ?? 0) > 1 ? 's' : ''} ✕
|
||||||
</button>
|
</button>
|
||||||
<span class="info-sep">·</span>
|
<span class="info-sep">·</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -253,6 +263,15 @@
|
||||||
background: color-mix(in srgb, var(--ctp-red) 25%, transparent);
|
background: color-mix(in srgb, var(--ctp-red) 25%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-conflict-external {
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-conflict-external:hover {
|
||||||
|
background: color-mix(in srgb, var(--ctp-peach) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.info-profile {
|
.info-profile {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--ctp-blue);
|
color: var(--ctp-blue);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
// File overlap conflict detection — Svelte 5 runes
|
// File overlap conflict detection — Svelte 5 runes
|
||||||
// Tracks which files each agent session writes to per project.
|
// Tracks which files each agent session writes to per project.
|
||||||
// Detects when two or more sessions write to the same file (file overlap conflict).
|
// 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 {
|
export interface FileConflict {
|
||||||
/** Absolute file path */
|
/** Absolute file path */
|
||||||
|
|
@ -11,6 +15,8 @@ export interface FileConflict {
|
||||||
sessionIds: string[];
|
sessionIds: string[];
|
||||||
/** Timestamp of most recent write */
|
/** Timestamp of most recent write */
|
||||||
lastWriteTs: number;
|
lastWriteTs: number;
|
||||||
|
/** True if this conflict involves an external (non-agent) writer */
|
||||||
|
isExternal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectConflicts {
|
export interface ProjectConflicts {
|
||||||
|
|
@ -19,6 +25,8 @@ export interface ProjectConflicts {
|
||||||
conflicts: FileConflict[];
|
conflicts: FileConflict[];
|
||||||
/** Total conflicting files */
|
/** Total conflicting files */
|
||||||
conflictCount: number;
|
conflictCount: number;
|
||||||
|
/** Number of files with external write conflicts */
|
||||||
|
externalConflictCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
@ -37,6 +45,13 @@ let acknowledgedFiles = $state<Map<string, Set<string>>>(new Map());
|
||||||
// sessionId -> worktree path (null = main working tree)
|
// sessionId -> worktree path (null = main working tree)
|
||||||
let sessionWorktrees = $state<Map<string, string | null>>(new Map());
|
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 ---
|
// --- Public API ---
|
||||||
|
|
||||||
/** Register the worktree path for a session (null = main working tree) */
|
/** 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);
|
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);
|
let entry = projectMap.get(filePath);
|
||||||
const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false;
|
const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false;
|
||||||
|
|
||||||
|
|
@ -88,6 +113,47 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
|
||||||
return isNewConflict;
|
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
|
* 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.
|
* (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) */
|
/** Get all conflicts for a project (excludes acknowledged and worktree-suppressed) */
|
||||||
export function getProjectConflicts(projectId: string): ProjectConflicts {
|
export function getProjectConflicts(projectId: string): ProjectConflicts {
|
||||||
const projectMap = projectFileWrites.get(projectId);
|
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 ackSet = acknowledgedFiles.get(projectId);
|
||||||
const conflicts: FileConflict[] = [];
|
const conflicts: FileConflict[] = [];
|
||||||
|
let externalConflictCount = 0;
|
||||||
for (const [filePath, entry] of projectMap) {
|
for (const [filePath, entry] of projectMap) {
|
||||||
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) {
|
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) {
|
||||||
|
const isExternal = entry.sessionIds.has(EXTERNAL_SESSION_ID);
|
||||||
|
if (isExternal) externalConflictCount++;
|
||||||
conflicts.push({
|
conflicts.push({
|
||||||
filePath,
|
filePath,
|
||||||
shortName: filePath.split('/').pop() ?? filePath,
|
shortName: filePath.split('/').pop() ?? filePath,
|
||||||
sessionIds: Array.from(entry.sessionIds),
|
sessionIds: Array.from(entry.sessionIds),
|
||||||
lastWriteTs: entry.lastWriteTs,
|
lastWriteTs: entry.lastWriteTs,
|
||||||
|
isExternal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Most recent conflicts first
|
// Most recent conflicts first
|
||||||
conflicts.sort((a, b) => b.lastWriteTs - a.lastWriteTs);
|
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 */
|
/** 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 {
|
export function clearProjectConflicts(projectId: string): void {
|
||||||
projectFileWrites.delete(projectId);
|
projectFileWrites.delete(projectId);
|
||||||
acknowledgedFiles.delete(projectId);
|
acknowledgedFiles.delete(projectId);
|
||||||
|
agentWriteTimestamps.delete(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear all conflict state */
|
/** Clear all conflict state */
|
||||||
|
|
@ -207,4 +278,5 @@ export function clearAllConflicts(): void {
|
||||||
projectFileWrites = new Map();
|
projectFileWrites = new Map();
|
||||||
acknowledgedFiles = new Map();
|
acknowledgedFiles = new Map();
|
||||||
sessionWorktrees = new Map();
|
sessionWorktrees = new Map();
|
||||||
|
agentWriteTimestamps = new Map();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import {
|
import {
|
||||||
recordFileWrite,
|
recordFileWrite,
|
||||||
|
recordExternalWrite,
|
||||||
getProjectConflicts,
|
getProjectConflicts,
|
||||||
|
getExternalConflictCount,
|
||||||
hasConflicts,
|
hasConflicts,
|
||||||
getTotalConflictCount,
|
getTotalConflictCount,
|
||||||
clearSessionWrites,
|
clearSessionWrites,
|
||||||
|
|
@ -9,6 +11,7 @@ import {
|
||||||
clearAllConflicts,
|
clearAllConflicts,
|
||||||
acknowledgeConflicts,
|
acknowledgeConflicts,
|
||||||
setSessionWorktree,
|
setSessionWorktree,
|
||||||
|
EXTERNAL_SESSION_ID,
|
||||||
} from './conflicts.svelte';
|
} from './conflicts.svelte';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -235,4 +238,98 @@ describe('conflicts store', () => {
|
||||||
expect(hasConflicts('proj-1')).toBe(true);
|
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;
|
contextPressure: number | null;
|
||||||
/** Number of file conflicts (2+ agents writing same file) */
|
/** Number of file conflicts (2+ agents writing same file) */
|
||||||
fileConflictCount: number;
|
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) */
|
/** Attention urgency score (higher = more urgent, 0 = no attention needed) */
|
||||||
attentionScore: number;
|
attentionScore: number;
|
||||||
/** Human-readable attention reason */
|
/** Human-readable attention reason */
|
||||||
|
|
@ -235,6 +237,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
// File conflicts
|
// File conflicts
|
||||||
const conflicts = getProjectConflicts(tracker.projectId);
|
const conflicts = getProjectConflicts(tracker.projectId);
|
||||||
const fileConflictCount = conflicts.conflictCount;
|
const fileConflictCount = conflicts.conflictCount;
|
||||||
|
const externalConflictCount = conflicts.externalConflictCount;
|
||||||
|
|
||||||
// Attention scoring — highest-priority signal wins
|
// Attention scoring — highest-priority signal wins
|
||||||
let attentionScore = 0;
|
let attentionScore = 0;
|
||||||
|
|
@ -252,7 +255,8 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
attentionReason = `Context ${Math.round(contextPressure * 100)}% — near limit`;
|
attentionReason = `Context ${Math.round(contextPressure * 100)}% — near limit`;
|
||||||
} else if (fileConflictCount > 0) {
|
} else if (fileConflictCount > 0) {
|
||||||
attentionScore = SCORE_FILE_CONFLICT;
|
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) {
|
} 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)}%`;
|
||||||
|
|
@ -267,6 +271,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
|
||||||
burnRatePerHour,
|
burnRatePerHour,
|
||||||
contextPressure,
|
contextPressure,
|
||||||
fileConflictCount,
|
fileConflictCount,
|
||||||
|
externalConflictCount,
|
||||||
attentionScore,
|
attentionScore,
|
||||||
attentionReason,
|
attentionReason,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue