* 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>
179 lines
6.1 KiB
TypeScript
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])
|
|
})
|
|
})
|