teqo-backup/scripts/auto-unblock.mjs
Francisco Solla 1ebae91428 fix(OPS126): auto-unblock honra o proxy do homeserver e resolve o opencode fora do PATH
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.
2026-09-17 02:07:18 -03:00

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))
}