mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:25:18 +00:00
Duas causas independentes mantinham o agente de desbloqueio (OPS106) sem entregar: 1. `fetch failed` no launcher: o homeserver só alcança o GitHub pelo proxy CONNECT local (github-tunnel + http-proxy-socks) e o `fetch` global do Node ignora HTTP(S)_PROXY sem `NODE_USE_ENV_PROXY` (Node >= 24). O launcher morria lendo os jobs do run (run 35171293357) e nenhum token era criado. `scripts/auto-unblock.sh` passa a exportar `NODE_USE_ENV_PROXY=1` quando há proxy no env; o agente destacado herda. 2. exit 127 do agente: o serviço do runner não carrega o profile interativo, então `~/.opencode/bin` fica fora do PATH e o `timeout ... opencode` morria com *No such file or directory* (log unblock-35101121310.log), deixando o token `blocked` sem PR. Novo `resolveOpenCodeBinary` (OPENCODE_BIN → argv0 com path → `$HOME/.opencode/bin` → PATH) usado pelo wrapper, pelo `--check` e por um gate fail-closed no launcher (token blocked, sem provisionar worktree). Runbook + changelog OPS126; unit tests do resolver. Token órfão #1089 fechado no replay. Flakiness de e2e full segue na #1124.
433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* OPS106 auto-unblock — synchronous orchestrator. Runs on the homeserver
|
|
* inside `.github/workflows/auto-unblock.yml` (via `scripts/auto-unblock.sh`,
|
|
* which holds the single-flight flock across the detached agent). Decides,
|
|
* creates the visible token Issue and spawns the detached wrapper; it never
|
|
* runs the agent itself, so the runner slot is released in ~1 minute.
|
|
*
|
|
* Env from the workflow:
|
|
* GITHUB_TOKEN built-in token (actions:read, issues:write) for run/token reads
|
|
* AUTOMERGE_PAT real-user PAT handed ONLY to the detached agent (push/PR/outcome)
|
|
* RUN_ID/RUN_URL/RUN_SHA the failed Deploy run
|
|
*
|
|
* Flags:
|
|
* --lock-busy=0|1 flock state from scripts/auto-unblock.sh
|
|
* --check homeserver bootstrap smoke (fail-closed, actionable output)
|
|
* --dry-run decide and print; never create/comment/spawn
|
|
*/
|
|
|
|
import { spawn, spawnSync } from 'node:child_process'
|
|
import {
|
|
accessSync,
|
|
constants,
|
|
existsSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readFileSync,
|
|
writeFileSync,
|
|
} from 'node:fs'
|
|
import { connect } from 'node:net'
|
|
import { homedir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import {
|
|
classifyDeployFailure,
|
|
decideUnblock,
|
|
resolveOpenCodeBinary,
|
|
tokenBody,
|
|
tokenTitle,
|
|
UNBLOCK_BLOCKED_LABEL,
|
|
UNBLOCK_LABEL,
|
|
} from './lib/auto-unblock.mjs'
|
|
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
|
|
import { parsePostgresContainerHealth, SHARED_POSTGRES_CONTAINER } from './lib/db-start.mjs'
|
|
import { createApi } from './lib/github-api.mjs'
|
|
|
|
const die = dieWithLabel('auto-unblock')
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const unblockHome = process.env.TEQO_UNBLOCK_HOME ?? join(homedir(), 'teqo-unblock')
|
|
const stateDir = join(unblockHome, 'state')
|
|
const logsDir = join(unblockHome, 'logs')
|
|
const lockFile = process.env.TEQO_UNBLOCK_LOCK ?? '/tmp/teqo-unblock.lock'
|
|
|
|
const { flags } = parseEqualsFlags(process.argv.slice(2))
|
|
const say = (message) => console.log(`[auto-unblock] ${message}`)
|
|
|
|
const repoFullName = () => process.env.GITHUB_REPOSITORY ?? 'fsolla/teqo'
|
|
|
|
/** `auto-unblock` label is ensured idempotently (POST 422 on an existing one). */
|
|
const ensureLabel = async (api) => {
|
|
if (await api.getLabel(UNBLOCK_LABEL)) return
|
|
try {
|
|
await api.createLabel(UNBLOCK_LABEL, {
|
|
color: '5319E7',
|
|
description: 'Token visível do agente de desbloqueio (OPS106)',
|
|
})
|
|
} catch {
|
|
// concurrent create — fall through to the read-back below
|
|
}
|
|
if (!(await api.getLabel(UNBLOCK_LABEL))) {
|
|
throw new Error(`label '${UNBLOCK_LABEL}' ausente e não foi possível criar (issues:write?).`)
|
|
}
|
|
}
|
|
|
|
const run = (command, args) => spawnSync(command, args, { encoding: 'utf8', timeout: 30_000 })
|
|
|
|
/** X_OK probe for the opencode resolver (mirrors the detached wrapper). */
|
|
const isExecutable = (path) => {
|
|
try {
|
|
accessSync(path, constants.X_OK)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const port5432State = async () => {
|
|
// Reuses the db-start owner's parser and container name; a Docker daemon
|
|
// that is down fails here instead of silently reading as "port free".
|
|
const inspect = run('docker', [
|
|
'inspect',
|
|
'--format',
|
|
'{{json .State}}',
|
|
SHARED_POSTGRES_CONTAINER,
|
|
])
|
|
if (inspect.status === 0) {
|
|
const state = parsePostgresContainerHealth(inspect.stdout)
|
|
if (state?.running !== true) {
|
|
throw new Error(`container ${SHARED_POSTGRES_CONTAINER} existe mas não está rodando`)
|
|
}
|
|
return `container ${SHARED_POSTGRES_CONTAINER} (${state.health || 'running'})`
|
|
}
|
|
if (!`${inspect.stderr ?? ''}`.includes('No such object')) {
|
|
throw new Error(`docker inspect falhou: ${`${inspect.stderr ?? ''}`.trim().split('\n')[0]}`)
|
|
}
|
|
const free = await new Promise((resolvePort) => {
|
|
const socket = connect({ host: '127.0.0.1', port: 5432 })
|
|
socket.once('connect', () => {
|
|
socket.destroy()
|
|
resolvePort(false)
|
|
})
|
|
socket.once('error', () => resolvePort(true))
|
|
})
|
|
if (!free) {
|
|
throw new Error(`porta 5432 ocupada por um processo que não é o ${SHARED_POSTGRES_CONTAINER}`)
|
|
}
|
|
return 'porta 5432 livre'
|
|
}
|
|
|
|
/** `--check`: homeserver bootstrap smoke — every failure is actionable. */
|
|
const runCheck = async () => {
|
|
const failures = []
|
|
const check = async (name, fn) => {
|
|
try {
|
|
const detail = await fn()
|
|
console.log(`PASS ${name}${detail ? ` — ${detail}` : ''}`)
|
|
} catch (error) {
|
|
failures.push(name)
|
|
console.log(`FAIL ${name} — ${error?.message ?? String(error)}`)
|
|
}
|
|
}
|
|
const version =
|
|
(command, args = ['--version']) =>
|
|
() => {
|
|
const result = run(command, args)
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) throw new Error(`${command} saiu com ${result.status}`)
|
|
return (result.stdout || result.stderr || '').trim().split('\n')[0]
|
|
}
|
|
|
|
await check('node >= 24', () => {
|
|
const major = Number(process.versions.node.split('.')[0])
|
|
if (major < 24) throw new Error(`node ${process.versions.node}`)
|
|
return process.versions.node
|
|
})
|
|
await check('git', version('git'))
|
|
await check('pnpm', version('pnpm'))
|
|
await check('flock', version('flock'))
|
|
await check('timeout', version('timeout'))
|
|
await check('docker', version('docker'))
|
|
await check('docker daemon', () => {
|
|
const result = run('docker', ['info'])
|
|
if (result.status !== 0) {
|
|
throw new Error(`${`${result.stderr ?? ''}`.trim().split('\n')[0] || 'docker info falhou'}`)
|
|
}
|
|
return 'daemon acessível'
|
|
})
|
|
await check('deps do repo', () => {
|
|
// The detached wrapper runs `node scripts/worktree.mjs` from the checkout,
|
|
// which imports dotenv/pg at load; the workflow installs them before the
|
|
// launcher (actions/checkout cleans node_modules on the self-hosted runner).
|
|
for (const relative of [
|
|
'node_modules/dotenv',
|
|
'node_modules/pg',
|
|
'node_modules/.bin/payload',
|
|
]) {
|
|
if (!existsSync(join(repoRoot, relative))) {
|
|
throw new Error(`${relative} ausente — rode \`pnpm install --frozen-lockfile\``)
|
|
}
|
|
}
|
|
return 'node_modules ok'
|
|
})
|
|
await check('opencode', () => {
|
|
// The runner service PATH lacks `~/.opencode/bin`; resolve explicitly so
|
|
// this smoke is truthful (live finding: exit 127, launcher passed anyway).
|
|
const binary = resolveOpenCodeBinary({
|
|
argv0: 'opencode',
|
|
env: process.env,
|
|
home: homedir(),
|
|
exists: isExecutable,
|
|
})
|
|
if (!binary) {
|
|
throw new Error(
|
|
'binário não encontrado no PATH nem em ~/.opencode/bin — instale o opencode no usuário do runner',
|
|
)
|
|
}
|
|
const result = run(binary, ['--version'])
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) throw new Error(`${binary} saiu com ${result.status}`)
|
|
return `${(result.stdout || result.stderr || '').trim().split('\n')[0]} (${binary})`
|
|
})
|
|
await check('opencode auth', () => {
|
|
const dataHome = process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share')
|
|
const authFile = join(dataHome, 'opencode', 'auth.json')
|
|
const credentials = JSON.parse(readFileSync(authFile, 'utf8'))
|
|
const providers = Object.keys(credentials ?? {})
|
|
if (providers.length === 0) {
|
|
throw new Error('auth.json vazio — rode `opencode auth login` no usuário do runner')
|
|
}
|
|
return providers.join(', ')
|
|
})
|
|
await check('porta 5432', port5432State)
|
|
await check('dirs graváveis', () => {
|
|
for (const dir of [logsDir, stateDir, join(unblockHome, 'worktrees')]) {
|
|
mkdirSync(dir, { recursive: true })
|
|
accessSync(dir, constants.W_OK)
|
|
}
|
|
return unblockHome
|
|
})
|
|
|
|
const api = createApi({})
|
|
await check('GITHUB_TOKEN (built-in)', async () => {
|
|
const repository = await api.getRepository()
|
|
return repository.fullName
|
|
})
|
|
await check('AUTOMERGE_PAT com push', async () => {
|
|
const pat = process.env.AUTOMERGE_PAT
|
|
if (!pat) throw new Error('AUTOMERGE_PAT ausente no ambiente')
|
|
const patApi = createApi({ token: pat })
|
|
const repository = await patApi.getRepository()
|
|
if (!repository.permissions.push) throw new Error('PAT sem permissão de push')
|
|
return 'permissions.push'
|
|
})
|
|
await check(`label ${UNBLOCK_LABEL}`, async () => {
|
|
await ensureLabel(api)
|
|
return UNBLOCK_LABEL
|
|
})
|
|
|
|
if (failures.length > 0) {
|
|
die(`--check reprovou: ${failures.join(', ')}`)
|
|
}
|
|
say('--check ok — bootstrap do homeserver completo')
|
|
}
|
|
|
|
const commentSkip = (api, token, { runUrl, runId, reason }) =>
|
|
api.addComment(
|
|
token.number,
|
|
[
|
|
`Nova falha do \`verify\` (run ${runId}: ${runUrl}) — **não** dispara outro agente.`,
|
|
`Motivo: ${reason}. Esta Issue segue como token do single-flight.`,
|
|
].join('\n'),
|
|
)
|
|
|
|
const main = async () => {
|
|
mkdirSync(stateDir, { recursive: true })
|
|
mkdirSync(logsDir, { recursive: true })
|
|
|
|
if (flags.check === true) {
|
|
await runCheck()
|
|
return
|
|
}
|
|
|
|
const runId = process.env.RUN_ID
|
|
const runUrl = process.env.RUN_URL ?? ''
|
|
const sha = process.env.RUN_SHA ?? ''
|
|
if (!runId) die('RUN_ID ausente (definido por .github/workflows/auto-unblock.yml).')
|
|
|
|
const api = createApi({})
|
|
const failure = classifyDeployFailure(await api.getWorkflowRunJobs(runId))
|
|
if (!failure.verifyFailed) {
|
|
say(`run ${runId}: o job verify não falhou — nada a desbloquear.`)
|
|
return
|
|
}
|
|
|
|
const openTokens = await api.listIssues({ state: 'open', labels: UNBLOCK_LABEL })
|
|
const token = openTokens[0] ?? null
|
|
const decision = decideUnblock({
|
|
lockBusy: flags['lock-busy'] === '1',
|
|
openToken: Boolean(token),
|
|
tokenAgeMs: token ? Date.now() - Date.parse(token.createdAt) : null,
|
|
})
|
|
say(`run ${runId}: decisão '${decision.action}' (${decision.reason}).`)
|
|
|
|
if (flags['dry-run'] === true) {
|
|
say(`dry-run: nada criado/comentado; a ação seria '${decision.action}'.`)
|
|
return
|
|
}
|
|
|
|
if (decision.action === 'skip') return
|
|
|
|
if (decision.action === 'skip_comment') {
|
|
if (token) {
|
|
await commentSkip(api, token, {
|
|
runUrl,
|
|
runId,
|
|
reason:
|
|
decision.reason === 'agent-active'
|
|
? 'single-flight: agente em execução'
|
|
: 'token órfão recente (aguardando o TTL para reclaim)',
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (decision.action === 'reclaim') {
|
|
await api.addComment(
|
|
token.number,
|
|
'Token considerado órfão (lock livre há mais que o TTL) — reclamando para um novo agente.',
|
|
)
|
|
await api.closeIssue(token.number)
|
|
}
|
|
|
|
await ensureLabel(api)
|
|
const logPath = join(logsDir, `unblock-${runId}.log`)
|
|
const created = await api.createIssue({
|
|
title: tokenTitle({ runId, sha }),
|
|
body: tokenBody({
|
|
runUrl,
|
|
runId,
|
|
sha,
|
|
failedJobs: failure.failedJobs,
|
|
logPath,
|
|
}),
|
|
})
|
|
await api.addLabels(created.number, [UNBLOCK_LABEL])
|
|
|
|
const pat = process.env.AUTOMERGE_PAT
|
|
if (!pat) {
|
|
await api.addComment(
|
|
created.number,
|
|
'AUTOMERGE_PAT ausente no ambiente do runner — o agente não pode fazer push/PR. Fail-closed.',
|
|
)
|
|
await api.addLabels(created.number, [UNBLOCK_BLOCKED_LABEL])
|
|
die('AUTOMERGE_PAT ausente — agente não disparado (token marcado blocked).')
|
|
}
|
|
|
|
// Fail fast on a missing opencode BEFORE provisioning a worktree/DBs: the
|
|
// detached agent would otherwise die with exit 127 (live finding on the
|
|
// homeserver, where the runner PATH lacks `~/.opencode/bin`).
|
|
const opencodeBinary = resolveOpenCodeBinary({
|
|
argv0: 'opencode',
|
|
env: process.env,
|
|
home: homedir(),
|
|
exists: isExecutable,
|
|
})
|
|
if (!opencodeBinary) {
|
|
await api.addComment(
|
|
created.number,
|
|
[
|
|
'opencode não encontrado no runner (fora do PATH e sem `$HOME/.opencode/bin/opencode`) —',
|
|
'bootstrap pendente no homeserver. Instale/autentique o opencode no usuário do runner e rode',
|
|
'`pnpm unblock:check`. Fail-closed: nenhum worktree foi provisionado.',
|
|
].join('\n'),
|
|
)
|
|
await api.addLabels(created.number, [UNBLOCK_BLOCKED_LABEL])
|
|
die('opencode ausente — agente não disparado (token marcado blocked).')
|
|
}
|
|
|
|
const statePath = join(stateDir, `unblock-${runId}.json`)
|
|
writeFileSync(
|
|
statePath,
|
|
`${JSON.stringify(
|
|
{
|
|
runId,
|
|
runUrl,
|
|
sha,
|
|
repo: repoFullName(),
|
|
tokenIssue: created.number,
|
|
failedJobs: failure.failedJobs,
|
|
logPath,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
)
|
|
|
|
// The lock file must be HELD (by our own inherited fd 9): a probe opening a
|
|
// new description can only fail to acquire when someone holds it. This is
|
|
// stronger than an fd-existence check and catches the bypass path (running
|
|
// the orchestrator without `scripts/auto-unblock.sh`).
|
|
const lockProbe = run('flock', ['-n', lockFile, '-c', 'true'])
|
|
if (lockProbe.error) die(`flock indisponível: ${lockProbe.error.message}`)
|
|
if (lockProbe.status === 0) {
|
|
await api.addComment(
|
|
created.number,
|
|
'flock do single-flight NÃO está preso — rode via `scripts/auto-unblock.sh`. Fail-closed.',
|
|
)
|
|
await api.addLabels(created.number, [UNBLOCK_BLOCKED_LABEL])
|
|
die('lock do single-flight não preso — o agente não pode ser disparado.')
|
|
}
|
|
|
|
const childEnv = { ...process.env, GITHUB_TOKEN: pat, TEQO_UNBLOCK_HOME: unblockHome }
|
|
delete childEnv.AUTOMERGE_PAT
|
|
childEnv.WORKTREES_ROOT = join(unblockHome, 'worktrees')
|
|
for (const key of Object.keys(childEnv)) {
|
|
if (['DATABASE_URL', 'ALLOW_REMOTE_DB', 'TEQO_ENV', 'RUNNER_TRACKING_ID'].includes(key)) {
|
|
delete childEnv[key]
|
|
}
|
|
// Runner runtime tokens must not leak into a process that outlives the job.
|
|
if (key.startsWith('ACTIONS_')) delete childEnv[key]
|
|
}
|
|
|
|
const logFd = openSync(logPath, 'a')
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
join(repoRoot, 'scripts', 'auto-unblock-agent.mjs'),
|
|
`--state=${statePath}`,
|
|
`--token=${created.number}`,
|
|
],
|
|
{
|
|
cwd: repoRoot,
|
|
detached: true,
|
|
// fd 9 (inherited from auto-unblock.sh) keeps the flock for the agent's
|
|
// whole lifetime; stdio goes to the log file so the step never waits on
|
|
// its stdout pipe and the runner cannot reap the orphan.
|
|
stdio: ['ignore', logFd, logFd, 9],
|
|
env: childEnv,
|
|
},
|
|
)
|
|
// Fail-closed: only report success after the OS confirmed the spawn; a
|
|
// failure marks the token blocked instead of leaving it silently open.
|
|
try {
|
|
await new Promise((resolveSpawn, rejectSpawn) => {
|
|
child.once('spawn', resolveSpawn)
|
|
child.once('error', rejectSpawn)
|
|
})
|
|
} catch (error) {
|
|
await api.addComment(created.number, `Falha ao disparar o agente: ${error?.message ?? error}`)
|
|
await api.addLabels(created.number, [UNBLOCK_BLOCKED_LABEL])
|
|
die(`spawn do agente falhou: ${error?.message ?? error}`)
|
|
}
|
|
child.unref()
|
|
say(`agente disparado (pid ${child.pid}); token #${created.number}; log ${logPath}`)
|
|
}
|
|
|
|
try {
|
|
await main()
|
|
} catch (error) {
|
|
die(error?.stack ?? error?.message ?? String(error))
|
|
}
|