From 68e91ff37d5d961e72c7789b41d1c278a9b4bd60 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 14 Sep 2026 05:35:13 -0300 Subject: [PATCH] =?UTF-8?q?feat(OPS106):=20falha=20do=20verify=20dispara?= =?UTF-8?q?=20agente=20aut=C3=B4nomo=20de=20desbloqueio=20(single-flight)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/auto-unblock.yml | 77 ++++ AGENTS-infra.md | 2 + docs/AGENT-OPS.md | 4 +- docs/changelog/2026-09-14-ops106.md | 1 + docs/ops/teqo-1313-deploy.md | 89 +++- ...6-auto-unblock-verify-falha-agente-impl.md | 248 ++++++++++++ package.json | 1 + scripts/auto-unblock-agent.mjs | 204 ++++++++++ scripts/auto-unblock.mjs | 382 ++++++++++++++++++ scripts/auto-unblock.sh | 25 ++ scripts/lib/auto-unblock.mjs | 270 +++++++++++++ scripts/lib/cli.mjs | 25 ++ scripts/lib/db-start.mjs | 2 +- scripts/lib/github-api.mjs | 64 ++- scripts/lib/worktree.mjs | 39 ++ scripts/worktree.mjs | 46 ++- tests/unit/autoUnblock.unit.spec.ts | 328 +++++++++++++++ tests/unit/autoUnblockWorkflow.unit.spec.ts | 129 ++++++ tests/unit/githubApi.unit.spec.ts | 112 +++++ tests/unit/worktree.unit.spec.ts | 43 ++ 20 files changed, 2078 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/auto-unblock.yml create mode 100644 docs/changelog/2026-09-14-ops106.md create mode 100644 docs/plans/ops106-auto-unblock-verify-falha-agente-impl.md create mode 100644 scripts/auto-unblock-agent.mjs create mode 100644 scripts/auto-unblock.mjs create mode 100755 scripts/auto-unblock.sh create mode 100644 scripts/lib/auto-unblock.mjs create mode 100644 tests/unit/autoUnblock.unit.spec.ts create mode 100644 tests/unit/autoUnblockWorkflow.unit.spec.ts diff --git a/.github/workflows/auto-unblock.yml b/.github/workflows/auto-unblock.yml new file mode 100644 index 00000000..3cd2beea --- /dev/null +++ b/.github/workflows/auto-unblock.yml @@ -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 }} diff --git a/AGENTS-infra.md b/AGENTS-infra.md index 4678d3cd..90556378 100644 --- a/AGENTS-infra.md +++ b/AGENTS-infra.md @@ -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 diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 051fad08..f1c461e3 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -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/`, 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`. diff --git a/docs/changelog/2026-09-14-ops106.md b/docs/changelog/2026-09-14-ops106.md new file mode 100644 index 00000000..0ba3ec67 --- /dev/null +++ b/docs/changelog/2026-09-14-ops106.md @@ -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-.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`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index ae899136..62b1d1c6 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -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-` → 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-.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 — diff --git a/docs/plans/ops106-auto-unblock-verify-falha-agente-impl.md b/docs/plans/ops106-auto-unblock-verify-falha-agente-impl.md new file mode 100644 index 00000000..c3608b09 --- /dev/null +++ b/docs/plans/ops106-auto-unblock-verify-falha-agente-impl.md @@ -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
workflow_run [Deploy]
push + main + failure"} + R -- "não" --> N["nada (skip)"] + R -- "sim" --> J{"jobs do run:
verify falhou?"} + J -- "não (staging/prod)" --> N + J -- "sim" --> L{"flock -n + decideUnblock
(token auto-unblock aberto?)"} + L -- "lock ocupado / token recente" --> C["comenta o run no token
(não enfileira)"] + L -- "token órfão ≥ TTL" --> K["fecha o token antigo"] --> S + L -- "livre" --> S["cria token Issue
(auto-unblock + run URL + SHA)"] + S -. "PAT ausente" .-> B["token blocked
(gate humano)"] + S --> W["wrapper destacado
flock herdado (fd 9)"] + W --> P["worktree fix --headless
(fix/… + teqo_wt* + migrate + seed)"] + P --> G{"guard DB
local + teqo_wt* / *_test?"} + G -- "não" --> B2["token blocked + log
exit 1"] + G -- "sim" --> A["opencode run --command bug-fix
env sem DATABASE_URL/ALLOW_REMOTE_DB
timeout 4h"] + A --> O{"PR da branch existe?"} + O -- "sim" --> OK["comenta link + fecha token"] + O -- "não" --> B3["label blocked + path do log
(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 --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 --headless` (cwd = checkout, `WORKTREES_ROOT=$HOME/teqo-unblock/worktrees`). O caminho é o mesmo de hoje (`cmdNamespaceBranch`: `git fetch origin` → branch `fix/` fresco de `origin/main` → `worktree add` → `provision` com `teqo_wt`/`teqo_wt_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",""]}`, 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-.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:', 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=` 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 --auto --command bug-fix ` (cwd = worktree), com `report` montado por helper puro; B) mensagem literal `"/bug-fix "`; 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 --headless` capturando stdout (stderr → log) e faz `JSON.parse` da diretiva; `evaluateDatabaseTargets` (fail-closed); lança `timeout 4h ` 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-.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`. diff --git a/package.json b/package.json index 6bb5f8e8..d807ef80 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/auto-unblock-agent.mjs b/scripts/auto-unblock-agent.mjs new file mode 100644 index 00000000..15d7dc72 --- /dev/null +++ b/scripts/auto-unblock-agent.mjs @@ -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 `) — 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= e --token= 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)) +} diff --git a/scripts/auto-unblock.mjs b/scripts/auto-unblock.mjs new file mode 100644 index 00000000..7ad54837 --- /dev/null +++ b/scripts/auto-unblock.mjs @@ -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)) +} diff --git a/scripts/auto-unblock.sh b/scripts/auto-unblock.sh new file mode 100755 index 00000000..dfb0e008 --- /dev/null +++ b/scripts/auto-unblock.sh @@ -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" diff --git a/scripts/lib/auto-unblock.mjs b/scripts/lib/auto-unblock.mjs new file mode 100644 index 00000000..549063d2 --- /dev/null +++ b/scripts/lib/auto-unblock.mjs @@ -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[_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[_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 +} diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs index 92794311..4e4b32ec 100644 --- a/scripts/lib/cli.mjs +++ b/scripts/lib/cli.mjs @@ -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, 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') diff --git a/scripts/lib/db-start.mjs b/scripts/lib/db-start.mjs index 68afc264..d044a344 100644 --- a/scripts/lib/db-start.mjs +++ b/scripts/lib/db-start.mjs @@ -26,7 +26,7 @@ import { execFileSync } from 'node:child_process' const SHARED_COMPOSE_PROJECT = 'teqo' /** Compose names the project's service container `--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 diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index 7aca7a98..c9e7f63d 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -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}`) diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs index aae602b2..270caead 100644 --- a/scripts/lib/worktree.mjs +++ b/scripts/lib/worktree.mjs @@ -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 `. + */ +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`). diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index a419a203..f99738c6 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -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 ` (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 ` 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 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 ] [--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/ (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 " (a descrição chega com a skill) e --model quando a flag está presente', ) + console.log( + ' OPS106: --headless --directive 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
` 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)) diff --git a/tests/unit/autoUnblock.unit.spec.ts b/tests/unit/autoUnblock.unit.spec.ts new file mode 100644 index 00000000..07b6498f --- /dev/null +++ b/tests/unit/autoUnblock.unit.spec.ts @@ -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 = {}) => ({ + 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']) + }) +}) diff --git a/tests/unit/autoUnblockWorkflow.unit.spec.ts b/tests/unit/autoUnblockWorkflow.unit.spec.ts new file mode 100644 index 00000000..eb65cddd --- /dev/null +++ b/tests/unit/autoUnblockWorkflow.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/githubApi.unit.spec.ts b/tests/unit/githubApi.unit.spec.ts index a178649c..51f1920b 100644 --- a/tests/unit/githubApi.unit.spec.ts +++ b/tests/unit/githubApi.unit.spec.ts @@ -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({ diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index 32f20802..6c8ad0da 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -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({