Isolamento de run & coleta de runs órfãs¶
Port nativo de duas coisas que o paperclip faz bem — um worktree do git por run e um
reaper baseado em liveness de pid — para dentro do harness do Leopold. Sem Postgres,
sem daemon; o estado fica em .leopold/, o git roda a partir do orquestrador (Node),
sinais vão para grupos de processo.
Por quê¶
O driver SDK (leopold-driver run, "Path A") roda cada item do plano com um worker
novo do Claude Code via o query() do Agent SDK, in-process, com cwd: brief.root
(packages/driver/src/worker.ts). Os subagents que o worker cria herdam esse cwd. Duas lacunas:
- Sem isolamento. Uma run muta a working tree do usuário diretamente; duas runs no
mesmo checkout colidem. A doc já manda o usuário rodar
git worktree addna mão (docs/guardrails.md) — isto automatiza esse passo. - Sem coleta. Se uma run crasha, o
state.jsonmantémactive:truepara sempre, e qualquer worktree que ela criou fica órfão. Nada detecta nem limpa.
O "Path B" (/leopold-run, a skill in-session) conduz a própria sessão ao vivo, então
não consegue trocar o próprio cwd — a automação de worktree é só do Path A. Os dois paths compartilham .leopold/.
O quê¶
- Worktree por run — opt-in, Path A.
leopold-driver run --worktree(ouLEOPOLD_WORKTREE=1) provisiona um worktree dedicado em uma branch descartávelleopold/run-<id>e aponta ocwddo worker para ele. A run e seus subagents ficam isolados da tree do usuário e das outras runs. O driver roda git diretamente via Node, então o lock de git do worker não é afetado — o lock restringe o worker, não o orquestrador. Cai parabrief.rootse o projeto não for um repositório git. - Reaper de órfãs — ambos os paths. O driver persiste
orchestrator_pidnostate.json. Um preflight no startup coleta uma run órfã anterior:active:truemas o pid está morto (process.kill(pid, 0)lançaESRCH) → viraactive:false, logarun_reaped, limpa o worktree dela, remove tokens de run velhos. O Path B mantém sua checagem existente de inatividade de ~10 min. - Endurecimento da escrita de estado.
writeStatevira read-merge-write: o driver TS e a skill bash/Stop-hook são dois escritores com schemas diferentes, e o overwrite completo antigo derrubava os campos um do outro (session_id,max_subagents,worktree_path, …). O merge preserva chaves desconhecidas, então os campos novos sobrevivem aos dois escritores. - A limpeza nunca é destrutiva. O git está travado, então uma run faz stage mas nunca
commit. Um worktree com trabalho não commitado é preservado e logado
(
worktree_preserved) para o usuário revisar/mergear. Só um worktree limpo é removido (worktree remove --force+branch -D leopold/run-<id>). - Guard.
git worktreepassa a ser explicitamente permitido noguard-irreversible.sh, e o bloqueio degit branch -Dganha uma exceção estreita para as branches descartáveisleopold/run-*do próprio harness (deletar qualquer outra branch continua proibido).
Arquivos¶
| Arquivo | Mudança |
|---|---|
packages/driver/src/types.ts |
RunState += worktree_path?/worktree_branch?/orchestrator_pid?; Brief += worktreeRoot?; DriverConfig += worktree |
packages/driver/src/config.ts |
loadConfig lê --worktree/LEOPOLD_WORKTREE; writeState read-merge-write; initState escreve orchestrator_pid |
packages/driver/src/worktree.ts (novo) |
createWorktree, cleanupWorktree (preserva se estiver sujo), isGitRepo, isDirty |
packages/driver/src/reaper.ts (novo) |
reapOrphan (ativa + pid morto), isProcessAlive |
packages/driver/src/loop.ts |
preflight do reaper; provisiona o worktree; persiste worktree_path; limpeza no stop() |
packages/driver/src/worker.ts |
cwd: brief.worktreeRoot ?? brief.root |
hooks/guard-irreversible.sh |
permite git worktree; exceção de branch -D para leopold/run-* |
skills/leopold-run/SKILL.md |
anota --worktree; coleta um worktree órfão na checagem de inatividade |
packages/driver/test/worktree.test.ts, reaper.test.ts (novos) |
testes unitários contra um repositório git temporário |
Verificação¶
make driver-check(typecheck) +make driver-test(vitest) verdes.- Repositório git temporário:
leopold-driver run --worktreeprovisionaleopold/run-<id>, o cwd do worker é o worktree, uma run limpa o remove, uma run suja o preserva (com log). - Reaper: um
state.jsoncomactive:true+ pid morto viraactive:falsee seu worktree é removido no próximo startup; um pid vivo fica intocado. - Merge do
writeState: um campo escrito pelo bash (max_subagents) sobrevive a uma escrita posterior do driver, e vice-versa. - Guard:
git worktree add/remove,git branch -Degit reset --hardsão todos permitidos — sógit commitegit pushsão travados, então a limpeza de worktree não passa pelo guard.