Merge pull request #624 from fsolla/OPS37-container-postgres-compartilhado-e-recriado-por-outro-

fix(OPS37): db:start idempotente — não recria o container Postgres compartilhado
This commit is contained in:
Francisco Solla 2026-08-11 00:54:38 +00:00 committed by GitHub
commit e86331fe4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 464 additions and 14 deletions

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md).
**Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:<base>` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md).
**Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue <N>` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue <N>` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md).

View file

@ -0,0 +1,86 @@
# Impl: Container Postgres compartilhado é recriado por outro worktree — mata dev+e2e de todos em voo
Status: aprovado
Atualizado em: 2026-08-10
Issue: #605
Intenção: docs/plans/container-postgres-compartilhado-compose-replace.md
Appetite restante: herdado (~0,5 dia eng; um outcome verificável)
## Leitura da intenção
- **Outcome:** `pnpm db:start` com `teqo-postgres-1` saudável é no-op (não recria, não derruba conexões de outros worktrees); recriação legítima segue possível por comando explícito; nada de CI/produção muda.
- **O que NÃO negociar:** sem serialização/locks de agentes; sem tocar em containers de outros projetos; fix no **owner** do container (`db:start`), não na educação dos agentes.
- **O que reavaliar:** a pergunta em aberto da intenção — "por que o config_hash divergiu?" — foi **respondida com evidência** (ver abaixo): é o bind mount relativo `./docker/postgres/init`, único campo que difere entre worktrees. Isto descarta o fix alternativo "pinar o compose para não depender do workdir" como frágil; o fix no `db:start` cobre o mecanismo inteiro.
## Diagnóstico (verificado em 2026-08-10)
`docker-compose.yml` linha 27: `- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro` — bind mount **relativo**, resolvido por compose contra o diretório do projeto (o root do worktree). Medido:
- `docker compose -p teqo config --hash postgres` → OPS37: `39e89956…`; B195: `cf3cb522…`; C101: `917e11bb…` — três worktrees, três hashes.
- `docker compose -p teqo config` diff OPS37×B195: **única** diferença é `source:` do bind mount (path absoluto do worktree).
- Container atual roda com o hash do OPS37 (`com.docker.compose.config-hash` label) — qualquer `db:start` de outro worktree → hash difere → compose faz `replace` → `terminating connection due to administrator command` para todos.
O bind mount só importa na **primeira** inicialização do volume (`01-create-test-db.sql` cria `teqo_test`); depois é letra morta — mas compose não sabe disso e o inclui no hash.
## Abordagem recomendada
```mermaid
flowchart LR
A[pnpm db:start / worktree provisioning] --> B{container Up + healthy?}
B -- sim --> C[no-op: nada a recriar]
B -- não / --force-recreate --> D[compose -p teqo up -d --wait postgres]
D --> E[container de pé, nenhuma conexão viva foi derrubada]
```
**Opções consideradas:** A | B | C
**Recomendação:** **A** — preflight "container saudável → skip do compose" no owner `db:start` (novo `scripts/db-start.mjs` + lib compartilhada usada também pelo provisioning do worktree). Porque: ataca exatamente o caso perigoso (recriar container **de pé** com conexões vivas); quando o container não está de pé (missing/exited), compose up é seguro e continua o único caminho; dá mensagem clara ao operador; cobre os dois call sites do compose (`db:start` e `dockerComposeUp` do `worktree.mjs`, que tem o MESMO risco de replace durante `pnpm worktree next`).
**Rejeitadas:**
- **B — "pinar o bind mount"** (ex.: path absoluto para o main repo): resolveria o hash, mas injeta path do host no compose commitado — quebra portabilidade (main repo pode morar em outro caminho/máquina) e o bind é read-only só usado no primeiro init; se o path sumir, o container sobe com init vazio em silêncio. Frágil demais para um problema de 1 linha no script.
- **C — só `--no-recreate` no compose up**: comportamento correto no skip, mas silencioso — quando o config muda de verdade (bump de imagem), o operador não descobre que o container está stale, e o caminho explícito fica menos óbvio; além disso não dá diagnóstico quando algo mais está errado (container unhealthy). O preflight de A é o `--no-recreate` com luzes.
- ~~Remover o bind mount e criar `teqo_test` via `docker exec createdb` pós-start~~: espalha o bootstrap, quebra a garantia "só no primeiro init do volume", e a mudança de compose toca todos os worktrees de uma vez.
### Componentes / mudanças
- **`scripts/lib/db-start.mjs`** (novo): núcleo compartilhado —
- `shouldSkipStart({ running, health })` → puro: `true` quando `running && health ∈ {healthy, starting}` (skip; `starting` evita o race de substituir um container que um worktree vizinho acabou de criar).
- `parsePostgresContainerHealth(jsonText)` → puro: `docker inspect --format '{{json .State}}'` → `{ running, health }` (`health` vazio = container antigo sem healthcheck → **não** skip: compose up migra de volta para a config atual).
- `waitForHealthyPostgres({ timeoutMs, intervalMs })` → espera um container `starting` ficar `healthy` (bounded 60s/2s; timeouts injetáveis para os testes); `false` se sumir/virar `unhealthy`/estourar o tempo → cai no compose up.
- `startSharedPostgres({ cwd, forceRecreate })` → async: inspeciona `teqo-postgres-1`; skip com mensagem quando `healthy` (ou espera o boot de um `starting`) e `!forceRecreate`; senão `docker compose -p teqo up -d [--force-recreate] --wait --wait-timeout 120 postgres` com `cwd` (a lib não assume o root — o provisioning passa o dir do worktree). Docker inacessível → lança (o CLI imprime remédio; o `worktree.mjs` já try/catch → fallback env).
- **`scripts/db-start.mjs`** (novo, CLI): `--force-recreate` (recriação explícita) e `--help`; args desconhecidos → usage + exit 2; cwd = `process.cwd()` (compose file do worktree atual); erro de docker → mensagem estilo `db-doctor` ("Docker looks unreachable — is Docker running?") + exit 1. Sem `--force-recreate`, container saudável → `[db] teqo-postgres-1 already up (healthy) — nothing to recreate.` exit 0.
- **`package.json`**: `"db:start": "node scripts/db-start.mjs"` (substitui o raw compose up). `db:stop`/`db:doctor` intactos.
- **`scripts/worktree.mjs`**: `dockerComposeUp` inlined — o `provision()` chama `await startSharedPostgres({ cwd: dir })` direto — mesmo preflight no provisioning (idempotência de `worktree next`); `SHARED_COMPOSE_PROJECT` órfão removido (agora const privada da lib).
- **`tests/unit/dbStart.unit.spec.ts`** (novo): `shouldSkipStart` (matrix running×health), `parsePostgresContainerHealth` (healthy/starting/unhealthy/sem healthcheck/Health null/malformado), e o wrapper `startSharedPostgres`/`waitForHealthyPostgres` com `vi.mock('node:child_process')` (skip não chama compose; container ausente → compose up com argv correto; `--force-recreate` bypassa; `starting` → espera → healthy; `starting` travado/unhealthy/sumiu → false) — seguindo o padrão de `worktree.unit.spec.ts` (importa direto de `scripts/lib/*.mjs`, `// @vitest-environment node`).
- **Docs:** `AGENTS.md` → bullet "Database & local development": `db:start` é idempotente (no-op com container saudável; `pnpm db:start --force-recreate` para recriação explícita). `docs/CHANGELOG-AGENTS.md` → uma entrada curta.
- **Migration:** nenhuma. **Access/Consent:** n/a. **UI:** n/a.
### Dados → forma (se aplicável)
n/a — sem dados novos; o contrato `StartedAt` inalterado é a métrica de aceite (verificação com `docker inspect`).
## Fases verificáveis
1. **Núcleo + wiring** — `scripts/lib/db-start.mjs` + `scripts/db-start.mjs` + `package.json` + delegação em `worktree.mjs`. Verificação: `pnpm db:start` com container saudável imprime o no-op e `docker inspect teqo-postgres-1 --format '{{.State.StartedAt}}'` fica **igual** antes/depois; `docker compose -p teqo config --hash postgres` do worktree == hash do container (estado atual já é esse).
2. **Unit tests** — `tests/unit/dbStart.unit.spec.ts` (17 casos: matrix pura + wrapper mockado); rodar via `pnpm test:unit` (ou o alvo unit).
3. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, unit/int. `--force-recreate` é verificado pela flag passando ao compose (execução live opcional, avisando o humano — recriar derruba sessões de outros worktrees por ~2s).
## Rabbit holes / Não escopo (engenharia)
- Não mexer em `docker-compose.yml` (mantém compat: qualquer worktree continua podendo rodar compose up cru — agora inofensivo para os outros? **não**: compose up cru de outro worktree ainda recria. O escopo é o owner (`db:start`/provisioning); compose up manual é caminho do operador, e o `--force-recreate`/docs o tornam explícito).
- Não tocar em `db:stop` (parada explícita é intenção do operador).
- Não serializar/lockar agentes (anti-goal da intenção).
- Race raro: container ausente e dois worktrees rodam `db:start` simultâneo → ambos sobem compose; o segundo pode recriar o primeiro (sem conexões vivas ainda — inofensivo). Documentado, não corrigido.
## Riscos e mitigação
- **Container `starting` de um peer**: o `startSharedPostgres` espera o boot ficar `healthy` (60s, polling 2s) em vez de recriar; se travou (nunca healthy), cai no compose up com `--wait-timeout 120` — falha alta, sem hang infinito. `--force-recreate`/`db:doctor` seguem como escape.
- **Container antigo sem healthcheck**: `health` vazio → não skip → compose up migra para a config atual (uma recria única, sem conexões? pode ter — caso raro e legítimo; o plano da intenção aceita recriação quando a config mudou de verdade).
- **`worktree next` com docker inacessível**: comportamento preservado (throw → try/catch → fallback env).
- **Knip/águeiro**: `scripts/db-start.mjs` é entry point (não exporta nada); `scripts/lib/db-start.mjs` tem consumidores reais (`db-start.mjs` + `worktree.mjs` + o spec) — nada órfão.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (no-op com saudável; recreate explícito; sem CI/prod; sem locks)
- [x] Invariantes AGENTS/engineering-standards (idempotência; sem path absoluto no compose; owner do container)
- [x] Testes de domínio previstos: unit das decisões puras + wrapper mockado (17 casos)
- [x] Verificação live: `StartedAt` inalterado após `pnpm db:start` repetido (inclusive de outro worktree com hash diferente)

View file

@ -22,7 +22,7 @@
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"db:start": "docker compose -p teqo up -d --wait postgres",
"db:start": "node scripts/db-start.mjs",
"db:stop": "docker compose -p teqo down",
"db:doctor": "node scripts/db-doctor.mjs",
"db:pull": "node scripts/db-pull.mjs",

55
scripts/db-start.mjs Normal file
View file

@ -0,0 +1,55 @@
/**
* `pnpm db:start` — starts the ONE shared local Postgres container
* (`compose -p teqo`), or does nothing when it is already up and healthy.
*
* The no-op guard matters because the container is shared by every worktree
* (see scripts/lib/db-start.mjs): a plain `compose up` from another worktree
* recreates it and kills every live dev/e2e connection. Recreate on purpose:
* `pnpm db:start --force-recreate` (image bump / config change).
*/
import { pathToFileURL } from 'node:url'
import { startSharedPostgres } from './lib/db-start.mjs'
const USAGE = `Usage: pnpm db:start [--force-recreate]
Starts the shared local Postgres container (compose project "teqo").
No-op when the container is already up and healthy.
Options:
--force-recreate Recreate the container even when it is healthy
(apply a real compose/image change on purpose).
-h, --help Show this help.`
const main = async () => {
const args = process.argv.slice(2)
if (args.includes('--help') || args.includes('-h')) {
console.log(USAGE)
process.exit(0)
}
const unknown = args.filter((arg) => arg !== '--force-recreate')
if (unknown.length > 0) {
console.error(`[db] Unknown option: ${unknown.join(', ')}`)
console.error(USAGE)
process.exit(2)
}
const forceRecreate = args.includes('--force-recreate')
try {
await startSharedPostgres({ cwd: process.cwd(), forceRecreate })
} catch (error) {
if (error.code === 'ENOENT') {
console.error('[db] Docker looks unreachable — is Docker running?')
console.error('[db] Start Docker and retry: pnpm db:start')
} else {
const message = error.stderr?.toString()?.trim() || error.message
console.error(`[db] Failed to start the local Postgres: ${message.split('\n')[0]}`)
}
process.exit(1)
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
await main()
}

136
scripts/lib/db-start.mjs Normal file
View file

@ -0,0 +1,136 @@
/**
* Starts the ONE shared local Postgres container (`compose -p teqo`) from any
* worktree, WITHOUT recreating it when it is already up and serving.
*
* Why the guard exists (OPS37, 2026-08-10): docker-compose.yml binds the
* init scripts via a RELATIVE path (`./docker/postgres/init`), so compose
* resolves a different absolute mount source per worktree and computes a
* different `config_hash` for the same pinned project. A plain
* `docker compose -p teqo up` from any other worktree therefore decides the
* running container is stale and REPLACES it — killing every live connection
* (dev servers and e2e runs of ALL worktrees die with `terminating connection
* due to administrator command`). The bind mount only matters on the FIRST
* volume initialization; after that it is dead weight in the hash.
*
* The container must be immutable while healthy: `db:start` and worktree
* provisioning skip compose entirely when `teqo-postgres-1` is running and
* healthy — or, when a peer worktree is still booting it (`starting`), wait
* for that boot instead of replacing the container mid-healthcheck.
* Legitimate recreation (image bump, config change) stays possible via an
* explicit `--force-recreate`.
*/
import { execFileSync } from 'node:child_process'
/** Fixed compose project so `db:start`/provisioning always target ONE container. */
const SHARED_COMPOSE_PROJECT = 'teqo'
/** Compose names the project's service container `<project>-<service>-1`. */
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
/** Poll interval while waiting for a `starting` container. */
const STARTING_POLL_INTERVAL_MS = 2_000
/**
* Pure decision: skip compose entirely when the shared container is already
* up and serving (`healthy`), or being brought up by a peer worktree right
* now (`starting` — replacing it then would be a pointless race; we wait for
* that boot instead). Anything else (stopped, unhealthy, no healthcheck)
* falls through to compose up, which is safe: no live connections exist to
* kill, and a pre-healthcheck container gets migrated back to the current
* config.
*
* @returns {boolean} true when compose must NOT run.
*/
export const shouldSkipStart = ({ running, health }) =>
running === true && (health === 'healthy' || health === 'starting')
/**
* Parse `docker inspect --format '{{json .State}}'` output into
* `{ running, health }`. `health` is empty when the container predates the
* compose healthcheck (no `Health` object) — that never skips, so `db:start`
* brings the container back to the current config exactly once.
*
* @returns {{ running: boolean, health: string } | null} null on unparseable output.
*/
export const parsePostgresContainerHealth = (jsonText) => {
try {
const state = JSON.parse(jsonText)
return {
running: state.Running === true,
health: typeof state.Health?.Status === 'string' ? state.Health.Status : '',
}
} catch {
return null
}
}
/**
* Inspect the shared container's state. Returns null when it does not exist
* (compose will create it). Throws when Docker itself is unreachable — the
* caller decides the remedy (CLI prints one; worktree provisioning falls back
* to the shared databases).
*
* @returns {{ running: boolean, health: string } | null}
*/
const inspectSharedPostgres = () => {
try {
const output = execFileSync(
'docker',
['inspect', '--format', '{{json .State}}', SHARED_POSTGRES_CONTAINER],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
)
return parsePostgresContainerHealth(output)
} catch (error) {
const stderr = error.stderr?.toString() ?? ''
if (stderr.includes('No such object')) return null
throw error
}
}
/**
* Wait for a `starting` container to become `healthy`, bounded. Returns true
* only when it is serving; false when it disappears, turns `unhealthy`, or
* the timeout elapses — callers then fall back to compose up (no live
* connections exist to kill on a container that never became healthy).
* Timeouts are injectable so unit tests run without real waits.
*/
export const waitForHealthyPostgres = async ({
timeoutMs = STARTING_WAIT_MS,
intervalMs = STARTING_POLL_INTERVAL_MS,
} = {}) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, intervalMs))
const state = inspectSharedPostgres()
if (!state) return false
if (state.health === 'healthy') return true
if (state.health === 'unhealthy') return false
}
return false
}
/**
* Start (or reuse) the shared Postgres container from `cwd` — a no-op when it
* is already up and serving, unless `forceRecreate` explicitly asks for a
* recreate (image bump / config change). Creates the container when missing,
* starts it when stopped, and waits for a peer's fresh container instead of
* replacing it mid-healthcheck — always with `--wait` for the healthcheck.
*/
export const startSharedPostgres = async ({ cwd, forceRecreate = false }) => {
const state = inspectSharedPostgres()
if (!forceRecreate && state && shouldSkipStart(state)) {
if (state.health === 'healthy' || (await waitForHealthyPostgres())) {
console.log(`[db] ${SHARED_POSTGRES_CONTAINER} already up (healthy) — nothing to recreate.`)
return
}
}
const args = ['compose', '-p', SHARED_COMPOSE_PROJECT, 'up', '-d']
if (forceRecreate) args.push('--force-recreate')
args.push('--wait', '--wait-timeout', '120', 'postgres')
execFileSync('docker', args, { cwd, stdio: 'inherit' })
}

View file

@ -111,6 +111,7 @@ import {
nextClaimableIssue,
parseArgs,
} from './lib/agent-github.mjs'
import { startSharedPostgres } from './lib/db-start.mjs'
import {
DEV_PORT_BASE,
GENERATED_ENV_MARKER,
@ -143,8 +144,6 @@ const printLaunchDirective = ({ dir, purpose, issueNumber }) => {
if (line) console.log(line)
}
/** Fixed compose project so `db:start`/provisioning always target ONE container. */
const SHARED_COMPOSE_PROJECT = 'teqo'
/** Maintenance database on the shared local container — only ever localhost. */
const MAINTENANCE_DATABASE_URL = 'postgresql://teqo:teqo@localhost:5432/postgres'
@ -221,15 +220,6 @@ const envFileIsGenerated = (dir, name) => {
}
}
/** Start (or reuse) the ONE shared Postgres container from any worktree dir. */
const dockerComposeUp = (dir) => {
execFileSync(
'docker',
['compose', '-p', SHARED_COMPOSE_PROJECT, 'up', '-d', '--wait', 'postgres'],
{ cwd: dir, stdio: 'inherit' },
)
}
/** `CREATE DATABASE` for each missing generated name on the shared container. */
const ensureDatabases = async (names) => {
const client = new pg.Client({
@ -332,7 +322,8 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpo
const payloadSecret = mainEnv.PAYLOAD_SECRET ?? randomBytes(24).toString('hex')
try {
dockerComposeUp(dir)
// Start (or reuse) the ONE shared Postgres container from any worktree dir.
await startSharedPostgres({ cwd: dir })
} catch (error) {
console.warn('[worktree] Docker indisponível — usando o banco compartilhado (teqo/teqo_test).')
console.warn(`[worktree] (${error.message.split('\n')[0]})`)

View file

@ -0,0 +1,180 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
type ExecArgs = [command: string, args: string[], options: { cwd: string }]
const execFileSyncMock = vi.fn<(...args: ExecArgs) => unknown>()
vi.mock('node:child_process', () => ({
execFileSync: (...args: ExecArgs) => execFileSyncMock(...args),
}))
import {
parsePostgresContainerHealth,
shouldSkipStart,
startSharedPostgres,
waitForHealthyPostgres,
} from '../../scripts/lib/db-start.mjs'
/** The `docker inspect --format '{{json .State}}'` shape, not a custom one. */
const stateJson = ({ running = true, health = 'healthy' } = {}) =>
JSON.stringify({
Status: running ? 'running' : 'exited',
Running: running,
...(health ? { Health: { Status: health } } : {}),
})
/** Throws exactly what docker does when the container does not exist. */
const throwNoSuchObject = () => {
const error = new Error('No such object') as Error & { stderr: Buffer }
error.stderr = Buffer.from('No such object: teqo-postgres-1')
throw error
}
describe('shouldSkipStart', () => {
it('skips when the container is running and healthy', () => {
expect(shouldSkipStart({ running: true, health: 'healthy' })).toBe(true)
})
it('skips when the container is running and still starting (peer just created it)', () => {
expect(shouldSkipStart({ running: true, health: 'starting' })).toBe(true)
})
it('does not skip when the container is unhealthy', () => {
expect(shouldSkipStart({ running: true, health: 'unhealthy' })).toBe(false)
})
it('does not skip when the container predates the healthcheck (no Health object)', () => {
expect(shouldSkipStart({ running: true, health: '' })).toBe(false)
})
it('does not skip when the container is stopped', () => {
expect(shouldSkipStart({ running: false, health: 'healthy' })).toBe(false)
})
})
describe('parsePostgresContainerHealth', () => {
it('parses a running healthy container', () => {
expect(parsePostgresContainerHealth(stateJson({ health: 'healthy' }))).toEqual({
running: true,
health: 'healthy',
})
})
it('parses a stopped container without a Health object', () => {
expect(parsePostgresContainerHealth(stateJson({ running: false, health: '' }))).toEqual({
running: false,
health: '',
})
})
it('parses a running container without a Health object (legacy pre-healthcheck container)', () => {
expect(parsePostgresContainerHealth(stateJson({ health: '' }))).toEqual({
running: true,
health: '',
})
})
it('treats an explicit null Health as no healthcheck', () => {
expect(
parsePostgresContainerHealth('{"Status":"running","Running":true,"Health":null}'),
).toEqual({
running: true,
health: '',
})
})
it('returns null on unparseable output', () => {
expect(parsePostgresContainerHealth('not json')).toBeNull()
expect(parsePostgresContainerHealth('')).toBeNull()
})
})
describe('startSharedPostgres', () => {
beforeEach(() => {
execFileSyncMock.mockReset()
})
const composeArgs = () =>
execFileSyncMock.mock.calls.find((call) => call[1][0] === 'compose')?.[1]
it('is a no-op when the container is already healthy (never touches compose)', async () => {
execFileSyncMock.mockReturnValue(stateJson({ health: 'healthy' }))
await startSharedPostgres({ cwd: '/repo' })
expect(execFileSyncMock.mock.calls).toHaveLength(1)
expect(execFileSyncMock.mock.calls[0][1]).toEqual([
'inspect',
'--format',
'{{json .State}}',
'teqo-postgres-1',
])
})
it('runs compose up when the container does not exist', async () => {
execFileSyncMock.mockImplementationOnce(throwNoSuchObject).mockReturnValueOnce('')
await startSharedPostgres({ cwd: '/repo' })
expect(composeArgs()).toEqual([
'compose',
'-p',
'teqo',
'up',
'-d',
'--wait',
'--wait-timeout',
'120',
'postgres',
])
const composeCall = execFileSyncMock.mock.calls.find((call) => call[1][0] === 'compose')
expect(composeCall?.[2].cwd).toBe('/repo')
})
it('force-recreate bypasses the healthy skip', async () => {
execFileSyncMock.mockReturnValue(stateJson({ health: 'healthy' })).mockReturnValueOnce('')
await startSharedPostgres({ cwd: '/repo', forceRecreate: true })
expect(composeArgs()).toEqual([
'compose',
'-p',
'teqo',
'up',
'-d',
'--force-recreate',
'--wait',
'--wait-timeout',
'120',
'postgres',
])
})
})
describe('waitForHealthyPostgres', () => {
beforeEach(() => {
execFileSyncMock.mockReset()
})
it('waits for a starting container and returns true when it becomes healthy', async () => {
execFileSyncMock
.mockReturnValueOnce(stateJson({ health: 'starting' }))
.mockReturnValue(stateJson({ health: 'healthy' }))
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(true)
})
it('falls back (false) when a starting container never becomes healthy', async () => {
execFileSyncMock.mockReturnValue(stateJson({ health: 'starting' }))
expect(await waitForHealthyPostgres({ timeoutMs: 10, intervalMs: 1 })).toBe(false)
})
it('falls back (false) when the container turns unhealthy', async () => {
execFileSyncMock
.mockReturnValueOnce(stateJson({ health: 'starting' }))
.mockReturnValue(stateJson({ health: 'unhealthy' }))
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(false)
})
it('falls back (false) when the container disappears', async () => {
execFileSyncMock
.mockReturnValueOnce(stateJson({ health: 'starting' }))
.mockImplementation(throwNoSuchObject)
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(false)
})
})