feat(agent-pool): orquestrador de pool de workers Cursor Cloud (fases A–D) (#65)

* feat(agent-pool): fundacao read-only do orquestrador (fase A)

- docs/plans/agent-pool-orchestrator.md: plano completo (14 secoes)
- buildClaimQueue extraido de agent-claim para agent-github (comportamento identico, pinado)
- agent-pool-eligibility: isAutonomousClaimable puro e fail-closed + buildPoolQueue
- agent-pool-state: parsePoolConfig, marcadores pool-worker, classifyPoolClaim, spawn plan, auto-stop
- agent-pool-github: leitura de repo variables (degrada nao-estrito), claims/PRs/migration-lock
- pnpm agent:pool -- status|tick --dry-run (inspecao local; live chega na fase B)
- 34 specs unit novos (992/992 verde); lint/tsc/knip/cycles/format verdes

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* feat(agent-pool): spawn remoto de workers via Cursor API (fase B)

- agent-pool-cursor: client fino da Cloud Agents API v1 (fetch injetavel; CursorApiError com status/body)
- agent-pool-models: resolvePoolModel contra /v1/models (id/alias, sufixo -fast -> param, fallback composer-2.5 com warn)
- agent-pool-prompt: contrato do worker (claim pre-feito, pula passo 1; PR base stage + Closes #N; promote proibido)
- claim coordenado no tick: lock otimista + flip + marcador; agentId idempotente bc-<uuid>; 409 recupera; rollback em falha de spawn
- agent:pool workflow --action start|stop|pause|resume|tick|status (variables POOL_*); doctor; wrappers gh workflow run
- .github/workflows/agent-pool.yml (dispatch-only; schedule/triggers na fase C)
- 19 specs novos (1011/1011); lint/tsc/knip/cycles/format verdes

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* feat(agent-pool): ciclo de vida do supervisor (fase C)

- derivePoolClaims com status real dos runs (getCursorRun), heal de spawn via agentId deterministico, cancel de duplicatas
- reconcilePoolClaims: falha terminal -> comentario + blocked + archive; merge concluido -> slot livre + archive
- auto-stop real: fila drenada + zero ativos -> POOL_ENABLED=false (exclusoes migration-busy nao desligam)
- job summary markdown (GITHUB_STEP_SUMMARY) — superficie remota de status
- workflow: schedule */10min + pull_request closed em stage (ativam na default branch apos promote)
- 2 specs novos (1013/1013); lint/tsc/knip/cycles/format verdes

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* docs(agent-pool): runbook operacional e adendo do spike (fase D)

- skill agent-pool: runbook humano (pre-requisitos, start/stop/pause remoto, ciclo de vida, triage, smoke de aceite)
- AGENT-OPS: linha na tabela de comandos + secao Agent pool (canal, secrets, ativacao pos-promote)
- CHANGELOG-AGENTS: entrada curta da entrega
- engineering-audit: pause/resume do pool no protocolo do modo solitario
- plano secao 14.1: evidencias do spike 2026-07-30 (environment repo-file confirmado, id bc-<uuid>, degradacao 403, pendencias do primeiro run com CURSOR_API_KEY)

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* fix(test): teardown flake no homeSearchUi.unit.spec (dono do CI)

O scheduler do react (prefetch do next/link) podia disparar apos o teardown
do jsdom e derrubar o job unit com 'window is not defined' (run 30587862940).
afterEach(cleanup) — padrao dos 14 specs irmaos.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Francisco Solla 2026-07-30 20:08:34 -03:00 committed by GitHub
parent 4950c808b6
commit 680ff56754
22 changed files with 2459 additions and 37 deletions

View file

@ -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<N>-…` 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).

View file

@ -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.

75
.github/workflows/agent-pool.yml vendored Normal file
View file

@ -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[@]}"

View file

@ -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 -- <issue> <P0..P3>` | 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".

View file

@ -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-<uuid>` — 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).

View file

@ -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-<id> <agentUrl>`) | 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-<uuid5(issueNumber, date)>`
(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: <slug> }` 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<repoSlug, cloudModelId>` 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<N>-…`.
**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: <https://cursor.com/docs/cloud-agent/api/endpoints>.
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: <https://forum.cursor.com/t/clarification-on-cloud-agent-limits-simultaneous-agents-vs-environments-repos/157584>.
5. **Branches por worker:** o agente Cloud trabalha em branch própria gerada a partir de
`startingRef` (`workOnCurrentBranch: false`, default) — exatamente o modelo `agent/<id>-<slug>`
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: <https://cursor.com/docs/cloud-agent/automations>.
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-<uuid>` (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.

View file

@ -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",

View file

@ -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):`)

521
scripts/agent-pool.mjs Normal file
View file

@ -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 <branch>] [-- --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-<workerUuid>`
* 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 <branch>] [--max-slots N]\n' +
'(o workflow chama: node scripts/agent-pool.mjs workflow --action <ação>)',
)
}

View file

@ -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<any>} openReady
* @param {Map<string, any>} byId
* @returns {Array<{ issue: any, meta: Record<string, any>, 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)

View file

@ -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<string, unknown>} [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<string, unknown>} 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' })

View file

@ -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<import('./agent-github.mjs').buildClaimQueue>[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<import('./agent-github.mjs').buildClaimQueue>} claimQueue
* @param {Object} [options]
* @param {boolean} [options.migrationBusy]
* @param {Map<number, number>} [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 }
}

View file

@ -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'] })
}

View file

@ -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()
}

View file

@ -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 <PR>` e acompanhe `gh pr checks <PR> --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')

View file

@ -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<string, string | undefined>} [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 = /<!-- pool-worker (\{[^]*?\}) -->/g
/**
* @typedef {{ event: string, tick?: string, commentAt?: string | null, agentId?: string, runId?: string, reason?: string, worker?: string, v?: number }} PoolEvent
* @param {Record<string, unknown>} event
* @returns {string}
*/
export const formatPoolEvent = (event) =>
`<!-- pool-worker ${JSON.stringify({ v: 1, ...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<any>, freed: Array<any>, active: Array<any> }}
*/
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')

View file

@ -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<string, string>).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<typeof CursorApiError>).status).toBe(409)
expect((failure as InstanceType<typeof CursorApiError>).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<typeof CursorApiError>).status).toBe(0)
expect(calls).toHaveLength(0)
})
})

View file

@ -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> = {}): 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<string, unknown> = {}) => {
const { issue: issueOver, ...rest } = over
return {
issue: issue((issueOver as Partial<TestIssue>) ?? {}),
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<string, TestIssue>([
['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])
})
})

View file

@ -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)
})
})

View file

@ -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 <PR> --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()
})
})

View file

@ -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('<!-- pool-worker {malformed -->'),
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)
})
})

View file

@ -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(