- 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
152 lines
4.6 KiB
TypeScript
152 lines
4.6 KiB
TypeScript
// v2.8 In-memory conflict index — tracks which worktrees/agents are editing
|
|
// which files so the collision detector can find overlaps.
|
|
//
|
|
// Singleton exported as `conflictIndex`; imported by pending_changes.ts to
|
|
// register changes at queue time and unregister on worktree teardown.
|
|
//
|
|
// NOT persisted — survives only as long as the BooCoder process. Postgres
|
|
// is the durable record (pending_changes table); this is the hot in-memory
|
|
// probe for concurrent edit warnings.
|
|
|
|
import type { ConflictEntry, ConflictVerdict } from './collision-detector.js';
|
|
import { findConflicts } from './collision-detector.js';
|
|
|
|
export class ConflictIndex {
|
|
/**
|
|
* filePath → Set of ConflictEntry from various worktrees.
|
|
* A single worktree may have multiple entries for the same file
|
|
* (several pending edits to the same file in one session).
|
|
*/
|
|
#map = new Map<string, Set<ConflictEntry>>();
|
|
|
|
// ---- mutation -------------------------------------------------------
|
|
|
|
/**
|
|
* Register that `worktreeId` (agent) is touching `filePath`.
|
|
* Creates an entry in the index so subsequent callers see it as a conflict.
|
|
*/
|
|
registerChange(
|
|
filePath: string,
|
|
worktreeId: string,
|
|
agent: string,
|
|
lineRange?: { start: number; end: number },
|
|
): void {
|
|
let entries = this.#map.get(filePath);
|
|
if (!entries) {
|
|
entries = new Set();
|
|
this.#map.set(filePath, entries);
|
|
}
|
|
entries.add({
|
|
worktreeId,
|
|
agent,
|
|
lineRange,
|
|
status: 'pending' as const,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove all entries for a given worktree. Called on worktree teardown
|
|
* so stale entries don't trigger false warnings.
|
|
*/
|
|
removeWorktree(worktreeId: string): void {
|
|
for (const [filePath, entries] of this.#map) {
|
|
const before = entries.size;
|
|
for (const entry of entries) {
|
|
if (entry.worktreeId === worktreeId) {
|
|
entries.delete(entry);
|
|
}
|
|
}
|
|
if (entries.size === 0) {
|
|
this.#map.delete(filePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove entries older than `maxAgeMs`. Useful as a periodic cleanup
|
|
* when worktree teardown was missed (crash, unclean exit).
|
|
*/
|
|
sweepStale(maxAgeMs: number): number {
|
|
const cutoff = Date.now() - maxAgeMs;
|
|
let removed = 0;
|
|
|
|
for (const [filePath, entries] of this.#map) {
|
|
for (const entry of entries) {
|
|
if (entry.timestamp < cutoff) {
|
|
entries.delete(entry);
|
|
removed++;
|
|
}
|
|
}
|
|
if (entries.size === 0) {
|
|
this.#map.delete(filePath);
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
// ---- query ----------------------------------------------------------
|
|
|
|
/**
|
|
* Query the raw ConflictEntry set for a file path. Returns empty set
|
|
* when there are no entries (never mutated the file).
|
|
*/
|
|
getEntriesFor(filePath: string): ReadonlySet<ConflictEntry> {
|
|
return this.#map.get(filePath) ?? new Set();
|
|
}
|
|
|
|
/**
|
|
* Get all conflict verdicts for a given file path — which other
|
|
* worktrees are touching it. Returns empty when only one worktree
|
|
* has entries (no actual conflict).
|
|
*/
|
|
getConflictsFor(filePath: string): ConflictVerdict[] {
|
|
const entries = this.#map.get(filePath);
|
|
if (!entries || entries.size === 0) return [];
|
|
|
|
// Determine distinct worktree IDs. If only one, no conflict.
|
|
const worktreeIds = new Set<string>();
|
|
for (const e of entries) worktreeIds.add(e.worktreeId);
|
|
if (worktreeIds.size <= 1) return [];
|
|
|
|
// Use the first worktree as the "caller" so findConflicts excludes
|
|
// its entries and returns only entries from OTHER worktrees.
|
|
const caller = [...worktreeIds][0]!;
|
|
return findConflicts(
|
|
[filePath],
|
|
caller,
|
|
new Map(),
|
|
this.#toIndexData(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get conflicts for a set of file changes from a specific worktree.
|
|
* Delegates to the pure findConflicts function.
|
|
*/
|
|
query(
|
|
changedFiles: string[],
|
|
worktreeId: string,
|
|
changedRanges: Map<string, { start: number; end: number }>,
|
|
): ConflictVerdict[] {
|
|
return findConflicts(changedFiles, worktreeId, changedRanges, this.#toIndexData());
|
|
}
|
|
|
|
/**
|
|
* Snapshot the current map for testing/inspection.
|
|
*/
|
|
snapshot(): Map<string, ReadonlySet<ConflictEntry>> {
|
|
return new Map(this.#map);
|
|
}
|
|
|
|
// ---- private --------------------------------------------------------
|
|
|
|
#toIndexData(): ReadonlyMap<string, ReadonlySet<ConflictEntry>> {
|
|
return this.#map as ReadonlyMap<string, ReadonlySet<ConflictEntry>>;
|
|
}
|
|
}
|
|
|
|
// Singleton — the whole BooCoder process shares one conflict index.
|
|
export const conflictIndex = new ConflictIndex();
|