feat(OPS106): falha do verify dispara agente autônomo de desbloqueio (single-flight)

Workflow separado auto-unblock.yml (workflow_run no Deploy, push/main + job verify vermelho pela jobs API), launcher com flock próprio + Issue token auto-unblock como single-flight, e wrapper destacado que provisiona worktree fix/* via worktree.mjs fix --headless, aplica guard de banco local teqo_wt* fail-closed e roda opencode run --command bug-fix (cap 4h). deploy.yml/verify intocados; sem auto-retry e produção segue com approval humano.
This commit is contained in:
Francisco Solla 2026-09-14 05:35:13 -03:00
parent dd8daa2420
commit 37b338a700
20 changed files with 2078 additions and 13 deletions

77
.github/workflows/auto-unblock.yml vendored Normal file
View file

@ -0,0 +1,77 @@
# OPS106 — a red `verify` job of the Deploy workflow starts ONE autonomous
# unblock agent on the homeserver. Single-flight: an open `auto-unblock` token
# Issue + flock; a new failure with an active agent only comments, never queues.
#
# This is the deliberate exception to the deploy.yml rule "only a run with the
# full verify suite green reaches a self-hosted job": the launcher here is a
# thin, ~1-minute script that only decides + spawns a detached agent (the
# runner slot is released immediately). The agent itself is capped (4h), runs
# in a `fix/*` worktree with a LOCAL generated database (`teqo_wt*` /
# `*_test`, fail-closed guard) and delivers through a Ready PR + native
# auto-merge. It never touches prod/staging envs and never approves production;
# the Deploy workflow itself is untouched (no auto-retry, no auto-dispatch).
#
# Trigger surface: ONLY `push` runs of `Deploy` whose `verify` job failed
# (checked via the jobs API in the launcher — a red staging/production is out
# of scope). Manual dispatches of Deploy never spawn an agent.
name: Auto-unblock
on:
workflow_run:
workflows: ['Deploy']
types: [completed]
permissions:
contents: read
jobs:
unblock:
name: unblock verify failure
if: >-
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'
# Same self-hosted runner as the deploys: the agent must live next to the
# repo, the Docker dev Postgres and the opencode credentials.
runs-on: [self-hosted, homeserver]
# Includes `pnpm install` (~a couple of minutes on a clean self-hosted
# workspace) before the ~1-minute launcher.
timeout-minutes: 30
permissions:
contents: read
actions: read
issues: write
concurrency:
# Single-slot by design (never two launchers); the real single-flight is
# the token Issue + flock. The default pending behavior replaces a
# queued launcher instead of piling them up.
group: auto-unblock
cancel-in-progress: false
steps:
- uses: actions/checkout@v5
with:
# The worktree provisions from `origin/main`; no bot credential may
# leak into it (the agent pushes with the PAT credential helper).
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v6
# package-manager-cache: false — the job downloads nothing from the
# Actions cache service; setup-node's default auto-cache probe would be
# a needless dependency on the self-hosted runner.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Install dependencies
# The detached wrapper loads `scripts/worktree.mjs` (dotenv/pg at
# module load) from this checkout; actions/checkout cleans ignored
# files, so node_modules must exist BEFORE the launcher spawns it.
run: pnpm install --frozen-lockfile
- name: Dispatch the unblock agent (single-flight)
run: bash scripts/auto-unblock.sh
env:
GITHUB_TOKEN: ${{ github.token }}
AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
RUN_SHA: ${{ github.event.workflow_run.head_sha }}

View file

@ -4,6 +4,8 @@
Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: a merge to `main` starts the deploy run automatically (OPS104 supersedes the OPS102 manual-only anti-goal; `workflow_dispatch` stays as the manual escape) — the verifier IS the deploy's `verify` job. The deploy workflow has five jobs: `preflight` (hosted; `scripts/deploy-preflight.mjs` skips the run when another deploy is `queued`/`in_progress` — a `waiting` production approval does not count) → `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full, rodado uma vez por commit) → `deploy-staging` (`environment: staging`, OPS103) → `requeue` (hosted, outside the deploy concurrency group; `scripts/deploy-requeue.mjs` re-dispatches a fresh run when `main` advanced — OPS104) and `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). O script é único e parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`, bucket `teqo-media-staging`; envs de staging só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only.
**Auto-unblock do `verify` (OPS106):** um `verify` vermelho de push em `main` dispara UM agente autônomo de desbloqueio no homeserver — workflow separado `.github/workflows/auto-unblock.yml` (`workflow_run` no Deploy, push-only, conferindo pela jobs API que o job `verify` falhou), `scripts/auto-unblock.sh` (flock `/tmp/teqo-unblock.lock`) + `scripts/auto-unblock.mjs` (token Issue `auto-unblock` = single-flight; falha com agente ativo só comenta) e `scripts/auto-unblock-agent.mjs` destacado (`detached`, fd 9 herdado, sem `RUNNER_TRACKING_ID`; cap 4h) que provisiona `fix/*` via `scripts/worktree.mjs fix --headless`, aplica o guard de banco local `teqo_wt*`/`*_test` e roda `opencode run --command bug-fix`. O agente faz push/PR com o `AUTOMERGE_PAT` por credential helper não-persistente; `deploy.yml`/`verify` continuam intocados (sem auto-retry, produção segue com approval humano). Bootstrap + operação: `docs/ops/teqo-1313-deploy.md` (seção Auto-unblock); smoke `pnpm unblock:check`.
**Guarding `pnpm push` (OPS24+):** every PR push goes through `scripts/git-push.mjs`, which runs `ensure-repo-deps` (install + Husky in worktrees — OPS51), then `gate:push` (the pre-push hook), which chains `gate:ci` — a serialized mirror of the CI `checks` job (**without e2e**: OPS72 moved e2e to an explicit skill step) — plus the `docs-guards` (changelog entries additions-only + frozen HISTORY snapshot + uncommitted regenerable aggregate — OPS63/OPS85; conflict markers; the rewrite escape stays in CI only). The hook blocks `git push` on failure; escape: `git push --no-verify` (WIP only). There is no YAML `push` gate — the hook is the gate. **CI red on your PR is yours**, even pre-existing or infra — "out of feature scope" is not a stop condition (policy "Dono do PR, dono do CI" in `docs/AGENT-OPS.md`).
## Database & local development

View file

@ -23,6 +23,7 @@ main → push dispara deploy.yml sozinho (ou workflow_dispatch manual) → prefl
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **e2e local afetado** (OPS72, discricionário: roda os e2e que criou + os da mesma superfície — `pnpm test:e2e:affected`; ver skill) → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
- **Só humano:** aprovar o environment `production` no run (o único gate de publicação — OPS104 nunca auto-aprova), envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
- **Falha do `verify` (OPS106):** o deploy não fica mais parado esperando alguém notar — `auto-unblock.yml` (`workflow_run` no Deploy, só push em `main` e só job `verify` vermelho) dispara UM agente autônomo de desbloqueio no homeserver com o mesmo contrato do `worktree fix` (worktree `fix/*`, banco local `teqo_wt*`, opencode + `/bug-fix`, PR Ready + auto-merge). Single-flight: Issue token `auto-unblock` + `/tmp/teqo-unblock.lock`; falha com agente ativo só comenta no token (nunca enfileira). Guardrails fail-closed de banco/ambiente, cap de 4h, sem auto-retry do deploy e sem auto-approve de produção. Bootstrap/operação: `docs/ops/teqo-1313-deploy.md` (seção Auto-unblock) + `pnpm unblock:check`. O `AUTOMERGE_PAT` é entregue ao processo destacado (push/PR/outcome) por credential helper não-persistente — o built-in token fica só no launcher síncrono.
### Dono do PR, dono do CI
@ -40,6 +41,7 @@ main → push dispara deploy.yml sozinho (ou workflow_dispatch manual) → prefl
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (GitHub) |
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
| `pnpm worktree fix [bug] [--stay]` | Worktree de correção de bug (`fix/<slug>`, skill `/bug-fix` — o launch envia a descrição do bug); não claima nem cria Issues |
| `pnpm unblock:check` | Smoke do bootstrap do agente de desbloqueio no homeserver (OPS106; exige `GITHUB_TOKEN` + `AUTOMERGE_PAT`) |
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue no GitHub (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
| `pnpm agent:status` | Overview / fila / mermaid |
@ -51,7 +53,7 @@ main → push dispara deploy.yml sozinho (ou workflow_dispatch manual) → prefl
| `GITHUB_TOKEN=… pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main` no **GitHub**: required check `CI (PR) / checks` (idempotente — cria/atualiza/no-op) |
| `pnpm db:seed:minimal` | DB mínimo sintético |
Labels (GitHub): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
Labels (GitHub): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`, `auto-unblock` (token do single-flight do agente de desbloqueio — OPS106; fora da fila de claim).
**plan-issue (OPS17 + OPS18):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), promote dual idempotente: (A) `pnpm agent:ready` na sessão; (B) Action `plan-issue-ready-on-main-merge.yml` lê `Related #N` e promove Issues ainda “aguardando plano” (`blocked` + link `docs/plans/`). Chores sem plano continuam nascendo `ready`.

View file

@ -0,0 +1 @@
**OPS106 — Falha do `verify` dispara agente autônomo de desbloqueio (single-flight) (2026-09-14):** um `verify` vermelho do deploy deixou de ser um beco sem saída — o workflow novo `.github/workflows/auto-unblock.yml` (`workflow_run` no Deploy; só `push` em `main`, e só quando o job `verify` é quem falhou, conferido pela jobs API) dispara UM agente no homeserver com o mesmo contexto de `pnpm worktree fix`. O job instala as deps do checkout (`pnpm install --frozen-lockfile`) antes do launcher — o wrapper carrega `scripts/worktree.mjs` (`dotenv`/`pg` no load) e o checkout self-hosted é limpo a cada run. Launcher `scripts/auto-unblock.sh` (flock próprio `/tmp/teqo-unblock.lock`, nunca o lock do deploy) + `scripts/auto-unblock.mjs` (cria/comenta/fecha a Issue token `auto-unblock` que linka o run vermelho — o token aberto + o lock são o single-flight; falha com agente ativo só comenta, nunca enfileira; TTL de 3h reclaima órfão) e wrapper destacado `scripts/auto-unblock-agent.mjs` (fd 9 herdado mantém o lock, `RUNNER_TRACKING_ID` removido, stdio no log `~/teqo-unblock/logs/unblock-<run-id>.log`, cap de 4h com SIGTERM→SIGKILL) — o job termina em ~1 min e não prende o slot do runner. O agente provisiona o worktree pelo dono (`scripts/worktree.mjs fix --headless --directive`, mesma rota do humano), aplica guard fail-closed de banco (host local + nome `teqo_wt*`/`*_test`; `DATABASE_URL`/`ALLOW_REMOTE_DB`/`TEQO_ENV` removidos do env) e roda `opencode run --command bug-fix` com o report do run; push/PR usam o `AUTOMERGE_PAT` só no processo destacado, via credential helper por env (nada persistido no `.git`). Sem PR ao fim, o token vira `blocked` (gate humano); `deploy.yml`, `verify` e `deploy-homeserver.sh` ficam intocados — nenhum auto-retry do deploy e produção segue só com approval humano. Docs: `docs/AGENT-OPS.md`, `AGENTS-infra.md` e a seção “Auto-unblock (OPS106)” do `docs/ops/teqo-1313-deploy.md` (bootstrap do homeserver + `pnpm unblock:check`). Testes: `tests/unit/autoUnblock.unit.spec.ts` (decisão do single-flight, gate do `verify`, guard de banco, report/outcome/diretiva), `tests/unit/autoUnblockWorkflow.unit.spec.ts` (workflow + shell + wrapper) e casos novos em `worktree.unit.spec.ts`/`githubApi.unit.spec.ts`.

View file

@ -1,4 +1,4 @@
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 + OPS104 — início automático, produção com review)
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 + OPS104 + OPS106 — início automático, produção com review)
Desde a OPS104 o deploy **começa sozinho**: todo merge em `main` (evento
`push`) inicia o run do `deploy.yml` no head, e um segundo gatilho re-despacha
@ -66,6 +66,16 @@ teqo-<env>` → healthcheck → smoke (`/`, `/campanha/login`, `/admin`,
ambiente).
8. Falha = job vermelho; nada é publicado pela metade (rollback automático
após o swap, **só do ambiente que falhou**).
9. **`verify` vermelho (OPS106):** o workflow separado `auto-unblock.yml`
(`workflow_run` no Deploy, só `push` + `main` + `failure`) dispara **um**
agente autônomo de desbloqueio no homeserver — launcher síncrono de ~1 min
que cria/atualiza a Issue token `auto-unblock` e destaca o agente
(`scripts/auto-unblock-agent.mjs`, cap de 4h, flock `/tmp/teqo-unblock.lock`);
o agente provisiona um worktree `fix/*` com banco **local** `teqo_wt*`
(guard fail-closed), roda a skill `/bug-fix` headless e entrega PR Ready
(auto-merge nativo). Agente ativo + nova falha = só comentário no token
(nunca enfileira); sem PR = token `blocked` para o humano. O `deploy.yml` e
o `verify` não mudam, e não há auto-retry do deploy.
**Primeiro deploy (verificação ao vivo):** o caminho de estreia do OPS104 é
mergear em `main` e observar o run nascer sozinho: log do `preflight`
@ -256,6 +266,66 @@ a correção se re-mergeia e o próximo deploy completo publica.
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
| `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 |
| 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)
Uma falha do job `verify` dispara **um** agente autônomo de desbloqueio no
homeserver (workflow `.github/workflows/auto-unblock.yml`, runner self-hosted),
com o mesmo contexto de `pnpm worktree fix`: worktree `fix/*` provisionado pelo
dono (`scripts/worktree.mjs fix --headless`), bancos **locais** `teqo_wt*`/
`*_test`, skill `/bug-fix` headless (`opencode run --command bug-fix`), PR Ready
com auto-merge nativo. O registro visível é a Issue token `auto-unblock`, que
linka o run vermelho; o token aberto + o `flock /tmp/teqo-unblock.lock` são o
single-flight (falha com agente ativo só comenta — nunca enfileira).
Guardrails: banco alvo tem de ser local e casar `teqo_wt*`/`*_test` (senão o
agente nem lança; nunca `teqo_1313`/`teqo_staging`, nunca `~/stack/*.env`);
`DATABASE_URL`/`ALLOW_REMOTE_DB`/`TEQO_ENV` são removidos do env do agente; o
agente roda com o PAT (`AUTOMERGE_PAT`) só no processo destacado, via credential
helper não-persistente; cap de 4h; produção continua só com aprovação humana e
o deploy não tem auto-retry.
### Bootstrap (uma vez, manual — fora do repo)
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).
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
destacado). O armar do auto-merge continua no `agent-pr-ready-automerge.yml`.
3. **Docker/Postgres dev:** o runner user precisa de Docker; o agente sobe o
container dev do repo (`docker compose -p teqo` com o `docker-compose.yml`
da raiz — serviço `postgres`, init em `docker/postgres/init`; porta 5432) —
**não** é o Postgres do stack (`stack_default`), então nenhum banco de
prod/staging é alcançável pelo env do agente.
4. **Deps do checkout:** o workflow roda `pnpm install --frozen-lockfile` no
checkout do runner **antes** do launcher — o wrapper destacado carrega
`scripts/worktree.mjs`, que importa `dotenv`/`pg` no load, e o
`actions/checkout` limpa `node_modules` no runner self-hosted. O `--check`
valida `node_modules` (rode-o de um checkout com deps instaladas).
5. **Dir do agente:** `$HOME/teqo-unblock/{logs,state,worktrees}` (criado pelo
próprio launcher; `WORKTREES_ROOT=$HOME/teqo-unblock/worktrees`).
6. **Label:** `auto-unblock` (o launcher cria idempotentemente; o token nunca
recebe `ready`/`in-progress` e não entra na fila de claim).
7. **Smoke:** com `GITHUB_TOKEN` (built-in/PAT) e `AUTOMERGE_PAT` exportados,
`pnpm unblock:check` valida node/git/pnpm/flock/timeout/docker (binário +
daemon)/opencode/auth, porta 5432, dirs, deps do repo, label e
`permissions.push` do PAT — fail-closed com a mensagem acionável.
### Operação
- **Token `auto-unblock` aberto** = agente ativo (ou órfão dentro do TTL de 3h).
Falha nova com token aberto: comenta no token, não dispara.
- **Sem PR ao fim:** token recebe `blocked` e fica aberto (gate humano) — o log
está em `~/teqo-unblock/logs/unblock-<run-id>.log`.
- **Limpeza:** worktrees/databases acumulam um punhado por falha; apague com
`pnpm worktree kill` de dentro do worktree (dropa os bancos do worktree).
Gatilho para automatizar retention: **≥3 worktrees/DBs `teqo-unblock`
acumulados** (`ls ~/teqo-unblock/worktrees`).
## Backup passivo no Forgejo (OPS76-FOLLOWUP, 2026-08-21)
@ -288,8 +358,21 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**.
acesso ao homeserver e o self-hosted não recebe secrets de produção (as
envs vivem no próprio homeserver, inclusive as de staging — nunca como
GitHub secrets). `main` só anda por PR mergeado com CI verde; o deploy só
roda com `verify` full verde. Fork-PRs não rodam CI (same-repo gate +
setting do repo).
roda com `verify` full verde (**exceção OPS106**: o agente de desbloqueio é
um job self-hosted disparado por `verify` vermelho — launcher fino + agente
destacado com guardrails fail-closed; ver seção Auto-unblock). Fork-PRs não
rodam CI (same-repo gate + setting do repo).
- **Agente de desbloqueio (OPS106):** o `auto-unblock.yml` roda código na
máquina de produção justamente quando o `verify` falha. Mitigações: gatilho
restrito (push em `main` + job `verify` falho conferido pela jobs API),
launcher fino (só decide/spawna e sai), `flock` + Issue token
(single-flight), agente sem `RUNNER_TRACKING_ID` com cap de 4h, banco local
`teqo_wt*`/`*_test` fail-closed, env sem
`DATABASE_URL`/`ALLOW_REMOTE_DB`/`TEQO_ENV`, PAT por credential helper
não-persistente, entrega só por PR Ready com auto-merge (nunca push direto
em `main`) e produção ainda com approval humano. Risco residual aceito: o
agente tem Docker e o PAT no homeserver — sem sandbox/usuário dedicado
nesta fatia (revisitar só com evidência de abuso).
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver.
- Staging sem PII real (seed sintético), bucket/DB/env próprios e noindex —

View file

@ -0,0 +1,248 @@
# Impl: OPS106 — Falha do verify dispara agente autônomo de desbloqueio (single-flight)
Status: aprovado (gate humano 2026-09-14)
Atualizado em: 2026-09-14
Issue: #995
Intenção: docs/plans/ops106-auto-unblock-verify-falha-agente.md
Appetite restante: ~1–2 dias eng (herdado; cortes explícitos em "Rabbit holes / Não escopo")
## Leitura da intenção
- **Outcome:** um `verify` vermelho do `deploy.yml` (e só ele) dispara, sem intervenção humana, UM agente de desbloqueio no homeserver que diagnostica, corrige e abre PR Ready com auto-merge nativo — o mesmo contexto de `pnpm worktree fix` — com registro visível linkando o run vermelho, single-flight estrito (nunca dois agentes; falha com agente ativo não enfileira) e guardrails fail-closed de banco/ambiente. Deploy continua sem auto-retry e produção continua só com aprovação humana.
- **O que NÃO negociar:**
- Nunca tocar banco/env de produção ou staging; alvo de banco tem de ser local `teqo_wt*`/`*_test`, senão fail-closed (nunca `teqo_1313`/`teqo_staging`; nunca sourcear `~/stack/*.env`).
- Nunca auto-aprovar `production`; nenhum auto-retry/auto-dispatch do deploy (anti-goal do OPS104 permanece); nenhum `--rerun-failed-jobs`.
- Nunca dois agentes de desbloqueio ativos; falha repetida com agente ativo **não** enfileira — no máximo comenta no token.
- Contexto do agente = `worktree fix`: opencode + skill `/bug-fix` + worktree `fix/*` + bancos de teste do worktree + MCPs/AGENTS; modelo default `deepseek/deepseek-flash` (override por `OPENCODE_WORKTREE_MODEL`).
- Uma tentativa por run/commit vermelho; sem loop infinito (timeout de agente e TTL de token).
- `deploy.yml` e `scripts/deploy-homeserver.sh` **intocados** (o reactor é um workflow separado); `verify` intocado; armar auto-merge continua exclusivamente no `agent-pr-ready-automerge.yml`.
- Registro visível (Issue token linkando o run vermelho) é o estado do single-flight; sem dashboard, sem ressurreição do pool (OPS65).
- **O que reavaliar (achados que mudam a hipótese da intenção):**
- **Detach é obrigatório, não estilo:** o runner self-hosted roda UM job por vez; um agente síncrono seguraria o slot (e o run `in_progress`) por horas — e, se o reactor fosse um job do próprio `deploy.yml`, o `preflight` de um push novo poderia skipar enquanto o run vermelho não conclui (finding 11). Workflow separado + processo destacado resolvem os dois.
- **`cmdNamespaceBranch` não tem saída machine-readable** (só `path:` humano): a composição exige uma diretiva headless no dono (`worktree.mjs`/`lib/worktree.mjs`), não parsing de saída humana.
- **`github.token` morre com o job:** quem faz push/PR e o outcome pós-agente é um processo destacado — o `AUTOMERGE_PAT` precisa ser o token dessa fase (o built-in fica no trecho síncrono).
- **`actions/checkout` persiste credencial e limpa untracked:** `persist-credentials: false` é obrigatório (o worktree compartilha o `.git` do checkout e herdaria o `extraheader` do bot) e o placeholder `.opencode/secrets/penpot-token` tem de ser (re)criado a cada run (o `clean` do checkout apaga arquivo manual).
- **Host check sozinho não basta no homeserver** (socat reescreve para 127.0.0.1): o guard de banco precisa do regex de nome (`teqo_wt*`) — finding 7.
- **`workflow_run.conclusion == 'failure'` cobre qualquer job do Deploy** (staging/produção também): o gate "só `verify`" é uma checagem de dados (`GET /actions/runs/{id}/jobs`), não do `if` do YAML.
- Porta 5432 livre no homeserver e o container dev (`docker compose -p teqo`, projeto `teqo`) é isolado do stack (`stack_default`) — o agente provisiona os próprios bancos sem tocar o Postgres do stack (finding 11).
## Abordagem recomendada
```mermaid
flowchart LR
V["verify (hosted)"] -- "conclusion=failure" --> R{"auto-unblock.yml<br/>workflow_run [Deploy]<br/>push + main + failure"}
R -- "não" --> N["nada (skip)"]
R -- "sim" --> J{"jobs do run:<br/>verify falhou?"}
J -- "não (staging/prod)" --> N
J -- "sim" --> L{"flock -n + decideUnblock<br/>(token auto-unblock aberto?)"}
L -- "lock ocupado / token recente" --> C["comenta o run no token<br/>(não enfileira)"]
L -- "token órfão ≥ TTL" --> K["fecha o token antigo"] --> S
L -- "livre" --> S["cria token Issue<br/>(auto-unblock + run URL + SHA)"]
S -. "PAT ausente" .-> B["token blocked<br/>(gate humano)"]
S --> W["wrapper destacado<br/>flock herdado (fd 9)"]
W --> P["worktree fix --headless<br/>(fix/… + teqo_wt* + migrate + seed)"]
P --> G{"guard DB<br/>local + teqo_wt* / *_test?"}
G -- "não" --> B2["token blocked + log<br/>exit 1"]
G -- "sim" --> A["opencode run --command bug-fix<br/>env sem DATABASE_URL/ALLOW_REMOTE_DB<br/>timeout 4h"]
A --> O{"PR da branch existe?"}
O -- "sim" --> OK["comenta link + fecha token"]
O -- "não" --> B3["label blocked + path do log<br/>(gate humano)"]
```
**Opções consideradas:** (1) gatilho: job `unblock` dentro do `deploy.yml` | workflow separado via `workflow_run` push-only | `workflow_run` reagindo a qualquer evento; (2) launcher: subprocesso de `worktree fix --stay` parseando a saída humana | provisionamento gêmeo dentro do reactor | modo `--headless` no dono; (3) execução: síncrona no job | destacada com `setsid` + flock herdado | `systemd-run --user`; (4) token do single-flight: Issue + flock + decisão pura | só flock local | só labels/estado do run.
**Recomendação:** **workflow separado `workflow_run` push-only (B) + `--headless` no dono (C) + execução destacada com flock herdado (B) + token Issue/`decideUnblock` (A)** — detalhado em D1–D10. É exatamente o decidido no gate (2026-09-14), sem nova capacidade na máquina de produção que a intenção não peça.
**Rejeitadas:** job no `deploy.yml` (muda o invariante documentado "só verify verde alcança job self-hosted" no arquivo que dona os deploys, acopla a vida do run vermelho ao slot do runner e obriga a pinar exceções nos testes de invariante); `workflow_run` sem filtro de `push` (um dispatch manual é ato humano deliberado; reagir a ele duplica agentes); provisionamento gêmeo (twin do `provision`/`cmdNamespaceBranch`); agente síncrono (prende o único slot do runner); `systemd-run` (unit transiente não sobrevive à limpeza do job, exige bus de usuário e joga o log no journal); só flock/só GitHub como token (sem registro visível ou com corrida entre jobs).
### Decisões de engenharia
#### D1 — Trigger placement
- **Opções:** A) job `unblock` dentro do `deploy.yml` (`needs: [verify]`, `if: needs.verify.result == 'failure' && github.ref == 'refs/heads/main'`, `runs-on: [self-hosted, homeserver]`); B) `.github/workflows/auto-unblock.yml` com `workflow_run` em `Deploy` completed e `if: conclusion == 'failure' && event == 'push' && head_branch == 'main'`; C) igual a B reagindo também a `workflow_dispatch`.
- **Recomendação:** **B** — mantém literal o invariante do `deploy.yml` ("um job self-hosted é execução de código na máquina de produção e só um run com verify full verde o alcança", header l.34-39); a definição do reactor vem sempre da default branch protegida (não do commit vermelho); o payload `github.event.workflow_run` traz `id`/`html_url`/`head_sha`/`event` sem consultar `needs`; um run do reactor **não** aparece em `listWorkflowRuns('deploy.yml')` (o `preflight` do OPS104 ignora) e não entra no grupo `concurrency: deploy-homeserver`; permissões declaradas no próprio arquivo (o topo do `deploy.yml` é `contents: read`). O gate "só verify" é de dados: `GET /actions/runs/{id}/jobs` e só segue se o job `verify` estiver `failure` (D7).
- **Rejeitadas:** A pelo invariante/exception creep no arquivo dono dos deploys, pela interação runner-slot/preflight e por espalhar o pin do trigger em `ciSkipInvariants.unit.spec.ts`; C porque o dispatch manual é ato humano (quem dispatcha está presente) e o gatilho decidido é o push automático — uma red manual não gera agente.
#### D2 — Launcher architecture
- **Opções:** A) orquestrador chamando `pnpm worktree fix <bug> --stay` e parseando `path:`; B) provisionamento próprio dentro do reactor (twin); C) estender o dono com `worktree.mjs fix --headless` + helpers puros.
- **Recomendação:** **C** — composição exata:
- O wrapper destacado executa `node scripts/worktree.mjs fix <report> --headless` (cwd = checkout, `WORKTREES_ROOT=$HOME/teqo-unblock/worktrees`). O caminho é o mesmo de hoje (`cmdNamespaceBranch`: `git fetch origin` → branch `fix/<slug>` fresco de `origin/main` → `worktree add` → `provision` com `teqo_wt<slot>`/`teqo_wt<slot>_test` + migrate + `db:seed:minimal` + cópia de `.opencode/secrets`).
- Em `--headless`, o dono suprime a diretiva TUI e o `cd`; **todo o log vai para stderr** e o **stdout carrega exatamente UMA linha JSON**: `{"dir":"…","branch":"fix/…","model":"…","argv":["opencode","run","--model","…","--auto","--command","bug-fix","<report>"]}`, montada por `headlessDirective`/`opencodeHeadlessArgs` (puros em `scripts/lib/worktree.mjs`, testados).
- O wrapper faz `JSON.parse` do stdout inteiro (sem parsing de `path:`, sem colunas) e spawna `argv` com cwd `dir`.
- **Rejeitadas:** A porque parsing de saída humana é frágil e a flag teria de existir de qualquer forma; B porque duplica o provisionamento (twin) e o caminho do agente deixa de ser o mesmo que o humano usa; a diretiva `launch` existente é terminal-only de propósito (nunca TUI nested).
#### D3 — Síncrono vs destacado
- **Opções:** A) agente síncrono dentro do job; B) destacado com `detached`/`setsid` + flock herdado (fd 9) + `timeout`; C) `systemd-run --user`.
- **Recomendação:** **B**. `scripts/auto-unblock.sh` faz `exec 9>/tmp/teqo-unblock.lock` e `flock -n 9`; passa `--lock-busy=0|1` ao orquestrador (não sai cedo, para poder comentar no token quando ocupado). Com lock livre, o orquestrador cria o token e spawna `node scripts/auto-unblock-agent.mjs` com `detached: true`, `stdio: ['ignore', logFd, logFd, 9]` (o fd herdado mantém o flock pelo tempo de vida do wrapper) e `RUNNER_TRACKING_ID` removido do env (o runner não mata o processo destacado); o job conclui em <2 min e o slot do runner é liberado. O log é `$HOME/teqo-unblock/logs/unblock-<runid>.log` (fd aberto em append). Cap: `timeout --signal=TERM --kill-after=120 4h` no `opencode` e `timeout 30m` no provisionamento; quando o wrapper morre, o fd 9 fecha e o lock libera.
- **Rejeitadas:** A porque prende o único slot do runner (e um run `in_progress` por horas) — o oposto do achado 11; C porque transiente não sobrevive à limpeza do job, depende de bus de usuário e perde log em arquivo.
#### D4 — Single-flight token
- **Opções:** A) Issue token (`auto-unblock`) + flock local + `decideUnblock` puro; B) só flock local (sem registro); C) só labels/estado do run no GitHub (sem lock).
- **Recomendação:** **A**. Contrato:
- `decideUnblock({ lockBusy, openToken, tokenAgeMs, ttlMs = TOKEN_TTL_MS, lockStale })` → `{ action: 'start' | 'skip_comment' | 'reclaim', reason }`:
| lockBusy | openToken | tokenAge | lockStale | ação |
| -------- | --------- | -------- | --------- | ----------------------------------------------------------------------- |
| false | false | — | — | `start` |
| false | true | < TTL | — | `skip_comment` (`token-orfao-imaturo`) |
| false | true | ≥ TTL | — | `reclaim` (comenta + fecha + `start`) |
| true | false | — | — | `skip_comment` (`corrida-lock`; sem token, só log) |
| true | true | < TTL | false | `skip_comment` (`agente-ativo`) — comenta o run novo, **não enfileira** |
| true | true | ≥ TTL | true | `skip_comment` (`lock-preso`) — pede humano; **nunca** mata agente |
- TTL = 3h (`TOKEN_TTL_MS`); agente cap 4h (D3). Token via `createIssue` + `addLabels('auto-unblock')` + `addComment` (run URL, run id, SHA, job/step falhos); o label é garantido idempotentemente pelo orquestrador (`getLabel` → `createLabel`); o token **nunca** recebe `ready`/`in-progress` (não entra na fila do claim/pool).
- `reclaim` de órfão: comenta + `closeIssue` no antigo e emite um novo token.
- Outcome é do **wrapper** (não depende do agente): PR da branch existe (`listPullRequests({ head: 'fsolla:<branch>', state: 'all' })`) → comenta o link e fecha o token; sem PR → label `blocked`, mantém aberto e comenta o path do log (gate humano).
- Fecha no **agent exit**, não no merge: com o token aberto, um novo run vermelho cairia em `skip_comment` sem agente ativo (quebra de liveness); o PR é o registro da entrega, o token é o do single-flight.
- **Rejeitadas:** B (falha de processo vira lock mudo, sem rastro humano) e C (corrida entre jobs; "run vermelho" não é estado de agente).
#### D5 — Credenciais e PAT
- **Opções:** A) `AUTOMERGE_PAT` entregue ao processo destacado (`GITHUB_TOKEN` + credential helper por env), `github.token` só no trecho síncrono; B) `github.token` para tudo; C) deploy key SSH + bot token novo.
- **Recomendação:** **A**:
- Workflow: `GITHUB_TOKEN: ${{ github.token }}` (orquestrador síncrono: Issues/leitura do run) e `AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}`; fail-closed sem PAT (token `blocked` criado com o built-in + exit 1).
- Checkout com `persist-credentials: false` (o worktree compartilha o `.git` do checkout — sem isso o `extraheader` do bot vaza e o push sairia como `github-actions[bot]`, assassinando `opened`/`synchronize` e a classe do PR #746). O agente recebe `GITHUB_TOKEN=<PAT>` e o helper não-persistente: `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=credential.helper`, `GIT_CONFIG_VALUE_0='!f() { echo username=x-access-token; echo password=$GITHUB_TOKEN; }; f'` (nada em `.git/config`/`~/.git-credentials`); o `AUTOMERGE_PAT` é removido do env do filho (fica só `GITHUB_TOKEN`).
- `--check` valida PAT presente e `GET /repos/fsolla/teqo` → `permissions.push === true`; scopes exigidos documentados: contents:write, pull-requests:write, issues:write (o wrapper pós-job usa o PAT para comentar/fechar — o built-in não sobrevive ao job). O armar do auto-merge **continua** 100% no `agent-pr-ready-automerge.yml`.
- **Rejeitadas:** B (expiração + anti-recursão: push/PR como bot não cria runs de `opened`/`synchronize`; o safety net não armaria e o flip pós-merge ficaria mudo — classe PR #746); C (segredo novo e dois mecanismos de auth sem necessidade).
#### D6 — Guard de banco fail-closed
- **Opções:** A) helper puro `evaluateDatabaseTargets` rodando no wrapper entre provisionar e lançar + strip de env; B) confiar no provisionamento; C) importar `assertTestDatabase`.
- **Recomendação:** **A**. Regras: para cada `DATABASE_URL` de `.env.local` e `.env.test.local` (lidos com `dotenv`), exigir host local (`isLocalDatabaseUrl`/`databaseHostname` de `scripts/lib/cli.mjs`) **e** nome `isGeneratedDatabaseName` (`^teqo_wt[0-9]+(_test)?$`, `worktree-env.mjs`), rejeitando as portas dos proxies do stack (5433/5434); o arquivo de teste também satisfaz `TEST_DATABASE_NAME_RE` (`cli.mjs`). Host check sozinho não basta no homeserver (socat → 127.0.0.1). Env do agente sem `DATABASE_URL`, `ALLOW_REMOTE_DB` e `TEQO_ENV` (spawn com env montado, nunca `source ~/stack/*.env`). Falha → token `blocked` + exit 1, sem lançar o agente.
- **Rejeitadas:** B porque Docker indisponível cai no fallback compartilhado (`teqo`/`teqo_test`) e `.env` manual nunca é sobrescrito; C porque é guard de suíte de teste (host/DB `*_test`) e não cobre o banco dev nem o formato `teqo_wt*`.
#### D7 — Bug report handoff e contrato headless
- **Opções:** A) `opencode run --model <m> --auto --command bug-fix <report>` (cwd = worktree), com `report` montado por helper puro; B) mensagem literal `"/bug-fix <report>"`; C) `--agent` + prompt próprio.
- **Recomendação:** **A**. `--command bug-fix` é o contrato documentado do `opencode run` ("use message for args") e não depende do parse de slash do TUI; o report vai como UM argv (sem shell). O report (puro, `buildBugReport`) contém: run URL/id, SHA, nomes dos jobs/steps falhos (de `getWorkflowRunJobs`, do orquestrador síncrono), o caminho do log do job falho baixado pelo launcher (`$HOME/teqo-unblock/logs/`) e as instruções: não há humano disponível; siga a skill `/bug-fix` de ponta a ponta; pare após abrir o PR Ready (a aprovação de produção é fora deste fluxo); nunca toque prod/staging (`~/stack`, `teqo_1313`, `teqo_staging`, `ALLOW_REMOTE_DB`); o fix é na branch `fix/*` criada, PR base `main`. O gate "só `verify`" roda antes: `classifyDeployFailure(jobs)` só segue quando o job `verify` está `failure`; falha de API de jobs = exit 1 sem agente (fail-closed, visível).
- **Rejeitadas:** B porque o valor com `/` + espaços depende do parser de comandos da TUI (o headless tem `--command` de primeira classe); C porque `--agent` troca o agente primário, não a skill.
#### D8 — Homeserver bootstrap e smoke `--check`
- **Opções:** A) `--check` fail-closed documentado + passos humanos no runbook; B) descobrir no primeiro run vermelho; C) check automático em todo run.
- **Recomendação:** **A**. `node scripts/auto-unblock.mjs --check` (atalho `pnpm unblock:check`): node/pnpm/git/flock; `opencode --version` + `opencode auth list` (provider do preset presente); `docker info` + porta 5432 livre; `$HOME/teqo-unblock/{logs,worktrees}` gravável; `AUTOMERGE_PAT` presente + `permissions.push`; label `auto-unblock` acessível (`getLabel`); placeholder `.opencode/secrets/penpot-token` (o launcher grava dummy idempotente pós-checkout — o `actions/checkout` limpa untracked e apagaria um placeholder manual/commitado). Saída PASS/FAIL por item, exit 1 com mensagem acionável. Bootstrap humano (uma vez): opencode autenticado pelo usuário do runner, Node 24 + pnpm no PATH, runner com docker, PAT com os scopes, `WORKTREES_ROOT` dedicado. Documentado em `docs/ops/teqo-1313-deploy.md` + ponteiro em `AGENTS-infra.md`.
- **Rejeitadas:** B porque falha no pior momento (run vermelho, madrugada); C porque gastaria minutos/API por run.
#### D9 — Testes e pins
- **Opções:** A) unit puro + pins de YAML/script; B) só unit puro; C) e2e/int do fluxo real.
- **Recomendação:** **A**. Arquivos exatos:
- `tests/unit/autoUnblock.unit.spec.ts` (novo): tabela do `decideUnblock` (6 linhas), `classifyDeployFailure` (verify `failure` → true; staging `failure` com verify `success` → false; verify ausente → false), `evaluateDatabaseTargets` (happy `teqo_wt42`/`teqo_wt42_test`; fallback `teqo`/`teqo_test`; host não-local; portas 5433/5434; nome avulso), `buildBugReport` (URL/SHA/step/path do log presentes; instruções de não-humano e não-prod), `classifyOutcome` (PR aberto/merged → close; sem PR → blocked), `tokenTitle`/`tokenBody`/labels (nunca `ready`/`in-progress`), round-trip da diretiva headless.
- `tests/unit/worktree.unit.spec.ts` (editar): casos de `opencodeHeadlessArgs`/`headlessDirective` (preset default `deepseek/deepseek-flash`, `--auto`, `--command bug-fix`, report exato, sem `--variant`; stdout JSON puro e logs em stderr; modelo do mapa quando a flag está presente).
- `tests/unit/autoUnblockWorkflow.unit.spec.ts` (novo, padrão de `ciSkipInvariants.unit.spec.ts`/`deployScript.unit.spec.ts`, com comentários YAML removidos): `workflow_run` em `Deploy`, `conclusion == 'failure'`, `event == 'push'`, `head_branch == 'main'`, `runs-on: [self-hosted, homeserver]`, `persist-credentials: false`, `issues: write`/`actions: read`, `bash scripts/auto-unblock.sh`; e que o `deploy.yml` **não** referencia o reactor (`not.toContain('unblock')`), mantendo `ciSkipInvariants.unit.spec.ts` verde sem edição.
- `tests/unit/githubApi.unit.spec.ts` (editar): `getWorkflowRunJobs` (normalização/URL), `getLabel` (404→null), `createLabel` (POST), `listPullRequests({ head })` (query `head=owner:branch`).
- `scriptCliConventions.unit.spec.ts` fica verde (novos CLIs importam `dieWithLabel`).
- **Rejeitadas:** B porque o wiring (o que realmente quebra) não fica pinado; C por custo e por não haver superfície e2e.
#### D10 — Docs/ops contract
- **Opções:** A) changelog + `AGENT-OPS` + `AGENTS-infra` + runbook; B) só changelog.
- **Recomendação:** **A**: `docs/changelog/2026-09-14-ops106.md` (ajustar para a data do merge); `docs/AGENT-OPS.md` (linha do `auto-unblock.yml` na tabela de workflows, label `auto-unblock`, secrets — o `AUTOMERGE_PAT` agora é entregue ao agente autônomo, com riscos/guardrails); `AGENTS-infra.md` (ponteiro curto); `docs/ops/teqo-1313-deploy.md` (seção "Auto-unblock (OPS106)" com bootstrap, `--check` e falhas conhecidas + linha na tabela de falhas: "verify vermelho → agente de desbloqueio dispara sozinho; ver Issue `auto-unblock`"). O header do `deploy.yml` **não muda** (arquivo intocado).
- **Rejeitadas:** B porque o runbook é onde o humano procura quando o lock/token travar ou o PAT expirar.
### Componentes / mudanças
- **`.github/workflows/auto-unblock.yml`** (novo): `name: Auto-unblock`; `on.workflow_run: { workflows: ["Deploy"], types: [completed] }`; job `unblock` com `if: github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'`; `runs-on: [self-hosted, homeserver]`; `timeout-minutes: 10`; `permissions: { contents: read, actions: read, issues: write }`; `concurrency: { group: auto-unblock, cancel-in-progress: false }` (fora do grupo `deploy-homeserver`); steps: `actions/checkout@v5` com `fetch-depth: 0` e `persist-credentials: false` (fetch-depth 0 dá `origin/main` completo ao `worktree add`), `pnpm/action-setup@v6`, `actions/setup-node@v5` (`node-version: 24`, `package-manager-cache: false`), step com env `GITHUB_TOKEN`/`AUTOMERGE_PAT`/`RUN_ID`/`RUN_URL`/`RUN_SHA` chamando `bash scripts/auto-unblock.sh`.
- **`scripts/auto-unblock.sh`** (novo, fino): `mkdir -p $HOME/teqo-unblock/{logs,worktrees}`; `exec 9>"${TEQO_UNBLOCK_LOCK:-/tmp/teqo-unblock.lock}"`; `LOCK_BUSY=0|1` via `flock -n 9`; `node scripts/auto-unblock.mjs --lock-busy=$LOCK_BUSY`.
- **`scripts/auto-unblock.mjs`** (novo CLI, síncrono, ~1 min): parse de flags (`--lock-busy`, `--check`, `--dry-run`); `--check` (D8); com lock livre: valida PAT, `classifyDeployFailure(getWorkflowRunJobs(RUN_ID))`, `ensureLabel`, `decideUnblock` (`listIssues({ state: 'open', labels: 'auto-unblock' })`), cria/comenta/fecha o token conforme a ação; no `start`/`reclaim` abre o log e spawna `scripts/auto-unblock-agent.mjs` destacado com fd 9 no `stdio`, `RUNNER_TRACKING_ID` removido e exit 0.
- **`scripts/auto-unblock-agent.mjs`** (novo CLI destacado): monta o report (`buildBugReport` + log do job falho baixado); roda `timeout 30m node scripts/worktree.mjs fix <report> --headless` capturando stdout (stderr → log) e faz `JSON.parse` da diretiva; `evaluateDatabaseTargets` (fail-closed); lança `timeout 4h <argv>` com env filtrado (sem `DATABASE_URL`/`ALLOW_REMOTE_DB`/`TEQO_ENV`) + PAT + `GIT_CONFIG_*` + cwd do worktree; classifica o outcome e comenta/fecha/labela o token; escreve o rodapé no log.
- **`scripts/lib/auto-unblock.mjs`** (novo, puro, sem I/O): `UNBLOCK_LABEL`, `TOKEN_TTL_MS`, `decideUnblock`, `classifyDeployFailure`, `evaluateDatabaseTargets`, `buildBugReport`, `classifyOutcome`, `tokenTitle`/`tokenBody`, `parseHeadlessDirective`.
- **`scripts/lib/worktree.mjs`** (editar o dono): `opencodeHeadlessArgs({ model, report })` e `headlessDirective({ dir, branch, model, report })` — reusa `OPENCODE_PRESET_MODEL`/`resolveWorktreeModel`; puros e testados.
- **`scripts/worktree.mjs`** (editar o dono): flag `--headless` no subcomando `fix` (e no `parseArgs`/usage); em headless, após o `provision` (mesmo `cmdNamespaceBranch`), imprime a linha JSON no stdout e logs no stderr; nunca imprime `cd`/diretiva TUI.
- **`scripts/lib/github-api.mjs`** (estender o dono): `getWorkflowRunJobs(runId)` (normaliza `{ id, name, conclusion, steps }`), `getLabel(name)` (404→null), `createLabel(name, { color, description })`, `listPullRequests({ state, head })` (query `head=owner:branch`), `downloadJobLog(jobId)` (texto puro, usa o `fetchImpl`/headers existentes).
- **`package.json`**: script `unblock:check` → `node scripts/auto-unblock.mjs --check`.
- **Migration:** sem migration (nenhuma collection/global/field).
- **Access / Consent:** N/A — nenhum dado de cidadão; o token é Issue do tracker GitHub.
- **UI:** Impeccable A / N/A — sem superfície de UI; nenhum shell a reusar.
### Dados → forma (se aplicável)
N/A — sem superfície de dados para usuário. A evidência é operacional: a Issue token (`auto-unblock`) linkando o run vermelho, o log do wrapper em `$HOME/teqo-unblock/logs/unblock-<runid>.log` e o PR `fix/*` com o resultado. Nada é apresentado em UI de produto.
## Fases verificáveis
1. **Fundação pura + API (unit-first)** — `scripts/lib/auto-unblock.mjs` (novos helpers), `listPullRequests({ head })`/`getWorkflowRunJobs`/`getLabel`/`createLabel`/`downloadJobLog` em `github-api.mjs`, `opencodeHeadlessArgs`/`headlessDirective` em `lib/worktree.mjs`; `tests/unit/autoUnblock.unit.spec.ts`, casos em `worktree.unit.spec.ts` e `githubApi.unit.spec.ts`. Gate: `pnpm test:unit` verde. _~2–3h._
2. **Modo headless no dono** — `--headless` em `scripts/worktree.mjs` (fix), stdout JSON/estderr logs, spec dedicado. Gate: `pnpm test:unit` verde. _~2h._
3. **CLIs e shell** — `scripts/auto-unblock.mjs`, `scripts/auto-unblock-agent.mjs`, `scripts/auto-unblock.sh`, `--check`/`--dry-run`, `unblock:check` no `package.json`. Gate: `pnpm gate:fast` verde. _~3–4h._
4. **Workflow + pins** — `.github/workflows/auto-unblock.yml`, `tests/unit/autoUnblockWorkflow.unit.spec.ts` (padrão text/YAML com comentários removidos) e confirmação de que `ciSkipInvariants.unit.spec.ts`/`deployScript.unit.spec.ts` seguem verdes sem edição. Gate: `pnpm test:unit` verde. _~1–2h._
5. **Docs + changelog** — `docs/changelog/2026-09-14-ops106.md` (additions-only), `docs/AGENT-OPS.md`, `AGENTS-infra.md`, `docs/ops/teqo-1313-deploy.md` (seção OPS106 + linha em falhas conhecidas). _~1,5h._
6. **Gates, entrega e dogfood** — `pnpm gate:fast`; `pnpm push`; PR Ready (`Closes #995`) com auto-merge nativo. Pós-merge: `node scripts/auto-unblock.mjs --check` no homeserver (bootstrap humano uma vez, documentado); o primeiro `verify` vermelho real é o dogfood (conferir token `auto-unblock`, log do wrapper, agente em execução e o resultado no token/PR). Sem forçar red em `main`. _~1–2h + observação._
Gates — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- **Agente dentro do job de deploy / slot do runner / preflight:** o reactor é workflow separado e o agente é destacado; um job self-hosted síncrono fica de fora (finding 11).
- **Reusar `/tmp/teqo-deploy.lock`:** o lock do unblock é próprio (`/tmp/teqo-unblock.lock`); compartilhar com o deploy faria o agente bloquear produção e vice-versa.
- **Sourcear `~/stack/*.env` / rodar seeder ou agente contra o Postgres do stack:** proibido; o agente só usa o container dev do repo (`docker compose -p teqo`, 5432) com `teqo_wt*`/`*_test`.
- **Usar `GITHUB_TOKEN` built-in para push/PR/outcome:** expira com o job e é a classe do PR #746; o PAT é do processo destacado (D5).
- **Auto-retry/auto-dispatch do deploy; `--rerun-failed-jobs`; resolver flake re-rodando job vermelho:** fora de escopo (anti-goals; flakes seguem em #882/#906/#849/#923).
- **Dashboard/painel de agentes, telemetria de causa, múltiplos agentes paralelos:** o registro visível (Issue token) basta; single-flight = um agente.
- **Auto-fechar o token no merge do PR:** fecha na saída do agente (D4); no merge quebraria a liveness do single-flight.
- **Ressurreição do pool (OPS65), tick agendado, Cursor Cloud:** nada disso; só o gatilho do `workflow_run`.
- **Isolamento forte do agente (usuário sem docker, socket proxy, sandbox de rede):** risco aceito/documentado nesta fatia (ver R1); revisitar só com evidência de abuso.
- **`systemd-run --user`/journald, limpeza automática de worktrees/DBs do unblock, `workflow_dispatch` manual do reactor:** sem necessidade agora; limpeza é manual (`pnpm worktree kill` de dentro do worktree), documentada no runbook.
- **Reescrever o `verify` para self-hosted ou mover jobs do `deploy.yml`:** intocado por decisão (D1).
## Riscos e mitigação
- **R1 — Agente autônomo com PAT na máquina de produção.** O runner user tem docker e acesso a `~/stack`; um agente descontrolado poderia ler/atacar prod. Detecção/defesa por token visível, log auditável, `--auto` restrito aos tools permitidos, prompt com proibições e DB guard. _Mitigação:_ runbook "Segurança" + revisitar com evidência (usuário dedicado/sandbox fora desta fatia).
- **R2 — Concorrência de recursos no homeserver (8c/16GB).** Build/e2e do agente pode competir com o stack (OOM do build é falha conhecida). _Mitigação:_ um agente por vez (single-flight), `nice`/prioridade baixa, cap de 4h, `NODE_OPTIONS` contido; se recorrente, rodar e2e pesado na workstation (item futuro).
- **R3 — Detach falhar e o job segurar o único slot do runner.** `timeout-minutes: 10` no job + `setsid`/`RUNNER_TRACKING_ID` removido limitam; se o runner ainda matar o processo, o lock libera e o token fica órfão → TTL reclaim (D4).
- **R4 — Falso positivo por flake de e2e (#882/#906).** O agente pode gastar horas atrás de flake. _Mitigação:_ uma tentativa por run, sem retry de deploy; sem PR → token `blocked` (gate humano); dependências suaves registradas na intenção.
- **R5 — PAT ausente/expirado/sem scope.** `--check` valida presença e push; issues:write é residual (o wrapper falha ao comentar e o token fica aberto para o humano — visível, não silencioso).
- **R6 — Trigger silencioso se `Deploy` for renomeado ou o arquivo não estiver na default branch.** Pin no `autoUnblockWorkflow.unit.spec.ts` (`workflows: ["Deploy"]`) + nota no runbook; o arquivo só vale após o merge (o primeiro red pós-merge dispara).
- **R7 — Worktree/DBs do unblock acumulando.** Uma falha rara por vez; limpeza manual documentada; gatilho mensurável de revisitação: ≥3 worktrees/DBs `teqo-unblock` acumulados (aí avaliar retention automática).
- **R8 — Token Issue virar ruído/fila errada.** O label `auto-unblock` não entra no claim (sem `ready`/`in-progress`); o `preflight` e o pool não enxergam Issues fora da fila.
- **R9 — `workflow_run` roda com a definição da default branch.** Propriedade desejada (não executa o commit vermelho como reactor), mas exige que o PR de OPS106 esteja mergeado para o mecanismo existir — o dogfood é o primeiro red real pós-merge.
- **R10 — Credencial vazando no worktree.** `persist-credentials: false` + helper por env + sem `.git-credentials`; o token só vive no env do processo do agente (auditável via log/JSON do token).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: (1) `verify` vermelho de push em `main` → agente em execução sem humano, com Issue token linkando o run; (2) single-flight: lock + token, falha repetida com agente ativo não enfileira (comenta); (3) contexto de `worktree fix` (opencode + `/bug-fix` + worktree `fix/*` + DB `teqo_wt*`/`*_test` + MCPs/AGENTS; preset `deepseek/deepseek-flash` sobrescrevível); (4) guardrails fail-closed de banco/ambiente, sem touch em prod/staging, sem auto-approve de production; (5) nenhum auto-retry do deploy.
- [ ] Invariantes AGENTS/engineering-standards: sem migration/UI/access/Consent; `deploy.yml`, `verify`, `deploy-homeserver.sh` e `ciSkipInvariants` intocados; sem secret novo (reusa `AUTOMERGE_PAT`); token fora da fila de claim.
- [ ] Testes de domínio previstos: `tests/unit/autoUnblock.unit.spec.ts` (novo), `tests/unit/autoUnblockWorkflow.unit.spec.ts` (novo), `tests/unit/worktree.unit.spec.ts` e `tests/unit/githubApi.unit.spec.ts` (edições); `scriptCliConventions.unit.spec.ts` verde.
- [ ] `pnpm gate:fast` e `pnpm test:unit` verdes; PR Ready; `node scripts/auto-unblock.mjs --check` documentado e executado no bootstrap do homeserver; primeiro red real observado (dogfood) e registrado no changelog/runbook.
### Self-score decision-quality
| Critério | Nota | Justificativa |
| -------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. Decisões caras com rejeitadas | 1,0 | D1–D10 com opções e rejeitadas explícitas (trigger, launcher, sync/detach, token, credencial, guard, handoff, bootstrap, testes, docs) |
| 2. Cabe no appetite | 1,0 | ~1–2 dias: 6 fases pequenas; nenhuma refatoração do fluxo existente (a edição no dono é additive: `--headless` + helpers puros) |
| 3. Rabbit holes nomeados | 1,0 | runner slot, lock compartilhado, stack env, token built-in, auto-retry/flake, dashboard, paralelismo, fechamento do token, sandbox, systemd-run, limpeza |
| 4. Depth check (reuso) | 0,9 | Reusa `worktree.mjs`/`lib/worktree.mjs`, `github-api.mjs`, guards de `cli.mjs`/`worktree-env.mjs`, flock do deploy e o auto-merge do `agent-pr-ready-automerge.yml`; novos módulos só onde não há dono (orquestrador/wrapper/puros) |
| 5. Outcome preservado | 0,9 | Engenharia não altera o aceite; residual = PAT scopes (issues:write só se prova no primeiro run) e auth do opencode headless (`--check` cobre o resto) |
| **Total** | **4,8/5** | ≥4 exigido; o desconto de 0,2 é a verificação de scopes do PAT e do opencode headless, que só o primeiro run real prova (mitigado por `--check`) |
### Ajustes de execução (revisão de 2026-09-14)
- O job instala deps (`pnpm install --frozen-lockfile`) antes do launcher: o
wrapper destacado carrega `scripts/worktree.mjs` (importa `dotenv`/`pg` no
load) e o `actions/checkout` limpa `node_modules` no runner self-hosted;
`--check` valida `node_modules`.
- `--dry-run` decide e imprime sem criar/comentar/fechar nada (guard antes dos
ramos de comentário/reclaim).
- Sem `downloadJobLog` no launcher (o endpoint de logs devolve zip): o report
entrega run/SHA/job/step + comandos `gh`/`curl` para o próprio agente buscar
o log.
- `--check` valida provider via `~/.local/share/opencode/auth.json` (não
`opencode auth list`) e o daemon com `docker info`, reusando
`parsePostgresContainerHealth`/`SHARED_POSTGRES_CONTAINER` do dono
`db-start.mjs`.
- `parseHeadlessDirective` exige `branch`; o outcome exige PR na branch exata
(sem fallback no `[0]`).
- `parseEqualsFlags` em `scripts/lib/cli.mjs` (forma `--flag=valor`) usado
pelos dois CLIs novos.
### Débitos diferidos (triagem 2026-09-14)
- **`--check` valida presença de provider, não o provider do preset**
(`deepseek/deepseek-flash`/`OPENCODE_WORKTREE_MODEL`). Gatilho: o primeiro
red real falhar por auth ausente **ou** a próxima edição do `--check`.
- **Retention automática do `~/teqo-unblock`** — ver R7 (gatilho ≥3
worktrees/DBs acumulados).
- **Sandbox/usuário dedicado para o agente** — risco aceito (R1); reabre só
com evidência de abuso, aí escala para Issue com `depends: #995`.

View file

@ -88,6 +88,7 @@
"prepare": "husky",
"agent:status": "node scripts/agent-status.mjs",
"worktree": "node scripts/worktree.mjs",
"unblock:check": "node scripts/auto-unblock.mjs --check",
"issue": "node scripts/issue.mjs"
},
"dependencies": {

View file

@ -0,0 +1,204 @@
#!/usr/bin/env node
/**
* OPS106 auto-unblock — detached agent wrapper. Spawned by
* `scripts/auto-unblock.mjs` with the single-flight flock inherited on fd 9,
* `detached: true` and stdio pointed at the run's log file. It:
* 1. provisions the `fix/*` worktree through the owner (`scripts/worktree.mjs
* fix --headless --directive <file>`) — same path as `pnpm worktree fix`;
* 2. enforces the fail-closed database guard on the generated env files;
* 3. runs `opencode run --command bug-fix` with the report and a 4h cap,
* PAT-only credentials and no prod/staging env;
* 4. classifies the outcome (PR found?) and closes/labels the token Issue.
* The token Issue is handled here — never by the agent — so a dead agent still
* leaves a visible, human-actionable record.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
buildBugReport,
classifyOutcome,
databaseUrlFromEnvText,
evaluateDatabaseTargets,
outcomeComment,
parseHeadlessDirective,
UNBLOCK_BLOCKED_LABEL,
} from './lib/auto-unblock.mjs'
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
import { createApi } from './lib/github-api.mjs'
const die = dieWithLabel('auto-unblock:agent')
const PROVISION_TIMEOUT_SECONDS = 30 * 60
const AGENT_TIMEOUT_SECONDS = 4 * 60 * 60
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const unblockHome = process.env.TEQO_UNBLOCK_HOME ?? join(process.env.HOME ?? '', 'teqo-unblock')
const say = (message) => console.log(`[auto-unblock:agent] ${message}`)
const { flags } = parseEqualsFlags(process.argv.slice(2))
const tokenIssue = Number(flags.token)
const statePath = typeof flags.state === 'string' ? flags.state : null
if (!Number.isInteger(tokenIssue) || tokenIssue <= 0 || !statePath) {
die('--state=<path> e --token=<issue> são obrigatórios.')
}
const api = createApi({})
/** Any failure after the token exists must leave it visibly blocked. */
const blockToken = async (message) => {
try {
await api.addComment(tokenIssue, message)
await api.addLabels(tokenIssue, [UNBLOCK_BLOCKED_LABEL])
} catch (error) {
say(`falha ao marcar o token como blocked: ${error?.message ?? error}`)
}
}
let state
try {
state = JSON.parse(readFileSync(statePath, 'utf8'))
if (!state?.runId) throw new Error('state sem runId')
} catch (error) {
await blockToken(`State ilegível (\`${statePath}\`): ${error?.message ?? error}`)
die(`state inválido: ${error?.message ?? error}`)
}
const runTimed = (label, seconds, command, args, options) => {
say(`${label} (cap ${seconds}s)`)
const result = spawnSync(
'timeout',
['--signal=TERM', '--kill-after=120', `${seconds}s`, command, ...args],
options,
)
say(`${label} terminou: status=${result.status} signal=${result.signal ?? 'none'}`)
if (result.error) say(`${label} erro: ${result.error.message}`)
return result
}
const main = async () => {
// opencode refuses to boot with a dangling `{file:...}` MCP reference; the
// checkout is cleaned every run, so the placeholder is (re)created here and
// copied into the worktree by the provisioning path.
const secretsDir = join(repoRoot, '.opencode', 'secrets')
mkdirSync(secretsDir, { recursive: true })
const penpotToken = join(secretsDir, 'penpot-token')
if (!existsSync(penpotToken)) writeFileSync(penpotToken, 'auto-unblock-placeholder\n')
const report = buildBugReport({
runUrl: state.runUrl,
runId: state.runId,
sha: state.sha,
failedJobs: state.failedJobs,
repo: state.repo,
})
const directivePath = join(unblockHome, 'state', `directive-${state.runId}.json`)
const provision = runTimed(
'provisionamento do worktree fix',
PROVISION_TIMEOUT_SECONDS,
process.execPath,
[
join(repoRoot, 'scripts', 'worktree.mjs'),
'fix',
report,
'--headless',
'--directive',
directivePath,
],
{ cwd: repoRoot, stdio: 'inherit' },
)
if (provision.status !== 0 || !existsSync(directivePath)) {
await blockToken(
`Provisionamento do worktree falhou (status ${provision.status}); o agente não foi lançado. Log: \`${state.logPath}\``,
)
die('provisionamento falhou — token blocked.')
}
const directive = parseHeadlessDirective(readFileSync(directivePath, 'utf8'))
say(`worktree pronto: ${directive.dir} (branch ${directive.branch})`)
const targets = evaluateDatabaseTargets({
devUrl: databaseUrlFromEnvText(readFileSync(join(directive.dir, '.env.local'), 'utf8')),
testUrl: databaseUrlFromEnvText(readFileSync(join(directive.dir, '.env.test.local'), 'utf8')),
})
if (!targets.ok) {
await blockToken(
[
'Guard de banco fail-closed: o ambiente provisionado não é local `teqo_wt*`/`*_test`.',
'',
...targets.problems.map((problem) => `- ${problem}`),
].join('\n'),
)
die(`guard de banco reprovou: ${targets.problems.join('; ')}`)
}
say('guard de banco ok (local + teqo_wt*).')
const childEnv = { ...process.env }
for (const key of Object.keys(childEnv)) {
if (['DATABASE_URL', 'ALLOW_REMOTE_DB', 'TEQO_ENV', 'AUTOMERGE_PAT'].includes(key)) {
delete childEnv[key]
}
}
// Non-persistent git credential helper: the PAT lives only in the process
// env (never in .git/config, never in ~/.git-credentials).
childEnv.GIT_CONFIG_COUNT = '1'
childEnv.GIT_CONFIG_KEY_0 = 'credential.helper'
childEnv.GIT_CONFIG_VALUE_0 =
'!f() { echo username=x-access-token; echo password=$GITHUB_TOKEN; }; f'
const [command, ...args] = directive.argv
const agentRun = runTimed(
'opencode run --command bug-fix',
AGENT_TIMEOUT_SECONDS,
command,
args,
{
cwd: directive.dir,
stdio: 'inherit',
env: childEnv,
},
)
const [owner] = String(state.repo ?? 'fsolla/teqo').split('/')
const pullRequests = await api.listPullRequests({
state: 'all',
head: `${owner}:${directive.branch}`,
})
// Exact branch match only: a stale/ignored head filter must never read as
// "PR opened" (which would close the token on someone else's PR).
const pullRequest = pullRequests.find((pr) => pr.head.ref === directive.branch) ?? null
const outcome = classifyOutcome({ exitCode: agentRun.status, pullRequest })
await api.addComment(
tokenIssue,
outcomeComment({
status: outcome.status,
exitCode: agentRun.status,
branch: directive.branch,
pullRequest,
logPath: state.logPath,
}),
)
if (outcome.status === 'pr-opened') {
await api.closeIssue(tokenIssue)
say(`PR aberto (${pullRequest.htmlUrl ?? pullRequest.number}) — token #${tokenIssue} fechado.`)
} else {
await api.addLabels(tokenIssue, [UNBLOCK_BLOCKED_LABEL])
say(`sem PR — token #${tokenIssue} marcado blocked (gate humano).`)
}
process.exit(outcome.status === 'pr-opened' ? 0 : 1)
}
try {
await main()
} catch (error) {
await blockToken(
`Wrapper do agente falhou: \`${error?.message ?? error}\` — log: \`${state.logPath}\``,
)
die(error?.stack ?? error?.message ?? String(error))
}

382
scripts/auto-unblock.mjs Normal file
View file

@ -0,0 +1,382 @@
#!/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,
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 })
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', version('opencode'))
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).')
}
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))
}

25
scripts/auto-unblock.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
#
# OPS106 auto-unblock — entrypoint of `.github/workflows/auto-unblock.yml` on
# the homeserver. Holds `/tmp/teqo-unblock.lock` (flock) for the read of the
# single-flight state and hands the open fd 9 to the detached agent wrapper, so
# the lock is held for the agent's whole lifetime while this job exits in ~1
# minute (the runner slot is not blocked). A second failure finds the lock busy
# and only comments on the open token — never queues an agent.
#
# The lock is intentionally NOT the deploy lock: the agent must never block a
# deploy and a deploy must never be mistaken for the agent.
set -euo pipefail
UNBLOCK_HOME="${TEQO_UNBLOCK_HOME:-$HOME/teqo-unblock}"
LOCK_FILE="${TEQO_UNBLOCK_LOCK:-/tmp/teqo-unblock.lock}"
mkdir -p "$UNBLOCK_HOME/logs" "$UNBLOCK_HOME/state" "$UNBLOCK_HOME/worktrees"
exec 9>"$LOCK_FILE"
LOCK_BUSY=0
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"
exec node scripts/auto-unblock.mjs "--lock-busy=$LOCK_BUSY"

View file

@ -0,0 +1,270 @@
/**
* Pure helpers for the OPS106 auto-unblock reactor (fail-closed glue between
* `.github/workflows/auto-unblock.yml`, `scripts/auto-unblock.mjs` and the
* detached `scripts/auto-unblock-agent.mjs`). No I/O: every decision the
* reactor makes is a pure function here so the unit suite can pin the
* guardrails without a GitHub token, Docker or opencode.
*/
import { databaseHostname, isLocalDatabaseUrl, TEST_DATABASE_NAME_RE } from './cli.mjs'
import { isGeneratedDatabaseName } from './worktree-env.mjs'
/** The single label identifying a single-flight token Issue. */
export const UNBLOCK_LABEL = 'auto-unblock'
/** Fail-closed marker used when the agent could not deliver a PR. */
export const UNBLOCK_BLOCKED_LABEL = 'blocked'
/**
* Orphan-token TTL. Only consulted when the flock is FREE (no wrapper alive):
* a live agent always holds the lock, so a busy lock is never overridden by
* the TTL — the agent cap (`auto-unblock-agent.mjs`, 4h) always wins.
*/
export const UNBLOCK_TOKEN_TTL_MS = 3 * 60 * 60 * 1000
/**
* 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
* `scripts/deploy-homeserver.sh` (TEQO_BUILD_PROXY_PORT per environment).
*/
const STACK_PROXY_PORTS = new Set(['5433', '5434'])
/**
* Single-flight decision table. `lockBusy` comes from `flock -n` in
* `scripts/auto-unblock.sh`; `openToken` is the presence of an open
* `UNBLOCK_LABEL` Issue; `tokenAgeMs` its age (Date.now - createdAt).
*
* | lockBusy | openToken | tokenAge | ação |
* | -------- | --------- | -------- | ------------- |
* | true | true | qualquer | skip_comment | (agente ativo — nunca enfileira)
* | true | false | qualquer | skip | (corrida de lock — sem token para comentar)
* | false | false | qualquer | start |
* | false | true | < TTL | skip_comment | (órfão imaturo — evita thrash)
* | false | true | >= TTL | reclaim | (órfão velho — fecha e começa de novo)
* @param {{ lockBusy?: boolean, openToken?: boolean, tokenAgeMs?: number | null, ttlMs?: number }} [options]
*/
export const decideUnblock = ({
lockBusy,
openToken = false,
tokenAgeMs = null,
ttlMs = UNBLOCK_TOKEN_TTL_MS,
} = {}) => {
if (lockBusy) {
return openToken
? { action: 'skip_comment', reason: 'agent-active' }
: { action: 'skip', reason: 'lock-raced' }
}
if (!openToken) return { action: 'start', reason: 'clear' }
// Unknown age (missing/unparseable createdAt) is treated as stale: the lock
// is free, so no agent is alive and reclaiming is the fail-closed move.
if (typeof tokenAgeMs !== 'number' || !Number.isFinite(tokenAgeMs)) {
return { action: 'reclaim', reason: 'orphan-token-age-unknown' }
}
if (tokenAgeMs >= ttlMs) return { action: 'reclaim', reason: 'orphan-token-stale' }
return { action: 'skip_comment', reason: 'orphan-token-fresh' }
}
/**
* The DEPLOY run only earns an agent when the `verify` job itself failed.
* `workflow_run.conclusion == 'failure'` is true for a red staging/production
* too (secrets, runner, rollback), and those are explicitly out of scope.
* @param {Array<{ name?: string, conclusion?: string | null, htmlUrl?: string, steps?: Array<{ name?: string, conclusion?: string | null }> }>} [jobs]
*/
export const classifyDeployFailure = (jobs = []) => {
const list = Array.isArray(jobs) ? jobs : []
const failedJobs = list
.filter((job) => job?.conclusion === 'failure')
.map((job) => ({
name: job.name ?? '',
url: job.htmlUrl ?? '',
steps: (Array.isArray(job.steps) ? job.steps : [])
.filter((step) => step?.conclusion === 'failure')
.map((step) => step.name ?? ''),
}))
const verify = list.find((job) => job?.name === 'verify')
return { verifyFailed: verify?.conclusion === 'failure', failedJobs }
}
const evaluateOne = (label, url, { requireTestName = false } = {}) => {
const problems = []
if (!url) return [`${label}: DATABASE_URL ausente`]
if (!isLocalDatabaseUrl(url)) {
problems.push(`${label}: host não-local (${databaseHostname(url) ?? 'inparseável'})`)
}
let name = null
let port = ''
try {
const parsed = new URL(url)
name = parsed.pathname.replace(/^\/+/, '')
port = parsed.port
} catch {
problems.push(`${label}: URL inparseável`)
return problems
}
if (!isGeneratedDatabaseName(name)) {
problems.push(`${label}: banco '${name}' fora do padrão teqo_wt<slot>[_test]`)
} else if (requireTestName && !TEST_DATABASE_NAME_RE.test(name)) {
problems.push(`${label}: banco de teste '${name}' precisa terminar em _test`)
}
if (STACK_PROXY_PORTS.has(port)) {
problems.push(`${label}: porta ${port} é o proxy do stack (prod/staging)`)
}
return problems
}
/**
* Fail-closed database guard for the agent's provisioned env. Host check alone
* is not enough on the homeserver: the stack proxies rewrite the host to
* 127.0.0.1, so the generated NAME (`teqo_wt<slot>[_test]`) is the contract
* that keeps the agent away from `teqo_1313`/`teqo_staging`.
*/
export const evaluateDatabaseTargets = ({ devUrl, testUrl } = {}) => {
const problems = [
...evaluateOne('.env.local', devUrl),
...evaluateOne('.env.test.local', testUrl, { requireTestName: true }),
]
return { ok: problems.length === 0, problems }
}
/** `DATABASE_URL=...` from a generated worktree env file (last line wins). */
export const databaseUrlFromEnvText = (text) => {
let found = null
for (const line of String(text ?? '').split('\n')) {
const match = /^DATABASE_URL=(.+)$/.exec(line.trim())
if (match) found = match[1].trim()
}
return found
}
/**
* The bug report handed to the agent as the `/bug-fix` argument. Self-contained
* by design: the agent is headless, may not have `gh`, and must never need a
* human to know which run failed or where its guardrails are.
* @param {{ runUrl?: string, runId?: string, sha?: string, failedJobs?: Array<{ name?: string, steps?: string[] }>, repo?: string }} [options]
*/
export const buildBugReport = ({
runUrl = '',
runId = '',
sha = '',
failedJobs = [],
repo = 'fsolla/teqo',
} = {}) => {
const jobLines = failedJobs.map(
(job) =>
`- ${job.name}${job.steps?.length ? ` (steps com falha: ${job.steps.join(', ')})` : ''}`,
)
return [
'Falha do job `verify` do deploy (GitHub Actions) — desbloqueie o deploy.',
'',
`Run vermelho: ${runUrl}`,
`Run id: ${runId}`,
`Commit: ${sha}`,
'',
'Jobs com falha:',
...(jobLines.length > 0 ? jobLines : ['- (não informado pela API)']),
'',
'Como investigar os logs:',
`- Com gh: \`gh run view ${runId} --log-failed --repo ${repo}\``,
`- Sem gh: \`curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/repos/${repo}/actions/runs/${runId}/logs -o /tmp/run-${runId}.zip\` e descompacte em /tmp/run-${runId}.`,
'',
'Regras desta sessão autônoma:',
'- NÃO há humano disponível: não peça aprovação nem pare para perguntar — decida com a evidência.',
'- Siga a skill /bug-fix de ponta a ponta (reproduzir → causa-raiz → fix + teste de regressão → verificação).',
'- NUNCA toque produção/staging: não leia `~/stack/*.env`, não use `teqo_1313`/`teqo_staging`, não defina ALLOW_REMOTE_DB.',
'- Feche com `pnpm push` e um PR Ready base `main`; o auto-merge nativo arma sozinho quando o required check ficar verde. NÃO aprove produção.',
'- Não feche a Issue do disparo — o wrapper do auto-unblock cuida dela.',
].join('\n')
}
/** Title of the single-flight token Issue (pt-BR, user-visible). */
export const tokenTitle = ({ runId = '', sha = '' } = {}) =>
`Auto-unblock: verify falhou no deploy run ${runId}${sha ? ` (${sha.slice(0, 7)})` : ''}`
/**
* Body of the single-flight token Issue.
* @param {{ runUrl?: string, runId?: string, sha?: string, failedJobs?: Array<{ name?: string }>, logPath?: string }} [options]
*/
export const tokenBody = ({
runUrl = '',
runId = '',
sha = '',
failedJobs = [],
logPath = '',
} = {}) => {
const jobLines = failedJobs.map((job) => `- ${job.name}`)
return [
'Token visível do **single-flight** do agente de desbloqueio (OPS106): enquanto esta Issue',
'estiver aberta, uma nova falha do `verify` **não** dispara outro agente — no máximo comenta aqui.',
'',
`- Run vermelho: ${runUrl}`,
`- Run id: ${runId}`,
`- Commit: ${sha}`,
...(jobLines.length > 0 ? ['- Jobs com falha:', ...jobLines] : []),
...(logPath ? [`- Log do agente: \`${logPath}\``] : []),
'',
'O agente roda no homeserver com banco de teste local (`teqo_wt*`) e abre um PR `fix/*`.',
'Se ele terminar sem PR, esta Issue recebe o label `blocked` para intervenção humana.',
].join('\n')
}
/**
* Outcome classification after the agent wrapper exits.
* @param {{ exitCode?: number | null, pullRequest?: { htmlUrl?: string, number?: number } | null }} [options]
*/
export const classifyOutcome = ({ exitCode = null, pullRequest = null } = {}) =>
pullRequest ? { status: 'pr-opened', exitCode, pullRequest } : { status: 'no-pr', exitCode }
/**
* The report the wrapper prints in the token after a completed agent run.
* @param {{ status: string, exitCode?: number | null, branch?: string, pullRequest?: { htmlUrl?: string, number?: number } | null, logPath?: string }} options
*/
export const outcomeComment = ({
status,
exitCode = null,
branch = '',
pullRequest = null,
logPath = '',
}) =>
status === 'pr-opened'
? [
`Agente concluído (exit ${exitCode}). PR aberto: ${pullRequest?.htmlUrl ?? pullRequest?.number ?? ''}`,
'',
`Branch: \`${branch}\``,
`Log: \`${logPath}\``,
].join('\n')
: [
`Agente terminou SEM PR (exit ${exitCode}).`,
'',
`Branch: \`${branch}\``,
`Log: \`${logPath}\``,
'',
'Intervenção humana necessária: inspecione o log e a branch; o token fica aberto como `blocked`',
'e nenhuma nova falha do `verify` dispara agente enquanto ele estiver aberto.',
].join('\n')
/**
* Parse of the machine-readable directive `scripts/worktree.mjs --headless`
* writes to a file. Tolerant to surrounding blank lines; fail-high on a
* malformed payload (a new agent must never launch with an unknown dir/argv).
*/
export const parseHeadlessDirective = (text) => {
const line = String(text ?? '')
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean)
.at(-1)
if (!line) throw new Error('diretiva headless ausente')
const parsed = JSON.parse(line)
if (
!parsed ||
typeof parsed.dir !== 'string' ||
parsed.dir.length === 0 ||
typeof parsed.branch !== 'string' ||
parsed.branch.length === 0 ||
!Array.isArray(parsed.argv)
) {
throw new Error('diretiva headless inválida (dir/branch/argv)')
}
if (parsed.argv.length === 0) throw new Error('diretiva headless sem argv')
return parsed
}

View file

@ -22,6 +22,31 @@ export const dieWithLabel = (label) => (message) => {
export const sha256Hex = (buffer) => createHash('sha256').update(buffer).digest('hex')
/**
* `--flag=value` / `--flag` parser for the self-contained CLIs that use the
* equals form (the auto-unblock family). The historical `--flag value` parser
* lives in `agent-forgejo.mjs` (`parseArgs`); keeping the equals form here
* means an absent flag can never swallow the next argv as its value.
*
* @param {string[]} argv
* @returns {{ flags: Record<string, string | boolean>, positional: string[] }}
*/
export const parseEqualsFlags = (argv) => {
const flags = {}
const positional = []
for (const arg of Array.isArray(argv) ? argv : []) {
if (typeof arg !== 'string' || !arg.startsWith('--')) {
positional.push(arg)
continue
}
const body = arg.slice(2)
const equals = body.indexOf('=')
if (equals === -1) flags[body] = true
else flags[body.slice(0, equals)] = body.slice(equals + 1)
}
return { flags, positional }
}
/** The dotenv preamble every DB-touching script needs (.env.local wins over .env). */
export const loadCliEnv = () => {
const { config: loadEnv } = require('dotenv')

View file

@ -26,7 +26,7 @@ import { execFileSync } from 'node:child_process'
const SHARED_COMPOSE_PROJECT = 'teqo'
/** Compose names the project's service container `<project>-<service>-1`. */
const SHARED_POSTGRES_CONTAINER = 'teqo-postgres-1'
export const SHARED_POSTGRES_CONTAINER = 'teqo-postgres-1'
/** How long to wait for a peer's `starting` container before falling back to compose. */
const STARTING_WAIT_MS = 60_000

View file

@ -296,10 +296,15 @@ export const createApi = ({
}))
},
/** GET /pulls — normalized, GitHub PATCH uses `draft` not `is_draft`. */
listPullRequests: async ({ state = 'open', limit = 100 } = {}) => {
/**
* GET /pulls — normalized, GitHub PATCH uses `draft` not `is_draft`.
* `head` is the GitHub filter `owner:branch` (OPS106: the auto-unblock
* wrapper finds the agent's PR by the `fix/*` branch it created).
* @param {{ state?: string, limit?: number, head?: string }} [options]
*/
listPullRequests: async ({ state = 'open', limit = 100, head } = {}) => {
const pulls = await request(`/repos/${owner}/${name}/pulls`, {
query: { state, per_page: limit },
query: { state, head, per_page: limit },
})
return (Array.isArray(pulls) ? pulls : []).map(normalizePullRequest)
},
@ -336,6 +341,59 @@ export const createApi = ({
}))
},
/**
* GET /repos/{owner}/{repo}/actions/runs/{id}/jobs — normalized job list
* of a run (OPS106 gate: only a failed `verify` job earns an agent).
* Steps keep name+conclusion so the bug report names the failed step.
*/
getWorkflowRunJobs: async (runId) => {
const payload = await request(`/repos/${owner}/${name}/actions/runs/${runId}/jobs`, {
query: { per_page: 100 },
})
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : []
return jobs.map((job) => ({
id: job.id,
name: job.name ?? '',
status: job.status ?? '',
conclusion: job.conclusion ?? null,
htmlUrl: job.html_url ?? '',
steps: (Array.isArray(job.steps) ? job.steps : []).map((step) => ({
name: step.name ?? '',
conclusion: step.conclusion ?? null,
})),
}))
},
/** GET /labels/{name} — `null` when the label does not exist (404). */
getLabel: async (label) => {
const found = await request(`/repos/${owner}/${name}/labels/${encodeURIComponent(label)}`)
if (!found) return null
return { name: found.name ?? '', color: found.color ?? '' }
},
/** POST /labels — creates a repo label (idempotent callers probe first). */
createLabel: async (label, { color = '1D76DB', description = '' } = {}) => {
const created = await request(`/repos/${owner}/${name}/labels`, {
method: 'POST',
body: { name: label, color, description },
})
return { name: created.name ?? label, color: created.color ?? color }
},
/** GET /repos/{owner}/{repo} — normalized (permissions used by `--check`). */
getRepository: async () => {
const repoPayload = await request(`/repos/${owner}/${name}`)
return {
fullName: repoPayload.full_name ?? repo,
defaultBranch: repoPayload.default_branch ?? 'main',
permissions: {
admin: Boolean(repoPayload.permissions?.admin),
push: Boolean(repoPayload.permissions?.push),
pull: Boolean(repoPayload.permissions?.pull),
},
}
},
/** GET /git/ref/heads/{branch} — `{ ref, sha }`; `null` when the branch is gone (404). */
getBranchHead: async (branch = 'main') => {
const ref = await request(`/repos/${owner}/${name}/git/ref/heads/${branch}`)

View file

@ -232,6 +232,45 @@ export const opencodeLaunchDirective = ({
return `launch opencode ${args.join(' ')}`
}
/**
* Headless launch for the OPS106 auto-unblock wrapper: the same `/bug-fix`
* contract as `fix`, no TUI and no `--prompt` (the headless CLI has
* `--command` as a first-class option — the report rides as the message/args).
* The directive is written to a FILE, never stdout: the provisioning path
* inherits stdio from git/pnpm/seed children, so stdout cannot be a clean JSON
* channel. Emitted by `scripts/worktree.mjs fix --headless --directive <path>`.
*/
export const OPENCODE_HEADLESS_COMMAND = 'bug-fix'
/**
* opencode `run` argv for the auto-unblock agent (model + report, no shell).
* @param {{ model?: string, report?: string, command?: string }} [options]
*/
export const opencodeHeadlessArgs = ({
model,
report,
command = OPENCODE_HEADLESS_COMMAND,
} = {}) => {
if (typeof model !== 'string' || model.length === 0) {
throw new Error('opencodeHeadlessArgs: model ausente')
}
if (typeof report !== 'string' || report.trim().length === 0) {
throw new Error('opencodeHeadlessArgs: report vazio')
}
return ['opencode', 'run', '--model', model, '--auto', '--command', command, report]
}
/**
* Machine-readable `--headless` directive persisted for the wrapper.
* @param {{ dir?: string, branch?: string, model?: string, report?: string }} [options]
*/
export const headlessDirective = ({ dir, branch, model, report } = {}) => {
if (typeof dir !== 'string' || dir.length === 0) {
throw new Error('headlessDirective: dir ausente')
}
return { dir, branch: branch ?? '', model, argv: opencodeHeadlessArgs({ model, report }) }
}
/**
* Branch for a `/plan-issue` planning worktree — see `namespaceBranchName`
* (namespace `plans/plan-issue-…`, fallback label `plano`).

View file

@ -170,6 +170,7 @@ import {
import {
branchNameForIssue,
fixBranchName,
headlessDirective,
OPENCODE_PRESET_MODEL,
opencodeLaunchDirective,
planBranchName,
@ -661,9 +662,14 @@ const cmdNamespaceBranch = async ({
branchName,
flags = {},
argument = null,
headless = false,
directivePath = null,
}) => {
if (headless && !directivePath) {
die('`--headless` requer `--directive <path>` (a diretiva vai para arquivo, nunca stdout).')
}
// Fail-high on conflicting model flags before touching git.
resolveLaunchModel(flags)
const launchModel = resolveLaunchModel(flags)
git(['fetch', 'origin'])
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
@ -702,6 +708,15 @@ const cmdNamespaceBranch = async ({
console.log(` banco dev: postgresql://teqo:teqo@localhost:5432/${env.devDatabase}`)
console.log(` banco test: postgresql://teqo:teqo@localhost:5432/${env.testDatabase}`)
if (headless) {
// OPS106: the auto-unblock wrapper consumes this file; stdout stays human
// logs (git/pnpm/seed children inherit stdio and would corrupt a JSON line).
const directive = headlessDirective({ dir, branch, model: launchModel, report: argument ?? '' })
writeFileSync(directivePath, `${JSON.stringify(directive)}\n`)
console.log(`[worktree] diretiva headless escrita em ${directivePath}`)
return
}
if (!stay) {
printLaunchDirective({ dir, purpose, argument, flags })
console.log(`cd ${dir}`)
@ -758,8 +773,13 @@ const cmdNew = async (stay, skipMigrate, bag, flags = {}) =>
* launch prompt carries the bag as the skill's argument — the bug description
* arrives with `/bug-fix` (sanitized in the directive builder). Never claims
* nor creates Issues: the bug record is the skill's post-mortem.
*
* OPS106: `--headless --directive <path>` is the auto-unblock variant — it
* provisions exactly the same worktree and writes the machine-readable launch
* directive (opencode `run --command bug-fix`) to the file instead of printing
* the TUI launch/cd. The bug report arrives as the `bag`.
*/
const cmdFix = async (stay, skipMigrate, bag, flags = {}) =>
const cmdFix = async (stay, skipMigrate, bag, flags = {}, headless = false, directivePath = null) =>
cmdNamespaceBranch({
stay,
skipMigrate,
@ -769,6 +789,8 @@ const cmdFix = async (stay, skipMigrate, bag, flags = {}) =>
branchName: (taken) => fixBranchName({ bag, taken }),
flags,
argument: bag,
headless,
directivePath,
})
/** Generated database names referenced by a worktree's own env files. */
@ -852,7 +874,7 @@ const cmdKill = async (force) => {
console.log(`cd ${mainRoot}`)
}
const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['issue']))
const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['issue', 'directive']))
const subcommand = positional[0]
if (!subcommand) {
@ -911,7 +933,7 @@ if (!subcommand) {
' terminal, mesma diretiva `launch` porém sem --prompt (apenas conversar) e --model <map> quando a flag está presente',
)
console.log(
`\n fix [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
`\n fix [bag] [--stay] [--no-migrate] [--headless --directive <path>] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
)
console.log(' cria um worktree de CORREÇÃO DE BUG (skill /bug-fix) DIFERENTE a cada')
console.log(' invocação: com bag (a descrição do bug), branch fix/<bag> (sufixo -2/-3 se o')
@ -920,6 +942,13 @@ if (!subcommand) {
console.log(
' Issues — o registro do bug é o post-mortem da skill; no terminal, a diretiva `launch` envia --prompt "/bug-fix <bag>" (a descrição chega com a skill) e --model <map> quando a flag está presente',
)
console.log(
' OPS106: --headless --directive <path> provisiona o mesmo worktree e grava a diretiva',
)
console.log(
' machine-readable de launch (opencode run --command bug-fix) no arquivo, sem TUI/cd — é o',
)
console.log(' caminho do wrapper auto-unblock no homeserver.')
console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force),')
console.log(' remove os bancos gerados do worktree (best-effort) e imprime')
console.log(' `cd <main>` no fim — o shell sempre volta ao worktree principal')
@ -940,7 +969,14 @@ try {
else if (subcommand === 'new')
await cmdNew(Boolean(flags.stay), Boolean(flags['no-migrate']), positional[1], flags)
else if (subcommand === 'fix')
await cmdFix(Boolean(flags.stay), Boolean(flags['no-migrate']), positional[1], flags)
await cmdFix(
Boolean(flags.stay),
Boolean(flags['no-migrate']),
positional[1],
flags,
Boolean(flags.headless),
flags.directive ?? null,
)
else if (subcommand === 'kill') {
if (flags.stay) die('`--stay` não se aplica a `kill` — ele sempre volta ao main.')
await cmdKill(Boolean(flags.force))

View file

@ -0,0 +1,328 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildBugReport,
classifyDeployFailure,
classifyOutcome,
databaseUrlFromEnvText,
decideUnblock,
evaluateDatabaseTargets,
outcomeComment,
parseHeadlessDirective,
tokenBody,
tokenTitle,
UNBLOCK_LABEL,
UNBLOCK_TOKEN_TTL_MS,
} from '../../scripts/lib/auto-unblock.mjs'
import { parseEqualsFlags } from '../../scripts/lib/cli.mjs'
const job = (over: Record<string, unknown> = {}) => ({
name: 'verify',
conclusion: 'failure',
htmlUrl: 'https://github.com/fsolla/teqo/actions/runs/1/job/2',
steps: [
{ name: 'Lint', conclusion: 'success' },
{ name: 'Integration tests (full suite)', conclusion: 'failure' },
],
...over,
})
describe('decideUnblock (single-flight)', () => {
it('sem lock e sem token → start', () => {
expect(decideUnblock({ lockBusy: false, openToken: false })).toEqual({
action: 'start',
reason: 'clear',
})
})
it('lock livre + token órfão imaturo → skip_comment (não dispara outro)', () => {
expect(decideUnblock({ lockBusy: false, openToken: true, tokenAgeMs: 60_000 })).toEqual({
action: 'skip_comment',
reason: 'orphan-token-fresh',
})
})
it('lock livre + token órfão velho → reclaim', () => {
expect(
decideUnblock({
lockBusy: false,
openToken: true,
tokenAgeMs: UNBLOCK_TOKEN_TTL_MS,
}),
).toEqual({ action: 'reclaim', reason: 'orphan-token-stale' })
})
it('lock livre + idade desconhecida (createdAt ilegível) → reclaim (fail-closed)', () => {
expect(decideUnblock({ lockBusy: false, openToken: true, tokenAgeMs: Number.NaN })).toEqual({
action: 'reclaim',
reason: 'orphan-token-age-unknown',
})
expect(decideUnblock({ lockBusy: false, openToken: true, tokenAgeMs: null })).toEqual({
action: 'reclaim',
reason: 'orphan-token-age-unknown',
})
})
it('lock ocupado + token aberto → skip_comment com agente ativo (nunca enfileira)', () => {
for (const tokenAgeMs of [1_000, UNBLOCK_TOKEN_TTL_MS * 10]) {
expect(decideUnblock({ lockBusy: true, openToken: true, tokenAgeMs })).toEqual({
action: 'skip_comment',
reason: 'agent-active',
})
}
})
it('lock ocupado sem token → skip silencioso (corrida de lock)', () => {
expect(decideUnblock({ lockBusy: true, openToken: false })).toEqual({
action: 'skip',
reason: 'lock-raced',
})
})
})
describe('classifyDeployFailure (só o job verify interessa)', () => {
it('verify falhou → dispara, com jobs/steps falhos normalizados', () => {
const result = classifyDeployFailure([
{ name: 'preflight', conclusion: 'success', steps: [] },
job(),
])
expect(result.verifyFailed).toBe(true)
expect(result.failedJobs).toEqual([
{
name: 'verify',
url: 'https://github.com/fsolla/teqo/actions/runs/1/job/2',
steps: ['Integration tests (full suite)'],
},
])
})
it('staging vermelho com verify verde → NÃO dispara', () => {
const result = classifyDeployFailure([
{ name: 'verify', conclusion: 'success', steps: [] },
{ name: 'deploy staging (teqo-staging)', conclusion: 'failure', steps: [] },
])
expect(result.verifyFailed).toBe(false)
})
it('verify ausente/skipped/cancelled → NÃO dispara (fail-closed)', () => {
expect(classifyDeployFailure([]).verifyFailed).toBe(false)
expect(
classifyDeployFailure([{ name: 'verify', conclusion: 'skipped', steps: [] }]).verifyFailed,
).toBe(false)
expect(
classifyDeployFailure([{ name: 'verify', conclusion: 'cancelled', steps: [] }]).verifyFailed,
).toBe(false)
})
})
describe('evaluateDatabaseTargets (guard fail-closed do banco)', () => {
const dev = 'postgresql://teqo:teqo@localhost:5432/teqo_wt42'
const test = 'postgresql://teqo:teqo@localhost:5432/teqo_wt42_test'
it('bancos gerados locais do worktree → ok', () => {
expect(evaluateDatabaseTargets({ devUrl: dev, testUrl: test })).toEqual({
ok: true,
problems: [],
})
})
it('aceita 127.0.0.1 (host local)', () => {
expect(
evaluateDatabaseTargets({
devUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_wt7',
testUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_wt7_test',
}).ok,
).toBe(true)
})
it('.env.test.local precisa terminar em _test (não basta teqo_wtN)', () => {
const result = evaluateDatabaseTargets({
devUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_wt42',
testUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_wt42',
})
expect(result.ok).toBe(false)
expect(result.problems.join(' ')).toContain('_test')
})
it('fallback compartilhado (teqo/teqo_test) → falha (não é banco gerado)', () => {
const result = evaluateDatabaseTargets({
devUrl: 'postgresql://teqo:teqo@localhost:5432/teqo',
testUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_test',
})
expect(result.ok).toBe(false)
expect(result.problems.join(' ')).toContain('teqo_wt')
})
it('nome de produção/staging → falha mesmo em host local', () => {
const result = evaluateDatabaseTargets({
devUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_1313',
testUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_staging',
})
expect(result.ok).toBe(false)
expect(result.problems).toHaveLength(2)
})
it('host não-local → falha (nunca aponta para fora)', () => {
const result = evaluateDatabaseTargets({
devUrl: 'postgresql://teqo:teqo@10.20.30.40:5432/teqo_wt42',
testUrl: 'postgresql://teqo:teqo@db.example.com:5432/teqo_wt42_test',
})
expect(result.ok).toBe(false)
expect(result.problems.join(' ')).toContain('não-local')
})
it('portas 5433/5434 (proxies do stack) → falha por defesa em profundidade', () => {
const result = evaluateDatabaseTargets({
devUrl: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_wt42',
testUrl: 'postgresql://teqo:teqo@127.0.0.1:5434/teqo_wt42_test',
})
expect(result.ok).toBe(false)
expect(result.problems.join(' ')).toContain('proxy')
})
it('URL ausente/inparseável → falha', () => {
expect(evaluateDatabaseTargets({}).ok).toBe(false)
expect(
evaluateDatabaseTargets({ devUrl: 'não é url', testUrl: test }).problems.join(' '),
).toContain('inparseável')
})
})
describe('databaseUrlFromEnvText', () => {
it('extrai o DATABASE_URL do texto do env gerado', () => {
const text =
'# marker\nPORT=3142\nDATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_wt42\n'
expect(databaseUrlFromEnvText(text)).toBe('postgresql://teqo:teqo@localhost:5432/teqo_wt42')
})
it('sem DATABASE_URL → null', () => {
expect(databaseUrlFromEnvText('PORT=3000\n')).toBeNull()
})
})
describe('buildBugReport (handoff headless)', () => {
const report = buildBugReport({
runUrl: 'https://github.com/fsolla/teqo/actions/runs/123',
runId: '123',
sha: 'abcdef1234567890',
failedJobs: [{ name: 'verify', steps: ['Lint'] }],
})
it('carrega run/SHA/jobs e o comando de logs', () => {
expect(report).toContain('https://github.com/fsolla/teqo/actions/runs/123')
expect(report).toContain('abcdef1234567890')
expect(report).toContain('- verify (steps com falha: Lint)')
expect(report).toContain('gh run view 123 --log-failed')
})
it('proíbe prod/staging e avisa que não há humano', () => {
expect(report).toContain('NÃO há humano disponível')
expect(report).toContain('teqo_1313')
expect(report).toContain('ALLOW_REMOTE_DB')
expect(report).toContain('Não feche a Issue do disparo')
})
})
describe('token do single-flight', () => {
it('título e corpo linkam o run e explicam a regra de não enfileirar', () => {
expect(tokenTitle({ runId: '123', sha: 'abcdef1234567890' })).toBe(
'Auto-unblock: verify falhou no deploy run 123 (abcdef1)',
)
const body = tokenBody({
runUrl: 'https://github.com/fsolla/teqo/actions/runs/123',
runId: '123',
sha: 'abcdef1234567890',
failedJobs: [{ name: 'verify' }],
logPath: '/home/x/teqo-unblock/logs/unblock-123.log',
})
expect(body).toContain('não** dispara outro agente')
expect(body).toContain('https://github.com/fsolla/teqo/actions/runs/123')
expect(body).toContain('`blocked`')
})
it('o label do token não entra na fila de claim', () => {
expect(UNBLOCK_LABEL).toBe('auto-unblock')
expect(UNBLOCK_LABEL).not.toBe('ready')
expect(UNBLOCK_LABEL).not.toBe('in-progress')
})
})
describe('classifyOutcome', () => {
it('PR encontrado → pr-opened', () => {
const pr = { number: 1, htmlUrl: 'https://x/1' }
expect(classifyOutcome({ exitCode: 0, pullRequest: pr })).toEqual({
status: 'pr-opened',
exitCode: 0,
pullRequest: pr,
})
})
it('sem PR → no-pr (gate humano)', () => {
expect(classifyOutcome({ exitCode: 1 })).toEqual({ status: 'no-pr', exitCode: 1 })
})
it('outcomeComment cobre os dois desfechos', () => {
expect(
outcomeComment({
status: 'pr-opened',
exitCode: 0,
branch: 'fix/x',
pullRequest: { htmlUrl: 'https://x/1' },
logPath: '/tmp/log',
}),
).toContain('https://x/1')
expect(
outcomeComment({ status: 'no-pr', exitCode: 143, branch: 'fix/x', logPath: '/tmp/log' }),
).toContain('SEM PR')
})
})
describe('parseHeadlessDirective', () => {
const valid = JSON.stringify({
dir: '/home/x/teqo-unblock/worktrees/fix/abc',
branch: 'fix/abc',
model: 'deepseek/deepseek-flash',
argv: [
'opencode',
'run',
'--model',
'deepseek/deepseek-flash',
'--auto',
'--command',
'bug-fix',
'x',
],
})
it('lê a última linha JSON não vazia', () => {
expect(parseHeadlessDirective(`\n${valid}\n`).branch).toBe('fix/abc')
})
it('falha fechado em payload inválido (sem dir, sem branch, sem argv)', () => {
expect(() => parseHeadlessDirective('')).toThrow(/ausente/)
expect(() => parseHeadlessDirective('{não json}')).toThrow()
expect(() => parseHeadlessDirective(JSON.stringify({ dir: '/x' }))).toThrow(/inválida/)
expect(() =>
parseHeadlessDirective(JSON.stringify({ dir: '/x', branch: '', argv: ['opencode'] })),
).toThrow(/inválida/)
expect(() =>
parseHeadlessDirective(JSON.stringify({ dir: '/x', branch: 'fix/a', argv: [] })),
).toThrow(/argv/)
})
})
describe('parseEqualsFlags (cli compartilhado)', () => {
it('separa --flag=valor, --flag booleano e posicionais', () => {
const { flags, positional } = parseEqualsFlags(['--state=/tmp/s.json', '--check', 'extra'])
expect(flags).toEqual({ state: '/tmp/s.json', check: true })
expect(positional).toEqual(['extra'])
})
it('valor vazio não engole o próximo argv', () => {
const parsed = parseEqualsFlags(['--token=', '42'])
expect(parsed.flags).toEqual({ token: '' })
expect(parsed.positional).toEqual(['42'])
})
})

View file

@ -0,0 +1,129 @@
// @vitest-environment node
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..')
/** YAML with comment-only lines stripped — header prose must never satisfy a pin. */
const readWithoutComments = (relativePath: string) =>
readFileSync(join(repoRoot, relativePath), 'utf8')
.split('\n')
.filter((line) => !line.trimStart().startsWith('#'))
.join('\n')
const workflow = readWithoutComments('.github/workflows/auto-unblock.yml')
const deployWorkflow = readWithoutComments('.github/workflows/deploy.yml')
const shell = readFileSync(join(repoRoot, 'scripts', 'auto-unblock.sh'), 'utf8')
const agent = readFileSync(join(repoRoot, 'scripts', 'auto-unblock-agent.mjs'), 'utf8')
const orchestrator = readFileSync(join(repoRoot, 'scripts', 'auto-unblock.mjs'), 'utf8')
describe('auto-unblock.yml (OPS106 reactor)', () => {
it('is triggered only by a completed Deploy run', () => {
expect(workflow).toContain('workflow_run:')
expect(workflow).toContain("workflows: ['Deploy']")
expect(workflow).toContain('types: [completed]')
})
it('gates on push to main and conclusion failure', () => {
expect(workflow).toContain("github.event.workflow_run.conclusion == 'failure'")
expect(workflow).toContain("github.event.workflow_run.event == 'push'")
expect(workflow).toContain("github.event.workflow_run.head_branch == 'main'")
})
it('runs on the homeserver runner with the least permissions it needs', () => {
expect(workflow).toContain('runs-on: [self-hosted, homeserver]')
expect(workflow).toContain('actions: read')
expect(workflow).toContain('issues: write')
})
it('checks out without persisted bot credentials and dispatches the shell entrypoint', () => {
expect(workflow).toContain('persist-credentials: false')
expect(workflow).toContain('bash scripts/auto-unblock.sh')
expect(workflow).toContain('AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}')
expect(workflow).toContain('RUN_ID: ${{ github.event.workflow_run.id }}')
})
it('installs repo deps before the launcher (the detached wrapper loads worktree.mjs)', () => {
expect(workflow).toContain('pnpm install --frozen-lockfile')
})
it('keeps the Deploy workflow untouched (no auto-retry, no reactor wiring)', () => {
expect(deployWorkflow).not.toContain('unblock')
expect(deployWorkflow).not.toContain('workflow_run')
})
it('pins the deploy job id the reactor keys on (display name would break the gate)', () => {
// The jobs API reports the job `name` (id when no display name is set);
// classifyDeployFailure keys on exactly `verify`. A `name:` inside the job
// block would change that value and silently disable the reactor.
expect(deployWorkflow).toMatch(/^ verify:$/m)
const verifyBlock = deployWorkflow
.split(/\n(?= [A-Za-z0-9_.-]+:)/)
.find((block) => block.startsWith(' verify:'))
expect(verifyBlock, 'job verify não encontrado no deploy.yml').toBeDefined()
expect(verifyBlock).not.toMatch(/^ name:/m)
})
it('the token never enters the claim queue (no ready/in-progress label)', () => {
expect(orchestrator).not.toContain("'in-progress'")
expect(orchestrator).not.toContain("'ready'")
expect(orchestrator).toContain('UNBLOCK_LABEL')
expect(orchestrator).toContain('UNBLOCK_BLOCKED_LABEL')
})
})
describe('scripts/auto-unblock.sh (flock handoff)', () => {
it('is valid bash', () => {
expect(() =>
execFileSync('bash', ['-n', join(repoRoot, 'scripts', 'auto-unblock.sh')], {
stdio: 'pipe',
}),
).not.toThrow()
})
it('holds the agent-only lock and hands fd 9 to the orchestrator', () => {
expect(shell).toContain('exec 9>"$LOCK_FILE"')
expect(shell).toContain('flock -n 9')
expect(shell).toContain('exec node scripts/auto-unblock.mjs')
// The deploy lock must never be reused: the agent cannot block a deploy,
// and a deploy cannot be mistaken for the agent.
expect(shell).not.toContain('teqo-deploy.lock')
expect(shell).toContain('/tmp/teqo-unblock.lock')
})
})
describe('scripts/auto-unblock-agent.mjs (detached wrapper)', () => {
it('provisions through the worktree owner in headless mode', () => {
expect(agent).toContain("'worktree.mjs'")
expect(agent).toContain("'--headless'")
expect(agent).toContain("'--directive'")
})
it('enforces the database guard before launching opencode', () => {
const guardIndex = agent.indexOf('const targets = evaluateDatabaseTargets(')
const launchIndex = agent.indexOf('const agentRun = runTimed(')
expect(guardIndex).toBeGreaterThan(-1)
expect(launchIndex).toBeGreaterThan(guardIndex)
})
it('receives the token number by argv (blocked comment works without state)', () => {
expect(agent).toContain('flags.token')
expect(orchestrator).toContain('`--token=${created.number}`')
})
it('injects the PAT only via the process env (no persisted git credential)', () => {
expect(agent).toContain("'credential.helper'")
expect(agent).toContain('password=$GITHUB_TOKEN')
expect(agent).not.toContain('git config')
})
it('caps the opencode run with escalation to SIGKILL', () => {
expect(agent).toContain("'--kill-after=120'")
expect(agent).toContain('AGENT_TIMEOUT_SECONDS')
})
})

View file

@ -168,6 +168,118 @@ describe('github-api (issue tracker layer)', () => {
await expect(api.listWorkflowRuns('deploy.yml')).resolves.toEqual([])
})
it('getWorkflowRunJobs normalizes jobs and their steps (OPS106 verify gate)', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init })
return ok({
jobs: [
{
id: 2,
name: 'verify',
status: 'completed',
conclusion: 'failure',
html_url: 'https://github.com/fsolla/teqo/actions/runs/1/job/2',
steps: [
{ name: 'Lint', conclusion: 'success' },
{ name: 'Integration tests (full suite)', conclusion: 'failure' },
],
},
],
})
},
})
const jobs = await api.getWorkflowRunJobs(1)
expect(calls[0].url).toContain('/actions/runs/1/jobs?per_page=100')
expect(jobs).toEqual([
{
id: 2,
name: 'verify',
status: 'completed',
conclusion: 'failure',
htmlUrl: 'https://github.com/fsolla/teqo/actions/runs/1/job/2',
steps: [
{ name: 'Lint', conclusion: 'success' },
{ name: 'Integration tests (full suite)', conclusion: 'failure' },
],
},
])
})
it('getWorkflowRunJobs tolerates a missing jobs array', async () => {
const api = createApi({ token: 'tok', fetchImpl: async () => ok({}) })
await expect(api.getWorkflowRunJobs(1)).resolves.toEqual([])
})
it('getLabel returns null on 404 and normalizes an existing label', async () => {
const missing = createApi({
token: 'tok',
retries: 0,
fetchImpl: async () => ok({ message: 'Not Found' }, 404),
})
await expect(missing.getLabel('auto-unblock')).resolves.toBeNull()
const found = createApi({
token: 'tok',
fetchImpl: async () => ok({ name: 'auto-unblock', color: '1D76DB' }),
})
await expect(found.getLabel('auto-unblock')).resolves.toEqual({
name: 'auto-unblock',
color: '1D76DB',
})
})
it('createLabel POSTs name/color/description', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init })
return ok({ name: 'auto-unblock', color: '1D76DB' })
},
})
await api.createLabel('auto-unblock', { color: '1D76DB', description: 'agente de desbloqueio' })
expect(calls[0].url).toContain('/labels')
expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({
name: 'auto-unblock',
color: '1D76DB',
description: 'agente de desbloqueio',
})
})
it('listPullRequests forwards the head filter (owner:branch)', async () => {
const calls: FetchCall[] = []
const api = createApi({
token: 'tok',
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init })
return ok([])
},
})
await api.listPullRequests({ state: 'all', head: 'fsolla:fix/abc' })
expect(calls[0].url).toContain('state=all')
expect(calls[0].url).toContain('head=fsolla%3Afix%2Fabc')
})
it('getRepository normalizes the push permission (--check)', async () => {
const api = createApi({
token: 'tok',
fetchImpl: async () =>
ok({
full_name: 'fsolla/teqo',
default_branch: 'main',
permissions: { admin: false, push: true, pull: true },
}),
})
await expect(api.getRepository()).resolves.toEqual({
fullName: 'fsolla/teqo',
defaultBranch: 'main',
permissions: { admin: false, push: true, pull: true },
})
})
it('getBranchHead reads and normalizes the branch ref', async () => {
const calls: FetchCall[] = []
const api = createApi({

View file

@ -18,9 +18,12 @@ import {
branchNameForIssue,
FIX_BRANCH_PREFIX,
fixBranchName,
headlessDirective,
issueCodeAndSubject,
OPENCODE_HEADLESS_COMMAND,
OPENCODE_PRESET_MODEL,
OPENCODE_SKILL_COMMAND_BY_PURPOSE,
opencodeHeadlessArgs,
opencodeLaunchDirective,
PLAN_BRANCH_PREFIX,
planBranchName,
@ -288,6 +291,46 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33
})
})
describe('opencodeHeadlessArgs + headlessDirective (OPS106 auto-unblock)', () => {
it('builds the opencode run argv with the bug-fix command and the report as one arg', () => {
expect(
opencodeHeadlessArgs({ model: 'deepseek/deepseek-flash', report: 'run 1 falhou' }),
).toEqual([
'opencode',
'run',
'--model',
'deepseek/deepseek-flash',
'--auto',
'--command',
OPENCODE_HEADLESS_COMMAND,
'run 1 falhou',
])
})
it('defaults the command to bug-fix and allows an override', () => {
expect(OPENCODE_HEADLESS_COMMAND).toBe('bug-fix')
expect(opencodeHeadlessArgs({ model: 'm', report: 'r', command: 'x' })).toContain('x')
})
it('fails high on a missing model or empty report', () => {
expect(() => opencodeHeadlessArgs({ model: '', report: 'r' })).toThrow(/model/)
expect(() => opencodeHeadlessArgs({ model: 'm', report: ' ' })).toThrow(/report/)
})
it('headlessDirective carries dir/branch/model and the argv', () => {
const directive = headlessDirective({
dir: '/work/fix/abc',
branch: 'fix/abc',
model: 'deepseek/deepseek-flash',
report: 'r',
})
expect(directive.dir).toBe('/work/fix/abc')
expect(directive.branch).toBe('fix/abc')
expect(directive.argv[0]).toBe('opencode')
expect(() => headlessDirective({ model: 'm', report: 'r' })).toThrow(/dir/)
})
})
describe('resolveWorktreeModel + WORKTREE_MODEL_MAP (OPS93 menu, OPS95 values, OPS100 glm/free)', () => {
it('pins the 7-flag map — cheap/pro/zen/go/alibaba/glm/free', () => {
expect(WORKTREE_MODEL_MAP).toEqual({