mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
* 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>
157 lines
5.1 KiB
JavaScript
157 lines
5.1 KiB
JavaScript
/**
|
|
* Shared helpers for the agent ops scripts (`pnpm agent:*`). Everything goes
|
|
* through the authenticated `gh` CLI — agents never get a GitHub token of
|
|
* their own, and these scripts never touch stage/prod database URLs.
|
|
*
|
|
* Issue contract (spec + status + deps in GitHub Issues, per the parallel
|
|
* paradigm plan): each trackable issue carries a YAML-ish frontmatter block
|
|
* at the top of the body:
|
|
*
|
|
* ---
|
|
* id: B73
|
|
* depends: [B43, B65]
|
|
* serializes: [migrations]
|
|
* priority: P1
|
|
* ---
|
|
*
|
|
* State lives in labels: ready | in-progress | blocked | done (+ in-prod).
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
|
|
import { dieWithLabel } from './cli.mjs'
|
|
|
|
const PRIORITIES = ['P0', 'P1', 'P2', 'P3']
|
|
export const priorityRank = (priority) => PRIORITIES.indexOf(priority)
|
|
|
|
export const dieAgent = (script) => dieWithLabel(`agent:${script}`)
|
|
|
|
/** Run gh and return stdout (throws with the gh stderr on failure). */
|
|
export const gh = (args) =>
|
|
execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim()
|
|
|
|
export const ghJson = (args) => JSON.parse(gh(args))
|
|
|
|
export const parseFrontmatter = (body) => {
|
|
const match = /^---\n([\s\S]*?)\n---\n?/.exec(body ?? '')
|
|
if (!match) return { meta: {}, rest: body ?? '' }
|
|
const meta = {}
|
|
for (const line of match[1].split('\n')) {
|
|
const kv = /^([a-zA-Z]+):\s*(.*)$/.exec(line.trim())
|
|
if (!kv) continue
|
|
const [, key, raw] = kv
|
|
meta[key] = raw.startsWith('[')
|
|
? raw
|
|
.slice(1, -1)
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
: raw
|
|
}
|
|
return { meta, rest: body.slice(match[0].length) }
|
|
}
|
|
|
|
export const serializeFrontmatter = (meta, rest) => {
|
|
const lines = ['---']
|
|
for (const [key, value] of Object.entries(meta)) {
|
|
lines.push(`${key}: ${Array.isArray(value) ? `[${value.join(', ')}]` : value}`)
|
|
}
|
|
lines.push('---', '')
|
|
return lines.join('\n') + (rest ?? '')
|
|
}
|
|
|
|
/** All issues (open or closed) that carry a frontmatter `id`, keyed by id. */
|
|
export const issuesById = () => {
|
|
const issues = ghJson([
|
|
'issue',
|
|
'list',
|
|
'--state',
|
|
'all',
|
|
'--limit',
|
|
'500',
|
|
'--json',
|
|
'number,title,body,state,labels',
|
|
])
|
|
const byId = new Map()
|
|
for (const issue of issues) {
|
|
const { meta } = parseFrontmatter(issue.body)
|
|
if (typeof meta.id === 'string' && meta.id.length > 0) byId.set(meta.id, issue)
|
|
}
|
|
return byId
|
|
}
|
|
|
|
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)
|
|
for (const label of remove) args.push('--remove-label', label)
|
|
if (add.length + remove.length > 0) gh(args)
|
|
}
|
|
|
|
export const parseArgs = (argv, flagsWithValue) => {
|
|
const flags = {}
|
|
const positional = []
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index]
|
|
if (arg.startsWith('--')) {
|
|
const name = arg.slice(2)
|
|
if (flagsWithValue.has(name)) {
|
|
flags[name] = argv[index + 1]
|
|
index += 1
|
|
} else {
|
|
flags[name] = true
|
|
}
|
|
} else {
|
|
positional.push(arg)
|
|
}
|
|
}
|
|
return { flags, positional }
|
|
}
|