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.
This commit is contained in:
Francisco Solla 2026-09-17 01:48:29 -03:00
parent f70c5e2d0b
commit 1ebae91428
7 changed files with 198 additions and 3 deletions

View file

@ -0,0 +1 @@
**OPS126 — auto-unblock volta a despachar de verdade no homeserver: fetch honra o proxy e o opencode é resolvido fora do PATH (2026-09-17):** duas causas independentes mantinham o agente de desbloqueio (OPS106) sem entregar nada. **(1) `fetch failed` no launcher:** o homeserver só alcança o GitHub pelo proxy CONNECT local (`github-tunnel` + `http-proxy-socks`, `~/actions-runner/.env` com `HTTPS_PROXY/HTTP_PROXY=127.0.0.1:1081`); o `fetch` global do Node ignora `HTTP(S)_PROXY` a menos que `NODE_USE_ENV_PROXY` seja definido **antes** do processo subir (Node ≥24), então a leitura dos jobs do run caía em conexão direta e falhava de forma intermitente (run `35171293357`: 4/4 `fetch failed` e nenhum token criado). `scripts/auto-unblock.sh` passa a exportar `NODE_USE_ENV_PROXY=1` quando há proxy configurado — o agente destacado herda. **(2) exit 127 do agente:** o serviço do runner não carrega o profile interativo, então `~/.opencode/bin` não está no PATH e o `timeout ... opencode` morria com _No such file or directory_ (log `unblock-35101121310.log`), deixando o token `blocked` sem PR. Novo helper puro `resolveOpenCodeBinary` (`OPENCODE_BIN` → argv0 com path → `$HOME/.opencode/bin/<argv0>` → PATH) é usado pelo wrapper e pelo `--check` (que agora acusa o binário ausente em vez de passar), e o launcher falha fechado **antes** de provisionar o worktree quando o binário não existe. Sem schema, sem UI, sem migration. Verificação: `pnpm vitest run tests/unit/autoUnblock.unit.spec.ts tests/unit/autoUnblockWorkflow.unit.spec.ts`, `bash -n scripts/auto-unblock.sh`; smoke no homeserver (`NODE_USE_ENV_PROXY=1 node -e fetch` → 200 estável). Bootstrap OPS106 §1 concluído no homeserver em 2026-09-17: opencode 1.18.31 instalado em `~/.opencode/bin` (mesmo binário da workstation, via `--binary`) e `~/.local/share/opencode/auth.json` (chmod 600) replicado — `opencode run --model deepseek/deepseek-flash` responde pelo proxy. Antes disso, qualquer token nascia `blocked`. Token `auto-unblock` #1089 (órfão `blocked` de run superado) fechado no replay. Runs vermelhos de referência: `35170301963`, `35166706444` (flakiness de e2e segue na Issue #1124).

View file

@ -296,6 +296,8 @@ a correção se re-mergeia e o próximo deploy completo publica.
| `deploy-staging` falha cedo (`teqo-staging.env`/serviço ausente) | Bootstrap do staging incompleto (DB, env file, serviços no compose ou environment `staging`) | Rodar o bootstrap da seção Staging; produção nunca é tocada (staging vermelho fail-closa o job `deploy-production`) |
| `verify` vermelho | Regressão real na suíte full (ou flake — #882/#906) | O `auto-unblock.yml` dispara o agente sozinho (OPS106): acompanhe a Issue `auto-unblock` (token) e o PR `fix/*`. Sem PR, o token fica `blocked` — inspecione o log em `~/teqo-unblock/logs/`. Correção manual segue possível (re-merge ou `workflow_dispatch`) |
| Agente de desbloqueio não disparou (token inexistente) | Bootstrap do homeserver incompleto — opencode sem auth, Node/pnpm fora do PATH do runner, PAT ausente/sem push, Docker/porta 5432, label ausente | Rodar `pnpm unblock:check` no homeserver (com `GITHUB_TOKEN` e `AUTOMERGE_PAT` exportados) e seguir a seção Auto-unblock (OPS106) abaixo |
| Launcher `auto-unblock` morre em `fetch failed` (nenhum token criado) | Node fetch ignora HTTP(S)\_PROXY sem NODE_USE_ENV_PROXY (Node ≥24); no homeserver o GitHub só sai pelo proxy CONNECT do runner | OPS126: `scripts/auto-unblock.sh` liga o `NODE_USE_ENV_PROXY` quando há proxy no env; manter `HTTPS_PROXY/HTTP_PROXY` no `~/actions-runner/.env`. Re-dispatch ou aguarde a próxima falha do `verify` |
| Agente morre com exit 127 (`opencode`: No such file or directory) | Serviço do runner não carrega o profile → `~/.opencode/bin` fora do PATH; ou opencode nem instalado | OPS126: `resolveOpenCodeBinary` (`OPENCODE_BIN` → `$HOME/.opencode/bin` → PATH); o launcher falha fechado (token `blocked`) se faltar. Instale/autentique via `pnpm unblock:check` |
| Token `auto-unblock` preso (agente morto, sem PR) | Wrapper morreu (OOM/timeout/erro) — lock livre, token < TTL se torna `skip_comment` na próxima falha | O TTL de 3h reclaima o token automaticamente (comenta + fecha + novo agente). Antes disso, inspecione o log e a branch `fix/*`; para liberar na hora, feche a Issue token com um comentário |
## Auto-unblock (OPS106)
@ -321,6 +323,13 @@ o deploy não tem auto-retry.
1. **opencode no homeserver:** instalado e autenticado no usuário do runner
(`opencode auth login`; o provider do modelo default — `deepseek/deepseek-flash`
via env `OPENCODE_WORKTREE_MODEL` para override — precisa estar presente).
O serviço do runner **não** carrega o profile interativo, então
`~/.opencode/bin` fica fora do PATH: o OPS126 resolve o binário
(`OPENCODE_BIN` → `$HOME/.opencode/bin/opencode` → PATH) e o launcher falha
fechado se não achar. Rede: o homeserver só alcança o GitHub pelo proxy
CONNECT local (`github-tunnel` + `http-proxy-socks`; `~/actions-runner/.env`
com `HTTPS_PROXY/HTTP_PROXY`) — o `scripts/auto-unblock.sh` liga o
`NODE_USE_ENV_PROXY` para o `fetch` do Node respeitá-lo.
2. **PAT:** secret `AUTOMERGE_PAT` no repo com os escopos `contents: write`,
`pull requests: write` e `issues: write` (o agente faz push/PR e o outcome
comenta/fecha a Issue; o built-in token do job não sobrevive ao processo

View file

@ -14,7 +14,8 @@
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -25,6 +26,7 @@ import {
evaluateDatabaseTargets,
outcomeComment,
parseHeadlessDirective,
resolveOpenCodeBinary,
UNBLOCK_BLOCKED_LABEL,
} from './lib/auto-unblock.mjs'
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
@ -40,6 +42,16 @@ const unblockHome = process.env.TEQO_UNBLOCK_HOME ?? join(process.env.HOME ?? ''
const say = (message) => console.log(`[auto-unblock:agent] ${message}`)
/** X_OK probe the resolver uses — a missing binary must fail closed, not 127. */
const isExecutable = (path) => {
try {
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
const { flags } = parseEqualsFlags(process.argv.slice(2))
const tokenIssue = Number(flags.token)
const statePath = typeof flags.state === 'string' ? flags.state : null
@ -151,7 +163,26 @@ const main = async () => {
childEnv.GIT_CONFIG_VALUE_0 =
'!f() { echo username=x-access-token; echo password=$GITHUB_TOKEN; }; f'
const [command, ...args] = directive.argv
const [rawCommand, ...args] = directive.argv
const resolvedCommand = resolveOpenCodeBinary({
argv0: rawCommand,
env: childEnv,
home: process.env.HOME ?? homedir(),
exists: isExecutable,
})
if (rawCommand === 'opencode' && !resolvedCommand) {
await blockToken(
[
'opencode não encontrado: fora do PATH do runner e sem `$HOME/.opencode/bin/opencode`.',
'',
'Bootstrap pendente no homeserver (instalar/autenticar opencode no usuário do runner) —',
'rode `pnpm unblock:check` com `GITHUB_TOKEN`/`AUTOMERGE_PAT` exportados.',
].join('\n'),
)
die('opencode não encontrado — token blocked (bootstrap pendente).')
}
const command = resolvedCommand ?? rawCommand
say(`opencode resolver: '${rawCommand}' → ${command}`)
const agentRun = runTimed(
'opencode run --command bug-fix',
AGENT_TIMEOUT_SECONDS,

View file

@ -35,6 +35,7 @@ import { fileURLToPath } from 'node:url'
import {
classifyDeployFailure,
decideUnblock,
resolveOpenCodeBinary,
tokenBody,
tokenTitle,
UNBLOCK_BLOCKED_LABEL,
@ -75,6 +76,16 @@ const ensureLabel = async (api) => {
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".
@ -161,7 +172,25 @@ const runCheck = async () => {
}
return 'node_modules ok'
})
await check('opencode', version('opencode'))
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')
@ -297,6 +326,28 @@ const main = async () => {
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,

View file

@ -22,4 +22,15 @@ flock -n 9 || LOCK_BUSY=1
# Hand the exact lock path to the orchestrator (its lock probe uses it).
export TEQO_UNBLOCK_LOCK="$LOCK_FILE"
# Node's global fetch ignores HTTP(S)_PROXY unless NODE_USE_ENV_PROXY is set
# BEFORE the process starts (Node >= 24). The homeserver reaches GitHub only
# through the runner's local CONNECT proxy (github-tunnel + http-proxy-socks),
# and a direct fetch fails intermittently (live finding: launcher run
# 35171293357 died on `fetch failed` reading the run's jobs). Enable the env
# proxy whenever the runner configured one; the detached agent inherits it.
if [[ -n "${HTTPS_PROXY:-}${HTTP_PROXY:-}" ]]; then
export NODE_USE_ENV_PROXY="${NODE_USE_ENV_PROXY:-1}"
fi
exec node scripts/auto-unblock.mjs "--lock-busy=$LOCK_BUSY"

View file

@ -6,6 +6,8 @@
* guardrails without a GitHub token, Docker or opencode.
*/
import { join } from 'node:path'
import { databaseHostname, isLocalDatabaseUrl, TEST_DATABASE_NAME_RE } from './cli.mjs'
import { isGeneratedDatabaseName } from './worktree-env.mjs'
@ -22,6 +24,36 @@ export const UNBLOCK_BLOCKED_LABEL = 'blocked'
*/
export const UNBLOCK_TOKEN_TTL_MS = 3 * 60 * 60 * 1000
/**
* Locate the `opencode` executable for headless launches. The self-hosted
* runner service does not source the interactive shell profile, so
* `$HOME/.opencode/bin` is absent from its PATH — the OPS106 agent died with
* exit 127 (`timeout: failed to run command 'opencode'`) on the homeserver
* even though opencode was installed for the runner user
* (`unblock-35101121310.log`). Resolution order: explicit `OPENCODE_BIN`, a
* path-like argv0, the default install dir under HOME, then the PATH entries.
* Returns null when nothing matches — the caller fails closed instead of
* spawning a doomed `timeout ... opencode`.
*
* @param {{ argv0?: string, env?: Record<string, string | undefined>, home?: string, exists?: (path: string) => boolean }} [options]
* @returns {string | null}
*/
export const resolveOpenCodeBinary = ({
argv0 = 'opencode',
env = {},
home = '',
exists = () => false,
} = {}) => {
if (env.OPENCODE_BIN) return env.OPENCODE_BIN
if (argv0.includes('/')) return argv0
const candidates = []
if (home) candidates.push(join(home, '.opencode', 'bin', argv0))
for (const dir of String(env.PATH ?? '').split(':')) {
if (dir) candidates.push(join(dir, argv0))
}
return candidates.find((candidate) => exists(candidate)) ?? null
}
/**
* Local ports that are only ever the homeserver's stack DB proxies — defense
* in depth on top of the name check; the owner of the mapping is

View file

@ -11,6 +11,7 @@ import {
evaluateDatabaseTargets,
outcomeComment,
parseHeadlessDirective,
resolveOpenCodeBinary,
tokenBody,
tokenTitle,
UNBLOCK_LABEL,
@ -279,6 +280,65 @@ describe('classifyOutcome', () => {
})
})
describe('resolveOpenCodeBinary (o PATH do runner não tem ~/.opencode/bin)', () => {
const home = '/home/runner'
const binDir = `${home}/.opencode/bin/opencode`
it('OPENCODE_BIN tem precedência', () => {
expect(
resolveOpenCodeBinary({
env: { OPENCODE_BIN: '/custom/opencode', PATH: '/usr/bin' },
home,
exists: () => true,
}),
).toBe('/custom/opencode')
})
it('argv0 com path é devolvido como veio (o runner pode usar caminho absoluto)', () => {
expect(
resolveOpenCodeBinary({
argv0: '/opt/opencode/bin/opencode',
env: {},
home: '',
exists: () => false,
}),
).toBe('/opt/opencode/bin/opencode')
})
it('cai no install padrão ~/.opencode/bin quando fora do PATH (causa do exit 127)', () => {
expect(
resolveOpenCodeBinary({
argv0: 'opencode',
env: { PATH: '/usr/bin:/bin' },
home,
exists: (candidate) => candidate === binDir,
}),
).toBe(binDir)
})
it('encontra o opencode no PATH quando presente', () => {
expect(
resolveOpenCodeBinary({
argv0: 'opencode',
env: { PATH: '/usr/bin:/usr/local/bin' },
home,
exists: (candidate) => candidate === '/usr/local/bin/opencode',
}),
).toBe('/usr/local/bin/opencode')
})
it('sem candidato existente → null (caller falha fechado, sem `timeout ... opencode`)', () => {
expect(
resolveOpenCodeBinary({
argv0: 'opencode',
env: { PATH: '/usr/bin' },
home,
exists: () => false,
}),
).toBeNull()
})
})
describe('parseHeadlessDirective', () => {
const valid = JSON.stringify({
dir: '/home/x/teqo-unblock/worktrees/fix/abc',