<?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>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>
    <item>
      <title>Sesiones remotas siempre disponibles para tus agentes de codigo</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 25 Jul 2026 19:19:33 +0000</pubDate>
      <link>https://dev.to/aws-builders/sesiones-remotas-siempre-disponibles-para-tus-agentes-de-codigo-549f</link>
      <guid>https://dev.to/aws-builders/sesiones-remotas-siempre-disponibles-para-tus-agentes-de-codigo-549f</guid>
      <description>&lt;p&gt;Una sesión de agente que sobrevive a cerrar la laptop, corriendo en una sola instancia EC2 chica que manejas desde el teléfono. Este post cubre tanto Claude Code como Kiro CLI, porque resuelven el problema de "alcanzar la sesión desde otro lado" de maneras completamente distintas, y esa diferencia decide la mayor parte de tu arquitectura.&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;Versión ingenua&lt;/th&gt;
&lt;th&gt;Después&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Las credenciales viven en&lt;/td&gt;
&lt;td&gt;el volumen raíz de la instancia&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Secrets Manager&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sobrevive al reemplazo de la instancia&lt;/td&gt;
&lt;td&gt;no, re-login manual&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;sí, sin atender (~2 min)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiempo para notar una caja muerta&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6 días&lt;/strong&gt; (a mano)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;15 min&lt;/strong&gt; (alarma)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clone fallido al arrancar&lt;/td&gt;
&lt;td&gt;tragado por `\&lt;/td&gt;
&lt;td&gt;\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ciclo de vida acoplado a&lt;/td&gt;
&lt;td&gt;el stack de producción&lt;/td&gt;
&lt;td&gt;su propio stack&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El costo es una instancia ARM on-demand chica más un volumen gp3 de 30 GB: revisa el precio de lista actual de tu región, pero es la parte más barata de todo esto.&lt;/p&gt;

&lt;p&gt;Una instancia chica siempre encendida lo resuelve, con dos condiciones:&lt;br&gt;
puedes alcanzar la sesión desde donde estés, y la caja regresa por su&lt;br&gt;
cuenta cuando la infraestructura se mueve por debajo de ella. La segunda es de lo que este post trata en realidad.&lt;/p&gt;
&lt;h2&gt;
  
  
  Los dos modelos remotos
&lt;/h2&gt;

&lt;p&gt;Esta es la bifurcación del camino, así que hazla bien antes de construir nada.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code tiene una sesión remota de primera mano.&lt;/strong&gt; {% raw %}&lt;code&gt;claude&lt;br&gt;
remote-control&lt;/code&gt; registra la sesión en curso con un punto de encuentro&lt;br&gt;
hospedado, y la manejas desde &lt;code&gt;claude.ai/code&lt;/code&gt; o la app móvil. La caja hace una conexión &lt;strong&gt;de salida&lt;/strong&gt; nada más:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;--continue&lt;/code&gt; reanuda la misma sesión a través de reinicios del proceso, así que un enlace en marcadores se queda estable cuando el servicio rebota.&lt;br&gt;
Esto necesita un CLI reciente: la caja de este post corría la 2.1.211. Al tener éxito imprime:&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. Use your other devices as a remote control. Press Ctrl+C to stop.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La consecuencia de seguridad es grande: &lt;strong&gt;sin puertos de entrada, sin SSH, sin listener público.&lt;/strong&gt; El security group puede ser solo de salida.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kiro CLI no tiene equivalente.&lt;/strong&gt; Sus docs describen dos modos, ninguno de los cuales es una sesión remota:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Headless, para CI: &lt;code&gt;kiro-cli chat --no-interactive "prompt"&lt;/code&gt;, autenticado por &lt;code&gt;KIRO_API_KEY&lt;/code&gt;. Los docs son explícitos en que "No es posible input del usuario a media sesión": un solo prompt, de principio a fin, sin reanudar.&lt;/li&gt;
&lt;li&gt;Interactivo con persistencia local: las sesiones se auto-guardan por directorio y regresan con &lt;code&gt;kiro-cli chat --resume&lt;/code&gt; (también &lt;code&gt;--resume-picker&lt;/code&gt;, &lt;code&gt;--resume-id &amp;lt;ID&amp;gt;&lt;/code&gt;, &lt;code&gt;--list-sessions&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Así que para Kiro la sesión siempre encendida es algo que &lt;em&gt;tú&lt;/em&gt; construyes:&lt;br&gt;
mantén el proceso vivo en un multiplexor de terminal, y conéctate a él por SSM.&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;# en la caja, como el usuario de la app&lt;/span&gt;
tmux new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; agent &lt;span class="s1"&gt;'kiro-cli chat'&lt;/span&gt;

&lt;span class="c"&gt;# desde donde sea&lt;/span&gt;
aws ssm start-session &lt;span class="nt"&gt;--target&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-iu&lt;/span&gt; appuser tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;--resume&lt;/code&gt; es la red de seguridad más que el mecanismo: si el proceso se muere, la conversación sigue en disco, indexada por el directorio de trabajo.&lt;/p&gt;

&lt;p&gt;Lectura práctica: en un teléfono, Claude Code gana de calle, una pestaña de navegador le gana a un shell de SSM móvil conectándose a tmux. El modelo de Kiro le queda mejor a una laptop o tablet con una terminal de verdad. El resto de este post aplica a los dos, porque las partes difíciles (credenciales, recuperación, monitoreo) son idénticas.&lt;/p&gt;
&lt;h3&gt;
  
  
  Construir el lado de Claude Code
&lt;/h3&gt;

&lt;p&gt;Una unidad de systemd, y el proceso mismo es la sesión. Como la conexión es de salida, no se requiere nada más.&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;/home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La salud es lo que sea que diga systemd: &lt;code&gt;systemctl is-active&lt;/code&gt;. Esa es toda la integración.&lt;/p&gt;
&lt;h3&gt;
  
  
  Construir el lado de Kiro
&lt;/h3&gt;

&lt;p&gt;Kiro necesita que lo durable sea la &lt;em&gt;terminal&lt;/em&gt;, no el proceso del agente, porque no hay sesión que re-registrar. Corre tmux bajo systemd y deja que el agente viva adentro:&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="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Kiro agent session (tmux)&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;forking&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/appuser/myapp&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PATH=/home/appuser/.local/bin:/usr/local/bin:/usr/bin:/bin&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/tmux new-session -d -s agent 'kiro-cli chat --resume'&lt;/span&gt;
&lt;span class="py"&gt;ExecStop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/tmux kill-session -t agent&lt;/span&gt;
&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;--resume&lt;/code&gt; en &lt;code&gt;ExecStart&lt;/code&gt; es deliberado: si la caja reinicia, la sesión nueva de tmux se reconecta a la conversación que ya está en disco para ese directorio en lugar de arrancar en frío.&lt;/p&gt;

&lt;p&gt;Conéctate desde donde sea por SSM, todavía sin puertos de entrada:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ssm start-session &lt;span class="nt"&gt;--target&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-iu&lt;/span&gt; appuser tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La salud significa algo distinto aquí, y este es el único lugar donde los dos caminos de verdad divergen. &lt;code&gt;systemctl is-active&lt;/code&gt; sobre una unidad tmux de tipo forking te dice que tmux está vivo, no que el agente adentro lo esté. Pregúntale a tmux directo:&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="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; appuser tmux has-session &lt;span class="nt"&gt;-t&lt;/span&gt; agent 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;PANES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; appuser tmux list-panes &lt;span class="nt"&gt;-t&lt;/span&gt; agent &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'#{pane_dead}'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'^0$'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PANES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; heartbeat OK &lt;span class="s2"&gt;"tmux:&lt;/span&gt;&lt;span class="nv"&gt;$PANES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"tmux session has no live pane"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;fail &lt;span class="s2"&gt;"tmux session missing"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;#{pane_dead}&lt;/code&gt; es el detalle que vale la pena guardar. Una sesión de tmux cuyo único panel ya salió sigue respondiendo &lt;code&gt;has-session&lt;/code&gt; con éxito, así que el chequeo ingenuo reporta sano una sesión con un agente muerto adentro, la misma clase de falso negativo que el timer inerte de más adelante.&lt;/p&gt;

&lt;p&gt;La historia de credenciales de Kiro es más simple en un aspecto: la auth headless es una sola API key en &lt;code&gt;KIRO_API_KEY&lt;/code&gt;, sin flujo de navegador y sin expiración de sesión, que cae directo en el mismo patrón de Secrets Manager. Vale la pena tenerla en la caja aunque manejes de forma interactiva, porque hace útil la caja para one-shots por script:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kiro-cli chat &lt;span class="nt"&gt;--no-interactive&lt;/span&gt; &lt;span class="nt"&gt;--trust-all-tools&lt;/span&gt; &lt;span class="s2"&gt;"run the test suite and summarise failures"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Sé deliberado con &lt;code&gt;--trust-all-tools&lt;/code&gt; en una máquina sin atender:&lt;br&gt;
auto-aprueba cada tool call. &lt;code&gt;--trust-tools=read,grep&lt;/code&gt; es el default más seguro para cualquier cosa agendada.&lt;/p&gt;

&lt;p&gt;Si de plano prefieres no construir nada de esto para Kiro, AWS publica una &lt;a href="https://aws-samples.github.io/sample-one-click-generative-ai-solutions/en/solutions/kiro-ide/" rel="noopener noreferrer"&gt;muestra Kiro IDE Remote de un clic&lt;/a&gt; que pone el IDE completo en un escritorio remoto alcanzable desde un navegador, con Kiro CLI y el AWS CLI preinstalados. Más pesada que una instancia chica, y un perfil de costo distinto, pero se salta el armado.&lt;/p&gt;
&lt;h2&gt;
  
  
  El punto de partida, y qué tenía de malo
&lt;/h2&gt;

&lt;p&gt;La caja original era una instancia EC2 definida dentro del stack del&lt;br&gt;
backend de producción. El user-data instalaba el toolchain, clonaba el repo, escribía una unidad de systemd, y la dejaba &lt;strong&gt;deshabilitada&lt;/strong&gt; a propósito: arrancar un agente sin autenticar nada más entra en crash-loop.&lt;br&gt;
Un humano luego se metía por SSM una vez, iniciaba sesión de forma&lt;br&gt;
interactiva, y habilitaba el servicio.&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;// Dentro del stack del backend de producción. Tres errores separados.&lt;/span&gt;
&lt;span class="nx"&gt;userData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCommands&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sudo -u appuser bash -lc 'curl -fsSL https://example-agent-installer.sh | bash' || true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`sudo -u appuser bash -lc 'test -d ~/myapp || git clone https://github.com/acme/myapp.git ~/myapp' || true`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...archivo de unidad escrito aquí, a propósito sin habilitar...&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;systemctl daemon-reload&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;Eso funcionó por meses. Luego se movió el AMI.&lt;/p&gt;

&lt;p&gt;La instancia usaba &lt;code&gt;MachineImage.fromSsmParameter(...)&lt;/code&gt; para seguir la imagen actual de Ubuntu 24.04. Cuando ese parámetro resolvió un AMI más nuevo, CloudFormation vio cambiar el &lt;code&gt;ImageId&lt;/code&gt;, lo cual es un &lt;strong&gt;reemplazo&lt;/strong&gt;, no una actualización. Instancia nueva, volumen nuevo, y todo lo del volumen raíz viejo se fue.&lt;/p&gt;

&lt;p&gt;El reemplazo arrancó y se veía bien. No lo estaba:&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;+ sudo -u ubuntu bash -lc 'test -d ~/myapp || git clone https://github.com/acme/myapp.git ~/myapp'
Cloning into '/home/ubuntu/myapp'...
fatal: could not read Username for 'https://github.com': No such device or address
+ true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Un repo &lt;strong&gt;privado&lt;/strong&gt;, clonado sobre https pelón, sin credenciales. &lt;code&gt;|| true&lt;/code&gt;&lt;br&gt;
se lo tragó, cloud-init reportó un arranque limpio, y la falla era&lt;br&gt;
invisible a menos que alguien leyera el log.&lt;/p&gt;

&lt;p&gt;Las consecuencias se encadenaron. Sin repo, el &lt;code&gt;WorkingDirectory&lt;/code&gt; de la unidad no existía, así que el servicio no podía arrancar aunque estuviera habilitado. No estaba habilitado, porque eso es un paso manual. Y el login del agente vivía solo en el volumen que se acababa de destruir:&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 status agent-remote-control
&lt;span class="gp"&gt;   Loaded: loaded (/etc/systemd/system/agent-remote-control.service;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;disabled&lt;span class="p"&gt;;&lt;/span&gt; preset: enabled&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;   Active: inactive (dead)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Seis días después alguien fue a usarla.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tres fallas independientes, una causa raíz: cada camino de recuperación requería un humano, y nada decía que se necesitaba un humano.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Ronda 1: las credenciales van en un almacén de secretos, no en un volumen
&lt;/h2&gt;

&lt;p&gt;Si la caja no puede re-autenticarse sola, cada reemplazo es un outage. Dos secretos, sembrados una vez por cuenta en lugar de por instancia:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/myapp/agent-box-github-token        -&amp;gt; string de token pelón
/myapp/agent-box-agent-credentials   -&amp;gt; blob JSON de credenciales
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El script de bootstrap jala los dos, autentica, clona, y habilita el&lt;br&gt;
servicio. Cada paso es idempotente, así que es seguro correrlo en cada arranque y en un timer.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;log &lt;span class="s2"&gt;"1/5 GitHub auth"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;gh auth status &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"    already authenticated"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;GH_TOKEN_VALUE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;secret &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GH_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&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;$GH_TOKEN_VALUE&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; fail &lt;span class="s2"&gt;"could not read &lt;/span&gt;&lt;span class="nv"&gt;$GH_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GH_TOKEN_VALUE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | gh auth login &lt;span class="nt"&gt;--with-token&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"gh auth login failed"&lt;/span&gt;
  &lt;span class="nb"&gt;unset &lt;/span&gt;GH_TOKEN_VALUE
&lt;span class="k"&gt;fi
&lt;/span&gt;gh auth setup-git &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&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;p&gt;&lt;strong&gt;El script no se puede descargar.&lt;/strong&gt; La jugada obvia es hacerle &lt;code&gt;curl&lt;/code&gt; desde el repo en el user-data. Ese repo es privado, y el token que autorizaría la descarga es lo que el script instala. Circular. En su lugar, incrusta el script en el user-data: léelo en tiempo de synth y mételo en base64.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;El alcance de IAM es exactamente dos ARNs.&lt;/strong&gt; La caja obtiene &lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt; sobre sus propios secretos de bootstrap y nada más. No tiene por qué leer el password de la base de datos.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;role&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;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-agent-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;h3&gt;
  
  
  Trampa 1: el blob de credenciales no es solo el token
&lt;/h3&gt;

&lt;p&gt;Restaurar solo el token OAuth producía un servicio que arrancaba y se moría de inmediato:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La funcionalidad de remote-control necesita contexto de cuenta, los&lt;br&gt;
identificadores de la cuenta y de la organización, junto con el token.&lt;br&gt;
Incómodamente, en macOS estos viven en dos lugares distintos: el token en el keychain de login, el objeto de cuenta en el propio archivo de config del CLI. Una restauración que lee solo uno de los dos se ve completa y falla en runtime.&lt;/p&gt;

&lt;p&gt;Guarda los dos en un secreto y escríbelos a sus dos destinos en 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;if&lt;/span&gt; &lt;span class="n"&gt;sec&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oauthAccount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oauthAccount&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="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oauthAccount&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;sec&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizationUuid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizationUuid&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="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizationUuid&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;h3&gt;
  
  
  Trampa 2: el diálogo de trust headless
&lt;/h3&gt;

&lt;p&gt;Un CLI de agente pregunta si debería confiar en un workspace en el primer uso dentro de un directorio. Un servicio de systemd no tiene TTY y no puede contestar, así que entra en crash-loop en el prompt. Pre-configura la bandera:&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;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects&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="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasTrustDialogAccepted&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="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasCompletedProjectOnboarding&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="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasCompletedOnboarding&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="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;En esta es fácil perder una hora, porque el texto del error es sobre trust y no dice nada de TTYs.&lt;/p&gt;
&lt;h3&gt;
  
  
  Trampa 3: un heredoc se come tu pipe en silencio
&lt;/h3&gt;

&lt;p&gt;La primera versión del paso de contexto de cuenta hacía pipe de un secreto hacia Python mientras también usaba un heredoc para el script:&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;# ROTO&lt;/span&gt;
secret &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | python3 - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WORKDIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;TPY&lt;/span&gt;&lt;span class="sh"&gt;'
sec = json.load(sys.stdin)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El heredoc &lt;em&gt;es&lt;/em&gt; stdin. El pipe se descarta, y Python lee su propio código fuente como el payload JSON:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Escribe el secreto a un archivo &lt;code&gt;mktemp&lt;/code&gt; con &lt;code&gt;umask 077&lt;/code&gt; y pasa la ruta como argumento.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ronda 2: mantenerlo corriendo
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Restart=always&lt;/code&gt; maneja el caso fácil: el proceso crasheó, arráncalo de nuevo.&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="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Agent remote-control session&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/appuser/myapp&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PATH=/home/appuser/.local/bin:/usr/local/bin:/usr/bin:/bin&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;StartLimitIntervalSec=0&lt;/code&gt; importa más de lo que parece. Por default systemd se rinde después de una ráfaga de fallas rápidas y estaciona la unidad en &lt;code&gt;failed&lt;/code&gt; &lt;strong&gt;permanentemente&lt;/strong&gt;. En una caja sin atender eso convierte un problema de red pasajero en un outage que dura hasta que alguien se da cuenta, que es justo el modo de falla que estamos diseñando para que no pase.&lt;/p&gt;

&lt;p&gt;Pero &lt;code&gt;Restart=always&lt;/code&gt; no puede ayudar con los estados que de verdad mataron esta caja: credenciales faltantes, un clone borrado, una unidad dejada deshabilitada. Esos necesitan algo fuera de la unidad. Como el script de bootstrap es idempotente, el watchdog es nada más el bootstrap en un timer:&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="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Watchdog for the agent session&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnBootSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*:0/5&lt;/span&gt;
&lt;span class="py"&gt;AccuracySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30s&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Trampa 4: &lt;code&gt;OnUnitActiveSec&lt;/code&gt; en una unidad que nunca ha corrido
&lt;/h3&gt;

&lt;p&gt;El primer timer usaba &lt;code&gt;OnUnitActiveSec=5min&lt;/code&gt;. Se instaló limpio, reportó habilitado, y nunca disparó: ese setting es relativo a la última activación de la unidad, y una unidad que nunca ha corrido no tiene siguiente disparo:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEXT  LEFT  LAST                          PASSED     UNIT
-     -     Wed 2026-07-22 16:43:57 UTC   13ms ago   agent-bootstrap.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;NEXT&lt;/code&gt; es &lt;code&gt;-&lt;/code&gt;. Un watchdog que está inerte en silencio es peor que ninguno, porque se lee como cubierto. Usa &lt;code&gt;OnCalendar&lt;/code&gt; de reloj de pared en su lugar.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ronda 3: la parte que de verdad lo habría atrapado
&lt;/h2&gt;

&lt;p&gt;Todo lo de arriba acorta el outage. Nada de eso acorta los &lt;strong&gt;seis días&lt;/strong&gt;, porque una recuperación de la que nunca te enteras es indistinguible de ninguna recuperación.&lt;/p&gt;

&lt;p&gt;El watchdog ya corre cada cinco minutos y ya sabe si el servicio está sano.&lt;br&gt;
Haz que lo diga, en voz alta, a algún lugar fuera de 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;heartbeat&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;stream ts
  &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /var/lib/cloud/data/instance-id 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&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;&lt;span class="s2"&gt;000"&lt;/span&gt;
  aws logs create-log-stream &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--log-stream-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  &lt;/span&gt;aws logs put-log-events &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--log-stream-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--log-events&lt;/span&gt; &lt;span class="s2"&gt;"timestamp=&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="s2"&gt;,message=AGENT_BOX_HEARTBEAT &lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$detail&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; log &lt;span class="s2"&gt;"WARN: heartbeat not delivered"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

fail&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; log &lt;span class="s2"&gt;"ERROR: &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; heartbeat DOWN &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Un metric filter cuenta las líneas sanas, y la alarma dispara ante su&lt;br&gt;
&lt;strong&gt;ausencia&lt;/strong&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="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MetricFilter&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;HeartbeatMetricFilter&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;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricNamespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp/AgentBox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RemoteControlHeartbeat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"AGENT_BOX_HEARTBEAT OK"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;metricValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultValue&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="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Alarm&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;RemoteControlDownAlarm&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="na"&gt;alarmName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp-agent-box-down&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metric&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;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Metric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp/AgentBox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RemoteControlHeartbeat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;statistic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sum&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="na"&gt;threshold&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="na"&gt;comparisonOperator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ComparisonOperator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LESS_THAN_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;evaluationPeriods&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="na"&gt;treatMissingData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TreatMissingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BREACHING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;addAlarmAction&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;cwactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SnsAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alarmTopic&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;&lt;code&gt;treatMissingData: BREACHING&lt;/code&gt; es todo el diseño.&lt;/strong&gt; El instinto es alarmar sobre una línea de error, pero cada modo de falla en este postmortem produjo &lt;em&gt;cero logs&lt;/em&gt;: una instancia que nunca arrancó, un rol sin permisos, un watchdog que nunca disparó. La ausencia es la señal. Esta es la única alarma de la cuenta que no debe caer por default en &lt;code&gt;NOT_BREACHING&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Dos detalles de apoyo. El heartbeat dispara también en el camino de falla, así que una corrida rota es tan visible como una sana: la alarma se indexa en la ausencia de &lt;code&gt;OK&lt;/code&gt;, no en la presencia de &lt;code&gt;DOWN&lt;/code&gt;. Y &lt;code&gt;activating&lt;/code&gt; cuenta como sano, ya que es el estado normal por unos segundos después de cualquier reinicio:&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;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;systemctl is-active agent-remote-control 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;active|activating&lt;span class="p"&gt;)&lt;/span&gt; heartbeat OK &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; fail &lt;span class="s2"&gt;"service is &lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Cambia por el chequeo de &lt;code&gt;tmux has-session&lt;/code&gt; + &lt;code&gt;#{pane_dead}&lt;/code&gt; de antes para una caja de Kiro. Todo lo de aguas abajo (log group, metric filter, alarma, topic de SNS) es idéntico, porque la línea de heartbeat es el único contrato entre la caja y la alarma.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ronda 4: deja de compartir ciclo de vida con producción
&lt;/h2&gt;

&lt;p&gt;La caja original vivía en el stack del backend de producción. Eso es lo que la mató: un parámetro de AMI dentro de &lt;em&gt;ese&lt;/em&gt; stack reemplazó la instancia. Cada deploy de producción también la detenía/arrancaba.&lt;/p&gt;

&lt;p&gt;Una comodidad de desarrollo no tiene por qué compartir ciclo de vida con producción. Su propio stack, con un lookup de VPC en lugar de un export entre stacks, un export recrearía el acoplamiento que la separación existe para quitar:&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;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentBoxStack&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Stack&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;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Construct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&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;vpc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ec2&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="nf"&gt;fromLookup&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;Vpc&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="na"&gt;vpcName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp-vpc&lt;/span&gt;&lt;span class="dl"&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;alarmTopic&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;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Topic&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;AgentBoxAlerts&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="na"&gt;topicName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp-agent-box-alerts&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;alarmTopic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addSubscription&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;snssub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EmailSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ops@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AgentBox&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;AgentBox&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="na"&gt;appName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp&lt;/span&gt;&lt;span class="dl"&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;githubRepo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;acme/myapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;alarmTopic&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Su propio topic de SNS, también. Importar el topic de producción metería el stack de vuelta justo en el grafo de dependencias.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El movimiento destruye y recrea la instancia&lt;/strong&gt;, y el orden no es opcional si algún recurso tiene un nombre físico fijo. El rol de IAM aquí lo tiene:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx cdk deploy MyApp-Backend    &lt;span class="c"&gt;# borra la caja vieja y su rol de nombre fijo&lt;/span&gt;
npx cdk deploy MyApp-AgentBox   &lt;span class="c"&gt;# los crea de nuevo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Al revés, el segundo deploy falla por un nombre de rol duplicado.&lt;/p&gt;
&lt;h2&gt;
  
  
  El resultado
&lt;/h2&gt;

&lt;p&gt;La separación del stack destruyó la instancia y construyó una nueva desde cero, lo que reproduce la falla original exactamente. Esta vez nadie la tocó:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MyApp-AgentBox | 12/13 | CREATE_COMPLETE | AWS::EC2::Instance | AgentBox/Instance
MyApp-AgentBox | 13/13 | CREATE_COMPLETE | AWS::CloudFormation::Stack | MyApp-AgentBox
 ✅  MyApp-AgentBox
✨  Deployment time: 177.81s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Como dos minutos después, sin atender:&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;[agent-box-bootstrap] 1/5 GitHub auth
[agent-box-bootstrap] 2/5 Repo clone
[agent-box-bootstrap] 3/5 Agent credentials
[agent-box-bootstrap] 4/5 Account context + workspace trust
[agent-box-bootstrap] 5/5 Enabling the always-on service
[agent-box-bootstrap] done (active)
systemd[1]: Finished agent-bootstrap.service.
systemd[1]: agent-bootstrap.service: Consumed 2.954s CPU time, 52.5M memory peak
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Y la alarma hizo su trabajo a lo largo de toda la ventana. Durante el hueco del arranque:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Threshold Crossed: no datapoints were received for 1 period and 1 missing
datapoint was treated as [Breaching].    ALARM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;luego, por su cuenta, en cuanto aterrizó el primer heartbeat:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Threshold Crossed: 1 datapoint [1.0 (22/07/26 20:40:00)] was not less than
the threshold (1.0).    OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Una alarma que dispara ante un outage real y se limpia sola sin ayuda es todo el entregable. La recuperación está padre; la alarma es lo que convierte seis días en quince minutos.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lo que no ayudó
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fijar el AMI.&lt;/strong&gt; Tentador, ya que el drift del AMI fue el gatillo. Pero solo cierra la única puerta: un reemplazo forzado por un cambio de tipo de instancia o una edición de user-data marcada como reemplazo borra el volumen igual, y heredas una imagen congelada sin parchar. Una vez que la caja se puede reconstruir sola, fijar compra muy poco.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un volumen de datos persistente para el home.&lt;/strong&gt; Una opción real, y sobrevive al reemplazo, pero vuelve las credenciales un copo de nieve otra vez. Una caja que se reconstruye desde un almacén de secretos es más fácil de razonar que una que depende de que un volumen sobreviva.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mover la caja a su propio repo.&lt;/strong&gt; Considerado y rechazado. La falla era clonar un repo privado sin credenciales; un repo separado no cambia eso, porque la caja de todos modos tiene que clonar el privado. Stack separado, mismo repo, esa fue la separación útil.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Diseña la alarma antes que la recuperación.&lt;/strong&gt; Un auto-arreglo del que nunca te enteras es infalsificable. Los seis días no los causó un camino de reparación faltante; los causó una señal faltante.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarma sobre la ausencia, no sobre errores.&lt;/strong&gt; La infraestructura muerta es callada. Cualquier monitor construido sobre hacer match a un string de error es ciego a las fallas totales que más importan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;|| true&lt;/code&gt; es una decisión de nunca enterarte.&lt;/strong&gt; Va solo en pasos de verdad opcionales. En cualquier cosa de carga estructural convierte una falla ruidosa en una silenciosa, y un reporte de arranque limpio desde una máquina muerta es lo que hace que un outage dure días en lugar de minutos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las credenciales en el volumen de una instancia son un outage esperando un gatillo.&lt;/strong&gt; Todo lo que pueda ser reemplazado, lo será. Pon las credenciales donde un reemplazo las pueda alcanzar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sabe cuáles cambios de config son reemplazos.&lt;/strong&gt; Una edición de user-data es un detener/actualizar/arrancar sobre el mismo volumen; un cambio de &lt;code&gt;ImageId&lt;/code&gt; lo destruye. La misma superficie de diff, un radio de impacto salvajemente distinto: lee el plan antes de desplegar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La infraestructura de conveniencia no va en el stack de producción.&lt;/strong&gt; No por costo, no por orden, por radio de impacto. Fue un deploy de producción el que destruyó la caja.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un watchdog que nunca ha disparado no es un watchdog.&lt;/strong&gt; Revisa el &lt;code&gt;NEXT&lt;/code&gt; del timer. El monitoreo inerte es peor que ninguno, porque se cuenta como cobertura.&lt;/li&gt;
&lt;/ol&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-always-on-agent-box-code" rel="noopener noreferrer"&gt;
        blog-always-on-agent-box-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Companion code — "Six days of silence: an always-on agent box for Claude Code and Kiro"
    &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;Companion code — "Six days of silence: an always-on agent box for Claude Code and Kiro"&lt;/h1&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Reading order&lt;/h2&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;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;Blog section&lt;/th&gt;

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

&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;00-starting-point/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;The starting point, and what was wrong with it&lt;/td&gt;

&lt;td&gt;The naive user-data, with the three mistakes intact&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;01-bootstrap-from-secrets/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 1&lt;/td&gt;

&lt;td&gt;The idempotent bootstrap script + its IAM scope&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;02-keep-it-running/&lt;/code&gt;&lt;/td&gt;

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

&lt;td&gt;systemd units — Claude path, Kiro path, watchdog timer&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;03-heartbeat-and-alarm/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 3&lt;/td&gt;

&lt;td&gt;Heartbeat emitter, both health checks, metric filter + alarm&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;04-own-stack/&lt;/code&gt;&lt;/td&gt;

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

&lt;td&gt;The isolated CDK stack&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;05-dead-ends/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Traps 3 and 4&lt;/td&gt;

&lt;td&gt;Kept deliberately, with an explanation of why they fail&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;06-final/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;The result&lt;/td&gt;

&lt;td&gt;Cumulative final state of the script and the construct&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;Which agent path to use&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;The two agents differ only in what keeps the session alive and how you check its health.
Everything else — secrets, bootstrap, watchdog, heartbeat, alarm, stack — is shared.&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;

&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;Kiro CLI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remote&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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/blog-always-on-agent-box-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>automation</category>
      <category>aws</category>
      <category>cli</category>
      <category>devops</category>
    </item>
    <item>
      <title>Resolviendo los 404 de Google Search Console</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 19 Jul 2026 21:30:19 +0000</pubDate>
      <link>https://dev.to/aws-builders/resolviendo-los-404-de-google-search-console-3bc3</link>
      <guid>https://dev.to/aws-builders/resolviendo-los-404-de-google-search-console-3bc3</guid>
      <description>&lt;p&gt;Tres semanas después de publicar un sitemap dinámico que orgullosamente listaba cada perfil de miembro, Google Search Console me dijo que esos perfiles eran un error. No con un error de plano, sino con dos veredictos más callados: &lt;strong&gt;Soft 404&lt;/strong&gt; y &lt;strong&gt;Duplicada sin canónica seleccionada por el usuario&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Esta es la historia de leer ese reporte, separar el ruido de la&lt;br&gt;
única señal real, y el arreglo, que fue &lt;em&gt;quitar&lt;/em&gt; páginas del índice, no agregarlas.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;El reporte "Por qué las páginas no se indexan" de Google es ~80% benigno por diseño. Aprende a triarlo o vas a perseguir fantasmas.&lt;/li&gt;
&lt;li&gt;Mis perfiles de miembros salieron marcados como &lt;strong&gt;Soft 404&lt;/strong&gt; (uno) y &lt;strong&gt;Duplicada sin canónica seleccionada por el usuario&lt;/strong&gt; (otro). La misma causa raíz: el perfil público anónimo es &lt;em&gt;deliberadamente&lt;/em&gt; flaco, un esqueleto con el username, todo lo demás es PII oculta a quien no ha iniciado sesión. Flaco + casi idéntico entre usuarios se lee como "página vacía" y "clúster de duplicados".&lt;/li&gt;
&lt;li&gt;No puedes arreglar un Soft 404 enriqueciendo una página que por contrato no tienes permitido enriquecer. Así que el arreglo es &lt;code&gt;noindex,follow&lt;/code&gt; en la captura, más sacar las URLs del sitemap (un sitemap que lista una URL noindex es una autocontradicción que Google va a señalar).&lt;/li&gt;
&lt;li&gt;El modelo mental en una línea: &lt;strong&gt;una página que a propósito no tiene contenido para los visitantes anónimos no tiene nada que hacer en un índice construido para visitantes anónimos.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  El reporte que lo empezó todo
&lt;/h2&gt;

&lt;p&gt;El reporte de cobertura del índice, ordenado por número de páginas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Razón&lt;/th&gt;
&lt;th&gt;Fuente&lt;/th&gt;
&lt;th&gt;Páginas&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Descubierta, actualmente sin indexar&lt;/td&gt;
&lt;td&gt;Sistemas de Google&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicada sin canónica seleccionada por el usuario&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Página alternativa con etiqueta canónica correcta&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Página con redirección&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rastreada, actualmente sin indexar&lt;/td&gt;
&lt;td&gt;Sistemas de Google&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Soft 404&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Excluida por etiqueta 'noindex'&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No encontrada (404)&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bloqueada por acceso prohibido (403)&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Noventa y tantas URLs "sin indexar". El instinto es entrar en pánico. La primera jugada correcta es acomodar cada renglón en &lt;strong&gt;benigno-por-diseño&lt;/strong&gt; o &lt;strong&gt;señal-real&lt;/strong&gt;, porque la mayor parte de esta lista es Google trabajando exactamente como se pretende.&lt;/p&gt;
&lt;h2&gt;
  
  
  Triaje: qué ignorar
&lt;/h2&gt;

&lt;p&gt;Antes de tocar código, clasifiqué cada renglón. La mayoría no necesitaba acción:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Página con redirección (2):&lt;/strong&gt; &lt;code&gt;http://&lt;/code&gt; → &lt;code&gt;https://&lt;/code&gt; y &lt;code&gt;www.&lt;/code&gt; → apex.
Eso es canonicalización &lt;em&gt;funcionando&lt;/em&gt;. Google me está diciendo que siguió la redirección e indexó el destino. Nada que arreglar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluida por etiqueta 'noindex' (1):&lt;/strong&gt; la página de reset de
contraseña. Ese &lt;code&gt;noindex&lt;/code&gt; lo pusimos a propósito. Una palomita verde vestida de advertencia.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No encontrada 404 (1):&lt;/strong&gt; la raíz del origen de la API (el host &lt;code&gt;api.&lt;/code&gt;).
No tiene homepage; no debería indexarse. Un rastreador encontró un enlace a ella una vez. Inofensivo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bloqueada 403 (1):&lt;/strong&gt; una URL detrás de auth que regresa 403 a los
rastreadores sin sesión. Correcto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Descubierta / Rastreada, actualmente sin indexar (57):&lt;/strong&gt; el número grande y aterrador, y el más benigno de todos. Estas son las decisiones de crawl budget de Google &lt;em&gt;mismo&lt;/em&gt; ("conocemos esta URL, no hemos priorizado indexarla"). No las arreglas directo; se resuelven conforme el sitio se gana la prioridad de rastreo a través de contenido y enlaces.
Tratarlas como bugs es el clásico hoyo de conejo de Search Console.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Eso deja los únicos renglones que describen &lt;em&gt;mis&lt;/em&gt; páginas portándose mal:&lt;br&gt;
&lt;strong&gt;Duplicada sin canónica seleccionada por el usuario (9 + un 3&lt;br&gt;
relacionado)&lt;/strong&gt; y &lt;strong&gt;Soft 404 (1)&lt;/strong&gt;. Los dos, resultó, eran los mismos dos &lt;em&gt;tipos&lt;/em&gt; de página.&lt;/p&gt;
&lt;h2&gt;
  
  
  Señal 1: las páginas de tag del blog (una falsa alarma, verificada)
&lt;/h2&gt;

&lt;p&gt;Nueve de las URLs "Duplicada sin canónica" eran filtros de tag del blog: &lt;code&gt;/blog?tag=TechCareers&lt;/code&gt;, &lt;code&gt;/blog?tag=CloudComputing&lt;/code&gt;, y así. Las tres "Página alternativa con canónica correcta" tenían la misma forma&lt;br&gt;
(&lt;code&gt;/blog?tag=gestión&lt;/code&gt;, …).&lt;/p&gt;

&lt;p&gt;El reflejo es "¡agrega etiquetas canónicas!". Pero revisé primero el&lt;br&gt;
componente &lt;code&gt;PageMeta&lt;/code&gt;, y ya hacía lo correcto:&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;function&lt;/span&gt; &lt;span class="nf"&gt;resolveCanonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;canonical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canonical&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;pathname&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="c1"&gt;// nota: pathname, sin query string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La canónica se construye desde &lt;code&gt;origin + pathname&lt;/code&gt;: &lt;strong&gt;el query string se descarta&lt;/strong&gt;. Así que cada &lt;code&gt;/blog?tag=X&lt;/code&gt; se auto-reporta con canónica &lt;code&gt;/blog&lt;/code&gt;. Ese es el estado final buscado: los filtros de tag son vistas flacas y duplicativas que &lt;em&gt;no&lt;/em&gt; deberían indexarse por separado; deberían consolidarse en &lt;code&gt;/blog&lt;/code&gt;. La división 9-contra-3 es nada más Google a media reclasificación: algunas URLs ya se habían asentado en "página alternativa&lt;br&gt;
con canónica correcta" (la cubeta deseada), el resto seguía en la cubeta genérica de duplicados de camino para allá.&lt;/p&gt;

&lt;p&gt;Lección: &lt;strong&gt;antes de "arreglar" una advertencia de canónica, confirma qué canónica emite la página en realidad.&lt;/strong&gt; La mitad de estas advertencias son Google reportando un estado transitorio de algo que ya hiciste bien. Cerré esta como funcionando-como-se-diseñó y seguí adelante.&lt;/p&gt;
&lt;h2&gt;
  
  
  Señal 2: los perfiles (el bug real)
&lt;/h2&gt;

&lt;p&gt;Las dos URLs restantes eran perfiles de miembros:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/profile/rafael-ortiz&lt;/code&gt; → &lt;strong&gt;Soft 404&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/profile/hectorC&lt;/code&gt; → &lt;strong&gt;Duplicada sin canónica seleccionada por el
usuario&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Un Soft 404 significa que la página regresa HTTP 200 pero &lt;em&gt;parece&lt;/em&gt; un&lt;br&gt;
no-encontrado: vacía, flaca, o de relleno. Para ver por qué Google lo&lt;br&gt;
pensó, aquí está el cuerpo rastreable entero de la captura de un perfil:&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;body&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;&amp;lt;section class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mx-auto max-w-3xl py-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&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;&amp;lt;h1&amp;gt;@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;&lt;span class="sh"&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;&amp;lt;p class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text-xs text-white/50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta_bits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;# rol · miembro-desde
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p&amp;gt;&amp;lt;a href=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;MEMBER COMMUNITY: tech community&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/section&amp;gt;&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;Un username, una etiqueta de rol, una fecha de ingreso, un enlace a home.&lt;br&gt;
Eso es todo, y es &lt;em&gt;deliberadamente&lt;/em&gt; eso. El perfil público que se le&lt;br&gt;
muestra a un visitante sin sesión está anonimizado por privacidad: nombre real, bio, avatar, especialidad, país, enlaces, posts recientes son todos PII, ocultos para &lt;code&gt;viewer=None&lt;/code&gt;. La captura pre-renderizada se construye justo desde esa vista anónima, a propósito, para que el rastreador nunca pueda exponer más de lo que ve un humano sin sesión.&lt;/p&gt;

&lt;p&gt;Ahora los dos veredictos tienen sentido como una sola causa raíz:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Soft 404&lt;/strong&gt;: la página es tan flaca que Google decide que no hay
contenido real. A &lt;code&gt;rafael-ortiz&lt;/code&gt; le tocó cruzar el umbral.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicada sin canónica&lt;/strong&gt;: quítale el username y cada captura de perfil es &lt;em&gt;byte por byte el mismo esqueleto&lt;/em&gt;. Google agrupa las páginas casi idénticas y elige una representante; al resto le cae el veredicto de duplicada. A &lt;code&gt;hectorC&lt;/code&gt; le tocó el palito corto.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Por qué los arreglos obvios están los dos mal
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Enriquece la página para que no sea flaca."&lt;/strong&gt; No se puede. La flacura es una garantía de privacidad, no un descuido. Agregarle bio/avatar/posts a la captura filtraría PII a un HTML que los rastreadores sin sesión (y quien sea que vea el código fuente) pueden leer. La regla, &lt;em&gt;la captura nunca expone más de lo que ve un humano sin sesión&lt;/em&gt;, es de carga estructural.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Pon una canónica para que los duplicados se consoliden."&lt;/strong&gt; Una canónica apunta los duplicados a una página &lt;em&gt;representante&lt;/em&gt;. Pero no hay perfil representante: cada uno es una persona distinta con contenido (oculto) distinto. Y consolidar no tocaría el Soft 404. La canónica es la herramienta equivocada.&lt;/p&gt;

&lt;p&gt;Así que ninguno de los dos arreglos orientados a indexar aplica. Lo que fuerza una pregunta más honesta: &lt;strong&gt;¿estas páginas pertenecen a un índice de búsqueda siquiera?&lt;/strong&gt; Una página que a un visitante anónimo le muestra nada más &lt;code&gt;@username&lt;/code&gt; y un rol no tiene por qué rankear. Indexarla es puro ruido:&lt;br&gt;
reportes de Soft 404, clústeres de duplicados, y crawl budget gastado&lt;br&gt;
re-jalando esqueletos (alimentando esa pila de 55 URLs "Descubierta, sin indexar").&lt;/p&gt;

&lt;p&gt;La respuesta es no. Sírvele la página a los humanos y a los desplegadores de enlaces en redes; dile a los rastreadores que no la indexen.&lt;/p&gt;
&lt;h2&gt;
  
  
  El arreglo: &lt;code&gt;noindex,follow&lt;/code&gt;, y la contradicción del sitemap
&lt;/h2&gt;

&lt;p&gt;Tres cambios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Enséñale al core del pre-render a sobreescribir robots.&lt;/strong&gt; El embudo compartido &lt;code&gt;render_into_shell&lt;/code&gt; parcha el &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; del shell. Le di un argumento opcional &lt;code&gt;robots&lt;/code&gt; que reemplaza quirúrgicamente el&lt;br&gt;
&lt;code&gt;&amp;lt;meta name="robots"&amp;gt;&lt;/code&gt; por default del shell:&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;if&lt;/span&gt; &lt;span class="n"&gt;robots&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&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="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;meta name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;&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;&amp;lt;meta name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;count&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default &lt;code&gt;None&lt;/code&gt; → sin tocar, así que las capturas de blog / reportes /&lt;br&gt;
eventos mantienen su &lt;code&gt;index,follow&lt;/code&gt;. Solo los callers que se apuntan&lt;br&gt;
cambian de comportamiento.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Apunta el adaptador de perfil.&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;return&lt;/span&gt; &lt;span class="nf"&gt;render_into_shell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;shell_html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;body_html&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;jsonld&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_person_jsonld&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;og_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;robots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noindex,follow&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;¿Por qué &lt;code&gt;noindex,**follow**&lt;/code&gt; y no &lt;code&gt;noindex,nofollow&lt;/code&gt;? Porque la página todavía enlaza hacia afuera (a home, y con el tiempo a contenido indexable). &lt;code&gt;follow&lt;/code&gt; deja que el link equity fluya &lt;em&gt;a través&lt;/em&gt; del perfil aunque el perfil mismo no se indexe. &lt;code&gt;nofollow&lt;/code&gt; lo dejaría varado. La distinción importa: &lt;code&gt;noindex&lt;/code&gt; es sobre &lt;em&gt;esta&lt;/em&gt; página; &lt;code&gt;follow&lt;/code&gt; es sobre las páginas a las que apunta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Quita los perfiles del sitemap.&lt;/strong&gt; Este es el paso que la gente se salta. Un sitemap es una lista de "por favor indexa estas". Un meta &lt;code&gt;noindex&lt;/code&gt; es "por favor no indexes esta". Si una URL aparece en las dos, le entregaste a Google una contradicción, y va a sacar una advertencia nueva:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL enviada marcada como 'noindex'&lt;/strong&gt;. Acabas de cambiar dos advertencias por una nueva.&lt;/p&gt;

&lt;p&gt;Así que el endpoint del sitemap de perfiles, y su entrada en el índice de sitemaps, salieron por completo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!--
  Sin sitemap-profiles.xml: las capturas de perfil anónimas son noindex,follow
  (esqueletos flacos de solo-username, PII oculta por diseño → Soft 404 / duplicada
  en GSC). Listarlas aquí chocaría con el meta noindex.
--&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La ironía no se me escapa: la pasada de SEO anterior &lt;em&gt;agregó&lt;/em&gt; ese sitemap de perfiles. Me tomó tres semanas de datos de rastreo reales aprender que publicar un sitemap para páginas que un rastreador no debería querer era el instinto equivocado. Search Console es un maestro lento pero honesto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pruebas de regresión
&lt;/h2&gt;

&lt;p&gt;Dos afirmaciones fijan el comportamiento. Primero, la captura voltea robots:&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;test_profile_snapshot_is_noindex&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;render_profile_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_anon_profile&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;SHELL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;meta name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noindex,follow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;meta name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index, follow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&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;html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Segundo, el endpoint del sitemap ya no existe (para que nunca pueda&lt;br&gt;
derivar de vuelta en silencio a contradecir el meta):&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;test_sitemap_profiles_removed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/sitemap-profiles.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El fixture del shell de la prueba de &lt;code&gt;noindex&lt;/code&gt; necesita la etiqueta&lt;br&gt;
&lt;code&gt;&amp;lt;meta name="robots"&amp;gt;&lt;/code&gt; real que carga el &lt;code&gt;index.html&lt;/code&gt; de producción, algo fácil de olvidar, y sin ella el reemplazo de regex es un no-op silencioso que pasa por la razón equivocada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué medir después
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;noindex&lt;/code&gt; es una señal, no un switch. Después de desplegar, vuelve a&lt;br&gt;
renderizar las capturas vivas para que de verdad carguen el meta nuevo, luego en Search Console:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validar corrección&lt;/strong&gt; tanto en el item de Soft 404 como en el de
Duplicada. Esto le dice a Google que re-rastree el conjunto afectado.&lt;/li&gt;
&lt;li&gt;Observa los perfiles migrar a &lt;strong&gt;Excluida por etiqueta 'noindex'&lt;/strong&gt;: ese es el estado de &lt;em&gt;éxito&lt;/em&gt; aquí, no un problema nuevo. Significa que Google
vio el meta y obedeció.&lt;/li&gt;
&lt;li&gt;Confirma que el conteo de "Descubierta, actualmente sin indexar" va bajando conforme el crawl budget deja de gastarse re-jalando esqueletos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Espera semanas, no horas. Los cambios de cobertura del índice se propagan en la agenda de Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  La conclusión
&lt;/h2&gt;

&lt;p&gt;El reporte de índice de Google mezcla "tu&lt;br&gt;
página está rota" con "Google trabaja como se pretende" con "Google todavía no llega a ella", y se ven casi idénticos. Acomodar cada renglón en benigno contra señal &lt;em&gt;antes&lt;/em&gt; de abrir un editor me salvó de "arreglar" etiquetas canónicas que ya estaban correctas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No toda página quiere estar indexada.&lt;/strong&gt; Una página construida para&lt;br&gt;
mostrarle nada a los visitantes anónimos no tiene por qué rankear, y&lt;br&gt;
forzarla a un índice produce exactamente el ruido de Soft-404-y-duplicada que esperarías. La jugada madura de SEO a veces es sacar páginas &lt;em&gt;fuera&lt;/em&gt;: &lt;code&gt;noindex,follow&lt;/code&gt; en la página, y fuera del sitemap, juntos, para que las dos señales concuerden.&lt;/p&gt;

</description>
      <category>google</category>
      <category>seo</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Alarmas que despiertan por causa real, no por un número random</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 19 Jul 2026 20:17:34 +0000</pubDate>
      <link>https://dev.to/aws-builders/alarmas-que-despiertan-por-causa-no-por-un-numero-mkm</link>
      <guid>https://dev.to/aws-builders/alarmas-que-despiertan-por-causa-no-por-un-numero-mkm</guid>
      <description>&lt;p&gt;El escenario es un trabajo en segundo plano en una plataforma de&lt;br&gt;
comunidad: un matcher nocturno que empareja items nuevos contra un pool de usuarios elegibles y escribe filas a una tabla &lt;code&gt;matches&lt;/code&gt;. Pero la lección es general: cualquier alarma sobre un &lt;em&gt;conteo de salida&lt;/em&gt; (items emparejados, correos enviados, registros procesados, recomendaciones generadas) tiene el mismo fallo.  &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-cause-aware-alarms-code" rel="noopener noreferrer"&gt;
        blog-cause-aware-alarms-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code companion — alarms that page on cause, not on a number
    &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 — alarms that page on cause, not on a number&lt;/h1&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;00-output-threshold-alarm/   the naive version: emitter + alarm that page on a COUNT
01-diagnose/                 read-only SQL that falsifies the alarm's hypothesis
02-instrument-the-emitter/   the pure classifier + the instrumented job
03-cause-aware-alarm/        same threshold, cause-accurate line + a benign metric
04-test-the-gate/            test the classification, not the plumbing
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;00-output-threshold-alarm/&lt;/code&gt; — what pages on a coincidence. Note the
&lt;code&gt;3/3&lt;/code&gt; streak hedge: a workaround for an ambiguous metric, not a fix.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;01-diagnose/funnel.sql&lt;/code&gt; — before touching a threshold, confirm the
alarm caught its &lt;em&gt;stated&lt;/em&gt; failure. Here it hadn't: input + similarities
were healthy; the pool was quota-capped.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;02-instrument-the-emitter/&lt;/code&gt; — move the intelligence to the emitter
&lt;code&gt;classify.py&lt;/code&gt; is the whole idea in one pure function; &lt;code&gt;matcher.py&lt;/code&gt; shows
the two counters that feed it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;03-cause-aware-alarm/&lt;/code&gt; — the threshold is UNCHANGED. Only the log line
got smarter. Plus an unalarmed metric for the benign nights.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04-test-the-gate/&lt;/code&gt;…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/blog-cause-aware-alarms-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&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;&lt;/th&gt;
&lt;th&gt;Alarma por umbral de salida&lt;/th&gt;
&lt;th&gt;Alarma consciente de la causa&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qué vigila&lt;/td&gt;
&lt;td&gt;"¿el conteo llegó a 0?"&lt;/td&gt;
&lt;td&gt;"¿el conteo llegó a 0 &lt;em&gt;por una razón rota&lt;/em&gt;?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dónde vive la lógica&lt;/td&gt;
&lt;td&gt;el umbral en el CDK&lt;/td&gt;
&lt;td&gt;el emisor (el trabajo mismo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Despertadas falsas&lt;/td&gt;
&lt;td&gt;cada mes, en agenda&lt;/td&gt;
&lt;td&gt;ninguna observada&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noches-0 benignas&lt;/td&gt;
&lt;td&gt;indistinguibles de una falla&lt;/td&gt;
&lt;td&gt;registradas como una métrica aparte, sin alarma&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arreglo cuando dispara en falso&lt;/td&gt;
&lt;td&gt;subir el umbral → quedar ciego&lt;/td&gt;
&lt;td&gt;ya está correcto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Costo nuevo de AWS&lt;/td&gt;
&lt;td&gt;$0 (derivado de logs)&lt;/td&gt;
&lt;td&gt;$0 (derivado de logs)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tres ideas hacen el trabajo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Una alarma por conteo de salida confunde causas.&lt;/strong&gt; &lt;code&gt;count == 0&lt;/code&gt; puede significar "roto" o "correctamente callado". Un solo umbral no los distingue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnostica antes de ajustar.&lt;/strong&gt; Cuando una alarma dispara, primero pregunta si atrapó la falla que &lt;em&gt;dice&lt;/em&gt; atrapar. Razona desde los datos, no desde la descripción de la alarma.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mueve la inteligencia al emisor.&lt;/strong&gt; Instrumenta el pipeline para que emita &lt;em&gt;por qué&lt;/em&gt; el conteo fue 0. Deja la alarma tonta; haz lista la línea de log.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  La trampa: un conteo no es un diagnóstico
&lt;/h2&gt;

&lt;p&gt;La alarma original era una métrica derivada de logs de manual. El trabajo loguea una línea cuando termina con 0 resultados; un metric filter la cuenta; la alarma despierta ante una racha sostenida:&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;# 00-output-threshold-alarm/emit.py (la versión ingenua)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scanned_items&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;created&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="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_zero_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&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;scanned_items&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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 00-output-threshold-alarm/alarm.ts&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MetricFilter&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;ZeroResultsFilter&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;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"matcher_zero_results"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;metricNamespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp/Jobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MatcherZeroResults&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;defaultValue&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="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Alarm&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;ZeroResultsAlarm&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="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;zeroResultsMetric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;threshold&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="na"&gt;evaluationPeriods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;datapointsToAlarm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 3 noches seguidas&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;evaluationPeriods: 3&lt;/code&gt; ya es una cobertura: alguien sabía que una sola noche 0 era varianza normal y exigió una &lt;em&gt;racha&lt;/em&gt; antes de despertar. Esa cobertura es la pista. Compra tiempo; no arregla la ambigüedad. Si el conteo puede legítimamente quedarse en 0 por un rato, entonces "3 noches seguidas" no es "roto", es "ya esperamos tres noches". La alarma dispara de todos modos. Nada más te entrenaste a esperar más para la despertada falsa.&lt;/p&gt;

&lt;p&gt;El problema real: &lt;code&gt;created == 0&lt;/code&gt; tiene (al menos) dos causas.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Roto.&lt;/strong&gt; Un umbral o un embedding regresó y &lt;em&gt;nada&lt;/em&gt; pasa la barra ya, la clásica falla de "regresa 0 para siempre". Esto debería despertar, fuerte, rápido.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correctamente callado.&lt;/strong&gt; El pool elegible es chico y cada candidato que &lt;em&gt;podía&lt;/em&gt; emparejar ya lo hizo, o pegó contra una cuota mensual por usuario. El trabajo se está portando perfecto. Esto &lt;strong&gt;nunca&lt;/strong&gt; debería despertar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Paso 1: diagnostica antes de ajustar
&lt;/h2&gt;

&lt;p&gt;El instinto cuando dispara una alarma "inestable" es subir el umbral o ensanchar la ventana. Resístelo. Primero responde una pregunta: &lt;strong&gt;¿la alarma atrapó lo que dice que atrapó?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;La propia descripción de la alarma culpaba a una regresión. Así que revisé las precondiciones de esa regresión directo, desde datos de producción, antes de tocar cualquier cosa:&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;-- 01-diagnose/funnel.sql: ¿está sano el input? (abreviado)&lt;/span&gt;
&lt;span class="c1"&gt;-- 1. ¿Hay items frescos para emparejar siquiera?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;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;interval&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;-- sano: cientos&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. ¿Los candidatos pasan el piso de similitud? (la barra "regresada")&lt;/span&gt;
&lt;span class="c1"&gt;--    Puntúa el item más nuevo contra el pool elegible.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;max&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&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;item_vec&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;best_similarity&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;eligible_users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                               &lt;span class="c1"&gt;-- sano: bien arriba del piso&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. ¿Los usuarios elegibles ya fueron servidos este periodo?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&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;n&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;usage_counters&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'matcher'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;this_month&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&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;tier&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;-- la respuesta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los datos contaban una historia clara: input sano, similitudes sanas, y &lt;strong&gt;9 de 11 usuarios servibles ya estaban en su tope mensual&lt;/strong&gt;. Una ráfaga más temprano en el mes había consumido legítimamente la cuota del pool; las "0 noches" eran el pool sentado en su tope hasta el siguiente reinicio.&lt;br&gt;
Ninguna regresión. La alarma había disparado sobre un sistema &lt;em&gt;correcto&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Este es el paso de carga estructural y el que más fácil se salta. La alarma me entregó una hipótesis ("regresión"); los datos la falsearon. Todo lo que viene después solo vale la pena hacerlo porque me detuve a revisar.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Regla de dedo: la descripción de una alarma es una &lt;em&gt;hipótesis&lt;/em&gt;, no un&lt;br&gt;
diagnóstico. El primer trabajo de un humano de guardia (o de un agente)&lt;br&gt;
es confirmar que la alarma atrapó su falla declarada, no silenciar el&lt;br&gt;
síntoma.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Paso 2: nombra la falla con precisión
&lt;/h2&gt;

&lt;p&gt;Antes de escribir código, escribe la oración que la alarma &lt;em&gt;debería&lt;/em&gt;&lt;br&gt;
codificar. La mía:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Despierta cuando el trabajo escaneó input real y candidatos elegibles&lt;br&gt;
llegaron a la compuerta final &lt;strong&gt;con cuota de sobra&lt;/strong&gt;, y aun así produjo 0&lt;br&gt;
resultados.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Todo lo que esa oración excluye es, por construcción, un no-incidente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;0 porque ningún candidato llegó a la compuerta final → el pool ya estaba
servido o inactivo. &lt;strong&gt;Callado.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;0 porque cada candidato que llegó a la compuerta estaba bloqueado por
cuota → demanda real, correctamente limitada por tasa. &lt;strong&gt;Callado.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;0 porque candidatos llegaron a la compuerta, tenían cuota, y &lt;em&gt;nada
pasó&lt;/em&gt; → la barra está rota. &lt;strong&gt;Despierta.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nota que esto es una afirmación sobre los &lt;em&gt;estados internos del pipeline&lt;/em&gt;,&lt;br&gt;
no sobre el conteo de salida. Lo que significa que la métrica de salida&lt;br&gt;
nunca lo puede expresar. El emisor tiene que hacerlo.&lt;/p&gt;
&lt;h2&gt;
  
  
  Paso 3: mueve la inteligencia al emisor
&lt;/h2&gt;

&lt;p&gt;La alarma se queda tonta, un umbral sobre un conteo. Hacemos lista la&lt;br&gt;
&lt;em&gt;línea de log&lt;/em&gt; haciendo que el trabajo clasifique su propia corrida de 0 resultados. Dos contadores baratos, acumulados conforme corre el pipeline, capturan la distinció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;# 02-instrument-the-emitter/matcher.py (forma)
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NightlyMatcher&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;      &lt;span class="c1"&gt;# candidatos que pasaron los pre-filtros
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quota_blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;     &lt;span class="c1"&gt;# descartados SOLO por el tope por usuario
&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_process_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_prefilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# similitud, dedup, ...
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&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;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# llegaron a la compuerta
&lt;/span&gt;        &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;                              &lt;span class="c1"&gt;# encaje débil
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_has_quota&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quota_blocked&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;              &lt;span class="c1"&gt;# demanda real, en tope
&lt;/span&gt;                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_create_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El clasificador es una función pura, trivial de razonar y de probar:&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;# 02-instrument-the-emitter/classify.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_zero_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reached_gate&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;quota_blocked&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="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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;¿Por qué una corrida que escaneó input real creó 0 resultados?&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reached_gate&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;or&lt;/span&gt; &lt;span class="n"&gt;quota_blocked&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="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;        &lt;span class="c1"&gt;# pool servido/inactivo, o la demanda pegó el tope
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;       &lt;span class="c1"&gt;# candidatos + cuota de sobra, nada pasó
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Y la compuerta de emisión enruta a dos eventos de log &lt;em&gt;distintos&lt;/em&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="c1"&gt;# 02-instrument-the-emitter/emit.py
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scanned_items&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;created_total&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;if&lt;/span&gt; &lt;span class="nf"&gt;classify_zero_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quota_blocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_quiet_night&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;# solo observ., sin despertar
&lt;/span&gt;                    &lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;quota_blocked&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quota_blocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_zero_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# la regresión real
&lt;/span&gt;                       &lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El metric filter de la alarma no cambia: sigue haciendo match a&lt;br&gt;
&lt;code&gt;"matcher_zero_results"&lt;/code&gt;. Pero esa línea ahora solo aparece para la falla genuina. En una noche callada el trabajo emite &lt;code&gt;matcher_quiet_night&lt;/code&gt; en su lugar, que &lt;strong&gt;no&lt;/strong&gt; hace match al patrón de la alarma, así que la métrica publica su &lt;code&gt;defaultValue&lt;/code&gt; de 0 y la racha se rompe. La despertada falsa desapareció no porque subimos un umbral, sino porque el emisor dejó de mentir sobre la causa.&lt;/p&gt;
&lt;h2&gt;
  
  
  Paso 4: mantén observable el camino benigno
&lt;/h2&gt;

&lt;p&gt;"No despertar" no es "no registrar". Las noches calladas son una señal real: te dicen que el pool servible se está topando, lo cual es un dato de &lt;em&gt;crecimiento&lt;/em&gt;, no un incidente. Así que el evento benigno recibe su propia métrica, sin alarma:&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;// 03-cause-aware-alarm/quiet-metric.ts: visibilidad, no una despertada&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MetricFilter&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;QuietNightFilter&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;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"matcher_quiet_night"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;metricNamespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp/Jobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MatcherQuietNight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;defaultValue&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ahora el dashboard muestra las dos líneas: regresiones (que despiertan) y noches calladas (que no). Cuando alguien pregunta después "¿por qué cayeron&lt;br&gt;
los matches en la segunda mitad del mes?", la respuesta es una gráfica, no una sesión forense de SQL. Y si las noches calladas suben de manera sostenida, esa es la señal para subir los topes o ensanchar la elegibilidad, una decisión de producto que la alarma vieja sepultaba bajo un incidente falso.&lt;/p&gt;

&lt;h2&gt;
  
  
  Las lecciones del umbral
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No subas el umbral para silenciar una despertada falsa.&lt;/strong&gt; Es el
reflejo, y está al revés. Subirlo (o ensanchar la ventana) hace la alarma
&lt;em&gt;más lenta para atrapar la falla real&lt;/em&gt; mientras de todos modos termina disparando por la benigna. Cambias un falso negativo por un falso positivo demorado. Arregla la ambigüedad en su lugar; deja el umbral apretado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una cobertura en la config de la alarma es un olor.&lt;/strong&gt; &lt;code&gt;datapointsToAlarm: 3&lt;/code&gt;, &lt;code&gt;evaluationPeriods: N&lt;/code&gt;, un sospechosamente redondo &lt;code&gt;&amp;gt; 10&lt;/code&gt;: cada uno es a menudo un humano dándole la vuelta a una métrica ambigua. A veces es la decisión correcta (un solo throttle está bien; alarma sobre la ráfaga). Pero si la &lt;em&gt;causa&lt;/em&gt; es ambigua, ninguna cantidad de contar rachas la resuelve. Pregúntate cuál de las dos tienes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarma sobre la falla, mide el resto.&lt;/strong&gt; Exactamente una señal debería despertar: el estado por el que despertarías a un humano. Todo lo de al lado, el cero benigno, el parpadeo reintentado, la demanda limitada por tasa, es una métrica sin acción de alarma. Despertar sobre estados ambiguos es como las alarmas terminan silenciadas.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Los metric filters literales hacen match a subcadenas.&lt;/strong&gt; Este me
mordió. El &lt;code&gt;FilterPattern.literal('"matcher_zero_results"')&lt;/code&gt; de
CloudWatch hace match a cualquier evento de log que &lt;em&gt;contenga&lt;/em&gt; esa
cadena. Nombra el evento benigno &lt;code&gt;matcher_quiet_night&lt;/code&gt;, &lt;strong&gt;no&lt;/strong&gt;
&lt;code&gt;matcher_zero_results_ok&lt;/code&gt;: el segundo contiene el patrón de la alarma como subcadena y la dispararía de todos modos, derrotando en silencio todo el arreglo. Elige un token sin traslape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;defaultValue: 0&lt;/code&gt; es lo que rompe la racha.&lt;/strong&gt; El filter emite 0 por
cada periodo sin línea que haga match. Ese es el mecanismo que le permite
a una noche callada "publicar un 0" y reiniciar una alarma de datapoints
consecutivos. Sin él, los datapoints faltantes dependen de
&lt;code&gt;treatMissingData&lt;/code&gt; y la lógica de racha se vuelve turbia. Consérvalo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prueba la compuerta, no la plomería.&lt;/strong&gt; La prueba valiosa no es "¿sirve
el metric filter?" (eso es trabajo de AWS). Es "¿el trabajo emite el
&lt;em&gt;evento&lt;/em&gt; correcto para cada estado interno?". Maneja el emisor y afirma
la línea de log:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 04-test-the-gate/test_emit.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_pages_only_on_regression&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NightlyMatcher&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;_process_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="o"&gt;=+&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quota_blocked&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="n"&gt;created&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;with&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture_logs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;logs&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;m&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="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&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;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_zero_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;        &lt;span class="c1"&gt;# regresión → despierta
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_quiet_night&lt;/span&gt;&lt;span class="sh"&gt;"&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;events&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_quiet_when_quota_capped&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NightlyMatcher&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;_process_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="o"&gt;=+&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quota_blocked&lt;/span&gt;&lt;span class="o"&gt;=+&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&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;with&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture_logs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;logs&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;m&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="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&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;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_quiet_night&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;         &lt;span class="c1"&gt;# demanda en tope → callado
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_zero_results&lt;/span&gt;&lt;span class="sh"&gt;"&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;events&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Una checklist reutilizable
&lt;/h2&gt;

&lt;p&gt;Cuando una alarma dispara y sospechas que es ruido, antes de tocar un&lt;br&gt;
umbral:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirma la falla declarada.&lt;/strong&gt; ¿Los datos muestran la condición exacta
que la alarma dice? Si no, la alarma está mal categorizando: un bug de
diseño, no un problema de ajuste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enumera las causas del valor de la métrica.&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;5xx&lt;/code&gt;, &lt;code&gt;lento&lt;/code&gt;,
&lt;code&gt;profundidad de cola&lt;/code&gt;: lista cada razón distinta por la que llega al
valor de la alarma. Si hay más de una, y difieren en si son incidentes,
la métrica es ambigua.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escribe la falla en una oración.&lt;/strong&gt; En términos del estado interno del
sistema, no de su salida. Si la métrica no puede expresar esa oración,
el emisor debe hacerlo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mueve la lógica al emisor.&lt;/strong&gt; Emite una señal distinta por causa.
Despierta sobre la del incidente; mide el resto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cuida las colisiones de subcadena y el &lt;code&gt;defaultValue&lt;/code&gt;.&lt;/strong&gt; (Ve las
trampas.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prueba la clasificación, no la alarma.&lt;/strong&gt; Afirma que el emisor produce
el evento correcto para cada estado.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  La forma de todo esto
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ANTES:  count == 0  ─────────────────────────► alarma ──► despierta (a veces mal)

DESPUÉS:  corrida termina en 0 ─► clasifica(estado interno) ─┬─ "regression" ─► línea warn ─► alarma ─► despierta
                                                             └─ "quiet"      ─► línea info ─► métrica (sin despertar)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un principio: &lt;strong&gt;una alarma debería disparar por una causa, no por una&lt;br&gt;
coincidencia.&lt;/strong&gt; Cuando un solo valor de salida tiene múltiples causas, no&lt;br&gt;
ajustes el umbral: empuja el diagnóstico río arriba, al código que ya sabe&lt;br&gt;
&lt;em&gt;por qué&lt;/em&gt; el número es lo que es, y deja que lo diga en la línea de log. La&lt;br&gt;
alarma se queda como un umbral tonto. La inteligencia vive donde está el&lt;br&gt;
contexto. Y el pager suena solo cuando algo de verdad anda mal.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>sre</category>
    </item>
    <item>
      <title>El día que `pnpm audit` tumbó todos los deploys (y qué correr en su lugar)</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 19 Jul 2026 19:23:46 +0000</pubDate>
      <link>https://dev.to/aws-builders/el-dia-que-pnpm-audit-tumbo-todos-los-deploys-y-que-correr-en-su-lugar-3leh</link>
      <guid>https://dev.to/aws-builders/el-dia-que-pnpm-audit-tumbo-todos-los-deploys-y-que-correr-en-su-lugar-3leh</guid>
      <description>&lt;p&gt;Un deploy de frontend falló. No había cambiado código en el pipeline, nose había subido ninguna dependencia, el lockfile era byte por byte&lt;br&gt;
idéntico al del deploy que había pasado en verde 23 horas antes. El falló:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERR_PNPM_AUDIT_BAD_RESPONSE  The audit endpoint (at
https://registry.npmjs.org/-/npm/v1/security/audits/quick) responded with
410: {"error":"This endpoint is being retired. Use the bulk advisory
endpoint instead."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;npm había retirado el endpoint al que &lt;code&gt;pnpm audit&lt;/code&gt; hace POST. Mi compuerta de seguridad ahora estaba fallando por &lt;em&gt;infraestructura&lt;/em&gt;, no por vulnerabilidades, y como era una compuerta dura, bloqueaba &lt;strong&gt;todos&lt;/strong&gt; los deploys de frontend que iban detrás, incluyendo un arreglo de bug sin relación que estaba esperando para salir.&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-pnpm-audit-osv-scanner-code" rel="noopener noreferrer"&gt;
        blog-pnpm-audit-osv-scanner-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code companion — replacing a dead `pnpm audit` gate with osv-scanner
    &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 — replacing a dead &lt;code&gt;pnpm audit&lt;/code&gt; gate with osv-scanner&lt;/h1&gt;
&lt;/div&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;00-dead-gate/&lt;/code&gt; — what broke. Note it's a &lt;strong&gt;network call&lt;/strong&gt;, not a local
scan; when npm retired the endpoint it failed on infrastructure and
blocked every deploy.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;02-osv-scanner-step/&lt;/code&gt; — scan &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; against the OSV DB. No
endpoint to retire. But osv-scanner is blunt: it fails on &lt;em&gt;any&lt;/em&gt; advisory
across the &lt;em&gt;whole&lt;/em&gt; lockfile, so we discard its exit code (&lt;code&gt;|| true&lt;/code&gt;) and
filter ourselves.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;03-preserve-semantics/audit-prod-gate.mjs&lt;/code&gt; — re-impose the old flags
&lt;code&gt;--prod&lt;/code&gt; (runtime tree from &lt;code&gt;pnpm ls --prod&lt;/code&gt;) and &lt;code&gt;--audit-level high&lt;/code&gt;
(CVSS ≥ 7 from the OSV report). Dev/build deps report but never block.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04-verify/split.sh&lt;/code&gt; — the test that matters: does it block the thing it
should and pass the thing it should, on your real tree?&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The two principles&lt;/h2&gt;

&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A gate that fails on infrastructure is worse than no gate.&lt;/strong&gt; It blocks
good work while…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/blog-pnpm-audit-osv-scanner-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&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;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pnpm audit --prod --audit-level high&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;osv-scanner + filtro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cómo obtiene los datos&lt;/td&gt;
&lt;td&gt;POST al endpoint de audit de npm&lt;/td&gt;
&lt;td&gt;escanea el lockfile contra la base de datos de OSV&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Falla cuando retiran el endpoint&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;sí, bloquea todos los deploys&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no (no hay endpoint)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Solo prod&lt;/td&gt;
&lt;td&gt;integrado (&lt;code&gt;--prod&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;reconstruido desde &lt;code&gt;pnpm ls --prod&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Umbral de severidad&lt;/td&gt;
&lt;td&gt;integrado (&lt;code&gt;--audit-level&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;reconstruido del CVSS en el reporte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Depende de&lt;/td&gt;
&lt;td&gt;que npm mantenga viva una API legada&lt;/td&gt;
&lt;td&gt;un binario de escáner versionado + una base de datos pública&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Una compuerta que falla por infraestructura es peor que no tener compuerta.&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No terceerices un chequeo de seguridad a un endpoint de un
proveedor.&lt;/strong&gt; Escanea un artefacto que tú controlas (el lockfile) contra una base de datos. Los endpoints se retiran; tu lockfile no.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Qué era &lt;code&gt;pnpm audit&lt;/code&gt; en realidad
&lt;/h2&gt;

&lt;p&gt;Parece un escaneo local. No lo es. &lt;code&gt;pnpm audit&lt;/code&gt; serializa tu árbol de&lt;br&gt;
dependencias, le hace POST al endpoint de audit del registry, y renderiza los advisories que regresen. El chequeo &lt;em&gt;es&lt;/em&gt; una llamada de red a una API específica de npm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://registry.npmjs.org/-/npm/v1/security/audits/quick
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm deprecó ese endpoint (y su hermano &lt;code&gt;/audits&lt;/code&gt;) en favor de una API más nueva de bulk-advisory, y con el tiempo puso los dos en &lt;strong&gt;HTTP 410 Gone&lt;/strong&gt;. Cualquier herramienta que siga llamando al endpoint viejo, incluyendo el &lt;code&gt;pnpm audit&lt;/code&gt; de un montón de imágenes de CI fijadas, ahora falla sin condición. No es "no se encontraron vulnerabilidades", no es "se encontraron algunas": es un error duro, en cada corrida.&lt;/p&gt;

&lt;p&gt;La compuerta había estado en verde por meses. Nada de mi lado cambió. Un tercero retiró una API y mi pipeline de deploy se puso en rojo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Los tres arreglos equivocados
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ponle &lt;code&gt;|| true&lt;/code&gt;.&lt;/strong&gt; La forma más rápida de desbloquear. También la peor: convertiste una compuerta de seguridad en un comentario. No da ninguna protección y miente en verde para siempre. Si la compuerta no vale la pena arreglarla, &lt;em&gt;bórrala&lt;/em&gt;, no dejes una decorativa en la que la gente confía.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sube la versión de la herramienta.&lt;/strong&gt; Sospecha razonable: quizá un &lt;code&gt;pnpm&lt;/code&gt; más nuevo usa el endpoint nuevo. Lo revisé: tanto la versión fijada como la última le hacen POST al &lt;code&gt;/audits/quick&lt;/code&gt; retirado. El endpoint ya no existe para nadie; subir de versión no lo conjura de vuelta. (Puede variar conforme pnpm migre, pero "ojalá la herramienta se haya movido a la API nueva" no es un plan.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agrega una lista de ignorados.&lt;/strong&gt; Cámbiate a un escáner, luego suprime cada hallazgo que salte. Esto invierte la compuerta: ahora bloquea por default y tú justificas las excepciones con la mano, así que cada advisory nuevo, incluyendo el ruido de dev que el viejo flag &lt;code&gt;--prod&lt;/code&gt; excluía gratis, se vuelve un triaje manual. La lista de negados crece, y el día que caiga uno real queda sepultado en el mismo movimiento con el que descartas el ruido.&lt;/p&gt;

&lt;p&gt;El arreglo correcto mantiene la compuerta con sentido: la misma pregunta, otra fuente de datos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué debería afirmar la compuerta en realidad
&lt;/h2&gt;

&lt;p&gt;Antes de reemplazarla, escribe qué &lt;em&gt;significaban&lt;/em&gt; los flags viejos, porque un escáner no los va a reproducir a menos que lo obligues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm audit &lt;span class="nt"&gt;--prod&lt;/span&gt; &lt;span class="nt"&gt;--audit-level&lt;/span&gt; high
             ▲            ▲
             │            └─ piso de severidad: solo HIGH/CRITICAL.
             │               MEDIUM/LOW se reportan, nunca bloquean.
             └─ alcance: solo dependencias de RUNTIME. Las de dev/build son
                ruidosas &lt;span class="o"&gt;(&lt;/span&gt;bundlers, frameworks de pruebas, sus árboles
                transitivos&lt;span class="o"&gt;)&lt;/span&gt; y no llegan a los usuarios.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese segundo flag es el que la gente olvida, y es de carga estructural. La mayor parte del ruido de CVEs en un proyecto de frontend vive en el árbol de dev. Un reemplazo que escanee el lockfile &lt;em&gt;entero&lt;/em&gt; va a "encontrar más vulnerabilidades" y se va a sentir más minucioso, cuando en realidad nada más bloquea deploys por tooling de build que nunca llega a un usuario. &lt;/p&gt;

&lt;p&gt;Preservar &lt;code&gt;--prod&lt;/code&gt; no es un lujo: es la diferencia entre una compuerta que la gente respeta y una que rodean.&lt;/p&gt;

&lt;h2&gt;
  
  
  El reemplazo: escanea el lockfile, no un endpoint
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/google/osv-scanner" rel="noopener noreferrer"&gt;osv-scanner&lt;/a&gt; lee &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; directo y checa cada paquete contra la &lt;a href="https://osv.dev" rel="noopener noreferrer"&gt;base de datos de OSV&lt;/a&gt;. Ningún POST a npm, nada que retirar:&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;# 02-osv-scanner-step/audit.sh (forma)&lt;/span&gt;
osv-scanner scan &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="nt"&gt;--lockfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pnpm-lock.yaml &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; osv.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pero osv-scanner es tosco a propósito: reporta &lt;strong&gt;todos&lt;/strong&gt; los advisories en &lt;strong&gt;toda&lt;/strong&gt; severidad a lo largo del lockfile &lt;strong&gt;entero&lt;/strong&gt;, y sale con código distinto de cero si encuentra cualquier cosa. Apúntalo a un proyecto real y va a fallar por una dependencia transitiva de solo-dev de baja severidad desde el día uno. Tal cual sale de la caja, es la trampa de "escanear el lockfile entero" de la sección anterior.&lt;/p&gt;

&lt;p&gt;Así que no usamos su código de salida. Tomamos su JSON y le volvemos a imponer las dos reglas de la compuerta vieja nosotros mismos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preservando &lt;code&gt;--prod&lt;/code&gt; y &lt;code&gt;--audit-level high&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Dos insumos, un script chico:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;osv-scanner scan &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="nt"&gt;--lockfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pnpm-lock.yaml &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; osv.json &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;pnpm &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;--prod&lt;/span&gt; &lt;span class="nt"&gt;--depth&lt;/span&gt; Infinity &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; prod.json
node audit-prod-gate.mjs   &lt;span class="c"&gt;# sale con 1 solo ante un HIGH/CRITICAL de runtime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;El &lt;code&gt;|| true&lt;/code&gt; en el escaneo: el "distinto de cero ante cualquier
advisory" de osv-scanner mataría el paso antes de que corra mi filtro.
Quiero sus hallazgos, no su veredicto.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pnpm ls --prod&lt;/code&gt; da el árbol de dependencias de runtime, el alcance de
&lt;code&gt;--prod&lt;/code&gt;, reconstruido. Lo que no esté ahí es de solo dev/build y no
puede bloquear.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La compuerta en sí (archivo completo en la carpeta de código):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 03-preserve-semantics/audit-prod-gate.mjs (núcleo)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HIGH_CVSS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// HIGH empieza en 7.0, CRITICAL en 9.0: los dos bloquean.&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prodNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                       &lt;span class="c1"&gt;// aplana pnpm ls --prod&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&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="nx"&gt;prodNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dependencies&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;span class="nx"&gt;prodTree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dependencies&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;blocking&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;osv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packages&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;prodNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;package&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// solo-dev → salta&lt;/span&gt;
  &lt;span class="k"&gt;for &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;g&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groups&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="c1"&gt;// uno por advisory&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_severity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;HIGH_CVSS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// CVSS de OSV&lt;/span&gt;
      &lt;span class="nx"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;package&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;package&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&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;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&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="p"&gt;}&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="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="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;&lt;code&gt;groups[].max_severity&lt;/code&gt; es el puntaje CVSS que osv-scanner ya calculó: no re derivo la severidad, solo le pongo el umbral. La pertenencia a prod por nombre es conservadora a propósito: si un paquete aparece en cualquier parte del árbol de runtime, trato sus advisories como enviables. Mejor bloquear uno real que dejarlo pasar por un tecnicismo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verificar que hace lo correcto
&lt;/h2&gt;

&lt;p&gt;El valor de una compuerta de seguridad está por completo en su&lt;br&gt;
comportamiento en los bordes, así que prueba ambas direcciones contra el lockfile real. La mía encontró 8 advisories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total 2 packages affected by 8 known vulnerabilities
(0 Critical, 3 High, 3 Medium, 2 Low)
  undici     7.26.0   3× HIGH (CVSS 7.4–7.5)   ← solo-dev
  protobufjs 7.6.2    1× MEDIUM (CVSS 5.3)      ← runtime
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;De manera ingenua, "3 vulnerabilidades HIGH" suena a que la compuerta&lt;br&gt;
debería gritar. No debería, y aquí está la prueba de que los dos filtros funcionan:&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;# 04-verify/split.sh&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;pnpm why undici &lt;span class="nt"&gt;--prod&lt;/span&gt;
   &lt;span class="o"&gt;(&lt;/span&gt;empty&lt;span class="o"&gt;)&lt;/span&gt;                         &lt;span class="c"&gt;# undici NO está en el árbol de runtime → excluido&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;pnpm why protobufjs &lt;span class="nt"&gt;--prod&lt;/span&gt;
   protobufjs 7.6.2
   └─┬ amazon-chime-sdk-js         &lt;span class="c"&gt;# runtime, pero MEDIUM &amp;lt; HIGH → debajo de la compuerta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Así que la compuerta &lt;strong&gt;pasa&lt;/strong&gt;, correctamente. Los tres advisories HIGH están en tooling de build que nunca se envía; el único advisory de runtime está debajo del piso de severidad. Para probar que no solo está por encima, baja el umbral a 5.0 en local: entonces marca el &lt;code&gt;protobufjs&lt;/code&gt; de runtime y falla. La compuerta discrimina; no está atorada en abierto.&lt;/p&gt;

&lt;p&gt;Esta es la prueba que importa. No "¿corre el escáner?" sino "¿bloquea lo que debe y deja pasar lo que debe?", sobre tu árbol real.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Descarta el código de salida del escáner, quédate con sus hallazgos.&lt;/strong&gt; osv-scanner sale distinto de cero ante &lt;em&gt;cualquier&lt;/em&gt; advisory. Si no le pones &lt;code&gt;|| true&lt;/code&gt; (o configuras lo suyo), falla el paso antes de que corra tu filtro de prod/severidad, y vuelves a bloquear por ruido del árbol de dev.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fija la versión del escáner.&lt;/strong&gt; &lt;code&gt;releases/latest&lt;/code&gt; puede cambiar la forma de la salida o el comportamiento de falla por default entre corridas, justo la inestabilidad de la que estás tratando de escapar. Fija &lt;code&gt;v2.4.0&lt;/code&gt; (o la que sea), sube de versión a propósito.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haz la descarga consciente de la arquitectura.&lt;/strong&gt; Los runners ARM
autoalojados y el &lt;code&gt;ubuntu-latest&lt;/code&gt; x86 necesitan binarios distintos.
&lt;code&gt;case "$(uname -m)" in aarch64|arm64) …&lt;/code&gt; le gana a un &lt;code&gt;_amd64&lt;/code&gt; fijo a mano.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide qué significa un CVSS ausente.&lt;/strong&gt; Algunos advisories no traen puntaje CVSS. &lt;code&gt;parseFloat("")&lt;/code&gt; es &lt;code&gt;NaN&lt;/code&gt;, así que no cruza el umbral y no bloquea. Es una decisión deliberada (empatar con el viejo comportamiento etiquetado por severidad), pero tómala a propósito, y loguea el conteo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Que &lt;code&gt;pnpm audit&lt;/code&gt; parezca local engaña a todos.&lt;/strong&gt; Es una llamada de red. Trata cualquier "audit" que le llame a un registry como una dependencia de disponibilidad de tu pipeline, no como un chequeo local.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lo que salté a propósito
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auditar dependencias de dev como compuerta bloqueante.&lt;/strong&gt; No se envían.
Las reporto (osv-scanner imprime las 8) pero solo bloquea un
HIGH/CRITICAL de &lt;em&gt;runtime&lt;/em&gt;, igual que el viejo &lt;code&gt;--prod&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subir versiones automático.&lt;/strong&gt; Renovate/Dependabot es otro ciclo aparte. El trabajo de esta compuerta es &lt;em&gt;detener un deploy malo&lt;/em&gt;, no abrir PRs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  La forma de todo esto
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ANTES:  árbol de dependencias ─POST─► endpoint de npm audit ──► veredicto ──► compuerta
                                             │
                                        (retirado: 410) ──► compuerta en rojo para siempre

DESPUÉS:  pnpm-lock.yaml ──► osv-scanner ──► hallazgos JSON ─┐
          pnpm ls --prod ──► conjunto de paquetes de runtime ┴─► filtro
                                    (prod ∧ CVSS ≥ 7) ──► compuerta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un principio, dos mitades. Una compuerta de seguridad debe fallar por&lt;br&gt;
&lt;strong&gt;vulnerabilidades, no por el clima&lt;/strong&gt;, así que escanea un artefacto que tú controlas contra una base de datos, no el endpoint de un proveedor que te puede dar 410 por debajo. Y cuando cambies de herramienta, migra la &lt;em&gt;semántica&lt;/em&gt;, no solo el comando: los flags viejos codificaban decisiones reales (solo runtime, solo HIGH/CRITICAL), y un reemplazo que los suelta en silencio no es más minucioso, es una compuerta distinta y más ruidosa que la gente va a aprender a ignorar.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
