diff --git a/server/src/demos/concurrency.ts b/server/src/demos/concurrency.ts index eead83d..d20fda4 100644 --- a/server/src/demos/concurrency.ts +++ b/server/src/demos/concurrency.ts @@ -57,6 +57,30 @@ async function testCAS() { ); check("el mensaje instruye al modelo qué hacer", !!stale?.message.includes("read_file")); check("el archivo NO se pisó", (await readFile(f, "utf8")) === "cambio-1"); + + // Y si el que chocó consigo mismo es el MISMO agente, se le dice así. + // + // El bug que cubre: el mensaje decía "lo modificó agente-1" sin aclarar que + // agente-1 era él. El agente concluía que había otro trabajando, se ponía a + // esperar y releía el archivo una y otra vez por un compañero inexistente. + // Visto en una sesión real: cuatro relecturas y un `sleep 5` para nada. + let propio: StaleContentError | null = null; + try { + await fileMutation.writeIfUnchanged({ + path: f, + content: "cambio-3", + expected: "original", // viejo otra vez, pero ahora lo escribió él mismo + agentId: "agente-1", + }); + } catch (e) { + propio = e instanceof StaleContentError ? e : null; + } + check("rechaza igual si chocó consigo mismo", propio !== null); + check( + "y le dice que fue ÉL, no otro agente", + !!propio?.message.includes("TÚ") && !!propio?.message.includes("nadie más"), + propio?.message ?? "", + ); } async function testParallelDistinctFiles() { diff --git a/server/src/engine/file-mutation.ts b/server/src/engine/file-mutation.ts index ed921a3..b43c384 100644 --- a/server/src/engine/file-mutation.ts +++ b/server/src/engine/file-mutation.ts @@ -21,18 +21,36 @@ export class StaleContentError extends Error { readonly path: string, /** Quién lo tocó y hace cuánto, si se sabe (registro efímero). */ readonly lastWriter?: { agentId: string; agoMs: number }, + /** Quién está intentando escribir ahora, para no atribuirle a otro lo suyo. */ + quienEscribe?: string, ) { - super(StaleContentError.buildMessage(path, lastWriter)); + super(StaleContentError.buildMessage(path, lastWriter, quienEscribe)); this.name = "StaleContentError"; } /** * El mensaje va AL MODELO, no al humano: tiene que decirle qué hacer. * Con atribución fresca es más útil; sin ella degrada pero sigue siendo accionable. + * + * Y si el último escritor fue ÉL MISMO, se le dice así de claro. Antes leía + * "lo modificó agente-1 hace 3s" sin saber que agente-1 era él, concluía que + * había alguien más trabajando, y se ponía a esperar y a releer el archivo una + * y otra vez por un compañero que no existía. Visto en una sesión real: cuatro + * relecturas y un `sleep 5` para nada. */ - private static buildMessage(path: string, w?: { agentId: string; agoMs: number }): string { - const quien = w ? ` Lo modificó ${w.agentId} hace ${Math.round(w.agoMs / 1000)}s.` : ""; - return `El archivo ${path} cambió desde que lo leíste.${quien} Léelo otra vez (read_file) antes de editarlo, y vuelve a aplicar tu cambio sobre el contenido nuevo.`; + private static buildMessage( + path: string, + w?: { agentId: string; agoMs: number }, + quienEscribe?: string, + ): string { + if (!w) { + return `El archivo ${path} cambió desde que lo leíste. Léelo otra vez (read_file) antes de editarlo, y vuelve a aplicar tu cambio sobre el contenido nuevo.`; + } + const hace = Math.round(w.agoMs / 1000); + if (quienEscribe && w.agentId === quienEscribe) { + return `El archivo ${path} cambió desde que lo leíste: lo escribiste TÚ hace ${hace}s y te quedaste con la versión de antes. No hay nadie más trabajando en él. Léelo otra vez (read_file) y aplica tu cambio sobre lo que ahora hay.`; + } + return `El archivo ${path} cambió desde que lo leíste. Lo modificó ${w.agentId} hace ${hace}s. Léelo otra vez (read_file) antes de editarlo, y vuelve a aplicar tu cambio sobre el contenido nuevo.`; } } @@ -78,7 +96,7 @@ export class FileMutation { if (opts.expected !== undefined) { const actual = existsSync(key) ? await readFile(key, "utf8") : null; if (actual !== opts.expected) { - throw new StaleContentError(opts.path, this.writerInfo(key)); + throw new StaleContentError(opts.path, this.writerInfo(key), opts.agentId); } }