mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
chore(OPS110-F1): flags desconhecidas falham alto, single-flight do start pinado e help/list completos (Closes #1026)
This commit is contained in:
parent
8dece9cd32
commit
13aeb9bb36
5 changed files with 473 additions and 30 deletions
1
docs/changelog/2026-09-15-ops110-f1.md
Normal file
1
docs/changelog/2026-09-15-ops110-f1.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS110-F1 — `agent:session` fail-high de flags, single-flight do `start` pinado e help/list completos (2026-09-15):** fecha os débitos do `/simplify` pós-OPS110 sem tocar o contrato de produto. (F1) o CLI agora rejeita **flag desconhecida por verbo** — `SESSION_FLAG_ALLOWLIST` + `validateSessionFlags` + `nearestAcceptedFlag` na lib pura (`scripts/lib/agent-session.mjs`), chamados no dispatch de `scripts/agent-session.mjs`: `--prupose=next` deixa de cair no default mudo `new` (criava sessão sem driver) e falha com sugestão Damerau de distância ≤1 (`--prupose` → `--purpose`) ou ponteiro para o USAGE; `--hostname/--port` são aceitos onde já eram lidos (`serve`/`start`/`list`) e o parser compartilhado `parseEqualsFlags` ficou intocado (contrato silencioso de 5–7 scripts). (F2) a decisão "reusar vs criar" saiu de dentro do `cmdStart` para a função pura `resolveStartDecision` (filesystem/probe/liveness injetados), com `reason` (`forced|missing|unreadable|server-mismatch|running|idle`) para a CLI preservar o aviso de "estado ilegível", e o reclaim do start-lock virou `isStaleLock` (PID morto/ilegível/não-numérico → stale) — o invariante "nunca dois runs no mesmo branch" ganha pin de unit puro (estado vivo→reuse, idle→fresh, `--new`→fresh, URL divergente→fresh, lock morto→reclaim, lock vivo→die); a corrida real entre dois processos permanece documentada como lacuna (o lock segue guard de produção). (F3) o USAGE do `start` ganhou `--hostname/--port` e a nota de `--model` obrigatório quando o purpose tem skill, e o `list` humano exibe a coluna `LOG` (`formatSessionList` acrescenta `logPath` como último campo) — o contrato `list --json`/`sessionListPayload` (OPS109) fica intocado. Unit: `tests/unit/agentSession.unit.spec.ts` +3 describes (allowlist exata, validador/sugestão, decisão de reuso, stale lock) e o pin de `formatSessionList` atualizado. Sem migration/UI/access/Consent/e2e; `pnpm gate:fast` verde (324 files / 3268 tests). Triage dos achados do `/simplify`: 0 registros (3 descartes de pré-existentes/tradeoffs aceitos, 1 defer com gatilho do USAGE×allowlist, 2 já rastreados na intenção).
|
||||
115
docs/plans/ops110-f1-agent-session-hardening-impl.md
Normal file
115
docs/plans/ops110-f1-agent-session-hardening-impl.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Impl: OPS110-F1 — `agent:session`: flags desconhecidas fail-high, single-flight do `start` testado e polish de help/list
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-15
|
||||
Issue: #1026
|
||||
Intenção: docs/plans/ops110-f1-agent-session-hardening.md
|
||||
Appetite restante: herdado (~1 dia eng, 3 fases fill-in: F1 ~2–3h, F2 ~3–4h, F3 ~1h)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** fechar os débitos do `/simplify` pós-OPS110 sem tocar o contrato de produto — (a) flag desconhecida em `agent:session` falha alto em vez de cair no default mudo de `cmdStart`; (b) o invariante "nunca dois runs no mesmo branch" ganha pin automatizado da decisão de reuso e do reclaim do lock; (c) USAGE do `start` e `logPath` no `list` humano ficam completos.
|
||||
- **O que NÃO negociar:** contrato `list --json`/`sessionListPayload` (OPS109) intocado; `parseEqualsFlags` mantém a semântica silenciosa (família auto-unblock depende dela); lib `scripts/lib/agent-session.mjs` permanece PURA (I/O só por injeção); credencial só por env (D9) e fail-closed de bind inalterados; sem migration/UI/access.
|
||||
- **O que reavaliar:** a hipótese de que "sugestão distância-1" basta — o aceite cita literalmente `--prupose`→`--purpose`, que é uma transposição; Levenshtein puro daria distância 2. Decisão de engenharia abaixo (Damerau × limiar).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[argv] --> B[parseEqualsFlags]
|
||||
B --> C{subcommand conhecido?}
|
||||
C -- não --> U[USAGE + exit 1]
|
||||
C -- sim --> V[validateSessionFlags<br/>whitelist + nearestFlag]
|
||||
V -- desconhecida --> D[die: sugestão/USAGE]
|
||||
V -- ok --> S{verbo}
|
||||
S -- start --> L[acquireStartLock<br/>isStaleLock injetável]
|
||||
L --> R[resolveStartDecision<br/>readFile/probeBusy/driverAlive injetados]
|
||||
R -- reuse --> T[attach]
|
||||
R -- fresh --> N[createSession + spawnDetached]
|
||||
N --> T
|
||||
S -- list --> F[formatSessionList + logPath]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) 3 fases curtas no mesmo lote sobre as superfícies já existentes (`scripts/lib/agent-session.mjs` + `scripts/agent-session.mjs` + `tests/unit/agentSession.unit.spec.ts`) | B) só F1 agora, deferir F2/F3 | C) reescrever o CLI em módulos por verbo e embutir tudo.
|
||||
|
||||
**Recomendação:** A — as três fases são cheap_polish na MESMA superfície já extraída na OPS110; F1 é a de maior risco (falha muda no caminho crítico), F2 pina o invariante mais caro de reverter (dois drivers no mesmo worktree) e F3 é cosmético. Toda a lógica nova nasce como função pura na lib (testável sem processo/HTTP) e a CLI fina só injeta I/O — exatamente o desenho atual.
|
||||
|
||||
**Rejeitadas:** B — deixaria sem pin o único invariante de segurança de processo do item e a falha muda (F1) sozinha não fecha o outcome; C — o CLI fino é intencional na OPS110 e o refactor não remove volatilidade, só adiciona cerimônia ([decision-quality.md] "Fronteira de módulo em volatilidade real" / "Pass-through raso").
|
||||
|
||||
### Decisões de engenharia (caras de reverter → com rejeitadas)
|
||||
|
||||
- **Onde validar flags (F1).**
|
||||
Opções: A) validador puro novo em `scripts/lib/agent-session.mjs` (`SESSION_FLAG_WHITELIST` + `validateSessionFlags`), chamado pela CLI logo após resolver o verbo | B) endurecer `parseEqualsFlags` para rejeitar desconhecidas | C) rejeitar dentro de cada `cmd*`.
|
||||
Recomendação: A — mantém a lib testável sem argv real e não muda o parser compartilhado; a CLI já é a dona do `die`/USAGE.
|
||||
Rejeitadas: B porque `parseEqualsFlags` é contrato silencioso de 5–7 scripts (B5/Pass3) e a intenção proíbe mudar a semântica; C porque espalha a whitelist por verbo e duplica o dispatch.
|
||||
- **Forma da sugestão (F1).**
|
||||
Opções: A) distância de edição com transposição (Damerau) e limiar ≤1 (`nearestAcceptedFlag`) | B) Levenshtein puro com limiar ≤2 | C) sem sugestão.
|
||||
Recomendação: A — cobre `--prupose` (transposição = 1) mantendo limiar estrito; menos falso-positivo que ≤2.
|
||||
Rejeitadas: B porque limiar 2 sugere em substituições duplas legítimas (ruído acionável falso); C porque o aceite exige mensagem acionável.
|
||||
- **Seam do single-flight (F2).**
|
||||
Opções: A) extrair `resolveStartDecision` (async, puro) + `isStaleLock` (puro) com `readFile`/`probeBusy`/`driverAlive`/`forceNew` injetados; CLI mantém `openSync`/`unlinkSync`/HTTP | B) mover fs/probe para a lib | C) testar via integração spawnando processos reais.
|
||||
Recomendação: A — pina a decisão sem violar a pureza da lib e sem flakiness de processo.
|
||||
Rejeitadas: B porque quebra o contrato "lib pura" (cabeçalho do módulo) e a fronteira CLI/servidor; C porque acopla a suíte ao SO/`/proc` e não isola a regra.
|
||||
- **Sugestão no `list` humano (F3).**
|
||||
Opções: A) acrescentar `logPath` como última coluna em `formatSessionList` + header da CLI | B) coluna conforme/`logPath` só no `--json`.
|
||||
Recomendação: A — o dado já existe no estado (spread do payload) e o pedido é enxergá-lo no humano.
|
||||
Rejeitadas: B porque `--json` já o carrega; não fecha o outcome.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`validateSessionFlags` / `SESSION_FLAG_WHITELIST` / `nearestAcceptedFlag`** (`scripts/lib/agent-session.mjs`): puros. Whitelist por verbo (nomes, valores irrelevantes): `serve`→{hostname,port}; `start`→{purpose,dir,model,issue,argument,new,hostname,port}; `attach`/`stop`→{session,branch,issue}; `list`→{json}. Retorna/`throw` mensagem acionável (`sugestão: --purpose` ou lista do USAGE). Reusa o padrão de whitelist manual fail-high já existente em `scripts/lib/imageFill.mjs:70-72`.
|
||||
- **`resolveStartDecision` + `isStaleLock`** (`scripts/lib/agent-session.mjs`): `resolveStartDecision({ forceNew, statePath, readFile, serverUrl, probeBusy, driverAlive })` async puro → `{ action: 'reuse'|'fresh', state? }`; preserva a semântica atual de `readReusable` (`scripts/agent-session.mjs:480-494`): `--new`→fresh; estado ilegível→fresh (warn fica na CLI); `state.url !== serverUrl`→fresh; vivo se `driverAlive || busy`. `isStaleLock({ holderPid, isPidAlive })` puro encapsula o ramo de reclaim (`:420-435`).
|
||||
- **`scripts/agent-session.mjs`**: USAGE do `start`/`serve` completado (F3); chamada de `validateSessionFlags` no dispatch (`:655-674`); `cmdStart` (`:458-549`) passa a delegar a decisão; `acquireStartLock` (`:400-442`) usa `isStaleLock`; header do `list` (`:640`) ganha `LOG` e o repasse de `logPath`.
|
||||
- **`formatSessionList`** (`scripts/lib/agent-session.mjs:318-323`): acrescenta `row.logPath` como último campo; `sessionListPayload` (`:308-315`) e o contrato OPS109 NÃO são tocados. Header literal da CLI alinhado.
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** N/A (não toca collections/PII).
|
||||
- **UI:** N/A (texto de CLI; apenas strings pt-BR).
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- N/A — não há dados/KPI/mapa/série; a saída é texto de terminal e o contrato JSON versionado já existente não muda.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **F1 — Flags estritas (tracer, ~2–3h).** Adicionar whitelist + `nearestAcceptedFlag` + `validateSessionFlags` na lib; chamar no dispatch só para verbos conhecidos (desconhecido segue → USAGE, `:671-673`). Unit novo cobre: `--prupose=next`→sugere `--purpose`; `--foo` sem vizinho→mensagem com USAGE; booleanos `--json`/`--new` aceitos; `serve --purpose=x` rejeitado; verbo desconhecido continua USAGE+exit 1. `pnpm gate:fast`.
|
||||
2. **F2 — Single-flight com seam testável (~3–4h).** Extrair `resolveStartDecision`/`isStaleLock`; `cmdStart` e `acquireStartLock` passam a injetar as dependências reais sem mudar comportamento. Unit cobre: estado vivo (driverAlive/busy)→reuse; URL divergente→fresh; estado ilegível→fresh; `--new`→fresh; lock com PID morto→reclaim; lock com PID vivo→`die`. Comentar no spec a lacuna que fica (corrida real entre processos) como não-escopo. `pnpm gate:fast`.
|
||||
3. **F3 — Help/list (~1h).** USAGE do `start` com `--hostname/--port` e "`--model` obrigatório quando há skill"; `formatSessionList` + header com `logPath`. Atualizar spec de `:462-489`. `pnpm gate:fast`.
|
||||
4. **Gates finais.** `pnpm gate:fast` verde (lint → typecheck → unit) e push do branch via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Refactor do CLI em módulos por verbo; a CLI fina é intencional (OPS110).
|
||||
- Mudar `parseEqualsFlags`/aceitar `--flag valor` — contrato de outros scripts.
|
||||
- Sugestão por Levenshtein sem limiar ou "fuzzy" amplo — ruído acionável.
|
||||
- Mover fs/HTTP/`process.kill` para a lib (quebraria a pureza).
|
||||
- Teste de integração spawnando dois `start` reais — flaky, depende de `/proc`/porta.
|
||||
- Validar semanticamente valores de flag (e-mail de branch, range de issue) — fora.
|
||||
- Unificar a fronteira dupla `purposeInvocation` (`scripts/lib/worktree.mjs` ∥ lib) — adiada com gatilho na intenção.
|
||||
- Tocar `sessionListPayload`/contrato `list --json` ou o painel OPS109.
|
||||
|
||||
### Débitos do `/simplify` (triage OPS110-F1 — 0 registros)
|
||||
|
||||
- **Allowlist × texto do USAGE** (lib ⟥ CLI): duas fontes do mesmo conjunto; o spec pina a allowlist exata, o USAGE textual não. Não criar pin agora — **gatilho:** ao adicionar/remover flag, derivar/pinar o USAGE. (cheap_polish, defer)
|
||||
- **`parseEqualsFlags` e `--`**: pré-existente no parser compartilhado de 5–7 scripts; fora de escopo (não mudar a semântica). (cheap_polish, descartado)
|
||||
- **`osaDistance` O(n·m)**: tradeoff aceito — strings de flag curtas, caminho só de erro, sem dep de Levenshtein no `package.json`. (cheap_polish, descartado)
|
||||
- **`isStaleLock` com 1 call site**: seam de teste deliberado (pina o reclaim sem processo real), não reuso perdido. (cheap_polish, descartado)
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Whitelist divergir do parser:** a whitelist valida NOMES, não a forma `=`; testes pinam booleanos `--json`/`--new` e o `--flag` sem valor. Nenhuma mudança em `parseEqualsFlags`.
|
||||
- **Sugestão errada em `--prupose`:** Damerau com limiar ≤1 cobre a transposição exigida pelo aceite; casos sem vizinho caem no USAGE.
|
||||
- **F2 alterar comportamento ao extrair:** testes espelham cada ramo atual de `readReusable` (URL, ilegível, busy, driver morto) e o reclaim do lock (PID morto/vivo/ilegível); `--new` curto-circuita.
|
||||
- **Quebrar a pureza da lib:** revisor de import — proibido `node:fs`/`fetch`/`child_process` novos na lib; I/O só por parâmetro.
|
||||
- **Regressão no `list`:** `formatSessionList` é consumido só pela CLI e pelo spec; payload OPS109 intocado (spread já traz `logPath`).
|
||||
- **Corrida real de dois processos:** permanece fora do teste (lacuna documentada no spec) — o lock continua sendo o guard de produção.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Flag desconhecida em qualquer verbo falha com mensagem acionável (inclui `--prupose`→`--purpose`); verbo desconhecido segue USAGE+exit 1.
|
||||
- [ ] `resolveStartDecision` + `isStaleLock` pinados em unit puro (reuse/fresh/`--new`/reclaim); corrida real documentada como lacuna.
|
||||
- [ ] USAGE do `start` completo (`--hostname/--port`, `--model` obrigatório) e `list` humano exibindo `logPath`.
|
||||
- [ ] Contrato `list --json`/`sessionListPayload` (OPS109) e `parseEqualsFlags` inalterados; lib mantida pura; identificadores em inglês, strings em pt-BR.
|
||||
- [ ] `pnpm gate:fast` verde; sem migration/UI/access.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: nenhuma collection/PII/transação envolvida; nada reescrito como twin (edita-se o dono existente).
|
||||
|
||||
**Self-score decision-quality (gate ≥4):** decisões caras (onde validar, forma da sugestão, seam do lock, coluna do list) têm Opções A/B/C + Recomendação + Rejeitadas (1,0); cabe no appetite ~1 dia em 3 fases (1,0); rabbit holes nomeados (1,0); depth check reusa a lib/CLI e o padrão de whitelist existentes, sem twin (1,0); o aceite de produto da intenção permanece coberto e o contrato OPS110/OPS109 preservado (1,0) → **5,0/5**.
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
* 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]
|
||||
* pnpm agent:session start --purpose=<next|plan|new|fix> --dir=<D> [--model=<M>] [--issue=N] [--argument="<bag>"] [--new] [--hostname=H] [--port=P]
|
||||
* 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>`).
|
||||
|
|
@ -18,14 +18,15 @@
|
|||
* 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]
|
||||
* pnpm agent:session list [--json] [--hostname=H] [--port=P]
|
||||
* 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.
|
||||
* `--` entrega `$ARGUMENTS`) foram verificados na 1.18.31. Flag desconhecida
|
||||
* falha alto por verbo (OPS110-F1) — nunca cai num default mudo.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import {
|
||||
|
|
@ -47,11 +48,13 @@ import {
|
|||
driverArgs,
|
||||
driverLogPath,
|
||||
formatSessionList,
|
||||
isStaleLock,
|
||||
parseServerState,
|
||||
parseSessionState,
|
||||
purposeInvocation,
|
||||
resolveServerConfig,
|
||||
resolveSessionRef,
|
||||
resolveStartDecision,
|
||||
serializeServerState,
|
||||
serializeSessionState,
|
||||
serverArgs,
|
||||
|
|
@ -62,6 +65,7 @@ import {
|
|||
startLockPath,
|
||||
STATE_VERSION,
|
||||
validateServerBind,
|
||||
validateSessionFlags,
|
||||
} from './lib/agent-session.mjs'
|
||||
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
|
||||
|
||||
|
|
@ -70,11 +74,13 @@ 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]
|
||||
start --purpose=<next|plan|new|fix> --dir=<D> [--model=<M>] [--issue=N] [--argument="<bag>"] [--new] [--hostname=H] [--port=P]
|
||||
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`
|
||||
list [--json] [--hostname=H] [--port=P] status dos runs registrados
|
||||
|
||||
--model é obrigatório quando o purpose tem skill (next/plan/fix); 'new' não dispara driver.`
|
||||
|
||||
/** Loopback by default; the credential rides the env, never the state/log. */
|
||||
const serverPassword = () => process.env.OPENCODE_SERVER_PASSWORD ?? ''
|
||||
|
|
@ -419,13 +425,13 @@ const acquireStartLock = ({ sessionDir, branch }) => {
|
|||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error
|
||||
let holder = null
|
||||
let holder = NaN
|
||||
try {
|
||||
holder = Number(readFileSync(lockPath, 'utf8').trim())
|
||||
} catch {
|
||||
// unreadable lock — treat as stale below
|
||||
// unreadable lock — treated as stale by isStaleLock
|
||||
}
|
||||
if (!pidAlive(holder)) {
|
||||
if (isStaleLock({ holderPid: holder, isPidAlive: pidAlive })) {
|
||||
try {
|
||||
unlinkSync(lockPath)
|
||||
} catch {
|
||||
|
|
@ -477,21 +483,17 @@ const cmdStart = async (flags) => {
|
|||
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
|
||||
}
|
||||
const decideReuse = () =>
|
||||
resolveStartDecision({
|
||||
forceNew: Boolean(flags.new),
|
||||
statePath,
|
||||
readFile: (path) => readFileSync(path, 'utf8'),
|
||||
exists: existsSync,
|
||||
serverUrl: server.url,
|
||||
probeBusy: async (sessionID) =>
|
||||
(await sessionStatuses(server.url))[sessionID]?.type === 'busy',
|
||||
driverAlive,
|
||||
})
|
||||
|
||||
// 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.
|
||||
|
|
@ -499,10 +501,15 @@ const cmdStart = async (flags) => {
|
|||
let state
|
||||
let reused = false
|
||||
try {
|
||||
const existing = await readReusable()
|
||||
if (existing) {
|
||||
const decision = await decideReuse()
|
||||
if (decision.reason === 'unreadable') {
|
||||
console.warn(
|
||||
`[agent:session] estado ilegível em ${statePath} (${decision.error?.message}) — tratando como sessão nova.`,
|
||||
)
|
||||
}
|
||||
if (decision.action === 'reuse') {
|
||||
reused = true
|
||||
state = existing
|
||||
state = decision.state
|
||||
} else {
|
||||
const sessionID = await createSession(server.url, dir)
|
||||
state = {
|
||||
|
|
@ -637,7 +644,7 @@ const cmdList = async (flags) => {
|
|||
console.log(`nenhuma sessão registrada em ${sessionDir}`)
|
||||
return
|
||||
}
|
||||
console.log('STATUS REF SESSÃO BRANCH DIR')
|
||||
console.log('STATUS REF SESSÃO BRANCH DIR LOG')
|
||||
for (const line of formatSessionList(
|
||||
states.map((state) => ({ ...state, status: derived[state.sessionID] })),
|
||||
)) {
|
||||
|
|
@ -661,6 +668,11 @@ try {
|
|||
`argumento posicional inesperado: ${positional.slice(1).join(' ')} (use --flag=valor; veja o USAGE).`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
validateSessionFlags({ subcommand, flags })
|
||||
} catch (error) {
|
||||
die(`${error.message} Veja o USAGE: \`pnpm agent:session\`.`)
|
||||
}
|
||||
if (subcommand === 'serve') await cmdServe(flags)
|
||||
else if (subcommand === 'start') {
|
||||
process.exitCode = await cmdStart(flags)
|
||||
|
|
|
|||
|
|
@ -198,6 +198,84 @@ export const purposeInvocation = ({ purpose = null, issueNumber = null, argument
|
|||
return { command, arguments: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepted flag NAMES per subcommand (OPS110-F1). Names only — the `=`/boolean
|
||||
* form stays `parseEqualsFlags`' concern. `list`/`start`/`serve` legitimately
|
||||
* take `--hostname/--port` (they override the probe/spawn target). An unknown
|
||||
* name must fail high: `--prupose=next` used to fall through to the `new`
|
||||
* default and create a driverless session.
|
||||
*/
|
||||
export const SESSION_FLAG_ALLOWLIST = {
|
||||
serve: ['hostname', 'port'],
|
||||
start: ['purpose', 'dir', 'model', 'issue', 'argument', 'new', 'hostname', 'port'],
|
||||
attach: ['session', 'branch', 'issue'],
|
||||
stop: ['session', 'branch', 'issue'],
|
||||
list: ['json', 'hostname', 'port'],
|
||||
}
|
||||
|
||||
/** Optimal string alignment (Damerau–Levenshtein, no adjacent transposition twice). */
|
||||
const osaDistance = (left, right) => {
|
||||
const a = String(left ?? '')
|
||||
const b = String(right ?? '')
|
||||
if (a === b) return 0
|
||||
if (a.length === 0) return b.length
|
||||
if (b.length === 0) return a.length
|
||||
const rows = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0))
|
||||
for (let i = 0; i <= a.length; i += 1) rows[i][0] = i
|
||||
for (let j = 0; j <= b.length; j += 1) rows[0][j] = j
|
||||
for (let i = 1; i <= a.length; i += 1) {
|
||||
for (let j = 1; j <= b.length; j += 1) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost)
|
||||
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
||||
rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows[a.length][b.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Closest accepted flag at edit distance ≤1, or null. Damerau (transposition
|
||||
* counts as 1) so the required `--prupose` → `--purpose` suggestion fires;
|
||||
* limiar estrito evita sugerir em erros grandes (ruído acionável falso).
|
||||
* @param {string} name
|
||||
* @param {readonly string[]} accepted
|
||||
*/
|
||||
export const nearestAcceptedFlag = (name, accepted) => {
|
||||
let best = null
|
||||
let bestDistance = Number.POSITIVE_INFINITY
|
||||
for (const candidate of accepted) {
|
||||
const distance = osaDistance(name, candidate)
|
||||
if (distance < bestDistance || (distance === bestDistance && candidate < String(best))) {
|
||||
best = candidate
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
return bestDistance <= 1 ? best : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail high on any flag the subcommand does not accept. Throws a message with
|
||||
* a suggestion when a near neighbor exists; unknown subcommands return silently
|
||||
* (the CLI prints the USAGE for those). A bare `--` parses to an empty name and
|
||||
* is ignored, not reported as a typo.
|
||||
* @param {{ subcommand?: string | null, flags?: Record<string, unknown> }} [options]
|
||||
*/
|
||||
export const validateSessionFlags = ({ subcommand = null, flags = {} } = {}) => {
|
||||
const accepted = SESSION_FLAG_ALLOWLIST[subcommand]
|
||||
if (!accepted) return
|
||||
for (const name of Object.keys(flags)) {
|
||||
if (name === '' || accepted.includes(name)) continue
|
||||
const suggestion = nearestAcceptedFlag(name, accepted)
|
||||
throw new Error(
|
||||
suggestion
|
||||
? `flag desconhecida: --${name} (sugestão: --${suggestion}).`
|
||||
: `flag desconhecida: --${name}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* argv of the detached run driver (sem o binário — o caller usa
|
||||
* `spawn('opencode', …)`). The `--` separator is required so a command
|
||||
|
|
@ -289,6 +367,63 @@ export const resolveSessionRef = ({
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide "reuse the existing run vs create a fresh one" — the single-flight
|
||||
* invariant's brain (OPS110-F1). Pure: the caller injects the filesystem read,
|
||||
* the busy probe and the driver liveness check. Mirrors `cmdStart`'s in-lock
|
||||
* decision: `--new`/no state/unreadable state/different server → fresh; an
|
||||
* alive driver or a busy session on the SAME server → reuse. `reason` lets the
|
||||
* CLI keep its "estado ilegível" warning without the lib doing I/O.
|
||||
* @param {{
|
||||
* forceNew?: boolean,
|
||||
* statePath: string,
|
||||
* readFile: (path: string) => string,
|
||||
* exists: (path: string) => boolean,
|
||||
* parse?: (text: string) => Record<string, unknown>,
|
||||
* serverUrl: string,
|
||||
* probeBusy: (sessionID: string) => boolean | Promise<boolean>,
|
||||
* driverAlive?: (pid: number | null, sessionID: string | null) => boolean,
|
||||
* }} options
|
||||
* @returns {Promise<{ action: 'reuse' | 'fresh', reason: 'forced' | 'missing' | 'unreadable' | 'server-mismatch' | 'running' | 'idle', state?: Record<string, unknown>, error?: Error }>}
|
||||
*/
|
||||
export const resolveStartDecision = async ({
|
||||
forceNew = false,
|
||||
statePath,
|
||||
readFile,
|
||||
exists,
|
||||
parse = parseSessionState,
|
||||
serverUrl,
|
||||
probeBusy,
|
||||
driverAlive = () => false,
|
||||
}) => {
|
||||
if (forceNew) return { action: 'fresh', reason: 'forced' }
|
||||
if (!exists(statePath)) return { action: 'fresh', reason: 'missing' }
|
||||
let existing
|
||||
try {
|
||||
existing = parse(readFile(statePath))
|
||||
} catch (error) {
|
||||
return { action: 'fresh', reason: 'unreadable', error }
|
||||
}
|
||||
if (existing.url !== serverUrl) return { action: 'fresh', reason: 'server-mismatch' }
|
||||
const busy = Boolean(await probeBusy(existing.sessionID))
|
||||
if (driverAlive(existing.driverPid, existing.sessionID) || busy) {
|
||||
return { action: 'reuse', reason: 'running', state: existing }
|
||||
}
|
||||
return { action: 'fresh', reason: 'idle' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an existing start-lock's holder is gone and the lock can be
|
||||
* reclaimed. Pure: PID liveness is injected. A missing/non-numeric/dead holder
|
||||
* counts as stale — the same branch the CLI used to inline.
|
||||
* @param {{ holderPid?: number | string | null, isPidAlive: (pid: number) => boolean }} options
|
||||
*/
|
||||
export const isStaleLock = ({ holderPid, isPidAlive }) => {
|
||||
const pid = Number(holderPid)
|
||||
if (!Number.isInteger(pid) || pid <= 0) return true
|
||||
return !isPidAlive(pid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Status shown by `list`: `stopped` (explicit stop) wins, then `unknown`
|
||||
* (server unreachable), then `working` (busy on the server or driver alive),
|
||||
|
|
@ -314,10 +449,10 @@ export const sessionListPayload = ({ states, statuses = {}, server }) => ({
|
|||
})),
|
||||
})
|
||||
|
||||
/** Human `list` lines — one per run, status first, `—` for namespace runs. */
|
||||
/** Human `list` lines — status first, `—` for namespace runs; `logPath` last. */
|
||||
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}`
|
||||
return `${String(row.status).padEnd(8)} ${reference} ${row.sessionID} ${row.branch} ${row.dir} ${row.logPath ?? '—'}`
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,16 +12,20 @@ import {
|
|||
driverLogPath,
|
||||
formatSessionList,
|
||||
isLoopbackHost,
|
||||
isStaleLock,
|
||||
nearestAcceptedFlag,
|
||||
parseServerState,
|
||||
parseSessionState,
|
||||
purposeInvocation,
|
||||
resolveServerConfig,
|
||||
resolveSessionRef,
|
||||
resolveStartDecision,
|
||||
serializeServerState,
|
||||
serializeSessionState,
|
||||
serverArgs,
|
||||
serverStatePath,
|
||||
SESSION_COMMAND_BY_PURPOSE,
|
||||
SESSION_FLAG_ALLOWLIST,
|
||||
sessionDirFromEnv,
|
||||
sessionListPayload,
|
||||
sessionSlug,
|
||||
|
|
@ -29,6 +33,7 @@ import {
|
|||
startLockPath,
|
||||
STATE_VERSION,
|
||||
validateServerBind,
|
||||
validateSessionFlags,
|
||||
} from '../../scripts/lib/agent-session.mjs'
|
||||
|
||||
const state = (over: Record<string, unknown> = {}) => ({
|
||||
|
|
@ -239,6 +244,91 @@ describe('purposeInvocation (mapa purpose→comando movido do worktree)', () =>
|
|||
})
|
||||
})
|
||||
|
||||
describe('validateSessionFlags + nearestAcceptedFlag (OPS110-F1 — flag desconhecida falha alto)', () => {
|
||||
it('pins the accepted flag names per verb', () => {
|
||||
expect(SESSION_FLAG_ALLOWLIST).toEqual({
|
||||
serve: ['hostname', 'port'],
|
||||
start: ['purpose', 'dir', 'model', 'issue', 'argument', 'new', 'hostname', 'port'],
|
||||
attach: ['session', 'branch', 'issue'],
|
||||
stop: ['session', 'branch', 'issue'],
|
||||
list: ['json', 'hostname', 'port'],
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts every documented flag per verb (names only, valued or boolean)', () => {
|
||||
expect(() =>
|
||||
validateSessionFlags({
|
||||
subcommand: 'start',
|
||||
flags: {
|
||||
purpose: 'next',
|
||||
dir: '/d',
|
||||
model: 'm',
|
||||
issue: '1',
|
||||
argument: 'x',
|
||||
new: true,
|
||||
hostname: 'h',
|
||||
port: '1',
|
||||
},
|
||||
}),
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
validateSessionFlags({ subcommand: 'serve', flags: { hostname: 'h', port: '1' } }),
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
validateSessionFlags({ subcommand: 'list', flags: { json: true, hostname: 'h', port: '1' } }),
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
validateSessionFlags({ subcommand: 'attach', flags: { session: 'ses_x' } }),
|
||||
).not.toThrow()
|
||||
expect(() => validateSessionFlags({ subcommand: 'stop', flags: { branch: 'b' } })).not.toThrow()
|
||||
})
|
||||
|
||||
it('suggests the transposition neighbor: --prupose → --purpose', () => {
|
||||
expect(nearestAcceptedFlag('prupose', ['purpose', 'dir', 'model'])).toBe('purpose')
|
||||
expect(() => validateSessionFlags({ subcommand: 'start', flags: { prupose: 'next' } })).toThrow(
|
||||
/--prupose[\s\S]*--purpose/,
|
||||
)
|
||||
})
|
||||
|
||||
it('suggests substitution/insertion neighbors at distance 1, nothing at distance 2', () => {
|
||||
expect(nearestAcceptedFlag('purposee', ['purpose'])).toBe('purpose')
|
||||
expect(nearestAcceptedFlag('purpos', ['purpose'])).toBe('purpose')
|
||||
expect(nearestAcceptedFlag('prpose', ['purpose'])).toBe('purpose')
|
||||
expect(nearestAcceptedFlag('purposxx', ['purpose'])).toBeNull()
|
||||
expect(nearestAcceptedFlag('totallyWrong', ['purpose', 'dir'])).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an unknown flag without a suggestion as-is', () => {
|
||||
expect(() =>
|
||||
validateSessionFlags({ subcommand: 'start', flags: { totallyWrong: true } }),
|
||||
).toThrow('flag desconhecida: --totallyWrong.')
|
||||
})
|
||||
|
||||
it('rejects a flag that belongs to another verb', () => {
|
||||
expect(() => validateSessionFlags({ subcommand: 'serve', flags: { purpose: 'next' } })).toThrow(
|
||||
/--purpose/,
|
||||
)
|
||||
expect(() => validateSessionFlags({ subcommand: 'list', flags: { dir: '/d' } })).toThrow(
|
||||
/--dir/,
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a bare `--` (empty flag name), never a typo', () => {
|
||||
expect(() =>
|
||||
validateSessionFlags({ subcommand: 'start', flags: { '': true, purpose: 'next' } }),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('stays silent for an unknown verb (the CLI prints the USAGE itself)', () => {
|
||||
expect(() =>
|
||||
validateSessionFlags({ subcommand: 'bogus', flags: { whatever: true } }),
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
validateSessionFlags({ subcommand: null, flags: { whatever: true } }),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('driverArgs + attachArgs (o argv verificado ao vivo)', () => {
|
||||
it('drives the run through the server with --auto and --command + `--` separator', () => {
|
||||
expect(
|
||||
|
|
@ -385,6 +475,93 @@ describe('resolveSessionRef (--session > --branch > --issue > branch do cwd)', (
|
|||
})
|
||||
})
|
||||
|
||||
describe('resolveStartDecision (OPS110-F1 — single-flight "reusar vs criar")', () => {
|
||||
const liveState = state()
|
||||
const base = {
|
||||
statePath: '/state/OPS110.json',
|
||||
serverUrl: liveState.url,
|
||||
readFile: () => JSON.stringify(liveState),
|
||||
exists: () => true,
|
||||
probeBusy: async () => false,
|
||||
driverAlive: () => false,
|
||||
}
|
||||
|
||||
it('reuses when the driver is alive on the same server', async () => {
|
||||
expect(await resolveStartDecision({ ...base, driverAlive: () => true })).toMatchObject({
|
||||
action: 'reuse',
|
||||
reason: 'running',
|
||||
state: liveState,
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses when the session is busy even with the driver gone', async () => {
|
||||
expect(await resolveStartDecision({ ...base, probeBusy: async () => true })).toMatchObject({
|
||||
action: 'reuse',
|
||||
reason: 'running',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates fresh when idle (driver dead and not busy)', async () => {
|
||||
expect(await resolveStartDecision(base)).toMatchObject({ action: 'fresh', reason: 'idle' })
|
||||
})
|
||||
|
||||
it('--new forces fresh without reading the state', async () => {
|
||||
let read = false
|
||||
const decision = await resolveStartDecision({
|
||||
...base,
|
||||
forceNew: true,
|
||||
readFile: () => {
|
||||
read = true
|
||||
return 'ignored'
|
||||
},
|
||||
})
|
||||
expect(decision).toMatchObject({ action: 'fresh', reason: 'forced' })
|
||||
expect(read).toBe(false)
|
||||
})
|
||||
|
||||
it('creates fresh when there is no state file', async () => {
|
||||
expect(await resolveStartDecision({ ...base, exists: () => false })).toMatchObject({
|
||||
action: 'fresh',
|
||||
reason: 'missing',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates fresh on unreadable state and surfaces the error for the CLI warning', async () => {
|
||||
const decision = await resolveStartDecision({
|
||||
...base,
|
||||
readFile: () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
})
|
||||
expect(decision.action).toBe('fresh')
|
||||
expect(decision.reason).toBe('unreadable')
|
||||
expect(decision.error?.message).toBe('boom')
|
||||
})
|
||||
|
||||
it('creates fresh when the state points at another server', async () => {
|
||||
expect(await resolveStartDecision({ ...base, serverUrl: 'http://other:1' })).toMatchObject({
|
||||
action: 'fresh',
|
||||
reason: 'server-mismatch',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isStaleLock (OPS110-F1 — reclaim do lock com PID morto)', () => {
|
||||
it('treats a dead PID as stale', () => {
|
||||
expect(isStaleLock({ holderPid: 4242, isPidAlive: () => false })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a live holder (not stale)', () => {
|
||||
expect(isStaleLock({ holderPid: 4242, isPidAlive: () => true })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a missing/non-numeric holder as stale (never deadlocks forever)', () => {
|
||||
expect(isStaleLock({ holderPid: NaN, isPidAlive: () => true })).toBe(true)
|
||||
expect(isStaleLock({ holderPid: null, isPidAlive: () => true })).toBe(true)
|
||||
expect(isStaleLock({ holderPid: 0, isPidAlive: () => true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSessionStatus + list (contrato OPS109)', () => {
|
||||
it('stopped > unknown (server down) > working (busy or driver alive) > idle', () => {
|
||||
const live = state()
|
||||
|
|
@ -459,7 +636,7 @@ describe('deriveSessionStatus + list (contrato OPS109)', () => {
|
|||
expect(payload.sessions[0].status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('human list prints one line per run with status/session/branch/dir', () => {
|
||||
it('human list prints one line per run with status/session/branch/dir/log', () => {
|
||||
const lines = formatSessionList([
|
||||
{
|
||||
status: 'working',
|
||||
|
|
@ -468,6 +645,7 @@ describe('deriveSessionStatus + list (contrato OPS109)', () => {
|
|||
branch: 'OPS110-acompanhar-runs',
|
||||
sessionID: 'ses_abc',
|
||||
dir: '/work/OPS110',
|
||||
logPath: '/state/ops110-acompanhar-runs.log',
|
||||
},
|
||||
{
|
||||
status: 'idle',
|
||||
|
|
@ -484,7 +662,9 @@ describe('deriveSessionStatus + list (contrato OPS109)', () => {
|
|||
expect(lines[0]).toContain('#1019')
|
||||
expect(lines[0]).toContain('ses_abc')
|
||||
expect(lines[0]).toContain('/work/OPS110')
|
||||
expect(lines[0]).toContain('/state/ops110-acompanhar-runs.log')
|
||||
expect(lines[1]).toContain('plans/plan-issue-1')
|
||||
expect(lines[1]).not.toContain('#null')
|
||||
expect(lines[1].endsWith('—')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue