create-panal-agent 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-panal-agent",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Crea un agente de IA para Panal, funcionando y cobrando on-chain, en cinco minutos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,6 @@
42
42
  "scripts": {
43
43
  "build": "tsc -p tsconfig.json",
44
44
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
- "test": "tsx test/i18n.test.ts && tsx test/memoria.test.ts && tsx test/reintento.test.ts && tsx test/zip.test.ts && tsx test/adjuntos.test.ts && tsx test/salida.test.ts && tsx test/scaffold.test.ts"
45
+ "test": "tsx test/i18n.test.ts && tsx test/memoria.test.ts && tsx test/reintento.test.ts && tsx test/zip.test.ts && tsx test/adjuntos.test.ts && tsx test/salida.test.ts && tsx test/vigilante.test.ts && tsx test/scaffold.test.ts"
46
46
  }
47
47
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
15
15
  import { leerAdjuntos, type AdjuntoRecibido, type AdjuntosLeidos } from './adjuntos.js';
16
- import { comoArchivo, formatoPedido } from './salida.js';
16
+ import { comoArchivo, formatoPedido, nombreDelTema } from './salida.js';
17
17
  import { historialComoTexto, type Turno } from './memoria.js';
18
18
 
19
19
  /**
@@ -199,7 +199,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
199
199
  const problema = revisar(brief, texto);
200
200
  if (!problema) {
201
201
  console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
202
- return conArchivoSiLoPidio(brief, texto, ctx);
202
+ return await conArchivoSiLoPidio(brief, texto, ctx);
203
203
  }
204
204
  console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
205
205
  // A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
@@ -207,7 +207,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
207
207
  // entregar algo imperfecto y que él decida, que dejarlo sin nada.
208
208
  if (intento === 2) {
209
209
  console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
210
- return conArchivoSiLoPidio(brief, texto, ctx);
210
+ return await conArchivoSiLoPidio(brief, texto, ctx);
211
211
  }
212
212
  queja = problema;
213
213
  }
@@ -372,10 +372,10 @@ function revisar(brief: string, resultado: string): string | null {
372
372
  * lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
373
373
  * se baja es exactamente el que le entregaste.
374
374
  */
375
- function conArchivoSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
375
+ async function conArchivoSiLoPidio(brief: string, texto: string, ctx: TaskContext): Promise<TaskResult> {
376
376
  const formato = formatoPedido(brief);
377
377
  if (!formato) return texto;
378
- const archivo = comoArchivo(formato, 'entrega', `Panal - entrega ${etiqueta(ctx)}`, texto);
378
+ const archivo = comoArchivo(formato, await nombreDelTema(brief, 'entrega'), `Panal - entrega ${etiqueta(ctx)}`, texto);
379
379
  const bytes = typeof archivo.data === 'string' ? archivo.data.length : archivo.data.byteLength;
380
380
  console.log(`[agente] ${etiqueta(ctx)} ${archivo.name} de ${bytes} bytes adjunto`);
381
381
  // El TEXTO se sigue entregando: es lo que se ancla en la cadena. El archivo
@@ -38,6 +38,27 @@ const PERFIL = {
38
38
  // vacía NO la sustituye `??`. Con `??` el botUrl acababa siendo '' y la
39
39
  // comprobación de "no has puesto tu URL" no saltaba nunca.
40
40
  botUrl: process.env.PUBLIC_URL?.trim() || 'https://cambia-esto.example.com',
41
+
42
+ // TU CARA, si quieres tenerla. Todo esto es opcional y va vacío por defecto:
43
+ // un agente sin logo no vale menos, es lo que hay hoy en todo el mercado.
44
+ //
45
+ // Lo que compra ponerlo es que el cliente pueda MIRARTE antes de pagarte. En
46
+ // el mercado sales entre desconocidos, y un repositorio que se puede abrir
47
+ // dice más de ti que cualquier descripción que escribas de ti mismo.
48
+ //
49
+ // Se guarda en tu ficha del registro, así que cambiarlo cuesta una
50
+ // transacción: `npm run register` otra vez y ya.
51
+ links: {
52
+ // El logo sale en tu tarjeta, en el mercado y en la app. Https, cuadrado y
53
+ // pequeño: se pinta a 56 px, no hace falta más.
54
+ logo: '',
55
+ web: '',
56
+ // Tu perfil o el repositorio del agente: valen `usuario` y `usuario/repo`.
57
+ github: '',
58
+ // Solo el usuario; también se traga el enlace entero si lo pegas.
59
+ x: '',
60
+ telegram: '',
61
+ },
41
62
  };
42
63
 
43
64
  /** Lo que cobras por tarea. */
@@ -15,6 +15,7 @@
15
15
  * peso y confusión.
16
16
  */
17
17
 
18
+ import { llmChat, resolverLlm, stripFilesManifest } from '@panal/sdk';
18
19
  import { textoAPdf } from './pdf.js';
19
20
  import { escribirZip } from './zip.js';
20
21
 
@@ -36,7 +37,20 @@ export interface ArchivoDeSalida {
36
37
  * Devuelve `null` cuando no pide nada, que es el caso normal.
37
38
  */
38
39
  export function formatoPedido(brief: string): Formato | null {
39
- const t = brief.toLowerCase();
40
+ // El manifiesto de adjuntos NO es lo que pidio el cliente: es contabilidad
41
+ // del protocolo, y va pegada al FINAL del brief. Sin quitarla, sus lineas
42
+ // `name:` y `mime:` son las ultimas menciones de un formato que hay en el
43
+ // texto, y esta funcion se queda justamente con la ultima.
44
+ //
45
+ // El efecto es que el adjunto elige el formato de SALIDA. Comprobado en un
46
+ // encargo real de mainnet (#67): el cliente pidio JSON, adjunto un `.txt`, y
47
+ // el manifiesto —`name: pedidos n.txt`, `mime: text/plain`— gano al «devuelve
48
+ // solo el JSON» que estaba escrito antes. Se entrego un .txt.
49
+ //
50
+ // No es raro ni un caso de laboratorio: casi todo adjunto lleva en el nombre
51
+ // una extension que aqui es un formato. Adjuntar un PDF hacia que la entrega
52
+ // fuera un PDF, se pidiera lo que se pidiera.
53
+ const t = stripFilesManifest(brief).toLowerCase();
40
54
  const mencion: { formato: Formato; en: number }[] = [];
41
55
 
42
56
  for (const [formato, patron] of PATRONES) {
@@ -334,6 +348,159 @@ export function comoTabla(texto: string): string | null {
334
348
  ].join('\n');
335
349
  }
336
350
 
351
+ /* ── cómo se llama el archivo ────────────────────────────────────────────── */
352
+
353
+ /**
354
+ * Los reservados de Windows y los separadores de ruta.
355
+ *
356
+ * NO se tocan las letras: un nombre en chino, en árabe o con tildes es un
357
+ * nombre perfectamente válido, y quitárselos sería justo lo contrario de lo
358
+ * que hace falta aquí.
359
+ */
360
+ const PROHIBIDOS = /[/\\:*?"<>|]/g;
361
+
362
+ /** Cuántos caracteres como mucho. Un nombre no es un resumen. */
363
+ const MAX_NOMBRE = 60;
364
+
365
+ /** ¿Es un carácter imprimible? Los de control no valen en un nombre. */
366
+ function imprimible(c: string): boolean {
367
+ const p = c.codePointAt(0) ?? 0;
368
+ return p >= 0x20 && p !== 0x7f;
369
+ }
370
+
371
+ /**
372
+ * Un título cualquiera, convertido en nombre de archivo.
373
+ *
374
+ * Se exporta para poder probarlo sin gastar una llamada al modelo.
375
+ */
376
+ export function comoNombre(crudo: string): string {
377
+ const linea = crudo.split(/\r?\n/).find((l) => l.trim()) ?? '';
378
+ const limpio = [...linea]
379
+ .filter(imprimible)
380
+ .join('')
381
+ .trim()
382
+ // El modelo devuelve el título entrecomillado más veces de las que parece.
383
+ .replace(/^["'`«“]+|["'`»”]+$/g, '')
384
+ // Y a veces le pone extensión, que aquí la pone `comoArchivo`.
385
+ .replace(/\.(pdf|docx?|xlsx?|md|txt|csv|json|zip)$/i, '')
386
+ .replace(PROHIBIDOS, ' ')
387
+ .replace(/\s+/g, '-')
388
+ .replace(/-{2,}/g, '-')
389
+ .replace(/^[-.]+|[-.]+$/g, '')
390
+ .toLowerCase();
391
+ // Por code points y no con `.slice`: cortar por unidades UTF-16 parte por la
392
+ // mitad un carácter fuera del plano básico.
393
+ return [...limpio].slice(0, MAX_NOMBRE).join('').replace(/[-.]+$/, '');
394
+ }
395
+
396
+ /**
397
+ * La cabecera `content-disposition` de un archivo que se descarga.
398
+ *
399
+ * DOS FORMAS DEL NOMBRE, Y LAS DOS HACEN FALTA (RFC 6266). Una cabecera HTTP
400
+ * solo admite latin-1, y desde que el nombre del archivo lo escribe el modelo
401
+ * en el idioma del cliente, un entregable puede llamarse
402
+ * `两个整数相除.pdf`. Interpolarlo tal cual en `filename="…"` no da un nombre
403
+ * feo: Node LANZA `ERR_INVALID_CHAR` al escribir la cabecera y la descarga
404
+ * responde 500. El archivo estaba entregado, pagado y anclado en la cadena, y
405
+ * el cliente no podía bajárselo.
406
+ *
407
+ * filename= una versión en ASCII, para quien no entienda lo otro
408
+ * filename*= el nombre de verdad, en UTF-8 percent-encoded
409
+ *
410
+ * Los navegadores prefieren `filename*` cuando está, así que el nombre bueno
411
+ * es el que se ve. Y las comillas se van del ASCII a propósito: una comilla
412
+ * dentro de `filename="…"` parte la cabecera por la mitad.
413
+ */
414
+ export function comoAdjunto(nombre: string): string {
415
+ const ascii =
416
+ [...nombre]
417
+ .map((c) => {
418
+ const p = c.codePointAt(0) ?? 0;
419
+ return p >= 0x20 && p < 0x7f && c !== '"' && c !== '\\' ? c : '_';
420
+ })
421
+ .join('')
422
+ .replace(/_{2,}/g, '_')
423
+ .replace(/^[_.]+|[_.]+$/g, '') || 'archivo';
424
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(nombre)}`;
425
+ }
426
+
427
+ /**
428
+ * Lo que se le pide al modelo. Corto a propósito: es un nombre, no un resumen.
429
+ *
430
+ * Las dos reglas que de verdad cambian el resultado son las dos últimas. Sin
431
+ * la del SUJETO, el modelo nombra la acción —«escribir-casos-de-prueba»— y
432
+ * todos los archivos de un mismo agente vuelven a llamarse igual, que es el
433
+ * problema que esto viene a arreglar. Y sin la del IDIOMA contesta en inglés
434
+ * aunque el encargo venga en otro, porque el inglés es su idioma por defecto.
435
+ */
436
+ const PIDE_UN_NOMBRE =
437
+ 'You name files. Given a client request, reply with ONLY a file name for the deliverable.\n' +
438
+ 'Two to five words. No extension, no quotes, no path, no explanation, no punctuation at the ends.\n' +
439
+ 'Name the SUBJECT the work is about, never the action asked for: for "write the test cases for a ' +
440
+ 'function that divides two integers" answer "division de dos enteros", not "escribir casos de prueba".\n' +
441
+ 'Write it in the SAME language the client wrote in, in their own script. Do not translate it to English.';
442
+
443
+ /**
444
+ * El nombre del archivo que se entrega, sacado del TEMA del encargo.
445
+ *
446
+ * ANTES TODOS SE LLAMABAN IGUAL. Cada agente tenía un nombre fijo —
447
+ * `casos-de-prueba.pdf`, `revision.pdf`, `traducciones.pdf`—, así que un
448
+ * cliente que encargara tres cosas al mismo agente acababa con tres archivos
449
+ * del mismo nombre en su carpeta de descargas, pisándose unos a otros o
450
+ * quedando como «casos-de-prueba (2).pdf». Y estaba en castellano para todo el
451
+ * mundo, cuando el contenido va en el idioma del cliente desde hace tiempo.
452
+ *
453
+ * EL TEMA SALE DEL ENCARGO, no de la entrega. La entrega es texto plano sin
454
+ * título —el prompt prohíbe los encabezados a propósito—, así que su primera
455
+ * línea es el primer caso de prueba, no de qué va la cosa. El encargo, en
456
+ * cambio, lo escribió el cliente: dice el tema y está en su idioma.
457
+ *
458
+ * NUNCA LANZA Y NUNCA DEVUELVE VACÍO. Si el modelo no contesta, tarda o
459
+ * devuelve algo que no sirve, se usa el nombre de siempre. Nombrar un archivo
460
+ * no puede impedir entregarlo: el pago ya está bloqueado.
461
+ */
462
+ export async function nombreDelTema(brief: string, deReserva: string): Promise<string> {
463
+ try {
464
+ const cfg = resolverLlm(process.env);
465
+ const respuesta = await llmChat(
466
+ // NO SE TOCA NI LA TEMPERATURA NI EL TOPE DE TOKENS, y las dos cosas se
467
+ // aprendieron probando contra el modelo de verdad.
468
+ //
469
+ // Aquí ponía `temperature: 0` —lo natural para pedir algo determinista— y
470
+ // el modelo lo rechazaba con un 400: hay modelos que solo aceptan 1. Y
471
+ // ponía `maxTokens: 32` —es un nombre, no un texto— y la respuesta volvía
472
+ // con `choices` vacío, porque un modelo que razona antes de contestar se
473
+ // gasta ese presupuesto pensando y no le queda para escribir.
474
+ //
475
+ // Los dos fallos son INVISIBLES: se cae al nombre de siempre, que es
476
+ // exactamente lo que había antes, así que la función habría quedado
477
+ // muerta sin que nadie lo notara. Se hereda lo que el operador ya tiene
478
+ // configurado, que es lo que funciona en el resto de sus llamadas.
479
+ //
480
+ // Lo único propio es el reloj: un timeout más corto que el del trabajo y
481
+ // un solo reintento, porque esto va DESPUÉS de tener la entrega hecha y
482
+ // no puede retrasarla.
483
+ { ...cfg, timeoutMs: 20_000, maxRetries: 1 },
484
+ // El encargo entero no hace falta: el tema está al principio, y mandarlo
485
+ // completo puede ser mandar un contrato de treinta páginas para sacar
486
+ // cuatro palabras.
487
+ // Sin el manifiesto, por lo mismo que en `formatoPedido`: son 1.500
488
+ // caracteres de presupuesto y un hash de 64 ocupa sitio sin decir nada
489
+ // del tema. Con adjuntos cortos llegaba a colarse entero.
490
+ { system: PIDE_UN_NOMBRE, user: stripFilesManifest(brief).trim().slice(0, 1_500) },
491
+ );
492
+ const nombre = comoNombre(respuesta);
493
+ if (nombre) return nombre;
494
+ console.warn(`[salida] el modelo no dio un nombre usable; el archivo va como «${deReserva}»`);
495
+ } catch (err) {
496
+ console.warn(
497
+ `[salida] no se pudo nombrar el archivo (${err instanceof Error ? err.message.split('\n')[0] : err}); ` +
498
+ `va como «${deReserva}»`,
499
+ );
500
+ }
501
+ return deReserva;
502
+ }
503
+
337
504
  /**
338
505
  * El archivo listo para adjuntar a la entrega.
339
506
  *
@@ -47,6 +47,7 @@ import {
47
47
  type DeliveredFile,
48
48
  type PermitDomain,
49
49
  } from '@panal/sdk';
50
+ import { comoAdjunto } from './salida.js';
50
51
  import { privateKeyToAccount } from 'viem/accounts';
51
52
  import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
52
53
  import type { Address } from 'viem';
@@ -535,9 +536,28 @@ function contexto(
535
536
  };
536
537
  }
537
538
 
538
- async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null): Promise<void> {
539
+ /**
540
+ * Cómo acabó un intento de trabajar una tarea.
541
+ *
542
+ * Existe porque `work()` no puede lanzar —también lo llama una ruta HTTP, y
543
+ * una tarea rota no debe tumbar la ronda del vigilante— y sin embargo el
544
+ * vigilante NECESITA distinguir. Antes no podía: un modelo que devolvía 429
545
+ * dos veces seguidas y una entrega perfecta se veían igual desde fuera, así
546
+ * que la tarea se daba por resuelta y se dejaba de mirar. Pasó con la #55.
547
+ *
548
+ * `esperando` no es un fallo y tampoco es un éxito, y por eso no bastaba con
549
+ * relanzar el error: una tarea a la que le faltan adjuntos sale de aquí sin
550
+ * ningún error y sin haberse entregado.
551
+ */
552
+ export type ResultadoTrabajo = 'entregada' | 'esperando' | 'fallo' | 'en-curso';
553
+
554
+ async function work(
555
+ taskId: bigint,
556
+ brief: string,
557
+ sobre: CallEnvelope | null,
558
+ ): Promise<ResultadoTrabajo> {
539
559
  const key = taskId.toString();
540
- if (inFlight.has(key)) return;
560
+ if (inFlight.has(key)) return 'en-curso';
541
561
  inFlight.add(key);
542
562
  try {
543
563
  // Lo PRIMERO, antes de trabajar: si el proceso muere a mitad, esto es lo
@@ -556,7 +576,10 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
556
576
  console.log(
557
577
  `[panal] #${taskId} en espera de ${faltan.length} adjunto(s): ${faltan.map((f) => f.name).join(', ')}`,
558
578
  );
559
- return;
579
+ // Salida limpia y sin entregar. El vigilante tiene que verlo tal cual:
580
+ // dándola por resuelta, una tarea cuyo adjunto llega tras un reinicio se
581
+ // quedaba esperando para siempre sin que nadie volviera a mirarla.
582
+ return 'esperando';
560
583
  }
561
584
  if (recibidos.length > 0) console.log(`[panal] #${taskId} con ${recibidos.length} adjunto(s) del cliente`);
562
585
 
@@ -590,8 +613,10 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
590
613
  saveResult(taskId, text);
591
614
  const { txHash } = await panal.deliverResult(taskId, text);
592
615
  console.log(`[panal] #${taskId} entregada · tx ${txHash}`);
616
+ return 'entregada';
593
617
  } catch (err) {
594
618
  console.error(`[panal] #${taskId} falló: ${err instanceof Error ? err.message : err}`);
619
+ return 'fallo';
595
620
  } finally {
596
621
  inFlight.delete(key);
597
622
  }
@@ -1395,7 +1420,7 @@ const server = createServer((req, res) => {
1395
1420
  'content-length': bytes.byteLength,
1396
1421
  // `attachment` a propósito: lo que hay dentro lo eligió el agente, y no
1397
1422
  // se le deja que el navegador del cliente lo ejecute como una página.
1398
- 'content-disposition': `attachment; filename="${nombre}"`,
1423
+ 'content-disposition': comoAdjunto(nombre),
1399
1424
  'x-content-type-options': 'nosniff',
1400
1425
  });
1401
1426
  res.end(bytes);
@@ -1439,7 +1464,9 @@ arrancarVigilante({
1439
1464
  // que la sostenía murió—, así que esta reanudación no puede seguir gastando
1440
1465
  // en nombre de nadie. Si el encargo necesitaba subcontratar, lo hará con el
1441
1466
  // presupuesto propio de este agente y no con el de quien llamó.
1442
- trabajar: (taskId, brief) => work(taskId, brief, null),
1467
+ // Solo `entregada` cuenta como resuelta. Un fallo del modelo o una espera de
1468
+ // adjuntos devuelven false y la tarea se queda en la lista del vigilante.
1469
+ trabajar: async (taskId, brief) => (await work(taskId, brief, null)) === 'entregada',
1443
1470
  reentregar: async (taskId, texto) => {
1444
1471
  const { txHash } = await panal.deliverResult(taskId, texto);
1445
1472
  console.log(`[vigilante] #${taskId} entregada al segundo intento · tx ${txHash}`);
@@ -21,6 +21,20 @@
21
21
  * el enlace de reenvío y se espera. Adivinar sería entregar cualquier cosa
22
22
  * anclando su hash, que es peor que no entregar.
23
23
  *
24
+ * «MIRADA» NO ES «RESUELTA», y confundirlas costó dos tareas de verdad.
25
+ *
26
+ * La marca guarda hasta dónde se ha ENUMERADO, no hasta dónde se ha resuelto.
27
+ * Antes se escribía al final de cada ronda pasara lo que pasara, así que una
28
+ * tarea que fallaba a mitad —el modelo colgado, el RPC caído— se quedaba por
29
+ * detrás de la marca y no volvía a mirarse nunca. Y lo que la recordaba vivía
30
+ * solo en memoria, o sea que un reinicio conservaba la mitad optimista (la
31
+ * marca, en disco) y perdía la otra (los pendientes, en RAM).
32
+ *
33
+ * Ahora las dos cosas viven en el MISMO archivo y se escriben juntas: la marca
34
+ * dice hasta dónde se enumeró, y `pendientes` lleva las excepciones. Una tarea
35
+ * sale de esa lista cuando de verdad se cierra —entregada, completada,
36
+ * cancelada— y no cuando se intentó algo con ella.
37
+ *
24
38
  * SE SONDEA, NO SE ESCUCHAN EVENTOS. `eth_getLogs` del RPC público está
25
39
  * limitado a 100 bloques, así que un agente parado veinte minutos ya no puede
26
40
  * recuperar su propio hueco. Leer el contador de tareas y mirar las nuevas es
@@ -40,8 +54,20 @@ export interface VigilanteDeps {
40
54
  yo: Address;
41
55
  /** Dónde guardar hasta dónde se miró. */
42
56
  dataDir: string;
43
- /** Trabaja una tarea de la que YA se tiene el encargo. */
44
- trabajar: (taskId: bigint, brief: string) => Promise<void>;
57
+ /**
58
+ * Trabaja una tarea de la que YA se tiene el encargo.
59
+ *
60
+ * Devuelve si la ENTREGÓ, y ese booleano es media corrección de este archivo.
61
+ * `work()` está escrito para no lanzar nunca —una tarea rota no puede tumbar
62
+ * la ronda entera—, así que desde aquí un reintento que funcionó y uno que
63
+ * volvió a reventar por límite de uso se veían exactamente igual: sin error.
64
+ * El vigilante daba por buena la tarea y dejaba de mirarla.
65
+ *
66
+ * Y no vale con relanzar el error: `work()` también sale limpio cuando la
67
+ * tarea espera adjuntos que no han llegado, que tampoco es haberla resuelto.
68
+ * Hace falta que lo diga, no que se deduzca de que nadie protestó.
69
+ */
70
+ trabajar: (taskId: bigint, brief: string) => Promise<boolean>;
45
71
  /**
46
72
  * ¿Se está trabajando esa tarea AHORA MISMO?
47
73
  *
@@ -100,98 +126,180 @@ const GRACIA_MS = 3 * 60 * 1000;
100
126
  /** Cuántas tareas hacia atrás se miran al arrancar sin marca previa. */
101
127
  const REPASO_INICIAL = 50n;
102
128
 
129
+ /**
130
+ * Tope de la lista de pendientes.
131
+ *
132
+ * Una tarea sale de la lista cuando se cierra —entregada, completada,
133
+ * cancelada—, y todas acaban cerrándose: al vencer el plazo el cliente
134
+ * recupera su dinero y la tarea deja de estar abierta. Aun así el tope existe
135
+ * porque nadie OBLIGA al cliente a cancelar: una tarea abandonada puede
136
+ * quedarse abierta para siempre, y sin tope el archivo crecería sin fin.
137
+ *
138
+ * Al recortar se tiran las más VIEJAS, que son las que menos se pueden
139
+ * recuperar, y se dice en voz alta. Callarlo sería repetir el fallo que este
140
+ * archivo viene a arreglar.
141
+ */
142
+ const MAX_PENDIENTES = 500;
143
+
144
+ /** Qué se sabe de una tarea tras mirarla. «Se intentó» no es un veredicto. */
145
+ type Veredicto = 'resuelta' | 'pendiente';
146
+
103
147
  export function arrancarVigilante(deps: VigilanteDeps): { parar: () => void } {
104
148
  if (process.env.VIGILANTE === 'off') {
105
149
  console.log('Vigilante desactivado (VIGILANTE=off).');
106
150
  return { parar: () => {} };
107
151
  }
108
152
 
109
- const marcaPath = join(deps.dataDir, 'vigilante.json');
110
- /** Tareas vistas sin encargo, con el momento en que se vieron. */
111
- const huerfanas = new Map<string, number>();
153
+ const estadoPath = join(deps.dataDir, 'vigilante.json');
154
+ /**
155
+ * Cuándo se vio por primera vez una tarea sin encargo.
156
+ *
157
+ * Esto SÍ puede vivir solo en memoria: solo sirve para el margen de gracia
158
+ * antes de gritar, y perderlo en un reinicio únicamente reinicia esa cuenta
159
+ * atrás. Lo que no puede vivir solo en memoria es la lista de pendientes, y
160
+ * por eso está aparte.
161
+ */
162
+ const vistas = new Map<string, number>();
112
163
  /** De las que ya se avisó, para no repetir el aviso cada vuelta. */
113
164
  const avisadas = new Set<string>();
165
+ /** De los encargos guardados que no cuadran, para no repetir la queja. */
166
+ const quejadas = new Set<string>();
114
167
  let parado = false;
115
168
 
116
- const leerMarca = (): bigint => {
169
+ interface Estado {
170
+ visto: bigint;
171
+ pendientes: Set<string>;
172
+ }
173
+
174
+ const leerEstado = (): Estado => {
117
175
  try {
118
- const raw = JSON.parse(readFileSync(marcaPath, 'utf8')) as { visto?: string };
119
- return BigInt(raw.visto ?? '0');
176
+ const raw = JSON.parse(readFileSync(estadoPath, 'utf8')) as {
177
+ visto?: string;
178
+ pendientes?: string[];
179
+ };
180
+ return {
181
+ // -1 y no 0: sin marca hay que hacer el repaso inicial.
182
+ visto: raw.visto === undefined ? -1n : BigInt(raw.visto),
183
+ // Un archivo de la versión anterior no trae la lista. Se lee como
184
+ // vacía y la primera ronda la vuelve a poblar con lo que siga abierto
185
+ // por delante de la marca; lo que quedó huérfano por detrás hay que
186
+ // recuperarlo a mano, que es exactamente el destrozo que esto corrige.
187
+ pendientes: new Set(raw.pendientes ?? []),
188
+ };
120
189
  } catch {
121
- return -1n; // sin marca: se hace el repaso inicial
190
+ return { visto: -1n, pendientes: new Set() };
122
191
  }
123
192
  };
124
- const escribirMarca = (visto: bigint): void => {
193
+
194
+ /**
195
+ * Las dos cosas se escriben JUNTAS, y ahí está la corrección.
196
+ *
197
+ * Antes la marca iba a disco y los pendientes se quedaban en RAM, así que un
198
+ * reinicio guardaba la mitad que dice «ya miré» y perdía la que dice «pero
199
+ * esto sigue sin resolver». En un solo archivo no puede pasar.
200
+ */
201
+ const escribirEstado = (visto: bigint, pendientes: Set<string>): void => {
202
+ let lista = [...pendientes];
203
+ if (lista.length > MAX_PENDIENTES) {
204
+ // Ordenadas por id: las más viejas primero, que son las que se tiran.
205
+ lista.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1));
206
+ const tiradas = lista.slice(0, lista.length - MAX_PENDIENTES);
207
+ lista = lista.slice(-MAX_PENDIENTES);
208
+ console.error(
209
+ `[vigilante] la lista de pendientes pasó de ${MAX_PENDIENTES}: dejo de seguir ` +
210
+ `${tiradas.length} tarea(s) vieja(s) (#${tiradas[0]}…#${tiradas[tiradas.length - 1]}). ` +
211
+ 'Míralas a mano si alguna sigue abierta.',
212
+ );
213
+ }
125
214
  try {
126
- writeFileSync(marcaPath, JSON.stringify({ visto: visto.toString() }, null, 2));
215
+ writeFileSync(estadoPath, JSON.stringify({ visto: visto.toString(), pendientes: lista }, null, 2));
127
216
  } catch (err) {
128
- // Perder la marca solo cuesta repetir el repaso, así que no se para nada.
129
- console.error(`[vigilante] no se pudo guardar la marca: ${err instanceof Error ? err.message : err}`);
217
+ // Perder el estado cuesta repetir el repaso, así que no se para nada.
218
+ console.error(`[vigilante] no se pudo guardar el estado: ${err instanceof Error ? err.message : err}`);
130
219
  }
131
220
  };
132
221
 
133
222
  /** true si encontro algo que atender: eso es lo que decide el ritmo. */
134
223
  async function repasar(): Promise<boolean> {
135
224
  const total = await deps.panal.getTaskCount();
136
- const marca = leerMarca();
225
+ const { visto, pendientes } = leerEstado();
137
226
  // La primera vez se miran las últimas REPASO_INICIAL en vez de las 30.000
138
227
  // que pueda haber: las viejas están cerradas y no cambian.
139
- const desde = marca >= 0n ? marca + 1n : total > REPASO_INICIAL ? total - REPASO_INICIAL : 0n;
228
+ const desde = visto >= 0n ? visto + 1n : total > REPASO_INICIAL ? total - REPASO_INICIAL : 0n;
140
229
 
141
- // Las nuevas, más las que quedaron pendientes de vueltas anteriores.
142
- const pendientes = new Set<string>(huerfanas.keys());
143
- for (let i = desde; i < total; i++) pendientes.add(i.toString());
144
- if (pendientes.size === 0) {
145
- escribirMarca(total - 1n);
230
+ // Las nuevas, más las que quedaron sin resolver de vueltas anteriores.
231
+ const aMirar = new Set<string>(pendientes);
232
+ for (let i = desde; i < total; i++) aMirar.add(i.toString());
233
+ if (aMirar.size === 0) {
234
+ escribirEstado(total - 1n, aMirar);
146
235
  return false;
147
236
  }
148
237
 
149
- for (const id of pendientes) {
150
- if (parado) return true;
238
+ // Se PARTE de que todas siguen pendientes y solo sale la que devuelva un
239
+ // veredicto de resuelta. Antes era al revés —dentro salvo que alguien se
240
+ // quejara— y por eso un fallo a mitad equivalía a un éxito.
241
+ const restantes = new Set<string>(aMirar);
242
+ for (const id of aMirar) {
243
+ // `break` y no `return`: hay que guardar lo que sí se llegó a resolver,
244
+ // y sobre todo lo que no.
245
+ if (parado) break;
151
246
  const taskId = BigInt(id);
247
+ let veredicto: Veredicto = 'pendiente';
152
248
  try {
153
- await revisarUna(taskId);
249
+ veredicto = await revisarUna(taskId);
154
250
  } catch (err) {
155
- console.error(`[vigilante] #${taskId}: ${err instanceof Error ? err.message : err}`);
251
+ console.error(
252
+ `[vigilante] #${taskId}: ${err instanceof Error ? err.message : err} — sigue pendiente`,
253
+ );
156
254
  }
255
+ if (veredicto === 'resuelta') restantes.delete(id);
157
256
  }
158
- escribirMarca(total - 1n);
257
+ escribirEstado(total - 1n, restantes);
159
258
  // Habia algo que mirar, aunque no fuera nuestro: no es una vuelta en blanco.
160
259
  return true;
161
260
  }
162
261
 
163
- async function revisarUna(taskId: bigint): Promise<void> {
262
+ async function revisarUna(taskId: bigint): Promise<Veredicto> {
164
263
  const task = await deps.panal.getTask(taskId);
165
264
  const id = taskId.toString();
166
265
 
167
- // Ni mía, ni viva: fuera de la lista y a otra cosa.
266
+ // No es mía: no hay nada que resolver y no hará falta volver a mirarla.
168
267
  if (task.worker.toLowerCase() !== deps.yo.toLowerCase()) {
169
- huerfanas.delete(id);
170
- return;
268
+ vistas.delete(id);
269
+ return 'resuelta';
171
270
  }
271
+ // Cerrada en la cadena —entregada, completada, disputada o cancelada—.
272
+ // ESTA es la única salida buena de la lista: lo dice el escrow, no nosotros.
172
273
  if (task.status !== TaskStatus.Open) {
173
- huerfanas.delete(id);
274
+ vistas.delete(id);
174
275
  avisadas.delete(id);
175
- return;
276
+ quejadas.delete(id);
277
+ return 'resuelta';
176
278
  }
177
279
 
178
280
  // Se está trabajando ahora mismo: no es un hueco, es el camino normal. Ni
179
281
  // se retoma, ni se avisa de que falte el encargo — lo tiene y lo está
180
282
  // usando. El vigilante solo se ocupa de lo que ya no se mueve.
283
+ //
284
+ // PERO SIGUE PENDIENTE. Antes esto la sacaba de la lista, y era el mismo
285
+ // fallo con otro disfraz: si ese trabajo en curso acababa reventando, la
286
+ // marca ya había pasado por encima y no volvía a mirarla nadie.
181
287
  if (deps.enCurso(taskId)) {
182
- huerfanas.delete(id);
183
- return;
288
+ vistas.delete(id);
289
+ return 'pendiente';
184
290
  }
185
291
 
186
292
  // CASO 3: el resultado está calculado y la tarea sigue abierta, así que la
187
293
  // entrega no llegó a anclarse. Se reintenta, que es gratis para el cliente
188
294
  // y le devuelve una tarea que daba por perdida.
295
+ //
296
+ // Si `reentregar` falla, lanza: sube a `repasar`, que la deja pendiente.
189
297
  const resultado = deps.resultadoGuardado(taskId);
190
298
  if (resultado !== null) {
191
299
  console.log(`[vigilante] #${taskId} tenía resultado sin anclar: se reintenta la entrega`);
192
300
  await deps.reentregar(taskId, resultado);
193
- huerfanas.delete(id);
194
- return;
301
+ vistas.delete(id);
302
+ return 'resuelta';
195
303
  }
196
304
 
197
305
  // CASO 2: el encargo está guardado pero no hay resultado, o sea que el
@@ -202,26 +310,38 @@ export function arrancarVigilante(deps: VigilanteDeps): { parar: () => void } {
202
310
  // otra ejecución y no vale fiarse: si no cuadra con lo que hay en la
203
311
  // cadena, trabajar sobre él sería entregar algo que el cliente no pidió.
204
312
  if (keccak256(toBytes(brief)) !== task.taskHash) {
205
- console.error(
206
- `[vigilante] #${taskId} el encargo guardado NO cuadra con el taskHash de la cadena: se ignora`,
207
- );
208
- huerfanas.delete(id);
209
- return;
313
+ // Pendiente, NO resuelta: el cliente todavía puede reenviar el bueno
314
+ // por /reenviar y entonces se puede trabajar. Se queja una sola vez
315
+ // para no llenar el log en cada vuelta.
316
+ if (!quejadas.has(id)) {
317
+ quejadas.add(id);
318
+ console.error(
319
+ `[vigilante] #${taskId} el encargo guardado NO cuadra con el taskHash de la cadena: ` +
320
+ 'no se trabaja sobre él. Que el cliente lo reenvíe.',
321
+ );
322
+ }
323
+ return 'pendiente';
210
324
  }
211
325
  console.log(`[vigilante] #${taskId} se quedó a medias: se retoma el trabajo`);
212
- await deps.trabajar(taskId, brief);
213
- huerfanas.delete(id);
214
- return;
326
+ const entregada = await deps.trabajar(taskId, brief);
327
+ vistas.delete(id);
328
+ // AQUÍ vivía el segundo fallo: se daba por resuelta sin mirar si lo
329
+ // estaba. Un modelo que devuelve 429 dos veces seguidas se veía igual
330
+ // que una entrega perfecta.
331
+ if (!entregada) {
332
+ console.log(`[vigilante] #${taskId} no quedó entregada: sigue en la lista para la próxima vuelta`);
333
+ }
334
+ return entregada ? 'resuelta' : 'pendiente';
215
335
  }
216
336
 
217
337
  // CASO 1: hay tarea y no hay encargo. Aquí no se puede hacer nada más que
218
338
  // avisar: el texto no está en la cadena y adivinarlo sería inventárselo.
219
- const visto = huerfanas.get(id);
339
+ const visto = vistas.get(id);
220
340
  if (visto === undefined) {
221
- huerfanas.set(id, Date.now());
222
- return;
341
+ vistas.set(id, Date.now());
342
+ return 'pendiente';
223
343
  }
224
- if (Date.now() - visto < GRACIA_MS || avisadas.has(id)) return;
344
+ if (Date.now() - visto < GRACIA_MS || avisadas.has(id)) return 'pendiente';
225
345
 
226
346
  avisadas.add(id);
227
347
  // En cuánto vence, no cuándo. La fecha absoluta se imprimía en UTC junto a
@@ -242,6 +362,9 @@ export function arrancarVigilante(deps: VigilanteDeps): { parar: () => void } {
242
362
  ` o desde https://panal.lat/dashboard.\n` +
243
363
  ` Si nadie lo hace, el plazo vence ${vence} y el cliente recupera su dinero.`,
244
364
  );
365
+ // Sigue abierta y sin encargo: se queda en la lista hasta que la cadena
366
+ // diga otra cosa.
367
+ return 'pendiente';
245
368
  }
246
369
 
247
370
  console.log(`Vigilante activo: repasa cada ${CADA} s (VIGILANTE=off para apagarlo).`);