Skip to content

Passo de raciocínio, e uma retomada que confere o que o humano respondeu - #16

Merged
davidbenal merged 2 commits into
mainfrom
david/PROD-2127-passo-reason-e-retomada
Aug 24, 2026
Merged

Passo de raciocínio, e uma retomada que confere o que o humano respondeu#16
davidbenal merged 2 commits into
mainfrom
david/PROD-2127-passo-reason-e-retomada

Conversation

@davidbenal

Copy link
Copy Markdown
Collaborator

Metade do esqueleto da Etapa 1 do Crystal Ball (Linear PROD-2127). A outra metade é a PR do Workbench, que consome o --json que esta PR cria.

O problema

Um app declarado tem passos cujo resultado é estrutura, não arquivo: um dossiê de pesquisa, cinco caminhos criativos, um diagnóstico. O runner não sabia representar isso. ProviderOutput só aceita url ou data (lib/provider_base.py:25-32) e _run_step assume arquivo em todo passo. E passo de raciocínio não podia virar generation com asset falso só para caber: asset draft acende "precisa de você" no kanban do Workbench e infla a contagem de entrega.

O que entra

Kind reason: grava a estrutura em runs.state, uma linha em generations, zero assets. O cost_brl=0.0 vem com cost_source: nao-apurado, porque zero sem procedência já produziu prejuízo antes.

Três consertos na retomada, todos encontrados ao tentar provar o mecanismo, e nenhum deles aparecia em teste porque não existe uma única run em nenhum banco desta máquina:

  1. resume usava o session_id do parâmetro, e o CLI passava None (lib/cli.py:939). Com session_id or 0, a FK de generations.session_id era violada e a run inteira virava failed na primeira geração depois da pausa.
  2. A escolha do humano era gravada crua. Qualquer --selected passava, inclusive um que nunca esteve nas opções. Agora a pausa persiste as opções e a retomada resolve a resposta contra elas.
  3. Falha de step apagava o ponteiro e o trabalho já pago. Agora step_outputs sobrevive e a run falhada é retomável.

Mais: a pausa acende sessions.awaiting_input; from string vira uma opção, não uma por caractere; passo de mídia que declara output de estrutura falha alto; workflow save respeita o slug do YAML; run, workflow list e workflow show ganham --json; e studiolocal runs [--paused] é novo, porque o run_id só vivia no stdout de quem disparou.

Verificação

pytest -q, na raiz do repo:

194 passed in 2.77s

Sendo 7 novos em tests/test_workflow_app_steps.py e 187 que já existiam, nenhum alterado. O gate do ruff:

$ ruff check . --select E9,F --output-format concise
All checks passed!

Os 23 achados de estilo herdados continuam 23 (medido antes e depois com git stash): esta PR não acrescenta nenhum. Ficam num passo não bloqueante e declarado, porque seis deles pedem renomear exceção pública, o que é mudança de API e não cabe aqui.

Ponta a ponta, com geração real, no data root de desenvolvimento do Workbench

Disparo:

$ studiolocal run eco --project acme-verao-2027 \
    --inputs '{"palavra":"jabuticaba","tom":"neon","quantos":1,"observacao":"sem pessoas"}' --json
{"run_id": 1, "status": "paused", "session_id": 2, "cost_brl": 0.0,
 "step_outputs": {"eco": {"palavra": "jabuticaba", "opcoes": [...3 opções...]}},
 "step_id": "escolha", "prompt_to_user": "Qual eco de 'jabuticaba' vira imagem?"}

A pausa, como o kanban a lê:

sessao  awaiting_input  awaiting_reason                        coluna_gravada  rodando  drafts
2       1               Qual eco de 'jabuticaba' vira imagem?  por_fazer       0        0

awaiting_input=1 com rodando=0 e drafts=0 é a combinação que faz deriveColumn acender precisa_voce pelo gatilho certo. column_status continua por_fazer de propósito: a coluna é derivada, e o app nunca escreve nela.

Processo frio, e a recusa:

$ pgrep -fl "studiolocal run" || echo "nenhum processo studiolocal vivo"
nenhum processo studiolocal vivo

$ studiolocal runs --paused
run  status  workflow  project          sessão  passo pendente  início
1    paused  eco       acme-verao-2027  2       escolha         2026-08-22 21:43:53

$ studiolocal run eco --project acme-verao-2027 --resume 1 --selected '{"id":"inventada"}' --json
{"error": "'inventada' não está entre as opções da pausa em 'escolha': ['amanhecer', 'chuva', 'letreiro']"}
exit=2

Retomada válida:

$ studiolocal run eco --project acme-verao-2027 --resume 1 --selected '{"id":"chuva"}' --json
{"run_id": 1, "status": "done", "session_id": 2, "cost_brl": 0.68}

E o que ficou gravado:

id  session_id  run_id  step_index  kind    model                   status  cost_brl
3   2           1       0           reason  template/eco            done    0.0
4   2           1       2           image   nano-banana-pro-google  done    0.68

prompt: uma foto de jabuticaba sob chuva, tom neon (eco de jabuticaba)

id  kind   status  width  height  bytes    provider  cost_brl
5   image  draft   1024   1024    1137898  gemini    0.68

O prompt é a prova que interessa. A retomada recebeu só {"id":"chuva"}: as duas metades do texto, a frase da opção e a palavra jabuticaba, vieram de runs.state, de um processo que já tinha morrido. session_id=2 em vez de 0 é o conserto 1 funcionando, e antes dele a linha nem existiria.

O passo de raciocínio no ledger:

{"motor": "template", "step": "eco", "cost_source": "nao-apurado",
 "cost_gaps": ["passo de raciocínio: tokens não medidos"]}
assets do passo reason: 0

E a sessão não fechou ao gerar (closed_at nulo), que é o comportamento do Workbench e não o do CMS.

O que fica de fora, e vira issue

  • Não existe reaper de generation pendente: se o processo morrer durante um passo reason longo, o card fica preso em "em geração".
  • Não há expiração de run pausada (docs/DESIGN.md:554 já registrava).
  • workflow validate não existe: a validação continua sendo só o prefixo do schema.
  • O motor crystalball não entra aqui; o v1 do reason só conhece template. A Etapa 2 resolve.

🤖 Generated with Claude Code

davidbenal and others added 2 commits August 22, 2026 18:45
…isa ser confiável

A camada de Apps do Workbench (PROD-2127) tem passos cujo resultado é
estrutura, não arquivo: um dossiê de pesquisa, uma lista de caminhos
criativos, um diagnóstico. O runner não sabia representar isso.
`ProviderOutput` só aceita `url` ou `data` (provider_base.py:25-32), e
`_run_step` assume que todo passo salva arquivo. Passo de raciocínio não
podia virar generation com asset falso só para caber nesse caminho: asset
draft acende "precisa de você" no kanban e infla a contagem de entrega.

Entra o kind `reason`: grava a estrutura em `runs.state`, grava uma linha em
`generations` e nenhum asset. A linha entra no ledger por três motivos
medidos: enquanto está `pending` o card acende "em geração", o que cobre
espera longa de motor; `created_at`/`finished_at` dão latência por passo, que
não existia em lugar nenhum; e `cost_brl` ganha onde morar quando o custo de
token passar a ser medido. O zero vem marcado `cost_source: nao-apurado`,
porque zero sem procedência é o erro que a apuração de junho já cometeu.

Três consertos na retomada, todos encontrados ao tentar provar o mecanismo:

1. `resume` herdava `session_id` do parâmetro, e o CLI passava `None`
   (cli.py:939). Com `session_id or 0` isso violava a FK de
   `generations.session_id` e a run inteira virava `failed` na primeira
   geração depois da pausa. Reproduzido: `FOREIGN KEY constraint failed`.
   Agora o `session_id` vem da linha da run, que é quem sabe.

2. A escolha do humano era gravada crua, sem nenhuma checagem. Qualquer
   `--selected` era aceito, inclusive um que nunca esteve nas opções, e o
   passo seguinte consumia um valor que nunca esteve no estado. A pausa passa
   a persistir as opções, e a retomada resolve a resposta contra elas:
   `'inventada' não está entre as opções da pausa em 'escolha'`. Opção
   escalar continua devolvendo o dicionário do humano, para
   `{{ steps.pick.selected }}` seguir resolvendo.

3. Falha de step apagava o ponteiro e o trabalho já pago. Um 429 do provider
   depois de uma escolha humana perdia a escolha. `step_outputs` continua
   preservado e a run falhada passa a ser retomável sem repetir a pergunta.

Mais: a pausa acende `sessions.awaiting_input`, que é o que faz o card ir
para "precisa de você" no Workbench; `from` string vira uma opção, não uma
por caractere; passo de mídia que declara output de estrutura falha alto em
vez de descartar em silêncio; `workflow save` respeita o `slug` do YAML em
vez de derivá-lo do `--name`; e `run`, `workflow list` e `workflow show`
ganham `--json`, que é como o Workbench lê o mesmo arquivo sem ponte.

`studiolocal runs [--paused]` é novo e existe porque o `run_id` só vivia no
stdout de quem disparou: sem ele, run pausada era invisível depois do
terminal fechar.

CI: primeiro workflow deste repositório. Gate é pytest mais o subconjunto do
ruff que pega defeito (E9, F). Os 23 achados de estilo herdados ficam
declarados em voz alta num passo não bloqueante, porque seis deles pedem
renomear exceção pública.

Provado no data root de desenvolvimento do Workbench, com geração real:
run 1 pausou em `escolha`, o processo morreu, a retomada de processo frio
recusou `{"id":"inventada"}` e aceitou `{"id":"chuva"}`, e o prompt gravado
saiu "uma foto de jabuticaba sob chuva, tom neon (eco de jabuticaba)", com as
duas metades vindas de `runs.state`. Asset 1024x1024, R$ 0,68, sessão aberta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O Python do ubuntu-latest é externally managed, e `uv pip install --system`
sai com exit 2 antes de instalar qualquer coisa. Passa a criar venv e rodar
os três passos com `uv run`, da raiz do repositório, que é onde o editable
resolve `from lib.X`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant