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.
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
# aprobacion: si este no llega a 60, la puerta no aprueba aunque el promedio alcance.
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."""
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
# ------------------------------------------------------------------------------------------
@@ -1289,8 +1303,13 @@ def parse_tool_arguments(tool_call):
# ------------------------------------------------------------------------------------------
# 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"]
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"]
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
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
for turno in range(1, MAX_TURNS + 1):
queda_tiempo(f"turno {turno}")
try:
choice = chat_completion(cfg, messages, tools)
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.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 };")
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]}")
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']}"
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")
t0 = time.time()
resultados = []
pendientes = []
corte_por_degradacion = False
fin_de_pared = t0 + WALL_CLOCK_S
medidos = {}
ultimo_error = {}
# LOTES. El plugin de Penpot aguanta de forma confiable unos 5 o 6 prompts pesados y despues
# se degrada hasta dar timeouts de 30 s en `createPage`. Medido en tres corridas seguidas,
# siempre con la misma forma: los primeros funcionan, el resto falla en el setup sin
# excepcion. Procesar en lotes chicos con un handshake limpio en el medio le da al servidor
# un punto de recuperacion, y si igual se degrada se corta ahi en vez de quemar los que
# faltan de a uno.
for inicio in range(0, len(prompts), BATCH_SIZE):
lote = prompts[inicio:inicio + BATCH_SIZE]
n_lote = inicio // BATCH_SIZE + 1
total_lotes = (len(prompts) + BATCH_SIZE - 1) // BATCH_SIZE
if inicio > 0:
print(f"\n[LOTE] handshake limpio antes del lote {n_lote}/{total_lotes}")
# CORRIDA UNICA, EN PASADAS. El MCP de Penpot se cae seguido y cada ventana en que funciona
# es cara, asi que la corrida no puede rendirse por el fallo de uno: recorre todos los
# prompts, anota los que fallaron, y los reintenta en la pasada siguiente con un handshake
# limpio en el medio. Los cortes anteriores nos costaron tres ventanas del usuario.
por_medir = list(prompts)
for pasada in range(1, MAX_PASADAS + 1):
if not por_medir:
break
if time.time() > fin_de_pared:
print(f"\n[PARED] se agoto el tope de {WALL_CLOCK_S // 60} min; quedan "
f"{len(por_medir)} sin medir")
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
try:
mcp.handshake()
except Gate5Error as e:
print(f"[LOTE] el re-handshake fallo ({e}); se corta y quedan pendientes "
f"{len(prompts) - inicio} prompt(s)")
pendientes.extend(f["id"] for f in prompts[inicio:])
corte_por_degradacion = True
sonda, _ = mcp.execute_code("return 1;")
if PLUGIN_NOT_CONNECTED in sonda:
print("[PASADA] el plugin no esta conectado; no tiene sentido reintentar")
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:
resultados.append(run_prompt(cfg, mcp, tools, system_prompt, fila, png_dir))
except PluginNotConnected:
volcar(resultados, time.time() - t0)
raise
medidos[fila["id"]] = run_prompt(cfg, mcp, tools, system_prompt, fila,
png_dir, deadline=deadline)
except PluginNotConnected as e:
# 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:
# Un fallo de setup NO quema el prompt: queda PENDIENTE, no medido con score
# None. Y se corta el lote, porque una vez que el plugin empieza a dar timeouts
# los que siguen fallan todos igual -- insistir solo gasta paginas y ensucia el
# JSON.
print(f"[ERROR] {fila['id']}: {e}")
restantes = [f["id"] for f in lote[i:]] + \
[f["id"] for f in prompts[inicio + BATCH_SIZE:]]
print(f"[LOTE] se corta el lote. Quedan {len(restantes)} prompt(s) PENDIENTES "
f"(no medidos, no quemados): {restantes}")
pendientes.extend(restantes)
corte_por_degradacion = True
break
volcar(resultados, time.time() - t0)
if corte_por_degradacion:
break
# Un fallo de un prompt NO aborta la corrida ni quema el prompt: se reintenta.
clase = "timeout del prompt" if isinstance(e, PromptTimeout) else "fallo"
print(f"[ERROR] {fila['id']}: {clase} -> {str(e)[:220]}")
ultimo_error[fila["id"]] = str(e)[:400]
fallaron.append(fila)
except Exception as e: # noqa: BLE001 - una excepcion inesperada tampoco puede
print(f"[ERROR] {fila['id']}: excepcion inesperada {type(e).__name__}: "
f"{str(e)[:200]}") # tirar abajo los 9 prompts restantes
ultimo_error[fila["id"]] = f"{type(e).__name__}: {str(e)[:300]}"
fallaron.append(fila)
volcar(list(medidos.values()), time.time() - t0)
por_medir = fallaron
resultados = list(medidos.values())
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
aprueba, veredicto = resumen(fusionar(resultados), cfg["GATE5_TAG"],
esperados=len(todos_los_prompts))
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) |
{r["id"] for r in fusionar(resultados) if r.get("score") is None})
if sin_medir: