diff --git a/.planning/HANDOFF-2026-08-31.md b/.planning/HANDOFF-2026-08-31.md new file mode 100644 index 0000000..8e02b5b --- /dev/null +++ b/.planning/HANDOFF-2026-08-31.md @@ -0,0 +1,93 @@ +# HANDOFF — memorymaster, 2026-08-31 +# Que cubre: estado al cierre del dia, los 8 PRs que entraron, lo que quedo +# abierto con su evidencia, y las trampas que costaron tiempo hoy. +# Leer cuando: retomes este pane, o antes de tocar perfil compilado / PPR-7 / CI. +# Estado: main limpio, 0 PRs abiertos salvo #250 esperando CI. + +## Donde quedo todo + +`main` en `ded7dbc` + lo que entre del #250. Worktrees: 2 (el canonico y +`G:/tmp/memorymaster-workflow-intelligence`, que ya se puede borrar: su rama +esta mergeada). Ramas locales: 51. Stashes: 0. + +## Lo que entro hoy + +| PR | Que arreglo | +|----|-------------| +| #243 | El reduce del perfil va por lotes y un run a medias es reanudable | +| #244 | La sintesis de observaciones usa el proveedor vigente, no uno clavado | +| #245 | Guard: un `except` que escribe un codigo constante debe guardar la causa | +| #246 | Mutacion sobre ese guard; encontro un test que pasaba por el motivo equivocado | +| #247 | Reindexar GitNexus no puede borrar los embeddings, y se verifica al final | +| #248 | Workflow Intelligence v1 (revisado, no escrito por mi) — apagado por defecto | +| #249 | `release-truth` dejo de colgar del job de 70 minutos | +| #250 | El flaky de hermes: la espera de arranque no esperaba (en CI al cierre) | + +## Resultados medidos, no declarados + +- **Perfil compilado destrabado** tras 10 dias clavado. Hechos 29 -> 52, + vinietas inyectadas 32 -> 50, techo 800/40 -> 1400/60 (el viejo cortaba 20 + hechos en silencio). +- **PPR-7 volvio a emitir**: `synthesis_completed=2, emitted=2, failed=0`. + Primera emision desde el 2026-08-14. +- **Corpus curado**: 13.767 claims agrupadas por tema, 18% -> 96%. +- **Tenant**: cero claims VIVAS en NULL. 4.783 clones exactos archivados + apuntando a su original. +- **Migracion 0024 aplicada por la via gobernada**: tabla creada, 0 filas + (sin backfill), 147.335 claims intactas, quick_check ok, 0 FKs rotas. +- **Scan metadata-only**: 15.444 sesiones, 20.064 archivos, 0 errores, nada + activado (`candidates: 0`, `reviews: 0`). + +## Abierto, con evidencia y sin dueno asignado + +1. **`MemoryMaster-Dreaming` sale 1** con `errors: 1` en las dos ultimas + corridas. NO es el perfil (`not_due`, sano) ni las observaciones + (`failed: 0`). Esta en el camino de extraccion/consolidacion del dream. + Nadie lo persiguio todavia. +2. **El recall opt-in de observaciones entrega CERO**, y no por un bug del + camino: no hay ninguna observacion confirmada (2 archivadas + 1 candidata). + Medido corriendo `query_context_bundle` con y sin `include_observations`. +3. **96,3% del discovery de PPR-7 termina en `no_supports`** (4.549 de 4.724 en + 7 dias). El embudo esta ANTES de la sintesis, no en ella. +4. **El perfil tiene 11 dias de atraso**: `last_supported_at` no pasa del + 2026-08-20, donde termino el watermark del run 3. +5. **Precision de observaciones: NO MEDIBLE** (n=2). Se repite el veredicto del + 2026-08-24 con n=0. Reportar 100% sobre dos casos seria inventar un numero. +6. **Expiracion de preferencias NO ejercitada**: 10 hechos `preference`, todos + de agosto, TTL 90 dias. +7. **Hermes sync: verde solo por exit code.** No tiene log de resultado. +8. **Namespace de snapshots vacio** (`no snapshots found`): el rollback nunca se + ejercito y no hay a donde volver. +9. **Campo viejo sin limpiar**: el run 3 del perfil quedo con + `error_code='ProfileValidationError'` pese a `status='completed'`. + +## Trampas que costaron tiempo hoy (para no repetirlas) + +- **`release-truth` cuenta funciones de test.** Agregar una sin regenerar deja + el archivo viejo y rompe el CI. Cai CUATRO veces. El #249 hace que ahora te + lo diga un job de 30 segundos en vez de seis jobs y 70 minutos. +- **Con un archivo generado en el diff, MERGE le gana a REBASE.** El rebase + multiplica el conflicto por la cantidad de commits; hoy me dejo en HEAD + desacoplado con 33 de 43 archivos. El merge lo presenta una vez, sobre el + estado final, que es el unico que importa. +- **Un fallo de CI se clasifica nombrando el test, no adivinando.** Y la + pregunta que decide si relanzar es si existe un MECANISMO por el que el + cambio pueda causarlo: en el #247 pude probar que no existia (el archivo no + lo importa nadie); en el #248 existia y era real. +- **Un `failed: 0` puede significar "no se intento nada".** Los 3 jobs de PPR-7 + estaban `blocked` en el tope, asi que el fix no podia demostrarse. Casi lo + reporto como verde. +- **`grep` de un substring miente sobre el codigo**: `'opencode' in src` me dio + falso positivo por mi PROPIO docstring explicando la historia. +- **Los blobs base64 de un transcript matchean regexes de palabras.** Conte + "GPL 25 veces" y eran datos codificados; casi bloqueo un merge por licencia + sobre esa base. + +## Reglas nuevas que quedaron como codigo + +- `scripts/check_swallowed_cause.py` — un `except` no puede escribir una + etiqueta constante y tirar la excepcion. 4 hallazgos reales, 0 falsos. +- `scripts/mutate_swallowed_cause.py` — 6 mutaciones sobre ese guard; imprime + QUE test mato cada una, que es lo unico que hizo visible el test tramposo. +- `scripts/gitnexus_reindex.py` — reindexar sin `--embeddings` los BORRA (hoy + 12.079). Verifica el conteo DESPUES de correr, contra el valor previo. diff --git a/docs/generated/release-truth.json b/docs/generated/release-truth.json index 023fcfb..48c1389 100644 --- a/docs/generated/release-truth.json +++ b/docs/generated/release-truth.json @@ -137,7 +137,7 @@ "console_entrypoints": 9, "mcp_tools": 51, "ops_cli_commands": 5, - "pytest_test_functions": 4177 + "pytest_test_functions": 4182 }, "feature_profile_matrix": { "capture_hook": [ diff --git a/docs/generated/release-truth.md b/docs/generated/release-truth.md index c095f4e..d109e15 100644 --- a/docs/generated/release-truth.md +++ b/docs/generated/release-truth.md @@ -13,7 +13,7 @@ Do not edit this file by hand. Run `python scripts/generate_release_truth.py`. - Main CLI commands: **120** - Operations CLI commands: **5** - Console entrypoints: **9** -- Pytest source test functions: **4177** +- Pytest source test functions: **4182** ## MCP tools diff --git a/tests/test_hermes_http_fixture_wait.py b/tests/test_hermes_http_fixture_wait.py new file mode 100644 index 0000000..260e88f --- /dev/null +++ b/tests/test_hermes_http_fixture_wait.py @@ -0,0 +1,97 @@ +"""La espera de arranque del servidor MCP tiene que esperar de verdad. + +El bucle original dormia SOLO en el `except`. Si el servidor aceptaba la +conexion pero devolvia un estado distinto de 200 —el caso normal mientras +arranca— giraba sus 100 vueltas en milisegundos y se rendia sin haber esperado +nada. Y con un limite en vueltas y no en tiempo, el presupuesto real dependia de +si cada intento fallaba rapido o agotaba su timeout: no habia presupuesto. + +El sintoma no era un error de arranque legible. El test avanzaba, la llamada MCP +fallaba, y `_classify_transport_error` la reportaba como `authority_unavailable` +— su fallback para cualquier error de transporte sin clasificar. Cuatro caidas +en CI el 2026-08-31, todas en Windows, entre 1 y 1,5 horas de suite cada una. + +Estos tests anclan el REQUISITO ("la espera consume su presupuesto de tiempo +antes de rendirse"), no la implementacion. Y fijan explicitamente que esto NO es +un reintento: no repite aserciones ni tolera un fallo persistente. +""" +from __future__ import annotations + +import time + +import httpx + +from test_hermes_memory_provider_http import _wait_until_healthy + + +class _Resp: + def __init__(self, status_code: int) -> None: + self.status_code = status_code + + +def test_duerme_tambien_cuando_responde_distinto_de_200(monkeypatch): + """El bug exacto: sin sleep en esta rama, el presupuesto se evapora.""" + llamadas = {"get": 0, "sleep": 0.0} + + def fake_get(url, timeout=None): + llamadas["get"] += 1 + return _Resp(503) + + def fake_sleep(seconds): + llamadas["sleep"] += seconds + + monkeypatch.setattr(httpx, "get", fake_get) + monkeypatch.setattr(time, "sleep", fake_sleep) + + assert _wait_until_healthy("http://x/healthz", budget_seconds=0.3) is False + assert llamadas["get"] > 0 + assert llamadas["sleep"] > 0, ( + "giro sin dormir ante un 503: es el defecto que dejaba el arranque sin espera" + ) + + +def test_devuelve_true_apenas_hay_200(monkeypatch): + estados = [503, 503, 200] + + monkeypatch.setattr(httpx, "get", lambda url, timeout=None: _Resp(estados.pop(0))) + monkeypatch.setattr(time, "sleep", lambda s: None) + + assert _wait_until_healthy("http://x/healthz", budget_seconds=5.0) is True + assert not estados, "no consumio los intentos previos" + + +def test_tolera_errores_de_transporte_mientras_arranca(monkeypatch): + intentos = {"n": 0} + + def fake_get(url, timeout=None): + intentos["n"] += 1 + if intentos["n"] < 3: + raise httpx.ConnectError("connection refused") + return _Resp(200) + + monkeypatch.setattr(httpx, "get", fake_get) + monkeypatch.setattr(time, "sleep", lambda s: None) + + assert _wait_until_healthy("http://x/healthz", budget_seconds=5.0) is True + + +def test_el_limite_es_TIEMPO_y_se_respeta(monkeypatch): + """Sin limite temporal no hay presupuesto: 100 vueltas duran lo que duren.""" + reloj = {"t": 0.0} + monkeypatch.setattr(time, "monotonic", lambda: reloj["t"]) + monkeypatch.setattr(time, "sleep", lambda s: reloj.__setitem__("t", reloj["t"] + s)) + monkeypatch.setattr(httpx, "get", lambda url, timeout=None: _Resp(500)) + + inicio = reloj["t"] + assert _wait_until_healthy("http://x/h", budget_seconds=2.0, poll_seconds=0.1) is False + consumido = reloj["t"] - inicio + assert 2.0 <= consumido < 2.5, f"no consumio su presupuesto: {consumido}" + + +def test_no_es_un_reintento_del_test(monkeypatch): + """Un servidor que NUNCA levanta sigue fallando: no tapa una caida real.""" + monkeypatch.setattr(httpx, "get", lambda url, timeout=None: (_ for _ in ()).throw( + httpx.ConnectError("nunca levanta"))) + monkeypatch.setattr(time, "sleep", lambda s: None) + + assert _wait_until_healthy("http://x/h", budget_seconds=0.2) is False diff --git a/tests/test_hermes_memory_provider_http.py b/tests/test_hermes_memory_provider_http.py index e00ae1b..2191e56 100644 --- a/tests/test_hermes_memory_provider_http.py +++ b/tests/test_hermes_memory_provider_http.py @@ -39,6 +39,37 @@ def _free_port() -> int: return int(probe.getsockname()[1]) +def _wait_until_healthy( + health_url: str, *, budget_seconds: float = 30.0, poll_seconds: float = 0.05 +) -> bool: + """Espera a que el servidor responda 200, durmiendo en TODOS los caminos. + + El bucle anterior dormia solo en el `except`: si el servidor aceptaba la + conexion pero devolvia un estado distinto de 200 —arrancando— giraba sus 100 + vueltas en milisegundos y se rendia sin haber esperado nada. Y con 100 + iteraciones fijas el presupuesto real dependia de si cada intento fallaba + rapido o agotaba su timeout, o sea que no habia presupuesto. + + Ahora el limite es TIEMPO, no vueltas. Importa porque el sintoma no es un + error de arranque legible: el test avanza, la llamada MCP falla, y + `_classify_transport_error` la reporta como `authority_unavailable`, que es + su fallback para cualquier error de transporte sin clasificar. Cuatro caidas + en CI el 2026-08-31, todas en Windows, entre 1 y 1,5 h de suite cada una. + + Esto NO es un reintento del test: no repite aserciones ni tolera un fallo + real. Solo le da al arranque el tiempo que el bucle decia darle y no daba. + """ + deadline = time.monotonic() + budget_seconds + while time.monotonic() < deadline: + try: + if httpx.get(health_url, timeout=1.0).status_code == 200: + return True + except httpx.HTTPError: + pass + time.sleep(poll_seconds) + return False + + @pytest.fixture def mcp_http_server(tmp_path: Path): workspace = tmp_path / "workspace" @@ -91,13 +122,7 @@ def mcp_http_server(tmp_path: Path): creationflags=flags, ) health = f"http://127.0.0.1:{port}/healthz" - for _ in range(100): - try: - if httpx.get(health, timeout=0.2).status_code == 200: - break - except httpx.HTTPError: - time.sleep(0.02) - else: + if not _wait_until_healthy(health): raise AssertionError("disposable MemoryMaster MCP server did not start") yield f"http://127.0.0.1:{port}/mcp", token, db, workspace process.terminate()