teqo-backup/tests/unit/agentPoolEligibility.unit.spec.ts
Francisco Solla 680ff56754 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>
2026-07-30 20:08:34 -03:00

179 lines
6.1 KiB
TypeScript

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