#Comprobar la garantía que acabas de construir
Las dos páginas anteriores producen un programa completo. Ahora vamos a intentar romperlo en lugares donde un sistema ingenuo perdería trabajo o duplicaría un informe. La pregunta no es si el proceso vuelve a arrancar, sino si el estado recuperado representa lo que realmente ocurrió.
El laboratorio usa bases de datos distintas para cada caso. No borres tu base habitual ni ejecutes las pruebas sobre datos importantes. La variable AGENT_DB solo selecciona el archivo de este programa; no cambia ninguna configuración global del sistema.
#Caída 1 · Hay decisión, todavía no hay efecto
Crea una ejecución nueva y termina el proceso justo después de aceptar la decisión report:
AGENT_DB=caida-plan.db python3 agente.py new caso-caida "api db 5433"
AGENT_DB=caida-plan.db CRASH_AT=after_plan python3 agente.py work caso-caida
AGENT_DB=caida-plan.db python3 agente.py show caso-caida
El segundo comando termina deliberadamente con código 92. La ejecución debe seguir en running, con tres operaciones confirmadas y una decisión report en pending. No existe informe todavía. La decisión pendiente contiene exactamente el texto y las fuentes que se pretendían guardar.
Reanuda sin la variable de caída:
AGENT_DB=caida-plan.db python3 agente.py work caso-caida
El worker no vuelve a pedir al modelo que redacte otra cosa. Ejecuta la propuesta guardada, confirma el informe y pasa a completed. Esta es la razón para persistir la decisión antes del efecto.
#Caída 2 · El efecto ocurrió dentro de una transacción no confirmada
Repite con otra base y CRASH_AT=inside_report:
AGENT_DB=caida-transaccion.db python3 agente.py new caso-caida "api db 5433"
AGENT_DB=caida-transaccion.db CRASH_AT=inside_report python3 agente.py work caso-caida
AGENT_DB=caida-transaccion.db python3 agente.py show caso-caida
AGENT_DB=caida-transaccion.db python3 agente.py work caso-caida
La caída ocurre después del INSERT del informe, antes de guardar operación y avance. Al reabrir, SQLite recupera la transacción sin confirmar: no aparece un informe a medias. La decisión pendiente sigue existiendo porque se confirmó en una transacción anterior. Al reanudar se ejecuta una vez y se confirma todo junto.
No generalices este resultado a una API externa. Si execute hubiera enviado un correo antes de caer, SQLite no podría retirarlo. Ese efecto necesitaría una operación idempotente o reconciliación en el receptor.
#Caída 3 · Todo se confirmó, pero el proceso no llegó a contarlo
Con CRASH_AT=after_commit, el informe, observación y avance ya son durables cuando termina el proceso:
AGENT_DB=caida-confirmada.db python3 agente.py new caso-caida "api db 5433"
AGENT_DB=caida-confirmada.db CRASH_AT=after_commit python3 agente.py work caso-caida
AGENT_DB=caida-confirmada.db python3 agente.py work caso-caida
AGENT_DB=caida-confirmada.db python3 agente.py work caso-caida
Ambas reanudaciones encuentran completed y no repiten acciones. Debe seguir habiendo cuatro operaciones y un informe. Que la terminal no mostrara éxito antes de la caída no implica que faltara el efecto: la base es la evidencia de esta implementación.
Las tres pruebas recorren fronteras diferentes. Cubrir solo la caída anterior a la escritura no demostraría que se evita duplicar un efecto ya confirmado.
#Una comprobación automática de las tres fronteras
Este bloque es un programa de prueba separado; no lo añadas a agente.py. Puedes copiarlo en comprobar.py, situado al lado, y ejecutarlo con python3 comprobar.py. Usa directorios temporales y llama al programa real en procesos distintos.
import os
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
script = Path("agente.py").resolve()
with tempfile.TemporaryDirectory() as directory:
for mode in ["normal", "after_plan", "inside_report", "after_commit"]:
path = str(Path(directory) / f"{mode}.db")
env = {**os.environ, "AGENT_DB": path}
env.pop("OLLAMA_MODEL", None)
env.pop("CRASH_AT", None)
subprocess.run([sys.executable, str(script), "new", "r-1", "api db 5433"],
env=env, check=True, capture_output=True)
attempt = subprocess.run([sys.executable, str(script), "work", "r-1"],
env={**env, "CRASH_AT": mode}, capture_output=True)
assert attempt.returncode == (0 if mode == "normal" else 92)
db = sqlite3.connect(path)
count = db.execute("SELECT COUNT(*) FROM reports").fetchone()[0]
assert count == (1 if mode in {"normal", "after_commit"} else 0)
db.close()
for _ in range(2):
subprocess.run([sys.executable, str(script), "work", "r-1"],
env=env, check=True, capture_output=True)
db = sqlite3.connect(path)
assert db.execute("SELECT status FROM runs").fetchone()[0] == "completed"
assert db.execute("SELECT COUNT(*) FROM reports").fetchone()[0] == 1
assert db.execute("SELECT COUNT(*) FROM operations").fetchone()[0] == 4
assert db.execute("SELECT pending FROM runs").fetchone()[0] is None
db.close()
print(mode, "→ 4 operaciones, 1 informe, sin decisión pendiente")
La salida debe contener cuatro líneas con esos invariantes. Este test se ha ejecutado durante la preparación del curso. Prueba reinicios de proceso y transacciones locales; no simula pérdida física del disco ni verifica un modelo real.
#Cancelación, límites y entradas inválidas
Para comprobar que cancelar impide empezar nuevas acciones:
AGENT_DB=cancelacion.db python3 agente.py new caso-cancelado "api db"
AGENT_DB=cancelacion.db python3 agente.py cancel caso-cancelado
AGENT_DB=cancelacion.db python3 agente.py work caso-cancelado
Debe quedar cancelled y no tener operaciones ni informe. Para estudiar cancelación durante inferencia, un test puede bloquear propose, cancelar desde otra conexión y liberar la respuesta. La actualización condicional debe rechazarla por revisión o estado. No se necesita un modelo lento real para provocar esa carrera.
El límite de ocho acciones evita que un modelo repita búsquedas indefinidamente. El límite de doce solicitudes cuenta inferencias admitidas aunque fallen. En una aplicación real añade deadline, cuota de tokens y reservas compartidas entre hijos. Aquí el presupuesto limita vueltas; no afirma un límite monetario.
Prueba asimismo una fuente nunca leída, un identificador inexistente y un objeto con claves extra. La fuente no leída produce una observación para corregir; una forma inválida aborta antes de ejecutar. El run conserva lo confirmado. Después de un error debes inspeccionar la causa antes de reintentar, no repetir indefinidamente el mismo fallo.
#Diagnóstico por síntomas
| Síntoma | Qué mirar | Razón |
|---|---|---|
database is locked |
Otro escritor o transacción demasiado larga | SQLite permite un escritor simultáneo |
Se queda con pending |
Error al ejecutar esa decisión | La propuesta está guardada para poder diagnosticar |
budget_exhausted |
Historial y número de solicitudes | Puede haber repetición o intentos fallidos |
| No aparece un informe | Fuentes leídas, errores de herramienta y estado | Guardar una cita requiere lectura previa |
| Funciona simulado y falla real | Respuesta del adaptador, formato y capacidad | El simulador no prueba comportamiento del modelo |
| El informe cita bien pero concluye mal | Contenido de fuentes y afirmaciones | La validación estructural no demuestra soporte semántico |
No «repares» un estado cambiándolo a completed a mano. Corrige la causa, conserva la evidencia y define si se puede reanudar la intención o hace falta una nueva ejecución.
#Evolucionar sin reescribir la idea central
El siguiente cambio debe responder a un límite que puedas observar:
| Límite encontrado | Evolución | Contrato que debes conservar |
|---|---|---|
| Documentos reales grandes | Ingesta, fragmentos, versiones y contexto acotado | Toda cita apunta a evidencia recuperable |
| Varios usuarios | Autenticación y permisos en cada consulta | El modelo no elige su identidad |
| Herramientas lentas o remotas | Operaciones pendientes, idempotencia y reconciliación | Un timeout no se interpreta como ausencia de efecto |
| Varios workers o máquinas | Cola, leases y fencing, base de servidor | Solo la autoridad vigente confirma avance |
| Interfaz de progreso | Endpoint de consulta y SSE con cursor | Reconectar no vuelve a ejecutar herramientas |
| Investigación paralela | Hijos con briefing, inbox y admisión atómica | Una entrega repetida se incorpora una vez |
| Trayectorias largas | Constructor de contexto y compaction versionada | El resumen no sustituye estado ni artefactos |
Estas ampliaciones ya tienen su mecanismo desarrollado en los capítulos anteriores. Por ejemplo, el siguiente paso hacia varios workers no es poner work detrás de cinco procesos y esperar: debes mover la ejecución lenta fuera de la transacción y aplicar el protocolo de propiedad de la ejecución. Para efectos externos, usa el receptor idempotente como referencia de la garantía que debe ofrecer el destino.
#Decisiones que ahora deberías poder justificar
Debes poder explicar por qué se guarda la propuesta antes del efecto, por qué el modelo se llama fuera de la transacción, qué significa cada caída, por qué un hash no prueba verdad y qué parte de la recuperación dejaría de funcionar si el informe se enviara a un servicio remoto.
También deberías poder distinguir el objetivo del producto de la calidad del modelo. Este miniproyecto produce un borrador documentado y recuperable. Convertirlo en una herramienta profesional requiere evaluar respuestas reales, aplicar permisos de tu dominio y operar las garantías descritas; no se consigue únicamente cambiando el nombre del modelo.
#Referencias
- Transacciones, SQLite: confirmación y rollback de las pruebas.
- subprocess, Python: ejecución del programa en procesos independientes.
- APIs idempotentes, AWS: límite de las garantías locales al introducir efectos remotos.