<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Franchesco Romero</title>
    <description>The latest articles on DEV Community by Franchesco Romero (@elchesco_).</description>
    <link>https://dev.to/elchesco_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1636392%2Fc4a5090b-c32c-48a0-884c-1d8299fa6fb7.JPG</url>
      <title>DEV Community: Franchesco Romero</title>
      <link>https://dev.to/elchesco_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/elchesco_"/>
    <language>en</language>
    <item>
      <title>Lambda Web Functions cobra la memoria mientras el entorno viva: el punto de equilibrio contra Lambda clásica</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 25 Oct 2026 22:13:32 +0000</pubDate>
      <link>https://dev.to/aws-builders/lambda-web-functions-cobra-la-memoria-mientras-el-entorno-viva-el-punto-de-equilibrio-contra-45mo</link>
      <guid>https://dev.to/aws-builders/lambda-web-functions-cobra-la-memoria-mientras-el-entorno-viva-el-punto-de-equilibrio-contra-45mo</guid>
      <description>&lt;p&gt;AWS lanzó Lambda Web Functions el 12 de octubre de 2026. Corre un servidor HTTP normal, atiende hasta 128 peticiones a la vez por entorno y cobra CPU activa en vez de tiempo de reloj. Suena a un descuento directo sobre AWS Lambda clásica. No lo es: el costo pasa de "el tiempo que corre una petición" a "la memoria que ocupa un entorno mientras existe". Este post calcula dónde se cruzan las dos líneas y lo prueba con una API real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué cambia en la factura
&lt;/h2&gt;

&lt;p&gt;Lambda clásica (us-east-1, Arm) cobra cada petición mientras corre:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;$0.0000133334 por GB-segundo de memoria configurada, en tiempo de reloj, incluido el tiempo que pasa esperando a la base de datos&lt;/li&gt;
&lt;li&gt;$0.20 por millón de peticiones&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Web Functions cobra tres cosas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;$0.0000276944 por vCPU-segundo de CPU &lt;strong&gt;activa&lt;/strong&gt;. Esperar I/O casi no cuesta.&lt;/li&gt;
&lt;li&gt;$0.0000036667 por GB-segundo de memoria, &lt;strong&gt;mientras el entorno exista&lt;/strong&gt;, incluido el tiempo ocioso entre peticiones&lt;/li&gt;
&lt;li&gt;$0.30 por millón de peticiones&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dos consecuencias:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;La memoria se vuelve un piso mensual.&lt;/strong&gt; Si llega tráfico cada pocos minutos, el entorno nunca se apaga. Con 1 GB son unos $9.50 al mes antes de cobrar una sola petición.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La CPU cuesta casi el doble por segundo, pero sólo cuenta la CPU.&lt;/strong&gt; Una petición que tarda 200 ms y calcula durante 50 ms paga 50 ms.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Así que Web Functions gana por petición sólo cuando las peticiones esperan mucho más de lo que calculan, y necesita suficientes peticiones para pagar el piso.&lt;/p&gt;

&lt;h2&gt;
  
  
  El punto de equilibrio
&lt;/h2&gt;

&lt;p&gt;Los dos modelos pagan por petición; Web Functions además paga el piso. El cruce está en:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;break-even requests/month = floor / (classic cost per request - Web Functions cost per request)

classic per request        = latency_s * classic_GB * 0.0000133334 + 0.0000002
Web Functions per request  = cpu_s * 0.0000276944 + 0.0000003
floor                      = env_GB * 2,592,000 * 0.0000036667
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La página de precios de AWS trae un ejemplo de "API backend con mucho I/O": 200 ms por petición, 50 ms de CPU, unos 0.29 GB por entorno. Contra una función clásica de 1 GB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Web Functions memory floor   $2.76/month
classic, per million         $2.87
Web Functions, per million   $1.68
break-even                   2,331,877 requests/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arriba de 2.3 millones de peticiones al mes, Web Functions sale más económica. Ahora la misma carga contra una función clásica de 512 MB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Web Functions memory floor   $2.76/month
classic, per million         $1.53
Web Functions, per million   $1.68
Web Functions never pays off: per request it costs as much or more than classic.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;La respuesta se voltea según cuánta memoria necesitaría la función clásica.&lt;/strong&gt; La comparación sólo tiene sentido contra la configuración clásica que de verdad usarías. Una advertencia: Lambda clásica asigna CPU en proporción a la memoria, así que 50 ms de CPU a 1 GB tardan más a 512 MB, y la latencia sube con ellos. Mide antes de confiar en la línea más económica.&lt;/p&gt;

&lt;h2&gt;
  
  
  Una API real
&lt;/h2&gt;

&lt;p&gt;Una API pequeña en producción: FastAPI en una tarea de AWS Fargate (0.25 vCPU, 1 GB, Arm), detrás de un Application Load Balancer, con PostgreSQL en Amazon RDS. Catorce días de métricas de Amazon CloudWatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RequestCount                 101865.0
latency_s avg=0.075 max=33.25 p50=0.0085 p99=1.096
minutes with &amp;gt;=1 request: 20159 of 20160
p50 requests/min: 1
peak requests/min: 283 (4.72 rps)
MemoryUtilization  68.65622133758562
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unas 218,000 peticiones al mes, con tráfico en casi todos los minutos y una petición por minuto en la mediana. Como hay tráfico en cada minuto, un entorno se quedaría vivo todo el mes y el piso se paga completo.&lt;/p&gt;

&lt;p&gt;El punto de equilibrio en el caso más favorable para Web Functions, cero CPU por petición:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Web Functions memory floor   $6.56/month
classic, per million         $1.20
Web Functions, per million   $0.30
break-even                   7,286,426 requests/month
your traffic                 218,282 requests/month (3.0% of break-even)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Esta API necesitaría 33 veces su tráfico para que Web Functions le gane a Lambda clásica, aunque sus peticiones no usaran nada de CPU.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;La factura mensual completa:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Fargate + ALB (hoy)&lt;/th&gt;
&lt;th&gt;Web Functions&lt;/th&gt;
&lt;th&gt;Lambda clásica&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cómputo&lt;/td&gt;
&lt;td&gt;$8.39&lt;/td&gt;
&lt;td&gt;$7.03&lt;/td&gt;
&lt;td&gt;$0.26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Puerta de entrada&lt;/td&gt;
&lt;td&gt;ALB, 2 IPv4 públicas, AWS WAF regional: $32.50&lt;/td&gt;
&lt;td&gt;CloudFront + WAF: ~$9.22&lt;/td&gt;
&lt;td&gt;CloudFront + WAF: ~$9.22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$40.89&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$16.25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$9.48&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El hallazgo más grande está en la segunda fila. A este tamaño, el dinero no se va en el cómputo. El balanceador, sus dos direcciones IPv4 públicas y el WAF regional cuestan cuatro veces lo que cuesta el contenedor que tienen detrás. Cualquiera de las dos opciones de Lambda ahorra sobre todo porque quita el ALB, no porque calcule de forma más eficiente. Un dominio propio o un WAF delante de Lambda siguen necesitando Amazon CloudFront, así que el costo del WAF se muda en vez de desaparecer.&lt;/p&gt;

&lt;p&gt;Para esta API la pregunta es teórica de todos modos: en el lanzamiento, Web Functions sólo corre Node.js, y esta API es Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cuándo conviene
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Usa Web Functions&lt;/strong&gt; cuando el tráfico sea constante y alto, las peticiones pasen casi todo su tiempo esperando I/O, el código sea Node.js y la función clásica que reemplazas necesite mucha memoria.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quédate en Lambda clásica&lt;/strong&gt; cuando el tráfico sea bajo o a ráfagas. Si un entorno atiende unas cuantas peticiones por minuto, el piso de memoria es puro gasto extra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Si estás en contenedores, revisa la puerta de entrada antes que el cómputo.&lt;/strong&gt; Un servicio pequeño detrás de un ALB puede pagar más por el balanceador que por la tarea.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>performance</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Endurecer servidores mcp stdio</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 24 Oct 2026 19:50:20 +0000</pubDate>
      <link>https://dev.to/aws-builders/endurecer-servidores-mcp-stdio-2d7d</link>
      <guid>https://dev.to/aws-builders/endurecer-servidores-mcp-stdio-2d7d</guid>
      <description>&lt;p&gt;Un servidor de MCP que usa el transporte stdio es una línea de comandos que tu&lt;br&gt;
agente corre como tú. En octubre de 2026 eso dejó de ser abstracto: el&lt;br&gt;
CVE-2026-105740 (CVSS 9.9) de Langflow dejó que cualquier usuario con sesión&lt;br&gt;
registrara un servidor stdio cuyo comando iba directo a &lt;code&gt;bash -c&lt;/code&gt;. Este post&lt;br&gt;
recorre el bug, el arreglo que de verdad aguanta, y una auditoría de una máquina&lt;br&gt;
de desarrollo corriendo Claude Code, que sacó una línea de &lt;code&gt;npx&lt;/code&gt; apuntando a un&lt;br&gt;
scope de npm que nadie tiene.&lt;/p&gt;
&lt;h2&gt;
  
  
  El punto de partida: qué hizo Langflow
&lt;/h2&gt;

&lt;p&gt;El advisory (GHSA-7w94-79vh-5mr2) nombra las líneas. La API acepta un&lt;br&gt;
&lt;code&gt;MCPServerConfig&lt;/code&gt; con &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;args&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt; y &lt;code&gt;headers&lt;/code&gt; de cualquier&lt;br&gt;
usuario autenticado. No hay "ninguna allowlist de comandos permitidos, ninguna&lt;br&gt;
blocklist de comandos peligrosos". Después, el comando se arranca así:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exec &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;command_str&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; || echo &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Command failed...&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El dict de &lt;code&gt;env&lt;/code&gt; del usuario se mergea en el entorno del hijo. El trigger no es&lt;br&gt;
"corre este servidor": es &lt;code&gt;GET /api/v2/mcp/servers?action_count=true&lt;/code&gt;, la petición&lt;br&gt;
que lista los servidores. Afectado: 1.8.3 según el advisory, parchado en 1.9.0;&lt;br&gt;
una nota de triaje también lista 1.10.3, así que 1.10.3 o posterior cubre las dos&lt;br&gt;
lecturas. Los reportes de intentos de explotación desde honeypots están en los&lt;br&gt;
miles bajos; el que tiene una nota de metodología cuenta 2,023 desde el 19 de&lt;br&gt;
septiembre desde un solo sensor.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/blog-mcp-stdio-hardening-code" rel="noopener noreferrer"&gt;
        blog-mcp-stdio-hardening-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code companion - an MCP stdio server is a shell command: hardening after the Langflow CVE
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion: an MCP stdio server is a shell command&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Folder&lt;/th&gt;

&lt;th&gt;Post section&lt;/th&gt;

&lt;th&gt;What it is&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;00-the-bug/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 0: the bug&lt;/td&gt;

&lt;td&gt;
&lt;br&gt;
&lt;code&gt;vulnerable_spawn.py&lt;/code&gt;: the CVE-2026-105740 pattern reduced to its two lines, with harmless payloads. Kept as a DEAD END on purpose.&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;01-safe-spawn/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 1: the fix&lt;/td&gt;

&lt;td&gt;
&lt;br&gt;
&lt;code&gt;safe_spawn.py&lt;/code&gt;: server IDs mapped to pinned argv, no shell, env from an allowlist.&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;02-audit-your-machine/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 2: measure&lt;/td&gt;

&lt;td&gt;
&lt;br&gt;
&lt;code&gt;mcp_audit.py&lt;/code&gt;: every MCP server your agents will start, flagged STDIO / UNPINNED / AUTO-YES / NOT-FOUND / ENV / PLAIN-HTTP.&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;03-managed-policy/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 3: fleet policy&lt;/td&gt;

&lt;td&gt;Claude Code managed settings: exact &lt;code&gt;serverCommand&lt;/code&gt; allowlist, locked to managed scope.&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;04-aws-tool-role/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 4: identity&lt;/td&gt;

&lt;td&gt;IAM role for a tool that runs on AWS: one log group, explicit denies, confused-deputy-safe trust policy.&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;05-checklist/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 5&lt;/td&gt;

&lt;td&gt;What to check on your machine today, and what a platform must enforce.&lt;/td&gt;

&lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Suggested reading order&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Run &lt;code&gt;02&lt;/code&gt; first; it takes ten seconds and is…&lt;/p&gt;&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/blog-mcp-stdio-hardening-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;h2&gt;
  
  
  Ronda 0: el bug, reducido
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;00-the-bug/vulnerable_spawn.py&lt;/code&gt; guarda solo las dos líneas que importan y corre&lt;br&gt;
payloads inofensivos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1: mcp-server-started
2: mcp-server-started
3: mcp-server-started substitution-ran-before-exec
4: not node: PATH from the env field won
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres detalles no obvios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;La línea 2 parece una mitigación y no lo es.&lt;/strong&gt; &lt;code&gt;exec&lt;/code&gt; reemplaza el shell con
el primer programa, así que el &lt;code&gt;; echo chained&lt;/code&gt; nunca corre. La sustitución de
comandos sí, porque el shell expande &lt;code&gt;$(...)&lt;/code&gt; antes de &lt;code&gt;exec&lt;/code&gt; (línea 3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El campo env es su propia inyección.&lt;/strong&gt; La línea 4 pide &lt;code&gt;node --version&lt;/code&gt; y
obtiene un binario distinto, porque &lt;code&gt;PATH&lt;/code&gt; vino de la petición. &lt;code&gt;LD_PRELOAD&lt;/code&gt; hace
lo mismo para cualquier programa enlazado dinámicamente en Linux.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nada de eso importa mucho.&lt;/strong&gt; El campo command acepta cualquier programa. Un
parser perfecto que rechace &lt;code&gt;$(...)&lt;/code&gt; de todos modos corre &lt;code&gt;curl ... | sh&lt;/code&gt; si el
usuario lo tecleó como el comando. El bug es que un usuario define qué corre, no
cómo se parsea el string.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Ronda 1: el arreglo que aguanta
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;01-safe-spawn/safe_spawn.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;REGISTRY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ServerSpec&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ServerSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/playwright-mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--headless&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;echo-demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ServerSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/echo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp-server-started&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_stdio_server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;REGISTRY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NotAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown MCP server id: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;server_id&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user_args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NotAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments are part of the reviewed spec, not the request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;child_env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;shell&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1: mcp-server-started
refused: unknown MCP server id: "bash -c 'echo pwned'"
refused: arguments are part of the reviewed spec, not the request
refused: binary not installed: /usr/local/bin/playwright-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Las reglas, en orden de cuánto compran:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Los usuarios escogen un ID. Nunca mandan un comando.&lt;/li&gt;
&lt;li&gt;Cada ID mapea a un argv exacto que se revisa como una dependencia, con una
versión fijada instalada en tiempo de build de la imagen. Nada se jala al
arrancar.&lt;/li&gt;
&lt;li&gt;Sin shell.&lt;/li&gt;
&lt;li&gt;El entorno del hijo se construye desde una allowlist (&lt;code&gt;HOME&lt;/code&gt;, &lt;code&gt;LANG&lt;/code&gt;, &lt;code&gt;TZ&lt;/code&gt;, un
&lt;code&gt;PATH&lt;/code&gt; fijo) más los valores que un admin puso. El entorno del que llama nunca se
mergea con datos de la petición.&lt;/li&gt;
&lt;li&gt;Agregar un ID es un cambio de admin con un registro de auditoría. En Langflow, la
config persistía en los settings del proyecto que las revisiones de flow no
miraban.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Ronda 2: mide tu propia máquina
&lt;/h2&gt;

&lt;p&gt;El mismo riesgo existe sin un servidor vulnerable. Un agente de código lee las&lt;br&gt;
definiciones de servidor stdio de los archivos de config y las arranca como tú.&lt;br&gt;
&lt;code&gt;02-audit-your-machine/mcp_audit.py&lt;/code&gt; lista cada servidor de las ubicaciones&lt;br&gt;
usuales (los scopes user y local de &lt;code&gt;~/.claude.json&lt;/code&gt;, el &lt;code&gt;.mcp.json&lt;/code&gt; del proyecto,&lt;br&gt;
Kiro, Cursor, Claude Desktop) y marca lo que encuentra. Lee nada más, más una&lt;br&gt;
petición HEAD por paquete al registry de npm o PyPI.&lt;/p&gt;

&lt;p&gt;En la máquina que usé para escribir este post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude.json (local scope: ~/projects)
  playright            npx @playwrigth/mcp@latest                                   STDIO UNPINNED NOT-FOUND(npm:@playwrigth/mcp)
~/.claude.json (local scope: ~/projects/myapp)
  playright            npx @playwrigth/mcp@latest                                   STDIO UNPINNED NOT-FOUND(npm:@playwrigth/mcp)

2 servers, 2 stdio, 2 with a finding beyond STDIO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;playwrigth&lt;/code&gt; es un typo, y el scope de npm no existe.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org/@playwrigth/mcp
404
$ curl -s https://registry.npmjs.org/-/org/playwrigth/package
{"error":"Scope not found"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cualquiera puede crear la org &lt;code&gt;playwrigth&lt;/code&gt; en npm y publicar &lt;code&gt;@playwrigth/mcp&lt;/code&gt;. Los&lt;br&gt;
servidores de scope local se cargan cuando una sesión arranca en ese directorio, y&lt;br&gt;
&lt;code&gt;@latest&lt;/code&gt; significa que el siguiente arranque resuelve lo que sea que se haya&lt;br&gt;
publicado al último. El servidor nunca funcionó, así que nadie lo notó: una conexión&lt;br&gt;
de MCP fallida es una línea en &lt;code&gt;/mcp&lt;/code&gt;, no un error que detenga el trabajo.&lt;/p&gt;

&lt;p&gt;Dos detalles no obvios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;El lookup del registry es lo que lo encontró.&lt;/strong&gt; Sin él, la línea se lee como un
servidor de Playwright normal. El flag &lt;code&gt;NOT-FOUND&lt;/code&gt; de la auditoría existe para
este caso: un typo, un scope sin reclamar, o un paquete que se despublicó.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La aprobación del &lt;code&gt;.mcp.json&lt;/code&gt; del proyecto no es una respuesta completa.&lt;/strong&gt;
Claude Code pregunta antes de usar servidores de scope de proyecto en sesiones
interactivas, e ignora las aprobaciones que un repositorio clonado se commitea a
sí mismo. En &lt;code&gt;claude -p&lt;/code&gt;, las sesiones del Agent SDK y algunas sesiones de nube,
"carga los servidores de scope de proyecto sin preguntar". El scope local, donde
vivía este typo, es la propia config del usuario, así que no hay paso de
aprobación en absoluto.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El arreglo es un comando por directorio afectado:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp remove playright
claude mcp add playwright &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @playwright/mcp@0.0.83
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Ronda 3: política para una flota
&lt;/h2&gt;

&lt;p&gt;Un desarrollador puede arreglar un typo. Un equipo necesita la política para hacer&lt;br&gt;
el typo inerte. Claude Code lee &lt;code&gt;allowedMcpServers&lt;/code&gt; y &lt;code&gt;deniedMcpServers&lt;/code&gt; de los&lt;br&gt;
managed settings (&lt;code&gt;03-managed-policy/managed-settings.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowManagedMcpServersOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowedMcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"serverUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.githubcopilot.com/*"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"serverUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.example.com/*"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"serverCommand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@playwright/mcp@0.0.83"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deniedMcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"serverCommand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@playwright/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"serverUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://*"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con una entrada de &lt;code&gt;serverCommand&lt;/code&gt; presente, cada servidor stdio debe empatar un&lt;br&gt;
comando listado exactamente, cada argumento en orden. La línea con el typo no&lt;br&gt;
empata, así que no se carga.&lt;/p&gt;

&lt;p&gt;Tres detalles no obvios de los docs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;serverName&lt;/code&gt; no es un control de seguridad.&lt;/strong&gt; Es la etiqueta tecleada en
&lt;code&gt;claude mcp add&lt;/code&gt;. Cualquiera puede nombrar cualquier servidor &lt;code&gt;github&lt;/code&gt;. Usa
&lt;code&gt;serverCommand&lt;/code&gt; y &lt;code&gt;serverUrl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;serverCommand&lt;/code&gt; no compara &lt;code&gt;env&lt;/code&gt;.&lt;/strong&gt; Los docs lo dicen directo: "Algunas
variables de entorno cambian lo que &lt;code&gt;node&lt;/code&gt; carga al arrancar". Ese es el problema
de &lt;code&gt;LD_PRELOAD&lt;/code&gt; de Langflow en otro producto. Si el env importa, define el
servidor en &lt;code&gt;managed-mcp.json&lt;/code&gt;, donde el admin también pone su env.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sin &lt;code&gt;allowManagedMcpServersOnly&lt;/code&gt;, los usuarios ensanchan la lista.&lt;/strong&gt; Las
allowlists se mergean de cada scope, incluyendo &lt;code&gt;~/.claude/settings.json&lt;/code&gt;. Las
denylists siempre se mergean, y un deny siempre gana.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Ronda 4: la identidad como la que corre una herramienta
&lt;/h2&gt;

&lt;p&gt;En una laptop, un servidor stdio hereda todo lo que el desarrollador tiene:&lt;br&gt;
&lt;code&gt;~/.aws&lt;/code&gt;, el keychain, el SSH agent, el historial de shell. En una plataforma, el&lt;br&gt;
mismo servidor debería ser un servicio remoto con su propia identidad. Ese es el&lt;br&gt;
argumento más fuerte para los transportes remotos: un servidor HTTP corre en su&lt;br&gt;
propio proceso, como su propio rol, detrás de una auth que se puede revocar sin&lt;br&gt;
tocar la laptop de nadie.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;04-aws-tool-role/&lt;/code&gt; es la forma de AWS de eso:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Leer un log group, leer métricas, nada más.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Deny&lt;/code&gt; explícito sobre &lt;code&gt;iam:*&lt;/code&gt;, &lt;code&gt;sts:AssumeRole&lt;/code&gt;, &lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt;,
&lt;code&gt;ssm:GetParameter*&lt;/code&gt; y &lt;code&gt;kms:Decrypt&lt;/code&gt;, para que un &lt;code&gt;Allow&lt;/code&gt; amplio agregado después no
lo pueda ensanchar.&lt;/li&gt;
&lt;li&gt;Una trust policy que solo deja a las tareas de ECS en esta cuenta asumir el rol
(&lt;code&gt;aws:SourceAccount&lt;/code&gt; + &lt;code&gt;aws:SourceArn&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Detalle no obvio: &lt;code&gt;cloudwatch:GetMetricData&lt;/code&gt; no tiene scoping a nivel de recurso. Si&lt;br&gt;
los nombres de las métricas son sensibles, la respuesta es una cuenta separada, no&lt;br&gt;
un statement más listo.&lt;/p&gt;

&lt;p&gt;Lo que el rol no resuelve es el egreso. Una herramienta de solo-lectura que puede&lt;br&gt;
llamar a cualquier URL puede mandar lo que leyó a cualquier lado. Córrela donde las&lt;br&gt;
únicas rutas sean los endpoints de AWS que necesita.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo se ven los buenos defaults
&lt;/h2&gt;

&lt;p&gt;Azure MCP Server 3.0 pasó a estable la misma semana. Es una referencia útil para&lt;br&gt;
defaults, independiente de la nube:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Modo &lt;code&gt;--read-only&lt;/code&gt;, que también excluye las herramientas de envío de email y SMS.&lt;/li&gt;
&lt;li&gt;Filtrado con &lt;code&gt;--tool&lt;/code&gt; que "empata los nombres expuestos exactamente, ignorando
mayúsculas".&lt;/li&gt;
&lt;li&gt;Validación de endpoint anti-SSRF extendida a más endpoints de data-plane y de
control-plane.&lt;/li&gt;
&lt;li&gt;Sus herramientas de query de PostgreSQL y MySQL ahora están marcadas como
"destructivas, no-idempotentes, y no-de-solo-lectura", y el filtrado por verbo de
SQL se quitó. La guía es permisos de base de datos de menor privilegio en lugar de
tratar de filtrar el texto de SQL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El último punto es la misma lección que la Ronda 0 desde el otro lado: no trates de&lt;br&gt;
hacer segura una primitiva poderosa parseando su input. Dale una identidad que no&lt;br&gt;
pueda hacer la cosa peligrosa.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mide tu propia config antes de leer sobre el CVE de alguien más.&lt;/strong&gt; La auditoría
encontró un paquete reclamable en la máquina que escribió este post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La definición de un servidor stdio es código.&lt;/strong&gt; Revísala, fíjala, y decide quién
la puede escribir, de la manera en que lo harías para un workflow de CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No te parsees para salir de la ejecución de comandos.&lt;/strong&gt; Quita la capacidad del
usuario de escoger el comando.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las variables de entorno son inputs.&lt;/strong&gt; &lt;code&gt;PATH&lt;/code&gt;, &lt;code&gt;LD_PRELOAD&lt;/code&gt; y &lt;code&gt;NODE_OPTIONS&lt;/code&gt;
deciden qué corre tanto como el comando lo hace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La falla silenciosa es una superficie de ataque.&lt;/strong&gt; Un servidor que nunca conectó
por meses es un nombre que nadie está vigilando.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La identidad y el egreso son la frontera real.&lt;/strong&gt; Los flags de solo-lectura y los
filtros de herramientas ayudan; un rol que no puede hacer la cosa peligrosa es lo
que aguanta.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>agents</category>
      <category>cybersecurity</category>
      <category>mcp</category>
      <category>security</category>
    </item>
    <item>
      <title>Auditar un pipeline de IaC después del provider de Terraform troyanizado</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 24 Oct 2026 01:29:50 +0000</pubDate>
      <link>https://dev.to/aws-builders/auditar-un-pipeline-de-iac-despues-del-provider-de-terraform-troyanizado-2339</link>
      <guid>https://dev.to/aws-builders/auditar-un-pipeline-de-iac-despues-del-provider-de-terraform-troyanizado-2339</guid>
      <description>&lt;p&gt;El 8 de octubre, Zscaler ThreatLabz describió un provider de Terraform que se&lt;br&gt;
comporta exactamente como un provider normal de AWS y también, en el momento en&lt;br&gt;
que Terraform lo carga, jala un loader de un dominio parecido e instala un robador&lt;br&gt;
de credenciales (&lt;a href="https://www.zscaler.com/blogs/security-research/suspected-tradertraitor-group-uses-trojanized-terraform-provider-deliver" rel="noopener noreferrer"&gt;reporte&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Este stack no usa Terraform. Usa AWS CDK, desplegado desde GitHub Actions sobre&lt;br&gt;
runners ARM autoalojados. La pregunta de todos modos aplica: si un paquete en el&lt;br&gt;
toolchain de infraestructura se volviera hostil mañana, ¿qué obtendría? Este post&lt;br&gt;
es la auditoría que respondió esa pregunta, con los comandos, su salida, y los&lt;br&gt;
arreglos rankeados por palanca. La mayoría todavía no están hechos.&lt;/p&gt;
&lt;h2&gt;
  
  
  Qué es un provider troyanizado, en términos de CDK
&lt;/h2&gt;

&lt;p&gt;Un provider de Terraform es un binario que corre con las credenciales de nube que&lt;br&gt;
sean que estén en su entorno. En CDK el equivalente es cada paquete en&lt;br&gt;
&lt;code&gt;infra/node_modules&lt;/code&gt;: &lt;code&gt;cdk synth&lt;/code&gt; ejecuta tu app, tu app importa &lt;code&gt;aws-cdk-lib&lt;/code&gt; y&lt;br&gt;
cualquier otra cosa que instalaste, y todo eso corre como un solo proceso de Node.&lt;br&gt;
El provider de Zscaler se disparaba al cargar. Un paquete de npm malicioso se puede&lt;br&gt;
disparar al instalar (lifecycle scripts) o al importar (código de nivel superior).&lt;br&gt;
Al importar basta aquí, porque el synth importa todo.&lt;/p&gt;

&lt;p&gt;Así que la pregunta de la auditoría es: ¿qué credenciales y acceso de red existen&lt;br&gt;
en el proceso que corre &lt;code&gt;npm ci&lt;/code&gt; y &lt;code&gt;cdk synth&lt;/code&gt;?&lt;/p&gt;
&lt;h2&gt;
  
  
  El punto de partida
&lt;/h2&gt;

&lt;p&gt;El job de deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS credentials (OIDC)&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env.DEPLOY_ROLE_ARN != ''&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.DEPLOY_ROLE_ARN }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.AWS_REGION }}&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS credentials (legacy static keys)&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env.DEPLOY_ROLE_ARN == ''&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.AWS_REGION }}&lt;/span&gt;

&lt;span class="c1"&gt;# ... setup-node ...&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy infra&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;cd infra&lt;/span&gt;
    &lt;span class="s"&gt;npm ci&lt;/span&gt;
    &lt;span class="s"&gt;npx cdk deploy MyApp-Backend --require-approval never --progress events&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El orden importa: las credenciales se ponen primero, luego &lt;code&gt;npm ci&lt;/code&gt; y luego el&lt;br&gt;
synth corren dentro del mismo entorno.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hallazgo 1: el rol de OIDC que nadie asume
&lt;/h2&gt;

&lt;p&gt;El repo tiene un rol de OIDC, con alcance a &lt;code&gt;main&lt;/code&gt; y al entorno &lt;code&gt;production&lt;/code&gt;, con&lt;br&gt;
una sesión de una hora. El workflow lo prefiere. Luego:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh secret list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AWS_ACCESS_KEY_ID   2026-04-06
AWS_SECRET_ACCESS_KEY   2026-04-06
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hay un &lt;code&gt;AWS_DEPLOY_ROLE_ARN&lt;/code&gt;, así que el &lt;code&gt;if:&lt;/code&gt; siempre toma la rama de llaves&lt;br&gt;
estáticas.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam list-access-keys &lt;span class="nt"&gt;--user-name&lt;/span&gt; myapp-ci
aws iam get-access-key-last-used &lt;span class="nt"&gt;--access-key-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"CreateDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-06T04:03:15+00:00"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"LastUsedDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-15T22:46:00+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ServiceName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"autoscaling"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;El camino seguro se escribió, se mergeó y se desplegó, y nada lo usa.&lt;/strong&gt; Es fácil&lt;br&gt;
ver cómo pasa eso. La migración estaba bloqueada por una restricción real: el rol&lt;br&gt;
de OIDC todavía no cargaba cada permiso que los workflows de frontend, intelligence&lt;br&gt;
y security-scan necesitan. Mientras tanto, cada grant nuevo fue a las dos&lt;br&gt;
identidades hasta que el usuario pegó contra un techo fácil de pasar por alto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Maximum policy size of 2048 bytes exceeded for user myapp-ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese límite de 2048 bytes aplica a la suma de todas las inline policies de un&lt;br&gt;
usuario, no a cada una. El usuario de CI estaba en 1943 bytes (95%) cuando falló.&lt;br&gt;
El workaround fue una managed policy. El arreglo es terminar la migración, que quita&lt;br&gt;
al usuario y su límite juntos.&lt;/p&gt;

&lt;p&gt;Una llave estática así de vieja es lo que un robador de credenciales está buscando.&lt;br&gt;
El payload en el reporte de Zscaler es un backdoor multiplataforma con robadores de&lt;br&gt;
datos. En un runner, la llave se sienta en el entorno de cada proceso que el job de&lt;br&gt;
deploy arranca.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hallazgo 2: el install y el synth corren con credenciales de deploy
&lt;/h2&gt;

&lt;p&gt;Las credenciales tienen alcance, pero solo en el papel. El grant de CDK de la&lt;br&gt;
identidad de CI es nada más &lt;code&gt;sts:AssumeRole&lt;/code&gt; sobre los roles de bootstrap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sts:AssumeRole&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sts:TagSession&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="nx"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`arn:aws:iam::&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:role/cdk-hnb659fds-*-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uno de esos roles es el deploy role, que le entrega a CloudFormation el execution&lt;br&gt;
role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam list-attached-role-policies &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-name&lt;/span&gt; cdk-hnb659fds-cfn-exec-role-ACCOUNT-us-east-1 &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ATTACHEDPOLICIES    arn:aws:iam::aws:policy/AdministratorAccess AdministratorAccess
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese es el default de &lt;code&gt;cdk bootstrap&lt;/code&gt;. Cualquier código que corra en el paso de&lt;br&gt;
deploy puede desplegar un stack de CloudFormation, y ese stack puede crear un&lt;br&gt;
usuario de IAM con derechos de admin. La cadena es: &lt;code&gt;npm ci&lt;/code&gt; o &lt;code&gt;cdk synth&lt;/code&gt; →&lt;br&gt;
credenciales del entorno → &lt;code&gt;sts:AssumeRole&lt;/code&gt; sobre &lt;code&gt;cdk-*-deploy-role&lt;/code&gt; →&lt;br&gt;
CloudFormation → &lt;code&gt;AdministratorAccess&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Lo que limita el radio de impacto hoy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json; p = json.load(open('package-lock.json'))['packages']
print('packages', len(p) - 1)
print('install scripts', [k for k, v in p.items() if v.get('hasInstallScript')])
print('bundled', sum(1 for v in p.values() if v.get('inBundle')))
print('integrity missing', sum(1 for k, v in p.items() if k and not v.get('integrity') and not v.get('inBundle')))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages 49
install scripts []
bundled 20
integrity missing 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;49 paquetes, ninguno con install scripts, todos fijados por hash de integridad. Esa&lt;br&gt;
es una superficie chica y verificable. Es suerte más que política, sin embargo:&lt;br&gt;
nada detiene que el paquete 50 agregue un &lt;code&gt;postinstall&lt;/code&gt;, y nada detendría que&lt;br&gt;
código de nivel superior en cualquiera de los 49 corra en el synth.&lt;/p&gt;

&lt;p&gt;El arreglo es dividir el job. El synth no necesita credenciales cuando los lookups&lt;br&gt;
de contexto están cacheados en el &lt;code&gt;cdk.context.json&lt;/code&gt; commiteado (este repo ya hace&lt;br&gt;
eso para sus llamadas de &lt;code&gt;HostedZone.fromLookup&lt;/code&gt; y &lt;code&gt;Vpc.fromLookup&lt;/code&gt;). Así que:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Job &lt;code&gt;synth&lt;/code&gt;: sin credenciales de AWS, &lt;code&gt;npm ci --ignore-scripts&lt;/code&gt;, &lt;code&gt;cdk synth&lt;/code&gt;,
sube &lt;code&gt;cdk.out&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Job &lt;code&gt;deploy&lt;/code&gt;: descarga &lt;code&gt;cdk.out&lt;/code&gt;, asume el rol de OIDC, corre
&lt;code&gt;cdk deploy --app cdk.out&lt;/code&gt;. Sin &lt;code&gt;npm ci&lt;/code&gt; y sin código de app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El job de deploy todavía corre la CLI de CDK, que es un paquete. Esa es la misma&lt;br&gt;
confianza que le pones a &lt;code&gt;terraform&lt;/code&gt; mismo, y mucho menos que "cada paquete en el&lt;br&gt;
lockfile". El workflow está en la carpeta de código.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hallazgo 3: un lockfile prueba igualdad, no seguridad
&lt;/h2&gt;

&lt;p&gt;Tres alertas de Dependabot sobre &lt;code&gt;brace-expansion&lt;/code&gt; en &lt;code&gt;infra/&lt;/code&gt; no las puede&lt;br&gt;
arreglar este repo. La copia vulnerable viene dentro del tarball de &lt;code&gt;aws-cdk-lib&lt;/code&gt;&lt;br&gt;
como una de sus 20 dependencias bundleadas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_modules/aws-cdk-lib/node_modules/brace-expansion   "inBundle": true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una entrada de &lt;code&gt;overrides&lt;/code&gt; la deja sin cambios, y &lt;code&gt;npm audit fix&lt;/code&gt; reporta un&lt;br&gt;
arreglo que nunca aplica. El hash de integridad del lockfile cubre el tarball de&lt;br&gt;
&lt;code&gt;aws-cdk-lib&lt;/code&gt;, lo que significa que cubre la copia bundleada también, byte por&lt;br&gt;
byte. Esa es la misma garantía que &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; da para un provider:&lt;br&gt;
obtienes los bytes que obtuviste la vez pasada. El hash checa que los bytes no&lt;br&gt;
cambiaron. No dice nada sobre si eran seguros. Si la primera descarga estuvo mal, el&lt;br&gt;
lockfile fija los bytes malos.&lt;/p&gt;

&lt;p&gt;Los controles de Terraform del escrito de Zscaler (checksums, lockfiles) son&lt;br&gt;
necesarios, y solo llegan hasta ahí. Lo que de verdad reduce la exposición es una&lt;br&gt;
allowlist corta de fuentes (un registry privado o un network mirror para los&lt;br&gt;
providers, &lt;code&gt;onlyBuiltDependencies&lt;/code&gt; para los build scripts de npm) más los hallazgos&lt;br&gt;
2 y 4: limitar qué puede alcanzar el código una vez que corre.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hallazgo 4: un runner de larga vida con derechos de deploy, corriendo código de PR
&lt;/h2&gt;

&lt;p&gt;Los runners son autoalojados, de larga vida, y su instance profile espeja el deploy&lt;br&gt;
role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Espeja los permisos de deploy del rol de OIDC para que los jobs se comporten&lt;/span&gt;
&lt;span class="c1"&gt;// idénticos en el runner nuevo.&lt;/span&gt;
&lt;span class="nx"&gt;instanceRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ECRPushPull&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;instanceRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ECSDeploy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;instanceRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PassECSRoles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;instanceRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;S3SyncSpaAndMarketing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los jobs corren directo en el host, así que cualquier proceso en cualquier job&lt;br&gt;
puede obtener esas credenciales del instance metadata service. Un hop limit de 1 no&lt;br&gt;
ayuda, porque el job no está en un contenedor. El CI de pull request corre en el&lt;br&gt;
mismo pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/acme/myapp/actions/runs/&lt;span class="nv"&gt;$CI_RUN&lt;/span&gt;/jobs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.jobs[]|[.name,.runner_name,.conclusion]|@tsv'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;changes myapp-runner-eded276f1-1    success
frontend    myapp-runner-eded276f1-1    success
frontend-time-travel    myapp-runner-eded276f1-2    success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esa corrida era un PR de Dependabot subiendo &lt;code&gt;katex&lt;/code&gt;. El job &lt;code&gt;frontend&lt;/code&gt; corrió&lt;br&gt;
&lt;code&gt;pnpm install --frozen-lockfile&lt;/code&gt; con el paquete subido, en una caja con push a ECR,&lt;br&gt;
deploy a ECS y escritura a S3 sobre el bucket de la SPA de producción, a una&lt;br&gt;
petición de metadata de distancia.&lt;/p&gt;

&lt;p&gt;GitHub retiene los secretos del repositorio de las corridas disparadas por&lt;br&gt;
Dependabot. Eso se puede ver en el mismo PR, donde el job que despierta el pool de&lt;br&gt;
runners falló porque no tenía credenciales:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"conclusion":"failure","labels":["ubuntu-latest"],"name":"wake"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los jobs de CI corrieron de todos modos, porque otro workflow ya había despertado&lt;br&gt;
el pool. &lt;strong&gt;GitHub retuvo los secretos correctamente. El instance profile no es un&lt;br&gt;
secreto, así que nunca se retuvo.&lt;/strong&gt; La subida de &lt;code&gt;katex&lt;/code&gt; era benigna. El camino&lt;br&gt;
estaba abierto.&lt;/p&gt;

&lt;p&gt;Dos mitigaciones ya ayudan. pnpm 10 no corre ningún build script de dependencia a&lt;br&gt;
menos que estén en allowlist, y la allowlist tiene tres entradas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"pnpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"onlyBuiltDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"esbuild"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"protobufjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@sentry/cli"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El pool también escala a cero entre workflows, así que un compromiso no persiste por&lt;br&gt;
mucho. Ninguno de los dos cambios afecta lo que el código puede hacer al importar&lt;br&gt;
durante la corrida de pruebas.&lt;/p&gt;

&lt;p&gt;El arreglo: quítale los permisos de deploy al instance profile, para que solo pueda&lt;br&gt;
leer el token de registro del runner, y deja que los jobs de deploy obtengan&lt;br&gt;
credenciales a través de OIDC por job. Entonces un job de PR en la misma caja no&lt;br&gt;
obtiene nada del metadata service que importe. Por separado, los runners&lt;br&gt;
&lt;code&gt;--ephemeral&lt;/code&gt; se aseguran de que las sobras de un job no puedan alcanzar las&lt;br&gt;
credenciales del siguiente job.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hallazgo 5: nadie lo vería
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws guardduty list-detectors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"DetectorIds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Para la detección, el reporte de Zscaler recomienda vigilar actividad de proceso&lt;br&gt;
inesperada en las estaciones de trabajo de los desarrolladores y los sistemas de&lt;br&gt;
CI/CD. En esta cuenta, nada vigila los runners ni las llamadas de API que vienen de&lt;br&gt;
ellos. Una llave de CI robada usada desde otra IP se vería como la llave de CI.&lt;/p&gt;

&lt;p&gt;La señal más barata sin servicios nuevos: la llave estática solo se usa desde las&lt;br&gt;
IPs públicas del pool de runners. Una query de CloudTrail Lake o un script de&lt;br&gt;
&lt;code&gt;lookup-events&lt;/code&gt; que marque cualquier llamada de &lt;code&gt;myapp-ci&lt;/code&gt; desde una dirección fuera&lt;br&gt;
del pool atrapa el caso de la llave robada. Script en la carpeta de código. Los&lt;br&gt;
hallazgos de EC2 e IAM de GuardDuty cubren más terreno. Ponle precio para tu cuenta&lt;br&gt;
antes de habilitarlo.&lt;/p&gt;
&lt;h2&gt;
  
  
  Para lectores de Terraform y OpenTofu
&lt;/h2&gt;

&lt;p&gt;No probado en este stack, porque no tiene Terraform. Estos son los equivalentes de&lt;br&gt;
los controles de arriba.&lt;/p&gt;

&lt;p&gt;Restringe de dónde pueden venir los providers, en la config de la CLI&lt;br&gt;
(&lt;code&gt;~/.terraformrc&lt;/code&gt;, o &lt;code&gt;TF_CLI_CONFIG_FILE&lt;/code&gt; en CI):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider_installation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;network_mirror&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://terraform-mirror.example.com/providers/"&lt;/span&gt;
    &lt;span class="nx"&gt;include&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"registry.terraform.io/hashicorp/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;direct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;exclude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"registry.terraform.io/*/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commitea el lockfile con hashes para cada plataforma que corre &lt;code&gt;init&lt;/code&gt;, y falla el CI&lt;br&gt;
si &lt;code&gt;init&lt;/code&gt; lo cambiaría:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform providers lock &lt;span class="nt"&gt;-platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux_arm64 &lt;span class="nt"&gt;-platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux_amd64 &lt;span class="nt"&gt;-platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;darwin_arm64
terraform init &lt;span class="nt"&gt;-lockfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;readonly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Corre &lt;code&gt;plan&lt;/code&gt; con credenciales que pueden leer pero no escribir, y corre &lt;code&gt;apply&lt;/code&gt; en&lt;br&gt;
un job separado con el rol de OIDC, como en el hallazgo 2. Un provider llamado&lt;br&gt;
&lt;code&gt;awsbeta&lt;/code&gt; debería fallar una revisión de &lt;code&gt;required_providers&lt;/code&gt; de inmediato. La&lt;br&gt;
config del mirror de arriba hace que falle al instalar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audita el proceso, no el paquete.&lt;/strong&gt; "¿Este paquete es malicioso?" no se puede
responder. "¿Qué credenciales existen en el proceso que lo corre?" sí, y la
respuesta decide el radio de impacto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un camino seguro que nadie toma no es un control.&lt;/strong&gt; El rol de OIDC pasó la
revisión, se desplegó limpio, y no autenticó nada por meses. Checa qué corrió de
verdad: &lt;code&gt;get-access-key-last-used&lt;/code&gt; y &lt;code&gt;gh secret list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los lockfiles prueban igualdad, no seguridad.&lt;/strong&gt; Son la herramienta correcta
para "¿cambiaron los bytes?". Usa otra cosa para "¿deberían correr estos bytes?".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El instance metadata es un secreto que ningún almacén de secretos retiene.&lt;/strong&gt;
Cualquier cosa que retiene secretos de las corridas no confiables (Dependabot,
forks) se salta el instance profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El default del bootstrap es admin.&lt;/strong&gt; Los pipelines de CDK y de Terraform los dos
tienden a terminar en un rol que puede hacer cualquier cosa. Saber dónde termina el
tuyo antes de decidir cuánto importan los eslabones anteriores.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>aws</category>
      <category>cybersecurity</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Auditar agentes como identidades</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 24 Oct 2026 01:13:15 +0000</pubDate>
      <link>https://dev.to/aws-builders/auditar-agentes-como-identidades-30co</link>
      <guid>https://dev.to/aws-builders/auditar-agentes-como-identidades-30co</guid>
      <description>&lt;p&gt;Los agentes de IA son identidades no humanas. Algunos los despliegas tú mismo, y otros llegan con herramientas que ya usas. &lt;/p&gt;

&lt;h2&gt;
  
  
  Dos tipos de agente
&lt;/h2&gt;

&lt;p&gt;Los agentes &lt;strong&gt;elegidos&lt;/strong&gt; son los que están en tus diagramas: tú creaste el rol,&lt;br&gt;
así que en principio le pusiste alcance. Los agentes &lt;strong&gt;heredados&lt;/strong&gt; llegaron con&lt;br&gt;
una funcionalidad de un proveedor. GitHub trae Dependabot, tu issue tracker trae&lt;br&gt;
un summarizer, tu buzón recibe un asistente que conectaste en treinta segundos.&lt;br&gt;
Nadie escribió un rol para ellos, porque su acceso es un grant de OAuth o una&lt;br&gt;
instalación de GitHub App, no una policy de IAM.&lt;/p&gt;

&lt;p&gt;Los dos tipos tienen una identidad, un conjunto de permisos, un camino de red&lt;br&gt;
hacia las cosas, y actividad que podrías o no loguear. La revisión es la misma de&lt;br&gt;
cualquier forma. Los agentes heredados nada más son más fáciles de olvidar,&lt;br&gt;
porque ningún código de infraestructura los menciona.&lt;/p&gt;

&lt;p&gt;Este post lo propuso un agente programado que lee un buzón a través de un conector&lt;br&gt;
OAuth. Ese conector tiene más alcance hacia datos personales que cualquier cosa en&lt;br&gt;
la cuenta de producción. Pertenece al mismo inventario.&lt;/p&gt;
&lt;h2&gt;
  
  
  La pregunta cara: qué pueden alcanzar los agentes
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Los agentes de modelo: con alcance a propósito
&lt;/h3&gt;

&lt;p&gt;El rol del intelligence service lista los ARNs de modelo exactos que puede&lt;br&gt;
invocar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Restringir a los ARNs de modelo que de verdad usamos para que una tarea&lt;/span&gt;
&lt;span class="c1"&gt;// comprometida no pueda invocar modelos caros de escala Sonnet.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bedrockModelArns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s2"&gt;`arn:aws:bedrock:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::foundation-model/amazon.nova-micro-v1:0`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`arn:aws:bedrock:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::foundation-model/amazon.nova-lite-v1:0`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`arn:aws:bedrock:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::foundation-model/amazon.nova-pro-v1:0`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`arn:aws:bedrock:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::foundation-model/amazon.titan-embed-text-v2:0`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;arn:aws:bedrock:us-west-2::foundation-model/stability.stable-image-core-v1:1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;arn:aws:bedrock:us-west-2::foundation-model/stability.stable-image-ultra-v1:1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El backend, que también genera imágenes, no:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bedrock:InvokeModel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="nx"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;arn:aws:bedrock:*::foundation-model/stability.*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El wildcard fue deliberado: los modelos de imagen de Bedrock pasaron a Legacy tres&lt;br&gt;
veces seguidas (Nova Canvas, Titan v2, SDXL), y cada una significó perseguir un&lt;br&gt;
ARN nuevo. Sigue siendo cada modelo de Stability en cada región. Eso es una&lt;br&gt;
exposición de costo, no una exposición de datos, y una alarma de budget sobre el&lt;br&gt;
atributo de modelo es el control correcto para ello. En este caso la nueva&lt;br&gt;
funcionalidad de Cost Explorer sí ayuda.&lt;/p&gt;

&lt;p&gt;Los controles a nivel de prompt para estos agentes (agencia excesiva, inyección,&lt;br&gt;
manejo de output).&lt;br&gt;
Este post es sobre la identidad debajo de ellos.&lt;/p&gt;
&lt;h3&gt;
  
  
  El agente de código: con alcance en el papel
&lt;/h3&gt;

&lt;p&gt;El grant de secretos dice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Solo credenciales de bootstrap. NO los secretos de la app (rds, stripe, jwt...).&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ReadBootstrapSecrets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;secretsmanager:GetSecretValue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`arn:aws:secretsmanager:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:secret:/myapp/agent-box-github-token-*`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`arn:aws:secretsmanager:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:secret:/myapp/agent-box-claude-credentials-*`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La escritura de vuelta es todavía más estrecha: &lt;code&gt;PutSecretValue&lt;/code&gt; solo sobre las&lt;br&gt;
credenciales, para que "una sesión desbocada no deba poder sobreescribir"&lt;br&gt;
el token de GitHub. Cada statement es correcto. Más abajo, para depurar prod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EcsExecAndRead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ecs:ExecuteCommand&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ecs:DescribeServices&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ecs:DescribeTasks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EcsDeploy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ecs:RegisterTaskDefinition&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ecs:UpdateService&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ecs:RunTask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ecs:StopTask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ecs:TagResource&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pregúntale a IAM qué puede hacer el rol, en lugar de leer los statements uno por&lt;br&gt;
uno:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam simulate-principal-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-source-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT_ROLE_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action-names&lt;/span&gt; ecs:ExecuteCommand ecs:UpdateService ecs:RegisterTaskDefinition &lt;span class="se"&gt;\&lt;/span&gt;
                 secretsmanager:GetSecretValue iam:PassRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-arns&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'EvaluationResults[].[EvalActionName,EvalDecision]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ecs:ExecuteCommand  allowed
ecs:UpdateService   allowed
ecs:RegisterTaskDefinition  allowed
secretsmanager:GetSecretValue   implicitDeny
iam:PassRole    implicitDeny
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GetSecretValue&lt;/code&gt; sobre &lt;code&gt;*&lt;/code&gt; está denegado, que es lo que el comentario prometía.&lt;br&gt;
&lt;code&gt;ExecuteCommand&lt;/code&gt; sobre &lt;code&gt;*&lt;/code&gt; está permitido, y el contenedor del backend de&lt;br&gt;
producción recibe &lt;code&gt;DB_USER&lt;/code&gt;/&lt;code&gt;DB_PASSWORD&lt;/code&gt;, &lt;code&gt;JWT_PRIVATE_KEY&lt;/code&gt; y &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt;&lt;br&gt;
inyectados desde Secrets Manager como variables de entorno al arrancar. Un shell en&lt;br&gt;
ese contenedor está a un &lt;code&gt;env&lt;/code&gt; de distancia de cada secreto que el comentario dice&lt;br&gt;
que el agente no puede leer. Ese camino se usa a propósito: las queries ad-hoc de&lt;br&gt;
producción corren a través de ECS Exec a la tarea del backend. Es un manual de&lt;br&gt;
operaciones documentado, no un hipotético.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;iam:PassRole&lt;/code&gt; muestra &lt;code&gt;implicitDeny&lt;/code&gt; solo porque la simulación no pasó ningún&lt;br&gt;
contexto de &lt;code&gt;iam:PassedToService&lt;/code&gt;. El statement real permite pasar cualquier rol&lt;br&gt;
&lt;code&gt;myapp-*&lt;/code&gt; a tareas de ECS, y junto con &lt;code&gt;RegisterTaskDefinition&lt;/code&gt; y &lt;code&gt;UpdateService&lt;/code&gt;&lt;br&gt;
eso significa: correr cualquier imagen, bajo cualquier task role de app, como un&lt;br&gt;
servicio de producción.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Los secretos tenían alcance. El alcance del agente no.&lt;/strong&gt; El grant de secretos&lt;br&gt;
responde "cuáles secretos puede leer a través de la API de secretos". El alcance es&lt;br&gt;
la unión de cada camino hacia los datos, y aquí el camino más corto pasó por el&lt;br&gt;
contenedor.&lt;/p&gt;

&lt;h3&gt;
  
  
  El agente que agregarías al pipeline
&lt;/h3&gt;

&lt;p&gt;AWS Continuum for Penetration Testing (antes AWS Security Agent) entró a public&lt;br&gt;
preview el 5 de octubre como un paso en tiempo de deploy: hallazgos en la salida&lt;br&gt;
del pipeline, re-test automático después de un arreglo&lt;br&gt;
(&lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/10/aws-continuum-penetration-testing/" rel="noopener noreferrer"&gt;anuncio&lt;/a&gt;).&lt;br&gt;
El anuncio no lista los permisos que necesita. Lee&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/securityagent/latest/userguide/cicd-pentest.html" rel="noopener noreferrer"&gt;la guía de CI/CD&lt;/a&gt; antes de implementarlo.&lt;/p&gt;

&lt;p&gt;Lo que el anuncio sí deja claro es la colocación: el paso de deploy. En este stack,&lt;br&gt;
la identidad del job de deploy puede desplegar infraestructura a través de la&lt;br&gt;
cadena de bootstrap de CDK, que termina en &lt;code&gt;AdministratorAccess&lt;/code&gt;. Pega un snippet de un proveedor en ese job&lt;br&gt;
y el agente nuevo lo hereda todo, a través del entorno, sin que nadie escriba una&lt;br&gt;
policy. Dale su propio job, su propio rol, y nada en su entorno que el snippet no&lt;br&gt;
necesite. Un pentester necesita alcanzar la URL objetivo. No necesita tus&lt;br&gt;
credenciales de deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Una revisión para cada agente
&lt;/h2&gt;

&lt;p&gt;Cuatro preguntas, en el mismo orden para agentes elegidos y heredados:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identidad.&lt;/strong&gt; ¿Cuál principal es? ¿Un rol de IAM, un grant de OAuth, una GitHub
App, un PAT? ¿Quién lo tiene, y cuándo expira su credencial? Un refresh token de
OAuth que se muere del lado del servidor se lleva al agente con él, y solo un
login humano lo puede traer de vuelta. El &lt;a href="//./blog-always-on-agent-box-part2.md"&gt;post de la parte 2 sobre la caja&lt;/a&gt;
cubre cómo se ve eso.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permisos.&lt;/strong&gt; Corre &lt;code&gt;simulate-principal-policy&lt;/code&gt; contra las acciones que importan,
en lugar de leer statements. Los comentarios describen la intención. El simulador
describe la policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alcance.&lt;/strong&gt; Para cada activo sensible, lista cada camino hacia él, no solo la API
obvia. Los secretos alcanzan un proceso a través de la API de secretos, las
variables de entorno, un shell en un contenedor que los contiene, el instance
metadata service, un paso de CI que los imprime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actividad.&lt;/strong&gt; ¿Puedes distinguir sus llamadas de las de un humano? CloudTrail
muestra el nombre de la sesión del rol. Nombra las sesiones, y para los conectores
de OAuth checa qué loguea el proveedor. El conector del buzón de arriba no deja un
rastro que el dueño del buzón pueda consultar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El costo es la quinta pregunta, y la más barata. Respóndela al final.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué ayudaría después
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Divide el rol del agente de código por riesgo, no por servicio.&lt;/strong&gt; Mantén la
observabilidad de solo-lectura sobre &lt;code&gt;*&lt;/code&gt;. Mueve &lt;code&gt;ecs:ExecuteCommand&lt;/code&gt; y las
acciones de deploy a un segundo rol que la caja asume bajo demanda con una sesión
respaldada por MFA de un humano, o ponle alcance a &lt;code&gt;ExecuteCommand&lt;/code&gt; con una
condición &lt;code&gt;ecs:cluster&lt;/code&gt; a un clúster que no sea de prod. Esto cuesta fricción en
cada sesión de debug de prod. Esa fricción es el punto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agrega un permission boundary a cada rol de agente.&lt;/strong&gt; Un boundary no puede
otorgar nada, así que es seguro escribirlo de forma amplia: niega &lt;code&gt;iam:*&lt;/code&gt; excepto
&lt;code&gt;PassRole&lt;/code&gt;, niega &lt;code&gt;secretsmanager:*&lt;/code&gt; fuera del prefijo del agente, niega
&lt;code&gt;ecs:ExecuteCommand&lt;/code&gt; sobre prod salvo que un session tag diga que un humano
aprobó. Ejemplo en la carpeta de código.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventaría los agentes heredados.&lt;/strong&gt; Lista los grants de OAuth y las apps
instaladas por herramienta (GitHub, Google Workspace, el issue tracker, el chat).
Esto es una hoja de cálculo, no infraestructura. También es el único paso aquí sin
código, que es por lo que sigue sin hacerse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fija el ARN del modelo de imagen en el backend&lt;/strong&gt;, o quédate con el wildcard y
ponle un budget al atributo de modelo. Escoge uno y escribe la razón en el
comentario.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corre la simulación de alcance en CI.&lt;/strong&gt; Una prueba de contrato que falla si
cualquier rol de agente nuevamente obtiene &lt;code&gt;ecs:ExecuteCommand&lt;/code&gt;, &lt;code&gt;iam:PassRole&lt;/code&gt; o
&lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt; sobre &lt;code&gt;*&lt;/code&gt; atrapa las regresiones que la revisión se
pierde.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Cada agente, elegido o heredado, está listado con dueño, tipo de credencial y
expiración.&lt;/li&gt;
&lt;li&gt;[ ] Cada rol de agente se checó con &lt;code&gt;simulate-principal-policy&lt;/code&gt;, no leyendo el JSON.&lt;/li&gt;
&lt;li&gt;[ ] Ningún rol de agente tiene &lt;code&gt;ecs:ExecuteCommand&lt;/code&gt;, &lt;code&gt;ssm:StartSession&lt;/code&gt; o
&lt;code&gt;lambda:UpdateFunctionCode&lt;/code&gt; sobre producción sin un paso humano.&lt;/li&gt;
&lt;li&gt;[ ] Cada rol de agente tiene un permission boundary.&lt;/li&gt;
&lt;li&gt;[ ] Las sesiones de los roles de agente están nombradas, para que CloudTrail
distinga agente de humano.&lt;/li&gt;
&lt;li&gt;[ ] Los agentes de pipeline corren en su propio job, con su propio rol, sin
credenciales de deploy en su entorno.&lt;/li&gt;
&lt;li&gt;[ ] El gasto de agente se filtra sobre cada valor de SERVICE que vende modelos, no
solo &lt;code&gt;Amazon Bedrock&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Los grants de OAuth a funcionalidades de IA en herramientas SaaS se revisan en
agenda.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>De $32 a $0.90 al mes: un pool de CI que solo existe cuando hace falta</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 10 Oct 2026 21:31:11 +0000</pubDate>
      <link>https://dev.to/elchesco_/de-32-a-090-al-mes-un-pool-de-ci-que-solo-existe-cuando-hace-falta-51ol</link>
      <guid>https://dev.to/elchesco_/de-32-a-090-al-mes-un-pool-de-ci-que-solo-existe-cuando-hace-falta-51ol</guid>
      <description>&lt;p&gt;Un recorrido por el pipeline de CI/CD detrás de un proyecto chico de SPA + API + workers de IA: siete workflows de GitHub Actions, una caja spot ARM64&lt;br&gt;
autoalojada que ahora duerme en cero y se despierta por corrida, un listener que&lt;br&gt;
re-corre los jobs que un reclamo de spot mató, y una segunda pasada de la suite&lt;br&gt;
de frontend con el reloj movido 400 días hacia adelante. Con los números de los&lt;br&gt;
primeros tres días de scale-to-zero, y dos cosas que los logs mostraron y las&lt;br&gt;
pruebas no.&lt;/p&gt;

&lt;p&gt;Stack: GitHub Actions, un Auto Scaling Group de EC2 de instancias spot Graviton,&lt;br&gt;
AWS CDK, ECS Fargate (arm64), S3 + CloudFront, pytest, Vitest.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Pool siempre encendido&lt;/th&gt;
&lt;th&gt;Pool scale-to-zero&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Horas que la caja está arriba&lt;/td&gt;
&lt;td&gt;24/7&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2.7%&lt;/strong&gt; (2h12m de 81h)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Costo del pool (spot + EBS + IPv4)&lt;/td&gt;
&lt;td&gt;$32.34/mes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~$0.90/mes&lt;/strong&gt; (mismas tarifas, escaladas por uptime)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push al primer job autoalojado&lt;/td&gt;
&lt;td&gt;~2 s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1m44s a 3m56s&lt;/strong&gt; (5 cold starts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jobs matados por un reclamo de spot&lt;/td&gt;
&lt;td&gt;rojos hasta que alguien re-corre&lt;/td&gt;
&lt;td&gt;se re-corren automáticamente, máximo 3 intentos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bombas de tiempo de pruebas con compuerta de fecha&lt;/td&gt;
&lt;td&gt;encontradas el día que explotan&lt;/td&gt;
&lt;td&gt;encontradas en el PR que las escribe&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  La forma
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                      pull_request / push a main / cron / dispatch
                                         |
          +------------------------------+-------------------------------+
          |                                                              |
   runner-pool.yml  (ubuntu-latest)                     CI / Deploy * / Security Scan
   despierta: DesiredCapacity=1                          runs-on: self-hosted
          |                                             (los jobs se quedan "queued" hasta
          v                                              que un runner se registra, ~2-4 min)
   ASG lanza 1 caja spot Graviton  --------------------------&amp;gt;  los jobs corren
   el user-data registra 2 runners                                    |
                                                                      v
                                                         workflow_run: completed
                                                                      |
                              +---------------------------------------+------------------+
                              |                                                          |
                runner-pool.yml duerme (ubuntu-latest)              rerun-on-runner-loss.yml (ubuntu-latest)
                DesiredCapacity=0 salvo que otro                    ¿falló + el log dice "shutdown signal"?
                workflow de pool esté queued/en progreso            -&amp;gt; rerun-failed-jobs (máx 3)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Siete workflows. Todo lo que buildea o prueba corre en el pool autoalojado; todo&lt;br&gt;
lo que administra el pool corre en runners hospedados por GitHub:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Corre en&lt;/th&gt;
&lt;th&gt;Qué hace&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ci.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PR hacia &lt;code&gt;main&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;self-hosted&lt;/td&gt;
&lt;td&gt;Filtro de rutas, luego los jobs de frontend, frontend-time-travel, backend, intelligence, infra, ci-scripts en paralelo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deploy-backend.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;push a &lt;code&gt;main&lt;/code&gt; (&lt;code&gt;backend/**&lt;/code&gt;, &lt;code&gt;infra/**&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;self-hosted&lt;/td&gt;
&lt;td&gt;lint, 2 shards de pruebas, compuerta de cobertura mergeada, &lt;code&gt;cdk deploy&lt;/code&gt;, imagen arm64, migraciones, ECS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deploy-frontend.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;push a &lt;code&gt;main&lt;/code&gt; (&lt;code&gt;frontend/**&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;self-hosted&lt;/td&gt;
&lt;td&gt;Vitest, esperar el deploy de backend del mismo commit, audit, build, S3, invalidar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deploy-intelligence.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;push a &lt;code&gt;main&lt;/code&gt; (&lt;code&gt;intelligence/**&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;self-hosted&lt;/td&gt;
&lt;td&gt;pruebas, imagen arm64, migraciones, ECS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;security-scan.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;domingo 03:00 UTC&lt;/td&gt;
&lt;td&gt;self-hosted&lt;/td&gt;
&lt;td&gt;Trivy sobre las imágenes más recientes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;runner-pool.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;todo lo de arriba, más cron&lt;/td&gt;
&lt;td&gt;ubuntu-latest&lt;/td&gt;
&lt;td&gt;despertar antes, dormir después&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rerun-on-runner-loss.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;workflow_run: completed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ubuntu-latest&lt;/td&gt;
&lt;td&gt;re-correr los jobs que un reclamo de spot mató&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El lado del pool tiene que estar en runners hospedados por GitHub. Un job&lt;br&gt;
&lt;code&gt;self-hosted&lt;/code&gt; no puede arrancar el pool que necesita para correr, y no puede&lt;br&gt;
detener el pool en el que está corriendo.&lt;/p&gt;
&lt;h2&gt;
  
  
  El pool
&lt;/h2&gt;

&lt;p&gt;Un Auto Scaling Group, solo spot, diez tipos de instancia Graviton de 2 vCPU a lo&lt;br&gt;
largo de dos AZs, &lt;code&gt;PRICE_CAPACITY_OPTIMIZED&lt;/code&gt;. Cada caja registra dos procesos de&lt;br&gt;
runner, así que una caja corre dos jobs a la vez. &lt;code&gt;MaxSize&lt;/code&gt; es 2 para que Capacity&lt;br&gt;
Rebalance pueda lanzar el reemplazo antes de que AWS se lleve la caja vieja de&lt;br&gt;
vuelta.&lt;/p&gt;

&lt;p&gt;Hasta la semana pasada era &lt;code&gt;MinSize: 1&lt;/code&gt;, &lt;code&gt;DesiredCapacity: 1&lt;/code&gt;, sin política de&lt;br&gt;
escalado. Medido a lo largo de las 120 horas previas: &lt;strong&gt;3 horas con CPU por&lt;br&gt;
encima de 10%, media de 1.08%.&lt;/strong&gt; Una caja inactiva el 97.5% del tiempo, costando&lt;br&gt;
$23.94 de spot, $4.80 de EBS y $3.60 de IPv4 público al mes.&lt;/p&gt;

&lt;p&gt;El cambio de CDK son dos líneas y una eliminación:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;asg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;autoscaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AutoScalingGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RunnerAsg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mixedInstancesPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* diez tipos Graviton, solo spot */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;minCapacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxCapacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// desiredCapacity deliberadamente NO declarado.&lt;/span&gt;
  &lt;span class="na"&gt;capacityRebalance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;groupMetrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;autoscaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GroupMetrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos detalles no obvios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;desiredCapacity&lt;/code&gt; se tiene que ir.&lt;/strong&gt; CDK advierte que declararlo reinicia el
tamaño del grupo en cada deploy (aws/aws-cdk#5215). Con el tamaño ahora
manejado desde fuera de CDK, un &lt;code&gt;cdk deploy&lt;/code&gt; corriendo dentro del deploy de
backend le arrancaría la caja de debajo al job que está corriendo ese mismísimo
deploy. Omitido, CloudFormation lo pone en la creación y lo deja en paz después.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El volumen de EBS es &lt;code&gt;deleteOnTermination&lt;/code&gt;.&lt;/strong&gt; Dormir el pool termina la
instancia, lo que se lleva las horas de spot, el volumen y el IPv4 público con
ella. Detener en lugar de terminar seguiría pagando por el volumen.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Despertar y dormir sin tocar los otros workflows
&lt;/h3&gt;

&lt;p&gt;El diseño obvio es un job &lt;code&gt;wake&lt;/code&gt; arriba de cada workflow y una arista &lt;code&gt;needs: wake&lt;/code&gt;&lt;br&gt;
en cada job autoalojado. Eso son ocho archivos y docenas de aristas &lt;code&gt;needs:&lt;/code&gt;, y una&lt;br&gt;
arista equivocada rompe un deploy. En su lugar, un workflow separado dispara con&lt;br&gt;
los mismos eventos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;     &lt;span class="c1"&gt;# 10 min antes del security scan del domingo&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*/30&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;   &lt;span class="c1"&gt;# barredor&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;CI&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Deploy Backend&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Deploy Frontend&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Deploy Intelligence&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Deploy Marketing&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Security Scan&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runner-pool&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Corre en paralelo con el workflow real. Los jobs autoalojados empiezan &lt;code&gt;queued&lt;/code&gt;, y&lt;br&gt;
GitHub retiene un job en cola hasta que un runner con labels que empatan se&lt;br&gt;
registra. La cola es la sincronización. Ningún archivo de workflow fuera de este&lt;br&gt;
sabe que el pool puede dormir.&lt;/p&gt;

&lt;p&gt;El trigger de push a propósito no tiene filtros de ruta. Un push de solo-docs&lt;br&gt;
despierta la caja para nada; el lado de dormir limpia eso.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wake&lt;/code&gt; pone &lt;code&gt;DesiredCapacity=1&lt;/code&gt;. &lt;code&gt;sleep&lt;/code&gt; lo pone en 0, pero solo si nada más&lt;br&gt;
necesita la caja:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cmd_sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dry_run&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any_runner_busy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A runner is still busy; leaving the pool up.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="n"&gt;others&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;other_active_runs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;others&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Other runs still need the pool, leaving it up:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;others&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="nf"&gt;set_desired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dry_run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dry_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La asimetría es deliberada. Un scale-down perdido cuesta centavos y el dormir de&lt;br&gt;
la siguiente corrida lo recoge. Un scale-down equivocado mata un deploy a media&lt;br&gt;
migración, que es la falla exacta que el pool ya sufre por los reclamos de spot.&lt;/p&gt;

&lt;p&gt;El mismo razonamiento para los errores: cada camino en &lt;code&gt;runner_pool.py&lt;/code&gt; regresa 0.&lt;br&gt;
Un despertar que no puede alcanzar AWS deja los jobs en cola, lo cual es visible y&lt;br&gt;
recuperable. Un workflow de pool en rojo en cada PR no es ninguna de las dos.&lt;/p&gt;

&lt;p&gt;Esa regla se aprendió en el PR que la introdujo. El paso que resuelve el nombre del&lt;br&gt;
ASG corría bajo &lt;code&gt;bash -e&lt;/code&gt;, y el grant de IAM que necesitaba estaba en el mismo PR,&lt;br&gt;
así que &lt;code&gt;DescribeAutoScalingGroups&lt;/code&gt; regresó AccessDenied y el PR se puso en rojo. El&lt;br&gt;
arreglo es un &lt;code&gt;if !&lt;/code&gt; alrededor de la llamada:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws autoscaling describe-auto-scaling-groups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"AutoScalingGroups[?Tags[?Key=='Name' &amp;amp;&amp;amp; Value=='&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RUNNER_ASG_TAG&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;']].AutoScalingGroupName | [0]"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::warning::could not resolve the runner ASG, leaving the pool alone: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El nombre del ASG se resuelve por tag, no hardcodeado. CloudFormation lo genera y&lt;br&gt;
cambia si el construct alguna vez se reemplaza.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cómo se ve en el log de escalado
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-10T21:04:51  Successful  Launching a new EC2 instance: i-058255690229cf59c
2026-10-10T00:47:25  Successful  Terminating EC2 instance: i-0327f991f9094b5b1
2026-10-10T00:16:22  Successful  Launching a new EC2 instance: i-0327f991f9094b5b1
2026-10-07T20:32:53  Successful  Terminating EC2 instance: i-0d799f051fa663669
2026-10-07T20:12:34  Successful  Launching a new EC2 instance: i-0d799f051fa663669
2026-10-07T14:19:47  Successful  Terminating EC2 instance: i-0d92076be33dcc045
2026-10-07T14:01:02  Successful  Launching a new EC2 instance: i-0d92076be33dcc045
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Siete sesiones en 81 horas, de 3 a 31 minutos cada una, 2h12m en total. El script&lt;br&gt;
que las suma está en la carpeta de código (&lt;code&gt;04-measure/pool_uptime.py&lt;/code&gt;). Lee&lt;br&gt;
&lt;code&gt;describe-scaling-activities&lt;/code&gt;, empareja cada lanzamiento con su terminación, y&lt;br&gt;
cuenta una sesión todavía abierta hasta ahora.&lt;/p&gt;

&lt;p&gt;El precio es el cold start. Antes, el primer job autoalojado arrancaba como 2&lt;br&gt;
segundos después de que la corrida se creaba. Ahora:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Corrida&lt;/th&gt;
&lt;th&gt;Creada&lt;/th&gt;
&lt;th&gt;Primer job autoalojado&lt;/th&gt;
&lt;th&gt;Espera&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deploy Backend&lt;/td&gt;
&lt;td&gt;14:00:40&lt;/td&gt;
&lt;td&gt;14:04:16&lt;/td&gt;
&lt;td&gt;3m36s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy Backend&lt;/td&gt;
&lt;td&gt;20:12:13&lt;/td&gt;
&lt;td&gt;20:15:51&lt;/td&gt;
&lt;td&gt;3m38s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy Frontend&lt;/td&gt;
&lt;td&gt;00:16:53&lt;/td&gt;
&lt;td&gt;00:18:37&lt;/td&gt;
&lt;td&gt;1m44s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy Backend&lt;/td&gt;
&lt;td&gt;00:16:53&lt;/td&gt;
&lt;td&gt;00:19:43&lt;/td&gt;
&lt;td&gt;2m50s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;21:04:21&lt;/td&gt;
&lt;td&gt;21:08:17&lt;/td&gt;
&lt;td&gt;3m56s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tres minutos por corrida en frío, contra un deploy de backend que toma mucho más&lt;br&gt;
que eso. Las corridas pegadas una tras otra aterrizan en la caja caliente y no&lt;br&gt;
pagan nada.&lt;/p&gt;
&lt;h2&gt;
  
  
  CI en un pull request
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ci.yml&lt;/code&gt; es solo chequeos: sin credenciales de AWS, sin deploy. Un job de&lt;br&gt;
&lt;code&gt;dorny/paths-filter&lt;/code&gt; decide qué áreas cambiaron, y cada job de área tiene compuerta&lt;br&gt;
en su propio output, así que un PR de solo-frontend nunca arranca el servicio de&lt;br&gt;
PostgreSQL del backend. Las corridas de PR se cancelan en vuelo cuando el autor&lt;br&gt;
empuja de nuevo; las corridas de deploy nunca lo hacen, porque un deploy a medias&lt;br&gt;
es peor que uno lento.&lt;/p&gt;

&lt;p&gt;El job &lt;code&gt;ci-scripts&lt;/code&gt; es el que la mayoría de los pipelines no tienen. Los scripts&lt;br&gt;
que reparan el propio CI (&lt;code&gt;rerun_on_runner_loss.py&lt;/code&gt;, &lt;code&gt;runner_pool.py&lt;/code&gt;) tienen&lt;br&gt;
pruebas de contrato que stubbean &lt;code&gt;gh&lt;/code&gt; y &lt;code&gt;aws&lt;/code&gt; en el &lt;code&gt;PATH&lt;/code&gt;, y corren cuando esos&lt;br&gt;
scripts o sus workflows cambian. El filtro de rutas incluye el propio&lt;br&gt;
&lt;code&gt;runner-pool.yml&lt;/code&gt;, porque la lista de &lt;code&gt;workflow_run&lt;/code&gt; ahí tiene que empatar con&lt;br&gt;
&lt;code&gt;POOL_WORKFLOWS&lt;/code&gt; en el script, y editar solo el YAML es justo el drift que la&lt;br&gt;
prueba existe para atrapar.&lt;/p&gt;
&lt;h3&gt;
  
  
  El reloj 400 días adelante
&lt;/h3&gt;

&lt;p&gt;Cuatro veces en unas semanas, &lt;code&gt;main&lt;/code&gt; se puso en rojo en un commit que no tocaba&lt;br&gt;
nada relacionado. Cada vez era una prueba con un fixture como&lt;br&gt;
&lt;code&gt;closes_at: "2026-10-01"&lt;/code&gt; afirmando la rama de "todavía abierto". Pasaba en el PR&lt;br&gt;
que lo escribía, luego fallaba en cada corrida desde la mañana en que esa fecha&lt;br&gt;
llegaba en la vida real.&lt;/p&gt;

&lt;p&gt;El arreglo a nivel de prueba es fijar el reloj (&lt;code&gt;vi.spyOn(Date, "now").mockReturnValue(...)&lt;/code&gt;)&lt;br&gt;
a un instante entre las dos ramas. El arreglo a nivel de pipeline es hacer que la&lt;br&gt;
siguiente falle ahora, no en un año. &lt;code&gt;src/test/setup.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeTravelMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIME_TRAVEL_DAYS&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeTravelMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RealDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;realNow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RealDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RealDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ShiftedDate&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RealDate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ConstructorParameters&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;realNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;timeTravelMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(...(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;ConstructorParameters&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;realNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;timeTravelMs&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ShiftedDate&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DateConstructor&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Y un segundo job de CI, corriendo en paralelo con la suite normal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;frontend-time-travel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changes&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.changes.outputs.frontend == 'true'&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self-hosted&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# checkout, pnpm, node, install&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Vitest (clock 400 days ahead)&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;TIME_TRAVEL_DAYS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;400"&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd frontend &amp;amp;&amp;amp; pnpm exec vitest run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos detalles no obvios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Solo el &lt;code&gt;new Date()&lt;/code&gt; sin argumentos y &lt;code&gt;Date.now()&lt;/code&gt; se mueven.&lt;/strong&gt;
&lt;code&gt;new Date("2026-10-01")&lt;/code&gt; sigue parseando a ese instante. Así que las fechas de
fixture se quedan fijas y "ahora" se mueve más allá de ellas, que es la
situación exacta que rompe una prueba con compuerta de fecha.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una prueba que fija su propio reloj anula el movimiento y es inmune.&lt;/strong&gt; Esa es
la presión buscada: la única manera de poner en verde el job de time-travel en
una prueba con compuerta de fecha es escribirla de la manera permanente.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Jobs separados en lugar de un paso secuencial, para que una palomita en rojo diga&lt;br&gt;
de qué tipo de falla se trata: una regresión, o una bomba de fecha. En local:&lt;br&gt;
&lt;code&gt;TIME_TRAVEL_DAYS=400 pnpm test:run&lt;/code&gt;, y 1095 para un horizonte más largo.&lt;/p&gt;
&lt;h2&gt;
  
  
  Deploys en push a main
&lt;/h2&gt;

&lt;p&gt;Cada workflow de deploy tiene un filtro de rutas, un grupo de &lt;code&gt;concurrency&lt;/code&gt; con&lt;br&gt;
&lt;code&gt;cancel-in-progress: false&lt;/code&gt;, y un input &lt;code&gt;runner&lt;/code&gt; que cae por default en&lt;br&gt;
&lt;code&gt;self-hosted&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt;: ruff, basedpyright y pip-audit primero, luego dos shards de pytest
divididos por duraciones registradas, luego un job que mergea los dos archivos
&lt;code&gt;.coverage&lt;/code&gt; y exige 60% sobre la unión (las compuertas por shard dejan pasar
módulos que un shard nunca importa). Luego &lt;code&gt;cdk deploy&lt;/code&gt; del stack de backend, un
build de imagen arm64 nativo, las migraciones corridas como una tarea de ECS de
una sola vez con la imagen nueva, y solo entonces la actualización del servicio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: Vitest, luego una compuerta que espera el deploy de backend del
mismo commit cuando el commit tocó &lt;code&gt;backend/&lt;/code&gt; o &lt;code&gt;infra/&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intelligence&lt;/strong&gt;: pruebas con un chequeo de drift de modelo-a-schema, imagen
arm64, migraciones, ECS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El input &lt;code&gt;runner: ubuntu-latest&lt;/code&gt; es la salida de emergencia cuando spot no tiene&lt;br&gt;
capacidad en absoluto. Para el frontend es un build estático y funciona tal cual.&lt;br&gt;
Para los servicios arm64 tiene que buildear a través de QEMU, ya que un build&lt;br&gt;
amd64 nativo produce una imagen que la tarea de Fargate no puede jalar.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cuando spot se lleva la caja a media corrida
&lt;/h3&gt;

&lt;p&gt;Un reclamo aparece como una X roja en cualquier paso que estuviera corriendo,&lt;br&gt;
indistinguible de una prueba que falla hasta que se abre el log crudo.&lt;br&gt;
&lt;code&gt;rerun-on-runner-loss.yml&lt;/code&gt; lee los logs de los jobs fallidos, busca líneas que solo&lt;br&gt;
el host del runner produce (&lt;code&gt;The runner has received a shutdown signal&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;lost communication with the server&lt;/code&gt;), y re-corre los jobs fallidos. Se niega&lt;br&gt;
cuando existe una corrida más nueva, cuando el intento ya es el tercero, o cuando&lt;br&gt;
el log no se pudo leer.  &lt;/p&gt;

&lt;p&gt;El scale-to-zero y el listener de re-corrida se componen sin saber el uno del otro.&lt;br&gt;
La re-corrida crea un intento nuevo, el intento se forma en cola, la corrida en&lt;br&gt;
cola evita que &lt;code&gt;sleep&lt;/code&gt; escale hacia abajo, y si el pool ya se durmió, el siguiente&lt;br&gt;
despertar lo dispara... nada. Una re-corrida no es un evento &lt;code&gt;push&lt;/code&gt; ni&lt;br&gt;
&lt;code&gt;pull_request&lt;/code&gt;. El barredor de cada media hora solo duerme. En la práctica la caja&lt;br&gt;
reclamada la reemplaza Capacity Rebalance antes de que &lt;code&gt;sleep&lt;/code&gt; vea siquiera un pool&lt;br&gt;
inactivo, pero es la única costura en el diseño que vale la pena conocer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Qué mostraron los logs
&lt;/h2&gt;

&lt;p&gt;Cada prueba unitaria de &lt;code&gt;runner_pool.py&lt;/code&gt; pasa. Leer los logs reales de despertar y&lt;br&gt;
dormir de los primeros tres días sacó dos cosas que las pruebas no pudieron.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. El listado de runners es un 403 en producción.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Wake the pool  2026-10-10T21:14:30Z  gh api /repos/acme/myapp/actions/runners failed (1): gh: Resource not accessible by integration (HTTP 403)
Wake the pool  2026-10-10T21:14:41Z  gh api /repos/acme/myapp/actions/runners failed (1): gh: Resource not accessible by integration (HTTP 403)
Wake the pool  2026-10-10T21:15:11Z  No runner came online within 300s. The self-hosted jobs will queue. If spot has no capacity at all, re-run with the `runner: ubuntu-latest` input.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Listar runners autoalojados necesita el permiso de Administration del repositorio,&lt;br&gt;
y &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; no se le puede otorgar. &lt;code&gt;permissions: actions: read&lt;/code&gt; no es&lt;br&gt;
suficiente. El script trata cualquier error de API como "sin datos", así que:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wake&lt;/code&gt; nunca ve a un runner ponerse en línea, y hace poll por los 300 segundos
completos cada vez. El pool de todos modos despierta, porque &lt;code&gt;set-desired-capacity&lt;/code&gt;
pasa antes del poll. El costo son de 5 a 7 minutos facturados de runner hospedado
por evento en un repo privado.&lt;/li&gt;
&lt;li&gt;El chequeo de &lt;code&gt;sleep&lt;/code&gt; de "¿algún runner está ocupado?" siempre responde no. Ha
sido seguro solo porque el segundo chequeo, "¿algún workflow de pool está en cola
o en progreso?", usa la API de runs, que &lt;code&gt;actions: read&lt;/code&gt; sí cubre:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sleep the pool if nothing else needs it  gh api /repos/acme/myapp/actions/runners failed (1): gh: Resource not accessible by integration (HTTP 403)
Sleep the pool if nothing else needs it  Other runs still need the pool, leaving it up:
Sleep the pool if nothing else needs it    - Deploy Backend#38086462352 (queued)
Sleep the pool if nothing else needs it    - Deploy Frontend#38086462341 (in_progress)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La prueba de contrato stubbea &lt;code&gt;gh&lt;/code&gt; y sirve un &lt;code&gt;runners.json&lt;/code&gt; enlatado para esa&lt;br&gt;
ruta. Verificaba la tabla de decisión contra un permiso que el token real no tiene.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;*/30&lt;/code&gt; no significa cada 30 minutos.&lt;/strong&gt; El barredor disparó 12 veces entre el&lt;br&gt;
2026-10-08 y el 2026-10-10:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;03:29  10:50  17:38  22:38  02:39  09:47  16:42  21:23  01:02  07:21  13:53  18:06
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Huecos de 4 a 7 horas. GitHub documenta que los workflows agendados se pueden&lt;br&gt;
demorar o tirar bajo carga; en este repo, una agenda de 30 minutos se porta como&lt;br&gt;
una de 5 horas. El comentario en el workflow dice que el peor caso de un despertar&lt;br&gt;
perdido es "~30 min de spot inactivo, como 3 centavos". El peor caso real son&lt;br&gt;
varias horas: siguen siendo centavos, pero no el número del comentario.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>aws</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>2 GB de RAM y nada de laptop: cómo se ve de verdad desarrollar en iPad en 2026</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Mon, 28 Sep 2026 01:41:24 +0000</pubDate>
      <link>https://dev.to/aws-builders/2-gb-de-ram-y-nada-de-laptop-como-se-ve-de-verdad-desarrollar-en-ipad-en-2026-2bbi</link>
      <guid>https://dev.to/aws-builders/2-gb-de-ram-y-nada-de-laptop-como-se-ve-de-verdad-desarrollar-en-ipad-en-2026-2bbi</guid>
      <description>&lt;h2&gt;
  
  
  Qué cambió en 2026
&lt;/h2&gt;

&lt;p&gt;Cuatro cosas, y ninguna es la tableta.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Los CLI agénticos se volvieron remotos primero.&lt;/strong&gt; Claude Code registra una sesión desde un host tuyo y la abres en una pestaña del navegador. Lo que hace el trabajo nunca fue el editor, así que el editor dejó de necesitar ser local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los túneles de VS Code movieron el extension host a la máquina remota.&lt;/strong&gt; Sobre un túnel, una pestaña del navegador recibe el set completo de extensiones de escritorio. Sobre SFTP recibe nada más el subconjunto de web extensions. La misma barra de direcciones, otro producto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las VPN de malla hicieron alcanzable una caja de solo egreso.&lt;/strong&gt; Sin regla de entrada, sin bastión, y aun así una sesión de SSH, porque la caja marcó hacia afuera primero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La capacidad spot chica de ARM se volvió lo bastante accesible para sostener el trabajo pesado.&lt;/strong&gt; Así que la caja siempre encendida se queda chica a propósito.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La pregunta dejó de ser "¿puedo desarrollar en un iPad?". Ahora es "¿puedo reparar la máquina donde está mi agente, desde aquí?".&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué necesitas
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cosa&lt;/th&gt;
&lt;th&gt;Por qué&lt;/th&gt;
&lt;th&gt;Costo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Magic Keyboard Folio, o un Logitech Combo Touch&lt;/td&gt;
&lt;td&gt;El trackpad es el requisito. Una terminal sin teclado físico es una demostración&lt;/td&gt;
&lt;td&gt;Unos 250 USD de lista, más económico de terceros&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://blink.sh/" rel="noopener noreferrer"&gt;Blink Shell&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;La terminal. Trae &lt;code&gt;mosh&lt;/code&gt;, guarda las llaves en el Secure Enclave, tiene un comando &lt;code&gt;code&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Blink+ son 19.99 USD al año, con prueba de 14 días&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;La caja de la parte 1&lt;/td&gt;
&lt;td&gt;Donde viven Claude Code y el repo&lt;/td&gt;
&lt;td&gt;Unos 12 USD al mes en &lt;code&gt;t4g.small&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPN de malla&lt;/td&gt;
&lt;td&gt;Alcanzarla sin abrir un puerto&lt;/td&gt;
&lt;td&gt;El plan gratuito alcanza&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitor externo&lt;/td&gt;
&lt;td&gt;Nada. Los iPads sin chip M solo espejan&lt;/td&gt;
&lt;td&gt;No compres uno&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Empieza sin configurar nada
&lt;/h2&gt;

&lt;p&gt;Abre &lt;code&gt;claude.ai/code&lt;/code&gt; en Safari, escoge la máquina, trabaja. Agrégalo a la pantalla de inicio para que se comporte como app y no como pestaña. Si la caja de la parte 1 está corriendo, esto funciona ahora mismo sin instalar nada.&lt;/p&gt;

&lt;p&gt;Hazlo antes de construir cualquier otra cosa. Te dice si la ergonomía te acomoda antes de gastar un fin de semana en el resto.&lt;/p&gt;

&lt;p&gt;Luego fíjate en lo que no puede hacer: en esa pestaña no hay shell. Y ahí está todo el problema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Si todavía no tienes caja
&lt;/h2&gt;

&lt;p&gt;La versión durable es la parte 1. La versión corta, suficiente para seguir este post: una &lt;code&gt;t4g.small&lt;/code&gt; sobre Ubuntu arm64, un instance profile con &lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt;, y un security group &lt;strong&gt;sin ninguna regla de entrada&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;AMI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ssm get-parameter &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="nt"&gt;--query&lt;/span&gt; Parameter.Value &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; /aws/service/canonical/ubuntu/server/24.04/stable/current/arm64/hvm/ebs-gp3/ami-id&lt;span class="si"&gt;)&lt;/span&gt;

aws ec2 run-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AMI&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--instance-type&lt;/span&gt; t4g.small &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--iam-instance-profile&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;agent-box &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-group-ids&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--block-device-mappings&lt;/span&gt; &lt;span class="s1"&gt;'DeviceName=/dev/sda1,Ebs={VolumeSize=30,VolumeType=gp3,Encrypted=true}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata-options&lt;/span&gt; &lt;span class="nv"&gt;HttpTokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;required &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag-specifications&lt;/span&gt; &lt;span class="s1"&gt;'ResourceType=instance,Tags=[{Key=Name,Value=agent-box}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Luego, en la caja, los dos paquetes de los que depende este post y un archivo de swap, porque 2 GB con un proceso de Node encima se quedan sin memoria:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; tmux mosh
&lt;span class="nb"&gt;sudo &lt;/span&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 4G /swapfile &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pon todo eso en el script de bootstrap de la parte 1 en lugar de teclearlo una vez. Una instancia cuyo AMI viene de un parámetro de SSM se reemplaza con el tiempo, y el disco se va con ella.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configura la tableta
&lt;/h2&gt;

&lt;p&gt;Cuatro instalaciones, una sola vez.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. El teclado.&lt;/strong&gt; Magic Keyboard Folio para iPad (A16) si quieres el de Apple, Logitech Combo Touch si no. El requisito es el trackpad, no la marca. Seleccionar texto en una terminal con la yema del dedo es tan incómodo que dejas de hacerlo, y entonces dejas de leer bien la salida.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Blink Shell&lt;/strong&gt;, del App Store. Blink+ son 19.99 USD al año con prueba de 14 días, y es la única pieza que cuesta dinero directo. Se lo gana porque trae &lt;code&gt;mosh&lt;/code&gt;, guarda las llaves en el Secure Enclave y tiene un comando &lt;code&gt;code&lt;/code&gt;. Termius y Secure ShellFish son alternativas razonables; Secure ShellFish monta el host remoto en la app Archivos, que es otro juego de concesiones.&lt;/p&gt;

&lt;p&gt;Genera la llave dentro de Blink en lugar de copiar una llave privada al dispositivo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config → Keys → + → Secure Enclave
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con Tailscale SSH no vas a necesitar instalar esa llave pública en ningún lado, porque la tailnet negocia la identidad. Genérala de todos modos: es a lo que recurres el día que apagues la VPN.&lt;/p&gt;

&lt;p&gt;Luego agrega el host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config → Hosts → +
  Host:     agent-box
  HostName: agent-box     (el nombre en la tailnet)
  User:     appuser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Tailscale&lt;/strong&gt;, del App Store, con sesión en la misma cuenta que la caja.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;claude.ai/code&lt;/code&gt; en la pantalla de inicio.&lt;/strong&gt; Safari, Compartir, Agregar a inicio. Pierde la barra del navegador y se comporta como app. Instala también la app de Claude, para que el teléfono sirva de segunda pantalla cuando quieras ver cómo va una corrida larga.&lt;/p&gt;

&lt;p&gt;Luego corre la prueba que de verdad te dice si esto funciona:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mosh appuser@agent-box
tmux new &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arranca algo largo. Bloquea la tableta, espera un minuto, desbloquea, reconéctate con &lt;code&gt;mosh appuser@agent-box&lt;/code&gt; y &lt;code&gt;tmux new -A -s main&lt;/code&gt;. La sesión debe estar ahí con el trabajo todavía corriendo. Si está, la configuración quedó y todo lo de abajo es preferencia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Las tres maneras de entrar
&lt;/h2&gt;

&lt;p&gt;Esta es la parte que solo importa cuando la laptop está apagada.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. La sesión del navegador
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;claude.ai/code&lt;/code&gt; en Safari. Cero configuración, sobrevive que cierres la pestaña, y aquí pasa la mayor parte del trabajo.&lt;/p&gt;

&lt;p&gt;No tiene terminal. Así que cuando la caja necesita un humano, este camino no lo puede dar. Todo lo de abajo existe por esa frase.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Una terminal de verdad sobre la VPN de malla
&lt;/h3&gt;

&lt;p&gt;En la caja:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up &lt;span class="nt"&gt;--reset&lt;/span&gt; &lt;span class="nt"&gt;--ssh&lt;/span&gt; &lt;span class="nt"&gt;--hostname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;agent-box
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La caja marca hacia afuera, así que el security group se queda de solo egreso y nada escucha en internet. &lt;code&gt;--ssh&lt;/code&gt; deja que la tailnet negocie la identidad de SSH, así que no hay llave pública que copiar a la tableta.&lt;/p&gt;

&lt;p&gt;Tres detalles en ese solo comando:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sin &lt;code&gt;--advertise-tags&lt;/code&gt;, a propósito.&lt;/strong&gt; Un tag cuesta dos ediciones de política, no una. Hay que declararlo en &lt;code&gt;tagOwners&lt;/code&gt; o el comando muere de entrada con &lt;code&gt;requested tags [tag:agentbox] are invalid or not permitted&lt;/code&gt;, y además taguear el nodo lo saca de &lt;code&gt;autogroup:self&lt;/code&gt;, así que la regla &lt;code&gt;ssh&lt;/code&gt; por defecto deja de aplicarle y necesitas una segunda con &lt;code&gt;dst&lt;/code&gt; apuntando al tag. Sin tag, el nodo es tuyo y las dos cosas salen gratis. La concesión es la expiración de llave: los nodos sin tag expiran, así que apágale la expiración a ese dispositivo en la consola. Los nodos tagueados no expiran, y ese es el argumento real para hacerlo por el camino largo más adelante.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--reset&lt;/code&gt;&lt;/strong&gt; porque &lt;code&gt;tailscale up&lt;/code&gt; se niega a cambiar ajustes sin que vuelvas a mencionar todas las banderas no-default. Cualquier intento anterior deja banderas guardadas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--hostname&lt;/code&gt;&lt;/strong&gt; porque el default es el hostname de EC2, que cambia en cada reemplazo y te deja un nombre inservible en la tailnet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Aun así necesitas una sección &lt;code&gt;ssh&lt;/code&gt; en la política. Sin tag, apunta a tus propios dispositivos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"ssh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"accept"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"autogroup:member"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dst"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"autogroup:self"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"appuser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"autogroup:nonroot"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usa &lt;code&gt;accept&lt;/code&gt;, no &lt;code&gt;check&lt;/code&gt;. El &lt;code&gt;check&lt;/code&gt; pide re-autenticar en el navegador cada cierto tiempo, que es incómodo desde una tableta.&lt;/p&gt;

&lt;p&gt;Desde la tableta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mosh appuser@agent-box
tmux new &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Todo vive dentro de &lt;code&gt;tmux&lt;/code&gt;.&lt;/strong&gt; iPadOS mata una app en segundo plano sin preguntar, y lo va a hacer a media tarea. &lt;code&gt;mosh&lt;/code&gt; más &lt;code&gt;tmux new -A -s main&lt;/code&gt; te devuelve la sesión exactamente como estaba, a través de la suspensión, un cambio de Wi-Fi y un traspaso de hotspot. El SSH pelón te devuelve un socket muerto y trabajo perdido. Esa sola regla es la mayor parte de la diferencia entre que esto se sienta profesional o se sienta como una concesión.&lt;/p&gt;

&lt;p&gt;La &lt;code&gt;-A&lt;/code&gt; importa más de lo que parece: entra si la sesión existe y la crea si no, así un solo comando sirve para conectar y para reconectar. Con &lt;code&gt;tmux new -s main&lt;/code&gt; pelado el reconectar falla y acabas con &lt;code&gt;main&lt;/code&gt;, &lt;code&gt;main-1&lt;/code&gt;, &lt;code&gt;main-2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Y una trampa que va a parecer un bug de tmux y no lo es. Blink invoca &lt;code&gt;~/.local/blink/mosh-server&lt;/code&gt; antes del del sistema, y si no está, ese intento sale 127, Blink reintenta, y la sesión que acaba de morir se lleva tu servidor de tmux recién creado. El cliente entonces reporta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error connecting to /tmp/tmux-1000/default (No such file or directory)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;que te manda a depurar tmux, los permisos de &lt;code&gt;/tmp&lt;/code&gt; y los locales, y ninguno es el problema. Un symlink elimina el intento fallido:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.local/blink
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; mosh-server&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ~/.local/blink/mosh-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Session Manager en el navegador, como rompe-cristal
&lt;/h3&gt;

&lt;p&gt;La consola de AWS da un shell en la instancia desde Safari, sin nada instalado en la tableta y sin VPN en el camino. Solo necesita el agente de SSM y el instance profile que ya creaste.&lt;/p&gt;

&lt;p&gt;Es el camino que sigue funcionando cuando la configuración de la VPN está mal, y en la ventana justo después de que un reemplazo borró lo que hayas configurado a mano.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verifícalo desde la tableta antes de necesitarlo, incluyendo el paso de MFA.&lt;/strong&gt; Si el segundo factor vive nada más en la laptop que ahora está apagada en casa, este camino es decorativo. Son cinco minutos que no sirven de nada si los dejas para después.&lt;/p&gt;

&lt;h3&gt;
  
  
  Por qué las tres
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Camino&lt;/th&gt;
&lt;th&gt;Configuración&lt;/th&gt;
&lt;th&gt;Da un TTY&lt;/th&gt;
&lt;th&gt;Sobrevive un reemplazo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude.ai/code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ninguna&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Sí&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPN de malla más &lt;code&gt;mosh&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Los dos lados, una vez&lt;/td&gt;
&lt;td&gt;Sí&lt;/td&gt;
&lt;td&gt;Solo si la instalación de la VPN está en el bootstrap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session Manager&lt;/td&gt;
&lt;td&gt;Nada más allá del instance profile&lt;/td&gt;
&lt;td&gt;Sí&lt;/td&gt;
&lt;td&gt;Sí&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;La parte 2 terminó en una caja que estaba &lt;code&gt;active&lt;/code&gt; y sin registrar. Recuperar una sesión de Claude Code en ese estado necesita una terminal interactiva, así que el camino cómodo es justo el que no puede. Dos de estos tres no son opcionales.&lt;/p&gt;

&lt;h2&gt;
  
  
  El editor
&lt;/h2&gt;

&lt;p&gt;En la caja:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-Lk&lt;/span&gt; &lt;span class="s1"&gt;'https://code.visualstudio.com/sha/download?build=stable&amp;amp;os=cli-alpine-arm64'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; vscode_cli.tar.gz
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xf&lt;/span&gt; vscode_cli.tar.gz
./code &lt;span class="nt"&gt;--version&lt;/span&gt;
./code tunnel &lt;span class="nt"&gt;--accept-server-license-terms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El primer arranque imprime un código y una URL que abres en la tableta. Después de eso tienes VS Code con tus extensiones reales, la de Claude Code entre ellas, porque el extension host corre en la caja.&lt;/p&gt;

&lt;p&gt;Corre la descarga &lt;strong&gt;como el usuario de la caja, no como root&lt;/strong&gt;. Hecha desde un shell de root, el binario queda en el home de ese usuario con dueño root y todo lo de después falla de formas que nunca mencionan los permisos.&lt;/p&gt;

&lt;p&gt;Luego déjalo como servicio, &lt;strong&gt;en este orden&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;loginctl enable-linger appuser
./code tunnel service &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--accept-server-license-terms&lt;/span&gt;
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; is-active code-tunnel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;enable-linger&lt;/code&gt; no es opcional y va primero. &lt;code&gt;service install&lt;/code&gt; crea un servicio de &lt;strong&gt;usuario&lt;/strong&gt;, y un servicio de usuario muere con el último login si el usuario no tiene linger. El síntoma sin eso es "el túnel desaparece cuando me desconecto", que no apunta a systemd.&lt;/p&gt;

&lt;p&gt;Cuando el servicio esté arriba, &lt;strong&gt;mata el &lt;code&gt;./code tunnel&lt;/code&gt; que dejaste en primer plano&lt;/strong&gt; en el paso anterior. Si lo dejas, quedan dos procesos peleando por el mismo nombre de túnel, y el que sobrevive una desconexión es el del servicio, no el tuyo.&lt;/p&gt;

&lt;p&gt;Cuatro cosas que si no, te van a costar tiempo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;La carpeta tiene que ir en la URL.&lt;/strong&gt; &lt;code&gt;https://vscode.dev/tunnel/&amp;lt;nombre&amp;gt;&lt;/code&gt; abre un editor sin archivos y parece roto. Lo que quieres es &lt;code&gt;https://vscode.dev/tunnel/&amp;lt;nombre&amp;gt;/home/appuser/myapp&lt;/code&gt;. La forma relativa, &lt;code&gt;/tunnel/&amp;lt;nombre&amp;gt;/myapp&lt;/code&gt;, funciona en unas versiones y en otras no; la de ruta completa falla menos. O abre la URL pelada y usa File, Open Folder una vez, y queda en Recent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;code status&lt;/code&gt; siempre va a decir &lt;code&gt;No installation of Visual Studio Code stable was found&lt;/code&gt;.&lt;/strong&gt; Ese comando busca un VS Code de &lt;strong&gt;escritorio&lt;/strong&gt; en la máquina, que no es lo que un túnel necesita ni algo que deba estar ahí. No es un error. Para revisar el túnel usa &lt;code&gt;code tunnel service log&lt;/code&gt; o &lt;code&gt;systemctl --user is-active code-tunnel&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diez túneles por cuenta.&lt;/strong&gt; Pasando eso, el CLI borra uno sin usar al azar, así que limpia con &lt;code&gt;code tunnel unregister&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El build de ARM es &lt;code&gt;cli-alpine-arm64&lt;/code&gt; y corre en Ubuntu.&lt;/strong&gt; Verificado sobre glibc, &lt;code&gt;code 1.140.0&lt;/code&gt;; no hace falta un userland musl ni una variante linux-arm64. Aun así corre &lt;code&gt;./code --version&lt;/code&gt; justo después de extraer, porque una arquitectura equivocada falla ahí y no después, dentro de algo que parece un problema de túnel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Desde Blink en lugar de Safari.&lt;/strong&gt; El comando &lt;code&gt;code&lt;/code&gt; de Blink abre el mismo editor sin salir de la app de terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;code &amp;lt;tunnel_url&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;También tiene un modo SFTP que no necesita túnel, usando la extensión de filesystem de Blink:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;code appuser@agent-box:~/myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Práctico para una edición rápida, con una cosa que conviene saber: en modo SFTP el extension host corre en el navegador, así que recibes solo web extensions y la de Claude Code no está entre ellas. El túnel es la ruta que te da el editor real.&lt;/p&gt;

&lt;p&gt;Lo único que no se puede automatizar es la autenticación por dispositivo del túnel. El binario se instala desde un script, el token no, así que un reemplazo de instancia significa volver a registrarlo a mano. Al menos es un paso que sí se puede hacer desde la tableta.&lt;/p&gt;

&lt;p&gt;Un servidor de VS Code más su extension host más Claude Code en 2 GB va apretado incluso con swap. Si el editor del navegador va a ser la interfaz principal y no algo ocasional, dimensiona la instancia un escalón arriba.&lt;/p&gt;

&lt;h2&gt;
  
  
  El trabajo pesado va en otro lado
&lt;/h2&gt;

&lt;p&gt;La caja es chica a propósito. Las suites de pruebas que necesitan base de datos, los builds de contenedor y las pruebas de navegador van en CI, así que un push es la forma de correrlas. Si el proyecto ya se despliega por pipeline y no con builds locales, nada cambia cuando viajas, salvo quién levanta el contenedor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que no funciona
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contenedores, una base de datos local, una suite de pruebas contra un Postgres real.&lt;/strong&gt; iPadOS no da virtualización. No es un problema de permisos ni de encontrarle la vuelta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una segunda pantalla.&lt;/strong&gt; El escritorio extendido necesita M1 o posterior; todos los demás iPads espejan, así que un monitor te da una copia con barras negras de la tableta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tres pestañas del navegador haciendo todo esto a la vez.&lt;/strong&gt; Con 6 GB de RAM las pestañas se desalojan. Escoge un cliente principal y deja el trabajo real en &lt;code&gt;tmux&lt;/code&gt; del otro lado.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La configuración está terminada cuando puedes reparar la caja desde la tableta, no cuando puedes programar en ella.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Github Actions: Re-intentar automáticamente los jobs de CI que un reclamo de spot instance mató</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Wed, 16 Sep 2026 20:42:58 +0000</pubDate>
      <link>https://dev.to/elchesco_/github-actions-re-intentar-automaticamente-los-jobs-de-ci-que-un-reclamo-de-spot-instance-mato-1f79</link>
      <guid>https://dev.to/elchesco_/github-actions-re-intentar-automaticamente-los-jobs-de-ci-que-un-reclamo-de-spot-instance-mato-1f79</guid>
      <description>&lt;p&gt;Mi CI corre en un runner spot ARM64 autoalojado. Cuando AWS se lleva la instancia de vuelta a media corrida, GitHub muestra una X roja en cualquier paso que estuviera corriendo, y se ve idéntico a una prueba que falla. &lt;br&gt;
Esto es un workflow que lee el log del job, reconoce esa falla específica, y re-corre los jobs fallidos por su cuenta. Las fallas de prueba reales se quedan en rojo.&lt;/p&gt;

&lt;p&gt;Stack: GitHub Actions, una instancia spot EC2 en un Auto Scaling Group, CDK.&lt;/p&gt;
&lt;h2&gt;
  
  
  Qué pasó
&lt;/h2&gt;

&lt;p&gt;Un deploy de backend se puso en rojo en &lt;code&gt;Tests (shard 1/2)&lt;/code&gt;. El log crudo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;2026-09-16T19:31:20Z ........................................................................ [ 75%]
&lt;/span&gt;&lt;span class="gp"&gt;2026-09-16T19:31:31Z #&lt;/span&gt;&lt;span class="c"&gt;#[error]The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled.&lt;/span&gt;
&lt;span class="gp"&gt;2026-09-16T19:31:44Z #&lt;/span&gt;&lt;span class="c"&gt;#[error]The operation was canceled.&lt;/span&gt;
&lt;span class="go"&gt;2026-09-16T19:31:44Z Terminate orphan process: pid (18652) (pytest)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Puntos ininterrumpidos hasta 75%, luego la máquina se fue. Cero pruebas fallaron. El otro shard se murió 13 segundos después, mismo host. El deploy de frontend entonces abortó a propósito, porque se niega a publicar cuando el deploy de backend para ese commit no tuvo éxito.&lt;/p&gt;

&lt;p&gt;Un detalle que vale la pena saber: &lt;code&gt;gh run view &amp;lt;id&amp;gt; --log-failed&lt;/code&gt; imprime &lt;strong&gt;nada&lt;/strong&gt; para esta clase de falla. La salida vacía se ve como una herramienta rota, así que el log real casi nunca se abre. Hay que jalarlo por job:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/myorg/myapp/actions/jobs/&amp;lt;job-id&amp;gt;/logs | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Qué hace
&lt;/h2&gt;

&lt;p&gt;Un listener de &lt;code&gt;workflow_run&lt;/code&gt; dispara después de que cualquier corrida de CI o de deploy termina. Para una corrida fallida jala los logs de los jobs fallidos y busca líneas que solo el host del runner puede producir:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;RUNNER_LOSS_MARKERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The runner has received a shutdown signal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lost communication with the server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The runner has received a cancellation signal from the server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The operation was canceled by the runner service&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Si una hace match, llama a &lt;code&gt;rerun-failed-jobs&lt;/code&gt;. Si ninguna hace match, no hace nada y la corrida se queda en rojo.&lt;/p&gt;

&lt;p&gt;También se niega a actuar en tres casos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Existe una corrida más nueva del mismo workflow y branch.&lt;/strong&gt; Re-correr un intento más viejo nada más pelea con el concurrency group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intento 3 o posterior.&lt;/strong&gt; Si un tercer intento todavía muestra una señal de shutdown, el pool está caído, no inestable, y un humano debería mirar el ASG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El log no se pudo leer.&lt;/strong&gt; Un log expirado o que todavía se está subiendo no es evidencia de nada.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Cómo usarlo
&lt;/h2&gt;

&lt;p&gt;Dos archivos, sin dependencias más allá del CLI de &lt;code&gt;gh&lt;/code&gt; que los runners hospedados por GitHub ya tienen.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copia &lt;code&gt;rerun_on_runner_loss.py&lt;/code&gt; y &lt;code&gt;rerun-on-runner-loss.yml&lt;/code&gt; a
&lt;code&gt;.github/scripts/&lt;/code&gt; y &lt;code&gt;.github/workflows/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Lista tus workflows por nombre en el trigger:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;CI&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Deploy Backend&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Deploy Frontend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;        &lt;span class="c1"&gt;# rerun-failed-jobs, nada más&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;maybe-rerun&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;github.event.workflow_run.conclusion == 'failure' ||&lt;/span&gt;
      &lt;span class="s"&gt;github.event.workflow_run.conclusion == 'cancelled'&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;      &lt;span class="c1"&gt;# no self-hosted, ve abajo&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python3 .github/scripts/rerun_on_runner_loss.py&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;REPO&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.repository }}&lt;/span&gt;
          &lt;span class="na"&gt;RUN_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.id }}&lt;/span&gt;
          &lt;span class="na"&gt;RUN_ATTEMPT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.run_attempt }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;runs-on: ubuntu-latest&lt;/code&gt; no es un detalle. En &lt;code&gt;self-hosted&lt;/code&gt; este job se formaría detrás del mismísimo outage que existe para reparar.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;workflow_run&lt;/code&gt; solo dispara para la copia del workflow que está en la rama por default, así que no hace nada hasta que se mergea. Pruébalo re-corriendo el script a mano contra un run id conocido.&lt;/p&gt;
&lt;h2&gt;
  
  
  La mitad de infraestructura
&lt;/h2&gt;

&lt;p&gt;Re-correr solo ayuda si un runner regresa. Capacity Rebalance suscribe el ASG al aviso de interrupción de spot de dos minutos para que un reemplazo arranque antes de que se coseche la instancia, y llevaba meses habilitado aquí sin hacer nada:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;capacityRebalance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;minCapacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;maxCapacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// sin holgura, así que tiene que terminar primero&lt;/span&gt;
&lt;span class="nx"&gt;desiredCapacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Un ASG no puede exceder &lt;code&gt;MaxSize&lt;/code&gt;. Con &lt;code&gt;MaxSize: 1&lt;/code&gt; no hay dónde poner el reemplazo, así que la funcionalidad se degrada en silencio a terminar primero, que es el apagón que se suponía prevenía. &lt;code&gt;maxCapacity: 2&lt;/code&gt; con &lt;code&gt;desiredCapacity: 1&lt;/code&gt; lo arregla, y el costo en estado estable no cambia: la segunda caja solo existe durante el traslape.&lt;/p&gt;

&lt;p&gt;Ese cambio tiene una precondición. Cada instancia registraba sus runners bajo nombres fijos con &lt;code&gt;--replace&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./config.sh &lt;span class="nt"&gt;--name&lt;/span&gt; myapp-runner-&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="nt"&gt;--unattended&lt;/span&gt; &lt;span class="nt"&gt;--replace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Con dos cajas vivas, el reemplazo le roba el registro a la caja vieja&lt;br&gt;
&lt;strong&gt;mientras todavía está corriendo un job&lt;/strong&gt;, convirtiendo un rebalanceo elegante en una versión más rápida del kill que estaba evitando. Los nombres tienen que cargar el id de la instancia:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;INSTANCE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--retry&lt;/span&gt; 5 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$IMDS_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://169.254.169.254/latest/meta-data/instance-id &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RUNNER_SUFFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%s"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INSTANCE_ID&lt;/span&gt;&lt;span class="p"&gt;##*-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 9&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNNER_SUFFIX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;RUNNER_SUFFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

./config.sh &lt;span class="nt"&gt;--name&lt;/span&gt; myapp-runner-&lt;span class="nv"&gt;$RUNNER_SUFFIX&lt;/span&gt;-&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="nt"&gt;--unattended&lt;/span&gt; &lt;span class="nt"&gt;--replace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Cada &lt;code&gt;|| true&lt;/code&gt; ahí importa: ese user-data corre bajo &lt;code&gt;set -euo pipefail&lt;/code&gt;, y un solo curl sin guardar fallando al arrancar una vez produjo una instancia sana con cero runners registrados, lo que forma cada job para siempre.&lt;/p&gt;

&lt;p&gt;Los archivos completos están en &lt;code&gt;blog-ci-spot-reclaim-code/runner-asg.ts&lt;/code&gt; y &lt;code&gt;runner-user-data.sh&lt;/code&gt;.&lt;br&gt;
 omatización lo deja en paz.&lt;/p&gt;

&lt;p&gt;El lado de infraestructura afirma contra la plantilla de CloudFormation&lt;br&gt;
sintetizada, no contra el TypeScript, porque todo este post es evidencia de que&lt;br&gt;
una propiedad puede leerse bien y no hacer nada:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MaxSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DesiredCapacity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Qué no arregla
&lt;/h2&gt;

&lt;p&gt;Si el mercado de spot de ARM está apretado y el ASG se queda en cero instancias&lt;br&gt;
por horas, lo que ha pasado, el retry tampoco encuentra runner. La única cura&lt;br&gt;
para eso es &lt;code&gt;onDemandBaseCapacity: 1&lt;/code&gt;, un cambio deliberado de precio de como $9&lt;br&gt;
al mes a como $25 al mes.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/rerun-on-runner-loss" rel="noopener noreferrer"&gt;
        rerun-on-runner-loss
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Tell a spot reclaim apart from a failing test, and re-run only the jobs AWS killed.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Auto-rerun CI jobs that a spot reclaim killed&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;CI on a self-hosted spot runner goes red for two very different reasons: your
code broke, or AWS took the instance back mid-job. GitHub presents both the
same way, as a red X on whatever step was running.&lt;/p&gt;
&lt;p&gt;This repo is a small &lt;code&gt;workflow_run&lt;/code&gt; listener that tells them apart. It reads
the logs of the failed jobs, and only when they carry the runner-loss
fingerprint does it re-run them. Real test failures stay red.&lt;/p&gt;
&lt;p&gt;Full write-up: &lt;a href="https://github.com/elchesco/rerun-on-runner-loss/blog-post.md" rel="noopener noreferrer"&gt;blog-post.md&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Layout&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Files sit where they are meant to be installed, so the &lt;code&gt;.github/&lt;/code&gt; directory can
be copied wholesale.&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;.github/
  scripts/rerun_on_runner_loss.py        the decision, and the re-run call
  scripts/test/rerun_on_runner_loss.test.sh   contract test, stubs the gh CLI
  workflows/rerun-on-runner-loss.yml     the listener (must stay ubuntu-latest)
  workflows/test.yml                     runs the contract test in this repo
infra/
  runner-asg.ts                          ASG capacity block (CDK)
  runner-user-data.sh                    runner registration + offline sweep
  scripts/test/github-runner.test.sh     contract test on the&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/rerun-on-runner-loss" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>automation</category>
      <category>aws</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Tres capas de contexto para un agente de código, ordenadas por garantía</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 30 Aug 2026 18:09:58 +0000</pubDate>
      <link>https://dev.to/aws-builders/tres-capas-de-contexto-para-un-agente-de-codigo-ordenadas-por-garantia-2joe</link>
      <guid>https://dev.to/aws-builders/tres-capas-de-contexto-para-un-agente-de-codigo-ordenadas-por-garantia-2joe</guid>
      <description>&lt;h1&gt;
  
  
  Tres capas de contexto para un agente de código, ordenadas por garantía
&lt;/h1&gt;

&lt;p&gt;Una migración tiró una columna. Las pruebas estaban en verde, la revisión estaba hecha, el deploy era de rutina. Setenta y ocho segundos después el panel de admin estaba regresando 500s. El agente que revisó esa migración no tenía forma de saber que el deploy rota dos tasks una a la vez, porque nada en el repo lo dice.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/como-evite-que-claude-repitiera-mis-errores" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ffiles.speakerdeck.com%2Fpresentations%2F2ef4bdd2c2234c729b73efbd0ef3ef6f%2Fslide_0.jpg%3F40414533" height="1080" class="m-0" width="1920"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/como-evite-que-claude-repitiera-mis-errores" rel="noopener noreferrer" class="c-link"&gt;
            Cómo evité que Claude repitiera mis errores. - Speaker Deck
          &lt;/a&gt;
        &lt;/h2&gt;
          
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fd1eu30co0ohy4w.cloudfront.net%2Fassets%2Ffavicon-bdd5839d46040a50edf189174e6f7aacc8abb3aaecd56a4711cf00d820883f47.png" width="512" height="512"&gt;
          speakerdeck.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capa&lt;/th&gt;
&lt;th&gt;Qué va en ella&lt;/th&gt;
&lt;th&gt;Presente cuando&lt;/th&gt;
&lt;th&gt;Obedecida&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lo que el modelo no puede deducir leyendo el repo&lt;/td&gt;
&lt;td&gt;Cada sesión, siempre&lt;/td&gt;
&lt;td&gt;Casi siempre&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archivos de memoria&lt;/td&gt;
&lt;td&gt;Un archivo por error cometido, con la razón&lt;/td&gt;
&lt;td&gt;Cuando la recuperación lo juzga relevante&lt;/td&gt;
&lt;td&gt;Casi siempre&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hooks de git y de herramientas&lt;/td&gt;
&lt;td&gt;Reglas que no te puedes dar el lujo de que se salten&lt;/td&gt;
&lt;td&gt;Cada corrida&lt;/td&gt;
&lt;td&gt;No negociable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El orden es el punto. Cada capa cuesta más de configurar y da una garantía más fuerte. La mayoría de la gente trata de resolver todo en la capa uno, que es por lo que su &lt;code&gt;CLAUDE.md&lt;/code&gt; tiene 600 líneas y va empeorando.&lt;/p&gt;

&lt;p&gt;Dos números de los cinco meses, los dos medidos, los dos en:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; fue de 180 líneas, llegó a su pico en 249, se podó a 129, y se queda en 145.&lt;/li&gt;
&lt;li&gt;Los archivos de memoria fueron 12, 26, 36, 76, 91.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Los dos medidos el 21 de agosto de 2026. Corre los scripts en tu propio repo hoy y el conteo de memoria será más alto que el mío, porque esa capa nunca deja de crecer. Ese es el punto de ella.&lt;/p&gt;

&lt;p&gt;La capa que escribo a mano se encogió. La capa que sale de los errores creció. Si las dos tuyas están creciendo, estás poniendo todo en la capa uno.&lt;/p&gt;

&lt;h2&gt;
  
  
  El incidente
&lt;/h2&gt;

&lt;p&gt;21 de julio, 21:14. CloudWatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;UndefinedColumnError: column listings.public_visibility does not exist
GET /api/v1/admin/marketplace
500 · 500 · 500 · 500 · 500 ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La migración era correcta. El timing no. El backend corre dos tasks de Fargate y el rollout las reemplaza una a la vez, mientras que la migración corre antes de que las tasks nuevas estén sanas. Por setenta y ocho segundos el código viejo seleccionaba una columna que ya no existía.&lt;/p&gt;

&lt;p&gt;El arreglo es el expand y contract de manual: deja de referenciar la columna en un deploy, tírala en uno posterior. Esa no es la parte interesante. La parte interesante es que pedí una revisión de esa migración y nunca le dije a nadie, humano o de otro tipo, cómo se despliega este proyecto. La topología de deploy no está en el código. No hay ningún archivo que diga "esto rota dos a la vez, con traslape".&lt;/p&gt;

&lt;p&gt;El reflejo después de un incidente así es escribir un mejor prompt la próxima vez. Eso falla por una razón aburrida: un prompt se muere con la sesión.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 1: lo que el modelo no puede deducir
&lt;/h2&gt;

&lt;p&gt;La prueba de si una línea pertenece a &lt;code&gt;CLAUDE.md&lt;/code&gt; es una oración.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Si el modelo lo puede deducir leyendo el repo, córtalo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude lee tu manifiesto. No necesita que le expliques que usas React. El &lt;code&gt;CLAUDE.md&lt;/code&gt; típico que encuentras en línea es en su mayoría una descripción del repo, que el agente deriva en dos tool calls y deriva mejor de lo que tú la escribiste, porque el repo es la verdad y tu descripción tiene cuatro meses de vieja.&lt;/p&gt;

&lt;p&gt;Lo que sobrevive la prueba cae en cuatro formas, y todas son o una prohibición o una excepción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Base de datos&lt;/span&gt;

Nunca edites el schema a mano, siempre a través de migraciones.

Las migraciones que crean enums deben usar &lt;span class="sb"&gt;`op.execute()`&lt;/span&gt; crudo, no
&lt;span class="sb"&gt;`op.create_table`&lt;/span&gt; con un Enum nombrado: el paso de autogenerate se rompe.

&lt;span class="sb"&gt;`score_total`&lt;/span&gt; es una columna generada de Postgres. Nunca le asignes.

Las pruebas necesitan un Postgres real y el password no es el que está en el
README, que está viejo. Jálalo del secret manager del equipo.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nota la última línea. Carga el puntero, no el secreto. Un &lt;code&gt;CLAUDE.md&lt;/code&gt; se commitea, así que una credencial nunca va dentro de uno. Nombrar dónde vive el valor es todo el valor de la línea, y no cuesta nada.&lt;/p&gt;

&lt;h3&gt;
  
  
  Podar es una mejora medible
&lt;/h3&gt;

&lt;p&gt;La parte contraintuitiva: un &lt;code&gt;CLAUDE.md&lt;/code&gt; de 600 líneas rinde peor que uno de 100 líneas. Estos no son hechos que se acumulan, son instrucciones compitiendo por la atención. Cada línea irrelevante diluye las que importan.&lt;/p&gt;

&lt;p&gt;El mío creció a 249 líneas en once días, que es lo que pasa cuando tratas el archivo como documentación de onboarding. El 5 de abril lo corté a 129. A lo largo de los cuatro meses siguientes se fue de vuelta a 145 y nada se rompió.&lt;/p&gt;

&lt;p&gt;El truco práctico es hacer que el agente audite su propio contexto. Abre el archivo y pregunta:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;¿Qué en este archivo puedes deducir leyendo el repo?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Luego borra eso. Vas a borrar más de lo que esperas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 2: un error, un archivo, con la razón
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; es lo que sabías de antemano. La memoria es lo que aprendiste crasheando. Si tu &lt;code&gt;CLAUDE.md&lt;/code&gt; crece cada vez que algo se rompe, estás usando la capa equivocada.&lt;/p&gt;

&lt;p&gt;Aquí está el archivo que salió de los 78 segundos, textual excepto por el&lt;br&gt;
frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Una migración que hace DROP o RENAME de una columna sale en el mismo deploy que el código que deja de usarla, pero el backend corre 2 tasks de Fargate y las rota una a la vez, y la migración corre antes de que las tasks nuevas estén sanas.

&lt;span class="gs"&gt;**Por qué:**&lt;/span&gt; el rolling deploy significa que el código viejo y el nuevo sirven simultáneamente. Cualquier cambio de schema que no sea compatible hacia atrás con la imagen anterior rompe ese traslape. Pegó el 2026-07-21: &lt;span class="sb"&gt;`/admin/marketplace`&lt;/span&gt; tronó por ~78 segundos.

&lt;span class="gs"&gt;**Cómo aplicar:**&lt;/span&gt; los cambios aditivos (ADD COLUMN con un default, CREATE INDEX) son seguros de un jalón. Los destructivos necesitan expand/contract: el deploy A deja de referenciar la columna, el deploy B la tira. Un RENAME es un DROP más un ADD.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres partes, todas obligatorias: qué pasa, por qué pasa, qué hacer. Sin la tercera es una queja, no una regla.&lt;/p&gt;

&lt;h3&gt;
  
  
  La razón no es opcional
&lt;/h3&gt;

&lt;p&gt;Esta es la parte que la gente se salta, y saltársela es lo que hace que estos archivos se vuelvan superstición.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Nunca hagas DROP COLUMN en una migración."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esa regla nunca tira una columna. Ni en un proyecto de una sola instancia, ni en desarrollo. No tiene idea de dónde aplica, así que aplica en todos lados y se te atraviesa.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Nunca hagas DROP COLUMN en un solo deploy, porque el rolling
 deploy tiene dos versiones del código sirviendo a la vez."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esa sabe que un job de una sola instancia está bien, y se da cuenta por su cuenta de que un RENAME es un DROP disfrazado. La razón convierte una prohibición en un modelo mental, y un modelo mental generaliza a casos que nunca escribiste.&lt;/p&gt;

&lt;p&gt;La otra cosa que vale la pena decir: yo no escribí ese archivo. Dije "esto acaba de pasar, guárdalo para que no se repita" y el agente lo escribió. Escribir la memoria es trabajo del modelo. Decidir que algo merece una es tuyo, y la prueba es si te va a morder otra vez. Un typo no. Una propiedad del sistema que no está escrita en ningún lado, sí.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 3: cuando casi siempre no es suficiente
&lt;/h2&gt;

&lt;p&gt;Las capas uno y dos funcionan bien. Pero son instrucciones a un modelo, y un modelo tiene una tasa de olvido distinta de cero. Para la mayoría de las reglas esa tasa es irrelevante. Para unas cuantas no lo es.&lt;/p&gt;

&lt;p&gt;Tenía una regla de que cada funcionalidad visible al usuario se documenta en una guía que lee el lado no técnico del equipo. Vivió en &lt;code&gt;CLAUDE.md&lt;/code&gt; por seis meses y se seguía casi siempre. Casi siempre resultó significar que la guía iba tres funcionalidades atrasada y nadie lo notó hasta que alguien la necesitó.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;USER_VISIBLE_PATHS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
    &lt;span class="s1"&gt;'^backend/app/api/v1/admin/'&lt;/span&gt;
    &lt;span class="s1"&gt;'^backend/app/services/billing/'&lt;/span&gt;
    &lt;span class="s1"&gt;'^frontend/src/pages/admin/'&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# ¿tocó algo visible pero no la guía?&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="s1"&gt;'docs/team-guide.md'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✗ commit bloqueado"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="c"&gt;# e imprime la salida de emergencia:&lt;/span&gt;
    &lt;span class="c"&gt;#   git commit --no-verify&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El bash no es la parte interesante. Dos decisiones de diseño lo son.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El mensaje de error documenta la salida.&lt;/strong&gt; Un hook del que no te puedes salir obstruye el primer caso legítimo, y alguien lo desinstala esa semana. El mensaje está escrito para la persona que va a estar aquí a las once de la noche sin paciencia para acordarse de una bandera.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La lista de rutas es deliberadamente conservadora.&lt;/strong&gt; Los falsos positivos se descartan con una bandera. Una funcionalidad visible saliendo en silencio no se descarta, se descubre meses después.&lt;/p&gt;

&lt;h3&gt;
  
  
  Elegir la capa
&lt;/h3&gt;

&lt;p&gt;No "cuál es la mejor". Cuánto cuesta la falla.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Si la regla&lt;/th&gt;
&lt;th&gt;Ponla en&lt;/th&gt;
&lt;th&gt;Porque&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Es una preferencia de estilo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Un fallo ocasional no le hace daño a nadie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vino de un incidente específico&lt;/td&gt;
&lt;td&gt;Memoria&lt;/td&gt;
&lt;td&gt;Necesita la razón y el alcance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Se descubre semanas después&lt;/td&gt;
&lt;td&gt;Hook&lt;/td&gt;
&lt;td&gt;Nadie va a revisar. Se acumula&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cuesta dinero o uptime&lt;/td&gt;
&lt;td&gt;Hook&lt;/td&gt;
&lt;td&gt;El fallo ocasional deja de ser aceptable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No tengo una tasa de falla medida para ninguna capa y no la voy a inventar. El argumento es costo esperado, no frecuencia. Es el mismo cálculo que ya usas para decidir si algo se lleva una prueba automatizada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Una nota sobre los subagentes
&lt;/h2&gt;

&lt;p&gt;Adyacente, y muy malentendido: un subagente cuesta más tokens totales, no menos. Paga por su propio system prompt, su propia copia de &lt;code&gt;CLAUDE.md&lt;/code&gt; y sus propios schemas de herramientas, y luego su resumen te regresa como input.&lt;/p&gt;

&lt;p&gt;Lo que ahorras es ventana de contexto principal, que es un recurso distinto y a veces el que de verdad te falta. Vale la pena cuando la exploración abarca módulos que no conoces, o cuando las investigaciones pueden correr en paralelo.&lt;br&gt;
No vale la pena para una tarea que toca tres archivos que ya identificaste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué hacer el lunes
&lt;/h2&gt;

&lt;p&gt;Tres cosas, menos de una hora en total.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Abre tu &lt;code&gt;CLAUDE.md&lt;/code&gt; y pregúntale al agente qué es redundante.&lt;/strong&gt; "¿Qué en este archivo puedes deducir leyendo el repo?" Borra eso.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La próxima vez que algo se rompa, archívalo mientras está caliente.&lt;/strong&gt; Antes de arreglarlo, con la razón. Es cuando lo tienes fresco y cuando menos ganas tienes de escribirlo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escoge una regla y conviértela en un hook.&lt;/strong&gt; La que duele si se resbala.  Una. Con su salida documentada en el mensaje de error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El modelo no recuerda. Tu repositorio sí.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Construyendo un recomendador de emparejamiento de expertos</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Mon, 24 Aug 2026 00:03:34 +0000</pubDate>
      <link>https://dev.to/aws-builders/construyendo-un-recomendador-de-emparejamiento-de-expertos-57h2</link>
      <guid>https://dev.to/aws-builders/construyendo-un-recomendador-de-emparejamiento-de-expertos-57h2</guid>
      <description>&lt;h2&gt;
  
  
  La forma del problema
&lt;/h2&gt;

&lt;p&gt;Un directorio es una superficie: el miembro lo abre y adivina. &lt;br&gt;
Un recomendador es una superficie de empujar: el sistema propone y tiene que justificarse. La justificación es la parte difícil, y es donde vive la estadística.&lt;/p&gt;

&lt;p&gt;Tres restricciones hicieron esto distinto de un recomendador de contenido:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;El item es una persona con capacidad finita.&lt;/strong&gt; Un hilo se le puede recomendar a diez mil personas. Un experto no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una mala recomendación es cara de los dos lados.&lt;/strong&gt; Quien pide desperdicia una petición, el experto desperdicia una hora, y los dos aprenden a ignorar la superficie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La afirmación tiene que ser checable.&lt;/strong&gt; "Quizá te guste este hilo" no necesita evidencia. "Esta persona está un nivel adelante de ti en diseño de sistemas" sí.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Recuperación: híbrida, fusionada con RRF
&lt;/h2&gt;

&lt;p&gt;Tres recuperadores independientes sobre el conjunto de expertos elegibles, fusionados con Reciprocal Rank Fusion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf_fuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fusiona listas de ids rankeadas. El score depende solo del rank, nunca
    de la escala propia del recuperador, que es el punto: la similitud coseno y
    un conteo de hilos resueltos no son números comparables.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;fused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lst&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lst&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fused&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RRF es la primitiva correcta aquí por una razón que vale la pena decir: los recuperadores emiten cantidades incomparables. Uno regresa un coseno en &lt;code&gt;[-1, 1]&lt;/code&gt;, uno regresa un conteo entero de hilos resueltos, uno regresa un delta de nivel de escalera. Normalizarlos a una escala común requiere supuestos sobre sus distribuciones que nadie tiene a este volumen de datos. &lt;br&gt;
RRF descarta las magnitudes y se queda solo con el orden, que es exactamente la información que sobrevive a una muestra chica.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;k = 60&lt;/code&gt; es la constante estándar de la formulación original de Cormack et al.&lt;br&gt;
Aplana la cabeza: la diferencia entre el rank 1 y el rank 2 es&lt;br&gt;
&lt;code&gt;1/61 - 1/62 ≈ 0.00026&lt;/code&gt;, así que un recuperador no puede dominar por estar confiado, solo por estar consistentemente temprano a lo largo de las listas.&lt;/p&gt;
&lt;h2&gt;
  
  
  Scoring: un compuesto ponderado, con los pesos como datos
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SCORE_WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compass_gap_fit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# explícito y direccional
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;semantic_fit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# coseno del embedding de perfil
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skill_overlap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# tecnologías compartidas, ponderadas por rareza
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capacity_fit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# también una compuerta dura
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expert_quality&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# rating, aceptación, experiencia que satura
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;specialty_match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# burda, pero muy poblada
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fairness&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# penalización de exposición amortiguada por log
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Dos componentes valen la pena desempacar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;expert_quality&lt;/code&gt; satura.&lt;/strong&gt; Un experto sin historial puntúa 0.5, no 0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;expert_quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avg_rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proposed&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;proposed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;                      &lt;span class="c1"&gt;# prior neutral, no cero
&lt;/span&gt;    &lt;span class="n"&gt;rating_part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;avg_rating&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;
    &lt;span class="n"&gt;acceptance&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;proposed&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proposed&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;
    &lt;span class="n"&gt;experience&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;completed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rating_part&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;acceptance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;experience&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La saturación exponencial sobre &lt;code&gt;experience&lt;/code&gt; codifica que la diferencia entre 0 y 5 sesiones completadas es grande y la diferencia entre 40 y 45 es ruido.&lt;br&gt;
Un término lineal habría hecho inalcanzables a los veteranos. El prior de 0.5 para los no probados es la decisión de &lt;em&gt;cold start&lt;/em&gt; que deja crecer el pool de expertos más allá de quien haya ido primero; sin él, el sistema es un loop de &lt;em&gt;rico se hace más rico&lt;/em&gt; por construcción.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fairness&lt;/code&gt; es exposición amortiguada por log.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fairness_factor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times_recommended&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;times_recommended&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ln(e + n)&lt;/code&gt; da exactamente 1.0 en &lt;code&gt;n = 0&lt;/code&gt; y decae lento. Una penalización lineal habría hecho irrecomendable a un buen experto después de un puñado de ciclos.&lt;/p&gt;

&lt;h2&gt;
  
  
  La parte que importa: esto es un problema de asignación
&lt;/h2&gt;

&lt;p&gt;El instinto es computar un top-N por cada quien pide. Ese instinto está mal, y el modo de falla no es sutil.&lt;/p&gt;

&lt;p&gt;Si cada quien pide escoge de forma independiente a su mejor experto, las mismas tres personas más fuertes juntan todas las peticiones. Son las que tienen los mejores ratings y el historial más profundo, así que ganan cada ranking, y dejan de contestar en un mes. El recomendador entonces destruye el recurso que existe para asignar.&lt;/p&gt;

&lt;p&gt;Así que los pares se puntúan, y luego se asignan de forma global bajo una restricción de capacidad por experto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_requester&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Asignación global greedy. Ordenada por score, cada par consume una unidad
    de la capacidad de su experto.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;assigned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expert_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;assigned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requester_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;per_requester&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expert_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;assigned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requester_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esta es la aproximación greedy a un matching bipartito con restricción de grado. A esta escala (cientos de pares) la solución óptima vía &lt;code&gt;scipy.optimize.linear_sum_assignment&lt;/code&gt; y la greedy difieren por ruido, y la versión greedy tiene una propiedad que la óptima no: es inspeccionable en un dry run, línea por línea, en orden de score. Cuando un operador pregunta "¿por qué esta persona obtuvo ese experto?", la respuesta es una sola pasada hacia abajo por una lista ordenada.&lt;/p&gt;

&lt;p&gt;A quienes piden que la asignación no puede colocar no se les tira. Por construcción son aquellos cuyos mejores expertos están llenos, lo que los hace el insumo exacto para el clustering uno-a-muchos: agrúpalos por celda de escalera y propón una sola sesión.&lt;/p&gt;

&lt;h2&gt;
  
  
  La capa de datos, y la estadística que la hace defendible
&lt;/h2&gt;

&lt;p&gt;La segunda capa responde "qué debería aprender después, y cuánto vale".&lt;br&gt;
Compara la mediana de datos ponderada por fuente de los puntos de datos que reportan una habilidad contra los que no.&lt;/p&gt;

&lt;p&gt;Esa oración contiene tres maneras de engañar a alguien. Las tres necesitaron una compuerta.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Ponderación por fuente con decaimiento exponencial por recencia
&lt;/h3&gt;

&lt;p&gt;No todos los puntos de datos merecen voto igual. Cada fuente carga un peso de confianza y una corrección de sesgo, y cada punto decae con la edad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bias_correction_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;POWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EPOCH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scraped_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;halflife&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una vida media de 365 días significa que una publicación de dos años todavía cuenta, a un cuarto del peso de una fresca. El &lt;code&gt;LEFT JOIN&lt;/code&gt; sobre la tabla de fuentes es deliberado: un punto de datos cuya fila de fuente nunca se registró cuenta en el default neutral de 0.50 en lugar de desvanecerse, porque tirar datos en silencio es peor que ponderarlos de forma conservadora.&lt;/p&gt;

&lt;p&gt;El agregado es una &lt;strong&gt;mediana ponderada&lt;/strong&gt;, no una media ponderada. Las&lt;br&gt;
distribuciones de datos están sesgadas a la derecha y la cola es donde viven los errores de scraping; una sola cifra mal parseada mueve una media y no mueve una mediana.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;weighted_median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# todos los pesos en cero: degrada a mediana simple
&lt;/span&gt;        &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
                &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;half&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;half&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Estratificación, porque el número ingenuo mide antigüedad
&lt;/h3&gt;

&lt;p&gt;Este es el confusor que hace inútiles a la mayoría de las afirmaciones de "la habilidad X paga Y% más". La gente senior sabe más herramientas. Compara a todos los que reportan Kubernetes contra todos los que no, y una parte grande del delta es nada más antigüedad filtrándose por la comparación.&lt;/p&gt;

&lt;p&gt;Cada comparación por lo tanto pasa &lt;strong&gt;dentro&lt;/strong&gt; de un estrato &lt;code&gt;(rol, seniority, país, bucket de experiencia)&lt;/code&gt;. Los buckets son burdos a propósito (&lt;code&gt;0-2&lt;/code&gt;, &lt;code&gt;3-5&lt;/code&gt;, &lt;code&gt;6-9&lt;/code&gt;, &lt;code&gt;10+&lt;/code&gt;): estratos más finos matan de hambre a la muestra y el intervalo explota. Una habilidad cuyo efecto desaparece una vez estratificada se tira, no se reporta.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Un intervalo por bootstrap, no un estimado puntual
&lt;/h3&gt;

&lt;p&gt;Un estimado puntual se lee como una promesa. El bootstrap por percentil re muestrea las dos cohortes con reemplazo, recomputa las medianas ponderadas, y toma los percentiles 5/95 de la distribución de deltas resultante:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bootstrap_ci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;without_vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;without_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# determinista: los mismos insumos deben
&lt;/span&gt;    &lt;span class="n"&gt;deltas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;                          &lt;span class="c1"&gt;# producir el mismo intervalo en cada corrida
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_vals&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;with_vals&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;without_vals&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;without_vals&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;med_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;weighted_median&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;with_vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;with_w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;med_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;weighted_median&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;without_vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;without_w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;med_b&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;med_a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;med_b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;med_b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;)))]&lt;/span&gt;
    &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El bootstrap es la herramienta correcta porque la distribución muestral de una &lt;em&gt;mediana ponderada de una distribución sesgada&lt;/em&gt; no tiene una forma cerrada limpia. El re muestreo esquiva la derivación por completo.&lt;/p&gt;

&lt;p&gt;El RNG sembrado importa más de lo que parece. Un operador refrescando un dry run de admin no debe ver el número bambolearse; un intervalo de confianza que cambia al recargar es indistinguible de un bug.&lt;/p&gt;

&lt;p&gt;Una fila se surge solo si las tres se cumplen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;significant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ci_low&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;                  &lt;span class="c1"&gt;# el intervalo se queda de un lado del cero
&lt;/span&gt;               &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;premium&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min_pct&lt;/span&gt;        &lt;span class="c1"&gt;# bastante grande para valer el tiempo de una persona
&lt;/span&gt;               &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;premium&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;claim_cap_pct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# no un outlier absurdo
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Las filas no significativas de todos modos se computan y se guardan. La tabla de admin muestra qué se rechazó y por qué, porque un número que el sistema se negó a usar es tan interesante como uno que usó.&lt;/p&gt;

&lt;h2&gt;
  
  
  La medición que reencuadró el proyecto
&lt;/h2&gt;

&lt;p&gt;Todo lo de arriba estaba en verde en CI. Luego la primera dry run contra producción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;requesters&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;82  experts=1  scored_pairs=82  affinity=3  office_hours=0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1 experto de 51.&lt;/strong&gt; La causa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;requester_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requesters&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;eligible_experts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;experts&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open_load&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requester_ids&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- esto
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La intención era "no emparejes a alguien consigo mismo". La implementación era "excluye a cualquier experto que sea también un candidato a pedir", y casi todos los expertos lo son, porque no tienen ninguna petición abierta propia.&lt;/p&gt;

&lt;p&gt;Medido:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Roles activos elegibles como experto:                51
...que se apuntaron:                                 51
...bajo el tope de carga:                            51
...excluidos por ser también quienes piden:             50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El único sobreviviente era la única persona que resultó estar a media&lt;br&gt;
interacción, y las tres propuestas apuntaban a ella. El sistema había&lt;br&gt;
encontrado el modo de falla de burnout por su cuenta, en la primera corrida, a través de una línea pensada para prevenir un problema completamente distinto.&lt;/p&gt;

&lt;p&gt;La guarda de auto emparejamiento ya existía por par, que es donde va.&lt;/p&gt;

&lt;p&gt;Dos hallazgos más de la misma corrida:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Las cards podían salir sin razón.&lt;/strong&gt; Solo 4 miembros tenían una colocación de escalera de competencias, así que el componente más pesado casi siempre era 0 y la señal sobreviviente era un coseno de embedding que nadie puede leer. Una propuesta tenía una lista de razones vacía. Una card que no puede decir por qué es peor que ninguna card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El badge de tendencia disparaba con todo.&lt;/strong&gt; La ingesta era reciente, así que casi cada publicación caía en la ventana de 30 días y la línea base de 90 días era una o dos filas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;golang   229 publicaciones  trend=2.00
go       155 publicaciones  trend=462.00
python   109 publicaciones  trend=62.40
aws       43 publicaciones  trend=61.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una razón necesita un denominador que valga la pena dividir. El arreglo es un conteo mínimo de línea base antes de que se afirme una tendencia siquiera, y una cota sobre la razón.&lt;/p&gt;

&lt;h2&gt;
  
  
  La lección de frecuencia inversa, aprendida tres veces
&lt;/h2&gt;

&lt;p&gt;Esta es la parte con el mayor valor de transferencia, porque la misma idea estadística tuvo que aplicarse en tres niveles distintos y cada nivel se veía bien hasta que se inspeccionó.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nivel 1: lift, en el minero de adyacencia
&lt;/h3&gt;

&lt;p&gt;La adyacencia de habilidades es minería clásica de reglas de asociación sobre habilidades co-ocurrentes en publicaciones de vacantes. Para una regla&lt;br&gt;
&lt;code&gt;A -&amp;gt; B&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;soporte&lt;/strong&gt; = conteo de transacciones que contienen las dos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;confianza&lt;/strong&gt; = &lt;code&gt;P(B | A)&lt;/code&gt; = soporte / conteo(A)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lift&lt;/strong&gt; = &lt;code&gt;confianza / P(B)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La confianza sola es inútil, y la razón es instructiva. Linux co-ocurre con todo, así que &lt;code&gt;P(Linux | lo que sea)&lt;/code&gt; es alta y cada regla apunta a Linux. El lift divide por la tasa base: si saber A no sube la probabilidad de B por encima del azar, el lift es 1 y la regla no carga información.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;lift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;singles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;consequent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lift&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_lift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# 1.15
&lt;/span&gt;    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reglas que sobrevivieron sobre datos reales, ordenadas por &lt;code&gt;confianza * lift&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;laravel        -&amp;gt; php            conf=0.73  lift=11.33  support=8
terraform      -&amp;gt; kubernetes     conf=0.75  lift=9.25   support=9
rails          -&amp;gt; ruby           conf=0.78  lift=7.94   support=7
react native   -&amp;gt; react          conf=1.00  lift=5.48   support=7
gcp            -&amp;gt; aws            conf=0.73  lift=6.15   support=16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esas se leen bien para un humano, que es la única validación disponible a este tamaño de muestra.&lt;/p&gt;

&lt;p&gt;Una trampa que vale la pena registrar: las primeras pruebas unitarias de esto eran degeneradas. Un corpus donde el antecedente aparece en &lt;em&gt;cada&lt;/em&gt; transacción tiene lift 1.0 por construcción, así que nada puede ser significativo jamás.&lt;/p&gt;

&lt;p&gt;Los fixtures tuvieron que reescribirse para incluir publicaciones que no contuvieran ningún lado de la regla. Un corpus de mercado es diverso; un corpus de prueba tiene que serlo también, o no prueba nada.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nivel 2: frecuencia de documento, en la señal de tema
&lt;/h3&gt;

&lt;p&gt;El riel salió y casi cada card decía:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Comparten temas: introduccion-plataforma.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;La distribución de frecuencia de tags lo explica:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;talento-tecnologico        27 hilos   50.0%
introduccion-profesional   17 hilos   31.5%
introduccion-plataforma    14 hilos   25.9%
espacio-relajacion          4 hilos    7.4%
servidores-caseros          4 hilos    7.4%
ai-local-vs-nube            2 hilos    3.7%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres tags cubren la mitad de un corpus de 54 hilos, luego un acantilado a 7%.&lt;/p&gt;

&lt;p&gt;Este es el problema de Linux otra vez, un nivel arriba: un tag sobre la mitad del corpus no carga información sobre un par. Los tags por encima de 20% de frecuencia de documento se tiraron del scoring y del copy.&lt;/p&gt;

&lt;p&gt;Ese arreglo hizo el copy menos vergonzoso sin hacerlo significar nada, lo que lleva al tercer nivel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nivel 3: el vocabulario era la ontología equivocada
&lt;/h3&gt;

&lt;p&gt;Existían nueve tags en total. &lt;strong&gt;Ninguno de ellos era una tecnología.&lt;/strong&gt; &lt;br&gt;
Eran secciones del foro: un tablero de anuncios, dos áreas de introducción, un espacio fuera de tema. Ningún umbral de frecuencia puede rescatar una señal que está midiendo lo equivocado. Compartir "introducciones" significa que las dos personas se presentaron.&lt;/p&gt;

&lt;p&gt;La señal técnica que sí existía era el &lt;code&gt;tech_stack&lt;/code&gt; del perfil:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python 11 · aws 8 · docker 7 · node.js 7 · typescript 7
git 6 · react 6 · fastapi 5 · kubernetes 3 · terraform 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flaca (20 de 83 miembros) pero real, y nombra cosas que un experto puede enseñar. El componente de tema se reemplazó por completo, normalizado por el menor de los dos stacks para que un stack de 30 items no pueda diluir un buen match de 3-de-4, y extendido a través de las reglas de adyacencia minadas para que quien pide sobre Python conecte con un experto sobre FastAPI con la regla como la justificación declarada.&lt;/p&gt;

&lt;p&gt;Y luego, predeciblemente, el nivel 3 tuvo su propio nivel:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;También trabaja con python.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Python está en 11 de 20 stacks. El mismo problema, tercera recursión. La resolución esta vez &lt;em&gt;no&lt;/em&gt; fue un filtro duro, porque a diferencia de una sección de foro, Python es una habilidad enseñable real y ponerla en cero descarta traslape genuino. &lt;/p&gt;

&lt;p&gt;Las habilidades ubicuas se &lt;strong&gt;bajan de peso a un quinto&lt;/strong&gt; en lugar de tirarse, el copy nombra las habilidades más raras&lt;br&gt;
primero, y un stack compartido solo justifica una card cuando al menos una habilidad compartida es poco común:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;weight_of&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;common_skills&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una habilidad compartida rara ahora le gana a una ubicua 1.0 a 0.2, que es lo que pone a Terraform encima de Python en el ranking en lugar de debajo por accidente de quién lista más tecnologías.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La generalización: cada vez que una señal es un traslape de conjuntos, pregúntate cuál es la tasa base de cada elemento antes de dejar hablar al traslape.&lt;/strong&gt; El lift, el IDF, y este bajado de peso de habilidades son la misma corrección con tres nombres distintos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Afinado de pesos como learning to rank offline
&lt;/h2&gt;

&lt;p&gt;Los pesos compuestos se guardan en config, no en código, y hay una dry run que propone un rebalanceo a partir de los resultados calificados de los propios miembros:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;correlations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;safe_corr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per_key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres decisiones deliberadas alrededor de ella:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La clase positiva es una sesión agendada, no un click.&lt;/strong&gt; Un click en un hilo es barato; convertir una propuesta en una sesión de verdad agendada no. &lt;/p&gt;

&lt;p&gt;Optimizar por clicks afinaría el ranking hacia la curiosidad en lugar de hacia sesiones que pasan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La propuesta es una mezcla conservadora&lt;/strong&gt;, nunca el vector crudo derivado de la correlación:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;blended&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BLEND&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;corr_w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;BLEND&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con &lt;code&gt;BLEND = 0.5&lt;/code&gt; y un piso de 30 muestras, una semana flaca no puede mover el ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nada se aplica automáticamente.&lt;/strong&gt; Un operador previsualiza y aplica de forma explícita. Auto aplicar una correlación computada sobre decenas de muestras es como un ranker oscila.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trampas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trampa 1: la restricción de unicidad que creó duplicados.&lt;/strong&gt; Las filas son únicas por &lt;code&gt;(requester, expert, cycle_key)&lt;/code&gt;, lo cual es correcto por ciclo y mal a través de ellos. El cron semanal escribió un segundo ciclo encima del primero y el riel de inmediato se dobló:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;riel para &amp;lt;miembro&amp;gt;:
   &amp;lt;experto-a&amp;gt;  0.3997
   &amp;lt;experto-a&amp;gt;  0.3871   &amp;lt;--
   &amp;lt;experto-b&amp;gt;  0.3589
   &amp;lt;experto-b&amp;gt;  0.3462   &amp;lt;--
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arreglado en dos lugares, porque cualquiera solo deja un hoyo: la lectura se colapsa con &lt;code&gt;DISTINCT ON (expert_id)&lt;/code&gt; quedándose con la mejor fila, y un ciclo nuevo reemplaza las propuestas sin tocar de los viejos para que la tabla no pueda crecer una card por persona por semana para siempre. Solo se tiran las filas no mostradas; cualquier cosa que un miembro vio es historial de embudo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trampa 2: el CTA que no hacía nada.&lt;/strong&gt; El riel se renderizaba dentro de la misma página a la que enlazaba su botón, así que el click actualizaba el query string y la página se quedaba ahí. Peor, el enlace no cargaba id de match, así que una petición enviada nunca podía reportar de vuelta y la máquina de estados nunca podía avanzar más allá de &lt;code&gt;CLICKED&lt;/code&gt;. La tasa de aceptación, la única métrica sobre la que se juzga el rollout y la que está cableada a la alarma, se habría leído como &lt;strong&gt;cero para siempre&lt;/strong&gt;. El botón muerto se habría&lt;br&gt;
reportado en un día; la métrica muerta se habría creído por semanas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trampa 3: una auto-recomendación latente.&lt;/strong&gt; Cero filas en producción tenían &lt;code&gt;requester_id = expert_id&lt;/code&gt;, pero el clustering uno-a-muchos escogía su experto del pool completo sin excluir el cluster, y esas filas cargan al primer miembro del grupo como quien pide. Podía apuntarse a sí mismo. Simplemente nunca había&lt;br&gt;
disparado porque ningún cluster había alcanzado el umbral.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mide contra datos de producción antes de confiar en un diseño.&lt;/strong&gt; La primera dry run contra filas reales invalidó más del diseño que todas las pruebas juntas. Córrela antes de que se emita nada, no después.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Un recomendador para un recurso finito es un problema de asignación.&lt;/strong&gt; El top-N por usuario es la forma equivocada y su modo de falla es destruir el lado de la oferta. La restricción de capacidad va en la asignación, no en un post filtro.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Chequea la tasa base de cualquier señal de traslape de conjuntos.&lt;/strong&gt; El lift, la frecuencia de documento y la rareza de habilidad son la misma corrección. Una señal compartida por la mitad de la población no carga información sobre un par, no importa qué tan cierta sea.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Antes de afinar un umbral, verifica el vocabulario.&lt;/strong&gt; Se gastaron dos rondas en cortes de frecuencia para un conjunto de tags que no contenía ninguna tecnología en absoluto. Pregúntate qué &lt;em&gt;son&lt;/em&gt; las etiquetas antes de preguntar qué tan comunes son.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Un intervalo de confianza es una decisión de producto, no un detalle de estadística.&lt;/strong&gt; El intervalo, el piso de muestra y la cota de efecto son lo que se para entre una correlación y una promesa que los datos no pueden cumplir. Muestra el rango, declara la muestra, y niégate a imprimir el número cuando las compuertas fallan.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Siembra el bootstrap.&lt;/strong&gt; Un número que cambia al recargar es indistinguible de un bug, y destruye la confianza del operador más rápido que estar mal una vez.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instrumenta la conversión antes de publicar la superficie.&lt;/strong&gt; Un botón muerto se reporta en un día. Una métrica muerta se cree por un mes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Publica el motor a oscuras.&lt;/strong&gt; Emisión apagada por default y una dry run como la acción de admin por default significaron que cuatro bugs significativos se encontraron con datos reales y cero notificaciones enviadas.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>algorithms</category>
      <category>datascience</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Cómo mantener siempre disponibles las sesiones de tu agente de código remoto (parte 2)</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Wed, 19 Aug 2026 00:53:33 +0000</pubDate>
      <link>https://dev.to/aws-builders/mover-un-agente-de-ia-siempre-encendido-de-un-vps-de-24-a-fargate-spot-pt-2-o4f</link>
      <guid>https://dev.to/aws-builders/mover-un-agente-de-ia-siempre-encendido-de-un-vps-de-24-a-fargate-spot-pt-2-o4f</guid>
      <description>&lt;p&gt;La Parte 1 construyó una caja siempre encendida para una sesión de &lt;code&gt;remote-control&lt;/code&gt; de Claude Code y terminó con una alarma que convierte seis días de silencio en quince minutos. Esa alarma atrapa una caja &lt;em&gt;muerta&lt;/em&gt;. Esta es la secuela, y la caja de aquí nunca estuvo muerta. Estaba &lt;code&gt;active&lt;/code&gt;, su token estaba fresco, y en cada reinicio imprimía:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Take this session with you and pick up right where you left off on any device.
Open the Code tab in the Claude mobile app, or visit claude.ai/code in a browser.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;y la sesión no estaba en &lt;code&gt;claude.ai/code&lt;/code&gt;. Nunca se había registrado. El banner de arriba se imprime al arrancar, &lt;em&gt;antes&lt;/em&gt; de que haya pasado lo que anuncia, así que le mintió al monitoreo, me mintió a mí cuando lo "arreglé", y casi se mintió hasta meterse en este blog post como el arreglo. Este post es sobre la diferencia entre un proceso que dice que está listo y una sesión que de verdad lo está.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Lo que creía&lt;/th&gt;
&lt;th&gt;Lo que era cierto&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Causa raíz&lt;/td&gt;
&lt;td&gt;el registro se puso viejo a lo largo de ~6 días de uptime&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;nunca se registró&lt;/strong&gt;, el CLI se colgó en un prompt interactivo sin TTY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;El "arreglo"&lt;/td&gt;
&lt;td&gt;reinicia el servicio; el banner regresa; el heartbeat en verde&lt;/td&gt;
&lt;td&gt;el reinicio imprimió el banner otra vez y aun así no se registró&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;La señal de salud que construí&lt;/td&gt;
&lt;td&gt;grep del journal por &lt;code&gt;claude.ai/code&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ese string es el &lt;strong&gt;banner de pre-registro&lt;/strong&gt;, verde para un proceso colgado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;La señal real&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;la &lt;strong&gt;URL registrada&lt;/strong&gt; (&lt;code&gt;claude.ai/code/session_…&lt;/code&gt;), que solo se imprime después del registro&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Placeholders, como en la parte 1: &lt;code&gt;myapp&lt;/code&gt;, &lt;code&gt;appuser&lt;/code&gt;, &lt;code&gt;acme/myapp&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  El síntoma
&lt;/h2&gt;

&lt;p&gt;La sesión simplemente no estaba en la lista de &lt;code&gt;claude.ai/code&lt;/code&gt;, mientras que todo en la caja decía que debería estar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl is-active claude-remote-control
&lt;span class="go"&gt;active
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %y ~/.claude/.credentials.json
&lt;span class="gp"&gt;2026-07-28 02:54:11          #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;token refrescado hace horas, la auth está bien
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws cloudwatch describe-alarms &lt;span class="nt"&gt;--alarm-names&lt;/span&gt; myapp-agent-box-down &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --query 'MetricAlarms[].StateValue' --output text
OK
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Así que hice lo obvio: reinicié el servicio. Imprimió el banner, el proceso subió, el heartbeat se puso verde, y la sesión seguía sin estar ahí. Ese es el momento en el que vale la pena detenerse. Cada señal que tenía decía sano, y el producto real (una sesión que puedo abrir desde un navegador) no existía.&lt;br&gt;
&lt;strong&gt;Un reinicio que produce un banner no es un reinicio que produce una&lt;br&gt;
sesión.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Conseguir una señal que no puede mentir
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;systemctl is-active&lt;/code&gt; reporta que el proceso existe. El banner reporta que el proceso &lt;em&gt;arrancó&lt;/em&gt;. Ninguno reporta que la caja se registró con el punto de encuentro hospedado, lo único que mete la sesión en la lista. Para ver eso, pídele al CLI que escriba su propio log de debug y lee lo que de verdad pasa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude remote-control &lt;span class="nt"&gt;--name&lt;/span&gt; myapp-cloud &lt;span class="nt"&gt;--continue&lt;/span&gt; &lt;span class="nt"&gt;--debug-file&lt;/span&gt; /tmp/rc.log &lt;span class="nt"&gt;--verbose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos fallas cayeron de inmediato, y ninguna era "vieja":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Take this session with you and pick up right where you left off on any device.
Open the Code tab in the Claude mobile app, or visit claude.ai/code in a browser.
The session keeps running on this machine. ... Press Ctrl+C to stop.

Enable Remote Control? (y/n)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El proceso imprime el banner y luego &lt;strong&gt;se bloquea en un prompt interactivo&lt;/strong&gt;.&lt;br&gt;
Bajo systemd no hay TTY que lo conteste, así que espera ahí para siempre: &lt;code&gt;active&lt;/code&gt;, banner emitido, registro ni siquiera intentado. Los CLI recientes agregaron esta confirmación; la caja de la parte 1 es anterior a ella.&lt;/p&gt;

&lt;p&gt;Y cuando le di una &lt;code&gt;y&lt;/code&gt;, la segunda falla:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: No recent session found in this directory or its worktrees.
Run `claude remote-control` to start a new one.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--continue&lt;/code&gt;, la bandera de la parte 1 que mantiene el marcador estable reanudando la misma sesión, &lt;strong&gt;da error duro cuando no hay sesión que reanudar&lt;/strong&gt;. En una caja cuyo historial de sesión local se había borrado en una reconstrucción, &lt;code&gt;--continue&lt;/code&gt; no era una optimización; era un arranque que solo podía fallar.&lt;/p&gt;

&lt;h2&gt;
  
  
  El arreglo real: contesta el prompt, y no dependas de que una sesión exista
&lt;/h2&gt;

&lt;p&gt;Las dos fallas viven en una línea, así que el arreglo es una línea, un wrapper en &lt;code&gt;ExecStart&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/bash -c 'cd /home/appuser/myapp; &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;echo y | /home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue || &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;echo y | /home/appuser/.local/bin/claude remote-control --name myapp-cloud'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres cosas se ganan su lugar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;echo y&lt;/code&gt;&lt;/strong&gt; contesta el prompt. Importa que el proceso siga corriendo después de que su stdin se cierra (verificado, sí lo hace), así que un &lt;code&gt;echo&lt;/code&gt; de una sola vez basta; sin &lt;code&gt;yes&lt;/code&gt; inundando el pipe, sin &lt;code&gt;sleep infinity&lt;/code&gt; manteniéndolo abierto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--continue || &amp;lt;nuevo&amp;gt;&lt;/code&gt;&lt;/strong&gt; trata de reanudar (marcador estable) y cae de vuelta a una sesión nueva cuando no hay nada que reanudar. Como &lt;code&gt;echo y&lt;/code&gt; cierra limpio, un &lt;code&gt;--continue&lt;/code&gt; fallido de verdad &lt;em&gt;regresa&lt;/em&gt; y corre el fallback; un &lt;code&gt;sleep infinity&lt;/code&gt; aquí colgaría el pipeline en el camino de falla y reintroduciría el atoro silencioso exacto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sin &lt;code&gt;$@&lt;/code&gt; ni &lt;code&gt;$VAR&lt;/code&gt;&lt;/strong&gt; en &lt;code&gt;ExecStart&lt;/code&gt;. systemd expande &lt;code&gt;${}&lt;/code&gt; y &lt;code&gt;$VAR&lt;/code&gt; pelón él mismo, y mutila &lt;code&gt;$@&lt;/code&gt;; la jugada segura es repetir la ruta en lugar de factorizarla en una variable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ahora el log de debug muestra lo que el banner solo fingía:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[bridge:init] Resuming session session_01Pp8… on environment env_01Vp…
[bridge:init] Registered, server environmentId=env_01Vp…
[bridge:title] server title: myapp-cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Registered&lt;/code&gt;. Ese es el recibo. El banner era el menú.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defensa en profundidad, todavía vale la pena conservarla
&lt;/h2&gt;

&lt;p&gt;El wrapper arregla el outage. Dos guardas del primer borrador de este trabajo se quedan, porque endurecen la caja contra &lt;em&gt;otras&lt;/em&gt; maneras en que la sesión puede caducar:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reciclado diario.&lt;/strong&gt; &lt;code&gt;RuntimeMaxSec=86400&lt;/code&gt; rebota la unidad cada 24h; &lt;code&gt;Restart=always&lt;/code&gt; la trae de vuelta y &lt;code&gt;--continue&lt;/code&gt; reanuda la misma sesión, así que el marcador aguanta. Si un registro alguna vez se pone viejo en un uptime largo, esto le pone tope al radio de impacto en un día en lugar de confiar en&lt;br&gt;
que viva para siempre. &lt;code&gt;TimeoutStopSec=20&lt;/code&gt; mantiene cada reciclado rápido, ya que el CLI ignora &lt;code&gt;SIGTERM&lt;/code&gt; por ~90s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escritura de vuelta de credenciales.&lt;/strong&gt; La parte 1 señaló que el CLI rota su refresh token y la captura del secreto se pudre. Un helper chico empuja el token vivo de vuelta a Secrets Manager en cada corrida del watchdog, protegiendo con fuerza contra escribir un archivo local malformado encima de un secreto bueno, para que una instancia &lt;em&gt;reemplazada&lt;/em&gt; ya no arranque sin autenticar.&lt;/p&gt;
&lt;h2&gt;
  
  
  El health check al que engañó el mismo banner
&lt;/h2&gt;

&lt;p&gt;Aquí está la parte que arde. La primera versión del chequeo de readiness, la que se suponía atrapaba justo este estado de "arriba pero sin registrar", hacía esto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# MAL: hace match al banner de pre-registro, así que está verde para un proceso colgado&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; claude-remote-control &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"claude.ai/code"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claude.ai/code&lt;/code&gt; está en el banner que se imprime &lt;em&gt;antes&lt;/em&gt; del prompt en el que el proceso estaba colgado. El chequeo que construí para detectar la falla quedaba satisfecho por la falla. Es el bug original vestido con el disfraz de su propio arreglo.&lt;/p&gt;

&lt;p&gt;La sesión registrada imprime una línea distinta, una URL con un id de sesión o de environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Continue coding in the Claude mobile app or https://claude.ai/code/session_01Pp8…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El discriminador entre "anunciada" y "registrada" es un solo carácter: lo que sigue a &lt;code&gt;code&lt;/code&gt;. El banner dice &lt;code&gt;code in a browser&lt;/code&gt;; la cosa real dice &lt;code&gt;code/session_…&lt;/code&gt; o &lt;code&gt;code?environment=…&lt;/code&gt;. Así que haz match a eso:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# solo una sesión REGISTRADA imprime una URL con '/' o '?' después de 'code'&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; claude-remote-control &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'claude\.ai/code[/?]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con el chequeo corregido el heartbeat significa lo que dice: active, lo bastante joven para haberse registrado, y una URL de registro en su propio log.&lt;br&gt;
Cualquier otra cosa degrada el latido a &lt;code&gt;DEGRADED&lt;/code&gt; y reinicia, y &lt;code&gt;DEGRADED&lt;/code&gt; no hace match al filtro &lt;code&gt;OK&lt;/code&gt; de la parte 1, así que un estado que no se va a auto-sanar de todos modos le suena a la alarma existente.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callejones sin salida (los dos el mismo error)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checar por una conexión de red abierta.&lt;/strong&gt; La sonda de readiness intuitiva:
seguramente una sesión registrada sostiene un socket al punto de encuentro.
Medido en la caja sana: cero sockets establecidos. Una sesión registrada
inactiva no mantiene ninguno. El chequeo reiniciaría una caja &lt;em&gt;funcionando&lt;/em&gt;
en cada tick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep del banner de arranque.&lt;/strong&gt; Cubierto arriba. Verde antes de que el
trabajo esté hecho.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Los dos fallan de la misma manera: afirman sobre algo que es cierto &lt;em&gt;antes&lt;/em&gt; de&lt;br&gt;
que pase la cosa real. Un socket que quizá todavía no esté ahí; un banner que&lt;br&gt;
está ahí demasiado pronto. El arreglo en los dos casos es afirmar sobre el&lt;br&gt;
artefacto que existe &lt;em&gt;solo después del éxito&lt;/em&gt;: la línea &lt;code&gt;Registered&lt;/code&gt;, la URL&lt;br&gt;
con un id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Un banner es un menú, no un recibo.&lt;/strong&gt; Cualquier mensaje de "ya puedes
hacer X" impreso al arrancar es una intención, no una confirmación. Los
health checks tienen que indexarse en el artefacto que existe solo una vez
que X de verdad pasó.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reiniciar hasta que reaparezca el mensaje feliz no es verificación.&lt;/strong&gt; Vi
el banner, vi el heartbeat ponerse verde, y seguí adelante, dos veces. El
mensaje era el mismo las dos veces porque nunca dependió del éxito.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los prompts interactivos son minas en cajas sin atender.&lt;/strong&gt; Una sola
compuerta &lt;code&gt;(y/n)&lt;/code&gt; nueva, incontestable sin un TTY, convierte en silencio un
servicio que funciona en uno colgado. Dale la respuesta explícitamente; no
asumas que un servicio hereda la de tu terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las banderas de conveniencia tienen modos de falla.&lt;/strong&gt; &lt;code&gt;--continue&lt;/code&gt;
mantiene un marcador estable y falla duro cuando no hay nada que continuar.
En una caja que se puede reconstruir desde nada, "reanudar" necesita un "o
arranca de nuevo" al lado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cuando no hay API de estado, afirma sobre el log de éxito, el
específico.&lt;/strong&gt; No el banner optimista. La línea que solo el camino exitoso
puede producir.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>agents</category>
      <category>aws</category>
      <category>devops</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Arquitectura de 10 agentes, 1k users, presupuesto: dos pizzas al mes</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 16 Aug 2026 23:36:58 +0000</pubDate>
      <link>https://dev.to/aws-builders/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes-gjg</link>
      <guid>https://dev.to/aws-builders/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes-gjg</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ffiles.speakerdeck.com%2Fpresentations%2F5b4afa7a49aa43a89c24d86b3e3f905b%2Fslide_0.jpg%3F40294994" height="1080" class="m-0" width="1920"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes" rel="noopener noreferrer" class="c-link"&gt;
            Arquitectura de 10 agentes, 1k users, presupuesto: dos pizzas al mes - Speaker Deck
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Construir sistemas multi-agente no tiene que ser caro. En esta sesión comparto cómo diseñé una arquitectura con 10 agentes de IA sirviendo a más de 1,00&amp;amp;hellip;
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fd1eu30co0ohy4w.cloudfront.net%2Fassets%2Ffavicon-bdd5839d46040a50edf189174e6f7aacc8abb3aaecd56a4711cf00d820883f47.png" width="512" height="512"&gt;
          speakerdeck.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Este post es el acompañamiento de mi charla en el AWS Summit CDMX 2026 (DEV302). No es un truco de free tier ni una demo de juguete: son 8 o 9 decisiones de ingeniería, cada una medida, que convierten el camino default (unos 250 dólares al mes) en algo que cabe en dos pizzas. Voy a mostrarte los números y el código.&lt;/p&gt;

&lt;p&gt;Para que sea concreto uso un caso ficticio llamado &lt;strong&gt;Aula&lt;/strong&gt;, una plataforma de cursos en línea con unos 1,000 estudiantes. El conocimiento ya existe: dudas resueltas, material de cada lección, ejercicios. Diez agentes convierten ese conocimiento en respuestas fundamentadas en el historial. Aula no es un producto real, pero los números sí salen de un sistema real.&lt;/p&gt;

&lt;h2&gt;
  
  
  El caso: qué hacen los diez agentes
&lt;/h2&gt;

&lt;p&gt;Cinco flujos que ve el estudiante:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Una duda&lt;/strong&gt; ("no entiendo los closures"), que devuelve una explicación fundamentada en el historial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una entrega&lt;/strong&gt; (sube un ejercicio), que devuelve feedback editable, no la respuesta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qué sigue&lt;/strong&gt; ("¿qué estudio después?"), que devuelve la siguiente lección más un plan de 3 pasos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buscar&lt;/strong&gt; ("¿ya lo preguntaron?"), que devuelve dudas similares ya resueltas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resumen&lt;/strong&gt; de un hilo largo, que devuelve los puntos clave.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Diez agentes en total: classifier, extractor, retriever, enricher, synthesizer, drafter, summarizer, recommender, action_plan y quality_judge. Cada uno es una clase, no un framework. Ahí empieza la historia de costo.&lt;/p&gt;

&lt;h2&gt;
  
  
  El camino default cuesta 22 pizzas
&lt;/h2&gt;

&lt;p&gt;Si sigues el tutorial que la mayoría encuentra, armas esto: Amazon Bedrock Agents, AgentCore, Amazon OpenSearch Serverless, un NAT Gateway, un Application Load Balancer, Amazon RDS Proxy y AWS X-Ray. Suma unos 250 dólares al mes. Diecisiete pizzas solo de piso fijo, antes de la primera petición.&lt;/p&gt;

&lt;p&gt;El reto no es recortar. Recortar es fácil. El reto es sostener el mismo sistema, en producción, seguro, manejable y escalable, gastando lo que dos pizzas. Voy a cotizar cada decisión en pizzas que se comería de tu factura. Una rebanada son unos 1.75 dólares (unos 30 pesos). Una pizza son 8 rebanadas, unos 14 dólares. El tope: dos pizzas, 16 rebanadas, unos 28 dólares al mes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisión 1: un modelo por tarea (el 90/10)
&lt;/h2&gt;

&lt;p&gt;La palanca más grande, y la que no te vende nadie, es el tier por agente. No todos los pasos necesitan el modelo premium.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;7 agentes en &lt;strong&gt;Amazon Nova Lite&lt;/strong&gt; (clasificar, extraer, condensar, recomendar, evaluar).&lt;/li&gt;
&lt;li&gt;2 agentes en &lt;strong&gt;Amazon Nova Pro&lt;/strong&gt;, solo los que producen lo que LEE el estudiante (synthesizer y drafter).&lt;/li&gt;
&lt;li&gt;1 agente con &lt;strong&gt;Amazon Titan Embeddings&lt;/strong&gt; (el retriever, que no es un LLM de chat).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El resultado medido: el 90% de las llamadas al modelo van al tier económico, y el costo baja a unos 0.004 dólares por duda. Si todo corriera en Nova Pro, sería cerca de 10 veces más.&lt;/p&gt;

&lt;p&gt;¿Y la calidad? No es fe. Un shadow run midió que Nova Lite clasifica con un 98.5% de acuerdo contra Nova Pro en esa tarea. El tier se elige por dificultad, no por default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# El tier lo fija el agente, no el framework.
&lt;/span&gt;&lt;span class="n"&gt;MODEL_LITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amazon.nova-lite-v1:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# 7 agentes
&lt;/span&gt;&lt;span class="n"&gt;MODEL_PRO&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amazon.nova-pro-v1:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# 2 agentes: lo que LEE el estudiante
&lt;/span&gt;&lt;span class="n"&gt;MODEL_EMB&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amazon.titan-embed-text-v2:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# 1 agente: retrieval
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DrafterAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseAgent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drafter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MODEL_PRO&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Ver &lt;a href="//code/base_agent.py"&gt;&lt;code&gt;code/base_agent.py&lt;/code&gt;&lt;/a&gt; y &lt;a href="//code/tier_routing.py"&gt;&lt;code&gt;code/tier_routing.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decisión 2: mata el piso fijo (ingreso y red)
&lt;/h2&gt;

&lt;p&gt;Dos ausencias que casi nadie cuestiona, y que juntas valen casi 5 pizzas que no pago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amazon API Gateway, no Application Load Balancer.&lt;/strong&gt; El ALB cuesta unos 16 dólares al mes fijos, pizza y media, llegue 1 persona o un millón. Amazon API Gateway HTTP API cobra por request: a 1,000 usuarios con tráfico modesto, cerca de 1.5 dólares al mes. Llega al contenedor por VPC Link y service discovery, sin ALB de por medio. ¿Cuándo sí ALB? Routing L7 sofisticado, WebSockets, o miles de peticiones por segundo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS Fargate en subnet pública, no NAT Gateway.&lt;/strong&gt; El NAT cuesta unos 33 dólares al mes fijos (3 pizzas) aunque el tráfico sea cero. Lo mío: subnet pública con IP pública y un security group que solo deja entrar al VPC Link. La IP es pública pero funcionalmente inalcanzable. Cuidado con el consejo de "usa VPC endpoints": 4 o 5 endpoints a 7 dólares cada uno salen más caros que el NAT. Valen por compliance, no por ahorro.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decisión 3: usa lo que ya pagas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;pgvector, no Amazon OpenSearch Service.&lt;/strong&gt; A escala de miles de embeddings, pgvector basta y sobra. El menú arranca en OpenSearch a 174 dólares o más, entre 8 y 15 veces mi presupuesto, en una sola línea. Lo mío: &lt;code&gt;CREATE EXTENSION vector&lt;/code&gt; en la misma Amazon RDS que ya pago. La pregunta correcta no es si pgvector es tan bueno, es si necesito lo que OpenSearch hace de más. A 3,000 vectores, no.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- El retriever: distancia coseno con índice HNSW, prefiltrando por curso.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;contenido&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolucion&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dudas_embedding&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;curso_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;curso&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Tus filas ya son la memoria.&lt;/strong&gt; AgentCore Memory ofrece memoria semántica gestionada. Pero la memoria de Aula, qué respuestas sirvieron por tema, ya está en filas de Postgres. Leer 20 filas, agruparlas y meterlas al prompt como un bloque de historial son unas 120 líneas y 50 milisegundos. No gana por económico, gana por simplicidad y por no necesitar código nuevo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workers asyncio, no AWS Lambda por tarea.&lt;/strong&gt; Los trabajos programados (reindex, resumen semanal, barrido de evaluaciones, recordatorios) corren como tareas asyncio dentro del contenedor de AWS Fargate que ya está prendido para la API. El break point honesto: con más de una réplica, un cron in process dispara doble, y ahí sí subes a Amazon EventBridge Scheduler apuntando a ECS RunTask (cerca de 0 dólares). Managed cuando el patrón lo justifica, no por default.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decisión 4: observabilidad por log, no por traza
&lt;/h2&gt;

&lt;p&gt;Latencia, tokens y costo por agente salen de &lt;strong&gt;una línea de log en formato EMF&lt;/strong&gt; (Embedded Metric Format), que Amazon CloudWatch convierte en métrica sin ninguna llamada a la API en el hot path. AWS X-Ray a bajo volumen es casi gratis (unos 0.40 dólares), así que aquí el argumento no es el costo, es el acoplamiento: EMF no necesita SDK ni escala por traza, y tú decides qué métricas existen.&lt;/p&gt;

&lt;p&gt;La trampa que sí cuesta dinero: meter un ID de alta cardinalidad (como &lt;code&gt;student_id&lt;/code&gt;) como dimensión son miles de métricas custom, cientos de dólares al mes. Los IDs van al cuerpo del log, nunca a la dimensión.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1 línea EMF -&amp;gt; métrica en CloudWatch, sin PutMetricData.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;emit_emf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cost_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_aws&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CloudWatchMetrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Namespace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aula/Agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dimensions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AgentName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ModelUsed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LatencyMs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CostUsd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="p"&gt;}]},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AgentName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ModelUsed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LatencyMs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CostUsd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cost_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Ver &lt;a href="//code/emf.py"&gt;&lt;code&gt;code/emf.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decisión 5: seguridad y resiliencia sin inflar la factura
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Seguridad.&lt;/strong&gt; El input del usuario entra a un LLM, así que va como dato delimitado, no como instrucción. Salida estructurada más el juez como respaldo. IAM al mínimo: &lt;code&gt;bedrock:InvokeModel&lt;/code&gt; solo sobre los ARNs de mis 2 modelos, no un comodín. Guardrails gestionados solo a escala (PII, toxicidad) o por compliance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resiliencia.&lt;/strong&gt; El estudiante espera unos 2 segundos. Amazon Bedrock puede responder 429 en picos. La respuesta: retry con backoff acotado por el presupuesto de latencia (2 intentos máximo) y fallback de tier. Si Nova Pro está throttled, degrada a Nova Lite con la confianza más baja. Degrada, no revienta. Provisioned Throughput solo si el 429 es crónico, no para un pico.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invoke_resilient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL_PRO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL_LITE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;budget_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ThrottlingException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;# sigue throttled: degrada, no revienta
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence_penalty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Ver &lt;a href="//code/resilience.py"&gt;&lt;code&gt;code/resilience.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dónde se va el costo, de verdad
&lt;/h2&gt;

&lt;p&gt;Aquí está lo bonito de medir con EMF: puedes ver dónde vive cada dólar sin instrumentar nada extra.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concepto&lt;/th&gt;
&lt;th&gt;Rebanadas&lt;/th&gt;
&lt;th&gt;Costo aprox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Fargate ×2, 24/7&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;22 dólares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Bedrock (10 agentes)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3 dólares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EMF (métricas por log)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2 dólares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon API Gateway&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1 dólar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~28 dólares&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El 72% de la factura son los dos contenedores de AWS Fargate prendidos todo el tiempo. Todo lo demás junto son 5 rebanadas. La lección: &lt;strong&gt;el costo fijo que no usas es tu mayor fuga&lt;/strong&gt;, y aquí ya está exprimido.&lt;/p&gt;
&lt;h2&gt;
  
  
  La parte honesta
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Redondeo a 2 pizzas, pero son unos 2 y cuarto. El redondeo lo absorbe.&lt;/li&gt;
&lt;li&gt;Las cifras del core (AWS Fargate, Amazon Bedrock, EMF) son medidas. Las de tool use en Aula son estimadas.&lt;/li&gt;
&lt;li&gt;El costo real de este sistema no es dinero, es tiempo de ingeniería y seguridad. Medir cada decisión toma más trabajo que aceptar el default.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Lo contraintuitivo: baja al crecer
&lt;/h2&gt;

&lt;p&gt;Costo por estudiante al mes, a 1,000 estudiantes: unos 2 centavos.&lt;/p&gt;

&lt;p&gt;Como la factura es costo fijo (los contenedores no escalan con el uso), se reparte. Más estudiantes, menos por cabeza. Es lo opuesto a una arquitectura lineal por request. A 10 usuarios sale carísimo por cabeza; a 1,000 sale a 2 centavos, y sigue bajando.&lt;/p&gt;
&lt;h2&gt;
  
  
  Para llevar
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;El piso fijo que no usas es tu mayor fuga: NAT, ALB, OCU.&lt;/li&gt;
&lt;li&gt;Usa lo que ya pagas: pgvector, asyncio, filas como memoria.&lt;/li&gt;
&lt;li&gt;Haz coincidir el tier del modelo con la dificultad de la tarea (el 90/10).&lt;/li&gt;
&lt;li&gt;El patrón de uso elige la herramienta, no el default.&lt;/li&gt;
&lt;li&gt;La pregunta correcta no es "¿uso managed?", es "¿qué NO quiero mantener?".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Las slides, el código y las notas están en el repositorio. Si vas a construir agentes sobre Amazon Bedrock y el presupuesto importa, empieza por medir dónde se va cada dólar. Casi siempre es una sola cosa.&lt;/p&gt;

&lt;p&gt;Demo: &lt;a href="https://awssummitcdmx2026.elches.co/" rel="noopener noreferrer"&gt;https://awssummitcdmx2026.elches.co/&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/diez-agentes-dos-pizzas" rel="noopener noreferrer"&gt;
        diez-agentes-dos-pizzas
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Diez agentes en producción sobre Amazon Bedrock por el costo de dos pizzas al mes. Código de acompañamiento (AWS Summit CDMX 2026, DEV302).
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Diez agentes en producción por el costo de dos pizzas al mes&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Código de acompañamiento de la charla &lt;strong&gt;AWS Summit CDMX 2026 (DEV302)&lt;/strong&gt;: un sistema multi agente en producción sobre Amazon Bedrock, con Q&amp;amp;A, feedback de ejercicios, recomendaciones, búsqueda y resumen, para unos 1,000 usuarios, por unos &lt;strong&gt;28 dólares al mes&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Post: &lt;a href="https://dev.to/aws-builders/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes-gjg" rel="nofollow"&gt;Arquitectura de 10 agentes, 1k users, presupuesto: dos pizzas al mes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Slides: &lt;a href="https://speakerdeck.com/elchesco/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes" rel="nofollow noopener noreferrer"&gt;Speaker Deck&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;El caso es &lt;strong&gt;ficticio&lt;/strong&gt;: &lt;em&gt;Aula&lt;/em&gt;, una plataforma de cursos en línea con ~1,000 estudiantes. No es un producto real. Los números (latencia, costo por duda, picos de conexiones) salen de un sistema real. Los snippets son &lt;strong&gt;ilustrativos&lt;/strong&gt;: muestran cada decisión de costo, no son un proyecto ejecutable completo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Las decisiones, en código&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Cada archivo es una decisión de la charla. Los marcados con 🔒 estaban en slides que oculté por tiempo, pero el código va aquí completo.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Archivo&lt;/th&gt;

&lt;th&gt;Decisión&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;/table&gt;&lt;/div&gt;…&lt;p&gt;&lt;/p&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/diez-agentes-dos-pizzas" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;Gracias por leer. Preguntas y correcciones son bienvenidas.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>aws</category>
    </item>
    <item>
      <title>¿Cómo revertir una migración promovida a Aurora sin perder los datos nuevos?</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 25 Jul 2026 22:56:24 +0000</pubDate>
      <link>https://dev.to/aws-builders/como-revertir-una-migracion-promovida-a-aurora-sin-perder-los-datos-nuevos-3gcn</link>
      <guid>https://dev.to/aws-builders/como-revertir-una-migracion-promovida-a-aurora-sin-perder-los-datos-nuevos-3gcn</guid>
      <description>&lt;p&gt;Migraste una base de datos MySQL de Amazon RDS a Amazon Aurora. La read replica de Aurora se puso al día, la promoviste, cambiaste el endpoint de la app, y ahora los writes aterrizan en Aurora. &lt;/p&gt;

&lt;p&gt;Diez minutos después, o quizá tres horas después, algo anda mal y quieres regresar a la vieja instancia de RDS.&lt;/p&gt;

&lt;p&gt;Aquí está la trampa. La vieja instancia de RDS está congelada en el momento en que hiciste el corte. Cada pedido, registro y edición que pegó en Aurora desde entonces no existe en RDS. "Revertir" de forma ingenua significa "tirar a la basura todo lo que pasó en la base de datos nueva". Y el arreglo de siempre, tomar una ventana de mantenimiento y recargar RDS desde Aurora, no escala: a 100 TB una recarga se mide en días, no en minutos.&lt;/p&gt;

&lt;p&gt;Esta es la historia de construir una reversión que conserva los datos&lt;br&gt;
nuevos, probarla en AWS real hasta que se rompió de maneras interesantes, y reducirla a un manual de operaciones que una persona cansada pueda seguir a las 3 de la mañana. Todo lo de abajo lo corrí en instancias desechables en una región aislada y las borré después. &lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;La vieja instancia de RDS no debería ser un backup congelado. Hazla una &lt;strong&gt;réplica inversa viva&lt;/strong&gt; de Aurora: después de promover Aurora, apunta RDS de vuelta a Aurora sobre replicación por binary-log para que siga tragándose cada write nuevo. La reversión es entonces "déjala terminar, luego cambia el endpoint".&lt;/li&gt;
&lt;li&gt;Con la réplica inversa mantenida caliente, &lt;strong&gt;el tiempo de reversión es constante e independiente del tamaño de la base de datos&lt;/strong&gt;: como 1 a 5 minutos a 1 GB y a 100 TB, porque haces el corte del delta, no de la base de datos.&lt;/li&gt;
&lt;li&gt;La única compuerta que importa es &lt;code&gt;Seconds_Behind_Source = 0&lt;/code&gt;. Llegar ahí significa que RDS tiene el 100% de los writes de la era Aurora. Hacer el corte antes pierde datos.&lt;/li&gt;
&lt;li&gt;Dos rutas funcionan: replicación nativa por binlog (recomendada, barata, replica DDL) y AWS DMS (más manejada por consola, pero ignora en silencio &lt;code&gt;DROP TABLE&lt;/code&gt; y unos cuantos ALTER). Las dos las validé en vivo.&lt;/li&gt;
&lt;li&gt;Un "standby frío vacío" (una instancia lista pero sin datos) cuesta lo &lt;strong&gt;mismo por hora&lt;/strong&gt; que una réplica caliente y te da la velocidad de reversión de no tener nada. Si pagas por mantener una caja corriendo, mantenla caliente.&lt;/li&gt;
&lt;li&gt;Los filos que costaron tiempo real de debug: una regla de security group que falla en silencio, una edición de parameter group que es todo-o-nada, un stored procedure cuyo conteo de argumentos no es lo que los docs implican, y una regla de compatibilidad de versión que bloquea la migración de plano.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  La forma del problema
&lt;/h2&gt;

&lt;p&gt;La migración hacia adelante de RDS a Aurora es una cosa resuelta de un solo botón: creas una read replica de Aurora desde la instancia de RDS, esperas, promueves. AWS hace el snapshot y convierte por ti. Esa parte la tomo como dada.&lt;/p&gt;

&lt;p&gt;La dirección interesante es hacia atrás. Una vez que Aurora es primaria y toma writes, la vieja instancia de RDS se va quedando más desactualizada cada segundo. Una reversión que importa tiene que responder una pregunta: &lt;strong&gt;¿a dónde van los writes de la era Aurora?&lt;/strong&gt; Si la respuesta es "a ningún lado, los perdemos", no es una reversión, es un desastre con pasos extra.&lt;/p&gt;

&lt;p&gt;La respuesta que escala es invertir la replicación. Cuando RDS era la fuente y Aurora la réplica, los datos fluían de RDS a Aurora. Después de la promoción lo configuras al revés: Aurora es la fuente, RDS es la réplica.&lt;br&gt;
Ahora RDS jala de forma continua todo lo que Aurora escribe. La vieja&lt;br&gt;
instancia no está congelada, es un espejo caliente que siempre va un segundo o dos atrás de Aurora. Revertir se vuelve: detén Aurora, deja que RDS termine el último segundo, cambia el endpoint.&lt;/p&gt;

&lt;p&gt;La parte elegante es el costo de la sincronización inicial. En el instante de la promoción, RDS ya es byte por byte igual a Aurora, porque &lt;em&gt;era&lt;/em&gt; la fuente. Así que el enlace inverso no re-copia la base de datos. La siembras en la coordenada de la promoción y de ahí en adelante solo carga el delta.&lt;br&gt;
Por eso el tamaño de la base de datos deja de importar.&lt;/p&gt;
&lt;h2&gt;
  
  
  Construyéndolo, y viéndolo romperse
&lt;/h2&gt;

&lt;p&gt;Levanté el mundo post corte en instancias desechables: un clúster de Aurora MySQL como "prod nuevo", una instancia de RDS for MySQL como el objetivo de reversión, en una región aislada. Luego cableé la replicación inversa y la empujé por el camino feliz y por los caminos de falla.&lt;/p&gt;
&lt;h3&gt;
  
  
  Callejón sin salida 1: el security group que falla en silencio
&lt;/h3&gt;

&lt;p&gt;Primer intento de arrancar la replicación, la réplica nada más se quedó ahí sentada:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Replica_IO_Running: Connecting
Source_Server_Id: 0
Last_IO_Errno: 0
Last_IO_Error:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Connecting&lt;/code&gt;, para siempre. Sin número de error, sin texto de error. El hilo de IO estaba tratando de alcanzar Aurora y no recibía nada, pero nada se reportaba. La causa: las dos bases de datos eran públicamente accesibles (para que mi cliente de SQL las alcanzara), lo que significaba que la réplica resolvía el endpoint de Aurora a su IP &lt;strong&gt;pública&lt;/strong&gt;. La conexión de replicación entonces llegaba a Aurora desde la IP pública de la réplica, que el security group no permitía. Un drop silencioso, presentado como un esperanzado &lt;code&gt;Connecting&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;La lección no es "abre la IP pública". Es lo contrario. En producción, mantén las dos bases de datos privadas. Entonces el tráfico de réplica a fuente fluye sobre IPs privadas dentro de la VPC, y una sola regla de autorreferencia del security group lo cubre. La accesibilidad pública fue lo que creó el problema.&lt;/p&gt;

&lt;h3&gt;
  
  
  El camino feliz
&lt;/h3&gt;

&lt;p&gt;Con la red arreglada, la mecánica funcionó exactamente como se anunciaba.&lt;br&gt;
Escribe dos filas a Aurora, y en unos segundos aparecen en RDS. Detén el enlace, drena a cero lag, corre los dos procedimientos de reversión, y RDS se vuelve una primaria independiente que acepta writes mientras Aurora queda desacoplada. Cero datos perdidos. La secuencia completa está en el manual de operaciones al final.&lt;/p&gt;
&lt;h3&gt;
  
  
  Callejón sin salida 2: revertir demasiado pronto pierde exactamente lo que crees
&lt;/h3&gt;

&lt;p&gt;Todo el diseño descansa en una sola disciplina, así que me obligué a verla fallar. Detuve la réplica, escribí dos filas más a Aurora, y luego "reverti" sin esperar a que la réplica se pusiera al día.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Aurora: 7 filas.&lt;/li&gt;
&lt;li&gt;RDS después de revertir: 5 filas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Las dos filas escritas durante la ventana se fueron. Esta es toda la razón por la que &lt;code&gt;Seconds_Behind_Source = 0&lt;/code&gt; es una compuerta dura y no una sugerencia. La reversión es sin pérdidas solo si congelas los writes en la fuente y dejas que la réplica drene a cero &lt;em&gt;antes&lt;/em&gt; de hacer el corte.&lt;/p&gt;
&lt;h3&gt;
  
  
  Callejón sin salida 3: qué rompe de verdad la réplica
&lt;/h3&gt;

&lt;p&gt;Esperaba que los writes divergentes en el objetivo rompieran la replicación ruidosamente. No lo hicieron. Las read replicas de RDS traen por default &lt;code&gt;slave_exec_mode = IDEMPOTENT&lt;/code&gt;, que en silencio hace que la fuente gane un conflicto de primary key en lugar de dar error. Así que un write perdido en el objetivo de reversión se sobreescribe calladito, no se marca.&lt;/p&gt;

&lt;p&gt;Lo que &lt;em&gt;sí&lt;/em&gt; la rompe es el schema drift. Tiré una columna en el objetivo, luego escribí una fila en la fuente que usaba esa columna. El aplicador de SQL se detuvo en seco:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Last_SQL_Errno: 13146
Replica_SQL_Running: No
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La réplica se congeló y se quedó atrás en silencio, todavía reportando un hilo de IO sano. La conclusión operativa: alarma sobre que &lt;code&gt;Replica_SQL_Running&lt;/code&gt; voltee a &lt;code&gt;No&lt;/code&gt;, no solo sobre el lag. Y durante la ventana de standby caliente, trata el objetivo como estrictamente solo lectura y congela el DDL en la fuente.&lt;/p&gt;

&lt;h3&gt;
  
  
  Callejón sin salida 4: el conteo de argumentos que los docs implican está mal
&lt;/h3&gt;

&lt;p&gt;La variante GTID es la que quieres en producción, porque sobrevive a un failover de Aurora. Al configurarla, el stored procedure rechazó mi llamada:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt; &lt;span class="mi"&gt;1318&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Incorrect&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt;
&lt;span class="n"&gt;mysql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rds_set_external_master_with_auto_position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El procedimiento toma seis argumentos: host, port, user, password,&lt;br&gt;
&lt;code&gt;ssl_encryption&lt;/code&gt;, y &lt;code&gt;delay&lt;/code&gt;. Leyendo ejemplos que omiten el &lt;code&gt;delay&lt;/code&gt; final, es fácil pasar cinco. Trampa relacionada: el tunable se llama &lt;code&gt;gtid-mode&lt;/code&gt; con guion en el parameter group, mientras que &lt;code&gt;gtid_mode&lt;/code&gt; con guion bajo es una vista de runtime de solo-lectura que rechaza modificaciones.&lt;/p&gt;
&lt;h3&gt;
  
  
  Callejón sin salida 5: la edición del parameter group que se aplicó a medias
&lt;/h3&gt;

&lt;p&gt;Esta costó el más tiempo y produjo el síntoma más confuso. Después de&lt;br&gt;
habilitar GTID, la replicación no arrancaba:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Got fatal error 1236 from source when reading data from binary log:
'Binary log is not open'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Aurora, &lt;code&gt;@@gtid_mode&lt;/code&gt; era &lt;code&gt;ON&lt;/code&gt;, pero &lt;code&gt;@@log_bin&lt;/code&gt; era &lt;code&gt;0&lt;/code&gt;. El binary logging estaba apagado, así que no había nada que replicar. La causa: había puesto &lt;code&gt;binlog_format=ROW&lt;/code&gt; y dos parámetros de GTID en una sola edición del parameter group, y uno de los nombres de parámetro estaba mal. &lt;strong&gt;Un modify de parameter group es todo o nada.&lt;/strong&gt; Toda la edición se rechazó, lo que en silencio dejó &lt;code&gt;binlog_format&lt;/code&gt; en su default &lt;code&gt;OFF&lt;/code&gt;. Los parámetros de GTID, puestos en una llamada posterior exitosa, estaban bien, que es por lo que el&lt;br&gt;
estado se veía a medio arreglar.&lt;/p&gt;

&lt;p&gt;El arreglo: pon &lt;code&gt;binlog_format=ROW&lt;/code&gt;, reinicia el writer de Aurora, y&lt;br&gt;
verifica &lt;code&gt;@@log_bin = 1&lt;/code&gt; y un &lt;code&gt;SHOW MASTER STATUS&lt;/code&gt; no vacío &lt;em&gt;antes&lt;/em&gt; de cablear la réplica. Aurora necesita el reinicio para de verdad abrir el binary log.&lt;/p&gt;

&lt;p&gt;Una vez que eso quedó bien, el auto-posicionamiento de GTID funcionó de principio a fin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Auto_Position: 1
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 0
Retrieved_Gtid_Set: &amp;lt;aurora-uuid&amp;gt;:1-3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  La segunda ruta: AWS DMS
&lt;/h2&gt;

&lt;p&gt;El binlog nativo es la ruta recomendada, pero AWS DMS vale la pena conocerlo porque es casi por completo manejado por consola. Corrí una tarea de full load más CDC de Aurora a una segunda instancia de RDS. Funcionó, y es rápido:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Métrica&lt;/th&gt;
&lt;th&gt;Resultado&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full load, 2 tablas, 274 MB&lt;/td&gt;
&lt;td&gt;20.2 s (como 48 GB/h en la instancia más chica)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDC, un solo insert&lt;/td&gt;
&lt;td&gt;propagado en menos de 12 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pero DMS tiene un hueco que el binlog no. Creé una tabla en Aurora (se replicó bien), luego la tiré:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Aurora: tabla ida.&lt;/li&gt;
&lt;li&gt;Objetivo de DMS: tabla todavía ahí.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El CDC de DMS no replica &lt;code&gt;DROP TABLE&lt;/code&gt; ni &lt;code&gt;RENAME TABLE&lt;/code&gt;, e ignora unos cuantos ALTER. Sobre una ventana de reversión larga eso es schema drift silencioso. Usa DMS si quieres el flujo de consola y puedes vivir con la restricción; usa binlog si quieres que el DDL venga incluido.&lt;/p&gt;

&lt;h2&gt;
  
  
  Los números, y por qué el tamaño deja de importar
&lt;/h2&gt;

&lt;p&gt;El punto de todo el ejercicio es que el tiempo de reversión se desacopla del tamaño de la base de datos. Dos tablas cuentan la historia. La primera es la ejecución de la reversión cuando la réplica caliente se mantiene corriendo:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tamaño de la BD&lt;/th&gt;
&lt;th&gt;Tiempo de reversión (réplica caliente)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 GB&lt;/td&gt;
&lt;td&gt;~1 a 2 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 GB&lt;/td&gt;
&lt;td&gt;~1 a 3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 TB&lt;/td&gt;
&lt;td&gt;~1 a 5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 TB&lt;/td&gt;
&lt;td&gt;~1 a 5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Constante. La única variable es cuánto delta se apiló durante el incidente, y una instancia de clase producción drena eso a como 1 a 2 GB por minuto, así que hasta un backlog gordo de 10 GB se limpia en minutos de un solo dígito.&lt;/p&gt;

&lt;p&gt;La segunda tabla es lo que pasa si &lt;strong&gt;no&lt;/strong&gt; mantuviste la réplica caliente y tienes que reconstruir el fallback desde cero. Este es el camino a evitar:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tamaño de la BD&lt;/th&gt;
&lt;th&gt;Reconstruir vía DMS paralelo (~0.9 TB/h)&lt;/th&gt;
&lt;th&gt;Reconstruir vía dump + restore (~60 GB/h)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 GB&lt;/td&gt;
&lt;td&gt;~7 min&lt;/td&gt;
&lt;td&gt;~1.7 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 TB&lt;/td&gt;
&lt;td&gt;~1.1 h&lt;/td&gt;
&lt;td&gt;~17 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 TB&lt;/td&gt;
&lt;td&gt;~4 a 5 días&lt;/td&gt;
&lt;td&gt;inviable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 100 TB la diferencia es minutos contra días. Ese es todo el argumento para mantener la vieja instancia caliente.&lt;/p&gt;

&lt;p&gt;Un ancla medida para los números de reconstrucción: en una instancia de clase producción (32 vCPU, 256 GB) un insert lógico de un solo hilo corrió a como 1.2 GB por minuto, o 72 GB/h. El apply de binlog basado en filas y el restore lógico son del mismo orden de magnitud, que es sobre lo que están construidos los estimados de arriba. Las mediciones de instancia chica (un par de MB/s de apply de binlog, como 48 GB/h de full load de DMS) son pisos conservadores que escalan hacia arriba con el tamaño de la instancia.&lt;/p&gt;

&lt;h2&gt;
  
  
  La trampa del standby frío vacío
&lt;/h2&gt;

&lt;p&gt;Un punto medio tentador es mantener una instancia de RDS aprovisionada y corriendo pero vacía, lista para llenarse bajo demanda. Suena ahorrador. Es lo peor de los dos mundos.&lt;/p&gt;

&lt;p&gt;Una instancia vacía e inactiva cuesta lo &lt;strong&gt;mismo por hora&lt;/strong&gt; que una réplica caliente, porque pagas por el cómputo, no por los datos. Pero una instancia vacía no tiene línea base, así que revertir significa cargar la base de datos entera en ella primero. Ese es el camino de reconstrucción lineal con el tamaño: minutos a 100 GB, días a 100 TB. Pagas precio completo de standby por la velocidad de reversión de no tener nada. Peor, estarías recargando desde Aurora, la base de datos de la que estás tratando de escapar, así que si el gatillo fue corrupción la copias fielmente.&lt;/p&gt;

&lt;p&gt;Si estás pagando por mantener una caja corriendo como objetivo de reversión, mantenla caliente. Si de plano no puedes, la opción honesta es ningún standby y una reversión aceptada de varios días con pérdida alta, no una caja vacía que cuesta lo mismo que la buena opción.&lt;/p&gt;

&lt;h2&gt;
  
  
  El manual de operaciones
&lt;/h2&gt;

&lt;p&gt;Todo el asunto en una oración: la vieja instancia de RDS es una copia viva que ha estado tragándose cada write de Aurora desde el corte, así que revertir es detener Aurora, dejar que RDS termine los últimos writes, cambiar el endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configúralo una vez, cuando promuevas Aurora:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Agarra la coordenada de binlog de Aurora en el instante en que la app empieza a escribirle (&lt;code&gt;SHOW MASTER STATUS&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Apunta RDS a Aurora desde esa coordenada (&lt;code&gt;mysql.rds_set_external_master&lt;/code&gt; o la variante de auto-posición de GTID) y arranca la replicación.&lt;/li&gt;
&lt;li&gt;Confirma &lt;code&gt;Replica_IO_Running: Yes&lt;/code&gt; y &lt;code&gt;Replica_SQL_Running: Yes&lt;/code&gt;. Déjala corriendo. Alarma sobre que el hilo de SQL se detenga. No le escribas a RDS y no cambies el schema en Aurora mientras corre.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Revierte, cuando lo decidas:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detén que la app le escriba a Aurora.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET GLOBAL read_only = ON&lt;/code&gt; en Aurora, para que nada se cuele.&lt;/li&gt;
&lt;li&gt;Observa RDS hasta &lt;code&gt;Seconds_Behind_Source = 0&lt;/code&gt; con los dos hilos en &lt;code&gt;Yes&lt;/code&gt;. Esta es la compuerta. Significa que RDS ahora tiene cada write de la era Aurora. Si un hilo dice &lt;code&gt;No&lt;/code&gt;, párate y escala, no hagas el corte.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mysql.rds_stop_replication&lt;/code&gt; y luego &lt;code&gt;mysql.rds_reset_external_master&lt;/code&gt; en RDS.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET GLOBAL read_only = OFF&lt;/code&gt; en RDS.&lt;/li&gt;
&lt;li&gt;Apunta el endpoint de la app a RDS. Vuélvela a levantar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No hay deshacer después del paso 4. Una vez que el enlace se corta y RDS es escribible, las dos bases de datos son independientes. Que es exactamente por lo que el paso 3 no es opcional: es la garantía de que te estás llevando todos los datos de vuelta contigo.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>database</category>
      <category>infrastructure</category>
    </item>
  </channel>
</rss>
