A pausa publica o contrato, o app se registra, e o refine ajusta fora do pipeline - #24
Merged
Merged
Conversation
`state["pending_aceita"]`/`pending_campo_livre` já eram persistidos pelo runner, mas nada os publicava: `run --json` só devolvia step_id/prompt/options, `runs --json` não dizia o que uma pausa aceita, e não existia como consultar UMA run pausada por fora do stdout de quem a disparou. `contrato_persistido(state)`, extraída de `_resolver_escolha`, é a fonte única agora lida nos três lugares: o ramo pausado de `run --json`, as quatro chaves `pending_*` novas de `runs --json`, e o subcomando `runs show <run_id> --json` (que virou `runs` grupo + `show`, sem quebrar `runs --json/--paused` como comando plano). Preserva o fallback de pausa gravada antes de `pending_aceita` existir no state, para não publicar `null` onde uma run de verdade está esperando resposta. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`templates/apps/crystal-ball.yaml` não era citado por nenhum instalador, e `Tracker.create_workflow` é um INSERT puro contra `slug UNIQUE` (migrations/001_initial.sql:38) — registrar duas vezes estourava `IntegrityError` cru, o bug que o David batia ao registrar na mão. `Tracker.upsert_workflow` faz `INSERT ... ON CONFLICT(slug) DO UPDATE`, e `workflow save` passa a usá-lo. `studiolocal workflow install [SLUG] [--force]` instala manifestos de `templates/apps/`: valida schema e o contrato de cada pausa `human_pick` (reusando `_contrato_da_pausa`, a mesma checagem que o runner faz em execução) antes de gravar qualquer coisa; YAML já presente no root que diverge do template é preservado, a menos que `--force`; manifesto quebrado não grava nem arquivo nem linha no banco, e não impede os outros do catálogo de instalarem. `install` chama o catálogo no fim, sem force e silencioso, só no bootstrap (nunca em `_ctx()`), para uma máquina nova nascer com o app já registrado. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Verbo novo, `refine <run_id> --alvo <prompt_video|cena:N> --queixa "<texto>" [--json]`. Entra pela conversa, depois que o pacote do Crystal Ball já existe — por isso o par alvo->função é fechado em Python (`FUNCOES_POR_CONVERSA`, `reason_engines.py`) e não no manifesto: o interpolador do runner é cego para índice de lista, e refinamento não pode ser alcançado por passo declarado. `lib/refinamento.py` concentra a lógica. Recusa ANTES de qualquer chamada de rede ou escrita: status fora de done/paused, cena inexistente (lista os números disponíveis), run sem sessão (generations.session_id é NOT NULL). Escreve sempre no lugar canônico que as `ui.vistas` leem (`step_outputs.pacote.resultado...`), rechama `validar_prompt_video` depois de refinar o prompt de vídeo (a função não revalida sozinha), marca `nota_desatualizada` e nunca recalcula a nota nem toca `status`/`finished_at`. Histórico append-only em `state["refinamentos"]`. `Tracker.update_run_state_if_status` faz a escrita atômica (`UPDATE ... WHERE status = ?`): uma mudança de status entre a leitura e a escrita não é sobrescrita em silêncio — a generation já criada fica, e o comando sai com exit 2. `marcar_custo_nao_apurado`, extraída de `workflow_runner.py`, é a fonte única do `cost_source='nao-apurado'`, usada tanto pelo runner quanto pelo refine. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Todo teste do Crystal Ball até aqui troca o ReasonRouter por um duplo — nenhum carregava lib/motores/crystalball_llm.py de fato. O motor só importa stdlib mais requests, então carregá-lo em teste é offline e barato. tests/test_motor_vendorado.py confere: o blob do arquivo bate com o hash declarado em PROVENIENCIA.md (mesmo método do `git hash-object`); carga.motor() registra `_crystalball_motor` em sys.modules e não toca sys.path; a assinatura real de cada função em FUNCOES_POR_MOTOR e FUNCOES_POR_CONVERSA aceita os argumentos que reason_engines.py e refinamento.py de fato passam; PESOS tem 7 chaves, soma 1.0 e é idêntico ao PESOS_REAIS copiado à mão em test_reason_engines.py; chave_ligada() restaura o ambiente mesmo quando o bloco levanta. README.md ganha os três verbos novos (workflow install, runs show, refine) na seção de comandos do terminal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ó o status update_run_state_if_status fazia UPDATE ... WHERE status = ?. Como refine nunca muda status, duas chamadas concorrentes sobre a MESMA run com o MESMO status (ex: A refina prompt_video, B refina cena:1, ao mesmo tempo) passavam as duas pela guarda, e a segunda escrita sobrescrevia a primeira por completo, em silêncio, sem levantar RefinamentoConcorrente. Reproduzido com duas conexões SQLite lendo o mesmo state antes de qualquer escrita. Tracker.update_run_state_if_status ganha state_bruto_esperado opcional, que estende o WHERE para comparar também a string exata do state lido no início de refinar(). Qualquer escrita concorrente no meio, com status mudando ou não, agora zera o rowcount e refinar() levanta RefinamentoConcorrente como já fazia para o caso de status. Teste novo reproduz o cenário com duas leituras do mesmo (status, state) e confirma que a segunda escrita falha e o state final é o da primeira, intacto. Confirmado que falha sem o conserto antes deste commit.
resumo devolvido por refinar() (e por `refine --json`) só trazia run_id, alvo, funcao, generation_id e o_que_mudou — quem chamasse não tinha como saber o que de fato foi escrito sem uma segunda leitura para comparar. Acrescenta status (da run, inalterado pelo refine), resultado (o valor novo escrito: o prompt de vídeo inteiro para prompt_video, a ficha da cena para cena:N), avisos (revalidado para prompt_video; None para cena:N, que não recalcula _avisos) e nota_desatualizada (sempre true).
… e runs --json 3a) No ramo `except WorkflowPaused` de `run_cmd`, `_resumo(p.run_id)` e `_contrato_da_run(tracker, p.run_id)` liam e faziam parse do MESMO state em duas consultas separadas. `_carregar_resumo` agora devolve (resumo, estado) numa consulta só, e `_contrato_da_run` ganha um parâmetro opcional `estado` para reusar o que já foi carregado — quem só tem o run_id continua funcionando exatamente como antes. 3b) Em `_runs_listar` (`runs --json`), `_pendente(row)` e `_pendencia_json(row)` eram chamadas juntas por linha, cada uma com seu próprio json.loads do mesmo state. `_pendencia_json` passa a fazer o parse uma vez e devolver também `pending_step`; `_pendente` continua existindo à parte, sem mudança, para o branch tabular (não-JSON), que não chama `_pendencia_json`. Refactor puro: suíte inteira roda sem diferença de comportamento.
Achado real, rodando de verdade: refine --alvo cena:N devolvia sucesso e trocava o hash da cena no state, mas a descrição continuava a mesma — nenhum traço da queixa no resultado. Causa: regenerate_scene (vendorado) não tem parâmetro de queixa no contrato original, e o wrapper montava `contexto` só com briefingText, direcao e biblia_visual. A queixa ficava presa em state["refinamentos"], nunca chegava ao Gemini. Conserto na camada de wrapper (lib/refinamento.py), sem tocar o motor vendorado: briefingText é o único campo do contexto que o motor de fato copia para o prompt que monta a chamada (ctx["briefing_resumo"], em regenerate_scene, crystalball_llm.py ~1318 — confirmado lendo _call, que concatena system_prompt + user_text e manda pro Gemini de verdade). O briefing original é preservado; a queixa entra como um acréscimo explícito, rotulado como ajuste específico para a cena. Teste em tests/test_refine.py com MotorFalso captura o contexto recebido por regenerate_scene e prova que a queixa aparece em briefingText junto com o briefing original — falha sem a correção (confirmado revertendo o fix e rodando o teste antes de comitar).
Achado real, conferido direto via sqlite: depois de um refine,
state["nota_desatualizada"] fica true no banco, mas `runs show <id>
--json` tinha uma lista fixa de chaves que não incluía o campo — a tela
que já lê essa chave para acender o aviso de nota possivelmente
desatualizada nunca via o dado, mesmo presente.
Acrescenta `nota_desatualizada` (estado.get("nota_desatualizada", False))
em runs_show. Estendido também para os outros dois lugares que montam
JSON a partir do mesmo state parseado, sem duplicar lógica:
- `_carregar_resumo` (run --json), que alimenta tanto o retorno de
sucesso quanto o ramo pausado (WorkflowPaused) — uma run pode ter sido
refinada enquanto pausada (refine aceita status 'paused') e depois
retomada até uma nova pausa ou até done, então o campo é legítimo nos
dois ramos, não só no de sucesso.
- `_pendencia_json` (runs --json, listagem): é booleano, vem do mesmo
parse que os cinco campos `pending_*` já expostos por linha, e não
reintroduz o custo que o comentário da função rejeita (a lista de
opções inteira por run).
Teste em tests/test_refine.py roda a CLI de verdade (CliRunner sobre
lib.cli.main), porque o bug estava na função que monta o JSON, não em
refinar() — o resumo devolvido por refinar() já trazia o campo certo
antes desta correção. Falha sem o fix (confirmado revertendo e rodando
antes de comitar).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Segunda frente da Etapa 2 (
PROD-2128), sobre o motor vendorado da #23. Três coisas que faltavam para o Crystal Ball rodar como app de verdade, cada uma no seu commit, CI verde entre um e outro.1. A pausa publica seu contrato
state["pending_aceita"]/pending_campo_livre"(workflow_runner.py:382-383) já eram persistidos pelo runner, mas nada os publicava:run --jsonsó devolviastep_id/prompt_to_user/options,runs --jsonnão dizia o que uma pausa aceita, e não existia como consultar UMA run pausada por fora do stdout de quem a disparou.contrato_persistido(state), extraída de_resolver_escolha, é a fonte única lida nos três lugares: o ramo pausado derun --json(ganhaaceita/campo_livre), as quatro chavespending_*novas deruns --json(pending_aceita,pending_campo_livre,pending_prompt,pending_opcoes— contagem, não a lista inteira, porque é endpoint de listagem), e o subcomando novoruns show <run_id> --json(que virourunsgrupo +show, sem quebrarruns --json/--pausedcomo comando plano — Click cominvoke_without_command=True). Preserva o fallback de pausa gravada antes depending_aceitaexistir no state, para nunca publicarnullonde uma run de verdade está esperando resposta.2. O manifesto registrado
templates/apps/crystal-ball.yamlnão era citado por nenhum instalador, eTracker.create_workflowé um INSERT puro contraslug UNIQUE(migrations/001_initial.sql:38) — registrar duas vezes estouravaIntegrityErrorcru, o bug que o David batia ao registrar na mão.Tracker.upsert_workflowfazINSERT ... ON CONFLICT(slug) DO UPDATE, eworkflow savepassa a usá-lo.studiolocal workflow install [SLUG] [--force]instala manifestos detemplates/apps/: valida schema (WorkflowSpec.from_yaml) e o contrato de cada pausahuman_pick— reusando_contrato_da_pausa, a MESMA checagem que o runner aplica em execução, não uma reimplementação — antes de gravar qualquer coisa. YAML já presente no root que diverge do template é preservado, a menos que--force; manifesto quebrado não grava nem arquivo nem linha no banco, e não impede os outros do catálogo de instalarem.installchama o catálogo no fim, sem force e silencioso, só no bootstrap (nunca em_ctx(), para não sobrescrever em silêncio uma edição manual a cada invocação).3.
studiolocal refineVerbo novo:
refine <run_id> --alvo <prompt_video|cena:N> --queixa "<texto>" [--json]. Entra pela conversa, depois que o pacote já existe — por isso o par alvo→função é fechado em Python (FUNCOES_POR_CONVERSA, espelhandoFUNCOES_POR_MOTOR) e não no manifesto: o interpolador do runner é cego para índice de lista ({{ steps.pacote.prompts_de_cena.0... }}devolveNoneem silêncio — medido, decisão já tomada, não é para arrumar aqui).lib/refinamento.pyconcentra a lógica. Recusa ANTES de qualquer chamada de rede ou escrita: status fora dedone/paused(runningainda grava,failednão tem resultado), cena inexistente (lista os números disponíveis), run sem sessão (generations.session_idéNOT NULL). Escreve sempre no lugar canônico que asui.vistasdo manifesto leem (step_outputs.pacote.resultado...); rechamavalidar_prompt_videodepois de refinar o prompt de vídeo, porque a função não revalida sozinha; marcanota_desatualizadae nunca recalcula a nota (_criticaré privada e cara) nem tocastatus/finished_at. Histórico append-only emstate["refinamentos"], comantespara permitir desfazer manualmente.Tracker.update_run_state_if_statusfaz a escrita atômica (UPDATE ... WHERE status = ?): uma mudança de status entre a leitura e a escrita não é sobrescrita em silêncio — agenerationjá criada fica registrada, e o comando sai com exit 2.marcar_custo_nao_apurado, extraída deworkflow_runner.py:567-578, é a fonte única docost_source='nao-apurado', chamada tanto pelo runner quanto pelo refine.4. Teste que toca o motor vendorado de verdade
Todo teste do Crystal Ball até aqui troca o
ReasonRouterpor um duplo — nenhum carregavalib/motores/crystalball_llm.pyde fato. O motor só importa stdlib maisrequests, então carregá-lo em teste é offline e barato.tests/test_motor_vendorado.pyconfere: o blob do arquivo bate com o hash declarado emPROVENIENCIA.md(mesmo método dogit hash-object);carga.motor()registra_crystalball_motoremsys.modulese não tocasys.path; a assinatura real de cada função emFUNCOES_POR_MOTOReFUNCOES_POR_CONVERSAaceita os argumentos quereason_engines.py/refinamento.pyde fato passam;PESOStem 7 chaves, soma 1.0 e é idêntico aoPESOS_REAIScopiado à mão emtest_reason_engines.py;chave_ligada()restaura o ambiente mesmo quando o bloco levanta. README ganha os três verbos novos na seção de comandos.Prova
Desvios do plano
regenerate_scene(contexto, cena),refine_video_prompt(prompt, complaint, biblia=None),Tracker.update_run(...)incondicional existente): tudo bateu com o código real.runsvirou umclick.Group(invoke_without_command=True) em vez de continuarclick.Command, para caberruns showcomo subcomando sem quebrarruns --project/--paused/--jsoncomo estava. Não havia outra forma de encaixar os dois no Click sem isso.workflow installsome da leitura de_apps_dir()(nova função) em vez de inline, para o teste do manifesto quebrado poder apontar para um diretório temporário viamonkeypatchsem depender do conteúdo real detemplates/apps/.🤖 Generated with Claude Code