- Task state machine: TIMED_OUT state, retriable steps, timeout detection - Paseo hub: paseo-client.ts (HTTP+CLI), PaseoBackend (AgentBackend), 14 tests - Collision detection: collision-detector.ts, conflict-index.ts, ws-frames type - PTY search: ring buffer, search route, capture-pane fallback
116 lines
3.6 KiB
TypeScript
116 lines
3.6 KiB
TypeScript
// v2.8 Collision detection — pure functions that find file overlaps between
|
|
// worktrees/agents editing the same files concurrently. Advisory only; writes
|
|
// are never blocked, but the collision info surfaces in the UI and logs.
|
|
//
|
|
// Severity levels:
|
|
// same_line — the same file, exact same line region
|
|
// adjacent_line — the same file, lines touch or are within 5 lines
|
|
// different_area — the same file, distant lines
|
|
//
|
|
// Pure functions, no side effects. Testable in isolation.
|
|
|
|
export type ConflictSeverity = 'same_line' | 'adjacent_line' | 'different_area';
|
|
|
|
export interface ConflictVerdict {
|
|
filePath: string;
|
|
worktrees: string[];
|
|
severity: ConflictSeverity;
|
|
agents: string[];
|
|
}
|
|
|
|
/**
|
|
* Registry entry for a single file change recorded by a worktree.
|
|
* Stored in the ConflictIndex Map value for each file path.
|
|
*/
|
|
export interface ConflictEntry {
|
|
worktreeId: string;
|
|
agent: string;
|
|
/**
|
|
* Approximate line range touched by the change. undefined when the change
|
|
* creates or deletes the file (full-file collision vs. same-line).
|
|
*/
|
|
lineRange?: { start: number; end: number };
|
|
status: 'pending' | 'applied' | 'reverted';
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* Shape of the conflict index consumed by findConflicts.
|
|
* File path → set of entries from different worktrees/agents.
|
|
*/
|
|
export type ConflictIndexData = ReadonlyMap<string, ReadonlySet<ConflictEntry>>;
|
|
|
|
/**
|
|
* Find file overlaps between `changedFiles` and the conflict index, excluding
|
|
* the caller's own worktree.
|
|
*
|
|
* Returns one ConflictVerdict per file that has entries from other worktrees.
|
|
* Severity is the highest found (same_line > adjacent_line > different_area).
|
|
*/
|
|
export function findConflicts(
|
|
changedFiles: string[],
|
|
worktreeId: string,
|
|
/** Approximate line range for the proposed changes, keyed by file path */
|
|
changedRanges: Map<string, { start: number; end: number }>,
|
|
conflictIndex: ConflictIndexData,
|
|
): ConflictVerdict[] {
|
|
const verdicts: ConflictVerdict[] = [];
|
|
|
|
for (const filePath of changedFiles) {
|
|
const entries = conflictIndex.get(filePath);
|
|
if (!entries || entries.size === 0) continue;
|
|
|
|
// Filter to entries from OTHER worktrees
|
|
const otherEntries = [...entries].filter((e) => e.worktreeId !== worktreeId);
|
|
if (otherEntries.length === 0) continue;
|
|
|
|
const myRange = changedRanges.get(filePath);
|
|
let severity: ConflictSeverity = 'different_area';
|
|
|
|
for (const entry of otherEntries) {
|
|
if (!myRange || !entry.lineRange) {
|
|
// Full-file changes (create/delete) always hit at least different_area
|
|
continue;
|
|
}
|
|
const sev = lineOverlapSeverity(myRange, entry.lineRange);
|
|
if (sev === 'same_line') {
|
|
severity = 'same_line';
|
|
break; // Can't get higher than this
|
|
}
|
|
if (sev === 'adjacent_line' && severity === 'different_area') {
|
|
severity = 'adjacent_line';
|
|
}
|
|
}
|
|
|
|
const worktrees = [...new Set(otherEntries.map((e) => e.worktreeId))];
|
|
const agents = [...new Set(otherEntries.map((e) => e.agent))];
|
|
|
|
verdicts.push({ filePath, worktrees, severity, agents });
|
|
}
|
|
|
|
return verdicts;
|
|
}
|
|
|
|
const ADJACENT_LINE_THRESHOLD = 5;
|
|
|
|
/**
|
|
* Determine severity of overlap between two line ranges.
|
|
*/
|
|
function lineOverlapSeverity(
|
|
a: { start: number; end: number },
|
|
b: { start: number; end: number },
|
|
): ConflictSeverity {
|
|
// Same_line: ranges intersect
|
|
if (a.start <= b.end && b.start <= a.end) {
|
|
return 'same_line';
|
|
}
|
|
|
|
// Adjacent: ranges are within ADJACENT_LINE_THRESHOLD lines of each other
|
|
const gap = a.start > b.end ? a.start - b.end : b.start - a.end;
|
|
if (gap <= ADJACENT_LINE_THRESHOLD) {
|
|
return 'adjacent_line';
|
|
}
|
|
|
|
return 'different_area';
|
|
}
|