From 869b00c924498fb3d0f95b55c3682d225e6c3ff2 Mon Sep 17 00:00:00 2001 From: Alejandro Lembke Barrientos Date: Thu, 30 Jul 2026 20:27:46 +0000 Subject: [PATCH] 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. --- scripts/35_gate5_penpot_design.py | 160 +++++++++++++++++++++--------- 1 file changed, 113 insertions(+), 47 deletions(-) diff --git a/scripts/35_gate5_penpot_design.py b/scripts/35_gate5_penpot_design.py index c0478ed..553e188 100644 --- a/scripts/35_gate5_penpot_design.py +++ b/scripts/35_gate5_penpot_design.py @@ -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, _es_error = mcp.execute_code("return { gate5Probe: true };") - if PLUGIN_NOT_CONNECTED in sonda: - raise PluginNotConnected(sonda) - print(f"[MCP] sonda de conectividad del plugin: {sonda[:120]}") + # 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: + 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() + sonda, _ = mcp.execute_code("return 1;") + if PLUGIN_NOT_CONNECTED in sonda: + print("[PASADA] el plugin no esta conectado; no tiene sentido reintentar") + break 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 + print(f"[PASADA] el re-handshake fallo ({e}); se corta") break - print(f"\n[LOTE] {n_lote}/{total_lotes}: {[f['id'] for f in lote]}") + 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 - 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 + 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 - volcar(resultados, time.time() - t0) - if corte_por_degradacion: - break + except Gate5Error as e: + # 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: