mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(OPS110): sessões persistentes de agente com attach/detach (Closes #1019)
This commit is contained in:
parent
93416cd5ef
commit
ab9c164615
17 changed files with 1983 additions and 151 deletions
|
|
@ -6,23 +6,28 @@
|
|||
# na última linha, porque um processo filho (node) não consegue mudar o cwd do
|
||||
# shell que o chamou.
|
||||
#
|
||||
# No terminal (esta função), o script também imprime a diretiva `launch
|
||||
# opencode <dir> --model <preset|map> --auto [--prompt "…"]` (OPS26+OPS33+OPS93+OPS95):
|
||||
# No terminal (esta função), o script também imprime a diretiva `launch node
|
||||
# scripts/agent-session.mjs start --purpose=<next|plan|new|fix> --dir=<dir>
|
||||
# --model=<preset|map> [--issue=<N>] [--argument="<bag>"]` (OPS26+OPS33+OPS93+OPS95+OPS110):
|
||||
# a função executa o cd e então a linha (tokenizada por xargs — honra as aspas
|
||||
# do prompt, nunca eval), e o TUI do opencode abre no worktree — `next` com
|
||||
# `/work-issue --issue <N>` já enviado (OPS33: a Issue claimada vai no prompt),
|
||||
# `plan` com `/plan-issue` já enviado (OPS31), `fix` com `/bug-fix <bag>` já
|
||||
# enviado (a descrição do bug vai no prompt — aspas/barra-invertida do bag são
|
||||
# removidas porque o xargs não honra escapes), `new` sem prompt (apenas
|
||||
# conversar). Sem `--variant` (OPS95: o yargs do TUI rejeita o flag e só
|
||||
# imprime o helper; variantes ficam na config global da máquina, via Ctrl+T).
|
||||
# Modelo por invocação: `--cheap` (cheapestinference/deepseek-v4-flash),
|
||||
# `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free),
|
||||
# `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) no
|
||||
# mapa fixo `WORKTREE_MODEL_MAP` (OPS93 menu; valores OPS95); sem flag o preset `deepseek/deepseek-flash`
|
||||
# do argumento, nunca eval). Desde a OPS110 o launch não abre um TUI local dono
|
||||
# da sessão: o `agent-session` sobe/reaproveita o `opencode serve` compartilhado,
|
||||
# cria a sessão endereçável, dispara o driver destacado (que auto-submete
|
||||
# `/work-issue --issue <N>` no `next`, `/plan-issue` no `plan`, `/bug-fix <bag>`
|
||||
# no `fix`; `new` abre só a sessão) e ANEXA o TUI (`opencode attach -s <id>`) —
|
||||
# fechar o terminal NÃO encerra o run; reentre com `pnpm agent:session attach` e
|
||||
# encerre com `pnpm agent:session stop`. Sem `--variant` (OPS95: o yargs do TUI
|
||||
# rejeita o flag e só imprime o helper; variantes ficam na config global da
|
||||
# máquina, via Ctrl+T). Modelo por invocação: `--cheap`
|
||||
# (cheapestinference/deepseek-v4-flash), `--pro` (deepseek/deepseek-v4-pro),
|
||||
# `--zen` (opencode-go/ox-alpha-free), `--go` (opencode-go/hy3), `--alibaba`
|
||||
# (alibaba-token-plan/deepseek-v4-flash), `--glm` (opencode-go/glm-5.3-flash),
|
||||
# `--free` (openrouter/openrouter/free) no mapa fixo `WORKTREE_MODEL_MAP`
|
||||
# (OPS93 menu; valores OPS95); sem flag o preset `deepseek/deepseek-flash`
|
||||
# permanece. Sem `exec` de propósito: ao sair do opencode, o terminal volta ao
|
||||
# shell dentro do worktree. Presets são constantes em scripts/lib/worktree.mjs;
|
||||
# o marcador TEQO_WORKTREE_TERMINAL=1 é o que separa esta superfície da do comando
|
||||
# o ciclo de vida das sessões vive em scripts/agent-session.mjs. O marcador
|
||||
# TEQO_WORKTREE_TERMINAL=1 é o que separa esta superfície da do comando
|
||||
# `/worktree` do opencode (que nunca lança TUI). `--stay` suprime cd e launch.
|
||||
#
|
||||
# Instalação (uma linha no profile; requer bash ou zsh — usa BASH_SOURCE, arrays e here-strings):
|
||||
|
|
@ -96,11 +101,12 @@ worktree() {
|
|||
|
||||
# Diretiva `launch` (só existe quando TEQO_WORKTREE_TERMINAL=1 e sem --stay): o
|
||||
# script a gera a partir de constantes + dir slugificado (sem espaços), e desde
|
||||
# o OPS33 o valor do `--prompt` carrega espaço e vem CITADO (`"/work-issue
|
||||
# --issue <N>"`) — o split por IFS=' ' não honra aspas, então a tokenização usa
|
||||
# xargs (processa aspas duplas como um shell, NÃO é eval; o conteúdo é 100%
|
||||
# gerado por constantes + número, sem input livre). Falha do launch (ex.:
|
||||
# opencode fora do PATH) só avisa: o worktree já está pronto e utilizável.
|
||||
# o OPS33 os valores com espaço vêm CITADOS (`--argument="<bag>"`; OPS110 —
|
||||
# antes era o `--prompt "/work-issue --issue <N>"`) — o split por IFS=' ' não
|
||||
# honra aspas, então a tokenização usa xargs (processa aspas duplas como um
|
||||
# shell, NÃO é eval; o conteúdo é 100% gerado por constantes + número, sem
|
||||
# input livre). Falha do launch (ex.: node/opencode fora do PATH) só avisa: o
|
||||
# worktree já está pronto e utilizável.
|
||||
local launch
|
||||
launch="$(printf '%s\n' "$out" | sed -n 's/^launch //p' | tail -n 1)"
|
||||
if [ -n "$launch" ]; then
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op
|
|||
|
||||
**Default-go:** `next`, `plan` e `new` imprimem `cd <dir>` na última linha **por padrão**; `--stay` suprime; modelos por invocação via `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` (mapa fixo `WORKTREE_MODEL_MAP`, at-most-one; sem flag o preset permanece); `--go` remapeado em OPS93 para o provider OpenCode Go e em OPS95 para `opencode-go/hy3` (antes no-op do OPS24). No terminal interativo, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile) para o `cd` ser aplicado de verdade no shell que te chamou — node não consegue mudar o cwd do shell pai. `kill` também imprime `cd <main>` no fim (a sessão nunca fica num diretório destruído).
|
||||
|
||||
**Launch do opencode (OPS26 + OPS33 + OPS31 + OPS93 + OPS95):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode <dir> --model <preset|map> --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` já enviado (a Issue claimada vai no prompt; a skill lê o resto do GitHub); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `--variant` na diretiva (OPS95: o yargs do TUI rejeita o flag desconhecido e só imprime o helper — variantes ficam na config global do opencode, selecionáveis via Ctrl+T; `opencode run --variant max` segue sendo a via CLI para runs headless). Modelo por invocação via `--cheap` (cheapestinference/deepseek-v4-flash), `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free), `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) — valores corrigidos no OPS95 — e `--glm` (opencode-go/glm-5.3-flash, OPS100), `--free` (openrouter/openrouter/free, OPS100); sem flag o preset `deepseek/deepseek-flash` permanece. Sem `exec`: sair do opencode volta ao shell no worktree. Presets e mapa são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
|
||||
**Launch do opencode (OPS26 + OPS33 + OPS31 + OPS93 + OPS95 + OPS110):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new`/`fix` imprimem também a diretiva `launch node <abs>/scripts/agent-session.mjs start --purpose=<next|plan|new|fix> --dir=<dir> --model <preset|map> [--issue <N>] [--argument "<bag>"]` **antes** do `cd`, e a função a executa depois do cd. Desde a OPS110 o launch não abre um TUI dono da sessão: o `agent-session` sobe/reaproveita o `opencode serve` compartilhado, cria a sessão endereçável, dispara o driver destacado (auto-submete `/work-issue --issue <N>` no `next`, `/plan-issue` no `plan`, `/bug-fix <bag>` no `fix`; `new` abre só a sessão) e anexa o TUI (`opencode attach -s <id>`) — fechar o terminal **não** encerra o run; reentre com `pnpm agent:session attach` e encerre com `pnpm agent:session stop` (ver a seção "Sessões persistentes" de `docs/AGENT-OPS.md`). Sem `--variant` na diretiva (OPS95: o yargs do TUI rejeita o flag desconhecido e só imprime o helper — variantes ficam na config global do opencode, selecionáveis via Ctrl+T; `opencode run --variant max` segue sendo a via CLI para runs headless). Modelo por invocação via `--cheap` (cheapestinference/deepseek-v4-flash), `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free), `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) — valores corrigidos no OPS95 — e `--glm` (opencode-go/glm-5.3-flash, OPS100), `--free` (openrouter/openrouter/free, OPS100); sem flag o preset `deepseek/deepseek-flash` permanece. Sem `exec`: sair do opencode volta ao shell no worktree. Presets e mapa são constantes em `scripts/lib/worktree.mjs`; o ciclo de vida das sessões (serve/start/attach/stop/list) vive em `scripts/agent-session.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
|
||||
|
||||
## Fluxo quando invocado como skill (agentes que não têm opencode)
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op
|
|||
- **Branch `<code>-<slug>`**: `code` = `id` do frontmatter; `slug` = título pt-BR via `src/lib/slug.ts` (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`.
|
||||
- **Base `origin/main`** (com `git fetch` antes); dir em `~/.cursor/worktrees/teqo/<branch>`.
|
||||
- **Provisiona o ambiente isolado do worktree** (determinístico, ver "Ambiente provisionado"): dev server na porta `3100+slot` e bancos próprios `teqo_wt<slot>` / `teqo_wt<slot>_test` no container compartilhado (`-p teqo`), com migrations aplicadas — agentes em paralelo não disputam porta 3000 nem o `teqo_test`. `--no-migrate` pula as migrations (bancos criados, envs escritos).
|
||||
- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch; remove os bancos gerados do worktree (best-effort).
|
||||
- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; **encerra a sessão persistente do run (OPS110: abort + fim do driver; estado/lock removidos — sem sessão órfã)**; remove e apaga o branch; remove os bancos gerados do worktree (best-effort).
|
||||
- **NUNCA usa `--force` no `add`**, não inventa código de Issue, e nunca desfaz claim (reverter `in-progress` → `ready` é outra entrega).
|
||||
|
||||
## Ambiente provisionado (leia a saída do script)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ Rode o script determinístico abaixo (não improvise a lógica) e reporte o resu
|
|||
- `next [--issue N] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: **claima** a próxima Issue claimável da fila (mesma fila/ordem e lock otimista de `pnpm agent:claim`) e cria (ou reutiliza) o worktree dela. Claim falhou → reporte o motivo — não há worktree. `--issue N` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress`, sem re-claim). A saída avisa "já claimada — NÃO rodar `pnpm agent:claim`". Por padrão o script imprime `cd <dir>` na última linha — aplique-o na sessão de shell atual (`cd <dir>`) e reporte o novo diretório. `--stay` suprime o `cd` (não troque de diretório) mas o claim ainda acontece. `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` escolhe o modelo por invocação no mapa fixo `WORKTREE_MODEL_MAP` (cheap=cheapestinference/deepseek-v4-flash, pro=deepseek/deepseek-v4-pro, zen=opencode-go/ox-alpha-free, go=opencode-go/hy3, alibaba=alibaba-token-plan/deepseek-v4-flash, glm=opencode-go/glm-5.3-flash, free=openrouter/openrouter/free); at-most-one (múltiplas → erro).
|
||||
- `plan [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree de planejamento **diferente a cada invocação** (branch `plans/plan-issue-<bag>`, ou `plans/plan-issue-<n>` sequencial sem bag; sufixo `-2`/`-3` em colisão) para rodar `/plan-issue` sem ocupar o main; nunca conflita com `next`. Não claima nada. Mesmo contrato de `cd` do `next`: aplique a última linha `cd <dir>` por padrão; `--stay` suprime; flag de modelo como no `next`.
|
||||
- `new [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree **neutro** (sem função pré-definida — explorar ideia, conversar, planejar sem registrar nada) **diferente a cada invocação** (branch `work/<bag>`, ou `work/<n>` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `work/…` nunca conflita com `next` nem `plan`. Não claima nada. Mesmo contrato de `cd` do `plan`: aplique a última linha `cd <dir>` por padrão; `--stay` suprime; flag de modelo como no `next`.
|
||||
- `fix [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree de **correção de bug** (skill `/bug-fix`) **diferente a cada invocação** (branch `fix/<bag>`, ou `fix/<n>` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `fix/…` nunca conflita com `next`, `plan` nem `new`. **Não claima nem cria Issues** — o registro do bug é o post-mortem da skill. O bag é a descrição do bug: no terminal, o launch envia `--prompt "/bug-fix <bag>"` (a descrição chega com a skill). Mesmo contrato de `cd` do `plan`: aplique a última linha `cd <dir>` por padrão; `--stay` suprime; flag de modelo como no `next`.
|
||||
- `kill [--force]`: destrói o worktree em que você está e remove seus bancos. O script imprime `cd <main>` na última linha — aplique-o (`cd <main>`) para a sessão sair do diretório destruído.
|
||||
- `fix [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree de **correção de bug** (skill `/bug-fix`) **diferente a cada invocação** (branch `fix/<bag>`, ou `fix/<n>` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `fix/…` nunca conflita com `next`, `plan` nem `new`. **Não claima nem cria Issues** — o registro do bug é o post-mortem da skill. O bag é a descrição do bug: no terminal, o launch carrega `--argument="<bag>"` e o driver destacado auto-submete `/bug-fix <bag>` (a descrição chega com a skill). Mesmo contrato de `cd` do `plan`: aplique a última linha `cd <dir>` por padrão; `--stay` suprime; flag de modelo como no `next`.
|
||||
- `kill [--force]`: destrói o worktree em que você está, encerra a sessão persistente do run (OPS110) e remove seus bancos. O script imprime `cd <main>` na última linha — aplique-o (`cd <main>`) para a sessão sair do diretório destruído.
|
||||
|
||||
> **Este comando roda SEM o marcador `TEQO_WORKTREE_TERMINAL`, então nunca recebe a diretiva `launch`** — só o `cd`. O launch do opencode (OPS26/OPS33+OPS93+OPS95, sem `--variant` — o TUI rejeita o flag) acontece apenas na função `worktree()` de `.agents/shell/worktree.sh` (terminal interativo); nunca abra um TUI aninhado a partir daqui.
|
||||
> **Este comando roda SEM o marcador `TEQO_WORKTREE_TERMINAL`, então nunca recebe a diretiva `launch`** — só o `cd`. O launch (OPS26/OPS33+OPS93+OPS95+**OPS110**: delega ao `scripts/agent-session.mjs`, que sobe o servidor compartilhado, dispara o driver destacado e anexa o TUI — fechar o terminal não encerra o run) acontece apenas na função `worktree()` de `.agents/shell/worktree.sh` (terminal interativo); nunca abra um TUI aninhado a partir daqui.
|
||||
|
||||
!`node scripts/worktree.mjs $ARGUMENTS`
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the
|
|||
|
||||
- Local dev runs against a local Docker Postgres (`pnpm db:start`). To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data).
|
||||
- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. **`db:start` is idempotent (OPS37):** with `teqo-postgres-1` up and healthy it is a no-op — a plain `compose up` from another worktree would recreate the shared container and kill every live dev/e2e connection (the compose file's relative `./docker/postgres/init` bind mount makes the config hash differ per worktree); apply a real config/image change on purpose with `pnpm db:start --force-recreate`.
|
||||
- **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue <N>` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim"` (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt<slot>` (dev) and `teqo_wt<slot>_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/`S3_*` (all-or-nothing)/VAPID from the main repo — plus the gitignored `.opencode/secrets/*` (e.g. `penpot-token`, referenced by the repo's `opencode.json` penpot/postgres MCPs — gated to subagents `penpot`/`postgres` via `permission`), without which opencode refuses to launch ("bad file reference" for the dangling `{file:…}`). **`next`/`plan`/`new` print `cd <dir>` as the last line BY DEFAULT** — the caller applies it... (line truncated to 2000 chars)
|
||||
- **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue <N>` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim"` (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt<slot>` (dev) and `teqo_wt<slot>_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/`S3_*` (all-or-nothing)/VAPID from the main repo — plus the gitignored `.opencode/secrets/*` (e.g. `penpot-token`, referenced by the repo's `opencode.json` penpot/postgres MCPs — gated to subagents `penpot`/`postgres` via `permission`), without which opencode refuses to launch ("bad file reference" for the dangling `{file:…}`). **`next`/`plan`/`new` print `cd <dir>` as the last line BY DEFAULT** — the caller applies it... (line truncated to 2000 chars) **OPS110 (sessões persistentes):** no terminal, `next`/`plan`/`new`/`fix` delegam o launch a `scripts/agent-session.mjs` — o run vive no `opencode serve` compartilhado (loopback `127.0.0.1:4199`, `TEQO_AGENT_SERVER_PORT`) com driver destacado (`--auto`) e TUI anexável (`opencode attach -s <sessionID>`); fechar o terminal não encerra (encerrar é `pnpm agent:session stop`; `worktree kill` encerra a sessão do worktree antes de removê-lo) e `list --json` é o endereçamento do OPS109; bind fora do loopback exige `OPENCODE_SERVER_PASSWORD` (fail-closed, sem `--mdns`).
|
||||
- To (repopulate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" in `AGENTS-public.md`.
|
||||
- To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data" in `AGENTS-campaign.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -72,4 +72,4 @@ src/
|
|||
|
||||
## Per-worktree environments
|
||||
|
||||
`pnpm worktree next` provisions isolated envs per worktree: dev port `3100+slot`, databases `teqo_wt<slot>` / `teqo_wt<slot>_test` on the shared container with full migration chain + minimal seed, `.env.local` + `.env.test.local` (gitignored), copies of `PAYLOAD_SECRET`/`S3_*`/VAPID from main repo. Same provisioning for `plan [bag]`, `new [bag]` and `fix [bag]` worktrees (`plans/plan-issue-…`, `work/…`, `fix/…` namespaces — `fix` opens `/bug-fix` with the bug description delivered; no claim, no Issue). The skill `local-database` covers the full workflow.
|
||||
`pnpm worktree next` provisions isolated envs per worktree: dev port `3100+slot`, databases `teqo_wt<slot>` / `teqo_wt<slot>_test` on the shared container with full migration chain + minimal seed, `.env.local` + `.env.test.local` (gitignored), copies of `PAYLOAD_SECRET`/`S3_*`/VAPID from main repo. Same provisioning for `plan [bag]`, `new [bag]` and `fix [bag]` worktrees (`plans/plan-issue-…`, `work/…`, `fix/…` namespaces — `fix` opens `/bug-fix` with the bug description delivered; no claim, no Issue). No terminal, o launch de `next`/`plan`/`new`/`fix` roda a sessão no `opencode serve` compartilhado e anexa o TUI (OPS110): fechar o terminal **não** encerra o run — reentre com `pnpm agent:session attach` e encerre com `pnpm agent:session stop` (estado/endereçamento em `~/.local/state/teqo/agent-sessions`; contrato do painel OPS109). The skill `local-database` covers the full workflow.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ main → push dispara deploy.yml sozinho (ou workflow_dispatch manual) → prefl
|
|||
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (GitHub) |
|
||||
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
|
||||
| `pnpm worktree fix [bug] [--stay]` | Worktree de correção de bug (`fix/<slug>`, skill `/bug-fix` — o launch envia a descrição do bug); não claima nem cria Issues |
|
||||
| `pnpm agent:session <serve\|start\|attach\|stop\|list> [--json]` | Sessões persistentes do run (OPS110 — attach/detach; ver seção abaixo) |
|
||||
| `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/`) |
|
||||
|
|
@ -57,6 +58,24 @@ Labels (GitHub): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `
|
|||
|
||||
**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`.
|
||||
|
||||
## Sessões persistentes (attach/detach — OPS110)
|
||||
|
||||
O launch do terminal (`worktree next|plan|new|fix`) **não abre mais um TUI dono da sessão**: ele chama `pnpm agent:session start …`, que sobe/reaproveita o `opencode serve` compartilhado, cria a sessão endereçável, dispara o **driver destacado** (`opencode run --attach … --auto --command <skill>`, que auto-aprova permissões enquanto vive) e **anexa** o TUI (`opencode attach -s <sessionID>`). Fechar o terminal/TUI desconecta — o run continua; encerrar é ato explícito.
|
||||
|
||||
| Comando | Faz |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `pnpm agent:session serve [--hostname=H] [--port=P]` | Sobe/reaproveita o servidor (loopback `127.0.0.1:4199`; `TEQO_AGENT_SERVER_HOST/PORT`) |
|
||||
| `pnpm agent:session start --purpose=<next\|plan\|new\|fix> --dir=<D> --model=<M> [--issue=N] [--argument="<bag>"] [--new]` | Cria a sessão, dispara o driver (quando há skill) e anexa o TUI |
|
||||
| `pnpm agent:session attach [--session=<ses_…>\|--branch=<B>\|--issue=N]` | (Re)entra na sessão — default: branch do cwd; sobe o servidor se ele estiver fora |
|
||||
| `pnpm agent:session stop [--session=<ses_…>\|--branch=<B>\|--issue=N]` | Ato explícito de encerrar: abort via API + fim do driver + `stoppedAt` |
|
||||
| `pnpm agent:session list [--json]` | Status dos runs (`working\|idle\|stopped\|unknown`); `--json` é o contrato do OPS109 |
|
||||
|
||||
- **Endereçamento (OPS109):** `~/.local/state/teqo/agent-sessions/<slug>.json` (`TEQO_AGENT_SESSION_DIR` > `XDG_STATE_HOME`) guarda `version, code, issue, purpose, branch, dir, sessionID, url, model, driverPid, logPath, startedAt, stoppedAt`; `list --json` entrega `{version, server, sessions:[…status]}` — é a única fonte para "abrir a sessão da Issue".
|
||||
- **Sair ≠ encerrar:** fechar o TUI/terminal não mata o run (verificado: SIGKILL no cliente e `tmux kill-session` não abortam; `attach` replayer histórico e streaming ao vivo). `Ctrl+C` no TUI limpa o input; sair é fechar o terminal ou o keybind `app_exit` (`ctrl+c,ctrl+d,<leader>q`).
|
||||
- **Reuso:** `start` com driver vivo ou sessão `busy` → apenas anexa (nunca dois runs no mesmo branch); caso contrário cria sessão nova (`--new` força). `worktree kill` encerra a sessão do worktree antes de destruí-lo (estado/lock removidos — sem sessão órfã).
|
||||
- **Segurança:** loopback por padrão; `--hostname` fora do loopback **exige** `OPENCODE_SERVER_PASSWORD` (fail-closed) e `--mdns` nunca é usado. A credencial vive só no ambiente — nunca no estado/log; loopback sem senha roda sem auth (aceito pelo produto: nada além de `127.0.0.1`).
|
||||
- **Modos de falha:** morte do driver deixa pedidos de permissão pendurados até um `attach` responder (o `stop` sempre aborta via API, sem depender do driver); reboot derruba o run (a sessão/histórico persiste e pode ser reanexada); o daemon do servidor fica de pé (idle, loopback) — parada manual pelo `pid` em `agent-sessions/server.json`.
|
||||
|
||||
## Contrato de PR
|
||||
|
||||
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
|
||||
|
|
|
|||
1
docs/changelog/2026-09-15-ops110.md
Normal file
1
docs/changelog/2026-09-15-ops110.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS110 — runs longos de agente sobrevivem ao terminal: attach/detach por `opencode serve` compartilhado (2026-09-15):** o launch do terminal (`worktree next|plan|new|fix`) deixou de abrir um TUI local dono da sessão — `opencodeLaunchDirective` (`scripts/lib/worktree.mjs`) agora emite `launch node scripts/agent-session.mjs start --purpose=<P> --dir=<D> --model=<M> [--issue=<N>] [--argument="<bag>"]` (o mapa purpose→skill mudou de casa para `scripts/lib/agent-session.mjs`, `purposeInvocation`; a mecânica do `.agents/shell/worktree.sh` ficou intocada — 1 linha `launch` tokenizada por xargs). O novo `scripts/agent-session.mjs` (`serve|start|attach|stop|list [--json]`, `pnpm agent:session`) sobe/reaproveita o `opencode serve` **compartilhado em loopback `127.0.0.1:4199`** (`TEQO_AGENT_SERVER_HOST/PORT`; bind fora do loopback exige `OPENCODE_SERVER_PASSWORD`, fail-closed; nunca `--mdns`), cria a sessão endereçável (`POST /session?directory=…`), dispara o **driver destacado** `opencode run --attach … --auto --command <skill> -- <args>` (é o `--auto` dele que auto-aprova permissões enquanto o run vive) e **anexa** o TUI `opencode attach -s <sessionID>`. Fechar o terminal/TUI desconecta — o run continua (verificado ao vivo na 1.18.31: SIGKILL no cliente e `tmux kill-session` não abortam; `POST /session/{id}/abort` interrompe e o driver sai); encerrar é ato explícito (`agent:session stop` = abort + fim do driver + `stoppedAt`). Sessão/endereçamento ficam em `~/.local/state/teqo/agent-sessions/<slug>.json` (`TEQO_AGENT_SESSION_DIR`/`XDG_STATE_HOME`; campos `version, code, issue, purpose, branch, dir, sessionID, url, model, driverPid, logPath, startedAt, stoppedAt`) e `list --json` entrega `{version, server, sessions:[…status]}` — o contrato que o painel OPS109 consome. `start` reusa o run existente quando o driver vive ou a sessão está `busy` (nunca dois runs no mesmo branch; `--new` força) e `worktree kill` encerra a sessão antes de destruir o worktree (estado/lock removidos — sem sessão órfã). Unit novos: `tests/unit/agentSession.unit.spec.ts` (paths/overrides, fail-closed de bind, argv do driver/attach com separador `--`, mapa purpose→comando, parse/serialize, payload do `list --json`; RED→verde) e `tests/unit/worktree.unit.spec.ts` atualizado (diretiva nova). Docs: seção "Sessões persistentes" em `docs/AGENT-OPS.md`, bullet em `AGENTS-infra.md`, `AGENTS.md` e `.opencode/commands/worktree.md`. Sem migration/UI/access/Consent/e2e (smoke ao vivo em dir de scratch: detach com run vivo, reattach com replay, `stop` interrompendo, `list` `working→idle→stopped`).
|
||||
174
docs/plans/ops110-attach-detach-runs-agent-impl.md
Normal file
174
docs/plans/ops110-attach-detach-runs-agent-impl.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Impl: OPS110 — Acompanhar runs longos de agente por attach/detach, sem manter terminal aberto
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-09-15
|
||||
Issue: #1019
|
||||
Intenção: docs/plans/ops110-attach-detach-runs-agent.md
|
||||
Appetite restante: ~1–2 dias eng (herdado) — cabe: 1 CLI + 1 lib pura + 2 specs unitários + docs/changelog; sem migration/UI/access/Consent.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome (nas palavras do aceite):** o run continua trabalhando sem nenhum cliente olhando (nenhum terminal/SSH aberto no workstation); (re)entrar de outro dispositivo retoma exatamente a **mesma sessão, mesmo worktree, mesmo histórico**; sair do cliente nunca é encerrar (encerrar é ato explícito); nada novo escutando além do loopback sem credencial (qualquer superfície além de `127.0.0.1` exige auth, fail-closed sem `OPENCODE_SERVER_PASSWORD`); worktree/branch segue o contrato de ambiente (sem nova forma de claim, sem sessão órfã); a sessão é **endereçável** a partir da Issue/worktree — contrato que o OPS109 (painel) consumirá.
|
||||
- **O que NÃO negociar:** não vira pool/supervisor (OPS65 removido — não ressuscitar); não cria caminho que burle gates humanos (PR/CI/approval); não cria segunda porta de entrada que ignore claim/worktree; não exige terminal aberto nem processo pendurado em SSH; não abre porta pública/internet; não é web UI como produto; sem notificações/frota/métricas; sem mudar claim/gates. Impeccable A — N/A (ferramenta/ops, sem UI).
|
||||
- **O que reavaliar (hipóteses da intenção/explorador):**
|
||||
- "Servidor nativo (a) vs multiplexador tmux (b)" → **decidido neste plano:** servidor + attach (D1/D2). O tmux cai porque não tem auto-approve fora do TUI, não dá session ID/URL endereçável para o OPS109 e adiciona dependência externa/preso a um pty.
|
||||
- "o `opencode serve` sozinho sustenta o run" → **falso**: sessão só-servidor sem cliente fica **pendurada na permissão de bash** (fato verificado ao vivo; request fica em `/permission`). O design passa a exigir um **driver destacado vivo pelo run inteiro** — é o `--auto` dele que aprova, como o `--auto` do launch de hoje.
|
||||
- "HTTP puro (`prompt_async`/REST) resolve sem cliente" → **falso** pelo mesmo fato; rejeitado em D2.
|
||||
- "reusar o 4096 (Zed `opencode acp`) ou o 4097 (`opencode web` do usuário)" → **rejeitado**: processos de terceiros na máquina; servidor próprio em **4199** (`TEQO_AGENT_SERVER_PORT` overridável), fora da faixa de dev dos worktrees (`3100+slot`, slot ≤ 999 → até 4099).
|
||||
- "a sessão só existe quando o TUI abre" → **falso**: `POST /session?directory=D` cria a sessão antes do run; o ID já nasce endereçável e vai para o estado no `start`.
|
||||
- "`opencode attach`/TUI fecha o run" → **falso** (probes): SIGKILL no cliente `run`, fechar o TUI e `tmux kill-session` **não** abortaram o run server-side; `POST /session/{id}/abort` interrompe e o driver sai. Ctrl+C no TUI pode limpar input em vez de sair (não aborta o run).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
W["worktree next/plan/new/fix (terminal)"] --> L["launch node scripts/agent-session.mjs start<br/>--purpose P --dir D --model M [--issue N] [--argument B]"]
|
||||
L --> H{"GET 127.0.0.1:4199/global/health"}
|
||||
H -->|"down / estranho"| SV["spawn detached: opencode serve<br/>--hostname 127.0.0.1 --port 4199 + log"]
|
||||
H -->|"up"| ST
|
||||
SV --> ST{"estado do branch existe<br/>e sessão viva?"}
|
||||
ST -->|"sim"| AT["opencode attach URL -s SES (foreground)"]
|
||||
ST -->|"não"| CS["POST /session?directory=D<br/>→ sessionID endereçável"]
|
||||
CS --> DR["spawn detached: opencode run --attach URL -s SES<br/>--model M --auto --command CMD -- args + log"]
|
||||
DR --> AT
|
||||
AT -.->|"fechar terminal (SIGHUP) = detach"| RUN["run segue no servidor<br/>(o --auto do driver aprova)"]
|
||||
RUN -.->|"laptop/celular"| AT2["agent-session attach<br/>(--session | --branch | --issue | branch do cwd)"]
|
||||
AT2 -.-> RUN
|
||||
AT2 -->|"ato explícito"| STOP["agent-session stop<br/>POST /session/id/abort + kill do driver + stoppedAt"]
|
||||
STOP --> LJ["list --json (contrato OPS109)"]
|
||||
RUN --> LJ
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) `opencode serve` compartilhado em loopback (127.0.0.1:4199) + driver destacado por run + `opencode attach` como cliente TUI; B) multiplexador de terminal (tmux) mantendo o TUI vivo entre anexações; C) servidor + HTTP puro (`prompt_async`/REST) sem cliente/driver vivo; D) um `opencode serve` por worktree.
|
||||
**Recomendação:** **A** — é a única direção que preserva os três aceites ao mesmo tempo: o run sobrevive sem cliente (servidor dono da sessão + driver `--auto` destacado), o reattach é exato (`-s <sessionID>` no mesmo `--dir`) e a sessão é endereçável (`sessionID` + `url` no estado, base do contrato OPS109). Os probes ao vivo na 1.18.31 validaram cada peça: fechar/SIGKILL no cliente não aborta; o exit do driver é 0 ao concluir; `abort` interrompe e o driver sai; `--command` com separador `--` entrega `$ARGUMENTS` corretamente (`work-issue -- --issue 1019` passou `"--issue 1019"`).
|
||||
**Alternativas rejeitadas:** B — sem auto-approve fora do TUI (o run ficaria pendurado na permissão assim que o humano saísse), sem session ID/URL estável para o OPS109 e dependência externa (tmux) no caminho crítico; C — a permissão de bash pendura sem cliente vivo (fato verificado) e não há como "responder depois" sem superfície de permissão; D — N portas/processos para gerenciar, URLs múltiplas para o OPS109 e o mesmo custo de driver por run (nenhum ganho de isolamento, já que as sessões são escopadas por `directory`).
|
||||
|
||||
### Decisões de engenharia (caras de reverter)
|
||||
|
||||
#### D1 — Topologia do servidor
|
||||
|
||||
- **Opções:** A) um `opencode serve` compartilhado em `127.0.0.1:4199` (`TEQO_AGENT_SERVER_PORT`), sessões escopadas por `directory` (recomendada); B) um servidor por worktree; C) reusar 4096/4097 de terceiros; D) porta efêmera com registro em arquivo.
|
||||
- **Recomendação:** **A** — um único processo loopback, uma URL estável por workstation, endereçamento simples para o OPS109; `serve` é idempotente (health check antes de subir).
|
||||
- **Alternativas rejeitadas:** B — N processos/portas e N URLs, sem ganho (sessões já isolam por diretório); C — são donos de terceiros (Zed ACP, `opencode web` do usuário), não nossos para gerenciar/derrubar; D — endereço instável entre reboots quebra o contrato de endereçamento do OPS109.
|
||||
|
||||
#### D2 — Quem sustenta o run (driver destacado)
|
||||
|
||||
- **Opções:** A) driver destacado por run (`opencode run --attach URL -s SES --dir DIR --model M --auto --command CMD -- <args>`, spawn `detached: true` + `unref()` + log em fd, precedente `scripts/auto-unblock.mjs:344–374`) (recomendada); B) `prompt_async`/HTTP puro sem cliente; C) tmux com o TUI vivo; D) confiar no servidor sozinho.
|
||||
- **Recomendação:** **A** — o driver é quem carrega o `--auto` do run inteiro e auto-aprova permissões; verificado que ele sai com exit 0 ao concluir, que SIGKILL no cliente não aborta e que `POST /session/{id}/abort` interrompe o run e faz o driver sair.
|
||||
- **Alternativas rejeitadas:** B — permissão pendura sem cliente (verificado); C — sem `--auto` fora do TUI e sem session ID/URL estável; D — o servidor não aprova permissões por conta própria.
|
||||
|
||||
#### D3 — UX do `start` (TUI anexado em foreground)
|
||||
|
||||
- **Opções:** A) `start` termina com `opencode attach <url> -s <id> --dir <dir>` no foreground, stdio herdado (recomendada); B) `start` retorna sem abrir nada (só imprime URL/ID); C) abrir um TUI local antigo.
|
||||
- **Recomendação:** **A** — mantém a UX atual do launch (o chat abre no worktree, agora como cliente do servidor) e o detach explícito é fechar o terminal; sair do TUI não encerra o run.
|
||||
- **Alternativas rejeitadas:** B — muda a UX do `worktree next/plan/...` e adiciona um passo manual; C — TUI local não fala com o servidor e conflita com o endereçamento.
|
||||
|
||||
#### D4 — Estado por run e contrato de endereçamento (OPS109)
|
||||
|
||||
- **Opções:** A) JSON por run em `~/.local/state/teqo/agent-sessions/<slug-do-branch>.json` (`TEQO_AGENT_SESSION_DIR` overridável; `XDG_STATE_HOME` respeitado), campos estáveis `version, code, issue, purpose, branch, dir, sessionID, url, model, driverPid, logPath, startedAt, stoppedAt` (recomendada); B) sem arquivo, redescobrir tudo pela API do servidor; C) banco/daemon próprio de sessões.
|
||||
- **Recomendação:** **A** — o opencode persiste a sessão; o arquivo é só o **índice de endereçamento** (branch/dir/sessionID/url/driverPid/log). Sem segredo persistido: credencial só por env (`OPENCODE_SERVER_PASSWORD`/`OPENCODE_SERVER_USERNAME`), nunca no estado/log. `code` derivado do prefixo do branch (`C15-…`), `null` nos namespaces `plans/`, `work/`, `fix/`.
|
||||
- **Alternativas rejeitadas:** B — a API do servidor não guarda issue/branch/purpose/driverPid; redescobrir exigiria heurística e quebraria o OPS109; C — rabbit hole de produto ("segundo dono da sessão").
|
||||
|
||||
#### D5 — Reuso de sessão, encerramento e `worktree kill`
|
||||
|
||||
- **Opções:** A) `start` com estado existente e sessão viva → apenas attach (não duplica driver/sessão); `--new` força sessão nova; servidor/sessão inexistente (reboot) → nova sessão; `stop` = abort via API + kill do driver + `stoppedAt`; `worktree kill` chama stop antes de remover e apaga o estado ao fim (recomendada); B) sempre criar sessão nova; C) nunca reusar/nunca parar.
|
||||
- **Recomendação:** **A** — cobre `worktree next --issue N` idempotente (worktree reaberto não pode ganhar dois runs no mesmo branch) e cumpre "sem sessão órfã". Estado vivo mas com script `start` concorrente é protegido por lock simples (ver R4).
|
||||
- **Alternativas rejeitadas:** B — dois runs no mesmo worktree/branch = corrupção de branch/PR; C — órfãos.
|
||||
|
||||
#### D6 — Dono do mapa purpose→comando/args e nova diretiva de launch
|
||||
|
||||
- **Opções:** A) mover `OPENCODE_SKILL_COMMAND_BY_PURPOSE` (hoje em `scripts/lib/worktree.mjs:91–96`) e a montagem command/args para `scripts/lib/agent-session.mjs`; `opencodeLaunchDirective` (`scripts/lib/worktree.mjs:207–233`) passa a emitir `launch node scripts/agent-session.mjs start --purpose <P> --dir <D> --model <M> [--issue N] [--argument "<B>"]` (recomendada); B) manter o mapa em `worktree.mjs` e o CLI de sessão chamá-lo de volta; C) duplicar o mapa no script novo.
|
||||
- **Recomendação:** **A** — o launch segue sendo a **única porta de entrada**; `.agents/shell/worktree.sh` não muda de mecânica (continua 1 linha `launch` tokenizada por xargs); a sanitização do bag (remover `"`/`\`) e a regra "issue só no `next`" são preservadas no novo dono.
|
||||
- **Alternativas rejeitadas:** B — inverte dependência e acopla o ciclo de vida ao provisionador; C — drift garantido entre dois mapas.
|
||||
|
||||
#### D7 — Superfície de comando
|
||||
|
||||
- **Opções:** A) um script com verbos `serve|start|attach|stop|list [--json]`, exposto como `"agent:session": "node scripts/agent-session.mjs"` no `package.json` (recomendada; precedente `pnpm issue`/`pnpm worktree`); B) um script por verbo; C) pendurar os verbos no `scripts/worktree.mjs`.
|
||||
- **Recomendação:** **A** — cluster coeso do ciclo de vida; `scripts/*.mjs` já é entrada do knip (`knip.json` `entry`), então o script novo entra no grafo sem config nova. `attach`/`stop` resolvem por `--session` > `--branch` > `--issue` > branch do cwd (`git rev-parse --abbrev-ref HEAD`); `list --json` é o contrato de endereçamento do OPS109.
|
||||
- **Alternativas rejeitadas:** B — 5 entrypoints, help/parse duplicados; C — mistura claim/provisionamento (worktree) com ciclo de vida de run (sessão).
|
||||
|
||||
#### D8 — Verificação sem servidor real no CI
|
||||
|
||||
- **Opções:** A) lib pura com `fetch`/`spawn`/`fs` injetáveis + unit tests; smoke ao vivo discricionário (recomendada); B) integração/e2e subindo `opencode serve` no CI; C) só smoke manual.
|
||||
- **Recomendação:** **A** — o repo roda unit sem banco/rede de propósito; o que regride é o **contrato** (argv, paths de estado, validação fail-closed, mapa purpose→comando, shape do `list --json`). Atualizar `tests/unit/worktree.unit.spec.ts` (diretiva nova; o spec atual importa `OPENCODE_SKILL_COMMAND_BY_PURPOSE` da lib antiga — o import passa a vir do novo dono) e criar `tests/unit/agentSession.unit.spec.ts` sob `tests/{unit,int,e2e}` (guard `check-test-locations`).
|
||||
- **Alternativas rejeitadas:** B — daemon externo/binary local no runner, flake e fora do padrão (o opencode já é smoke, não teste); C — o contrato crítico (endereçamento OPS109, fail-closed de bind) ficaria sem pin.
|
||||
|
||||
#### D9 — Segurança do bind e da credencial
|
||||
|
||||
- **Opções:** A) default loopback sem auth (aceito pelo produto); qualquer `--hostname` não-loopback **exige** `OPENCODE_SERVER_PASSWORD` ou falha fechado antes do spawn; **nunca** `--mdns`; credencial só por env (recomendada); B) espelhar o opencode (apenas avisa sem senha); C) bind na tailnet sem auth "porque a rede é confiável".
|
||||
- **Recomendação:** **A** — `validateServerBind({ hostname, password })` na lib pura, testado para loopback (`127.0.0.1`, `::1`, `localhost`) e para o caminho que lança sem senha; a credencial nunca vai para o estado/log. Repasse ao filho por env (hipótese: o cliente lê `OPENCODE_SERVER_PASSWORD`; se não ler, fallback `-p` documentado — a validar no tracer).
|
||||
- **Alternativas rejeitadas:** B — viola o aceite "fail-closed sem credencial"; C — anti-goal explícito da intenção.
|
||||
|
||||
#### D10 — Daemon do servidor na v1
|
||||
|
||||
- **Opções:** A) sem parada automática: `serve` idempotente + PID gravado no estado global (`server.json`) + parada manual documentada (recomendada); B) `serve --stop` completo com restart supervisionado; C) matar por `pkill opencode`.
|
||||
- **Recomendação:** **A** — loopback e idle custam pouco; manter o daemon evita matar o servidor no meio de um run. Registrar em `AGENT-OPS.md`; `serve --stop` fica como gatilho barato se o smoke mostrar necessidade.
|
||||
- **Alternativas rejeitadas:** B — supervisão de daemon é outro item (fora do appetite); C — padrão perigoso: mataria os servidores de terceiros (4096/4097).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`scripts/lib/agent-session.mjs` (novo, lib pura, testável):** dono dos símbolos — `AGENT_SERVER_PORT` (4199, `TEQO_AGENT_SERVER_PORT`), `AGENT_SERVER_HOST` (127.0.0.1), `AGENT_SESSION_DIR` (`TEQO_AGENT_SESSION_DIR` ?? `$XDG_STATE_HOME/teqo/agent-sessions` ?? `~/.local/state/teqo/agent-sessions`), `STATE_VERSION`, `isLoopbackHost`, `validateServerBind`, `sessionSlug` (reusa `slugify` de `src/lib/slug.ts`, já importado pela lib de worktree), `sessionStatePath`, `serializeSessionState`/`parseSessionState`, `purposeInvocation({ purpose, issueNumber, argument })` (mapa movido: `next → { command: 'work-issue', arguments: '--issue N' }`, `plan → { command: 'plan-issue', arguments: '' }`, `fix → { command: 'bug-fix', arguments: bag }`, `new → null`; sanitização do bag idempotente também aqui), `driverArgs`/`attachArgs`/`serverArgs` (SEM o binário — o caller usa `spawn('opencode', …)`; nenhum carrega `-p`, a credencial só vive no env), `resolveSessionRef({ states, session, branch, issue, cwdBranch })`, `formatSessionList(states, probes)`/`sessionListPayload`.
|
||||
- **`scripts/agent-session.mjs` (novo, CLI fina):** verbos `serve` (health check → spawn `opencode serve --hostname <H> --port <P>` detached + log; PID em `server.json`), `start` (reuso D5 → cria sessão `POST /session?directory=D` → spawn do driver detached com log → attach foreground), `attach` (`opencode attach <url> -s <id> [--dir <dir>]`), `stop` (resolve ref → `POST /session/{id}/abort` → SIGTERM/SIGKILL no `driverPid` → grava `stoppedAt`), `list [--json]` (varre o state dir; probe `GET /global/health` + `GET /session/status?directory=D`; status `working|idle|stopped|unknown`). Aviso de drift: comparar `version` do `/global/health` com `opencode --version` se não for igual. `worktree kill` delega o encerramento ao próprio CLI (`node scripts/agent-session.mjs stop --branch=<B>`, best-effort; estado só é descartado quando o stop funciona) — sem segunda implementação de abort/kill.
|
||||
- **`scripts/lib/worktree.mjs`:** `opencodeLaunchDirective` emite a linha nova (uma linha `launch node scripts/agent-session.mjs start ...`, dir sem espaços); remover `OPENCODE_SKILL_COMMAND_BY_PURPOSE` (dono passa a ser a lib nova); comentário/doc da função reescrito. `resolveWorktreeModel`/`WORKTREE_MODEL_MAP`/diretiva headless OPS106 intocados.
|
||||
- **`scripts/worktree.mjs`:** caminho do `kill` chama `stopSession` (lib nova, best-effort: servidor inacessível → segue o kill do PID/estado) antes do `git worktree remove`; ao fim da remoção, apaga o estado do branch. Help text e comentários realinhados. `next`/`plan`/`new`/`fix` inalterados fora da diretiva.
|
||||
- **`.agents/shell/worktree.sh`:** só comentários (l.9–26 e l.97–111) — mecânica intocada (1 linha `launch`, xargs, `cd` primeiro). Hipótese a validar no smoke: `launch` preserva o cwd (o caminho `scripts/agent-session.mjs` é relativo); se não preservar, o fallback é o `worktree.mjs` emitir o caminho absoluto (`node <repoRoot>/scripts/agent-session.mjs`) — 1 linha.
|
||||
- **`package.json`:** `"agent:session": "node scripts/agent-session.mjs"`.
|
||||
- **`tests/unit/worktree.unit.spec.ts`:** expectativas da diretiva nova; import do mapa vem da lib nova (ou o pin do mapa muda de casa). Demais pins (modelo, branches, env) intactos.
|
||||
- **`tests/unit/agentSession.unit.spec.ts` (novo):** paths/override do state dir, `sessionSlug` de branch com `/`, `validateServerBind` fail-closed (não-loopback sem senha lança; loopback nunca lança; `--mdns` nunca emitido), `driverArgv` com `--attach`/`-s`/`--auto`/`--command` + separador `--` e args (`--issue 1019` só no `next`; bag só no `fix`, sanitizado), `attachArgv`, `purposeInvocation`, `parseSessionState`/roundtrip, `formatSessionList`/payload do `list --json`.
|
||||
- **Docs:** seção nova em `docs/AGENT-OPS.md` (ciclo de vida, comandos, detach, stop, estado, contrato OPS109, loopback/credencial, modos de falha: morte do driver → permissão pendura; reboot → histórico persiste, run não; Ctrl+C limpa input, sair = fechar terminal/keybind `app_exit`); bullet do worktree em `AGENTS-infra.md`; changelog `docs/changelog/2026-09-15-ops110.md` (não editar `docs/CHANGELOG-AGENTS.md` nem o `HISTORY`).
|
||||
- **Sem migration, sem UI, sem access/Consent/PII, sem e2e** (diff é scripts + specs + docs; smoke ao vivo discricionário).
|
||||
|
||||
### Dados → forma (N/A — sem dados)
|
||||
|
||||
Ferramenta/ops: os "dados" são o estado operacional do run (`sessionID`, status) exposto em `list --json` para o OPS109 — texto/JSON de máquina, sem métrica, gráfico ou dado eleitoral/contato. Nada a desenhar.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — lib pura + contrato da diretiva (RED → verde).** Escrever primeiro `tests/unit/agentSession.unit.spec.ts` (caminhos, fail-closed de bind, argv, mapa purpose→comando, estado, list) — **RED** contra o repo atual (módulo não existe; diretiva ainda não é a nova). Extrair `scripts/lib/agent-session.mjs`, mover o mapa e ajustar `opencodeLaunchDirective`; atualizar `tests/unit/worktree.unit.spec.ts`. Gate: specs focados verdes + `pnpm gate:fast`. _~3–4h._
|
||||
2. **CLI `agent-session.mjs` — serve/start/attach/stop/list.** Fiar health check, spawn destacado do servidor e do driver (log em fd, `unref`), criação de sessão via API, attach foreground, stop/abort, `list --json`. Verificação: unit tests da lib via funções puras com fetch/spawn injetáveis (sem rede/no CI) + `node scripts/agent-session.mjs list --json` com state dir vazio/inexistente retornando payload válido (fail-soft, sem stacktrace). Gate: `pnpm gate:fast` + `pnpm knip`. _~5–6h._
|
||||
3. **Integração no launcher + kill.** `package.json`, hook do `kill` no `scripts/worktree.mjs`, comentários da shell. Verificação: specs de diretiva verdes; `TEQO_WORKTREE_TERMINAL=1 node scripts/worktree.mjs plan --stay` não emite launch (regra atual preservada) e o spec pina a linha emitida; `worktree kill` com estado de sessão chama stop antes do remove (coberto no smoke). Gate: `pnpm gate:fast`. _~2–3h._
|
||||
4. **Docs + changelog.** Checklist da seção Componentes (`AGENT-OPS.md`, `AGENTS-infra.md`, `.agents/shell/worktree.sh`, `docs/changelog/2026-09-15-ops110.md`; `pnpm format` se o Prettier reclamar). Gate: `pnpm push` (o `gate:ci` cobre docs-guards). _~1–2h._
|
||||
5. **Smoke ao vivo (discricionário) + entrega.** Em dir/worktree de **scratch** (nunca um claim real): `agent-session start` com comando de scratch, fechar o cliente/SIGKILL no TUI, confirmar run vivo via `list`/`/session/status`, reattach, `stop` interrompe o run e o driver sai; `worktree kill` sem sessão órfã (PID/driver mortos, estado removido). Registrar o resultado no changelog; `pnpm push`; PR Ready. _~2h + observação pós-merge._ **Total ~13–17h (~2 dias eng).**
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Painel de frota / web UI / pool** — status de todos os runs, iniciar/parar por lista, notificações, métricas, supervisor (anti-goals; OPS109 consome só o `list --json`).
|
||||
- **Expor bind na tailnet sem auth** ou "porque a rede é confiável" — qualquer superfície além do loopback exige credencial, fail-closed; `--mdns` nunca.
|
||||
- **Persistência própria de sessão** (banco/daemon guardando estado do run) — o opencode já persiste; aqui só o índice de endereçamento.
|
||||
- **Lock manager/scheduler completo** — a corrida de `start` resolve com lockfile simples por slug (R4), não vira fila de runs.
|
||||
- **Gerenciar/derrubar os servidores de terceiros (4096/4097)** ou "reusar um deles".
|
||||
- **Rotação de log, restart supervisionado do daemon, telemetria** — v1 aceita o log append + daemon idle (R2/R10).
|
||||
- **Integração/e2e com servidor real no CI** e **migration/UI/access/Consent** — sem superfície; smoke discricionário.
|
||||
- **Mudar claim/worktree/gates**, criar segunda porta de entrada ou sessão sem branch/claim.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **R1 — Morte do driver → permissão pendurada** (o run fica esperando um cliente que não existe). _Mitigação:_ o attach pode responder a permissão; `stop` aborta via API em modo tolerante (401/5xx não impedem o encerramento local do driver + `stoppedAt`); documentar em `AGENT-OPS.md` (sintoma + remédio).
|
||||
- **R2 — Daemon do servidor sem parada na v1.** Aceito (loopback, idle); PID no `server.json` para parada manual; `serve --stop` deferido como gatilho barato (D10).
|
||||
- **R3 — Drift de flags/API do opencode** (servidor/attach/run). _Mitigação:_ `/global/health` traz `version` e o `start` avisa se ≠ `opencode --version`; unit tests pinam o argv; smoke ao vivo; os fatos deste plano são da 1.18.31.
|
||||
- **R4 — Dois `start` simultâneos no mesmo branch/servidor** (driver/sessão duplicados). _Mitigação:_ lockfile por slug (`fs.openSync(..., 'wx')` com PID e reclaim se o PID morreu; sem dependência nova) com a **decisão de reuso relida dentro do lock**; segundo `start` sai com mensagem clara; caminho coberto pela lib (a corrida real não tem harness no repo — lacuna registrada no PR).
|
||||
- **R5 — Ctrl+C no TUI anexado limpa input em vez de sair** (probe; não aborta o run). _Mitigação:_ documentar as chaves de saída (`app_exit: ctrl+c, ctrl+d, <leader>q`; fechar terminal/SIGHUP = detach) e que encerrar é `agent-session stop`.
|
||||
- **R6 — Bind não-loopback sem senha** (violaria o aceite). _Mitigação:_ `validateServerBind` falha antes do health/reuse (não só antes do spawn); `isLoopbackHost` estrito (não aceita `127.0.0.1.evil`); unit tests dedicados; `--mdns` nunca emitido.
|
||||
- **R7 — Hypotese do repasse de credencial por env** (`opencode attach`/`run` lerem `OPENCODE_SERVER_PASSWORD` sem `-p`). _Mitigação:_ fallback `-p` documentado (trade-off: visível em `ps` local) e validado no tracer; a credencial nunca entra no estado/log.
|
||||
- **R8 — Worktrees reabertos/criados antes do merge não têm `scripts/agent-session.mjs`.** _Resolvido em execução:_ a diretiva carrega o caminho **absoluto** do checkout que a emitiu (`opencodeLaunchDirective` ganhou `sessionScript`; o default relativo fica para os specs) — o relaunch de worktree antigo não quebra nem fica pinado no código do branch.
|
||||
- **R9 — Contrato `list --json` (OPS109) evoluir quebrando o painel.** _Mitigação:_ `version` no estado e no payload; evolução additions-only; contrato documentado em `AGENT-OPS.md`.
|
||||
- **R10 — Log do driver sem rotação.** Aceito na v1 (arquivo append no state dir, caminho no estado); se crescer, truncar é trivial e não muda o contrato.
|
||||
- **R11 — `new` sem run dirigido** (sem driver enquanto o humano não conversar; permissões interativas dependem do cliente anexado). _Mitigação:_ `start --purpose new` cria sessão + attach sem driver (hipótese: `run` sem mensagem não é confiável — validar no tracer); documentar a limitação; o aceite de detach vale para runs dirigidos (`next`/`plan`/`fix`).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: (1) run continua sem nenhum cliente olhando; (2) reattach exato de outro dispositivo (mesma sessão `-s`, mesmo `--dir`, mesmo histórico); (3) sair do cliente ≠ encerrar (stop é ato explícito); (4) nada além do loopback sem credencial — `--hostname` não-loopback sem `OPENCODE_SERVER_PASSWORD` falha fechado; (5) worktree/branch segue o contrato, sem nova forma de claim nem sessão órfã (`worktree kill` para a sessão antes de remover); (6) sessão endereçável por Issue/branch/worktree via estado + `list --json` (contrato OPS109).
|
||||
- [ ] Invariantes AGENTS/engineering-standards: sem migration/UI/access/Consent/PII; claim/gates intocados; a diretiva de launch continua a única porta (`opencodeLaunchDirective`); shell function sem mudança de mecânica; sem segredo persistido/commitado; `scripts/*.mjs` como entrada do knip e specs em `tests/unit/` (guard `check-test-locations`); sem dependência nova.
|
||||
- [ ] Testes previstos: `tests/unit/agentSession.unit.spec.ts` novo (paths/overrides, fail-closed de bind, argv do driver/attach com separador `--`, mapa purpose→comando+args, parse/serialize, `list --json`) com RED comprovado contra o repo atual; `tests/unit/worktree.unit.spec.ts` atualizado (diretiva nova) e demais pins verdes.
|
||||
- [ ] Gates: `pnpm gate:fast` na iteração e `pnpm test:unit` (full); `pnpm push` (o `gate:ci` cobre format/lint/typecheck/unit/int/knip/cycles/docs-guards); e2e: **declarar "sem superfície de runtime"** (diff é scripts + specs + docs; OPS72 discricionário — o CI do PR aplica o conjunto curado por high-risk, pois `package.json`/`scripts/worktree.mjs` estão no blast radius). Smoke ao vivo com dir/worktree de **scratch** (sem claim real): detach com run vivo, reattach, `stop`, `worktree kill` — resultado registrado no changelog; incluir o `*-impl.md` no commit; PR Ready.
|
||||
|
||||
**Self-score decision-quality (gate ≥4):**
|
||||
|
||||
| Critério | Nota | Justificativa |
|
||||
| -------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1. Decisões caras com rejeitadas | 1,0 | D1–D10 com opções e rejeitadas explícitas (topologia, driver vs tmux vs HTTP, estado, reuso/kill, dono do mapa, superfície, verificação, bind, daemon) |
|
||||
| 2. Cabe no appetite | 1,0 | ~13–17h: 1 CLI + 1 lib pura + 2 specs unitários + hook de kill + docs; sem migration/UI/e2e |
|
||||
| 3. Rabbit holes nomeados | 1,0 | frota/web UI/pool, bind sem auth/`--mdns`, persistência própria, lock manager, servidores de terceiros, rotação/supervisão, e2e com servidor real, mudar claim/gates |
|
||||
| 4. Depth check (reuso) | 1,0 | reusa `slugify`, o spawn destacado do `auto-unblock.mjs:344–374`, a mecânica da shell (1 linha `launch`/xargs), o padrão de cluster `pnpm issue`/`worktree`, estado em XDG; edita os donos (diretiva/lib/worktree) sem twin |
|
||||
| 5. Intenção preservada | 1,0 | os 6 aceites cobertos; direção (a) escolhida; anti-goals respeitados; endereçamento OPS109 explícito no `list --json`; produto decide "de onde acompanha" (SSH/TUI, já validado) |
|
||||
| **Total** | **5,0/5** | ≥4 exigido |
|
||||
59
docs/plans/ops110-f1-agent-session-hardening.md
Normal file
59
docs/plans/ops110-f1-agent-session-hardening.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# OPS110-F1 — `agent:session`: flags desconhecidas fail-high, single-flight do `start` testado e polish de help/list
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-09-15
|
||||
Issue: (a registrar — OPS110-F1)
|
||||
Intenção: entrega OPS110 (Issue #1019, `docs/plans/ops110-attach-detach-runs-agent.md`) — débitos do ciclo `/simplify` capturados após o merge
|
||||
Appetite restante: ~1 dia eng (3 fases, fill-in)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** fechar as lacunas deixadas pelo ciclo OPS110 sem mudar o contrato de produto: (a) flags desconhecidas no CLI `agent:session` passam a falhar alto (hoje `--prupose=next` cai silenciosamente no default `new`, criando sessão sem driver); (b) o invariante "nunca dois runs no mesmo branch" (D5/R4 do impl plan) ganha pin automatizado do single-flight (`acquireStartLock` + reuso dentro do lock); (c) help/`list` ficam completos (flags `--hostname/--port` e obrigatoriedade do `--model` no USAGE do `start`; `logPath` visível no `list` humano).
|
||||
- **O que NÃO negociar:** o contrato de `list --json` (OPS109), o fail-closed de bind, a credencial só por env e o comportamento de attach/detach (fechar cliente não encerra; `stop` é explícito) ficam como estão.
|
||||
- **O que reavaliar:** nenhum achado de access/LGPD/schema.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
**Opções:** A) 3 fases curtas no mesmo lote (flags → teste do lock → help/list) | B) só a fase de flags agora e o resto defer | C) unificar tudo num refactor do CLI.
|
||||
**Recomendação:** A — as três são cheap_polish na mesma superfície (`scripts/agent-session.mjs`) e o lote cabe em ~1 dia; a fase 1 é a de maior risco (falha muda no caminho crítico), a 2 pin o invariante mais caro de reverter (dois runs no mesmo worktree), a 3 é cosmético.
|
||||
**Rejeitadas:** B — deixa sem pin o único invariante de segurança de processo do item; C — sem ganho, o CLI já foi extraído na OPS110.
|
||||
|
||||
### Fases verificáveis
|
||||
|
||||
1. **Flags estritas (F1).** `scripts/agent-session.mjs`: validador de flags aceitas por verbo (whitelist: `serve` hostname/port; `start` purpose/dir/model/issue/argument/new/hostname/port; `attach` session/branch/issue; `stop` idem; `list` json) — desconhecida → `die` com sugestão da mais próxima (distância 1) ou listando o USAGE. Unit novo do validador (puro, em `scripts/lib/agent-session.mjs`) + `pnpm gate:fast`. ~2–3h.
|
||||
2. **Single-flight com seam testável (F2).** Extrair de `cmdStart` a decisão "reusar vs criar" para uma função com I/O injetável (ex. `resolveStartDecision({ statePath, readFile, serverUrl, probeBusy, driverAlive, forceNew })`) na lib pura; unit cobre: estado vivo → reuse; estado stale (driver morto e idle) → fresh; `--new` → fresh; e o reclaim do lock com PID morto (hoje só `startLockPath` é pinado). Documentar no spec a lacuna que fica (corrida real de processos). ~3–4h.
|
||||
3. **Help/list (F3).** USAGE do `start` com `--hostname/--port` e "`--model` obrigatório quando há skill"; `list` humano ganha a coluna do `logPath` (mantendo o header). Spec de `formatSessionList` atualizado. ~1h.
|
||||
|
||||
### Dados → forma (N/A — sem dados)
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Refactor do CLI em módulos por verbo (a CLI fina atual é intencional).
|
||||
- Persistir credencial para `list`/`attach` sem env (proibido pelo D9 da OPS110).
|
||||
- Validação semântica de todos os valores de flag (ex. e-mail de branch) — fora.
|
||||
|
||||
## Adiado com gatilho (triage OPS110)
|
||||
|
||||
- **Fronteira dupla da regra "issue só no `next`" e da sanitização do bag** (`scripts/lib/worktree.mjs` na diretiva ∥ `scripts/lib/agent-session.mjs` no `purposeInvocation`): unificar no dono do ciclo de vida **quando** `plan`/`new` ganharem argumento próprio ou surgir um 3º purpose com bag/issue. Hoje há comentário cruzado nos dois pontos e pins nos dois specs.
|
||||
|
||||
## Explicitamente fora (skips e descartes da triage — não reabrir)
|
||||
|
||||
- Literal `/bug-fix` em dois donos (OPS106 × OPS110): os dois specs pinam juntos num rename; unificar acoplaria o auto-unblock ao ciclo de sessão.
|
||||
- 404 no `abort` tratado como "já encerrado": semanticamente correto (sessão inexistente não está rodando).
|
||||
- `stop`/`list`/`attach` dependerem de `OPENCODE_SERVER_PASSWORD` no env quando o servidor exigiu senha: decisão D9 (credencial nunca persistida); `stop` degrada para encerramento local e o hint é acionável.
|
||||
- Marcador `(line truncated to 2000 chars)` commitado em `AGENTS-infra.md:15`: pré-existente em `main`, fora do diff da OPS110.
|
||||
- knip não carregar `src/payload.config.ts`: P3 já ledgered (`docs/TECH-DEBT.md`).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- Whitelist de flags divergir do parser `parseEqualsFlags` (que aceita `--flag` sem valor): F1 deve rejeitar flag desconhecida, não mudar a forma `=`; teste cobre `--json`/`--new` booleanos.
|
||||
- F2 extrair decisão sem mover I/O para a lib: manter a lib pura (filesystem/probe entram por parâmetro).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Flag desconhecida em qualquer verbo falha com mensagem acionável (inclui `--prupose`→`--purpose`).
|
||||
- [ ] Decisão de reuso + reclaim do lock pinados em unit puro; corrida real documentada como lacuna.
|
||||
- [ ] USAGE do `start` completo; `list` mostra o log.
|
||||
- [ ] `pnpm gate:fast` verde; sem migration/UI/access.
|
||||
|
||||
**Self-score decision-quality (gate ≥4):** decisões A/B/C com rejeitadas (1,0); cabe no appetite ~1 dia (1,0); rabbit holes nomeados (1,0); reusa a lib/CLI existentes sem twin (1,0); contrato OPS110 preservado (1,0) → **5,0/5**.
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"paths": {
|
||||
"server-only": ["tests/helpers/serverOnly.ts"]
|
||||
},
|
||||
"ignoreBinaries": [],
|
||||
"ignoreBinaries": ["opencode"],
|
||||
"ignoreDependencies": [
|
||||
"tailwindcss-animate",
|
||||
"tw-animate-css",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@
|
|||
"typecheck:tsc6": "tsc6 --noEmit",
|
||||
"prepare": "husky",
|
||||
"agent:status": "node scripts/agent-status.mjs",
|
||||
"agent:session": "node scripts/agent-session.mjs",
|
||||
"worktree": "node scripts/worktree.mjs",
|
||||
"unblock:check": "node scripts/auto-unblock.mjs --check",
|
||||
"issue": "node scripts/issue.mjs"
|
||||
|
|
|
|||
678
scripts/agent-session.mjs
Normal file
678
scripts/agent-session.mjs
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* OPS110 — `pnpm agent:session` (CLI do ciclo de vida de runs persistentes).
|
||||
*
|
||||
* pnpm agent:session serve [--hostname=H] [--port=P]
|
||||
* Sobe (ou reaproveita) o `opencode serve` COMPARTILHADO em loopback —
|
||||
* ele é o dono das sessões e sobrevive a qualquer cliente. Bind fora do
|
||||
* loopback exige `OPENCODE_SERVER_PASSWORD` (fail-closed). Nunca `--mdns`.
|
||||
* pnpm agent:session start --purpose=<next|plan|new|fix> --dir=<D> --model=<M> [--issue=N] [--argument="<bag>"] [--new]
|
||||
* Cria a sessão endereçável, sobe o driver destacado
|
||||
* (`opencode run --attach … --auto --command <skill> -- …`) quando o
|
||||
* purpose tem skill, e abre o TUI anexado (`opencode attach -s <id>`).
|
||||
* Fechar o TUI/terminal NÃO encerra o run; `stop` encerra. Reusa o run
|
||||
* existente quando o driver ainda vive ou a sessão está busy; `--new`
|
||||
* força sessão nova.
|
||||
* pnpm agent:session attach [--session=<ses_…>|--branch=<B>|--issue=N]
|
||||
* (Re)entra na sessão — default: o branch do cwd. Sobe o servidor se
|
||||
* ele estiver fora (o histórico da sessão persiste).
|
||||
* pnpm agent:session stop [--session=<ses_…>|--branch=<B>|--issue=N]
|
||||
* Ato explícito de encerrar: abort via API + fim do driver + `stoppedAt`.
|
||||
* pnpm agent:session list [--json]
|
||||
* Estado dos runs (orquestrado pelo `serve`); `--json` é o contrato
|
||||
* versionado que o painel OPS109 consome.
|
||||
*
|
||||
* Estado por run em `~/.local/state/teqo/agent-sessions/<slug>.json` (índice de
|
||||
* endereçamento: branch/dir/sessionID/url/driverPid/log); nenhuma credencial é
|
||||
* persistida. Os fatos de semântica (cliente pode morrer, `abort` interrompe,
|
||||
* `--` entrega `$ARGUMENTS`) foram verificados na 1.18.31.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
import {
|
||||
attachArgs,
|
||||
codeFromBranch,
|
||||
deriveSessionStatus,
|
||||
driverArgs,
|
||||
driverLogPath,
|
||||
formatSessionList,
|
||||
parseServerState,
|
||||
parseSessionState,
|
||||
purposeInvocation,
|
||||
resolveServerConfig,
|
||||
resolveSessionRef,
|
||||
serializeServerState,
|
||||
serializeSessionState,
|
||||
serverArgs,
|
||||
serverStatePath,
|
||||
sessionDirFromEnv,
|
||||
sessionListPayload,
|
||||
sessionStatePath,
|
||||
startLockPath,
|
||||
STATE_VERSION,
|
||||
validateServerBind,
|
||||
} from './lib/agent-session.mjs'
|
||||
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
|
||||
|
||||
const die = dieWithLabel('agent:session')
|
||||
|
||||
const USAGE = `Uso: pnpm agent:session <serve|start|attach|stop|list> [flags]
|
||||
|
||||
serve [--hostname=H] [--port=P] sobe/reaproveita o opencode serve compartilhado
|
||||
start --purpose=<next|plan|new|fix> --dir=<D> --model=<M> [--issue=N] [--argument="<bag>"] [--new]
|
||||
cria a sessão, sobe o driver destacado e anexa o TUI
|
||||
attach [--session=<ses_…>|--branch=<B>|--issue=N] (re)entra na sessão (default: branch do cwd)
|
||||
stop [--session=<ses_…>|--branch=<B>|--issue=N] encerra de forma explícita (abort + fim do driver)
|
||||
list [--json] status dos runs registrados`
|
||||
|
||||
/** Loopback by default; the credential rides the env, never the state/log. */
|
||||
const serverPassword = () => process.env.OPENCODE_SERVER_PASSWORD ?? ''
|
||||
const authHeaders = () => {
|
||||
if (!serverPassword()) return {}
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'
|
||||
const token = Buffer.from(`${username}:${serverPassword()}`).toString('base64')
|
||||
return { authorization: `Basic ${token}` }
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms))
|
||||
|
||||
/**
|
||||
* Probe `/global/health`. Distinguishes "down" (null) from "up but this shell
|
||||
* has no credential" ({ authRequired: true }) — the caller can't assume a 401
|
||||
* is a different server.
|
||||
*/
|
||||
const serverHealth = async (url) => {
|
||||
try {
|
||||
const response = await fetch(`${url}/global/health`, {
|
||||
headers: authHeaders(),
|
||||
signal: AbortSignal.timeout(1500),
|
||||
})
|
||||
if (response.status === 401) return { authRequired: true }
|
||||
if (!response.ok) return null
|
||||
return await response.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const cliOpencodeVersion = () => {
|
||||
const result = spawnSync('opencode', ['--version'], { encoding: 'utf8' })
|
||||
return result.status === 0 ? result.stdout.trim() : null
|
||||
}
|
||||
|
||||
const waitForHealth = async (url, timeoutMs) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const health = await serverHealth(url)
|
||||
if (health?.healthy) return health
|
||||
await sleep(250)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn destacado com stdio em fd (precedente `scripts/auto-unblock.mjs`): o
|
||||
* processo sobrevive ao CLI; só o sucesso do `spawn` é confirmado no pai.
|
||||
* Lança (não `die`) para o caller preservar cleanup (lock/fd) antes de sair.
|
||||
* @returns {Promise<number>} pid do filho
|
||||
*/
|
||||
const spawnDetached = async (command, args, { cwd, logPath, label }) => {
|
||||
mkdirSync(dirname(logPath), { recursive: true })
|
||||
const logFd = openSync(logPath, 'a')
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
})
|
||||
try {
|
||||
await new Promise((resolveSpawn, rejectSpawn) => {
|
||||
child.once('spawn', resolveSpawn)
|
||||
child.once('error', rejectSpawn)
|
||||
})
|
||||
} catch (error) {
|
||||
closeSync(logFd)
|
||||
throw new Error(`não consegui iniciar ${label}: ${error?.message ?? error}`)
|
||||
}
|
||||
child.unref()
|
||||
closeSync(logFd)
|
||||
return child.pid
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the shared server is up and usable. Reuses a healthy one (idempotent);
|
||||
* otherwise validates the bind (fail-closed beyond loopback), spawns it
|
||||
* detached with a log, waits for health and records `server.json`.
|
||||
*/
|
||||
const ensureServer = async ({ hostname = null, port = null, sessionDir }) => {
|
||||
const config = resolveServerConfig({ hostname, port })
|
||||
// Fail-closed ANTES do reuse: um servidor já de pé não pode transformar um
|
||||
// bind fora do loopback num caminho sem credencial.
|
||||
validateServerBind({ hostname: config.hostname, password: serverPassword() })
|
||||
const existing = await serverHealth(config.url)
|
||||
if (existing?.healthy) return { ...config, version: existing.version ?? null, started: false }
|
||||
if (existing?.authRequired) {
|
||||
die(
|
||||
`o servidor em ${config.url} exige credencial — exporte OPENCODE_SERVER_PASSWORD (a mesma da sessão que o iniciou).`,
|
||||
)
|
||||
}
|
||||
|
||||
const logPath = join(sessionDir, 'server.log')
|
||||
let pid
|
||||
try {
|
||||
pid = await spawnDetached(
|
||||
'opencode',
|
||||
serverArgs({ port: config.port, hostname: config.hostname }),
|
||||
{
|
||||
cwd: sessionDir,
|
||||
logPath,
|
||||
label: `'opencode serve' (log: ${logPath})`,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
die(error.message)
|
||||
}
|
||||
|
||||
const health = await waitForHealth(config.url, 15_000)
|
||||
if (!health?.healthy) {
|
||||
die(
|
||||
`o servidor não ficou saudável em ${config.url} — veja ${logPath} (porta ocupada? use TEQO_AGENT_SERVER_PORT=<outra>).`,
|
||||
)
|
||||
}
|
||||
writeFileSync(
|
||||
serverStatePath({ sessionDir }),
|
||||
serializeServerState({
|
||||
version: STATE_VERSION,
|
||||
url: config.url,
|
||||
hostname: config.hostname,
|
||||
port: config.port,
|
||||
pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
logPath,
|
||||
}),
|
||||
)
|
||||
return { ...config, version: health.version ?? null, started: true }
|
||||
}
|
||||
|
||||
const warnVersionDrift = (serverVersion) => {
|
||||
const cliVersion = cliOpencodeVersion()
|
||||
if (serverVersion && cliVersion && serverVersion !== cliVersion) {
|
||||
console.warn(
|
||||
`[agent:session] aviso: servidor em opencode ${serverVersion} e CLI em ${cliVersion} — atualize o CLI ou reinicie o servidor se algo divergir.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON request against the server; `null` on connection failure. `tolerant`
|
||||
* devolve `null` também para 401/5xx — é o modo do `abort` (o `stop` encerra
|
||||
* localmente mesmo sem servidor/credencial); o default morre alto.
|
||||
*/
|
||||
const request = async (
|
||||
url,
|
||||
path,
|
||||
{ method = 'GET', body, timeoutMs = 10_000, tolerant = false } = {},
|
||||
) => {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`${url}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (response.status === 401) {
|
||||
if (tolerant) return null
|
||||
die(
|
||||
`o servidor em ${url} exige credencial — exporte OPENCODE_SERVER_PASSWORD (a mesma da sessão que o iniciou).`,
|
||||
)
|
||||
}
|
||||
if (response.status === 404) return { notFound: true }
|
||||
if (!response.ok) {
|
||||
if (tolerant) return null
|
||||
die(`${method} ${path} falhou: HTTP ${response.status}`)
|
||||
}
|
||||
if (response.status === 204) return {}
|
||||
return response.json().catch(() => ({}))
|
||||
}
|
||||
|
||||
const sessionExists = async (url, sessionID) => {
|
||||
const result = await request(url, `/session/${sessionID}`)
|
||||
return Boolean(result && !result.notFound)
|
||||
}
|
||||
|
||||
const sessionStatuses = async (url) => (await request(url, '/session/status')) ?? {}
|
||||
|
||||
const createSession = async (url, dir) => {
|
||||
const result = await request(url, `/session?directory=${encodeURIComponent(dir)}`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
if (!result?.id) die(`não consegui criar a sessão em ${url} (resposta sem id).`)
|
||||
return result.id
|
||||
}
|
||||
|
||||
/** Best-effort: o `stop` encerra o driver local mesmo com servidor fora/401. */
|
||||
const abortSession = async (url, sessionID) =>
|
||||
(await request(url, `/session/${sessionID}/abort`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
tolerant: true,
|
||||
})) !== null
|
||||
|
||||
const pidAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* O PID só vale como driver se AINDA for um processo opencode: um estado antigo
|
||||
* pode apontar para um PID reciclado, e `stop`/`list` não podem sinalizar (nem
|
||||
* marcar como vivo) um processo alheio. Em ambientes sem `/proc` (não-Linux) a
|
||||
* verificação é inconclusiva — assume vivo, preservando o comportamento.
|
||||
*/
|
||||
const driverAlive = (pid, sessionID = null) => {
|
||||
if (!pidAlive(pid)) return false
|
||||
try {
|
||||
const cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8')
|
||||
// O driver é `opencode run --attach … -s <sessionID>`: exigir o sessionID
|
||||
// impede confundir o run com o `serve` compartilhado, outro run, o
|
||||
// `opencode web` ou o ACP do Zed num PID reciclado. Sem sessionID, o
|
||||
// par opencode+run é o melhor filtro disponível.
|
||||
if (sessionID) return cmdline.includes(sessionID)
|
||||
return cmdline.includes('opencode') && cmdline.includes('run')
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** SIGTERM → grace → SIGKILL. Returns true when a process was signalled. */
|
||||
const stopDriver = async (pid, { sessionID = null, graceMs = 5000 } = {}) => {
|
||||
if (!driverAlive(pid, sessionID)) return false
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const deadline = Date.now() + graceMs
|
||||
while (Date.now() < deadline) {
|
||||
if (!driverAlive(pid, sessionID)) return true
|
||||
await sleep(100)
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const branchOfDir = (dir) => {
|
||||
const result = spawnSync('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const branch = result.status === 0 ? result.stdout.trim() : ''
|
||||
if (!branch || branch === 'HEAD') {
|
||||
die(`não consegui resolver o branch de ${dir} — rode dentro de um worktree do repo.`)
|
||||
}
|
||||
return branch
|
||||
}
|
||||
|
||||
const loadStates = (sessionDir) => {
|
||||
if (!existsSync(sessionDir)) return []
|
||||
const states = []
|
||||
for (const file of readdirSync(sessionDir)) {
|
||||
if (!file.endsWith('.json') || file === 'server.json') continue
|
||||
try {
|
||||
states.push(parseSessionState(readFileSync(join(sessionDir, file), 'utf8')))
|
||||
} catch (error) {
|
||||
console.warn(`[agent:session] ignorando ${file}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return states.sort((left, right) => String(right.startedAt).localeCompare(String(left.startedAt)))
|
||||
}
|
||||
|
||||
const writeState = (sessionDir, state) => {
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
writeFileSync(
|
||||
sessionStatePath({ sessionDir, branch: state.branch }),
|
||||
serializeSessionState(state),
|
||||
)
|
||||
return state
|
||||
}
|
||||
|
||||
const attach = (state) => {
|
||||
const result = spawnSync(
|
||||
'opencode',
|
||||
attachArgs({ url: state.url, sessionID: state.sessionID, dir: state.dir }),
|
||||
{ cwd: state.dir, stdio: 'inherit' },
|
||||
)
|
||||
return result.status ?? 0
|
||||
}
|
||||
|
||||
/** `--session` > `--branch` > `--issue` > branch do cwd (o cwd só é lido se preciso). */
|
||||
const resolveRef = ({ flags, sessionDir }) => {
|
||||
const states = loadStates(sessionDir)
|
||||
const issue = typeof flags.issue === 'string' ? Number(flags.issue) : null
|
||||
const cwdBranch = flags.session || flags.branch || issue ? null : branchOfDir(process.cwd())
|
||||
const state = resolveSessionRef({
|
||||
states,
|
||||
session: flags.session ?? null,
|
||||
branch: flags.branch ?? null,
|
||||
issue,
|
||||
cwdBranch,
|
||||
})
|
||||
if (!state) {
|
||||
const refs = [
|
||||
flags.session && `--session=${flags.session}`,
|
||||
flags.branch && `--branch=${flags.branch}`,
|
||||
flags.issue && `--issue=${flags.issue}`,
|
||||
cwdBranch && `branch ${cwdBranch}`,
|
||||
].filter(Boolean)
|
||||
die(
|
||||
`nenhuma sessão encontrada (${refs.join(', ') || 'sem referência'}) — veja \`pnpm agent:session list\`.`,
|
||||
)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** Single-flight per branch: two `start`s must not create two drivers/sessions. */
|
||||
const acquireStartLock = ({ sessionDir, branch }) => {
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const lockPath = startLockPath({ sessionDir, branch })
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const fd = openSync(lockPath, 'wx')
|
||||
writeFileSync(fd, `${process.pid}\n`)
|
||||
return () => {
|
||||
try {
|
||||
closeSync(fd)
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
try {
|
||||
unlinkSync(lockPath)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error
|
||||
let holder = null
|
||||
try {
|
||||
holder = Number(readFileSync(lockPath, 'utf8').trim())
|
||||
} catch {
|
||||
// unreadable lock — treat as stale below
|
||||
}
|
||||
if (!pidAlive(holder)) {
|
||||
try {
|
||||
unlinkSync(lockPath)
|
||||
} catch {
|
||||
// raced
|
||||
}
|
||||
continue
|
||||
}
|
||||
die(
|
||||
`outro 'start' está em andamento para ${branch} (pid ${holder}) — aguarde ou remova ${lockPath}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
die(`não consegui travar o start de ${branch} (${lockPath}) — tente de novo.`)
|
||||
}
|
||||
|
||||
const cmdServe = async (flags) => {
|
||||
const sessionDir = sessionDirFromEnv()
|
||||
const server = await ensureServer({
|
||||
hostname: flags.hostname ?? null,
|
||||
port: flags.port ?? null,
|
||||
sessionDir,
|
||||
})
|
||||
warnVersionDrift(server.version)
|
||||
console.log(
|
||||
`${server.started ? 'servidor iniciado' : 'servidor já está de pé'}: ${server.url}${server.version ? ` (opencode ${server.version})` : ''}`,
|
||||
)
|
||||
console.log(` estado: ${sessionDir}`)
|
||||
}
|
||||
|
||||
const cmdStart = async (flags) => {
|
||||
const dir = resolve(flags.dir ?? process.cwd())
|
||||
const purpose = flags.purpose ?? 'new'
|
||||
const sessionDir = sessionDirFromEnv()
|
||||
const branch = branchOfDir(dir)
|
||||
const model = flags.model ?? null
|
||||
const issueNumber = typeof flags.issue === 'string' ? Number(flags.issue) : null
|
||||
const invocation = purposeInvocation({
|
||||
purpose,
|
||||
issueNumber,
|
||||
argument: flags.argument ?? null,
|
||||
})
|
||||
if (invocation && !model) die('--model é obrigatório para iniciar o run (driver).')
|
||||
|
||||
const server = await ensureServer({
|
||||
hostname: flags.hostname ?? null,
|
||||
port: flags.port ?? null,
|
||||
sessionDir,
|
||||
})
|
||||
warnVersionDrift(server.version)
|
||||
|
||||
const statePath = sessionStatePath({ sessionDir, branch })
|
||||
const readReusable = async () => {
|
||||
if (flags.new || !existsSync(statePath)) return null
|
||||
let existing
|
||||
try {
|
||||
existing = parseSessionState(readFileSync(statePath, 'utf8'))
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[agent:session] estado ilegível em ${statePath} (${error.message}) — tratando como sessão nova.`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (existing.url !== server.url) return null
|
||||
const busy = (await sessionStatuses(server.url))[existing.sessionID]?.type === 'busy'
|
||||
return driverAlive(existing.driverPid, existing.sessionID) || busy ? existing : null
|
||||
}
|
||||
|
||||
// A decisão de reuso acontece DENTRO do lock: dois `start`s concorrentes do
|
||||
// mesmo branch não podem ambos ler "não vivo" e criar dois drivers/sessões.
|
||||
const release = acquireStartLock({ sessionDir, branch })
|
||||
let state
|
||||
let reused = false
|
||||
try {
|
||||
const existing = await readReusable()
|
||||
if (existing) {
|
||||
reused = true
|
||||
state = existing
|
||||
} else {
|
||||
const sessionID = await createSession(server.url, dir)
|
||||
state = {
|
||||
version: STATE_VERSION,
|
||||
code: codeFromBranch(branch),
|
||||
issue: Number.isInteger(issueNumber) && issueNumber > 0 ? issueNumber : null,
|
||||
purpose,
|
||||
branch,
|
||||
dir,
|
||||
sessionID,
|
||||
url: server.url,
|
||||
model,
|
||||
driverPid: null,
|
||||
logPath: driverLogPath({ sessionDir, branch }),
|
||||
startedAt: new Date().toISOString(),
|
||||
stoppedAt: null,
|
||||
}
|
||||
if (invocation) {
|
||||
state.driverPid = await spawnDetached(
|
||||
'opencode',
|
||||
driverArgs({ url: server.url, sessionID, dir, model, invocation }),
|
||||
{ cwd: dir, logPath: state.logPath, label: `o driver do run (log: ${state.logPath})` },
|
||||
)
|
||||
} else if (purpose !== 'new') {
|
||||
console.warn(
|
||||
`[agent:session] purpose=${purpose} sem comando/issue válida — sessão criada sem driver.`,
|
||||
)
|
||||
}
|
||||
writeState(sessionDir, state)
|
||||
}
|
||||
} finally {
|
||||
// Released BEFORE the attach: o lock só protege a criação (dois `start`s
|
||||
// não podem criar dois drivers/sessões), nunca a duração do TUI.
|
||||
release()
|
||||
}
|
||||
|
||||
if (reused) {
|
||||
console.log(`run já em andamento para ${branch} — anexando (${state.sessionID}).`)
|
||||
} else {
|
||||
console.log(`sessão ${state.sessionID} em ${server.url} (${purpose}) — log: ${state.logPath}`)
|
||||
}
|
||||
console.log('sair do TUI não encerra o run; encerrar: `pnpm agent:session stop`')
|
||||
return attach(state)
|
||||
}
|
||||
|
||||
const cmdAttach = async (flags) => {
|
||||
const sessionDir = sessionDirFromEnv()
|
||||
const state = resolveRef({ flags, sessionDir })
|
||||
const serverUrl = new URL(state.url)
|
||||
await ensureServer({
|
||||
hostname: serverUrl.hostname,
|
||||
port: Number(serverUrl.port),
|
||||
sessionDir,
|
||||
})
|
||||
if (!(await sessionExists(state.url, state.sessionID))) {
|
||||
die(
|
||||
`a sessão ${state.sessionID} não existe mais no servidor ${state.url} — inicie um novo run em ${state.dir}.`,
|
||||
)
|
||||
}
|
||||
return attach(state)
|
||||
}
|
||||
|
||||
const cmdStop = async (flags) => {
|
||||
const sessionDir = sessionDirFromEnv()
|
||||
const state = resolveRef({ flags, sessionDir })
|
||||
const aborted = await abortSession(state.url, state.sessionID)
|
||||
if (!aborted) {
|
||||
console.warn(
|
||||
`[agent:session] não consegui falar com o servidor ${state.url} — encerrando o driver local mesmo assim.`,
|
||||
)
|
||||
}
|
||||
const killed = await stopDriver(state.driverPid, { sessionID: state.sessionID })
|
||||
writeState(sessionDir, { ...state, driverPid: null, stoppedAt: new Date().toISOString() })
|
||||
console.log(
|
||||
`run encerrado: ${state.sessionID} (${state.branch})${aborted ? '' : ' [abort offline]'}${killed ? '' : ' [driver já havia saído]'}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Onde o `list` sonda: `server.json` (gravado pelo `serve`, inclusive com
|
||||
* `--port/--hostname` explícitos) > env/flags > defaults.
|
||||
*/
|
||||
const serverConfigForList = ({ sessionDir, flags }) => {
|
||||
const statePath = serverStatePath({ sessionDir })
|
||||
if (flags.hostname || flags.port || !existsSync(statePath)) {
|
||||
return resolveServerConfig({ hostname: flags.hostname ?? null, port: flags.port ?? null })
|
||||
}
|
||||
try {
|
||||
const server = parseServerState(readFileSync(statePath, 'utf8'))
|
||||
return { hostname: server.hostname, port: server.port, url: server.url }
|
||||
} catch (error) {
|
||||
console.warn(`[agent:session] server.json ilegível (${error.message}) — usando o default.`)
|
||||
return resolveServerConfig({})
|
||||
}
|
||||
}
|
||||
|
||||
const cmdList = async (flags) => {
|
||||
const sessionDir = sessionDirFromEnv()
|
||||
const states = loadStates(sessionDir)
|
||||
const config = serverConfigForList({ sessionDir, flags })
|
||||
const health = await serverHealth(config.url)
|
||||
const serverHealthy = Boolean(health?.healthy)
|
||||
const statuses = serverHealthy ? await sessionStatuses(config.url) : {}
|
||||
const derived = {}
|
||||
for (const state of states) {
|
||||
derived[state.sessionID] = deriveSessionStatus({
|
||||
state,
|
||||
serverHealthy,
|
||||
sessionBusy: statuses[state.sessionID]?.type === 'busy',
|
||||
driverAlive: driverAlive(state.driverPid, state.sessionID),
|
||||
})
|
||||
}
|
||||
if (flags.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
sessionListPayload({
|
||||
states,
|
||||
statuses: derived,
|
||||
server: {
|
||||
url: config.url,
|
||||
healthy: serverHealthy,
|
||||
version: health?.version ?? null,
|
||||
},
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (states.length === 0) {
|
||||
console.log(`nenhuma sessão registrada em ${sessionDir}`)
|
||||
return
|
||||
}
|
||||
console.log('STATUS REF SESSÃO BRANCH DIR')
|
||||
for (const line of formatSessionList(
|
||||
states.map((state) => ({ ...state, status: derived[state.sessionID] })),
|
||||
)) {
|
||||
console.log(line)
|
||||
}
|
||||
if (!serverHealthy) {
|
||||
console.log(
|
||||
health?.authRequired
|
||||
? '(servidor de pé mas exige credencial: exporte OPENCODE_SERVER_PASSWORD)'
|
||||
: `(servidor fora: ${config.url} — suba com \`pnpm agent:session serve\`)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { flags, positional } = parseEqualsFlags(process.argv.slice(2))
|
||||
const subcommand = positional[0]
|
||||
|
||||
try {
|
||||
if (positional.length > 1) {
|
||||
die(
|
||||
`argumento posicional inesperado: ${positional.slice(1).join(' ')} (use --flag=valor; veja o USAGE).`,
|
||||
)
|
||||
}
|
||||
if (subcommand === 'serve') await cmdServe(flags)
|
||||
else if (subcommand === 'start') {
|
||||
process.exitCode = await cmdStart(flags)
|
||||
} else if (subcommand === 'attach') {
|
||||
process.exitCode = await cmdAttach(flags)
|
||||
} else if (subcommand === 'stop') await cmdStop(flags)
|
||||
else if (subcommand === 'list') await cmdList(flags)
|
||||
else {
|
||||
console.log(USAGE)
|
||||
process.exitCode = 1
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.stderr) die(error.stderr.toString().trim())
|
||||
die(error?.message ?? String(error))
|
||||
}
|
||||
323
scripts/lib/agent-session.mjs
Normal file
323
scripts/lib/agent-session.mjs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
/**
|
||||
* OPS110 — sessões persistentes de agente (attach/detach).
|
||||
*
|
||||
* Pura por contrato: nada aqui faz I/O além de resolver paths (o CLI
|
||||
* `scripts/agent-session.mjs` é quem spawna/HTTP). O ciclo: um `opencode serve`
|
||||
* compartilhado (loopback por padrão) é dono das sessões; cada run é dirigido
|
||||
* por um `opencode run --attach … --auto --command …` destacado (é o `--auto`
|
||||
* dele que aprova permissões enquanto o run vive) e observado por um TUI
|
||||
* `opencode attach -s <sessionID>` que pode entrar e sair à vontade — sair do
|
||||
* cliente nunca encerra o run; encerrar é `stop` (abort via API).
|
||||
*
|
||||
* O estado por run (`sessionStatePath`) é o índice de endereçamento consumido
|
||||
* pelo painel OPS109 (`list --json`): branch/dir/sessionID/url/driverPid/log.
|
||||
* Nenhuma credencial é persistida — `OPENCODE_SERVER_PASSWORD`/
|
||||
* `OPENCODE_SERVER_USERNAME` vivem só no ambiente; bind fora do loopback sem
|
||||
* senha falha fechado (`validateServerBind`).
|
||||
*/
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { slugify } from '../../src/lib/slug.ts'
|
||||
|
||||
/** Default host of the shared agent server — loopback only, never `0.0.0.0`. */
|
||||
export const DEFAULT_AGENT_SERVER_HOST = '127.0.0.1'
|
||||
|
||||
/**
|
||||
* Default port. Fora da faixa de dev dos worktrees (`3100+slot`, slot ≤ 999 →
|
||||
* até 4099) e longe dos servidores que a máquina já pode ter (4096 Zed ACP,
|
||||
* 4097 `opencode web` do usuário). `TEQO_AGENT_SERVER_PORT` overrides.
|
||||
*/
|
||||
export const DEFAULT_AGENT_SERVER_PORT = 4199
|
||||
|
||||
/** State-file schema version — `list --json` carries it for OPS109. */
|
||||
export const STATE_VERSION = 1
|
||||
|
||||
/**
|
||||
* Resolve host/port/url for the shared server: explicit flags (`serve
|
||||
* --hostname/--port`) > env (`TEQO_AGENT_SERVER_HOST/PORT`) > defaults. Fail
|
||||
* high on a non-numeric/out-of-range port — never guess a listening address.
|
||||
* @param {{ hostname?: string | null, port?: number | string | null, env?: Record<string, string | undefined> }} [options]
|
||||
*/
|
||||
export const resolveServerConfig = ({ hostname = null, port = null, env = process.env } = {}) => {
|
||||
const resolvedHostname = hostname ?? env.TEQO_AGENT_SERVER_HOST ?? DEFAULT_AGENT_SERVER_HOST
|
||||
const rawPort = port ?? env.TEQO_AGENT_SERVER_PORT ?? DEFAULT_AGENT_SERVER_PORT
|
||||
const resolvedPort = Number(rawPort)
|
||||
if (!Number.isInteger(resolvedPort) || resolvedPort < 1 || resolvedPort > 65535) {
|
||||
throw new Error(`porta inválida para o servidor de sessões: ${rawPort}`)
|
||||
}
|
||||
return {
|
||||
hostname: resolvedHostname,
|
||||
port: resolvedPort,
|
||||
url: `http://${resolvedHostname}:${resolvedPort}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback spellings only — `0.0.0.0`, hostnames that merely START with `127.`
|
||||
* (`127.0.0.1.example.com`) and any routable address are NOT loopback.
|
||||
*/
|
||||
export const isLoopbackHost = (hostname) => {
|
||||
const host = String(hostname ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
if (host === 'localhost' || host === '::1') return true
|
||||
return /^127(\.\d{1,3}){3}$/.test(host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed bind policy: anything beyond loopback requires the opencode
|
||||
* server credential (`OPENCODE_SERVER_PASSWORD`); loopback may run without it.
|
||||
* Returns true on success so callers can gate the spawn on the exception.
|
||||
*/
|
||||
export const validateServerBind = ({ hostname, password }) => {
|
||||
if (isLoopbackHost(hostname)) return true
|
||||
if (typeof password === 'string' && password.length > 0) return true
|
||||
throw new Error(
|
||||
`bind em ${hostname} exige credencial: defina OPENCODE_SERVER_PASSWORD (fail-closed fora do loopback)`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* State dir: `TEQO_AGENT_SESSION_DIR` > `$XDG_STATE_HOME/teqo/agent-sessions` >
|
||||
* `~/.local/state/teqo/agent-sessions`.
|
||||
* @param {Record<string, string | undefined>} [env]
|
||||
*/
|
||||
export const sessionDirFromEnv = (env = process.env) => {
|
||||
if (env.TEQO_AGENT_SESSION_DIR) return env.TEQO_AGENT_SESSION_DIR
|
||||
const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state')
|
||||
return join(stateHome, 'teqo', 'agent-sessions')
|
||||
}
|
||||
|
||||
/** One filename-safe key per branch — `plans/plan-issue-1` → `plans-plan-issue-1`. */
|
||||
export const sessionSlug = (branch) => slugify(String(branch ?? ''))
|
||||
|
||||
/** `<code>-<slug>` claim branch → code; namespace branches (`plans/`, `work/`, `fix/`) → null. */
|
||||
export const codeFromBranch = (branch) => {
|
||||
const match = /^([A-Z][A-Za-z0-9]*)-/.exec(String(branch ?? ''))
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
export const sessionStatePath = ({ sessionDir, branch }) =>
|
||||
join(sessionDir, `${sessionSlug(branch)}.json`)
|
||||
|
||||
export const serverStatePath = ({ sessionDir }) => join(sessionDir, 'server.json')
|
||||
|
||||
export const startLockPath = ({ sessionDir, branch }) =>
|
||||
join(sessionDir, `${sessionSlug(branch)}.lock`)
|
||||
|
||||
export const driverLogPath = ({ sessionDir, branch }) =>
|
||||
join(sessionDir, `${sessionSlug(branch)}.log`)
|
||||
|
||||
const assertSessionState = (state) => {
|
||||
const invalid = (reason) => {
|
||||
throw new Error(`estado de sessão inválido: ${reason}`)
|
||||
}
|
||||
if (!state || typeof state !== 'object' || Array.isArray(state)) invalid('não é objeto')
|
||||
if (!Number.isInteger(state.version) || state.version < 1) invalid('version')
|
||||
if (typeof state.sessionID !== 'string' || !state.sessionID.startsWith('ses'))
|
||||
invalid('sessionID')
|
||||
if (typeof state.branch !== 'string' || state.branch.length === 0) invalid('branch')
|
||||
if (typeof state.dir !== 'string' || state.dir.length === 0) invalid('dir')
|
||||
if (typeof state.url !== 'string' || !state.url.startsWith('http')) invalid('url')
|
||||
return state
|
||||
}
|
||||
|
||||
export const serializeSessionState = (state) =>
|
||||
`${JSON.stringify(assertSessionState(state), null, 2)}\n`
|
||||
|
||||
export const parseSessionState = (text) => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('estado de sessão inválido: JSON ilegível')
|
||||
}
|
||||
return assertSessionState(parsed)
|
||||
}
|
||||
|
||||
const assertServerState = (state) => {
|
||||
const invalid = (reason) => {
|
||||
throw new Error(`estado do servidor inválido: ${reason}`)
|
||||
}
|
||||
if (!state || typeof state !== 'object' || Array.isArray(state)) invalid('não é objeto')
|
||||
if (typeof state.url !== 'string' || !state.url.startsWith('http')) invalid('url')
|
||||
if (!Number.isInteger(state.port)) invalid('port')
|
||||
if (!Number.isInteger(state.pid) || state.pid <= 0) invalid('pid')
|
||||
return state
|
||||
}
|
||||
|
||||
export const serializeServerState = (state) =>
|
||||
`${JSON.stringify(assertServerState(state), null, 2)}\n`
|
||||
|
||||
export const parseServerState = (text) => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('estado do servidor inválido: JSON ilegível')
|
||||
}
|
||||
return assertServerState(parsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill command per launch purpose — the terminal launch auto-submits it on
|
||||
* the server-owned session. `new` deliberately has none ("apenas conversar"):
|
||||
* the session is created and attached, no driver.
|
||||
*/
|
||||
export const SESSION_COMMAND_BY_PURPOSE = {
|
||||
next: 'work-issue',
|
||||
plan: 'plan-issue',
|
||||
new: null,
|
||||
fix: 'bug-fix',
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ command, arguments }` for the purpose, or null when there is nothing to
|
||||
* auto-submit. `next` carries only a valid claimed issue (`--issue N`), `fix`
|
||||
* carries the sanitized bag (quotes/backslashes stripped — the value rides a
|
||||
* single argv token), `plan` sends the bare command, unknown purposes degrade
|
||||
* to no command (fail-safe, mirrors the old directive).
|
||||
* @param {{ purpose?: string, issueNumber?: number | string | null, argument?: string | null }} [options]
|
||||
*/
|
||||
export const purposeInvocation = ({ purpose = null, issueNumber = null, argument = null } = {}) => {
|
||||
const command = SESSION_COMMAND_BY_PURPOSE[purpose] ?? null
|
||||
if (!command) return null
|
||||
if (purpose === 'next') {
|
||||
const number = Number(issueNumber)
|
||||
if (!Number.isInteger(number) || number <= 0) return null
|
||||
return { command, arguments: `--issue ${number}` }
|
||||
}
|
||||
if (purpose === 'fix') {
|
||||
// Mesma sanitização da diretiva de launch (fronteira dupla de propósito:
|
||||
// o xargs da camada shell e o argv do `opencode run` não honram escapes).
|
||||
const sanitized = typeof argument === 'string' ? argument.replace(/["\\]/g, '').trim() : ''
|
||||
return { command, arguments: sanitized }
|
||||
}
|
||||
return { command, arguments: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* argv of the detached run driver (sem o binário — o caller usa
|
||||
* `spawn('opencode', …)`). The `--` separator is required so a command
|
||||
* argument starting with `-` (`--issue 1019`) is not parsed as an opencode flag
|
||||
* by yargs — verified live on 1.18.31. No `-p`: the credential rides
|
||||
* `OPENCODE_SERVER_PASSWORD` in the inherited env (never in `ps`).
|
||||
* @param {{ url: string, sessionID: string, dir: string, model: string, invocation: { command: string, arguments?: string } | null }} options
|
||||
*/
|
||||
export const driverArgs = ({ url, sessionID, dir, model, invocation }) => {
|
||||
if (typeof url !== 'string' || url.length === 0) throw new Error('driverArgs: url ausente')
|
||||
if (typeof sessionID !== 'string' || sessionID.length === 0) {
|
||||
throw new Error('driverArgs: session ausente')
|
||||
}
|
||||
if (typeof dir !== 'string' || dir.length === 0) throw new Error('driverArgs: dir ausente')
|
||||
if (typeof model !== 'string' || model.length === 0) throw new Error('driverArgs: model ausente')
|
||||
if (!invocation || typeof invocation.command !== 'string' || invocation.command.length === 0) {
|
||||
throw new Error('driverArgs: invocation/comando ausente')
|
||||
}
|
||||
const argv = [
|
||||
'run',
|
||||
'--attach',
|
||||
url,
|
||||
'-s',
|
||||
sessionID,
|
||||
'--dir',
|
||||
dir,
|
||||
'--model',
|
||||
model,
|
||||
'--auto',
|
||||
'--command',
|
||||
invocation.command,
|
||||
]
|
||||
if (invocation.arguments) argv.push('--', invocation.arguments)
|
||||
return argv
|
||||
}
|
||||
|
||||
/**
|
||||
* Args do TUI anexado, SEM o binário (`opencode attach` não tem
|
||||
* `--auto`/`--model` por design; o caller usa `spawn('opencode', …)`).
|
||||
*/
|
||||
export const attachArgs = ({ url, sessionID, dir }) => {
|
||||
if (typeof url !== 'string' || url.length === 0) throw new Error('attachArgs: url ausente')
|
||||
if (typeof sessionID !== 'string' || sessionID.length === 0) {
|
||||
throw new Error('attachArgs: session ausente')
|
||||
}
|
||||
if (typeof dir !== 'string' || dir.length === 0) throw new Error('attachArgs: dir ausente')
|
||||
return ['attach', url, '--dir', dir, '-s', sessionID]
|
||||
}
|
||||
|
||||
/** Args do servidor headless, SEM o binário. Nunca `--mdns` (anunciaria na rede). */
|
||||
export const serverArgs = ({ port, hostname }) => {
|
||||
if (!Number.isInteger(port)) throw new Error('serverArgs: porta inválida')
|
||||
if (typeof hostname !== 'string' || hostname.length === 0) {
|
||||
throw new Error('serverArgs: hostname ausente')
|
||||
}
|
||||
return ['serve', '--port', String(port), '--hostname', hostname]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the state a command refers to — `--session` > `--branch` > `--issue` >
|
||||
* the current branch. Null when nothing matches (callers print the known refs).
|
||||
* @param {{ states: Array<Record<string, unknown>>, session?: string | null, branch?: string | null, issue?: number | string | null, cwdBranch?: string | null }} options
|
||||
*/
|
||||
export const resolveSessionRef = ({
|
||||
states,
|
||||
session = null,
|
||||
branch = null,
|
||||
issue = null,
|
||||
cwdBranch = null,
|
||||
}) => {
|
||||
const list = Array.isArray(states) ? states : []
|
||||
if (session) {
|
||||
const hit = list.find((state) => state.sessionID === session)
|
||||
if (hit) return hit
|
||||
}
|
||||
if (branch) {
|
||||
const hit = list.find((state) => state.branch === branch)
|
||||
if (hit) return hit
|
||||
}
|
||||
const number = issue === null || issue === '' ? null : Number(issue)
|
||||
if (Number.isInteger(number) && number > 0) {
|
||||
const hit = list.find((state) => state.issue === number)
|
||||
if (hit) return hit
|
||||
}
|
||||
if (cwdBranch) {
|
||||
const hit = list.find((state) => state.branch === cwdBranch)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Status shown by `list`: `stopped` (explicit stop) wins, then `unknown`
|
||||
* (server unreachable), then `working` (busy on the server or driver alive),
|
||||
* else `idle`.
|
||||
*/
|
||||
export const deriveSessionStatus = ({ state, serverHealthy, sessionBusy, driverAlive }) => {
|
||||
if (state?.stoppedAt) return 'stopped'
|
||||
if (!serverHealthy) return 'unknown'
|
||||
if (sessionBusy || driverAlive) return 'working'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned machine contract of `list --json` (OPS109 consumes it):
|
||||
* `{ version, server, sessions: [{...state, status}] }`.
|
||||
*/
|
||||
export const sessionListPayload = ({ states, statuses = {}, server }) => ({
|
||||
version: STATE_VERSION,
|
||||
server,
|
||||
sessions: (Array.isArray(states) ? states : []).map((state) => ({
|
||||
...state,
|
||||
status: statuses[state.sessionID] ?? 'unknown',
|
||||
})),
|
||||
})
|
||||
|
||||
/** Human `list` lines — one per run, status first, `—` for namespace runs. */
|
||||
export const formatSessionList = (rows) =>
|
||||
(Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const reference =
|
||||
[row.code, row.issue ? `#${row.issue}` : null].filter(Boolean).join(' ') || '—'
|
||||
return `${String(row.status).padEnd(8)} ${reference} ${row.sessionID} ${row.branch} ${row.dir}`
|
||||
})
|
||||
|
|
@ -77,24 +77,6 @@ export const resolveWorktreeModel = (flags = {}) => {
|
|||
return OPENCODE_PRESET_MODEL
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill command sent as the launch's initial message per purpose. `next`
|
||||
* sends `/work-issue` (the OPS25 command executes the full cycle; the OPS33
|
||||
* launch appends `--issue <N>` — the claimed issue — via
|
||||
* `opencodeLaunchDirective`); `plan` sends `/plan-issue` (OPS31 — auto-submit
|
||||
* is the real need of the flow), `fix` sends `/bug-fix` (with the bug
|
||||
* description as the argument) and `new` sends nothing ("apenas conversar",
|
||||
* no skill at all). The opencode CLI's `--prompt` always auto-submits, there
|
||||
* is no prefill-without-submit flag; prefill stays an upstream-only feature
|
||||
* request (proposition annotated), not a Teqo fallback.
|
||||
*/
|
||||
export const OPENCODE_SKILL_COMMAND_BY_PURPOSE = {
|
||||
next: '/work-issue',
|
||||
plan: '/plan-issue',
|
||||
new: null,
|
||||
fix: '/bug-fix',
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix of every neutral-worktree branch (`pnpm worktree new`). Lowercase-led
|
||||
* `work/…` — structurally disjoint from `next`'s uppercase-led `<Code>-<slug>`
|
||||
|
|
@ -188,21 +170,32 @@ const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback })
|
|||
/**
|
||||
* Launch directive for the opencode TUI, printed by `worktree next`/`plan`
|
||||
* right before the `cd <dir>` line when called from the interactive terminal
|
||||
* (`WORKTREE_TERMINAL=1`): `launch opencode <dir> --model <preset|map>
|
||||
* --auto [--prompt "<command>"]` (OPS95: no `--variant` — the TUI yargs
|
||||
* rejects the unknown flag and the helper prints instead of opening; modelo
|
||||
* vem do mapa `WORKTREE_MODEL_MAP` quando a flag `--cheap/--pro/--zen/
|
||||
* --go/--alibaba/--glm/--free` está presente, senão do preset). The shell function
|
||||
* (`.agents/shell/worktree.sh`) applies the `cd` first, then tokenizes and
|
||||
* executes this line (xargs — quote-aware, never eval) — the dir is always
|
||||
* `<root without spaces>/<slugified branch>`, so the line never needs quoting
|
||||
* for the path. `next` with an `issueNumber` sends `/work-issue --issue <N>`
|
||||
* (`WORKTREE_TERMINAL=1`): `launch node scripts/agent-session.mjs start
|
||||
* --purpose=<P> --dir=<dir> --model=<preset|map> [--issue=<N>]
|
||||
* [--argument="<bag>"]` (OPS110 — o launch deixa de abrir um TUI local dono da
|
||||
* sessão e passa a delegar ao `scripts/agent-session.mjs`, que sobe/anexa no
|
||||
* servidor compartilhado: o run sobrevive ao fechamento do terminal e pode ser
|
||||
* reatado de outro dispositivo). O mapa purpose→skill e a montagem do comando
|
||||
* vivem no dono do ciclo de vida (`scripts/lib/agent-session.mjs`,
|
||||
* `purposeInvocation`). OPS95 continua: sem `--variant`; modelo vem do mapa
|
||||
* `WORKTREE_MODEL_MAP` quando a flag `--cheap/--pro/--zen/--go/--alibaba/--glm/--free`
|
||||
* está presente, senão do preset.
|
||||
*
|
||||
* The shell function (`.agents/shell/worktree.sh`) applies the `cd` first, then
|
||||
* tokenizes and executes this line (xargs — quote-aware, never eval) — the dir
|
||||
* is always `<root without spaces>/<slugified branch>`, so the line never needs
|
||||
* quoting for the path. `next` with an `issueNumber` carries `--issue=<N>`
|
||||
* (OPS33: the launch delivers the claimed issue to the agent; the skill reads
|
||||
* the rest from GitHub), `fix` with an `argument` sends `/bug-fix <argument>`
|
||||
* (the bug description arrives with the skill), the prompt value is quoted in
|
||||
* the line because it now carries a space. Returns `null` outside the terminal
|
||||
* so the `/worktree` opencode command never launches a nested TUI.
|
||||
* @param {{ dir: string, purpose: string, terminal?: boolean, issueNumber?: number | null, model?: string | null, argument?: string | null }} options
|
||||
* the rest from GitHub), `fix` with an `argument` carries `--argument="<bag>"`
|
||||
* (the bug description arrives with the skill; quotes/backslashes are stripped
|
||||
* because the shell layer tokenizes with xargs, which does not honor
|
||||
* backslash escapes). Returns `null` outside the terminal so the `/worktree`
|
||||
* opencode command never launches a nested TUI.
|
||||
* `sessionScript` é o caminho do CLI de sessão — `worktree.mjs` passa o
|
||||
* ABSOLUTO do checkout que emitiu a diretiva (worktrees reabertos/criados
|
||||
* antes do merge não têm o arquivo novo; o relativo apontaria para o branch
|
||||
* errado). O default relativo existe para os specs.
|
||||
* @param {{ dir: string, purpose: string, terminal?: boolean, issueNumber?: number | null, model?: string | null, argument?: string | null, sessionScript?: string }} options
|
||||
*/
|
||||
export const opencodeLaunchDirective = ({
|
||||
dir,
|
||||
|
|
@ -211,25 +204,29 @@ export const opencodeLaunchDirective = ({
|
|||
issueNumber = null,
|
||||
model = null,
|
||||
argument = null,
|
||||
sessionScript = 'scripts/agent-session.mjs',
|
||||
}) => {
|
||||
if (!terminal) return null
|
||||
const prompt = OPENCODE_SKILL_COMMAND_BY_PURPOSE[purpose]
|
||||
const selectedModel = model ?? OPENCODE_PRESET_MODEL
|
||||
const args = [dir, '--model', selectedModel, '--auto']
|
||||
if (prompt) {
|
||||
// The issue suffix belongs to `next` alone — `plan`/`new`/`fix` never carry
|
||||
// a claimed issue (fail-safe: a stray issueNumber must not break them).
|
||||
const issueSuffix = purpose === 'next' && issueNumber ? `--issue ${issueNumber}` : null
|
||||
// The bag suffix belongs to `fix` alone — the bug description arrives with
|
||||
// the skill. Quotes/backslashes are stripped: the shell layer tokenizes the
|
||||
// directive with xargs, which does not honor backslash escapes.
|
||||
const sanitized = typeof argument === 'string' ? argument.replace(/["\\]/g, '').trim() : null
|
||||
const bagSuffix = purpose === 'fix' && sanitized ? sanitized : null
|
||||
const value = [prompt, issueSuffix, bagSuffix].filter(Boolean).join(' ')
|
||||
// JSON.stringify quotes the value — the directive carries spaces now.
|
||||
args.push('--prompt', JSON.stringify(value))
|
||||
const args = [
|
||||
'node',
|
||||
sessionScript,
|
||||
'start',
|
||||
`--purpose=${purpose}`,
|
||||
`--dir=${dir}`,
|
||||
`--model=${selectedModel}`,
|
||||
]
|
||||
// The issue suffix belongs to `next` alone — `plan`/`new`/`fix` never carry
|
||||
// a claimed issue (fail-safe: a stray issueNumber must not break them).
|
||||
if (purpose === 'next' && issueNumber) args.push(`--issue=${issueNumber}`)
|
||||
// The bag suffix belongs to `fix` alone — the bug description arrives with
|
||||
// the skill. JSON.stringify quotes the value (it carries spaces); xargs
|
||||
// strips the quotes at execution time.
|
||||
if (purpose === 'fix') {
|
||||
const sanitized = typeof argument === 'string' ? argument.replace(/["\\]/g, '').trim() : ''
|
||||
if (sanitized) args.push(`--argument=${JSON.stringify(sanitized)}`)
|
||||
}
|
||||
return `launch opencode ${args.join(' ')}`
|
||||
return `launch ${args.join(' ')}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -30,18 +30,23 @@
|
|||
* `deepseek/deepseek-flash` permanece.
|
||||
* Chamado do terminal interativo (com
|
||||
* `TEQO_WORKTREE_TERMINAL=1`, que só a função shell
|
||||
* seta), imprime também a diretiva `launch
|
||||
* opencode <dir> --model <preset|map>
|
||||
* --auto --prompt "/work-issue --issue <N>"` ANTES
|
||||
* do `cd` — a função shell executa o cd e então a
|
||||
* linha, e o TUI do opencode abre no worktree com
|
||||
* `/work-issue --issue <N>` já enviado (OPS26 +
|
||||
* OPS33: o launch entrega a Issue claimada ao
|
||||
* agente; a skill lê o resto do GitHub). OPS95:
|
||||
* sem `--variant` na diretiva — o yargs do TUI
|
||||
* rejeita o flag e só imprime o helper; variantes
|
||||
* ficam na config global da máquina (Ctrl+T).
|
||||
* Presets e mapa em scripts/lib/worktree.mjs.
|
||||
* seta), imprime também a diretiva `launch node
|
||||
* scripts/agent-session.mjs start --purpose=next
|
||||
* --dir=<dir> --model <preset|map> --issue=<N>`
|
||||
* ANTES do `cd` — a função shell executa o cd e
|
||||
* então a linha. Desde a OPS110 o launch não abre um
|
||||
* TUI local dono da sessão: o `agent-session` sobe
|
||||
* o `opencode serve` compartilhado, cria a sessão
|
||||
* endereçável, dispara o driver destacado com
|
||||
* `/work-issue --issue <N>` (auto-submit; a skill lê
|
||||
* o resto do GitHub) e anexa o TUI
|
||||
* (`opencode attach -s <sessionID>`) — fechar o
|
||||
* terminal NÃO encerra o run; encerrar é
|
||||
* `pnpm agent:session stop`. OPS95 continua: sem
|
||||
* `--variant`; variantes ficam na config global da
|
||||
* máquina (Ctrl+T). Presets e mapa em
|
||||
* scripts/lib/worktree.mjs; ciclo de vida em
|
||||
* scripts/lib/agent-session.mjs.
|
||||
* Sem o marcador (comando `/worktree` do opencode),
|
||||
* a diretiva não é impressa — nunca abre TUI aninhado.
|
||||
* Também PROVISIONA o ambiente isolado do worktree:
|
||||
|
|
@ -71,11 +76,11 @@
|
|||
* deles (branch nem slot) colide com um `next`
|
||||
* posterior (prefixo minúsculo `plans/…`).
|
||||
* Mesmo provisionamento isolado do `next`; no
|
||||
* terminal, mesma diretiva `launch` — com
|
||||
* `--prompt /plan-issue` já enviado (OPS31: o TUI
|
||||
* abre no fluxo de planejamento, sem digitação) e
|
||||
* `--model <map>` quando a flag de modelo está
|
||||
* presente.
|
||||
* terminal, mesma diretiva `launch` — o driver
|
||||
* destacado auto-submete `/plan-issue` (OPS31: a
|
||||
* sessão abre no fluxo de planejamento, sem
|
||||
* digitação) e `--model <map>` quando a flag de
|
||||
* modelo está presente (OPS110: attach/detach).
|
||||
* pnpm worktree new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
|
||||
* cria um worktree NEUTRO novo — sem função
|
||||
* pré-definida (explorar ideia, conversar, ou
|
||||
|
|
@ -87,10 +92,9 @@
|
|||
* nunca colide com `<code>-<slug>` de `next` nem
|
||||
* com `plans/plan-issue-…` de `plan`.
|
||||
* Mesmo provisionamento isolado do `next`/`plan`;
|
||||
* no terminal, mesma diretiva `launch` — SEM
|
||||
* `--prompt` (apenas conversar, nenhuma skill) e
|
||||
* `--model <map>` quando a flag de modelo está
|
||||
* presente.
|
||||
* no terminal, mesma diretiva `launch` — sessão
|
||||
* sem skill (apenas conversar) e `--model <map>`
|
||||
* quando a flag de modelo está presente (OPS110).
|
||||
* `--stay` suprime a linha `cd` e a diretiva;
|
||||
* `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` escolhe o
|
||||
* modelo por invocação (sem flag o preset permanece).
|
||||
|
|
@ -106,22 +110,25 @@
|
|||
* com `plans/plan-issue-…` de `plan` nem com
|
||||
* `work/…` de `new`. Mesmo provisionamento
|
||||
* isolado do `next`/`plan`/`new`; no terminal, a
|
||||
* diretiva `launch` envia `--prompt
|
||||
* "/bug-fix <bag>"` — a descrição do bug chega ao
|
||||
* agente junto com a skill (aspas/barra-invertida
|
||||
* do bag são removidas — o xargs da camada shell
|
||||
* não honra escapes); `--model <map>` quando a
|
||||
* flag de modelo está presente; `--stay` suprime
|
||||
* diretiva `launch` carrega `--argument="<bag>"`
|
||||
* — o driver destacado auto-submete `/bug-fix
|
||||
* <bag>` e a descrição do bug chega ao agente
|
||||
* junto com a skill (aspas/barra-invertida do bag
|
||||
* são removidas — o xargs da camada shell não
|
||||
* honra escapes); `--model <map>` quando a flag de
|
||||
* modelo está presente (OPS110); `--stay` suprime
|
||||
* cd e launch (nenhuma Issue é claimada nem
|
||||
* criada — o registro do bug é o post-mortem).
|
||||
* pnpm worktree kill [--force] destrói o worktree em que o shell atual está
|
||||
* (recusa worktree sujo sem `--force`) e remove os
|
||||
* bancos gerados do worktree (best-effort); por
|
||||
* padrão termina imprimindo `cd <main>` para o
|
||||
* shell voltar ao worktree principal — o cwd nunca
|
||||
* fica num diretório destruído (não aceita
|
||||
* `--stay`; sem launch, as flags de modelo são
|
||||
* irrelevantes)
|
||||
* (recusa worktree sujo sem `--force`), ENCERRA a
|
||||
* sessão persistente do run (OPS110: abort + fim
|
||||
* do driver; o estado/lock são removidos — sem
|
||||
* sessão órfã) e remove os bancos gerados do
|
||||
* worktree (best-effort); por padrão termina
|
||||
* imprimindo `cd <main>` para o shell voltar ao
|
||||
* worktree principal — o cwd nunca fica num
|
||||
* diretório destruído (não aceita `--stay`; sem
|
||||
* launch, as flags de modelo são irrelevantes)
|
||||
*
|
||||
* Read-only no GitHub? NÃO — desde o OPS33 `next` CLAIMA: claim determinístico
|
||||
* antes do worktree (mesma fila/ordem e lock otimista do `pnpm agent:claim`;
|
||||
|
|
@ -139,10 +146,12 @@ import {
|
|||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { parse as parseEnv } from 'dotenv'
|
||||
import pg from 'pg'
|
||||
|
|
@ -157,6 +166,7 @@ import {
|
|||
nextClaimableIssue,
|
||||
parseArgs,
|
||||
} from './lib/agent-forgejo.mjs'
|
||||
import { sessionDirFromEnv, sessionStatePath, startLockPath } from './lib/agent-session.mjs'
|
||||
import { startSharedPostgres } from './lib/db-start.mjs'
|
||||
import { githubApi as api } from './lib/github-api.mjs'
|
||||
import {
|
||||
|
|
@ -183,6 +193,12 @@ import {
|
|||
|
||||
const die = dieAgent('worktree')
|
||||
const WORKTREES_ROOT = process.env.WORKTREES_ROOT ?? join(homedir(), '.cursor', 'worktrees', 'teqo')
|
||||
/**
|
||||
* Root deste checkout — o `stopAgentSession` reusa o CLI daqui e a diretiva de
|
||||
* launch carrega o caminho ABSOLUTO dele (OPS110: worktree antigo não tem o
|
||||
* arquivo novo no branch).
|
||||
*/
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
/**
|
||||
* True when the interactive terminal shell function (`.agents/shell/worktree.sh`)
|
||||
|
|
@ -211,6 +227,9 @@ const printLaunchDirective = ({ dir, purpose, issueNumber, argument = null, flag
|
|||
issueNumber,
|
||||
model,
|
||||
argument,
|
||||
// Absoluto de propósito (OPS110): worktrees reabertos/criados antes do
|
||||
// merge não têm `scripts/agent-session.mjs`; o relativo quebraria o launch.
|
||||
sessionScript: join(REPO_ROOT, 'scripts', 'agent-session.mjs'),
|
||||
})
|
||||
if (line) console.log(line)
|
||||
}
|
||||
|
|
@ -834,6 +853,52 @@ const dropWorktreeDatabases = async (names) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPS110 — encerra a sessão persistente do run deste worktree antes de
|
||||
* destruí-lo (`stop`: abort via API + fim do driver). Só age quando existe
|
||||
* estado registrado (worktrees anteriores à OPS110 não têm sessão); o estado e
|
||||
* o lock são removidos ao fim para não deixar sessão órfã apontando para um
|
||||
* diretório destruído.
|
||||
*/
|
||||
const stopAgentSession = (branch) => {
|
||||
if (!branch) return
|
||||
const sessionDir = sessionDirFromEnv()
|
||||
const statePath = sessionStatePath({ sessionDir, branch })
|
||||
const lockPath = startLockPath({ sessionDir, branch })
|
||||
if (existsSync(statePath)) {
|
||||
let stopped = true
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[join(REPO_ROOT, 'scripts', 'agent-session.mjs'), 'stop', `--branch=${branch}`],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
} catch (error) {
|
||||
stopped = false
|
||||
console.warn(
|
||||
`[worktree] não consegui encerrar a sessão do run (${String(error.message).split('\n')[0]}); o kill segue.`,
|
||||
)
|
||||
}
|
||||
// Só descarta o endereço se o stop funcionou — um driver sobrevivente sem
|
||||
// estado viraria órfão invisível (e o estado fica para o `agent:session`).
|
||||
if (stopped) {
|
||||
try {
|
||||
unlinkSync(statePath)
|
||||
} catch {
|
||||
// já removido
|
||||
}
|
||||
} else {
|
||||
console.warn(`[worktree] a sessão pode seguir viva — estado preservado em ${statePath}`)
|
||||
}
|
||||
}
|
||||
// O lock é artefato transitório do `start` — não sobrevive ao worktree.
|
||||
try {
|
||||
unlinkSync(lockPath)
|
||||
} catch {
|
||||
// não havia lock
|
||||
}
|
||||
}
|
||||
|
||||
const cmdKill = async (force) => {
|
||||
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
|
||||
const mainRoot = entries[0]?.path
|
||||
|
|
@ -863,6 +928,9 @@ const cmdKill = async (force) => {
|
|||
// Read the generated DB names BEFORE the directory is removed.
|
||||
const databaseNames = worktreeDatabaseNamesOf(top)
|
||||
|
||||
// OPS110: encerra a sessão persistente do run antes de destruir o worktree.
|
||||
stopAgentSession(branch)
|
||||
|
||||
git(['-C', mainRoot, 'worktree', 'remove', '--force', top])
|
||||
if (branch) git(['-C', mainRoot, 'branch', '-D', branch])
|
||||
|
||||
|
|
@ -893,17 +961,17 @@ if (!subcommand) {
|
|||
console.log(' opencode command, ou a função `worktree()` de .agents/shell/worktree.sh);')
|
||||
console.log(' no terminal (TEQO_WORKTREE_TERMINAL=1) imprime também a diretiva')
|
||||
console.log(
|
||||
' `launch opencode <dir> --model <preset|map> --auto --prompt "/work-issue --issue <N>"` (OPS26+OPS33+OPS93+OPS95:',
|
||||
' `launch node scripts/agent-session.mjs start --purpose=next --dir=<dir> --model <preset|map> --issue=<N>` (OPS26+OPS33+OPS93+OPS95+OPS110:',
|
||||
)
|
||||
console.log(
|
||||
` abre o TUI com ${OPENCODE_PRESET_MODEL} (sem flag) ou com o mapa ${[
|
||||
` abre a sessão com ${OPENCODE_PRESET_MODEL} (sem flag) ou com o mapa ${[
|
||||
...WORKTREE_MODEL_FLAGS,
|
||||
]
|
||||
.map((flag) => `--${flag}=${WORKTREE_MODEL_MAP[flag]}`)
|
||||
.join(' ')} + auto + a Issue claimada já`,
|
||||
.join(' ')} + a Issue claimada auto-submetida pelo driver destacado;`,
|
||||
)
|
||||
console.log(
|
||||
' informada); --stay suprime cd e launch (o claim ainda acontece); --no-migrate pula migrations e o',
|
||||
' fechar o terminal NÃO encerra o run — `pnpm agent:session attach|stop`); --stay suprime cd e launch (o claim ainda acontece); --no-migrate pula migrations e o',
|
||||
)
|
||||
console.log(
|
||||
' seed mínimo (db:seed:minimal) nos bancos novos (OPS28: paridade com a CI); at-most-one de --cheap/--pro/--zen/--go/--alibaba/--glm/--free (múltiplas → erro)',
|
||||
|
|
@ -920,7 +988,7 @@ if (!subcommand) {
|
|||
' minúsculo plans/… nunca colide com o branch <code>-<slug> de `next`; no terminal,',
|
||||
)
|
||||
console.log(
|
||||
' mesma diretiva `launch` com --prompt /plan-issue enviado (abre no fluxo de planejamento, sem digitação) e --model <map> quando a flag está presente',
|
||||
' mesma diretiva `launch` — o driver destacado auto-submete /plan-issue (abre no fluxo de planejamento, sem digitação) e --model <map> quando a flag está presente',
|
||||
)
|
||||
console.log(
|
||||
`\n new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
|
||||
|
|
@ -930,7 +998,7 @@ if (!subcommand) {
|
|||
console.log(' próximo work/<n> sequencial livre; o prefixo minúsculo work/… nunca colide com')
|
||||
console.log(' o branch <code>-<slug> de `next` nem com plans/plan-issue-… de `plan`; no')
|
||||
console.log(
|
||||
' terminal, mesma diretiva `launch` porém sem --prompt (apenas conversar) e --model <map> quando a flag está presente',
|
||||
' terminal, mesma diretiva `launch` — sessão sem skill (apenas conversar) e --model <map> quando a flag está presente',
|
||||
)
|
||||
console.log(
|
||||
`\n fix [bag] [--stay] [--no-migrate] [--headless --directive <path>] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
|
||||
|
|
@ -940,7 +1008,7 @@ if (!subcommand) {
|
|||
console.log(' nome já existir); sem bag, o próximo fix/<n> sequencial livre; o prefixo')
|
||||
console.log(' minúsculo fix/… nunca colide com `next`, `plan` nem `new`; não claima nem cria')
|
||||
console.log(
|
||||
' Issues — o registro do bug é o post-mortem da skill; no terminal, a diretiva `launch` envia --prompt "/bug-fix <bag>" (a descrição chega com a skill) e --model <map> quando a flag está presente',
|
||||
' Issues — o registro do bug é o post-mortem da skill; no terminal, a diretiva `launch` carrega --argument="<bag>" (o driver destacado auto-submete /bug-fix <bag>, a descrição chega com a skill) e --model <map> quando a flag está presente',
|
||||
)
|
||||
console.log(
|
||||
' OPS106: --headless --directive <path> provisiona o mesmo worktree e grava a diretiva',
|
||||
|
|
@ -950,7 +1018,9 @@ if (!subcommand) {
|
|||
)
|
||||
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(' ENCERRA a sessão persistente do run (OPS110: abort + fim do')
|
||||
console.log(' driver; estado/lock removidos) e remove os bancos gerados do')
|
||||
console.log(' worktree (best-effort); imprime')
|
||||
console.log(' `cd <main>` no fim — o shell sempre volta ao worktree principal')
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
|
|||
490
tests/unit/agentSession.unit.spec.ts
Normal file
490
tests/unit/agentSession.unit.spec.ts
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
attachArgs,
|
||||
codeFromBranch,
|
||||
DEFAULT_AGENT_SERVER_HOST,
|
||||
DEFAULT_AGENT_SERVER_PORT,
|
||||
deriveSessionStatus,
|
||||
driverArgs,
|
||||
driverLogPath,
|
||||
formatSessionList,
|
||||
isLoopbackHost,
|
||||
parseServerState,
|
||||
parseSessionState,
|
||||
purposeInvocation,
|
||||
resolveServerConfig,
|
||||
resolveSessionRef,
|
||||
serializeServerState,
|
||||
serializeSessionState,
|
||||
serverArgs,
|
||||
serverStatePath,
|
||||
SESSION_COMMAND_BY_PURPOSE,
|
||||
sessionDirFromEnv,
|
||||
sessionListPayload,
|
||||
sessionSlug,
|
||||
sessionStatePath,
|
||||
startLockPath,
|
||||
STATE_VERSION,
|
||||
validateServerBind,
|
||||
} from '../../scripts/lib/agent-session.mjs'
|
||||
|
||||
const state = (over: Record<string, unknown> = {}) => ({
|
||||
version: STATE_VERSION,
|
||||
code: 'OPS110',
|
||||
issue: 1019,
|
||||
purpose: 'next',
|
||||
branch: 'OPS110-acompanhar-runs',
|
||||
dir: '/home/fsolla/.cursor/worktrees/teqo/OPS110-acompanhar-runs',
|
||||
sessionID: 'ses_f5b6ec763ffeTquA6nLR2XVxz3',
|
||||
url: `http://${DEFAULT_AGENT_SERVER_HOST}:${DEFAULT_AGENT_SERVER_PORT}`,
|
||||
model: 'deepseek/deepseek-flash',
|
||||
driverPid: 4242,
|
||||
logPath: '/home/fsolla/.local/state/teqo/agent-sessions/ops110-acompanhar-runs.log',
|
||||
startedAt: '2026-09-15T10:00:00.000Z',
|
||||
stoppedAt: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('resolveServerConfig (OPS110 — loopback por padrão, env/flag overridáveis)', () => {
|
||||
it('defaults to loopback:4199', () => {
|
||||
expect(resolveServerConfig({ env: {} })).toEqual({
|
||||
hostname: '127.0.0.1',
|
||||
port: 4199,
|
||||
url: 'http://127.0.0.1:4199',
|
||||
})
|
||||
expect(DEFAULT_AGENT_SERVER_HOST).toBe('127.0.0.1')
|
||||
expect(DEFAULT_AGENT_SERVER_PORT).toBe(4199)
|
||||
})
|
||||
|
||||
it('reads TEQO_AGENT_SERVER_HOST/PORT from the env', () => {
|
||||
expect(
|
||||
resolveServerConfig({
|
||||
env: { TEQO_AGENT_SERVER_HOST: '100.119.220.31', TEQO_AGENT_SERVER_PORT: '4300' },
|
||||
}),
|
||||
).toEqual({ hostname: '100.119.220.31', port: 4300, url: 'http://100.119.220.31:4300' })
|
||||
})
|
||||
|
||||
it('explicit flags win over the env (serve --hostname/--port)', () => {
|
||||
expect(
|
||||
resolveServerConfig({
|
||||
hostname: '127.0.0.1',
|
||||
port: 5000,
|
||||
env: { TEQO_AGENT_SERVER_PORT: '4300' },
|
||||
}),
|
||||
).toEqual({ hostname: '127.0.0.1', port: 5000, url: 'http://127.0.0.1:5000' })
|
||||
})
|
||||
|
||||
it('rejects a non-numeric/out-of-range port — fail high, never guess', () => {
|
||||
expect(() => resolveServerConfig({ env: { TEQO_AGENT_SERVER_PORT: 'abc' } })).toThrow(/porta/)
|
||||
expect(() => resolveServerConfig({ env: { TEQO_AGENT_SERVER_PORT: '70000' } })).toThrow(/porta/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateServerBind + isLoopbackHost (aceite: nada além do loopback sem credencial)', () => {
|
||||
it('recognizes loopback spellings only', () => {
|
||||
for (const host of ['127.0.0.1', '127.0.0.5', 'localhost', '::1', '[::1]', ' LOCALHOST ']) {
|
||||
expect(isLoopbackHost(host)).toBe(true)
|
||||
}
|
||||
for (const host of [
|
||||
'0.0.0.0',
|
||||
'100.119.220.31',
|
||||
'192.168.1.10',
|
||||
'jorgesolla1313.com.br',
|
||||
'127.0.0.1.evil',
|
||||
'127.0.0.1.example.com',
|
||||
]) {
|
||||
expect(isLoopbackHost(host)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('loopback never requires a password', () => {
|
||||
expect(validateServerBind({ hostname: '127.0.0.1', password: '' })).toBe(true)
|
||||
})
|
||||
|
||||
it('non-loopback without OPENCODE_SERVER_PASSWORD fails closed', () => {
|
||||
expect(() => validateServerBind({ hostname: '100.119.220.31', password: '' })).toThrow(
|
||||
/OPENCODE_SERVER_PASSWORD/,
|
||||
)
|
||||
expect(() => validateServerBind({ hostname: '0.0.0.0', password: undefined })).toThrow(
|
||||
/credencial|OPENCODE_SERVER_PASSWORD/,
|
||||
)
|
||||
})
|
||||
|
||||
it('non-loopback with a password is allowed', () => {
|
||||
expect(validateServerBind({ hostname: '100.119.220.31', password: 'x' })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionDirFromEnv + caminhos de estado', () => {
|
||||
it('TEQO_AGENT_SESSION_DIR wins; XDG_STATE_HOME; HOME fallback', () => {
|
||||
expect(sessionDirFromEnv({ TEQO_AGENT_SESSION_DIR: '/tmp/runs', HOME: '/home/x' })).toBe(
|
||||
'/tmp/runs',
|
||||
)
|
||||
expect(sessionDirFromEnv({ XDG_STATE_HOME: '/xdg', HOME: '/home/x' })).toBe(
|
||||
'/xdg/teqo/agent-sessions',
|
||||
)
|
||||
expect(sessionDirFromEnv({ HOME: '/home/x' })).toBe('/home/x/.local/state/teqo/agent-sessions')
|
||||
})
|
||||
|
||||
it('slugs the branch (paths with / become one filename) and derives every path', () => {
|
||||
expect(sessionSlug('OPS110-acompanhar-runs')).toBe('ops110-acompanhar-runs')
|
||||
expect(sessionSlug('plans/plan-issue-agenda eleitoral')).toBe(
|
||||
'plans-plan-issue-agenda-eleitoral',
|
||||
)
|
||||
const sessionDir = '/state'
|
||||
const branch = 'plans/plan-issue-agenda'
|
||||
expect(sessionStatePath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.json')
|
||||
expect(startLockPath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.lock')
|
||||
expect(driverLogPath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.log')
|
||||
expect(serverStatePath({ sessionDir })).toBe('/state/server.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('codeFromBranch', () => {
|
||||
it('reads the uppercase code prefix of a claim branch', () => {
|
||||
expect(codeFromBranch('OPS110-acompanhar-runs')).toBe('OPS110')
|
||||
expect(codeFromBranch('C15-fullcalendar')).toBe('C15')
|
||||
})
|
||||
|
||||
it('returns null for namespace branches (plan/new/fix have no claim code)', () => {
|
||||
expect(codeFromBranch('plans/plan-issue-1')).toBeNull()
|
||||
expect(codeFromBranch('work/ideia')).toBeNull()
|
||||
expect(codeFromBranch('fix/abc')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('estado da sessão (contrato OPS109)', () => {
|
||||
it('serializes and parses the documented fields (roundtrip)', () => {
|
||||
const parsed = parseSessionState(serializeSessionState(state()))
|
||||
expect(parsed).toEqual(state())
|
||||
expect(parsed.sessionID).toMatch(/^ses/)
|
||||
expect(parsed.version).toBe(STATE_VERSION)
|
||||
})
|
||||
|
||||
it('fails high on a malformed state (never silently accepts)', () => {
|
||||
expect(() => parseSessionState('not json')).toThrow(/inválido/)
|
||||
expect(() => parseSessionState(JSON.stringify({ branch: 'x' }))).toThrow(/inválido/)
|
||||
expect(() => parseSessionState(JSON.stringify({ ...state(), sessionID: 'nope' }))).toThrow(
|
||||
/inválido/,
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes/parses the server state', () => {
|
||||
const server = {
|
||||
version: STATE_VERSION,
|
||||
url: 'http://127.0.0.1:4199',
|
||||
hostname: '127.0.0.1',
|
||||
port: 4199,
|
||||
pid: 777,
|
||||
startedAt: '2026-09-15T10:00:00.000Z',
|
||||
logPath: '/state/server.log',
|
||||
}
|
||||
expect(parseServerState(serializeServerState(server))).toEqual(server)
|
||||
expect(() => parseServerState('{}')).toThrow(/inválido/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('purposeInvocation (mapa purpose→comando movido do worktree)', () => {
|
||||
it('pins the skill command by purpose', () => {
|
||||
expect(SESSION_COMMAND_BY_PURPOSE).toEqual({
|
||||
next: 'work-issue',
|
||||
plan: 'plan-issue',
|
||||
new: null,
|
||||
fix: 'bug-fix',
|
||||
})
|
||||
})
|
||||
|
||||
it('next carries the claimed issue as --issue <N>', () => {
|
||||
expect(purposeInvocation({ purpose: 'next', issueNumber: 1019 })).toEqual({
|
||||
command: 'work-issue',
|
||||
arguments: '--issue 1019',
|
||||
})
|
||||
})
|
||||
|
||||
it('next without a valid issue starts no driver — nothing to auto-submit', () => {
|
||||
expect(purposeInvocation({ purpose: 'next' })).toBeNull()
|
||||
expect(purposeInvocation({ purpose: 'next', issueNumber: 'x' })).toBeNull()
|
||||
expect(purposeInvocation({ purpose: 'next', issueNumber: 0 })).toBeNull()
|
||||
})
|
||||
|
||||
it('plan sends the bare /plan-issue command', () => {
|
||||
expect(purposeInvocation({ purpose: 'plan' })).toEqual({ command: 'plan-issue', arguments: '' })
|
||||
expect(purposeInvocation({ purpose: 'plan', issueNumber: 7 })).toEqual({
|
||||
command: 'plan-issue',
|
||||
arguments: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('new has no command at all (apenas conversar)', () => {
|
||||
expect(purposeInvocation({ purpose: 'new' })).toBeNull()
|
||||
})
|
||||
|
||||
it('fix sends the sanitized bag (xargs/argv-safe); empty bag sends the bare command', () => {
|
||||
expect(purposeInvocation({ purpose: 'fix', argument: 'a"b\\c bug' })).toEqual({
|
||||
command: 'bug-fix',
|
||||
arguments: 'abc bug',
|
||||
})
|
||||
expect(purposeInvocation({ purpose: 'fix', argument: ' "" ' })).toEqual({
|
||||
command: 'bug-fix',
|
||||
arguments: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('unknown purpose degrades to no command (fail-safe direction)', () => {
|
||||
expect(purposeInvocation({ purpose: 'bogus' })).toBeNull()
|
||||
expect(purposeInvocation({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('driverArgs + attachArgs (o argv verificado ao vivo)', () => {
|
||||
it('drives the run through the server with --auto and --command + `--` separator', () => {
|
||||
expect(
|
||||
driverArgs({
|
||||
url: 'http://127.0.0.1:4199',
|
||||
sessionID: 'ses_abc',
|
||||
dir: '/work/OPS110',
|
||||
model: 'deepseek/deepseek-flash',
|
||||
invocation: { command: 'work-issue', arguments: '--issue 1019' },
|
||||
}),
|
||||
).toEqual([
|
||||
'run',
|
||||
'--attach',
|
||||
'http://127.0.0.1:4199',
|
||||
'-s',
|
||||
'ses_abc',
|
||||
'--dir',
|
||||
'/work/OPS110',
|
||||
'--model',
|
||||
'deepseek/deepseek-flash',
|
||||
'--auto',
|
||||
'--command',
|
||||
'work-issue',
|
||||
'--',
|
||||
'--issue 1019',
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the `--` separator when there are no arguments (plan)', () => {
|
||||
expect(
|
||||
driverArgs({
|
||||
url: 'http://127.0.0.1:4199',
|
||||
sessionID: 'ses_abc',
|
||||
dir: '/work/plan',
|
||||
model: 'm',
|
||||
invocation: { command: 'plan-issue', arguments: '' },
|
||||
}),
|
||||
).toEqual([
|
||||
'run',
|
||||
'--attach',
|
||||
'http://127.0.0.1:4199',
|
||||
'-s',
|
||||
'ses_abc',
|
||||
'--dir',
|
||||
'/work/plan',
|
||||
'--model',
|
||||
'm',
|
||||
'--auto',
|
||||
'--command',
|
||||
'plan-issue',
|
||||
])
|
||||
})
|
||||
|
||||
it('fails high on a missing url/session/model (never spawns half a run)', () => {
|
||||
expect(() =>
|
||||
driverArgs({
|
||||
url: '',
|
||||
sessionID: 'ses_abc',
|
||||
dir: '/d',
|
||||
model: 'm',
|
||||
invocation: { command: 'work-issue', arguments: '' },
|
||||
}),
|
||||
).toThrow(/url/)
|
||||
expect(() =>
|
||||
driverArgs({
|
||||
url: 'http://x',
|
||||
sessionID: '',
|
||||
dir: '/d',
|
||||
model: 'm',
|
||||
invocation: { command: 'work-issue', arguments: '' },
|
||||
}),
|
||||
).toThrow(/session/)
|
||||
expect(() =>
|
||||
driverArgs({
|
||||
url: 'http://x',
|
||||
sessionID: 'ses_abc',
|
||||
dir: '/d',
|
||||
model: '',
|
||||
invocation: { command: 'work-issue', arguments: '' },
|
||||
}),
|
||||
).toThrow(/model/)
|
||||
expect(() =>
|
||||
driverArgs({
|
||||
url: 'http://x',
|
||||
sessionID: 'ses_abc',
|
||||
dir: '/d',
|
||||
model: 'm',
|
||||
invocation: null,
|
||||
}),
|
||||
).toThrow(/invocation|comando/i)
|
||||
})
|
||||
|
||||
it('fails high on a missing attach url/session/dir — never spawns a half attach', () => {
|
||||
expect(() => attachArgs({ url: '', sessionID: 'ses_a', dir: '/d' })).toThrow(/url/)
|
||||
expect(() => attachArgs({ url: 'http://x', sessionID: '', dir: '/d' })).toThrow(/session/)
|
||||
expect(() => attachArgs({ url: 'http://x', sessionID: 'ses_a', dir: '' })).toThrow(/dir/)
|
||||
})
|
||||
|
||||
it('attach resumes exactly the same session/dir (never passes --auto/--model)', () => {
|
||||
const argv = attachArgs({ url: 'http://127.0.0.1:4199', sessionID: 'ses_abc', dir: '/work/x' })
|
||||
expect(argv).toEqual(['attach', 'http://127.0.0.1:4199', '--dir', '/work/x', '-s', 'ses_abc'])
|
||||
expect(argv).not.toContain('--auto')
|
||||
expect(argv).not.toContain('--model')
|
||||
})
|
||||
|
||||
it('serverArgs is loopback-flagged and never enables --mdns', () => {
|
||||
expect(serverArgs({ port: 4199, hostname: '127.0.0.1' })).toEqual([
|
||||
'serve',
|
||||
'--port',
|
||||
'4199',
|
||||
'--hostname',
|
||||
'127.0.0.1',
|
||||
])
|
||||
expect(serverArgs({ port: 4199, hostname: '127.0.0.1' })).not.toContain('--mdns')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSessionRef (--session > --branch > --issue > branch do cwd)', () => {
|
||||
const next = state()
|
||||
const plan = state({
|
||||
code: null,
|
||||
issue: null,
|
||||
branch: 'plans/plan-issue-agenda',
|
||||
sessionID: 'ses_plan',
|
||||
dir: '/work/plans/plan-issue-agenda',
|
||||
})
|
||||
|
||||
it('resolves by session id first', () => {
|
||||
expect(
|
||||
resolveSessionRef({ states: [next, plan], session: 'ses_plan', branch: next.branch }),
|
||||
).toBe(plan)
|
||||
})
|
||||
|
||||
it('resolves by branch, then by issue, then by cwd branch', () => {
|
||||
expect(resolveSessionRef({ states: [next, plan], branch: plan.branch })).toBe(plan)
|
||||
expect(resolveSessionRef({ states: [next, plan], issue: 1019 })).toBe(next)
|
||||
expect(resolveSessionRef({ states: [next, plan], cwdBranch: next.branch })).toBe(next)
|
||||
})
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(resolveSessionRef({ states: [next, plan], session: 'ses_nope' })).toBeNull()
|
||||
expect(resolveSessionRef({ states: [], cwdBranch: 'x' })).toBeNull()
|
||||
expect(resolveSessionRef({ states: [next, plan] })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSessionStatus + list (contrato OPS109)', () => {
|
||||
it('stopped > unknown (server down) > working (busy or driver alive) > idle', () => {
|
||||
const live = state()
|
||||
expect(
|
||||
deriveSessionStatus({
|
||||
state: live,
|
||||
serverHealthy: true,
|
||||
sessionBusy: true,
|
||||
driverAlive: false,
|
||||
}),
|
||||
).toBe('working')
|
||||
expect(
|
||||
deriveSessionStatus({
|
||||
state: live,
|
||||
serverHealthy: true,
|
||||
sessionBusy: false,
|
||||
driverAlive: true,
|
||||
}),
|
||||
).toBe('working')
|
||||
expect(
|
||||
deriveSessionStatus({
|
||||
state: live,
|
||||
serverHealthy: true,
|
||||
sessionBusy: false,
|
||||
driverAlive: false,
|
||||
}),
|
||||
).toBe('idle')
|
||||
expect(
|
||||
deriveSessionStatus({
|
||||
state: live,
|
||||
serverHealthy: false,
|
||||
sessionBusy: false,
|
||||
driverAlive: false,
|
||||
}),
|
||||
).toBe('unknown')
|
||||
const stopped = state({ stoppedAt: '2026-09-15T11:00:00.000Z' })
|
||||
expect(
|
||||
deriveSessionStatus({
|
||||
state: stopped,
|
||||
serverHealthy: true,
|
||||
sessionBusy: true,
|
||||
driverAlive: true,
|
||||
}),
|
||||
).toBe('stopped')
|
||||
})
|
||||
|
||||
it('list payload carries the versioned contract with the derived status', () => {
|
||||
const payload = sessionListPayload({
|
||||
states: [state()],
|
||||
statuses: { ses_f5b6ec763ffeTquA6nLR2XVxz3: 'working' },
|
||||
server: { url: 'http://127.0.0.1:4199', healthy: true, version: '1.18.31' },
|
||||
})
|
||||
expect(payload.version).toBe(STATE_VERSION)
|
||||
expect(payload.server).toEqual({
|
||||
url: 'http://127.0.0.1:4199',
|
||||
healthy: true,
|
||||
version: '1.18.31',
|
||||
})
|
||||
expect(payload.sessions).toHaveLength(1)
|
||||
expect(payload.sessions[0]).toMatchObject({
|
||||
sessionID: 'ses_f5b6ec763ffeTquA6nLR2XVxz3',
|
||||
issue: 1019,
|
||||
status: 'working',
|
||||
})
|
||||
})
|
||||
|
||||
it('list payload marks a session without a known status as unknown (never lies)', () => {
|
||||
const payload = sessionListPayload({
|
||||
states: [state()],
|
||||
server: { url: 'http://127.0.0.1:4199', healthy: false, version: null },
|
||||
})
|
||||
expect(payload.sessions[0].status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('human list prints one line per run with status/session/branch/dir', () => {
|
||||
const lines = formatSessionList([
|
||||
{
|
||||
status: 'working',
|
||||
code: 'OPS110',
|
||||
issue: 1019,
|
||||
branch: 'OPS110-acompanhar-runs',
|
||||
sessionID: 'ses_abc',
|
||||
dir: '/work/OPS110',
|
||||
},
|
||||
{
|
||||
status: 'idle',
|
||||
code: null,
|
||||
issue: null,
|
||||
branch: 'plans/plan-issue-1',
|
||||
sessionID: 'ses_def',
|
||||
dir: '/work/plans/plan-issue-1',
|
||||
},
|
||||
])
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[0]).toContain('working')
|
||||
expect(lines[0]).toContain('OPS110')
|
||||
expect(lines[0]).toContain('#1019')
|
||||
expect(lines[0]).toContain('ses_abc')
|
||||
expect(lines[0]).toContain('/work/OPS110')
|
||||
expect(lines[1]).toContain('plans/plan-issue-1')
|
||||
expect(lines[1]).not.toContain('#null')
|
||||
})
|
||||
})
|
||||
|
|
@ -22,7 +22,6 @@ import {
|
|||
issueCodeAndSubject,
|
||||
OPENCODE_HEADLESS_COMMAND,
|
||||
OPENCODE_PRESET_MODEL,
|
||||
OPENCODE_SKILL_COMMAND_BY_PURPOSE,
|
||||
opencodeHeadlessArgs,
|
||||
opencodeLaunchDirective,
|
||||
PLAN_BRANCH_PREFIX,
|
||||
|
|
@ -150,7 +149,7 @@ describe('planBranchName (per-invocation planning worktrees)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33 + OPS93 + OPS95)', () => {
|
||||
describe('opencodeLaunchDirective (terminal-only agent-session launch, OPS26 + OPS33 + OPS93 + OPS95 + OPS110)', () => {
|
||||
const dir = '/home/fsolla/.cursor/worktrees/teqo/OPS26-foo'
|
||||
|
||||
it('returns null outside the terminal — the /worktree command never launches a TUI', () => {
|
||||
|
|
@ -161,42 +160,55 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33
|
|||
).toBeNull()
|
||||
})
|
||||
|
||||
it('next launches with the preset model, --auto and the /work-issue command sent (value always quoted)', () => {
|
||||
it('next delegates to scripts/agent-session.mjs start with the preset model (no local TUI)', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'next', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/work-issue"`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=next --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('next with an issueNumber sends /work-issue --issue <N> with the value quoted (spaces)', () => {
|
||||
it('uses the explicit sessionScript (absolute) — worktrees sem o arquivo novo ainda lançam', () => {
|
||||
expect(
|
||||
opencodeLaunchDirective({
|
||||
dir,
|
||||
purpose: 'plan',
|
||||
terminal: true,
|
||||
sessionScript: '/repo/scripts/agent-session.mjs',
|
||||
}),
|
||||
).toBe(
|
||||
`launch node /repo/scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('next with an issueNumber carries --issue=<N> (the claimed issue)', () => {
|
||||
expect(
|
||||
opencodeLaunchDirective({ dir, purpose: 'next', terminal: true, issueNumber: 595 }),
|
||||
).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/work-issue --issue 595"`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=next --dir=${dir} --model=${presetInEffect()} --issue=595`,
|
||||
)
|
||||
})
|
||||
|
||||
it('plan launches with the same presets and /plan-issue sent (OPS31, value quoted)', () => {
|
||||
it('plan launches through the same session CLI (the skill is auto-submitted by the driver)', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/plan-issue"`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('new launches prompt-less too — "apenas conversar", no skill sent', () => {
|
||||
it('new launches session-only — "apenas conversar", no driver/skill', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'new', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=new --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('plan/new ignore the issueNumber — only next carries the claimed issue', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, issueNumber: 7 })).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/plan-issue"`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'new', terminal: true, issueNumber: 7 })).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=new --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('fix launches with /bug-fix sent and the bag as the argument (quoted value)', () => {
|
||||
it('fix carries the bag as --argument (quoted value, xargs-safe)', () => {
|
||||
expect(
|
||||
opencodeLaunchDirective({
|
||||
dir,
|
||||
|
|
@ -205,22 +217,24 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33
|
|||
argument: '500 no autosave de estimativas',
|
||||
}),
|
||||
).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix 500 no autosave de estimativas"`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()} --argument="500 no autosave de estimativas"`,
|
||||
)
|
||||
})
|
||||
|
||||
it('fix without a bag sends the bare /bug-fix', () => {
|
||||
it('fix without a bag omits --argument', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'fix', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix"`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('fix strips quotes/backslashes from the bag (xargs-safe) and drops it when it empties', () => {
|
||||
expect(
|
||||
opencodeLaunchDirective({ dir, purpose: 'fix', terminal: true, argument: 'a"b\\c' }),
|
||||
).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix abc"`)
|
||||
opencodeLaunchDirective({ dir, purpose: 'fix', terminal: true, argument: 'a"b\\c bug' }),
|
||||
).toBe(
|
||||
`launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()} --argument="abc bug"`,
|
||||
)
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'fix', terminal: true, argument: ' "" ' })).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix"`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -233,32 +247,32 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33
|
|||
issueNumber: 7,
|
||||
argument: 'bug x',
|
||||
}),
|
||||
).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix bug x"`)
|
||||
).toBe(
|
||||
`launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()} --argument="bug x"`,
|
||||
)
|
||||
})
|
||||
|
||||
it('the argument belongs to fix alone — plan/new ignore it', () => {
|
||||
expect(
|
||||
opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, argument: 'bag x' }),
|
||||
).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/plan-issue"`)
|
||||
).toBe(
|
||||
`launch node scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
expect(
|
||||
opencodeLaunchDirective({ dir, purpose: 'new', terminal: true, argument: 'bag x' }),
|
||||
).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto`)
|
||||
).toBe(
|
||||
`launch node scripts/agent-session.mjs start --purpose=new --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('pins the preset constants — fallback comum deepseek-flash, override via OPENCODE_WORKTREE_MODEL', () => {
|
||||
expect(OPENCODE_PRESET_MODEL).toBe(presetInEffect())
|
||||
expect(WORKTREE_TERMINAL_ENV).toBe('TEQO_WORKTREE_TERMINAL')
|
||||
expect(OPENCODE_SKILL_COMMAND_BY_PURPOSE).toEqual({
|
||||
next: '/work-issue',
|
||||
plan: '/plan-issue',
|
||||
new: null,
|
||||
fix: '/bug-fix',
|
||||
})
|
||||
})
|
||||
|
||||
it('an unknown purpose degrades to a prompt-less launch (fail-safe direction)', () => {
|
||||
it('an unknown purpose still delegates — the session CLI decides there is no command (fail-safe)', () => {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'bogus', terminal: true })).toBe(
|
||||
`launch opencode ${dir} --model ${presetInEffect()} --auto`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=bogus --dir=${dir} --model=${presetInEffect()}`,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -274,7 +288,7 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33
|
|||
it('uses the explicit model when provided (OPS93 map, OPS95 values)', () => {
|
||||
for (const [, model] of Object.entries(WORKTREE_MODEL_MAP)) {
|
||||
expect(opencodeLaunchDirective({ dir, purpose: 'next', terminal: true, model })).toBe(
|
||||
`launch opencode ${dir} --model ${model} --auto --prompt "/work-issue"`,
|
||||
`launch node scripts/agent-session.mjs start --purpose=next --dir=${dir} --model=${model}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -378,13 +392,13 @@ describe('resolveWorktreeModel + WORKTREE_MODEL_MAP (OPS93 menu, OPS95 values, O
|
|||
expect(resolved).toBe(model)
|
||||
expect(
|
||||
opencodeLaunchDirective({ dir, purpose: 'next', terminal: true, model: resolved }),
|
||||
).toContain(`--model ${model} --auto`)
|
||||
).toContain(`--model=${model}`)
|
||||
expect(
|
||||
opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, model: resolved }),
|
||||
).toContain(`--model ${model} --auto`)
|
||||
).toContain(`--model=${model}`)
|
||||
expect(
|
||||
opencodeLaunchDirective({ dir, purpose: 'new', terminal: true, model: resolved }),
|
||||
).toContain(`--model ${model} --auto`)
|
||||
).toContain(`--model=${model}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue