Phase 6.3.17: make gate 5 measure all ten prompts in one resilient run

The Penpot MCP keeps going down and each window where it works is
expensive - three of the user's windows were spent on runs that aborted
partway. The run is now built to finish rather than to be correct about
why it stopped.

One prompt failing no longer ends the run. Failures are collected and
retried on a later pass, up to three passes, with a clean handshake and a
probe between them. Even an unexpected exception is caught per prompt,
because one bug in one prompt must not take down the other nine. The
opening probe no longer aborts either: a plugin that does not answer now
may answer on pass two, and aborting there throws away the whole window
for a transient state.

Two time budgets bound it: four minutes per prompt, checked before every
turn of the agent loop, and forty-five minutes of wall clock. Both are env
vars. A hang used to mean waiting forever; now it costs one prompt and the
run continues.

Results are written after every prompt, so a crash costs at most the
prompt in flight, and the JSON is only marked complete when all ten have a
score.

The run also inventories the accumulated gate5 pages at the end and writes
the list to disk. It does not delete them: the pages this run created are
already emptied after their PNG is exported, but the ones from earlier runs
are in the user's own file, so that is offered rather than assumed.
This commit is contained in:
2026-07-30 20:27:46 +00:00
parent ad62277f14
commit 869b00c924
+111 -45
View File
@@ -118,6 +118,16 @@ LIMPIAR_PAGINAS = os.environ.get("GATE5_KEEP_PAGES", "").lower() not in ("1", "t
# prompts pesados, asi que 4 deja margen y da un punto de re-handshake antes del deterioro. # prompts pesados, asi que 4 deja margen y da un punto de re-handshake antes del deterioro.
BATCH_SIZE = int(os.environ.get("GATE5_BATCH_SIZE", "4")) BATCH_SIZE = int(os.environ.get("GATE5_BATCH_SIZE", "4"))
# CORRIDA UNICA. El MCP de Penpot se cae seguido y cada ventana en que funciona es cara, asi que
# la corrida tiene que medir los 10 prompts de una y no rendirse por el fallo de uno.
# - Un prompt que falla NO aborta la corrida: se anota y se reintenta en una pasada posterior.
# - Tope por prompt, para que un cuelgue no coma la ventana entera.
# - Tope de pared, para que la corrida termine si o si y deje resultados.
MAX_PASADAS = int(os.environ.get("GATE5_MAX_PASSES", "3"))
PROMPT_TIMEOUT_S = int(os.environ.get("GATE5_PROMPT_TIMEOUT_S", "240"))
WALL_CLOCK_S = int(os.environ.get("GATE5_WALL_CLOCK_MIN", "45")) * 60
# El prompt que reproduce el fallo exacto de produccion. Tiene su propia condicion de # El prompt que reproduce el fallo exacto de produccion. Tiene su propia condicion de
# aprobacion: si este no llega a 60, la puerta no aprueba aunque el promedio alcance. # aprobacion: si este no llega a 60, la puerta no aprueba aunque el promedio alcance.
FLAGSHIP_PROMPT_ID = "g5-06-landing-pizzeria" FLAGSHIP_PROMPT_ID = "g5-06-landing-pizzeria"
@@ -144,6 +154,10 @@ class PluginNotConnected(Gate5Error):
"""El plugin de Penpot no esta abierto/conectado: no tiene sentido seguir.""" """El plugin de Penpot no esta abierto/conectado: no tiene sentido seguir."""
class PromptTimeout(Gate5Error):
"""Se agoto el tope de tiempo de ESTE prompt. No dice nada del resto de la corrida."""
# ------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------
# Patrones prohibidos: se IMPORTAN del lint, nunca se duplican # Patrones prohibidos: se IMPORTAN del lint, nunca se duplican
# ------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------
@@ -1289,8 +1303,13 @@ def parse_tool_arguments(tool_call):
# ------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------
# Loop de agente sobre un prompt # Loop de agente sobre un prompt
# ------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------
def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir): def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir, deadline=None):
prompt_id = fila["id"] prompt_id = fila["id"]
def queda_tiempo(que):
if deadline is not None and time.time() > deadline:
raise PromptTimeout(
f"{prompt_id}: se agoto el tope de {PROMPT_TIMEOUT_S}s en '{que}'")
tag = cfg["GATE5_TAG"] tag = cfg["GATE5_TAG"]
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
page_name = f"gate5/{tag}/{prompt_id}/{ts}" page_name = f"gate5/{tag}/{prompt_id}/{ts}"
@@ -1325,6 +1344,7 @@ def run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir):
finish_reason = None finish_reason = None
for turno in range(1, MAX_TURNS + 1): for turno in range(1, MAX_TURNS + 1):
queda_tiempo(f"turno {turno}")
try: try:
choice = chat_completion(cfg, messages, tools) choice = chat_completion(cfg, messages, tools)
except requests.RequestException as e: except requests.RequestException as e:
@@ -1730,11 +1750,18 @@ def run_full(cfg, tools, system_prompt):
mcp = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"]) mcp = PenpotMCP(cfg["PENPOT_MCP_URL"], cfg["PENPOT_MCP_TOKEN"])
mcp.handshake() mcp.handshake()
# Sonda barata: si el plugin no esta conectado, abortar ANTES de quemar los 8 prompts. # Sonda barata. Ya NO aborta: la corrida hace hasta MAX_PASADAS pasadas con handshake limpio,
# asi que un plugin que no responde ahora puede responder en la pasada 2. Abortar aca
# desperdiciaria la ventana entera por un estado transitorio.
try:
sonda, _es_error = mcp.execute_code("return { gate5Probe: true };") sonda, _es_error = mcp.execute_code("return { gate5Probe: true };")
if PLUGIN_NOT_CONNECTED in sonda: if PLUGIN_NOT_CONNECTED in sonda:
raise PluginNotConnected(sonda) print("[MCP] AVISO: el plugin no figura conectado. Se intenta igual: las pasadas "
"posteriores rehacen el handshake.")
else:
print(f"[MCP] sonda de conectividad del plugin: {sonda[:120]}") print(f"[MCP] sonda de conectividad del plugin: {sonda[:120]}")
except Gate5Error as e:
print(f"[MCP] AVISO: la sonda fallo ({str(e)[:140]}). Se intenta igual.")
png_dir = REPO_ROOT / "data" / f"gate5_png_{cfg['GATE5_TAG']}" png_dir = REPO_ROOT / "data" / f"gate5_png_{cfg['GATE5_TAG']}"
salida = REPO_ROOT / "data" / f"gate5_results_{cfg['GATE5_TAG']}.json" salida = REPO_ROOT / "data" / f"gate5_results_{cfg['GATE5_TAG']}.json"
@@ -1773,62 +1800,101 @@ def run_full(cfg, tools, system_prompt):
}, ensure_ascii=False, indent=2), encoding="utf-8") }, ensure_ascii=False, indent=2), encoding="utf-8")
t0 = time.time() t0 = time.time()
resultados = [] fin_de_pared = t0 + WALL_CLOCK_S
pendientes = [] medidos = {}
corte_por_degradacion = False ultimo_error = {}
# LOTES. El plugin de Penpot aguanta de forma confiable unos 5 o 6 prompts pesados y despues # CORRIDA UNICA, EN PASADAS. El MCP de Penpot se cae seguido y cada ventana en que funciona
# se degrada hasta dar timeouts de 30 s en `createPage`. Medido en tres corridas seguidas, # es cara, asi que la corrida no puede rendirse por el fallo de uno: recorre todos los
# siempre con la misma forma: los primeros funcionan, el resto falla en el setup sin # prompts, anota los que fallaron, y los reintenta en la pasada siguiente con un handshake
# excepcion. Procesar en lotes chicos con un handshake limpio en el medio le da al servidor # limpio en el medio. Los cortes anteriores nos costaron tres ventanas del usuario.
# un punto de recuperacion, y si igual se degrada se corta ahi en vez de quemar los que por_medir = list(prompts)
# faltan de a uno. for pasada in range(1, MAX_PASADAS + 1):
for inicio in range(0, len(prompts), BATCH_SIZE): if not por_medir:
lote = prompts[inicio:inicio + BATCH_SIZE] break
n_lote = inicio // BATCH_SIZE + 1 if time.time() > fin_de_pared:
total_lotes = (len(prompts) + BATCH_SIZE - 1) // BATCH_SIZE print(f"\n[PARED] se agoto el tope de {WALL_CLOCK_S // 60} min; quedan "
if inicio > 0: f"{len(por_medir)} sin medir")
print(f"\n[LOTE] handshake limpio antes del lote {n_lote}/{total_lotes}") break
if pasada > 1:
print(f"\n[PASADA {pasada}/{MAX_PASADAS}] handshake limpio y reintento de "
f"{len(por_medir)}: {[f['id'] for f in por_medir]}")
mcp.session_id = None mcp.session_id = None
try: try:
mcp.handshake() mcp.handshake()
except Gate5Error as e: sonda, _ = mcp.execute_code("return 1;")
print(f"[LOTE] el re-handshake fallo ({e}); se corta y quedan pendientes " if PLUGIN_NOT_CONNECTED in sonda:
f"{len(prompts) - inicio} prompt(s)") print("[PASADA] el plugin no esta conectado; no tiene sentido reintentar")
pendientes.extend(f["id"] for f in prompts[inicio:])
corte_por_degradacion = True
break break
print(f"\n[LOTE] {n_lote}/{total_lotes}: {[f['id'] for f in lote]}") except Gate5Error as e:
print(f"[PASADA] el re-handshake fallo ({e}); se corta")
break
else:
print(f"\n[PASADA 1/{MAX_PASADAS}] {len(por_medir)} prompt(s)")
for i, fila in enumerate(lote): fallaron = []
for fila in por_medir:
if time.time() > fin_de_pared:
print(f"[PARED] tope alcanzado; {fila['id']} y los siguientes quedan sin medir")
fallaron.append(fila)
continue
deadline = min(time.time() + PROMPT_TIMEOUT_S, fin_de_pared)
try: try:
resultados.append(run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir)) medidos[fila["id"]] = run_prompt(cfg, mcp, tools, system_prompt, fila,
except PluginNotConnected: png_dir, deadline=deadline)
volcar(resultados, time.time() - t0) except PluginNotConnected as e:
raise # Sin plugin no hay nada que medir en NINGUN prompt: se corta la pasada, pero la
# corrida NO aborta -- lo ya medido queda en disco y la pasada siguiente
# reintenta con handshake limpio.
print(f"[ERROR] {fila['id']}: plugin no conectado ({str(e)[:90]})")
ultimo_error[fila["id"]] = f"PluginNotConnected: {str(e)[:200]}"
fallaron.append(fila)
fallaron.extend(f for f in por_medir[por_medir.index(fila) + 1:])
break
except Gate5Error as e: except Gate5Error as e:
# Un fallo de setup NO quema el prompt: queda PENDIENTE, no medido con score # Un fallo de un prompt NO aborta la corrida ni quema el prompt: se reintenta.
# None. Y se corta el lote, porque una vez que el plugin empieza a dar timeouts clase = "timeout del prompt" if isinstance(e, PromptTimeout) else "fallo"
# los que siguen fallan todos igual -- insistir solo gasta paginas y ensucia el print(f"[ERROR] {fila['id']}: {clase} -> {str(e)[:220]}")
# JSON. ultimo_error[fila["id"]] = str(e)[:400]
print(f"[ERROR] {fila['id']}: {e}") fallaron.append(fila)
restantes = [f["id"] for f in lote[i:]] + \ except Exception as e: # noqa: BLE001 - una excepcion inesperada tampoco puede
[f["id"] for f in prompts[inicio + BATCH_SIZE:]] print(f"[ERROR] {fila['id']}: excepcion inesperada {type(e).__name__}: "
print(f"[LOTE] se corta el lote. Quedan {len(restantes)} prompt(s) PENDIENTES " f"{str(e)[:200]}") # tirar abajo los 9 prompts restantes
f"(no medidos, no quemados): {restantes}") ultimo_error[fila["id"]] = f"{type(e).__name__}: {str(e)[:300]}"
pendientes.extend(restantes) fallaron.append(fila)
corte_por_degradacion = True volcar(list(medidos.values()), time.time() - t0)
break por_medir = fallaron
volcar(resultados, time.time() - t0)
if corte_por_degradacion: resultados = list(medidos.values())
break for fila in por_medir:
resultados.append({"id": fila["id"], "score": None,
"error": ultimo_error.get(fila["id"], "no medido")})
pendientes = [f["id"] for f in por_medir]
dt = time.time() - t0 dt = time.time() - t0
aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"], aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"],
esperados=len(todos_los_prompts)) esperados=len(todos_los_prompts))
print(f"\n tiempo total: {dt / 60:.1f} min") print(f"\n tiempo total: {dt / 60:.1f} min")
volcar(resultados, dt, veredicto_parcial=veredicto, completo=True) volcar(resultados, dt, veredicto_parcial=veredicto,
completo=not pendientes)
# Inventario de las paginas gate5 que quedaron de corridas ANTERIORES. No se borran solas:
# es el archivo del usuario y la decision es suya. Se listan para poder ofrecerselo.
try:
inv = mcp.execute_json(
"const ps = penpotUtils.getPages().filter(function(p){"
" return p.name && p.name.indexOf('gate5/') === 0; });"
"return { total: ps.length, nombres: ps.slice(0, 60).map(function(p){ return p.name; }) };",
"inventario de paginas gate5")
print(f"\n[PAGINAS] el archivo tiene {inv.get('total')} pagina(s) gate5/ acumuladas de "
f"esta corrida y de anteriores.")
print(" Las de ESTA corrida ya quedaron vaciadas tras exportar su PNG. Las viejas se "
"dejan como estan: es el archivo del usuario y se le ofrece borrarlas en el C1.")
(REPO_ROOT / "data" / f"gate5_paginas_{cfg['GATE5_TAG']}.json").write_text(
json.dumps(inv, ensure_ascii=False, indent=2), encoding="utf-8")
except Gate5Error as e:
print(f"[WARN] no se pudo inventariar las paginas gate5 ({e})")
sin_medir = sorted(set(pendientes) | sin_medir = sorted(set(pendientes) |
{r["id"] for r in fusionar(resultados) if r.get("score") is None}) {r["id"] for r in fusionar(resultados) if r.get("score") is None})
if sin_medir: if sin_medir: