diff --git a/.cursor/skills/agent-pool/SKILL.md b/.cursor/skills/agent-pool/SKILL.md new file mode 100644 index 00000000..32dac9c9 --- /dev/null +++ b/.cursor/skills/agent-pool/SKILL.md @@ -0,0 +1,54 @@ +--- +name: agent-pool +description: Opera o pool de agentes Cursor Cloud do Teqo (supervisor remoto stateless em GitHub Actions) — start/stop/pause/resume/status remotos, triage de workers falhos, pré-requisitos (secrets) e smoke test. Usar quando o usuário pedir para ligar/desligar/pausar o pool, ver status dos workers, "pool de agentes", "orquestrador", triagem de Issue que o pool mandou a blocked, ou configurar CURSOR_API_KEY. +--- + +# Agent pool — operação do supervisor remoto + +O pool mantém até **5 Cursor Cloud Agents** (configurável, máx 8) rodando `work-issue` sobre Issues `ready` elegíveis para autonomia. O supervisor é o workflow **`.github/workflows/agent-pool.yml`** (tick stateless a cada 10 min + a cada merge em `stage` + sob dispatch) rodando `scripts/agent-pool.mjs` — nenhum processo local, laptop fechado não importa. Arquitetura, elegibilidade (`isAutonomousClaimable`), anti-race (claim coordenado pelo supervisor) e ciclo de vida: `docs/plans/agent-pool-orchestrator.md`. + +**O pool NUNCA faz `pnpm agent:promote`** — promote `stage→main` continua humano. + +## Pré-requisitos (uma vez) + +1. **Repo secret `CURSOR_API_KEY`** (Settings → Secrets and variables → Actions) — chave de cursor.com/dashboard/api. Sem ela o tick live morre antes de spawnar (fail-closed). +2. Workflow mergeado em `main` — `schedule` e o trigger de merge em `stage` **só rodam da default branch**. Antes do promote, use dispatch na branch (`--ref`). +3. Fila com Issues `ready` (`pnpm agent:status`) e nenhum audit solitário em curso. +4. Validar o ambiente: `pnpm agent:pool -- doctor` (gh auth, repo variables, chave Cursor, tabela de modelos `/v1/models`). + +## Operação remota (canal canônico = workflow_dispatch) + +```bash +pnpm agent:pool -- start # liga (default 5 slots) +pnpm agent:pool -- start -- --max-slots 3 +pnpm agent:pool -- stop # para de spawnar; ativos drenam até o merge +pnpm agent:pool -- pause # audit solitário / manutenção +pnpm agent:pool -- resume +pnpm agent:pool -- status # leitura local (config, slots, fila, exclusões) +``` + +Os wrappers disparam `gh workflow run agent-pool.yml -f action=…`; acompanhe com `gh run list --workflow agent-pool.yml --limit 3` (o job summary de cada run é a superfície remota de status: slots, spawns, falhas, decisão do tick). Estado escalar vive em repo variables `POOL_*` (Settings → Variables); workers/Issues em voo são derivados — Issues `in-progress` com marcador `pool-worker` nos comentários. + +## Ciclo de vida de uma Issue no pool + +`ready` → (tick: claim coordenado, flip + marcador) → `in-progress` → worker spawnado com o `model:` da Issue (fallback `composer-2.5`) → PR `--base stage` com `Closes #N` → CI green → auto-merge → `done` (flip determinístico do CI) → tick arquiva o agente e repõe o slot. + +- **Falha terminal** (run ERROR/CANCELLED/EXPIRED ou fim sem PR): tick comenta, move a Issue para `blocked` e arquiva o agente. **Triage humana**: ler o run em cursor.com/agents, decidir — re-`ready` manual se transitório (o circuit breaker recusa a 3ª tentativa automática), corrigir a spec se sistêmico. +- **Worker travado**: archive em cursor.com/agents → o próximo tick reconcilia como falha documentada. +- **Duplicata**: impossível em condições normais (alocador único + lock otimista + `agentId` idempotente); o tick cancela runs extras se alguém spawnar manualmente. +- **`needs:migration`/`serializes:[migrations]`**: o tick não spawna enquanto houver PR aberto tocando schema (`migration-lock`) — re-avalia a cada tick. +- **Audit solitário**: `pause` antes, `resume` depois (a skill `engineering-audit` lembra). + +## Smoke remoto (aceite — rodar na primeira ativação) + +1. 2 Issues `kind:chore` triviais, `ready`, sem `model:` → `start --max-slots 2` → 2 agents `pool-i-…` em cursor.com/agents e as 2 Issues `in-progress` com comentário de claim. Zero duplicata. +2. 1 Issue `blocked` e 1 `needs:consent` plantadas na fila → permanecem intactas. +3. 1 Issue com `model: cursor-grok-4.5-high` → run criado com esse modelo (doctor lista os ids válidos). +4. Workers abrem PRs `--base stage`; merge → slot reposto automaticamente no tick seguinte (≤10 min). +5. Fila drenada → `POOL_ENABLED=false` sozinho. `stop` → próximo tick sem spawns. +6. Tudo com o laptop desligado (start pelo browser/celular). + +## Se algo quebrar + +- `tick` vermelho no Actions: ler o log do job — gh sem `actions:write` (permissões do workflow), `CURSOR_API_KEY` ausente/errada, ou API da Cursor fora. O tick seguinte re-tenta; nada fica em estado intermediário perigoso (claim revertido em falha de spawn). +- "You’ve reached the limit" no spawn: arquivar agents antigos em cursor.com/agents (cap do plano: 8 simultâneos no Pro; o pool usa ≤5 para deixar margem a humanos). diff --git a/.cursor/skills/engineering-audit/SKILL.md b/.cursor/skills/engineering-audit/SKILL.md index 52a68855..de18f00f 100644 --- a/.cursor/skills/engineering-audit/SKILL.md +++ b/.cursor/skills/engineering-audit/SKILL.md @@ -5,7 +5,7 @@ description: Runs a Pass-style engineering audit of the Teqo codebase — code s # Engineering Audit (Pass N) -**Audit solitário (não paralelizável):** quando esta skill roda, o desenvolvimento paralelo **pausa** — nenhum outro agente trabalhando no repo. O agente do audit também **executa as remediações P0/P1 na mesma sessão** para trazer o projeto aos trilhos (não só registra); P2/P3 seguem o fluxo normal de ledger. +**Audit solitário (não paralelizável):** quando esta skill roda, o desenvolvimento paralelo **pausa** — nenhum outro agente trabalhando no repo. O agente do audit também **executa as remediações P0/P1 na mesma sessão** para trazer o projeto aos trilhos (não só registra); P2/P3 seguem o fluxo normal de ledger. Se o **agent-pool** estiver ligado, pause-o no início do audit (`gh workflow run agent-pool.yml -f action=pause`) e retome ao final (`-f action=resume`); o tick recusa spawn enquanto `POOL_PAUSED=true`. Read-only audit that sweeps the Teqo codebase for code smells, drift from documented patterns, and consolidation opportunities, then produces a findings ledger and a remediation plan. **No fixes in the audit run** — deliverables are updated docs, presented for sign-off before writing — exceto as remediações P0/P1 declaradas acima. diff --git a/.github/workflows/agent-pool.yml b/.github/workflows/agent-pool.yml new file mode 100644 index 00000000..84b9c8de --- /dev/null +++ b/.github/workflows/agent-pool.yml @@ -0,0 +1,75 @@ +# Agent pool supervisor (docs/plans/agent-pool-orchestrator.md). +# +# Stateless tick: reconciles pool claims and spawns Cursor Cloud workers up to +# POOL_MAX_SLOTS over `ready` autonomous-eligible issues. Three triggers: +# - workflow_dispatch — the canonical human channel (start/stop/pause/resume) +# - schedule */10min — the periodic reconcile (default branch only) +# - pull_request closed on stage — immediate slot replenish after a merge +# Schedule and PR triggers only run from the default branch: they activate +# after the human promote to main; until then dispatch on the branch works. +# +# Secrets: CURSOR_API_KEY (repo secret) — Cursor Cloud Agents API key. +# GITHUB_TOKEN needs actions: write (pool state lives in repo variables). +name: Agent pool supervisor + +on: + schedule: + - cron: '*/10 * * * *' + pull_request: + types: [closed] + branches: [stage] + workflow_dispatch: + inputs: + action: + description: 'Ação do pool' + required: true + type: choice + options: + - start + - stop + - pause + - resume + - tick + - status + maxSlots: + description: 'Slots simultâneos (só com action=start; default 5, máx 8)' + required: false + type: string + +# Single-allocator invariant: ticks never overlap. +concurrency: + group: agent-pool + cancel-in-progress: false + +permissions: + actions: write + contents: read + issues: write + pull-requests: read + +jobs: + tick: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + # The pool scripts are dependency-free .mjs (gh CLI + native fetch) — no + # pnpm install, ticks stay under a minute. + - name: agent:pool workflow + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + ACTION: ${{ inputs.action }} + MAX_SLOTS: ${{ inputs.maxSlots }} + run: | + # schedule / pull_request closed → reconcile tick; dispatch → the chosen action. + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + args=(--action "$ACTION" --actor "$GITHUB_ACTOR") + if [ -n "$MAX_SLOTS" ]; then args+=(--max-slots "$MAX_SLOTS"); fi + else + args=(--action tick --actor "$GITHUB_ACTOR") + fi + node scripts/agent-pool.mjs workflow "${args[@]}" diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index f3841baf..9bf5b97e 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -40,6 +40,7 @@ stage → CI stage green → (humano) pnpm agent:promote --i-am-human → main | `pnpm agent:status` | Read-only: overview, fila na ordem do claim (com `model:`), mermaid, bloqueios, sugestões de consolidação | | `pnpm agent:prioritize -- ` | Troca a label `prio:*` | | `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` → harvest em guardrail (`docs/GUARDRAILS.md`) | +| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Inspeção local do pool de workers Cloud; start/stop/pause **remotos** via `gh workflow run agent-pool.yml -f action=…` (ver "Agent pool" abaixo) | | `pnpm agent:promote -- --i-am-human` | **Humano:** PR `stage→main` + merge se CI green | | `pnpm db:seed:minimal` | DB mínimo sintético (contrato: [`scripts/lib/seed-minimal-manifest.mjs`](../scripts/lib/seed-minimal-manifest.mjs), pin `tests/unit/seedMinimalManifest.unit.spec.ts`) | | `pnpm db:refresh:stage` | **Humano:** nova Neon branch `stage` de prod + swap do secret `STAGE_DATABASE_URL` (requer `NEON_API_KEY`) | @@ -73,6 +74,10 @@ Fast gate local do agente (iteração): `pnpm gate:fast` (lint + typecheck + tes `.cursor/environment.json` instala deps, sobe Postgres nativo (`ensure-postgres.sh` — VMs Cloud não rodam Docker/compose), migra e roda `db:seed:minimal` — clone + `gh` + DB mínimo bastam. Sem secrets de stage/prod. Hooks do repo são portáteis (paths relativos em `.cursor/hooks.json`); hooks pessoais (ex.: impeccable) ficam no `~/.cursor/hooks.json` do usuário. +## Agent pool (orquestrador de workers Cloud) + +Supervisor **remoto** que mantém até 5 Cursor Cloud Agents rodando `work-issue` sobre Issues `ready` elegíveis para autonomia (label `ready`, sem `blocked`/`requirements-changed`/`needs:consent`, deps satisfeitas; `needs:migration` cede ao `migration-lock`). Implementação: `.github/workflows/agent-pool.yml` (tick stateless: `schedule */10min` + `pull_request` closed em `stage` + `workflow_dispatch`) rodando `scripts/agent-pool.mjs`; workers spawnados via Cloud Agents API v1 com o `model:` da Issue (fallback `composer-2.5`). Canal canônico humano: `gh workflow run agent-pool.yml -f action=start|stop|pause|resume` (wrappers: `pnpm agent:pool -- start|stop|pause|resume|status|doctor`). Estado escalar em repo variables `POOL_*`; ativos derivados (marcadores `pool-worker` nos comentários + status dos runs). Claim coordenado pelo supervisor (alocador único, sem race); falha terminal → Issue a `blocked` para triagem; fila drenada → pool se desliga. **Nunca** chama `agent:promote`. Requer repo secret `CURSOR_API_KEY`; `schedule`/trigger de PR só ativam após merge em `main`. Arquitetura e runbook: [`docs/plans/agent-pool-orchestrator.md`](plans/agent-pool-orchestrator.md) + skill `agent-pool`. + ## Guardrails progressivos `pnpm agent:file-miss` → Issue `kind:agent-miss` → harvest periódico → guardrail programático (spec `codebaseConventions`, ESLint, check CI) → ledger `docs/GUARDRAILS.md`. Padrão já fichado: "migration sem atualizar seed:minimal". diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 6f98399e..de5ceb0b 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -13,6 +13,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. 3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas. 4. **Campaign LGPD consent texts are not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy. The C2 supporter registry (merged to `main`, production-blocked) additionally requires `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'` (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist). +**Recently resolved (2026-07-30):** **Agent pool orchestrator** — supervisor remoto stateless que mantém até 5 Cursor Cloud Agents rodando `work-issue` sobre Issues `ready` autônomas: workflow `.github/workflows/agent-pool.yml` (tick `*/10min` + merge em `stage` + `workflow_dispatch` como canal canônico) rodando `scripts/agent-pool.mjs` (zero deps npm); elegibilidade fail-closed `isAutonomousClaimable` (`blocked`/`requirements-changed`/`needs:consent` nunca claimadas; `needs:migration` cede ao `migration-lock`); claim coordenado pelo supervisor (alocador único + lock otimista + `agentId` idempotente `bc-` — zero duplicata por construção); workers spawnados via Cloud Agents API v1 com o `model:` da Issue (fallback `composer-2.5`); estado escalar em repo variables `POOL_*`, ativos derivados (marcadores `pool-worker` + status dos runs); falha terminal → Issue a `blocked` + archive; fila drenada → auto-stop. `buildClaimQueue` extraído de `agent-claim` (comportamento idêntico, pinado). Nunca chama `agent:promote`. Requer repo secret `CURSOR_API_KEY`; `schedule` ativa após promote a `main`. Ops: skill `agent-pool`; plano: [`docs/plans/agent-pool-orchestrator.md`](docs/plans/agent-pool-orchestrator.md). + **Recently resolved (2026-07-30):** **CI/dev workflow + desbloqueio PR #50** — o PR #50 (B53) ficou BLOCKED por duas causas de infra empilhadas no job `migration-lock` (sem `actions/checkout` → `gh` sem `.git`; depois `GraphQL: Resource not accessible by integration` → `GITHUB_TOKEN` sem `pull-requests: read`) e por um spec int frágil ao `db:seed:minimal` (`homeSearchLeaderships` assertava conjunto global com nome fixo `'Lider'`, que casa word-start com `Seed Liderança Um` do seed). O incidente gerou: (a) política **"Dono do PR, dono do CI"** (`docs/AGENT-OPS.md`) — done = PR mergeado em stage com CI verde, falha fora da feature também é do dono; (b) `ci-pr.yml` reescrito em **jobs paralelos**; (c) **testes afetados pelo diff** — `scripts/test-affected.mjs` (`vitest --changed` vs base do PR) e `scripts/e2e-affected.mjs` + manifesto `scripts/lib/e2e-affected-manifest.mjs`, fallback full em paths de risco (núcleo puro em `scripts/lib/test-affected-core.mjs`, pins unit); e2e entra bloqueante no PR; (d) `ci.yml` (main) virou rede de segurança completa (seed:minimal + build + e2e full); stage segue smoke; (e) fast gate DRY: scripts `gate:fast`/`gate:push`, `.husky/pre-push` roda `gate:push` (knip fora, escape `--no-verify`). **Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Also the same day: **C2** Cadastro nominal de apoiadores (`supporter` join `Contact`↔campanha with optional `nucleus`, migration `20260718_222656_add_supporter` with `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent keys `apoiador-cadastro` / `apoiador-intencao-voto` resolved via a generalized `campaignConsent.ts` that fails closed, `/campanha/apoiadores` vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, `lideranca` has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos") — **renamed to `activity` / `/campanha/atividades` by C13 on 2026-07-25**; the old names survive only in frozen migration history. Also the same day: **B2** map foundation — static TopoJSON for Bahia municipalities + identity territories, `bahiaMunicipalityCodes`, helpers in `bahiaGeometries`, re-runnable `pnpm build:geometries` (no migration, no UI; Leaflet is B3). Post-`/simplify` scale follow-ups for that delivery are registered as **B5** (`docs/plans/escala-dry-pos-b2.md`). Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below). diff --git a/docs/plans/agent-pool-orchestrator.md b/docs/plans/agent-pool-orchestrator.md new file mode 100644 index 00000000..74ffd947 --- /dev/null +++ b/docs/plans/agent-pool-orchestrator.md @@ -0,0 +1,511 @@ +--- +name: Agent pool orchestrator +overview: 'Supervisor remoto stateless (GitHub Actions tick) que mantém até 5 Cursor Cloud Agents rodando work-issue sobre Issues ready; claim coordenado pelo supervisor, modelo da Issue no spawn, estado em repo variables + derivação; promote stage→main continua humano.' +todos: + - id: fase0-spike + content: 'Spike API Cursor: GET /v1/models, spawn com model explícito, agentId idempotente, boot real' + status: pending + - id: fase1-fundacao + content: 'PR A: eligibility + state + CLI status/tick --dry-run + testes unit' + status: pending + - id: fase2-spawn + content: 'PR B: cursor client + prompt + models + claim coordenado + workflow dispatch' + status: pending + - id: fase3-ciclo + content: 'PR C: schedule + replenish + failure path + migration cap + pause + auto-stop' + status: pending + - id: fase4-ops + content: 'PR D: skill agent-pool + AGENT-OPS + CHANGELOG + smoke remoto' + status: pending +isProject: false +--- + +# Orquestrador de pool de agentes Cursor Cloud (Teqo) + +## 1. Resumo executivo + +Um **supervisor remoto stateless** mantém até **5 Cursor Cloud Agents** em paralelo, cada um +executando `work-issue` sobre uma GitHub Issue `ready` e elegível para autonomia. O supervisor +é um **workflow do GitHub Actions** (`.github/workflows/agent-pool.yml`) que roda +`node scripts/agent-pool.mjs tick` a cada 10 minutos, a cada merge em `stage`, e sob +`workflow_dispatch` — nenhum processo local, nenhum daemon, laptop fechado não importa. + +A cada tick, um script Node determinístico (testável, sem deps npm): lê o estado do pool +(repo variables), deriva os workers ativos (API Cursor + labels das Issues), reconcilia falhas, +calcula `gap = maxSlots - ativos`, **claima ele mesmo** as próximas Issues elegíveis (flip +`ready→in-progress` com lock otimista — alocador único, sem race) e spawna workers via +`POST https://api.cursor.com/v1/agents` com o `model:` do frontmatter da Issue (fallback +`composer-2.5`). Para quando o humano mandar (`action=stop`) ou quando a fila drenar. +`agent:promote` nunca é chamado — promote `stage→main` continua humano. + +Decisão central: o supervisor é **GitHub Actions**, não um Cursor Automation — o tick é lógica +determinística com testes unitários, e cada tick de Automation queimaria um slot de agente (de 8 +do plano Pro) + tokens a cada 10 min. Automation fica documentado como plano B. + +## 2. Arquitetura (supervisor remoto obrigatório) + +### Três camadas + +```mermaid +flowchart TB + subgraph humano [Humano] + Cmd["gh workflow run agent-pool.yml -f action=start|stop|status"] + end + + subgraph sup [Supervisor remoto — GitHub Actions] + Trig["Triggers: schedule */10min · pull_request closed em stage · workflow_dispatch"] + Tick["pnpm agent:pool tick — scripts/agent-pool.mjs"] + Vars["Estado: repo variables POOL_*"] + Derive["Ativos derivados: Cursor API + labels GitHub"] + Trig --> Tick + Tick <--> Vars + Tick <--> Derive + end + + subgraph workers [Workers — Cursor Cloud Agents, cap 5] + Spawn["POST /v1/agents — prompt work-issue + model da Issue"] + W["worker × N: claim pré-feito → implementa → PR base stage → watch CI até merge"] + Spawn --> W + end + + Cmd -->|"workflow_dispatch (único canal)"| Trig + Tick -->|"claim coordenado + spawn até gap"| Spawn + W -->|"PR + auto-merge CI green"| Merge["merge em stage → issue-done-on-stage-merge.yml flip done"] + Merge -->|"trigger pull_request closed"| Trig +``` + +### Modelo de reconciliação: **stateless tick** (escolhido) + +Cada tick é um job curto (< 2 min) e idempotente que executa, nesta ordem: + +1. **Lê config** — repo variables: `POOL_ENABLED`, `POOL_MAX_SLOTS` (default 5), `POOL_PAUSED`, + `POOL_STARTED_AT`, `POOL_STARTED_BY`. +2. **Sai cedo** se `POOL_ENABLED != true` ("pool desligado") ou `POOL_PAUSED == true` + ("pausado — audit solitário"). +3. **Deriva workers do pool** — Issues `in-progress` cujo comentário de claim carrega o marcador + do pool (`pool-worker`) cruzadas com os runs da API Cursor (`GET /v1/agents/{id}/runs/{runId}`): + - run terminal **e** Issue `done` (CI flipou no merge) → slot livre; `POST /archive` no agente. + - run terminal **e** Issue `in-progress` com PR aberto → auto-merge em voo; slot **ocupado**, + re-avalia no próximo tick (grace 60 min → humano). + - run terminal **e** Issue `in-progress` sem PR, ou run `ERROR`/`CANCELLED`/`EXPIRED` → + **falha terminal documentada**: comentário na Issue, flip `in-progress→blocked` + (sai da fila, humano triage), archive do agente, circuit breaker (≥2 falhas na mesma Issue + → nunca re-enfileira; conta via comentários do pool). + - run não-terminal → slot ocupado. +4. **Gargalo de migrations** — mesma query do job `migration-lock` + (`gh pr list --state open --json files`): se ≥1 PR aberto toca `src/migrations/` ou + `payload-types.ts`, Issues `needs:migration` / `serializes:[migrations]` saem da fila **deste + tick** (re-avaliadas no próximo). +5. **Fila elegível** — `isAutonomousClaimable` (seção 3) ordenada por prio → mais antiga + (paridade com `agent:claim`). +6. **Spawn** — `gap = maxSlots - ativos`; claima e spawna até `gap` workers (seção 4 e 5). +7. **Auto-stop** — fila elegível vazia **e** zero ativos → `POOL_ENABLED=false` + sumário + "pool drenado". Fila vazia com ativos → não spawna, continua tickando até drenar. +8. **Sumário** — escreve GHA job summary (markdown: slots, fila, spawns, falhas) — é a superfície + remota de status no browser. + +Ticks são **serializados** por `concurrency: group: agent-pool, cancel-in-progress: false` — +invariante de alocador único. O código executado pelo tick é sempre o da **default branch** +(schedule e `pull_request` rodam sobre `main`); `workflow_dispatch` permite escolher branch +para teste. + +Justificativa vs. "supervisor session" (um Cloud Agent 24/7 com loop interno): rejeitado — +timeout de sessão não documentado, queima 1 slot dos 8 permanentemente, LLM fazendo trabalho de +cron, e não há nada no loop que um tick de 30 s não faça. + +### Onde persiste estado + +| Dado | Onde | Por quê | +| --------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled`, `maxSlots`, `paused`, `startedAt`, `startedBy` | **Repo variables** (`gh variable get/set POOL_*`) | Escalares de config; API própria; legíveis por humano no browser (Settings → Variables) | +| `activeRuns: [{runId, issueNumber, claimedAt}]` | **Derivado, não persistido** | Estado persistido dessincroniza da realidade (crash entre spawn e write). As duas fontes de verdade — status do run na Cursor API e labels/comentários no GitHub — bastam; o comentário de claim na Issue (com a URL do agente `bc-…`) é o vínculo durável worker↔Issue | +| `lastTickAt`, histórico de ticks, último erro | **GHA runs + job summary** (`gh run list --workflow agent-pool.yml`) | O próprio run é o registro; não duplica | +| Reserva worker→Issue | **Comentário de claim na Issue** (marcador `pool-worker bc- `) | Visível a humanos, auditável, self-healing | + +O step "set/get variables" usa `gh variable` com `GITHUB_TOKEN` e `permissions: actions: write` +(REST de Actions variables). Localmente, `pnpm agent:pool -- status` usa o `gh` auth do usuário. + +### Canal canônico: **`workflow_dispatch`** (um só) + +```bash +# start (qualquer máquina/phone com gh auth; também via browser Actions → Run workflow) +gh workflow run agent-pool.yml -f action=start # maxSlots default 5 +gh workflow run agent-pool.yml -f action=start -f maxSlots=3 + +# stop (para de spawnar; ativos drenam naturalmente até o merge) +gh workflow run agent-pool.yml -f action=stop + +# pausa para engineering-audit (e retomada) +gh workflow run agent-pool.yml -f action=pause +gh workflow run agent-pool.yml -f action=resume + +# status / tick manual +gh workflow run agent-pool.yml -f action=status +gh workflow run agent-pool.yml -f action=tick +``` + +`pnpm agent:pool -- start|stop|status` local é apenas um wrapper que dispara o +`workflow_dispatch` ou lê estado — o canal continua único. Comentário `/pool start` em Issue e +trigger de Automation foram avaliados e rejeitados (seção 12): dispatch já cobre CLI, browser e +celular sem superfície nova. + +## 3. Contrato de elegibilidade — `isAutonomousClaimable(issue)` + +Função pura em `scripts/lib/agent-pool-eligibility.mjs`, testada por tabela. Entrada: Issue +(labels, body/frontmatter, state), `doneIds`, `migrationBusy` (bool do tick), `now`. Saída: +`{ ok: true } | { ok: false, reason }`. + +| Condição | Decisão | Motivo | +| -------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------- | +| `state !== 'OPEN'` ou sem label `ready` | exclui (`not-ready`) | contrato básico do claim | +| label `in-progress` / `blocked` / `done` / `in-prod` | exclui (`state-label`) | estado é exclusivo | +| label `requirements-changed` | exclui (`needs-human`) | escopo mudou; gate humano pendente | +| label `needs:consent` | exclui (`needs-human`) | gate legal/produto — nunca autônomo | +| `depends:` com dep não satisfeita (não `done`/`in-prod`/fechada) | exclui (`blocked-by-deps`) | mesma regra de `agent-claim.mjs` (dep sem Issue = roadmap entregue, satisfeita) | +| `needs:migration` ou `serializes:[migrations]` **e** `migrationBusy` | exclui **neste tick** (`migration-busy`) | respeita o `migration-lock` (≤1 PR de schema); transitório, volta no próximo tick | +| sem link `docs/plans/` no body | **warn, não bloqueia** (v1) | preferir Issues com plano; loga no sumário | +| `POOL_PAUSED` (audit solitário) | pausa **global** no passo 2 do tick | engineering-audit é modo solitário (AGENT-OPS) | +| `model:` ausente/inválido | **não exclui** | fallback `composer-2.5` (seção 5) | + +Issues que exigem gate de `plan-issue` (wireframes, confirmação de produto) já chegam `blocked` +ou sem `ready` — o predicado as exclui por construção, e isso é pinado em teste. + +**Label nova `autonomy:confirmed`: rejeitada.** As regras sobre labels existentes cobrem o +contrato; uma label a mais é um passo manual a mais no `plan-issue` sem ganho de segurança +(as exclusões já são fail-closed). + +A fila do pool é uma **extensão** da fila do `agent-claim` (mesma ordenação, predicados mais +estritos). O builder da fila é extraído para `scripts/lib/agent-github.mjs` +(`buildClaimQueue(openReady, byId)`) e compartilhado — `agent-claim.mjs` continua com +comportamento idêntico (humano pode claimar `needs:consent` conscientemente; o pool, não). + +## 4. Estratégia anti-race de claim — **claim coordenado pelo supervisor** + +O supervisor é o **único alocador** e o tick é serializado (concurrency group). Para cada Issue +escolhida, na mesma transação lógica do tick: + +1. **Re-lê a Issue** (`gh issue view N --json labels,state`) — lock otimista idêntico ao de + `agent-claim.mjs` (recusa se `ready` sumiu ou `in-progress` apareceu — ex.: humano claimou). +2. **Flip `ready→in-progress` + comentário de claim do pool** (`Claimed by pool-worker — +worker será anunciado aqui`). +3. **Spawn** `POST /v1/agents` com `agentId` determinístico `bc-` + (idempotência: re-POST do mesmo `agentId` retorna `409 agent_id_conflict` em vez de duplicar + — retry de tick crashado é seguro). Falha no spawn → rollback do flip (`in-progress→ready`) + - comentário. +4. **Edita o comentário de claim** com a URL do agente (vínculo durável usado pela derivação). + +O prompt do worker diz: _"a Issue #N já está claimada para você (claim feito pelo +pool-supervisor); **não rode `pnpm agent:claim`** — comece no passo 1b da skill work-issue."_ +O fluxo manual (`pnpm agent:claim` sem pool) fica intocado. + +**Safety net no reconcile:** se dois agentes vivos mapearem para a mesma Issue (só possível via +erro humano no dashboard), o tick cancela o run mais novo (`POST …/runs/{runId}/cancel`) e loga. + +**Por que não as alternativas:** _jitter escalonado_ reduz colisão mas não a elimina (dois +workers bootam juntos, leem a fila ao mesmo tempo) e adiciona latência; _pré-atribuição sem +flip no supervisor_ deixa a Issue `ready` visível para o próximo tick durante os ~2–5 min de +boot do worker — janela de dupla atribuição que exigiria um segundo mecanismo (comentário de +reserva com TTL). O flip síncrono no tick fecha a janela por construção, reusa o lock otimista +já testado e mantém `agent:status` verdadeiro desde o instante do spawn. + +Parâmetros (só relevantes ao backoff de retry de tick): `spawnRetryBackoff = próximo tick` +(10 min) — sem retry intra-tick, falha de spawn vira warn no sumário. + +## 5. Seleção de modelo + +- Fonte: frontmatter `model:` da Issue (escrito por `agent:register --model`, tabela de slugs + em `.cursor/skills/model-selection/SKILL.md`). +- Spawn: `model: { id: }` no body do `POST /v1/agents`. A API valida contra + `GET /v1/models` (ids + aliases + params, ex.: `composer-2` com param `fast=true`). +- **Namespace:** os slugs do repo (Task tool) e os da Cloud API são tabelas irmãs, não idênticas. + O spike (Fase 0) despeja `GET /v1/models` e codifica o mapeamento explícito em + `scripts/lib/agent-pool-models.mjs`: `Map` com entradas conhecidas + (`composer-2.5` → id composer 2.5 da API, `composer-2.5-fast` → mesmo id + `params:[{id:'fast',value:'true'}]`, + `cursor-grok-4.5-high`, `kimi-k3-low`, `gemini-3.1-pro`, …). +- **Fallback:** `model:` ausente, desconhecido ou rejeitado pela API → **`composer-2.5`** + (pool "Cursor Models", custo incluído — regra 1 da model-selection). Warn no sumário do tick. +- Validação: o tick consulta `GET /v1/models` uma vez por tick (cache em memória do processo) + e nunca spawna com slug fora da lista — erro de validação não pode derrubar o tick. + +## 6. API de controle + +`pnpm agent:pool -- < comando >` → `scripts/agent-pool.mjs` (Node 24, zero deps npm: `gh` CLI + +`fetch` nativo; roda no runner GHA sem `pnpm install`): + +| Comando | Faz | +| ----------------------- | ------------------------------------------------------------------------------------------------------ | +| `start [--max-slots N]` | Wrapper: `gh workflow run agent-pool.yml -f action=start [-f maxSlots=N]` | +| `stop` | Idem `action=stop` (para de spawnar; ativos drenam) | +| `pause` / `resume` | Idem — modo audit solitário | +| `status` | Lê variables + Cursor API + Issues; imprime slots, Issues em voo, PRs, fila elegível, último tick/erro | +| `tick` | Executa a reconciliação (usado pelo workflow; local exige `--dry-run`) | +| `tick --dry-run` | Reconcilia **sem** claim/spawn/flip — imprime o plano do tick (default local) | + +Flags de ambiente: `CURSOR_API_KEY` (obrigatória para spawn/archive/cancel; `status` degrada +para "Cursor API indisponível" sem ela), `GH_TOKEN`/`gh auth` para GitHub. Logs: stdout +estruturado por passo (`[agent:pool] …`), pt-BR nas mensagens; em GHA, o sumário markdown vai +para `GITHUB_STEP_SUMMARY`. + +Secrets/permissões: `CURSOR_API_KEY` em repo secrets (só este workflow usa; nunca impressa; +rotação documentada no runbook). `GITHUB_TOKEN` com `issues: write` (flip/comentários), +`pull-requests: read` (migration cap), `contents: read`, `actions: write` (variables). + +## 7. Mudanças no repo (lista de arquivos previstos) + +| Arquivo | Ação | Fase | +| ---------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------- | +| `scripts/lib/agent-pool-eligibility.mjs` | novo — `isAutonomousClaimable` + fila do pool | 1 | +| `scripts/lib/agent-pool-state.mjs` | novo — variables get/set + derivação de ativos | 1 | +| `scripts/agent-pool.mjs` | novo — CLI da seção 6 | 1 (status/tick dry) → 2/3 (spawn/ciclo) | +| `scripts/lib/agent-github.mjs` | extrai `buildClaimQueue()` (mesma lógica, hoje inline em agent-claim) | 1 | +| `scripts/agent-claim.mjs` | consome `buildClaimQueue()` — **comportamento idêntico** | 1 | +| `scripts/lib/agent-pool-cursor.mjs` | novo — client fino da API v1 (models/agents/runs/cancel/archive) | 2 | +| `scripts/lib/agent-pool-prompt.mjs` | novo — template do prompt do worker | 2 | +| `scripts/lib/agent-pool-models.mjs` | novo — mapeamento slug repo → `model.id` Cloud + fallback | 2 | +| `.github/workflows/agent-pool.yml` | novo — dispatch (fase 2) + schedule + `pull_request` closed (fase 3) | 2→3 | +| `package.json` | script `agent:pool` | 1 | +| `tests/unit/agentPoolEligibility.unit.spec.ts` | novo — tabela do predicado + ordenação + migration cap | 1 | +| `tests/unit/agentPoolState.unit.spec.ts` | novo — derivação de ativos, failure path, circuit breaker, gap | 1→3 | +| `tests/unit/agentPoolModels.unit.spec.ts` | novo — mapping/fallback/validação | 2 | +| `tests/unit/agentPoolPrompt.unit.spec.ts` | novo — contrato do prompt (issue N, sem claim, PR base stage) | 2 | +| `docs/plans/agent-pool-orchestrator.md` | este plano + resultados do spike | 0 | +| `.cursor/skills/agent-pool/SKILL.md` | novo — runbook humano (start/stop/status/triage) | 4 | +| `docs/AGENT-OPS.md` | linha na tabela de comandos + seção curta de secrets | 4 | +| `docs/CHANGELOG-AGENTS.md` | uma entrada curta | 4 | + +Nada em `src/` — sem migrations, sem gates de app (`tsc`/lint cobrem scripts; `knip`/`cycles` +não são afetados por `scripts/`, verificar no gate). `pnpm gate:fast` antes de cada push, como +sempre. + +## 8. Fluxo operacional (runbook) + +**Pré-requisitos (uma vez):** + +1. `CURSOR_API_KEY` gerada em cursor.com/dashboard/api → repo secret + (Settings → Secrets → Actions). +2. Fila com Issues `ready` (ver `pnpm agent:status`); nenhum audit em curso. +3. Workflow mergeado em `main` — **schedule e trigger de PR só rodam da default branch** + (antes do promote, tudo funciona via `workflow_dispatch` na branch do PR B/C). + +**Start remoto:** `gh workflow run agent-pool.yml -f action=start` (ou browser). Em ≤1 tick +(≤10 min; dispatch dispara na hora) os primeiros `min(maxSlots, fila)` workers aparecem em +cursor.com/agents com nome `pool-i-…`. + +**Monitorar:** `pnpm agent:pool status` local, ou Actions → último run → job summary, ou +cursor.com/agents. Workers abrem PR `--base stage` com `Closes #N`; auto-merge no CI green; +`issue-done-on-stage-merge.yml` flipa `done`; o tick seguinte repõe o slot. + +**Stop/drain:** `action=stop` — próximo tick já não spawna; ativos seguem até o merge e o pool +marca `POOL_ENABLED=false` ao drenar. Matar worker travado: cursor.com/agents → archive (o tick +seguinte reconcilia a Issue como falha documentada) — ou `action=pause` antes para não repor. + +**Agente travado / Issue envenenada:** falha terminal → Issue vai a `blocked` com comentário do +pool; humano lê o run, decide re-`ready` manual (circuit breaker impede 3ª tentativa automática) +ou deixar bloqueada. + +**Audit solitário:** `action=pause` no início, `action=resume` no fim (a skill +`engineering-audit` ganha essa linha na Fase 4). + +## 9. Testes + +**Unit (vitest, `tests/unit/`, DATABASE_URL inválida como sempre):** + +- `isAutonomousClaimable`: tabela completa label × decisão da seção 3, incl. `needs:consent` e + `requirements-changed` **nunca** claimáveis com qualquer combinação de outras labels + (fail-closed), dep sem Issue = satisfeita, `migration-busy` liga/desliga. +- Ordenação da fila: paridade com a ordem do `agent-claim --dry-run` (mesmo input → mesma ordem). +- Derivação de ativos (fixtures de Issues + runs): occupied / freed-by-done / waiting-auto-merge / + failure-path / circuit breaker (2ª falha → blocked permanente). +- Cálculo de `gap` e cap hard (nunca spawna além de `maxSlots` mesmo com fila longa). +- Model mapping: slug válido → id Cloud; `-fast` → params; ausente/inválido → `composer-2.5`; + slug fora do `GET /v1/models` → fallback + warn. +- Prompt: contém `#N`, instrução de não-claim, `gh pr create --base stage`, `Closes #N`. +- State: parse/serialize de variables; defaults. + +**Smoke manual remoto (critérios de aceite):** + +1. 2 Issues `kind:chore` triviais (sem `model:`) + `action=start maxSlots=2` → 2 agents em + ≤15 min (boot real medido no spike; se ≤2 min não for físico — setup install — o número real + é documentado aqui e vira o critério). +2. Zero duplicata: cada worker claimou Issue diferente (comentários + labels). +3. Issues `blocked`/`requirements-changed`/`needs:consent` plantadas na fila: intactas. +4. Modelo: 1 Issue com `model: cursor-grok-4.5-high` → run criado com esse modelo (verificar no + dashboard/API); sem `model:` → composer. +5. Fila drenada → `POOL_ENABLED=false` sozinho. +6. `action=stop` → próximo tick sem spawns (≤10 min + margem). +7. `status` mostra slots/Issues/PRs/erros. +8. **Laptop desligado:** start pelo celular/browser → pool operando ≥30 min (trivialmente + satisfeito: schedule é GitHub-hosted; o limite real é o cap de 8 agentes do plano Pro). +9. Nenhuma dependência de sessão Desktop (verificável por construção: tudo é GHA + REST). + +## 10. Fases de implementação (appetite) + +- **Fase 0 — spike (30–45 min, antes de qualquer PR):** `GET /v1/me` + `GET /v1/models` (dump + commitado neste plano); spawn de 1 agente de teste no repo com `model.id` explícito e prompt + trivial; confirmar: boot via `.cursor/environment.json`, `gh` autenticado no worker, tempo real + de boot, formato exato do `agentId` cliente (`bc-…`) e o `409` no re-POST. Resultado: seção de + evidências atualizada + tabela de mapeamento de modelos. +- **Fase 1 — fundação read-only (appetite ~2–3h de sessão):** PR A — eligibility, state, + `buildClaimQueue` extraído, CLI `status`/`tick --dry-run`, testes. Nada spawna. +- **Fase 2 — spawn remoto (~3–4h):** PR B — cursor client, prompt, models, claim coordenado, + workflow com `workflow_dispatch` apenas. Smoke: 1 worker real numa Issue `kind:chore`. +- **Fase 3 — ciclo de vida (~3–4h):** PR C — schedule + trigger `pull_request` closed, + reposição, failure path + archive + circuit breaker, migration cap, pause/resume, auto-stop, + job summary rico. +- **Fase 4 — docs/ops (~1–2h):** PR D — skill `agent-pool`, AGENT-OPS, CHANGELOG, linha de pause + na skill engineering-audit, smoke remoto completo (seção 9) executado e registrado. + +Cada PR vai `--base stage` com CI green (fluxo normal de PR, mas **sem** `agent:register`/Issue +de spec — ops infra). **Schedule só ativa após o promote humano a `main`.** + +## 11. Fora de escopo + +- `pnpm agent:promote` (promote `stage→main` é humano — o pool nunca chama). +- Qualquer `DATABASE_URL` de stage/prod no supervisor ou nos workers (Cloud usa + `.cursor/cloud-setup.sh` + seed mínimo; o workflow não recebe secrets de banco). +- Editar Issues `in-progress` de outros agentes/humanos (o pool só toca as que ele claimou). +- Substituir ou alterar o fluxo `plan-issue` → `work-issue` para Issues normais. +- Supervisor local (`/loop`, `while` em shell, daemon em VPS) como default — rejeitado. +- Cancelamento automático de workers no `stop` (v1: stop = parar de spawnar; kill é manual). +- Webhooks da API v0 da Cursor (v1 tem "webhooks coming soon"; o trigger `pull_request` closed + do GHA já cobre o evento que interessa). +- Multi-repo, self-hosted pools, fleet autoscaling. + +## 12. Decisões travadas + opções rejeitadas + +**D1 — Superfície do supervisor.** +Opções: GHA `schedule`+`workflow_dispatch` stateless / Cursor Automation cron / Cloud Agent +supervisor 24/7 / daemon em VPS. +**Recomendação: GHA stateless tick.** O tick é lógica determinística com testes unitários (a +task exige `isAutonomousClaimable` testável); custa zero slots de agente e zero tokens (Pro = 8 +concorrentes — 5 workers + supervisor Automation deixariam 2 para humanos e queimariam LLM a +cada 10 min, ~144 ticks/dia); triggers nativos cobrem cron e merge; `workflow_dispatch` é remoto +por definição. Mantém o norte "só GitHub + Cursor" (Actions é GitHub) e a família de scripts +`agent:*`/`refresh-stage` (precedente direto de ops via dispatch). +Rejeitadas: Automation cron (plano B documentado — o mesmo `pnpm agent:pool tick` pode ser +embrulhado numa Automation depois, pois o script é agnóstico de superfície); supervisor 24/7 +(timeout não documentado, slot permanente, LLM fazendo cron); VPS (fora do norte). + +**D2 — Persistência de estado.** +Opções: repo variables + derivação / Issue dedicada "pool control" / arquivo em branch +`agent/pool-state`. +**Recomendação: repo variables para config + derivação para ativos.** Escalares em variables +(API simples, visível no browser); `activeRuns` derivado das duas fontes de verdade (Cursor API + +- labels/comentários GitHub) — estado persistido de runs dessincroniza no primeiro crash. + Rejeitadas: Issue de controle (parsing por tick, ruído de comentários); branch de estado + (commit de bot por tick, histórico poluído). + +**D3 — Canal de start/stop.** +**Recomendação: `workflow_dispatch` único** (CLI `gh`, browser, celular; wrapper `pnpm +agent:pool`). Rejeitadas: comentário `/pool` em Issue (segundo canal para policiar, parsing de +comentário); trigger de Automation (não é um canal de comando humano). + +**D4 — Anti-race.** +**Recomendação: claim coordenado pelo supervisor** (flip síncrono no tick serializado + +`agentId` idempotente + lock otimista reusado). Rejeitadas: jitter escalonado (não elimina a +janela); pré-atribuição sem flip (janela de boot de 2–5 min com a Issue ainda `ready`); +advisory lock novo (complexidade onde o alocador único basta). + +**D5 — Modelo.** +**Recomendação: mapeamento explícito `scripts/lib/agent-pool-models.mjs` validado contra +`GET /v1/models` no tick; fallback `composer-2.5`.** Rejeitada: passar o slug do repo cru sem +validação (namespaces diferem; falha de spawn por slug inválido derrubaria o ciclo). + +**D6 — Label `autonomy:confirmed`.** Rejeitada — regras sobre labels existentes já são +fail-closed; label extra é passo manual sem ganho. + +**D7 — PR do worker.** `autoCreatePR: false` no spawn — o worker segue a skill (`gh pr create +--base stage` + `Closes #N` + `gh pr merge --auto --merge` + watch). O auto-PR da API abriria +contra a base errada e sem o corpo do contrato. `startingRef: 'stage'` no spawn (diff mínimo +contra a base do PR). + +**D8 — Slot ocupado até o merge.** Conforme a spec: ativo = do claim ao merge em `stage` (ou +falha documentada). Efeito: o throughput do pool inclui a espera do CI (~dezenas de min por PR); +com auto-merge isso é hands-off e correto por construção. Derivação: Issue `in-progress` com +marcador do pool = ocupado. + +## 13. Rabbit holes + +- **Spawn sem API oficial:** a API v1 existe e é pública (beta) — risco baixo, mas shapes podem + mudar; o client fica isolado em `agent-pool-cursor.mjs` e o spike pinna o contrato. Plano B se + a API regredir: Automation por worker (uma Automation por slot com prompt fixo) — documentado, + não implementado. +- **Custo 5× Cloud:** agents são cobrados a preço de API por modelo; default `composer-2.5` + (pool incluído) e `maxSlots` configurável são as rédeas; spend limit no dashboard Cursor é + pré-requisito operacional; `GET /v1/agents/{id}/usage` no `status` fica como follow-up. +- **Flake de CI com 5 PRs simultâneos:** `migration-lock` já serializa schema; e2e/unit por diff + limitam carga; risco real é fila de runners GHA — visível no job summary, fora do controle do + pool (documentado, não mitigado em v1). +- **Migration-lock starvation:** humano abre PR de schema fora do pool → pool respeita o cap e + para de spawnar migrations; o inverso (pool segurando o único slot de migration enquanto o + worker demora) é resolvido pelo drain natural + triage humano da Issue `blocked`. +- **Timeout GHA 6h vs cron:** irrelevante no modelo stateless (tick < 2 min). Schedules do + GitHub atrasam sob carga (minutos) — aceitável num tick de 10 min; o trigger + `pull_request closed` compensa o replenish pós-merge. +- **`agentId` idempotente:** formato exato (`bc-` + o quê?) e comportamento do `409` são + confirmados no spike; se não funcionar, o reconcile anti-duplicata (cancel do run mais novo) + cobre a janela. +- **Boot real do worker:** `environment.json install` (pnpm install + Postgres + migrate + seed) + leva minutos; environment builds/snapshots do Cursor aceleram boots subsequentes. O critério + "5 agents em ≤2 min" é medido no spike e re-ancorado com o número real. +- **Phantom agents no cap de concorrência:** bug conhecido (agentes terminados contando no + limite; corrigido em 2026-06-17) — o tick arquiva todo agente terminal, e o runbook inclui + "archive em cursor.com/agents" como remediação. +- **Pool × sessões humanas no mesmo GitHub:** humano claima a Issue que o tick acabou de + escolher → lock otimista do passo 4.1 recusa e o tick segue para a próxima da fila. + +## 14. Evidências de plataforma (investigação) + +1. **Spawn remoto com modelo existe — Cloud Agents API v1 (public beta).** + `POST https://api.cursor.com/v1/agents` com `{ prompt: { text }, model: { id, params }, +repos: [{ url, startingRef }], name, agentId, autoCreatePR, envVars }`; auth Basic/Bearer com + API key de cursor.com/dashboard/api. Resposta traz `agent` + `run` iniciais. + `agentId` cliente → `409 agent_id_conflict` em re-POST (idempotência). + Fonte: . +2. **Fim de worker sem polling local:** `GET /v1/agents/{id}` → `latestRunId` → + `GET /v1/agents/{id}/runs/{runId}` → `status` ∈ `CREATING|RUNNING|FINISHED|ERROR|CANCELLED| +EXPIRED`, com `git.branches[].prUrl`. SSE stream existe mas não é necessária (tick amostra). + Webhooks v1: "coming soon" — o trigger `pull_request` closed do GHA cobre o evento de merge. +3. **Validação de modelos:** `GET /v1/models` retorna ids, aliases e `params` aceitos + (ex.: `composer-2` + `fast`). O mapeamento repo→API é codificado na Fase 0/2. +4. **Limites de concorrência:** plano Pro = **8 agentes simultâneos** (staff Cursor, forum + 2026-04-16); múltiplos agentes no mesmo repo são permitidos; bug de agentes fantasma no + contador corrigido em 2026-06-17 — remediação: arquivar em cursor.com/agents (o tick já + arquiva terminais). Cap 5 deixa 3 slots para humanos. + Fonte: . +5. **Branches por worker:** o agente Cloud trabalha em branch própria gerada a partir de + `startingRef` (`workOnCurrentBranch: false`, default) — exatamente o modelo `agent/-` + da work-issue; colisões de branch entre workers não existem por construção. +6. **Cursor Automations** (cursor.com/automations, skill `/automate`): triggers cron/GitHub/ + Slack/webhook/Linear; cada trigger = um run de cloud agent com instruções + MCPs + modelo; + cron "pode atrasar, nunca adianta". Avaliado como supervisor e rejeitado para v1 (D1); fica + como plano B de superfície. + Fonte: . +7. **Ambiente do worker:** repo-file managed (`.cursor/environment.json` presente → install + `pnpm install + cloud-setup.sh`: Postgres nativo + migrate + seed mínimo, sem secrets de + stage/prod). Confirmado pelo MCP `cursor-cloud-environment-info` deste próprio run. +8. **MCP `cursor-cloud`** (diagnóstico): `list-cloud-agents` com filtros de status/fonte, + `batch-fetch-details` (transcript/logs/diff), `get-events` — útil no `status` e em triage, + mas **não spawna** agentes: o spawn é sempre via REST v1. + +## 14.1 Adendo do spike (2026-07-30, implementação) + +Verificado neste run (Cloud Agent `bc-9cd1a18c-…`, repo fsolla/teqo): + +- **Environment repo-file confirmado em produção:** o `environment-info` deste run reporta + `source: Repository` + `environmentJsonPath: .cursor/environment.json` — workers spawnados + pela API neste repo bootam com `pnpm install` + `cloud-setup.sh` (Postgres nativo + migrate + + seed mínimo), sem secrets de stage/prod. Item 7 acima confirmado empiricamente. +- **Formato de id:** runs usam `bc-` (este run: `bc-9cd1a18c-3f2b-4661-b18b-401db63c1182`), + compatível com o `agentId` cliente do claim coordenado. +- **Tokens gh limitados degradam, não quebram:** o token deste run não lê repo variables nem + issues (`403 Resource not accessible by integration`) — `status`/dry-run degradam com avisos + por seção e o tick live permanece estrito (fail-closed). Em GHA o `GITHUB_TOKEN` com + `actions: write` é o caminho suportado. +- **Pendente (primeiro run com `CURSOR_API_KEY`):** a chave foi adicionada ao dashboard de + secrets após o início deste run — secrets só injetam em runs NOVOS. No primeiro run que a + receber: `pnpm agent:pool -- doctor` (valida `/v1/me` + despeja `/v1/models` para revisar o + mapeamento de slugs) e o smoke trivial de spawn + re-POST `agentId` → `409` (runbook na skill + `agent-pool`). Até lá, `agent-pool-models.mjs` resolve contra a tabela viva em tempo de tick + com fallback `composer-2.5` — nenhuma decisão depende de dados não verificados. diff --git a/package.json b/package.json index 1dc9d0dc..914b109c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "agent:prioritize": "node scripts/agent-prioritize.mjs", "agent:file-miss": "node scripts/agent-file-miss.mjs", "agent:promote": "node scripts/agent-promote.mjs", + "agent:pool": "node scripts/agent-pool.mjs", "build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs", "build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs", "build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs", diff --git a/scripts/agent-claim.mjs b/scripts/agent-claim.mjs index 2146a787..e709f41c 100644 --- a/scripts/agent-claim.mjs +++ b/scripts/agent-claim.mjs @@ -15,6 +15,7 @@ */ import { + buildClaimQueue, dieAgent, gh, ghJson, @@ -22,7 +23,6 @@ import { labelNames, parseArgs, parseFrontmatter, - priorityRank, setLabels, } from './lib/agent-github.mjs' @@ -42,39 +42,9 @@ const openReady = ghJson([ 'number,title,body,labels,createdAt', ]) -const byId = issuesById() -const doneIds = new Set( - [...byId.entries()] - .filter(([, issue]) => { - const labels = labelNames(issue) - return issue.state === 'CLOSED' || labels.includes('done') || labels.includes('in-prod') - }) - .map(([id]) => id), -) - -const queue = openReady - .map((issue) => { - const { meta } = parseFrontmatter(issue.body) - const depends = Array.isArray(meta.depends) ? meta.depends : [] - // A dep without an issue is a delivered roadmap item (B43/B47/B59…): they - // predate the Issues era and are never reopened, so they are satisfied — - // surfaced as a warning in the brief, never silently dropped. - const satisfiedWithoutIssue = depends.filter((id) => !byId.has(id)) - const blockedBy = depends.filter((id) => byId.has(id) && !doneIds.has(id)) - return { - issue, - meta, - priority: labelNames(issue).find((label) => /^prio:P[0-3]$/.test(label)) ?? 'prio:P2', - satisfiedWithoutIssue, - blockedBy, - } - }) - .filter((entry) => entry.blockedBy.length === 0) - .sort((a, b) => { - const rank = - priorityRank(a.priority.replace('prio:', '')) - priorityRank(b.priority.replace('prio:', '')) - return rank !== 0 ? rank : a.issue.createdAt.localeCompare(b.issue.createdAt) - }) +// Queue builder lives in agent-github.mjs (shared with the agent pool — +// identical ordering/filtering, pinned by agentPoolEligibility.unit.spec.ts). +const queue = buildClaimQueue(openReady, issuesById()) if (flags['dry-run'] || flags['dryrun']) { console.log(`[agent:claim] ${queue.length} unblocked ready issue(s):`) diff --git a/scripts/agent-pool.mjs b/scripts/agent-pool.mjs new file mode 100644 index 00000000..b3b66409 --- /dev/null +++ b/scripts/agent-pool.mjs @@ -0,0 +1,521 @@ +/** + * `pnpm agent:pool` — agent pool supervisor CLI + * (docs/plans/agent-pool-orchestrator.md). + * + * The pool keeps up to POOL_MAX_SLOTS (default 5) Cursor Cloud Agents running + * work-issue over `ready` autonomous-eligible issues. The supervisor is + * REMOTE: a stateless GitHub Actions tick (`.github/workflows/agent-pool.yml`) + * runs `workflow --action …` / `tick --live`; humans drive it via + * `gh workflow run` (canonical channel). Local usage is inspection + + * dispatch: + * + * pnpm agent:pool -- status # config, slots, fila elegível + * pnpm agent:pool -- tick --dry-run # reconcilia SEM escrever nada + * pnpm agent:pool -- doctor # pré-requisitos (gh, variables, CURSOR_API_KEY, modelos) + * pnpm agent:pool -- start|stop|pause|resume [-- --ref ] [-- --max-slots N] + * + * Promote stage→main is never part of the pool — it stays human + * (`pnpm agent:promote -- --i-am-human`). + */ + +import { appendFileSync } from 'node:fs' +import { + buildClaimQueue, + dieAgent, + gh, + ghJson, + issuesById, + labelNames, + parseArgs, +} from './lib/agent-github.mjs' + +import { + archiveCursorAgent, + cancelCursorRun, + createCursorAgent, + CursorApiError, + cursorApiMe, + getCursorAgent, + getCursorRun, + listCursorModels, +} from './lib/agent-pool-cursor.mjs' +import { buildPoolQueue, isAutonomousClaimable } from './lib/agent-pool-eligibility.mjs' +import { + blockIssueFromPool, + claimIssueForPool, + commentPoolEvent, + countOpenSchemaPrs, + listInProgressIssues, + listIssueComments, + listOpenPrs, + prClosingIssue, + readPoolVariables, + rollbackPoolClaim, + writePoolVariable, +} from './lib/agent-pool-github.mjs' +import { resolvePoolModel } from './lib/agent-pool-models.mjs' +import { buildPoolWorkerPrompt, extractPlanPath } from './lib/agent-pool-prompt.mjs' +import { + classifyPoolClaim, + computeSpawnPlan, + countPoolFailures, + decidePoolAutoStop, + parsePoolConfig, + parsePoolEvents, + POOL_OCCUPIED_CLASSES, + POOL_VARIABLE_NAMES, + reconcilePoolClaims, +} from './lib/agent-pool-state.mjs' + +const REPO_URL = 'https://github.com/fsolla/teqo' +const POOL_BASE_REF = 'stage' + +const die = dieAgent('pool') +const { flags, positional } = parseArgs( + process.argv.slice(2), + new Set(['action', 'actor', 'max-slots', 'ref']), +) +const command = positional[0] + +const log = (message) => console.log(`[agent:pool] ${message}`) + +// Job summary: in GitHub Actions every tick appends a markdown report; local +// runs just skip it. The summary IS the remote status surface in the browser. +const summaryLines = [] +const summary = (line) => { + if (process.env.GITHUB_STEP_SUMMARY) summaryLines.push(line) +} +const flushSummary = (title) => { + if (process.env.GITHUB_STEP_SUMMARY && summaryLines.length > 0) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `## ${title} (${new Date().toISOString()})\n\n${summaryLines.join('\n')}\n\n`, + ) + } +} + +const loadClaimQueue = () => + buildClaimQueue( + ghJson([ + 'issue', + 'list', + '--state', + 'open', + '--label', + 'ready', + '--limit', + '200', + '--json', + 'number,title,body,labels,createdAt', + ]), + issuesById(), + ) + +/** + * Derive every pool claim from issue labels + pool-worker comment markers, + * enriched with live Cursor run statuses when the API key is around. + * + * Self-healing: a claim whose spawn event never landed (tick crashed between + * create and comment) is recovered via the deterministic `bc-` + * agentId. Duplicates (only possible via dashboard manipulation) get the + * newer runs cancelled. `live` gates every WRITE (heal comment, cancels) — + * dry-run only reads. + */ +const derivePoolClaims = async ({ live }) => { + const openPrs = listOpenPrs() + const hasKey = Boolean(process.env.CURSOR_API_KEY) + const claims = [] + for (const issue of listInProgressIssues()) { + const events = parsePoolEvents(listIssueComments(issue.number)) + if (!events.some((event) => event.event === 'claim')) continue + + if (!events.some((event) => event.event === 'spawn') && hasKey) { + const latestClaim = events.filter((event) => event.event === 'claim').at(-1) + if (latestClaim?.worker) { + try { + const agent = await getCursorAgent(`bc-${latestClaim.worker}`) + if (agent?.id) { + const spawn = { + event: 'spawn', + agentId: agent.id, + runId: agent.latestRunId ?? null, + url: agent.url, + } + events.push(spawn) + if (live) { + commentPoolEvent( + issue.number, + spawn, + `Worker do pool (recuperado pelo tick): ${agent.url}`, + ) + } + log(` heal #${issue.number}: spawn recuperado via agentId ${agent.id}`) + } + } catch { + // Agent never created — classification decides via the spawn grace. + } + } + } + + const spawns = events.filter((event) => event.event === 'spawn') + if (spawns.length > 1 && hasKey && live) { + for (const extra of spawns.slice(1)) { + if (extra.agentId && extra.runId) { + await cancelCursorRun(extra.agentId, extra.runId).catch(() => {}) + log(` duplicata em #${issue.number}: run ${extra.runId} cancelada.`) + } + } + } + + const spawn = spawns[0] + let latestRunStatus = null + if (spawn?.agentId && hasKey) { + try { + const runId = spawn.runId ?? (await getCursorAgent(spawn.agentId)).latestRunId + if (runId) latestRunStatus = (await getCursorRun(spawn.agentId, runId)).status ?? null + } catch { + latestRunStatus = null + } + } + + claims.push({ + issue, + events, + classification: classifyPoolClaim({ + events, + issueDone: labelNames(issue).includes('done'), + hasOpenPr: Boolean(prClosingIssue(openPrs, issue.number)), + runStatus: latestRunStatus, + }), + }) + } + return claims +} + +const describeClaim = ({ issue, classification }) => { + const state = classification?.class ?? '?' + const reason = classification?.reason ? ` (${classification.reason})` : '' + const agent = classification?.agentId ? ` agent:${classification.agentId}` : '' + return `#${issue.number} ${issue.title} — ${state}${reason}${agent}` +} + +const runStatus = async () => { + const config = parsePoolConfig(readPoolVariables({ strict: false })) + log( + `pool: ${config.enabled ? 'LIGADO' : 'desligado'}${config.paused ? ' (pausado — audit solitário)' : ''}` + + ` maxSlots=${config.maxSlots}` + + (config.startedAt ? ` desde ${config.startedAt} por ${config.startedBy ?? '?'}` : ''), + ) + + // Status is a best-effort inspection tool: every section degrades with a + // warn instead of dying, so a limited token still gets the rest of the + // picture. The tick stays strict (it rehearses the live supervisor). + try { + const claims = await derivePoolClaims({ live: false }) + const active = claims.filter((claim) => POOL_OCCUPIED_CLASSES.has(claim.classification?.class)) + log(`\nslots: ${active.length}/${config.maxSlots} ocupados`) + if (claims.length === 0) { + log(' (nenhuma Issue claimada pelo pool)') + } + for (const claim of claims) log(` ${describeClaim(claim)}`) + } catch (error) { + console.error(`[agent:pool] aviso: claims indisponíveis — ${error.message}`) + } + + try { + const migrationBusy = countOpenSchemaPrs() > 0 + const { eligible, excluded } = buildPoolQueue(loadClaimQueue(), { migrationBusy }) + log( + `\nfila elegível: ${eligible.length}${migrationBusy ? ' (migration-busy: PR de schema aberto)' : ''}`, + ) + for (const { entry, hasPlan } of eligible) { + log( + ` #${entry.issue.number} [${entry.priority}]${entry.meta.model ? ` model:${entry.meta.model}` : ''} ${entry.issue.title}${hasPlan ? '' : ' (sem plano docs/plans — warn)'}`, + ) + } + for (const { entry, reason } of excluded) { + log(` excluída #${entry.issue.number} ${entry.issue.title} — ${reason}`) + } + } catch (error) { + console.error(`[agent:pool] aviso: fila indisponível — ${error.message}`) + } +} + +/** + * Spawn one worker for an already-claimed issue. The claim marker's worker + * UUID doubles as the idempotent `agentId` — a tick retried after a crash + * gets a 409 and recovers the existing agent instead of duplicating it. + */ +const spawnPoolWorker = async ({ entry, workerUuid, models }) => { + const chosen = resolvePoolModel(entry.meta.model, models) + if (chosen.warn) log(` aviso: ${chosen.warn}`) + const prompt = buildPoolWorkerPrompt({ + issueNumber: entry.issue.number, + issueTitle: entry.issue.title, + issueId: entry.meta.id ?? null, + planPath: extractPlanPath(entry.issue.body), + modelSlug: entry.meta.model ?? null, + }) + const agentId = `bc-${workerUuid}` + try { + const { agent, run } = await createCursorAgent({ + prompt: { text: prompt }, + model: chosen.model, + name: `pool-i${entry.issue.number}-${workerUuid.slice(0, 8)}`, + agentId, + repos: [{ url: REPO_URL, startingRef: POOL_BASE_REF }], + autoCreatePR: false, + }) + commentPoolEvent( + entry.issue.number, + { event: 'spawn', agentId: agent.id, runId: run.id, url: agent.url }, + `Worker do pool em execução: ${agent.url}`, + ) + log(` spawn #${entry.issue.number} → ${agent.url} (modelo ${chosen.model.id})`) + } catch (error) { + if (error instanceof CursorApiError && error.status === 409) { + const existing = await getCursorAgent(agentId) + commentPoolEvent( + entry.issue.number, + { + event: 'spawn', + agentId: existing.id, + runId: existing.latestRunId ?? null, + url: existing.url, + }, + `Worker do pool (recuperado após 409): ${existing.url}`, + ) + log(` spawn #${entry.issue.number} já existia (409) — recuperado: ${existing.url}`) + return + } + rollbackPoolClaim(entry.issue.number, `spawn falhou (${error.message})`) + commentPoolEvent( + entry.issue.number, + { event: 'failure', reason: 'spawn-error' }, + 'Pool-supervisor: spawn falhou — claim revertido para `ready`.', + ) + log(` spawn #${entry.issue.number} falhou (${error.message}) — claim revertido.`) + } +} + +const runTick = async ({ dryRun }) => { + summary(`- modo: **${dryRun ? 'dry-run' : 'LIVE'}**`) + const config = parsePoolConfig(readPoolVariables({ strict: !dryRun })) + log( + `tick (${dryRun ? 'dry-run' : 'LIVE'}) — enabled=${config.enabled} paused=${config.paused} maxSlots=${config.maxSlots}`, + ) + if (!config.enabled) { + log('pool desligado — nada a fazer.') + summary('- pool **desligado** — nada a fazer') + flushSummary('agent:pool tick') + return + } + if (config.paused) { + log('pool pausado (audit solitário) — nada a fazer.') + summary('- pool **pausado** (audit solitário) — nada a fazer') + flushSummary('agent:pool tick') + return + } + + const claims = await derivePoolClaims({ live: !dryRun }) + for (const claim of claims) log(` claim ${describeClaim(claim)}`) + + // Reconcile first: terminal failures leave the queue for human triage and + // merged workers free their agent — then the gap math sees the truth. + const reconciliation = reconcilePoolClaims(claims) + for (const claim of reconciliation.failures) { + const reason = claim.classification?.reason ?? 'desconhecida' + if (dryRun) { + log(` falha ${describeClaim(claim)} → bloquearia + arquivaria`) + summary(`- falha #${claim.issue.number} (${reason}) → blocked + archive`) + continue + } + commentPoolEvent( + claim.issue.number, + { event: 'failure', reason, agentId: claim.classification?.agentId ?? null }, + `Pool-supervisor: falha terminal do worker (${reason}) — a Issue vai a \`blocked\` para triagem humana (ver runs em cursor.com/agents).`, + ) + blockIssueFromPool(claim.issue.number) + if (claim.classification?.agentId) { + await archiveCursorAgent(claim.classification.agentId).catch(() => {}) + } + log(` falha ${describeClaim(claim)} → blocked + archive`) + summary(`- falha #${claim.issue.number} (${reason}) → blocked + archive`) + } + for (const claim of reconciliation.freed) { + const spawn = claim.events.find((event) => event.event === 'spawn') + if (!dryRun && spawn?.agentId) { + await archiveCursorAgent(spawn.agentId).catch(() => {}) + } + log(` merge concluído #${claim.issue.number} — slot livre, agente arquivado.`) + summary(`- #${claim.issue.number} mergeada em stage — slot livre`) + } + + const active = reconciliation.active + const migrationBusy = countOpenSchemaPrs() > 0 + const { eligible, excluded } = buildPoolQueue(loadClaimQueue(), { migrationBusy }) + const plan = computeSpawnPlan({ + eligible, + activeCount: active.length, + maxSlots: config.maxSlots, + }) + log( + `ativos=${active.length} elegíveis=${eligible.length} gap=${plan.gap}` + + (migrationBusy ? ' (migration-busy)' : ''), + ) + summary( + `- slots: **${active.length}/${config.maxSlots}** ocupados · elegíveis: ${eligible.length} · gap: ${plan.gap}${migrationBusy ? ' · migration-busy' : ''}`, + ) + + if (dryRun) { + for (const { entry } of plan.toSpawn) { + log(` spawnaria #${entry.issue.number} [${entry.priority}] ${entry.issue.title}`) + } + } else { + const models = process.env.CURSOR_API_KEY + ? ((await listCursorModels()).items ?? []) + : die('CURSOR_API_KEY ausente — tick live impossível (spawn/archive dependem dela).') + const tickIso = new Date().toISOString() + for (const { entry } of plan.toSpawn) { + // Circuit breaker per candidate: comments are already read here for the + // claim marker, so counting past failures costs nothing extra. + const failures = countPoolFailures(parsePoolEvents(listIssueComments(entry.issue.number))) + const verdict = isAutonomousClaimable(entry, { + migrationBusy, + poolFailureCount: failures, + }) + if (!verdict.ok) { + log(` skip #${entry.issue.number} — ${verdict.reason}`) + summary(`- skip #${entry.issue.number} (${verdict.reason})`) + continue + } + const claim = claimIssueForPool(entry.issue.number, { tickIso }) + if (!claim.ok) { + log(` #${entry.issue.number} claim perdido (${claim.reason}) — próxima da fila.`) + continue + } + await spawnPoolWorker({ entry, workerUuid: claim.workerUuid, models }) + summary(`- spawn #${entry.issue.number} (${entry.meta.model ?? 'composer-2.5'})`) + } + } + + if ( + decidePoolAutoStop({ + eligibleCount: eligible.length, + activeCount: active.length, + excludedReasons: excluded.map((item) => item.reason), + }) + ) { + if (dryRun) { + log('fila drenada e zero ativos — o tick live desligaria o pool (POOL_ENABLED=false).') + } else { + writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'false') + log('fila drenada e zero ativos — pool desligado automaticamente (POOL_ENABLED=false).') + summary('- **fila drenada — pool desligado automaticamente**') + } + } + flushSummary('agent:pool tick') +} + +/** Entrypoint used by the GitHub Actions workflow (the canonical remote channel). */ +const runWorkflow = async ({ action, actor, maxSlots }) => { + const now = new Date().toISOString() + switch (action) { + case 'start': { + writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'true') + writePoolVariable(POOL_VARIABLE_NAMES.startedAt, now) + writePoolVariable(POOL_VARIABLE_NAMES.startedBy, actor || 'desconhecido') + writePoolVariable(POOL_VARIABLE_NAMES.paused, 'false') + if (maxSlots) writePoolVariable(POOL_VARIABLE_NAMES.maxSlots, String(maxSlots)) + log(`pool ligado por ${actor ?? '?'} (${now})`) + await runTick({ dryRun: false }) + return + } + case 'stop': + writePoolVariable(POOL_VARIABLE_NAMES.enabled, 'false') + log('pool desligado — sem novos spawns; ativos drenam até o merge.') + summary(`- **stop** por ${actor ?? '?'} (${now}) — sem novos spawns`) + flushSummary('agent:pool stop') + return + case 'pause': + writePoolVariable(POOL_VARIABLE_NAMES.paused, 'true') + log('pool pausado (audit solitário) — sem novos spawns.') + summary(`- **pause** por ${actor ?? '?'} (${now})`) + flushSummary('agent:pool pause') + return + case 'resume': + writePoolVariable(POOL_VARIABLE_NAMES.paused, 'false') + log('pool retomado.') + await runTick({ dryRun: false }) + return + case 'tick': + await runTick({ dryRun: false }) + return + case 'status': + await runStatus() + return + default: + die(`action desconhecida: "${action}" (start|stop|pause|resume|tick|status)`) + } +} + +/** Human-facing wrapper: dispatch the workflow (canonical channel stays remote). */ +const dispatchWorkflow = (action) => { + const args = ['workflow', 'run', 'agent-pool.yml', '-f', `action=${action}`] + if (flags.ref) args.push('--ref', flags.ref) + if (action === 'start' && flags['max-slots']) args.push('-f', `maxSlots=${flags['max-slots']}`) + gh(args) + log( + `workflow_dispatch enviado (action=${action}). Acompanhe: gh run list --workflow agent-pool.yml --limit 3`, + ) +} + +const runDoctor = async () => { + log('doctor — pré-requisitos do pool') + try { + gh(['auth', 'status']) + log(' gh auth: OK') + } catch { + log(' gh auth: FALHOU (rode `gh auth login`)') + } + try { + const vars = readPoolVariables() + log(` repo variables: OK (${Object.keys(vars).length} lidas)`) + } catch { + log( + ' repo variables: SEM PERMISSÃO (precisa de actions:read; GHA usa GITHUB_TOKEN com actions:write)', + ) + } + if (!process.env.CURSOR_API_KEY) { + log(' CURSOR_API_KEY: ausente — spawn/archive indisponíveis (status/fila seguem OK)') + return + } + const me = await cursorApiMe() + log( + ` CURSOR_API_KEY: OK${me.userEmail ? ` (${me.userEmail})` : me.apiKeyName ? ` (${me.apiKeyName})` : ''}`, + ) + const { items } = await listCursorModels() + log(` /v1/models: ${(items ?? []).length} modelo(s)`) + for (const model of items ?? []) { + log( + ` ${model.id}${(model.aliases ?? []).length > 0 ? ` (aliases: ${model.aliases.join(', ')})` : ''}${(model.parameters ?? []).length > 0 ? ` params: ${model.parameters.map((param) => param.id).join(',')}` : ''}`, + ) + } +} + +if (command === 'status') { + await runStatus() +} else if (command === 'tick') { + await runTick({ dryRun: flags['dry-run'] || !flags.live }) +} else if (command === 'workflow') { + await runWorkflow({ action: flags.action, actor: flags.actor, maxSlots: flags['max-slots'] }) +} else if (['start', 'stop', 'pause', 'resume'].includes(command)) { + dispatchWorkflow(command) +} else if (command === 'doctor') { + await runDoctor() +} else { + die( + 'Usage: pnpm agent:pool -- status | tick [--dry-run|--live] | doctor | start|stop|pause|resume [--ref ] [--max-slots N]\n' + + '(o workflow chama: node scripts/agent-pool.mjs workflow --action )', + ) +} diff --git a/scripts/lib/agent-github.mjs b/scripts/lib/agent-github.mjs index b4ee7641..7f9858fe 100644 --- a/scripts/lib/agent-github.mjs +++ b/scripts/lib/agent-github.mjs @@ -82,6 +82,53 @@ export const issuesById = () => { export const labelNames = (issue) => issue.labels.map((label) => label.name) +/** + * Claim queue shared by `agent:claim` and the agent pool: open `ready` issues + * filtered to UNBLOCKED (every frontmatter `depends` id is closed or labeled + * `done`/`in-prod`; a dep without an issue is a delivered roadmap item — + * satisfied, surfaced as a warning, never silently dropped), ordered by + * `prio:P0..P3` then oldest first. Extracted verbatim from agent-claim.mjs; + * behavior is pinned by agentPoolEligibility.unit.spec.ts. + * + * @param {Array} openReady + * @param {Map} byId + * @returns {Array<{ issue: any, meta: Record, priority: string, satisfiedWithoutIssue: string[], blockedBy: string[] }>} + */ +export const buildClaimQueue = (openReady, byId) => { + const doneIds = new Set( + [...byId.entries()] + .filter(([, issue]) => { + const labels = labelNames(issue) + return issue.state === 'CLOSED' || labels.includes('done') || labels.includes('in-prod') + }) + .map(([id]) => id), + ) + + return openReady + .map((issue) => { + const { meta } = parseFrontmatter(issue.body) + const depends = Array.isArray(meta.depends) ? meta.depends : [] + // A dep without an issue is a delivered roadmap item (B43/B47/B59…): they + // predate the Issues era and are never reopened, so they are satisfied. + const satisfiedWithoutIssue = depends.filter((id) => !byId.has(id)) + const blockedBy = depends.filter((id) => byId.has(id) && !doneIds.has(id)) + return { + issue, + meta, + priority: labelNames(issue).find((label) => /^prio:P[0-3]$/.test(label)) ?? 'prio:P2', + satisfiedWithoutIssue, + blockedBy, + } + }) + .filter((entry) => entry.blockedBy.length === 0) + .sort((a, b) => { + const rank = + priorityRank(a.priority.replace('prio:', '')) - + priorityRank(b.priority.replace('prio:', '')) + return rank !== 0 ? rank : a.issue.createdAt.localeCompare(b.issue.createdAt) + }) +} + export const setLabels = (number, { add = [], remove = [] }) => { const args = ['issue', 'edit', String(number)] for (const label of add) args.push('--add-label', label) diff --git a/scripts/lib/agent-pool-cursor.mjs b/scripts/lib/agent-pool-cursor.mjs new file mode 100644 index 00000000..b7885bf3 --- /dev/null +++ b/scripts/lib/agent-pool-cursor.mjs @@ -0,0 +1,73 @@ +/** + * Thin Cursor Cloud Agents API v1 client (docs/plans/agent-pool-orchestrator.md + * §14). REST + `CURSOR_API_KEY` (Basic auth, key as username). The API is in + * public beta — the whole surface is isolated in this one module so a shape + * change touches nothing else. + */ + +const CURSOR_API_BASE = 'https://api.cursor.com' + +export class CursorApiError extends Error { + constructor(message, { status, body }) { + super(message) + this.name = 'CursorApiError' + this.status = status + this.body = body + } +} + +/** + * @param {string} path e.g. '/v1/agents' + * @param {Object} [options] + * @param {string} [options.method] + * @param {Record} [options.body] + * @param {string} [options.apiKey] defaults to CURSOR_API_KEY env + * @param {typeof fetch} [options.fetchImpl] injected in tests + */ +export const cursorApiRequest = async ( + path, + { method = 'GET', body, apiKey = process.env.CURSOR_API_KEY, fetchImpl = fetch } = {}, +) => { + if (!apiKey) { + throw new CursorApiError(`Cursor API ${method} ${path}: CURSOR_API_KEY ausente`, { + status: 0, + body: null, + }) + } + const response = await fetchImpl(`${CURSOR_API_BASE}${path}`, { + method, + headers: { + authorization: `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`, + 'content-type': 'application/json', + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + const text = await response.text() + const json = text ? JSON.parse(text) : {} + if (!response.ok) { + throw new CursorApiError(`Cursor API ${method} ${path} → ${response.status}`, { + status: response.status, + body: json, + }) + } + return json +} + +export const cursorApiMe = (options) => cursorApiRequest('/v1/me', options) + +export const listCursorModels = (options) => cursorApiRequest('/v1/models', options) + +/** @param {Record} payload the Create-An-Agent body */ +export const createCursorAgent = (payload, options) => + cursorApiRequest('/v1/agents', { ...options, method: 'POST', body: payload }) + +export const getCursorAgent = (id, options) => cursorApiRequest(`/v1/agents/${id}`, options) + +export const getCursorRun = (agentId, runId, options) => + cursorApiRequest(`/v1/agents/${agentId}/runs/${runId}`, options) + +export const cancelCursorRun = (agentId, runId, options) => + cursorApiRequest(`/v1/agents/${agentId}/runs/${runId}/cancel`, { ...options, method: 'POST' }) + +export const archiveCursorAgent = (id, options) => + cursorApiRequest(`/v1/agents/${id}/archive`, { ...options, method: 'POST' }) diff --git a/scripts/lib/agent-pool-eligibility.mjs b/scripts/lib/agent-pool-eligibility.mjs new file mode 100644 index 00000000..af057f40 --- /dev/null +++ b/scripts/lib/agent-pool-eligibility.mjs @@ -0,0 +1,80 @@ +/** + * Autonomous-claim eligibility for the agent pool + * (docs/plans/agent-pool-orchestrator.md §3). Pure — no IO. + * + * The pool consumes the SAME queue as `agent:claim` (buildClaimQueue) and then + * applies stricter, fail-closed predicates: anything that needs a human + * (`requirements-changed`, `needs:consent`, `blocked`) is out, migration-touching + * issues yield to the migration-lock while a schema PR is open, and an issue + * the pool already failed twice stays out (circuit breaker — enforced per + * candidate at claim time, where the comments are already being read). + */ + +import { labelNames } from './agent-github.mjs' + +export const POOL_CIRCUIT_BREAKER_FAILURES = 2 + +const EXCLUDED_STATE_LABELS = ['in-progress', 'blocked', 'done', 'in-prod'] +const HUMAN_GATE_LABELS = ['requirements-changed', 'needs:consent'] + +/** + * @param {ReturnType[number]} entry queue entry from buildClaimQueue + * @param {Object} [options] + * @param {boolean} [options.migrationBusy] + * @param {number} [options.poolFailureCount] + * @returns {{ ok: true } | { ok: false, reason: string }} + */ +export const isAutonomousClaimable = ( + entry, + { migrationBusy = false, poolFailureCount = 0 } = {}, +) => { + const labels = labelNames(entry.issue) + if (entry.issue.state !== 'OPEN') return { ok: false, reason: 'not-open' } + if (!labels.includes('ready')) return { ok: false, reason: 'not-ready' } + if (EXCLUDED_STATE_LABELS.some((label) => labels.includes(label))) { + return { ok: false, reason: 'state-label' } + } + if (HUMAN_GATE_LABELS.some((label) => labels.includes(label))) { + return { ok: false, reason: 'needs-human' } + } + if ((entry.blockedBy ?? []).length > 0) return { ok: false, reason: 'blocked-by-deps' } + if (poolFailureCount >= POOL_CIRCUIT_BREAKER_FAILURES) { + return { ok: false, reason: 'circuit-breaker' } + } + const serializes = Array.isArray(entry.meta?.serializes) ? entry.meta.serializes : [] + const touchesSchema = labels.includes('needs:migration') || serializes.includes('migrations') + if (touchesSchema && migrationBusy) return { ok: false, reason: 'migration-busy' } + return { ok: true } +} + +/** Plan link is a preference, never a blocker (v1) — status surfaces it as a warn. */ +export const issueHasPlanLink = (issue) => /docs\/plans\//.test(issue?.body ?? '') + +/** + * Splits the claim queue into pool-eligible entries (order preserved — the + * claim order IS the spawn order) and exclusions with their reason. + * + * @param {ReturnType} claimQueue + * @param {Object} [options] + * @param {boolean} [options.migrationBusy] + * @param {Map} [options.failureCountsByIssue] + */ +export const buildPoolQueue = ( + claimQueue, + { migrationBusy = false, failureCountsByIssue = new Map() } = {}, +) => { + const eligible = [] + const excluded = [] + for (const entry of claimQueue) { + const verdict = isAutonomousClaimable(entry, { + migrationBusy, + poolFailureCount: failureCountsByIssue.get(entry.issue.number) ?? 0, + }) + if (verdict.ok) { + eligible.push({ entry, hasPlan: issueHasPlanLink(entry.issue) }) + } else { + excluded.push({ entry, reason: verdict.reason }) + } + } + return { eligible, excluded } +} diff --git a/scripts/lib/agent-pool-github.mjs b/scripts/lib/agent-pool-github.mjs new file mode 100644 index 00000000..7c32a209 --- /dev/null +++ b/scripts/lib/agent-pool-github.mjs @@ -0,0 +1,150 @@ +/** + * gh-CLI IO wrappers for the agent pool supervisor. Pure logic lives in + * agent-pool-state.mjs / agent-pool-eligibility.mjs; the Cursor REST client in + * agent-pool-cursor.mjs. Everything here shells the authenticated `gh` — no + * GitHub token of its own, same contract as agent-github.mjs. + */ + +import { randomUUID } from 'node:crypto' + +import { gh, ghJson, labelNames, setLabels } from './agent-github.mjs' +import { formatPoolEvent } from './agent-pool-state.mjs' + +/** + * Repo variables (Settings → Variables) as a plain map. One `gh api` call; + * pool values are scalars, so the `name=value` encoding splits on the first + * `=` only. Requires `actions: read` (read) / `actions: write` (write). + * + * Non-strict mode degrades to `{}` + a warn: inspection commands (status, + * dry-run tick) still work for a token without Actions permissions, and the + * resulting config is the disabled default — the fail-closed direction. The + * live tick stays strict: if the supervisor cannot read its own config it + * must stop loudly, never guess. + */ +export const readPoolVariables = ({ strict = true } = {}) => { + let out + try { + out = gh([ + 'api', + 'repos/{owner}/{repo}/actions/variables', + '--paginate', + '--jq', + '.variables[] | .name + "=" + .value', + ]) + } catch (error) { + if (strict) throw error + console.error( + '[agent:pool] aviso: repo variables ilegíveis (token sem actions:read?) — usando defaults (pool desligado).', + ) + return {} + } + const vars = {} + for (const line of out.split('\n')) { + if (!line) continue + const eq = line.indexOf('=') + vars[line.slice(0, eq)] = line.slice(eq + 1) + } + return vars +} + +export const listInProgressIssues = () => + ghJson([ + 'issue', + 'list', + '--state', + 'open', + '--label', + 'in-progress', + '--limit', + '200', + '--json', + 'number,title,body,state,labels,createdAt', + ]) + +export const listIssueComments = (number) => + ghJson(['issue', 'view', String(number), '--json', 'comments']).comments ?? [] + +export const listOpenPrs = () => + ghJson(['pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,body,url']) + +/** The open PR whose body closes this issue (`Closes #N` / `Fixes #N`), if any. */ +export const prClosingIssue = (prs, issueNumber) => + prs.find((pr) => new RegExp(`(?:closes|fixes) #${issueNumber}\\b`, 'i').test(pr.body ?? '')) ?? + null + +// Same path predicate as the ci-pr.yml migration-lock job (single source of +// truth for "schema-touching PR"). +const SCHEMA_PR_PATH = /^(src\/migrations\/|src\/payload-types\.ts$|payload-types\.ts$)/ + +export const countOpenSchemaPrs = () => + ghJson(['pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,files']).filter( + (pr) => (pr.files ?? []).some((file) => SCHEMA_PR_PATH.test(file.path)), + ).length + +/** Create or update one repo variable (POST first time, PATCH after). */ +export const writePoolVariable = (name, value) => { + const existing = readPoolVariables() + if (Object.hasOwn(existing, name)) { + gh([ + 'api', + '-X', + 'PATCH', + `repos/{owner}/{repo}/actions/variables/${name}`, + '-f', + `value=${value}`, + ]) + } else { + gh([ + 'api', + '-X', + 'POST', + 'repos/{owner}/{repo}/actions/variables', + '-f', + `name=${name}`, + '-f', + `value=${value}`, + ]) + } +} + +/** Comment on an issue with human text plus the machine-readable pool marker. */ +export const commentPoolEvent = (number, event, text) => { + gh(['issue', 'comment', String(number), '--body', `${text} ${formatPoolEvent(event)}`]) +} + +/** + * Coordinated claim (plan §4): the supervisor is the single allocator. Re-read + * the issue (same optimistic lock as agent:claim), flip ready→in-progress and + * leave the claim marker with the worker UUID the spawn will reuse as the + * idempotent `agentId`. Returns `{ ok, workerUuid }` or `{ ok: false }` when + * the race was lost — the tick just moves to the next queue entry. + */ +export const claimIssueForPool = (number, { tickIso }) => { + const fresh = ghJson(['issue', 'view', String(number), '--json', 'number,state,labels']) + const labels = labelNames(fresh) + if (fresh.state !== 'OPEN' || !labels.includes('ready') || labels.includes('in-progress')) { + return { ok: false, reason: 'lost-race' } + } + setLabels(number, { add: ['in-progress'], remove: ['ready'] }) + const workerUuid = randomUUID() + commentPoolEvent( + number, + { event: 'claim', tick: tickIso, worker: workerUuid }, + `Claimed pelo pool-supervisor (tick ${tickIso}) — worker em spawn.`, + ) + return { ok: true, workerUuid } +} + +/** Undo the claim when the spawn itself failed — the issue goes back to ready. */ +export const rollbackPoolClaim = (number, note) => { + setLabels(number, { add: ['ready'], remove: ['in-progress'] }) + gh(['issue', 'comment', String(number), '--body', `Pool-supervisor: claim revertido — ${note}`]) +} + +/** + * Failure path: a terminally failed worker's issue leaves the claim queue for + * human triage. The pool only ever blocks issues IT claimed (claim marker). + */ +export const blockIssueFromPool = (number) => { + setLabels(number, { add: ['blocked'], remove: ['in-progress'] }) +} diff --git a/scripts/lib/agent-pool-models.mjs b/scripts/lib/agent-pool-models.mjs new file mode 100644 index 00000000..a69faf2c --- /dev/null +++ b/scripts/lib/agent-pool-models.mjs @@ -0,0 +1,62 @@ +/** + * Issue `model:` slug → Cursor Cloud `model.id` resolution + * (docs/plans/agent-pool-orchestrator.md §5). + * + * Two namespaces meet here: the repo's model-selection slugs (frontmatter + * `model:`) and the Cloud API ids. The live table (`GET /v1/models`) is the + * authority — a slug resolves by exact id or alias, the repo `-fast` suffix + * maps to the model's `fast` param when the table says it exists, and + * anything else falls back to composer-2.5 (pool "Cursor Models", included + * cost — model-selection rule 1). The fallback never crashes a tick. + */ + +export const POOL_DEFAULT_MODEL_SLUG = 'composer-2.5' + +const FAST_SUFFIX = '-fast' + +const findModel = (models, slug) => + models.find((model) => model.id === slug || (model.aliases ?? []).includes(slug)) ?? null + +/** + * @param {unknown} issueModel frontmatter `model:` value + * @param {Array<{ id: string, aliases?: string[], parameters?: Array<{ id: string }> }>} [apiModels] live /v1/models items + * @returns {{ model: { id: string, params?: Array<{ id: string, value: string }> }, requested: string | null, usedFallback: boolean, warn?: string }} + */ +export const resolvePoolModel = (issueModel, apiModels = []) => { + const requested = + typeof issueModel === 'string' && issueModel.trim() !== '' ? issueModel.trim() : null + + const fallback = () => { + const known = findModel(apiModels, POOL_DEFAULT_MODEL_SLUG) + const tableNote = apiModels.length === 0 ? ' (tabela /v1/models indisponível)' : '' + return { + model: { id: known?.id ?? POOL_DEFAULT_MODEL_SLUG }, + requested, + usedFallback: requested !== null, + ...(requested + ? { + warn: `modelo "${requested}" fora da tabela Cloud — fallback ${POOL_DEFAULT_MODEL_SLUG}${tableNote}`, + } + : {}), + } + } + + if (!requested) return fallback() + + if (requested.endsWith(FAST_SUFFIX)) { + const base = findModel(apiModels, requested.slice(0, -FAST_SUFFIX.length)) + const supportsFast = (base?.parameters ?? []).some((param) => param.id === 'fast') + if (base && supportsFast) { + return { + model: { id: base.id, params: [{ id: 'fast', value: 'true' }] }, + requested, + usedFallback: false, + } + } + } + + const direct = findModel(apiModels, requested) + if (direct) return { model: { id: direct.id }, requested, usedFallback: false } + + return fallback() +} diff --git a/scripts/lib/agent-pool-prompt.mjs b/scripts/lib/agent-pool-prompt.mjs new file mode 100644 index 00000000..b1e5acdd --- /dev/null +++ b/scripts/lib/agent-pool-prompt.mjs @@ -0,0 +1,54 @@ +/** + * Worker prompt for pool-spawned Cursor Cloud Agents + * (docs/plans/agent-pool-orchestrator.md §4/§5). + * + * The supervisor claims the issue BEFORE spawning (label flip + pool-worker + * marker comment — the coordinated-claim anti-race), so the worker SKIPS the + * claim step of work-issue and starts at step 1b. Everything else — gates, PR + * base stage with Closes #N, auto-merge + CI watch, never promote — is the + * standard skill contract restated so the worker cannot miss it. + */ + +/** First docs/plans/*.md path in the issue body (the linked plan), if any. */ +export const extractPlanPath = (body) => body?.match(/docs\/plans\/[\w./-]+\.md/)?.[0] ?? null + +/** + * @param {Object} options + * @param {number} options.issueNumber + * @param {string} options.issueTitle + * @param {string | null} [options.issueId] frontmatter id (e.g. B79) + * @param {string | null} [options.planPath] + * @param {string | null} [options.modelSlug] frontmatter model: as declared + */ +export const buildPoolWorkerPrompt = ({ issueNumber, issueTitle, issueId, planPath, modelSlug }) => + [ + 'Você é um worker do pool de agentes do Teqo (repo fsolla/teqo).', + '', + '## Sua Issue (já claimada — NÃO rode `pnpm agent:claim`)', + '', + `#${issueNumber} — ${issueTitle}${issueId ? ` (${issueId})` : ''}`, + 'O pool-supervisor já fez o claim desta Issue (label `in-progress` + comentário de claim). Outros workers do pool pegam outras Issues — trabalhe somente nesta.', + '', + '## Fluxo', + '', + 'Siga a skill `.cursor/skills/work-issue/SKILL.md` a partir do passo 1b, PULANDO o passo 1 (claim):', + '1. Rode `pnpm i` se `node_modules` não existir.', + planPath + ? `2. Plano: \`${planPath}\` — freshness audit enxuto (passo 3 da skill).` + : '2. O body da Issue é a spec (não há plano linkado — avalie se ele basta para trabalhar).', + '3. Execute as fases com os gates do repo: `pnpm gate:fast` antes do push (o pre-push roda `pnpm gate:push`). Comandos bare, nunca piped.', + `4. PR obrigatoriamente com \`gh pr create --base stage\` e "Closes #${issueNumber}" no body.`, + '5. `gh pr merge --auto --merge ` e acompanhe `gh pr checks --watch` até o merge. Falha de CI no seu PR é sua (docs/AGENT-OPS.md — "Dono do PR, dono do CI"): corrija na mesma branch.', + '', + '## Proibido', + '', + '- `pnpm agent:promote` — promote stage→main é humano.', + '- `DATABASE_URL` de stage/prod ou `ALLOW_REMOTE_DB` — o setup local (`.cursor/cloud-setup.sh` + seed mínimo) cobre tudo.', + `- Editar outras Issues \`in-progress\` ou trabalhar fora da Issue #${issueNumber}.`, + '', + modelSlug + ? `Modelo declarado da Issue: \`${modelSlug}\` — você foi spawnado nele (ou no fallback documentado pelo pool); a verificação assimétrica do passo 2 não se aplica.` + : 'A Issue não declara `model:` — você foi spawnado no default do pool (composer-2.5).', + '', + `Ao terminar — merge concluído ou falha terminal — comente na Issue #${issueNumber} o desfecho em uma linha e encerre.`, + ].join('\n') diff --git a/scripts/lib/agent-pool-state.mjs b/scripts/lib/agent-pool-state.mjs new file mode 100644 index 00000000..36e52b30 --- /dev/null +++ b/scripts/lib/agent-pool-state.mjs @@ -0,0 +1,205 @@ +/** + * Pure pool-state helpers for the agent pool supervisor + * (docs/plans/agent-pool-orchestrator.md §2). No IO — the gh wrappers live in + * agent-pool-github.mjs and the Cursor REST client in agent-pool-cursor.mjs. + * + * State model: scalar config lives in GitHub repo variables; the dynamic view + * (who is active) is DERIVED from two sources of truth — pool-worker event + * markers in issue comments and Cursor run statuses — never persisted, so it + * cannot drift from reality. + */ + +export const POOL_VARIABLE_NAMES = { + enabled: 'POOL_ENABLED', + maxSlots: 'POOL_MAX_SLOTS', + paused: 'POOL_PAUSED', + startedAt: 'POOL_STARTED_AT', + startedBy: 'POOL_STARTED_BY', +} + +export const POOL_DEFAULT_MAX_SLOTS = 5 +// Cursor Pro allows 8 concurrent cloud agents (plan §14); the pool leaves the +// remaining slots for human sessions. +export const POOL_HARD_MAX_SLOTS = 8 + +/** + * @param {Record} [vars] raw `gh api` variable map + * @returns {{ enabled: boolean, maxSlots: number, paused: boolean, startedAt: string | null, startedBy: string | null }} + */ +export const parsePoolConfig = (vars = {}) => { + const rawSlots = Number.parseInt(vars[POOL_VARIABLE_NAMES.maxSlots] ?? '', 10) + return { + enabled: vars[POOL_VARIABLE_NAMES.enabled] === 'true', + maxSlots: Number.isFinite(rawSlots) + ? Math.min(Math.max(rawSlots, 1), POOL_HARD_MAX_SLOTS) + : POOL_DEFAULT_MAX_SLOTS, + paused: vars[POOL_VARIABLE_NAMES.paused] === 'true', + startedAt: vars[POOL_VARIABLE_NAMES.startedAt] || null, + startedBy: vars[POOL_VARIABLE_NAMES.startedBy] || null, + } +} + +// --- pool-worker event markers (machine-readable HTML comments on issues) --- + +const POOL_EVENT_PATTERN = //g + +/** + * @typedef {{ event: string, tick?: string, commentAt?: string | null, agentId?: string, runId?: string, reason?: string, worker?: string, v?: number }} PoolEvent + * @param {Record} event + * @returns {string} + */ +export const formatPoolEvent = (event) => + `` + +/** + * Parse every pool-worker marker across an issue's comments, oldest first. + * @param {Array<{ body?: string, createdAt?: string }> | undefined} [comments] + * @returns {PoolEvent[]} + */ +export const parsePoolEvents = (comments = []) => { + const events = [] + for (const comment of comments ?? []) { + const body = comment?.body + if (!body) continue + for (const match of body.matchAll(POOL_EVENT_PATTERN)) { + try { + const parsed = JSON.parse(match[1]) + if (parsed && typeof parsed.event === 'string') { + events.push({ ...parsed, commentAt: comment.createdAt ?? null }) + } + } catch { + // Hand-edited / malformed marker: ignore it, never crash a tick. + } + } + } + return events +} + +/** @param {PoolEvent[]} events */ +export const countPoolFailures = (events) => + events.filter((event) => event.event === 'failure').length + +// --- claim classification (occupied vs freed vs failed) --- + +const TERMINAL_RUN_STATUSES = new Set(['FINISHED', 'ERROR', 'CANCELLED', 'EXPIRED']) + +export const POOL_CLAIM_CLASS = { + occupiedRunning: 'occupied-running', + occupiedBooting: 'occupied-booting', + occupiedAutoMerge: 'occupied-auto-merge', + freed: 'freed', + failed: 'failed', +} + +export const POOL_OCCUPIED_CLASSES = new Set([ + POOL_CLAIM_CLASS.occupiedRunning, + POOL_CLAIM_CLASS.occupiedBooting, + POOL_CLAIM_CLASS.occupiedAutoMerge, +]) + +/** + * Partition classified claims for the tick: failures to reconcile (blocked + + * archive), freed slots (merge done — archive the agent), and actives still + * occupying a slot (the gap math input). + * + * @param {Array<{ classification: { class: string } | null }>} claims + * @returns {{ failures: Array, freed: Array, active: Array }} + */ +export const reconcilePoolClaims = (claims) => { + const failures = [] + const freed = [] + const active = [] + for (const claim of claims) { + const cls = claim.classification?.class + if (cls === POOL_CLAIM_CLASS.failed) failures.push(claim) + else if (cls === POOL_CLAIM_CLASS.freed) freed.push(claim) + else if (cls && POOL_OCCUPIED_CLASSES.has(cls)) active.push(claim) + } + return { failures, freed, active } +} + +// A claim without a spawn event is only suspicious after the boot window — +// the worker takes minutes to come up (environment install), so a fresh claim +// is "booting", not lost. +export const POOL_SPAWN_GRACE_MS = 15 * 60 * 1000 + +/** + * Classify one pool-claimed issue. Returns null when the issue was not claimed + * by the pool (no claim marker) — those belong to humans/manual agents and the + * pool never touches them. + * + * Occupied means "from the claim to the merge in stage (or a documented + * terminal failure)" — a terminal run with an open PR is still occupied + * (auto-merge finishes the job hands-off), per the plan's slot definition. + * + * @param {Object} options + * @param {PoolEvent[]} options.events + * @param {boolean} [options.issueDone] + * @param {boolean} [options.hasOpenPr] + * @param {string | null} [options.runStatus] + * @param {number} [options.now] + * @returns {{ class: string, reason?: string, agentId?: string | null, runId?: string | null } | null} + */ +export const classifyPoolClaim = ({ + events, + issueDone = false, + hasOpenPr = false, + runStatus = null, + now = Date.now(), +}) => { + const claims = events.filter((event) => event.event === 'claim') + if (claims.length === 0) return null + if (issueDone) return { class: POOL_CLAIM_CLASS.freed } + + const spawn = events.find((event) => event.event === 'spawn') + if (!spawn) { + const latest = claims[claims.length - 1] + const claimedAtMs = Date.parse(latest.tick ?? latest.commentAt ?? '') + const ageMs = Number.isFinite(claimedAtMs) ? now - claimedAtMs : Number.POSITIVE_INFINITY + return ageMs < POOL_SPAWN_GRACE_MS + ? { class: POOL_CLAIM_CLASS.occupiedBooting, agentId: null } + : { class: POOL_CLAIM_CLASS.failed, reason: 'spawn-missing', agentId: null } + } + + if (runStatus && TERMINAL_RUN_STATUSES.has(runStatus)) { + if (hasOpenPr) { + return { class: POOL_CLAIM_CLASS.occupiedAutoMerge, agentId: spawn.agentId ?? null } + } + return { + class: POOL_CLAIM_CLASS.failed, + reason: `run-${runStatus.toLowerCase()}`, + agentId: spawn.agentId ?? null, + } + } + return { + class: POOL_CLAIM_CLASS.occupiedRunning, + agentId: spawn.agentId ?? null, + runId: spawn.runId ?? null, + } +} + +// --- spawn plan / auto-stop --- + +/** + * @param {Object} options + * @param {unknown[]} options.eligible + * @param {number} options.activeCount + * @param {number} options.maxSlots + */ +export const computeSpawnPlan = ({ eligible, activeCount, maxSlots }) => { + const gap = Math.max(0, maxSlots - activeCount) + return { gap, toSpawn: eligible.slice(0, gap) } +} + +/** + * The pool switches itself off only when the eligible queue is drained AND no + * worker is active — except when the only exclusions are `migration-busy`, + * which is transient (the schema PR will merge and the queue re-opens). + * + * @param {Object} options + * @param {number} options.eligibleCount + * @param {number} options.activeCount + * @param {string[]} [options.excludedReasons] + */ +export const decidePoolAutoStop = ({ eligibleCount, activeCount, excludedReasons = [] }) => + eligibleCount === 0 && activeCount === 0 && !excludedReasons.includes('migration-busy') diff --git a/tests/unit/agentPoolCursor.unit.spec.ts b/tests/unit/agentPoolCursor.unit.spec.ts new file mode 100644 index 00000000..ed154ab9 --- /dev/null +++ b/tests/unit/agentPoolCursor.unit.spec.ts @@ -0,0 +1,73 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { CursorApiError, cursorApiRequest } from '../../scripts/lib/agent-pool-cursor.mjs' + +const fakeResponse = (ok: boolean, status: number, payload: unknown) => + ({ + ok, + status, + text: async () => (payload === null ? '' : JSON.stringify(payload)), + }) as Response + +const captureFetch = (response: Response) => { + const calls: Array<{ url: string; init: RequestInit }> = [] + const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }) + return response + } + return { calls, fetchImpl: fetchImpl as typeof fetch } +} + +describe('cursorApiRequest', () => { + it('sends Basic auth with the key as username and parses JSON', async () => { + const { calls, fetchImpl } = captureFetch(fakeResponse(true, 200, { ok: true })) + const result = await cursorApiRequest('/v1/me', { apiKey: 'crsr_teste', fetchImpl }) + expect(result).toEqual({ ok: true }) + expect(calls[0]!.url).toBe('https://api.cursor.com/v1/me') + const auth = (calls[0]!.init.headers as Record).authorization + expect(auth).toBe(`Basic ${Buffer.from('crsr_teste:').toString('base64')}`) + }) + + it('posts a JSON body only when provided', async () => { + const { calls, fetchImpl } = captureFetch(fakeResponse(true, 200, { agent: {} })) + await cursorApiRequest('/v1/agents', { + method: 'POST', + body: { prompt: { text: 'oi' } }, + apiKey: 'k', + fetchImpl, + }) + expect(calls[0]!.init.body).toBe(JSON.stringify({ prompt: { text: 'oi' } })) + + const getCalls = captureFetch(fakeResponse(true, 200, {})) + await cursorApiRequest('/v1/models', { apiKey: 'k', fetchImpl: getCalls.fetchImpl }) + expect(getCalls.calls[0]!.init.body).toBeUndefined() + }) + + it('throws CursorApiError with status and parsed body on failure', async () => { + const { fetchImpl } = captureFetch( + fakeResponse(false, 409, { error: { code: 'agent_id_conflict' } }), + ) + const failure = await cursorApiRequest('/v1/agents', { + method: 'POST', + body: {}, + apiKey: 'k', + fetchImpl, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CursorApiError) + expect((failure as InstanceType).status).toBe(409) + expect((failure as InstanceType).body).toEqual({ + error: { code: 'agent_id_conflict' }, + }) + }) + + it('fails closed without an API key (status 0, no fetch)', async () => { + const { calls, fetchImpl } = captureFetch(fakeResponse(true, 200, {})) + const failure = await cursorApiRequest('/v1/me', { apiKey: '', fetchImpl }).catch( + (error: unknown) => error, + ) + expect((failure as InstanceType).status).toBe(0) + expect(calls).toHaveLength(0) + }) +}) diff --git a/tests/unit/agentPoolEligibility.unit.spec.ts b/tests/unit/agentPoolEligibility.unit.spec.ts new file mode 100644 index 00000000..7909af53 --- /dev/null +++ b/tests/unit/agentPoolEligibility.unit.spec.ts @@ -0,0 +1,179 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { buildClaimQueue } from '../../scripts/lib/agent-github.mjs' +import { + buildPoolQueue, + isAutonomousClaimable, + issueHasPlanLink, + POOL_CIRCUIT_BREAKER_FAILURES, +} from '../../scripts/lib/agent-pool-eligibility.mjs' + +type TestIssue = { + number: number + title: string + body: string + state: string + createdAt: string + labels: Array<{ name: string }> +} + +const issue = (over: Partial = {}): TestIssue => ({ + number: 1, + title: 'Issue de teste', + body: '', + state: 'OPEN', + createdAt: '2026-07-01T00:00:00Z', + labels: [{ name: 'ready' }, { name: 'prio:P2' }], + ...over, +}) + +const entry = (over: Record = {}) => { + const { issue: issueOver, ...rest } = over + return { + issue: issue((issueOver as Partial) ?? {}), + meta: {}, + blockedBy: [], + priority: 'prio:P2', + satisfiedWithoutIssue: [], + ...rest, + } +} + +describe('isAutonomousClaimable', () => { + it('accepts a plain ready issue', () => { + expect(isAutonomousClaimable(entry())).toEqual({ ok: true }) + }) + + it('rejects a closed issue', () => { + expect(isAutonomousClaimable(entry({ issue: { state: 'CLOSED' } }))).toEqual({ + ok: false, + reason: 'not-open', + }) + }) + + it('rejects an issue without the ready label', () => { + expect(isAutonomousClaimable(entry({ issue: { labels: [{ name: 'prio:P2' }] } }))).toEqual({ + ok: false, + reason: 'not-ready', + }) + }) + + it.each(['in-progress', 'blocked', 'done', 'in-prod'])( + 'rejects when the state label %s coexists with ready (stale double label)', + (stateLabel) => { + const verdict = isAutonomousClaimable( + entry({ issue: { labels: [{ name: 'ready' }, { name: stateLabel }] } }), + ) + expect(verdict).toEqual({ ok: false, reason: 'state-label' }) + }, + ) + + it.each(['requirements-changed', 'needs:consent'])( + 'never claims human-gated issues (%s), however ready they look', + (gateLabel) => { + const verdict = isAutonomousClaimable( + entry({ + issue: { + labels: [{ name: 'ready' }, { name: 'prio:P0' }, { name: gateLabel }], + body: 'Plano: [`docs/plans/x.md`](docs/plans/x.md)', + }, + }), + ) + expect(verdict).toEqual({ ok: false, reason: 'needs-human' }) + }, + ) + + it('rejects issues blocked by open deps', () => { + expect(isAutonomousClaimable(entry({ blockedBy: ['B99'] }))).toEqual({ + ok: false, + reason: 'blocked-by-deps', + }) + }) + + it('defers migration-touching issues only while a schema PR is open', () => { + const migrationLabels = [{ name: 'ready' }, { name: 'needs:migration' }] + expect( + isAutonomousClaimable(entry({ issue: { labels: migrationLabels } }), { + migrationBusy: true, + }), + ).toEqual({ ok: false, reason: 'migration-busy' }) + expect( + isAutonomousClaimable(entry({ issue: { labels: migrationLabels } }), { + migrationBusy: false, + }), + ).toEqual({ ok: true }) + }) + + it('reads the migration signal from frontmatter serializes too', () => { + const verdict = isAutonomousClaimable(entry({ meta: { serializes: ['migrations'] } }), { + migrationBusy: true, + }) + expect(verdict).toEqual({ ok: false, reason: 'migration-busy' }) + }) + + it('applies the circuit breaker at the failure threshold', () => { + expect( + isAutonomousClaimable(entry(), { poolFailureCount: POOL_CIRCUIT_BREAKER_FAILURES - 1 }), + ).toEqual({ ok: true }) + expect( + isAutonomousClaimable(entry(), { poolFailureCount: POOL_CIRCUIT_BREAKER_FAILURES }), + ).toEqual({ ok: false, reason: 'circuit-breaker' }) + }) +}) + +describe('buildPoolQueue', () => { + it('splits eligible/excluded preserving the claim order', () => { + const first = entry({ issue: { number: 10, title: 'primeira' } }) + const gated = entry({ + issue: { number: 11, labels: [{ name: 'ready' }, { name: 'needs:consent' }] }, + }) + const second = entry({ issue: { number: 12, title: 'segunda' } }) + const { eligible, excluded } = buildPoolQueue([first, gated, second]) + expect(eligible.map((item) => item.entry.issue.number)).toEqual([10, 12]) + expect(excluded).toEqual([{ entry: gated, reason: 'needs-human' }]) + }) + + it('flags the missing plan link as a warn, not a blocker', () => { + const withPlan = entry({ issue: { body: 'Plano: [`docs/plans/x.md`](docs/plans/x.md)' } }) + const withoutPlan = entry({ issue: { number: 2, body: 'sem link' } }) + const { eligible } = buildPoolQueue([withPlan, withoutPlan]) + expect(eligible.map((item) => item.hasPlan)).toEqual([true, false]) + }) +}) + +describe('issueHasPlanLink', () => { + it('matches docs/plans/ references in the body', () => { + expect(issueHasPlanLink(issue({ body: 'ver docs/plans/foo.md' }))).toBe(true) + expect(issueHasPlanLink(issue({ body: 'nada' }))).toBe(false) + }) +}) + +describe('buildClaimQueue parity (shared with agent:claim)', () => { + const byId = new Map([ + ['B1', issue({ number: 101, state: 'CLOSED', labels: [{ name: 'done' }] })], + ['B2', issue({ number: 102, labels: [{ name: 'in-progress' }] })], + ]) + const fm = (depends: string[]) => `---\nid: BX\ndepends: [${depends.join(', ')}]\n---\nspec` + + it('orders by prio then oldest, and treats deps without an issue as satisfied', () => { + const p2Old = issue({ number: 1, createdAt: '2026-07-01T00:00:00Z', body: fm(['B9']) }) + const p0 = issue({ + number: 2, + createdAt: '2026-07-03T00:00:00Z', + labels: [{ name: 'ready' }, { name: 'prio:P0' }], + }) + const p2New = issue({ number: 3, createdAt: '2026-07-02T00:00:00Z' }) + const queue = buildClaimQueue([p2New, p2Old, p0], byId) + expect(queue.map((item) => item.issue.number)).toEqual([2, 1, 3]) + expect(queue[1]!.satisfiedWithoutIssue).toEqual(['B9']) + }) + + it('filters issues whose deps are not done/closed/in-prod', () => { + const blocked = issue({ number: 1, body: fm(['B2']) }) + const free = issue({ number: 2, body: fm(['B1']) }) + const queue = buildClaimQueue([blocked, free], byId) + expect(queue.map((item) => item.issue.number)).toEqual([2]) + }) +}) diff --git a/tests/unit/agentPoolModels.unit.spec.ts b/tests/unit/agentPoolModels.unit.spec.ts new file mode 100644 index 00000000..a15738f6 --- /dev/null +++ b/tests/unit/agentPoolModels.unit.spec.ts @@ -0,0 +1,72 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { POOL_DEFAULT_MODEL_SLUG, resolvePoolModel } from '../../scripts/lib/agent-pool-models.mjs' + +const API_MODELS = [ + { id: 'composer-2.5', aliases: ['composer-latest'], parameters: [{ id: 'fast' }] }, + { id: 'cursor-grok-4.5-high' }, + { id: 'kimi-k3-low' }, +] + +describe('resolvePoolModel', () => { + it('resolves a declared slug by exact id', () => { + expect(resolvePoolModel('kimi-k3-low', API_MODELS)).toEqual({ + model: { id: 'kimi-k3-low' }, + requested: 'kimi-k3-low', + usedFallback: false, + }) + }) + + it('resolves by alias', () => { + const result = resolvePoolModel('composer-latest', API_MODELS) + expect(result.model).toEqual({ id: 'composer-2.5' }) + expect(result.usedFallback).toBe(false) + }) + + it('maps the repo -fast suffix to the fast param when the table supports it', () => { + const result = resolvePoolModel('composer-2.5-fast', API_MODELS) + expect(result.model).toEqual({ + id: 'composer-2.5', + params: [{ id: 'fast', value: 'true' }], + }) + expect(result.usedFallback).toBe(false) + }) + + it('falls back when the base of a -fast slug lacks the fast param', () => { + const result = resolvePoolModel('kimi-k3-low-fast', API_MODELS) + expect(result.model).toEqual({ id: POOL_DEFAULT_MODEL_SLUG }) + expect(result.usedFallback).toBe(true) + expect(result.warn).toContain('kimi-k3-low-fast') + }) + + it('falls back to composer-2.5 with a warn for unknown slugs', () => { + const result = resolvePoolModel('modelo-inventado-9', API_MODELS) + expect(result.model).toEqual({ id: POOL_DEFAULT_MODEL_SLUG }) + expect(result.requested).toBe('modelo-inventado-9') + expect(result.usedFallback).toBe(true) + expect(result.warn).toContain('fallback') + }) + + it('uses the default silently when the issue declares no model', () => { + for (const absent of [undefined, null, '', ' ']) { + const result = resolvePoolModel(absent, API_MODELS) + expect(result.model).toEqual({ id: POOL_DEFAULT_MODEL_SLUG }) + expect(result.usedFallback).toBe(false) + expect(result.warn).toBeUndefined() + } + }) + + it('still resolves when the live table is unavailable (degraded mode)', () => { + const result = resolvePoolModel('qualquer-coisa', []) + expect(result.model).toEqual({ id: POOL_DEFAULT_MODEL_SLUG }) + expect(result.warn).toContain('/v1/models') + }) + + it('ignores non-string frontmatter values', () => { + const result = resolvePoolModel(42, API_MODELS) + expect(result.model).toEqual({ id: POOL_DEFAULT_MODEL_SLUG }) + expect(result.usedFallback).toBe(false) + }) +}) diff --git a/tests/unit/agentPoolPrompt.unit.spec.ts b/tests/unit/agentPoolPrompt.unit.spec.ts new file mode 100644 index 00000000..a086184b --- /dev/null +++ b/tests/unit/agentPoolPrompt.unit.spec.ts @@ -0,0 +1,65 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { buildPoolWorkerPrompt, extractPlanPath } from '../../scripts/lib/agent-pool-prompt.mjs' + +const prompt = buildPoolWorkerPrompt({ + issueNumber: 42, + issueTitle: 'B91 — Ajustar badge', + issueId: 'B91', + planPath: 'docs/plans/ajustar-badge.md', + modelSlug: 'kimi-k3-low', +}) + +describe('buildPoolWorkerPrompt', () => { + it('tells the worker the issue is pre-claimed and forbids agent:claim', () => { + expect(prompt).toContain('#42') + expect(prompt).toContain('NÃO rode `pnpm agent:claim`') + expect(prompt).toContain('in-progress') + }) + + it('pins the PR contract: base stage, Closes #N, auto-merge, CI watch', () => { + expect(prompt).toContain('gh pr create --base stage') + expect(prompt).toContain('Closes #42') + expect(prompt).toContain('gh pr merge --auto --merge') + expect(prompt).toContain('gh pr checks --watch') + }) + + it('restates the hard prohibitions (promote humano, sem DB remota, escopo da Issue)', () => { + expect(prompt).toContain('pnpm agent:promote') + expect(prompt).toContain('DATABASE_URL') + expect(prompt).toContain('ALLOW_REMOTE_DB') + expect(prompt).toContain('trabalhar fora da Issue #42') + }) + + it('links the plan and the declared model when present', () => { + expect(prompt).toContain('docs/plans/ajustar-badge.md') + expect(prompt).toContain('kimi-k3-low') + }) + + it('notes the pool default when the issue declares no model', () => { + const noModel = buildPoolWorkerPrompt({ + issueNumber: 7, + issueTitle: 'x', + issueId: null, + planPath: null, + modelSlug: null, + }) + expect(noModel).toContain('composer-2.5') + expect(noModel).toContain('não há plano linkado') + }) +}) + +describe('extractPlanPath', () => { + it('pulls the first docs/plans path from the body', () => { + expect( + extractPlanPath('Plano: [`docs/plans/foo-bar.md`](docs/plans/foo-bar.md)\n\nmais texto'), + ).toBe('docs/plans/foo-bar.md') + }) + + it('returns null when the body has no plan link', () => { + expect(extractPlanPath('spec solta')).toBeNull() + expect(extractPlanPath('')).toBeNull() + }) +}) diff --git a/tests/unit/agentPoolState.unit.spec.ts b/tests/unit/agentPoolState.unit.spec.ts new file mode 100644 index 00000000..c3a4d4d1 --- /dev/null +++ b/tests/unit/agentPoolState.unit.spec.ts @@ -0,0 +1,215 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + classifyPoolClaim, + computeSpawnPlan, + countPoolFailures, + decidePoolAutoStop, + formatPoolEvent, + parsePoolConfig, + parsePoolEvents, + POOL_CLAIM_CLASS, + POOL_DEFAULT_MAX_SLOTS, + POOL_HARD_MAX_SLOTS, + POOL_SPAWN_GRACE_MS, + POOL_VARIABLE_NAMES, + reconcilePoolClaims, +} from '../../scripts/lib/agent-pool-state.mjs' + +const comment = (body: string, createdAt = '2026-07-30T12:00:00Z') => ({ body, createdAt }) + +describe('parsePoolConfig', () => { + it('defaults to a disabled pool with the default slot count', () => { + expect(parsePoolConfig({})).toEqual({ + enabled: false, + maxSlots: POOL_DEFAULT_MAX_SLOTS, + paused: false, + startedAt: null, + startedBy: null, + }) + }) + + it('reads the enabled/paused flags as strict true strings', () => { + const config = parsePoolConfig({ + [POOL_VARIABLE_NAMES.enabled]: 'true', + [POOL_VARIABLE_NAMES.paused]: 'true', + [POOL_VARIABLE_NAMES.startedAt]: '2026-07-30T00:00:00Z', + [POOL_VARIABLE_NAMES.startedBy]: 'fsolla', + }) + expect(config.enabled).toBe(true) + expect(config.paused).toBe(true) + expect(config.startedAt).toBe('2026-07-30T00:00:00Z') + expect(config.startedBy).toBe('fsolla') + }) + + it('clamps maxSlots into 1..POOL_HARD_MAX_SLOTS and falls back on garbage', () => { + expect(parsePoolConfig({ [POOL_VARIABLE_NAMES.maxSlots]: '0' }).maxSlots).toBe(1) + expect(parsePoolConfig({ [POOL_VARIABLE_NAMES.maxSlots]: '99' }).maxSlots).toBe( + POOL_HARD_MAX_SLOTS, + ) + expect(parsePoolConfig({ [POOL_VARIABLE_NAMES.maxSlots]: 'cinco' }).maxSlots).toBe( + POOL_DEFAULT_MAX_SLOTS, + ) + }) +}) + +describe('pool-worker event markers', () => { + it('round-trips events through format/parse', () => { + const marker = formatPoolEvent({ event: 'claim', tick: '2026-07-30T12:00:00Z', worker: 'abc' }) + const events = parsePoolEvents([comment(`Claimed pelo pool. ${marker}`)]) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + v: 1, + event: 'claim', + tick: '2026-07-30T12:00:00Z', + worker: 'abc', + commentAt: '2026-07-30T12:00:00Z', + }) + }) + + it('parses several markers across comments and ignores noise', () => { + const events = parsePoolEvents([ + comment('comentário humano sem marcador'), + comment( + `${formatPoolEvent({ event: 'claim' })} texto ${formatPoolEvent({ event: 'spawn', agentId: 'bc-1' })}`, + ), + comment(''), + comment(''), + ]) + expect(events.map((event) => event.event)).toEqual(['claim', 'spawn']) + }) + + it('counts failure events for the circuit breaker', () => { + const events = parsePoolEvents([ + comment(formatPoolEvent({ event: 'failure', reason: 'run-error' })), + comment(formatPoolEvent({ event: 'spawn' })), + comment(formatPoolEvent({ event: 'failure', reason: 'spawn-missing' })), + ]) + expect(countPoolFailures(events)).toBe(2) + }) +}) + +describe('classifyPoolClaim', () => { + const claimEvent = { event: 'claim', tick: '2026-07-30T12:00:00Z', commentAt: null } + const spawnEvent = { event: 'spawn', agentId: 'bc-1', runId: 'run-1', commentAt: null } + const now = Date.parse('2026-07-30T12:05:00Z') + + it('returns null for issues the pool never claimed (human-owned)', () => { + expect(classifyPoolClaim({ events: [], now })).toBeNull() + }) + + it('frees the slot once the issue is done (CI flipped on stage merge)', () => { + expect(classifyPoolClaim({ events: [claimEvent], issueDone: true, now })).toEqual({ + class: POOL_CLAIM_CLASS.freed, + }) + }) + + it('treats a fresh claim without spawn as booting, and a stale one as failed', () => { + expect(classifyPoolClaim({ events: [claimEvent], now })).toEqual({ + class: POOL_CLAIM_CLASS.occupiedBooting, + agentId: null, + }) + const staleNow = now + POOL_SPAWN_GRACE_MS + 1 + expect(classifyPoolClaim({ events: [claimEvent], now: staleNow })).toEqual({ + class: POOL_CLAIM_CLASS.failed, + reason: 'spawn-missing', + agentId: null, + }) + }) + + it('keeps the slot occupied while the run is live', () => { + expect( + classifyPoolClaim({ events: [claimEvent, spawnEvent], runStatus: 'RUNNING', now }), + ).toEqual({ + class: POOL_CLAIM_CLASS.occupiedRunning, + agentId: 'bc-1', + runId: 'run-1', + }) + }) + + it('stays occupied past a terminal run when the PR is still open (auto-merge)', () => { + expect( + classifyPoolClaim({ + events: [claimEvent, spawnEvent], + runStatus: 'FINISHED', + hasOpenPr: true, + now, + }), + ).toEqual({ class: POOL_CLAIM_CLASS.occupiedAutoMerge, agentId: 'bc-1' }) + }) + + it('fails terminally when the run ended without a PR and the issue is not done', () => { + expect( + classifyPoolClaim({ events: [claimEvent, spawnEvent], runStatus: 'ERROR', now }), + ).toEqual({ class: POOL_CLAIM_CLASS.failed, reason: 'run-error', agentId: 'bc-1' }) + expect( + classifyPoolClaim({ events: [claimEvent, spawnEvent], runStatus: 'FINISHED', now }), + ).toEqual({ class: POOL_CLAIM_CLASS.failed, reason: 'run-finished', agentId: 'bc-1' }) + }) +}) + +describe('reconcilePoolClaims', () => { + const withClass = (cls: string | null) => ({ classification: cls ? { class: cls } : null }) + + it('partitions failures, freed and actives for the tick', () => { + const claims = [ + withClass(POOL_CLAIM_CLASS.failed), + withClass(POOL_CLAIM_CLASS.freed), + withClass(POOL_CLAIM_CLASS.occupiedRunning), + withClass(POOL_CLAIM_CLASS.occupiedBooting), + withClass(POOL_CLAIM_CLASS.occupiedAutoMerge), + withClass(null), // not pool-owned + ] + const { failures, freed, active } = reconcilePoolClaims(claims) + expect(failures).toHaveLength(1) + expect(freed).toHaveLength(1) + expect(active).toHaveLength(3) + }) + + it('yields empty partitions when nothing is claimed', () => { + expect(reconcilePoolClaims([])).toEqual({ failures: [], freed: [], active: [] }) + }) +}) + +describe('computeSpawnPlan', () => { + const eligible = [{ entry: { issue: { number: 1 } } }, { entry: { issue: { number: 2 } } }] + + it('spawns up to the gap between actives and maxSlots', () => { + const plan = computeSpawnPlan({ eligible, activeCount: 3, maxSlots: 5 }) + expect(plan.gap).toBe(2) + expect(plan.toSpawn).toHaveLength(2) + }) + + it('never spawns past the cap, however long the queue', () => { + const plan = computeSpawnPlan({ eligible, activeCount: 5, maxSlots: 5 }) + expect(plan.gap).toBe(0) + expect(plan.toSpawn).toHaveLength(0) + }) +}) + +describe('decidePoolAutoStop', () => { + it('stops only when the queue drained and nothing is active', () => { + expect(decidePoolAutoStop({ eligibleCount: 0, activeCount: 0 })).toBe(true) + expect(decidePoolAutoStop({ eligibleCount: 2, activeCount: 0 })).toBe(false) + expect(decidePoolAutoStop({ eligibleCount: 0, activeCount: 1 })).toBe(false) + }) + + it('does not stop when exclusions are only the transient migration-busy', () => { + expect( + decidePoolAutoStop({ + eligibleCount: 0, + activeCount: 0, + excludedReasons: ['migration-busy'], + }), + ).toBe(false) + expect( + decidePoolAutoStop({ + eligibleCount: 0, + activeCount: 0, + excludedReasons: ['needs-human'], + }), + ).toBe(true) + }) +}) diff --git a/tests/unit/homeSearchUi.unit.spec.tsx b/tests/unit/homeSearchUi.unit.spec.tsx index 59a73654..9a5d7f8d 100644 --- a/tests/unit/homeSearchUi.unit.spec.tsx +++ b/tests/unit/homeSearchUi.unit.spec.tsx @@ -1,5 +1,5 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup' @@ -34,6 +34,14 @@ const searchResultsValue = ( resultKind, }) +// next/link schedules viewport-prefetch work via the React scheduler; without +// an explicit unmount the callback can fire after the jsdom teardown and crash +// the run with "window is not defined" (CI flake 2026-07-30, run 30587862940 — +// same afterEach(cleanup) pattern as the 14 sibling specs). +afterEach(() => { + cleanup() +}) + describe('HomeSearchHitRow', () => { it('wraps the link in a full-bleed hover strip', () => { const { container } = render(