# A limpeza de processos remotos funciona depois do cancelamento SSH?

Uma execução de agente cancelada não é o mesmo evento que um comando remoto interrompido. O processo local pode sair corretamente enquanto o transporte SSH continua ativo, o transporte pode desaparecer enquanto o shell remoto continua executando, e o shell pode morrer enquanto seus filhos continuam em outro grupo de processos. Se você transformar todos esses eventos em um único status chamado «cancelado», mais cedo ou mais tarde deixará uma migração de banco de dados, uma instalação de pacote, um worker de testes ou um auxiliar de implantação rodando depois que o agente informar que parou.

A solução não é apenas criar um truque inteligente com tratamento de sinais. Você precisa de um contrato de cancelamento que identifique a execução remota, crie um limite encerrável ao redor dos descendentes, preserve saída suficiente para diagnosticar uma execução interrompida e tenha uma resposta no lado remoto para o caso de o controlador desaparecer. Crie e teste esse contrato antes de permitir que um agente execute comandos com efeitos colaterais.

## O cancelamento SSH tem quatro etapas separadas

Uma solicitação de cancelamento precisa atravessar quatro limites: o agente decide parar, o supervisor local interrompe ou sinaliza o cliente SSH, o protocolo SSH transporta um evento de canal ou sinal, e o host remoto reage a esse evento. Cada etapa pode falhar de forma independente.

A RFC 4254 separa esses conceitos. Ela define uma mensagem de fechamento de canal e, separadamente, uma solicitação de canal `signal` para nomes como `TERM`, `INT` e `HUP`. O fechamento de um canal é um evento de transporte. Ele não significa «enviar SIGTERM a todos os descendentes remotos». A RFC também diz que os dados enviados antes do fechamento devem ser entregues, se possível. «Se possível» deixa muita coisa em aberto quando um laptop entra em suspensão, uma rota de rede cai ou um processo local é encerrado à força.

Essa distinção revela um erro comum de projeto:

1. Um agente inicia `ssh host long-command`.
2. A pessoa usuária pressiona cancelar.
3. O executor do agente encerra o processo filho local.
4. A interface marca o trabalho como cancelado.
5. `long-command`, ou um de seus filhos, continua no host.

A quinta linha não é uma exceção. Ela é o resultado padrão quando o servidor não tem motivo para encerrar o comando ou quando o comando se separou da sessão antes de a conexão desaparecer.

Um contrato de cancelamento útil diz exatamente o que o lado local tentará fazer e o que pertence ao lado remoto:

- O iniciador cria uma execução remota identificável com um ID aleatório.
- O wrapper remoto inicia o trabalho em seu próprio grupo de processos ou sessão.
- Um cancelamento normal envia `TERM` para esse grupo de processos e registra o resultado.
- O wrapper só escala para `KILL` depois de um período de tolerância definido.
- Um prazo remoto ou uma concessão encerra o trabalho quando o controlador não retorna.
- A saída e o status final sobrevivem ao fluxo SSH.

Não chame uma tarefa de cancelada até obter um de dois resultados: um registro remoto final confirmado ou um resultado explícito de «estado desconhecido». Fingir certeza depois de uma perda de conexão torna a resposta a incidentes mais lenta, porque todos começam com uma premissa falsa.

## Um PID remoto não basta para limpar os filhos

Encerrar o PID do shell remoto só é seguro quando o shell nunca cria processos, inicia um pipeline, executa um processo em segundo plano ou chama uma ferramenta que cria auxiliares. Poucos comandos têm esse perfil.

Considere este comando remoto comum:

```sh
build-assets | tee build.log &
wait
```

O shell tem um PID. O pipeline tem vários processos. `tee` pode continuar gravando o log depois que o shell sair. Um compilador pode iniciar processos workers. Um gerenciador de pacotes pode delegar o trabalho a um serviço. Se você executar `kill -TERM "$shell_pid"`, terá encerrado um membro de um grupo maior e aprendido muito pouco sobre os demais.

Os grupos de processos oferecem a unidade correta de cancelamento para uma execução remota curta. No Linux, cada processo pertence a um grupo de processos, e cada grupo pertence a uma sessão. Os sinais gerados pelo terminal vão para o grupo de processos em primeiro plano, por isso o comportamento do terminal pode parecer mais misterioso do que realmente é. A documentação do Linux para `setpgid(2)` também deixa claro que um filho herda o grupo de processos do pai, a menos que algo altere isso.

Para um comando controlado por agente, crie uma sessão nova para o trabalho. Normalmente, o líder da sessão tem um PID igual ao PGID e ao SID. Assim, um PID negativo em `kill` se dirige ao grupo de processos:

```sh
kill -TERM -- -"$pgid"
```

O sinal de menos inicial é o que diferencia o encerramento de um processo do encerramento de seu grupo de processos. O `--` também é importante. Ele impede que um valor malformado seja interpretado como uma opção.

Não presuma que o PID do trabalho seja seu ID de grupo de processos. Verifique isso no início. Um wrapper de shell, um gerenciador de serviços ou um programa que chama `setpgid` pode alterar a árvore. Este é o menor comando de inspeção que vale a pena manter no seu conjunto de testes:

```sh
ps -o pid=,ppid=,pgid=,sid=,stat=,etime=,command= -p "$pid"
```

Uma saída típica tem este formato:

```text
24182  24177  24182  24182 Ss       00:03 bash ./worker.sh /tmp/agent-runs/6c4...
```

Aqui, PID, PGID e SID são iguais. Isso é uma evidência de que `kill -TERM -- -24182` alcança o limite pretendido. Se o PGID não corresponder ao registro da execução, interrompa o início em vez de tentar adivinhar.

Um grupo de processos ainda tem limites. Um filho pode chamar `setsid`, um runtime de contêiner pode mover um processo para outro lugar, e um trabalho pode pedir a um gerenciador de serviços que execute algo fora do grupo. Esse comportamento às vezes é legítimo. Ele também significa que sua garantia de cancelamento terminou nessa transferência. Trate o trabalho separado como um tipo distinto, com identidade, operação de parada e registro de auditoria próprios.

## Um wrapper de cancelamento precisa de um caminho de limpeza real

Um wrapper de shell remoto deve ser responsável pelo PID do trabalho, capturar os sinais de encerramento esperados, alcançar o grupo do trabalho, aguardar brevemente e gravar um registro final. Ele não deve usar `pkill command-name`, examinar uma lista de processos sem vínculo ou encerrar todos os processos pertencentes a uma conta. Esses atalhos funcionam até o dia em que duas execuções de agente compartilham um usuário, o nome do host muda ou um nome de comando coincide com o trabalho de outra pessoa.

Este fixture orientado ao Linux é propositalmente simples. Ele cria um diretório de execução protegido, inicia um trabalho em uma sessão nova, grava a saída em arquivos e encerra o grupo do trabalho quando o wrapper recebe `TERM`, `INT` ou `HUP`.

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

run_id=${1:?run ID required}
shift
run_dir="${HOME}/.agent-runs/${run_id}"
umask 077
mkdir -p "$run_dir"

child_pid=""
child_pgid=""
finished=0

write_status() {
  local state=$1
  local code=${2:-}
  local tmp="$run_dir/status.tmp"
  printf '{"run_id":"%s","state":"%s","exit_code":"%s"}\n' \
    "$run_id" "$state" "$code" >"$tmp"
  mv "$tmp" "$run_dir/status.json"
}

stop_group() {
  if [[ -z ${child_pgid:-} ]]; then
    return
  fi

  kill -TERM -- "-$child_pgid" 2>/dev/null || true
  for _ in 1 2 3 4 5; do
    if ! kill -0 -- "-$child_pgid" 2>/dev/null; then
      return
    fi
    sleep 1
  done
  kill -KILL -- "-$child_pgid" 2>/dev/null || true
}

cancel() {
  local signal=$1
  trap - TERM INT HUP
  write_status "cancelling:$signal"
  stop_group
  wait "$child_pid" 2>/dev/null || true
  write_status "cancelled:$signal"
  finished=1
  exit 143
}

trap 'cancel TERM' TERM
trap 'cancel INT' INT
trap 'cancel HUP' HUP

write_status "starting"
setsid "$@" >"$run_dir/stdout.log" 2>"$run_dir/stderr.log" &
child_pid=$!
child_pgid=$(ps -o pgid= -p "$child_pid" | tr -d ' ')

if [[ "$child_pgid" != "$child_pid" ]]; then
  printf 'unexpected PGID for %s: %s\n' "$child_pid" "$child_pgid" \
    >"$run_dir/stderr.log"
  kill -TERM "$child_pid" 2>/dev/null || true
  write_status "launch_failed"
  exit 70
fi

printf '%s\n' "$child_pid" >"$run_dir/pid"
printf '%s\n' "$child_pgid" >"$run_dir/pgid"
write_status "running"

set +e
wait "$child_pid"
code=$?
set -e

if [[ $finished -eq 0 ]]; then
  write_status "finished" "$code"
fi
exit "$code"
```

Esse wrapper evita uma falha específica: um sinal de cancelamento chega ao wrapper, mas ele encerra apenas a si próprio e deixa o trabalho perdido. Ele não promete interromper descendentes que se separaram intencionalmente. Não deve fazer essa promessa.

O manual do Linux para `setsid(2)` diz que `setsid()` cria uma nova sessão e transforma o chamador no líder de um novo grupo de processos, inicialmente sem terminal de controle. O comando `setsid` do util-linux executa um programa nessa nova sessão, criando um processo filho quando necessário. Por isso, ele é um limite prático para uma execução de comando, não uma chave mágica de limpeza.

Mantenha o wrapper restrito. Ele deve iniciar, registrar, interromper e informar. Não esconda nele a lógica de negócio. O trabalho ainda deve ter seu próprio tratamento de transações, limpeza de arquivos temporários e regras de idempotência.

## Uma desconexão limpa do transporte não garante a limpeza

Usuários de SSH costumam tirar conclusões demais de um teste no terminal. Executam um comando com PTY, fecham o terminal, veem um processo sair depois de `SIGHUP` e concluem que a limpeza por desconexão funciona. Depois, um agente usa um canal SSH não interativo sem PTY e o comportamento muda.

Um PTY cria semântica de terminal. Uma desconexão do terminal pode levar à entrega de `SIGHUP`, mas apenas nas condições que governam terminais de controle e grupos de processos em primeiro plano. O manual do Linux descreve `SIGHUP` como uma desconexão de um terminal de controle ou a morte de um processo de controle. Essa descrição não diz que toda desconexão SSH sinaliza todos os processos iniciados pelo SSH.

O SSH não interativo costuma ser o melhor padrão para agentes, pois oferece uma saída mais limpa e menos surpresas causadas pela inicialização do shell. Ele também elimina qualquer dependência acidental do comportamento do terminal. Use um PTY somente quando o programa remoto precisar dele, como um instalador antigo que se recusa a executar de outra forma. Nesse caso, documente que o PTY faz parte do comportamento do comando e teste-o separadamente.

Há três casos de desconexão que vale a pena nomear:

### O cliente envia um cancelamento deliberado

O supervisor local ainda tem uma conexão ativa e pode enviar um sinal de protocolo ou abrir um comando de controle autenticado separado que sinaliza o PGID registrado. Esse é o melhor caso. O wrapper remoto recebe `TERM`, faz a limpeza e grava `cancelled:TERM`.

Não dependa do recebimento de `SIGINT` local por um cliente SSH para que exatamente isso aconteça, a menos que você tenha testado a biblioteca e a chamada do cliente que distribui. Um cliente de terminal, uma biblioteca SSH integrada e uma ferramenta MCP podem mapear o cancelamento local de maneiras diferentes. Alguns fecham um socket. Alguns encerram o processo local. Alguns conseguem enviar uma solicitação SSH `signal`. São implementações diferentes de uma interface que os usuários chamam de «cancelar».

### O cliente local falha ou perde a rede

O comando remoto pode continuar. O servidor não consegue distinguir um problema temporário de roteamento de um usuário que pretende deixar o trabalho continuar, a menos que seu protocolo diga como fazer isso. Uma concessão remota é a resposta honesta.

No início, grave um `deadline_epoch` no diretório remoto da execução. Um supervisor local o renova enquanto a execução continuar autorizada. Um watchdog remoto verifica esse valor e chama o mesmo caminho de limpeza do grupo de processos depois que ele expira. Escolha uma duração de concessão adequada à operação. Cinco minutos podem servir para um comando de shell. Podem ser imprudentes para uma compilação com fases longas, mas normalmente silenciosas.

### O host remoto falha ou reinicia

Você pode perder tanto o processo quanto o status final. Não informe «cancelado» ou «concluído» apenas porque a conexão SSH terminou. Marque a execução como desconhecida até que uma reconciliação posterior leia o diário do host, o estado da implantação, o registro de bloqueio ou um resultado específico da aplicação.

A parte difícil não é emitir uma palavra de status. É se recusar a emitir um status que seu sistema não consegue sustentar.

## Uma saída parcial prova observação, não conclusão

Um fluxo responde «quais bytes o cliente recebeu até agora?». Ele não responde «em que estado o comando remoto deixou o sistema?». Esse erro aparece quando um programa remoto imprime `done` antes de descarregar seu último arquivo ou quando a rede cai depois que o comando terminou, mas antes de o cliente receber o status de saída do SSH.

Mantenha dois registros:

- `stdout.log` e `stderr.log` guardam a saída de diagnóstico enquanto o trabalho a produz.
- `status.json` é um pequeno registro final gravado atomicamente depois que o wrapper observa a saída ou trata o cancelamento.

O `mv` do wrapper é importante. Grave um arquivo de status temporário no mesmo diretório e depois renomeie-o para o local definitivo. Os leitores verão o arquivo anterior completo ou o novo arquivo completo. Nunca devem analisar metade de um documento JSON e inventar um resultado.

A própria saída precisa de regras. Um comando pode armazenar muita saída em buffer quando stdout é um arquivo, em vez de um terminal. Se o progresso for importante, faça o trabalho emitir um status explícito, orientado por linhas, em stderr ou use um arquivo de progresso no nível da aplicação. Não resolva o buffering alocando um PTY para todos os comandos. Isso altera o comportamento e pode misturar stdout com stderr, o que piora a auditoria e a análise de falhas.

Trate estes casos de forma diferente na interface ou no diário do agente:

| Registro remoto | Estado do fluxo | Significado |
| --- | --- | --- |
| `finished`, código de saída presente | completo | O wrapper observou a conclusão normal. |
| `cancelled:TERM` | pode terminar abruptamente | O wrapper iniciou o cancelamento e interrompeu o grupo. |
| apenas `cancelling:TERM` | desconectado | A limpeza começou, mas o registro final não foi observado. Faça a reconciliação. |
| apenas `running`, concessão válida | desconectado | O trabalho ainda pode estar em execução. Não tente novamente às cegas. |
| nenhum registro utilizável | desconectado | O estado é desconhecido. Verifique os efeitos colaterais antes de iniciar novamente. |

Evite colocar tokens de acesso, dumps de configuração sem mascaramento ou credenciais nesses logs. A injeção de credenciais SSH pode manter a chave privada longe do agente, mas um comando remoto ainda pode imprimir segredos que leu do próprio ambiente ou da configuração. A retenção da saída faz parte do desenho do comando, não é um detalhe posterior.

## Teste árvores de processos, não apenas um shell inerte

`trap 'exit' TERM; sleep 600` é um teste fraco de cancelamento. Ele prova que um shell em primeiro plano pode receber um sinal. Não testa filhos, grupos de processos, limpeza atrasada, persistência da saída ou um shell remoto que desaparece no momento errado.

Use um trabalho que crie uma árvore de processos visível e registre cada sinal. Salve isto como `worker.sh` em um host Linux descartável:

```bash
#!/usr/bin/env bash
set -Eeuo pipefail
run_dir=${1:?run directory required}

note() {
  printf '%s pid=%s pgid=%s %s\n' \
    "$(date +%s)" "$$" "$(ps -o pgid= -p $$ | tr -d ' ')" "$1" \
    >>"$run_dir/worker.log"
}

trap 'note TERM; exit 143' TERM
trap 'note INT; exit 130' INT
trap 'note HUP; exit 129' HUP

(
  trap 'note grandchild_TERM; exit 143' TERM
  trap 'note grandchild_HUP; exit 129' HUP
  while :; do
    note grandchild_tick
    sleep 1
  done
) &
grandchild=$!

note "started grandchild=$grandchild"
while :; do
  note parent_tick
  sleep 1
done
```

Execute-o pelo wrapper com um ID de execução aleatório. Em outra sessão SSH, inspecione a árvore de processos e o diretório da execução:

```sh
run_id=cancel-test-$(date +%s)
ssh host.example './remote-wrapper.sh '"$run_id"' ./worker.sh \"$HOME/.agent-runs/'"$run_id"'\"'
```

As aspas exatas serão diferentes em um iniciador real. Tudo bem. O que não pode mudar são as evidências do teste: você precisa do ID da execução remota, do PID do wrapper, do PGID do trabalho, dos locais dos logs e do status final.

Depois, teste estes caminhos de falha, um de cada vez:

1. Envie `TERM` ao PID do wrapper. Confirme que o processo pai e o neto registraram o encerramento e que `ps` não encontra nenhum processo no PGID registrado.
2. Envie `TERM` diretamente ao PID do trabalho. Confirme que não se presume um comportamento seguro dos filhos. Esse teste explica por que o wrapper alcança um grupo.
3. Encerre o cliente SSH local sem enviar um sinal remoto. Confirme que o trabalho permanece ativo até a expiração da concessão remota. Se ele parar imediatamente, registre o motivo, como o comportamento de desligamento do PTY, em vez de tratar esse resultado como universal.
4. Desconecte depois que o wrapper gravar `cancelling:TERM`, mas antes de gravar seu registro final. Confirme que a reconciliação consegue distinguir uma observação incompleta de um novo trabalho em execução.
5. Inicie duas execuções na mesma conta, cancele uma e prove que a outra continua viva. Isso detecta lógica perigosa de `pkill` amplo e de limpeza de toda a conta.

Use `ps` e `pgrep -a -g "$pgid"` durante os testes e repita depois do período de tolerância. Verifique o arquivo de status remoto e os logs somente depois de verificar a tabela de processos. Um registro de status que diz «cancelado» enquanto os workers continuam vivos é um bug no supervisor, não uma simples divergência de relatório.

## Sinais de morte do pai ajudam apenas dentro de um worker controlado

O Linux oferece `PR_SET_PDEATHSIG`, que permite a um processo pedir ao kernel que envie um sinal quando o thread que o criou terminar. Isso pode ser útil quando você controla um pequeno auxiliar nativo que cria um filho imediato e quer que esse filho pare se o auxiliar morrer. A configuração sobrevive a `execve` em casos comuns, mas o manual documenta exceções importantes, incluindo mudanças de credenciais.

Isso não resolve sozinho a limpeza de agentes remotos.

Primeiro, o processo do servidor SSH não é necessariamente o pai com o qual você se importa. Segundo, o sinal se aplica ao processo que o configurou, não automaticamente a todos os descendentes. Terceiro, um comando remoto que cria processos, usa criação dupla de processos ou entrega o trabalho a outro serviço abandona essa relação. Quarto, essa função é específica do Linux, o que importa se sua frota tiver hosts Unix variados.

Use-a apenas como reforço quando a árvore de processos estiver sob seu controle. Por exemplo, um worker Linux pequeno pode configurar `PR_SET_PDEATHSIG` antes de executar um único filho controlado, enquanto o wrapper externo continua responsável pelo grupo de processos e pela concessão. Isso oferece dois detectores de falha com escopos diferentes. Não permite ignorar o limite do grupo nem o registro remoto final.

O mesmo alerta vale para `nohup`, `disown` e `setsid` dentro do trabalho. Eles são úteis quando alguém quer deliberadamente que o trabalho sobreviva a um terminal. São incompatíveis com a promessa de que cancelar uma execução do agente interrompe o trabalho. Torne essa escolha explícita no início.

## Um segundo canal de controle costuma ser mais limpo do que encerrar o primeiro

Quando um agente cancela uma chamada SSH em andamento, seu próprio contexto de execução pode já estar sendo desmontado. Depender desse processo que está morrendo para enviar um último sinal de protocolo cria condições de corrida. Um processo supervisor separado deve ser responsável pelo cancelamento e pela reconciliação.

Um projeto possível é este:

1. O supervisor gera um ID de execução criptograficamente aleatório e chama o wrapper remoto.
2. O wrapper registra seu PID, PGID, horário de início e status em um diretório com o nome desse ID.
3. O supervisor registra o ID da execução e o host remoto antes de começar a consumir a saída.
4. Ao cancelar, o supervisor abre uma nova ação de controle que lê o registro da execução, verifica a propriedade e a idade esperadas e sinaliza o PGID registrado.
5. O supervisor consulta o registro de status final até encontrar um estado terminal ou atingir um prazo de relatório.

A ação de controle precisa validar o registro da execução antes de sinalizar qualquer coisa. No mínimo, verifique se o diretório do registro pertence ao usuário esperado, se o PID ainda existe, se o PGID registrado corresponde ao `ps` e se o horário de início é compatível com o processo iniciado. O Linux pode reutilizar PIDs. Um arquivo PID antigo acompanhado de um `kill` incondicional é como um script de limpeza com falha termina um trabalho que não tem relação com ele semanas depois.

Não coloque o comando de limpeza atrás de uma instrução genérica do agente, como «encerre o processo da minha tarefa anterior». O agente deve receber um identificador opaco de execução. O supervisor transforma esse identificador em uma ação remota com escopo restrito. Esse desenho também torna a auditoria legível: a pessoa revisora vê que a execução `6c4...` solicitou o cancelamento do PGID `24182` em um host, não que um agente montou um comando arbitrário de encerramento.

Para agentes autônomos de programação, o Sallyport pode executar ações SSH sem expor a chave SSH ao agente. Isso mantém a custódia da credencial separada do contrato de cancelamento, que ainda precisa do identificador da execução, da verificação do grupo, da concessão remota e do registro final.

## A decisão correta de tentar novamente depende do efeito colateral

Um comando que apenas lê um arquivo geralmente pode ser repetido depois de uma desconexão com estado desconhecido. Um comando que cria um usuário, aplica uma migração, troca um certificado ou inicia uma implantação não pode. A camada SSH não consegue informar se a operação se tornou segura para repetir.

Dê aos comandos remotos com efeitos colaterais um token de idempotência derivado do ID da execução. O programa remoto deve armazenar o token junto do resultado da operação e devolver o resultado existente se vir o mesmo token novamente. Se isso for impossível, adicione uma consulta preliminar que identifique se a alteração solicitada já aconteceu.

Não use a limpeza como substituta da idempotência. Mesmo um `TERM` perfeito pode chegar depois que uma API remota aceitou uma solicitação, mas antes que o comando imprima sua resposta. Até um `KILL` pode chegar depois que uma transação de banco de dados foi confirmada. A limpeza de processos responde se o worker ainda está executando. Ela não desfaz efeitos externos.

Mantenha três resultados no plano de controle: concluído, cancelado com limpeza confirmada e desconhecido. Desconhecido é desconfortável, mas fornece à próxima pessoa operadora a informação necessária: inspecionar o estado remoto antes de emitir outra ação. Isso é muito melhor do que um botão verde de nova tentativa baseado em uma expectativa sem fundamento.

Um recurso de cancelamento está pronto quando você consegue interrompê-lo em cada limite e explicar a árvore de processos sobrevivente, a saída em disco, o registro final e a decisão de nova tentativa. Se não consegue fazer isso em um host descartável, não confie nele em um host de produção às 2 da manhã.
