#El historial no es todo el estado
Guardar una lista de mensajes parece suficiente hasta que una conversación contiene dos trabajos simultáneos. Uno está esperando una aprobación; el otro está leyendo archivos. Si ambos comparten un único campo status, no puedes representar su situación. Tampoco sabes qué permiso o presupuesto pertenece a cada uno.
Una sesión agrupa la conversación. Una ejecución o run persigue un objetivo concreto. Un proyecto delimita documentos, código o recursos. Una operación representa un efecto que queremos intentar y poder reconocer después. Son identidades distintas porque tienen ciclos de vida distintos.
Una sesión puede contener varias ejecuciones. Los mensajes explican la trayectoria; las operaciones y artefactos explican qué efectos y resultados pueden recuperarse.
#SQL suficiente para entender el esquema
Una base relacional guarda tablas: conjuntos de filas con columnas. Una clave primaria identifica una fila. Una clave foránea exige que una referencia apunte a otra fila existente. Un índice permite localizar registros sin recorrer toda la tabla, a cambio de almacenamiento y trabajo adicional al escribir.
SELECT consulta, INSERT crea y UPDATE modifica. WHERE limita las filas afectadas. Esas cláusulas son parte de la corrección: UPDATE runs SET status='cancelled' sin filtro cancelaría todas las ejecuciones.
Este esquema es una base para discutir el diseño. El proyecto final lo reducirá a lo necesario para poder recorrerlo completo:
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
project_id TEXT NOT NULL
);
CREATE TABLE runs (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
goal TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN
('queued', 'running', 'waiting', 'completed', 'partial', 'failed', 'cancelled')),
reason TEXT,
version INTEGER NOT NULL DEFAULT 0,
cancel_requested INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE messages (
run_id TEXT NOT NULL REFERENCES runs(id),
seq INTEGER NOT NULL,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (run_id, seq)
);
CREATE TABLE operations (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(id),
call_id TEXT NOT NULL,
arguments_hash TEXT NOT NULL,
status TEXT NOT NULL,
result TEXT,
UNIQUE (run_id, call_id)
);
NOT NULL evita ausencia de datos obligatorios. CHECK restringe estados posibles. La clave compuesta (run_id, seq) identifica una posición dentro de una ejecución; dos ejecuciones pueden tener secuencia 1. UNIQUE (run_id, call_id) impide registrar dos operaciones distintas para la misma llamada aceptada.
payload contiene JSON serializado en texto. Es práctico cuando el formato de los mensajes cambia, pero no convierte cualquier dato en válido: valida antes de guardar y añade una versión de esquema si necesitas migrarlo.
#Diseñar las transiciones antes de programarlas
Una máquina de estados describe estados permitidos y transiciones válidas. No requiere una biblioteca. Puedes expresarla como un diccionario:
TRANSICIONES = {
"queued": {"running", "cancelled"},
"running": {"waiting", "completed", "partial", "failed", "cancelled"},
"waiting": {"queued", "cancelled", "failed"},
"completed": set(),
"partial": set(),
"failed": set(),
"cancelled": set(),
}
El esquema normaliza estado y motivo. Los primeros ejemplos usan resultados expresivos del bucle, como candidate o budget_exhausted; no se insertan literalmente en este CHECK. El adaptador de persistencia debe traducirlos:
| Resultado del bucle | status almacenado |
reason |
|---|---|---|
candidate aún no verificado |
running |
verifying |
budget_exhausted con entrega incompleta |
partial |
budget_exhausted |
needs_input |
waiting |
needs_input |
waiting_reconciliation |
waiting |
reconciliation |
| Éxito verificado | completed |
Vacío |
partial es terminal para esa ejecución; continuar con más presupuesto puede crear otro run relacionado. Si tu producto permite ampliar el mismo run, añade esa transición explícitamente y conserva la auditoría. El miniproyecto final usa un esquema más pequeño y declara sus estados propios, en lugar de mezclarlo implícitamente con este diseño general.
Los estados terminales no vuelven a running por recibir un mensaje tardío. Si el usuario quiere reintentar un fracaso, crea una nueva ejecución con referencia a la anterior o define explícitamente otra transición. Mantener esa decisión visible evita que un worker reactive trabajo cancelado.
waiting necesita una causa: aprobación, dato faltante, resultado de un hijo o espera programada. También una condición para reanudar. «Está esperando» sin identificar qué evento la desbloquea produce tareas abandonadas indefinidamente.
#Evitar que dos procesos se sobrescriban
Supón que dos workers leen version=7. Ambos calculan el siguiente estado. Si guardan sin condición, el segundo puede borrar la decisión del primero. Una actualización condicional comprueba que la versión sigue siendo la que se leyó:
cursor = connection.execute(
"UPDATE runs SET status=?, version=version+1 WHERE id=? AND version=?",
("waiting", run_id, 7),
)
if cursor.rowcount != 1:
raise RuntimeError("El estado cambió; vuelve a leer antes de decidir")
Los signos ? son parámetros SQL. El driver los trata como valores, no como código; no construyas consultas concatenando la entrada del modelo. Esta comprobación detecta escrituras obsoletas, pero aún no impide que dos workers ejecuten el mismo efecto externo. La propiedad de una ejecución y los leases se desarrollan en orquestación.
#Qué persistir y qué reconstruir
Guarda hechos que no puedes recuperar de manera fiable: petición original, decisiones aceptadas, argumentos, observaciones, cambios de estado, aprobaciones y referencias de artefactos. Guarda también versiones de prompt, modelo y código cuando necesitas explicar por qué cambió un resultado.
Puedes reconstruir proyecciones como «últimos cinco mensajes» a partir de esos datos. Una proyección es una vista calculada para un uso concreto. El contexto enviado al modelo será una de ellas; la pantalla de progreso, otra. No deben convertirse accidentalmente en la única copia de la información.
No es necesario adoptar event sourcing completo. En una aplicación pequeña, una tabla runs con el estado actual y un log de eventos para auditoría suele ser suficiente. Si decides reconstruir todo exclusivamente desde eventos, tendrás que versionar sus esquemas y garantizar que el replay no produce efectos. Esa complejidad debe resolver una necesidad real.
#Tiempo y orden son preguntas diferentes
Una marca temporal ayuda a medir duración, pero dos máquinas pueden tener relojes ligeramente distintos. Para ordenar hechos dentro de un run usa una secuencia monotónica asignada por el escritor autorizado. Para relacionar un resultado con una llamada usa call_id; para relacionar un hijo con su coordinador usa una referencia de causalidad.
Si el resultado del hijo llega a las 12:00:01 y el servidor del padre registra las 12:00:00, no puedes deducir que el resultado ocurrió antes de la petición. La causalidad la expresa la referencia, no la hora.
#Comprueba que el esquema responde a preguntas reales
Antes de añadir columnas, intenta responder: ¿qué objetivo tiene este run?, ¿qué herramienta se aceptó y todavía no tiene observación?, ¿qué documento exacto vio el modelo?, ¿qué permiso cubría una escritura?, ¿quién puede cancelarlo? Si necesitas extraer estas respuestas de prosa libre, falta estado operativo.
El siguiente capítulo convierte esas relaciones en una frontera recuperable: una transacción que guarda juntos los datos que deben coincidir.
#Referencias
- sqlite3, Python: parámetros SQL, filas y transacciones.
- SQLite: claves foráneas: relaciones y activación de su comprobación.