mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
4950c808b6
commit
680ff56754
22 changed files with 2459 additions and 37 deletions
54
.cursor/skills/agent-pool/SKILL.md
Normal file
54
.cursor/skills/agent-pool/SKILL.md
Normal 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).
|
||||
|
|
@ -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
75
.github/workflows/agent-pool.yml
vendored
Normal 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[@]}"
|
||||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
511
docs/plans/agent-pool-orchestrator.md
Normal file
511
docs/plans/agent-pool-orchestrator.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
521
scripts/agent-pool.mjs
Normal 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>)',
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
73
scripts/lib/agent-pool-cursor.mjs
Normal file
73
scripts/lib/agent-pool-cursor.mjs
Normal 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' })
|
||||
80
scripts/lib/agent-pool-eligibility.mjs
Normal file
80
scripts/lib/agent-pool-eligibility.mjs
Normal 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 }
|
||||
}
|
||||
150
scripts/lib/agent-pool-github.mjs
Normal file
150
scripts/lib/agent-pool-github.mjs
Normal 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'] })
|
||||
}
|
||||
62
scripts/lib/agent-pool-models.mjs
Normal file
62
scripts/lib/agent-pool-models.mjs
Normal 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()
|
||||
}
|
||||
54
scripts/lib/agent-pool-prompt.mjs
Normal file
54
scripts/lib/agent-pool-prompt.mjs
Normal 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')
|
||||
205
scripts/lib/agent-pool-state.mjs
Normal file
205
scripts/lib/agent-pool-state.mjs
Normal 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')
|
||||
73
tests/unit/agentPoolCursor.unit.spec.ts
Normal file
73
tests/unit/agentPoolCursor.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
179
tests/unit/agentPoolEligibility.unit.spec.ts
Normal file
179
tests/unit/agentPoolEligibility.unit.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
72
tests/unit/agentPoolModels.unit.spec.ts
Normal file
72
tests/unit/agentPoolModels.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
65
tests/unit/agentPoolPrompt.unit.spec.ts
Normal file
65
tests/unit/agentPoolPrompt.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
215
tests/unit/agentPoolState.unit.spec.ts
Normal file
215
tests/unit/agentPoolState.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue