# Работает ли очистка удаленного процесса после отмены SSH?

Отмененный запуск агента не равен остановленной удаленной команде. Локальный процесс может завершиться штатно, пока SSH-транспорт еще работает. Транспорт может исчезнуть, а удаленная оболочка продолжить работу. Оболочка может завершиться, а ее потомки останутся в другой группе процессов. Если свести все эти события к одному статусу «отменено», рано или поздно после сообщения агента об остановке у вас продолжат работать миграция базы данных, установка пакета, worker тестов или помощник развертывания.

Проблему не решит одна хитрая обработка сигналов. Нужен договор об отмене: он должен называть удаленный запуск, создавать границу, которую можно завершить вместе со всеми потомками, сохранять достаточно вывода для диагностики прерванного запуска и предусматривать действия на удаленной стороне, если контроллер исчез. Создайте и проверьте такой договор до того, как разрешите агенту выполнять команды с побочными эффектами.

## У отмены SSH есть четыре отдельных перехода

Запрос отмены должен пройти через четыре границы: агент решает остановиться, локальный супервизор останавливает или сигнализирует SSH-клиенту, протокол SSH передает событие канала или сигнал, а удаленный хост реагирует на него. Каждый переход может отказать независимо от остальных.

RFC 4254 разделяет эти понятия. В нем отдельно описаны сообщение о закрытии канала и запрос канала `signal` для имен вроде `TERM`, `INT` и `HUP`. Закрытие канала относится к транспорту. Оно не означает «отправить SIGTERM всем удаленным потомкам». В RFC также сказано, что данные, отправленные до закрытия, по возможности должны быть доставлены. Слова «по возможности» многое меняют, когда ноутбук засыпает, сетевой маршрут пропадает или локальный процесс принудительно завершается.

Это различие выявляет распространенную ошибку:

1. Агент запускает `ssh host long-command`.
2. Пользователь нажимает кнопку отмены.
3. Запуск агента убивает локальный дочерний процесс.
4. Интерфейс помечает задачу как отмененную.
5. `long-command` или один из его потомков продолжает работать на хосте.

Пятый пункт не является редким исключением. Это результат по умолчанию, если серверу не сообщили, что команду нужно завершить, или если команда отсоединилась от сессии до исчезновения соединения.

Хороший договор об отмене точно описывает, что попытается сделать локальная сторона и за что отвечает удаленная сторона:

- Запускающая сторона создает один распознаваемый удаленный запуск со случайным идентификатором.
- Удаленная оболочка запускает рабочую нагрузку в собственной группе или сессии процессов.
- При обычной отмене отправляется `TERM` этой группе, а результат записывается.
- Оболочка отправляет `KILL` только после заданного льготного периода.
- Удаленный срок действия или аренда завершает работу, если контроллер не возвращается.
- Вывод и итоговый статус переживают поток SSH.

Не называйте задачу отмененной, пока не получили один из двух результатов: подтвержденную итоговую удаленную запись или явный результат «состояние неизвестно». Уверенность после потери соединения замедляет разбор инцидента, потому что все начинают с ложной предпосылки.

## Одного удаленного PID недостаточно для очистки потомков

Убийство PID удаленной оболочки безопасно только тогда, когда оболочка не порождает процессы, не запускает конвейер, не создает фоновый процесс и не вызывает инструмент, который запускает помощников. Таких команд немного.

Рассмотрим обычную удаленную команду:

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

У оболочки один PID, но у конвейера несколько процессов. `tee` может продолжить запись журнала после завершения оболочки. Компилятор может создать worker-процессы. Менеджер пакетов может передать работу сервису. Если отправить `kill -TERM "$shell_pid"`, вы завершите одного участника большой группы и почти ничего не узнаете об остальных.

Группы процессов дают правильную единицу отмены для короткого удаленного запуска. В Linux каждый процесс принадлежит группе процессов, а каждая группа процессов входит в сессию. Сигналы, создаваемые терминалом, отправляются группе процессов переднего плана, поэтому поведение терминала иногда кажется более магическим, чем оно есть. Документация Linux для `setpgid(2)` также уточняет, что дочерний процесс наследует группу родителя, пока что-то не изменит ее.

Для команды, которой управляет агент, создайте для рабочей нагрузки новую сессию. Обычно лидер сессии имеет PID, равный его PGID и SID. Тогда отрицательный PID в `kill` обращается к группе процессов:

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

Начальный минус отличает завершение процесса от завершения его группы. Ключ `--` тоже важен: он не дает некорректному значению быть разобранным как опция.

Не предполагайте вслепую, что PID рабочей нагрузки равен ее идентификатору группы. Проверьте это при запуске. Оболочка-обертка, менеджер сервисов или программа, вызывающая `setpgid`, могут изменить дерево. Это минимальная команда проверки, которую стоит держать в наборе тестов:

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

Типичный вывод выглядит так:

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

Здесь PID, PGID и SID совпадают. Это подтверждает, что `kill -TERM -- -24182` обращается к нужной границе. Если PGID не совпадает с записью запуска, завершите запуск с ошибкой, а не гадайте.

У группы процессов все же есть ограничения. Потомок может вызвать `setsid`, среда контейнеров может переместить процесс, а рабочая нагрузка может попросить менеджер сервисов запустить что-то вне группы. Иногда это законно. Но это означает, что ваша гарантия отмены закончилась на такой передаче. Считайте отсоединенную работу отдельным типом задачи с собственным идентификатором, операцией остановки и записью аудита.

## Обертке отмены нужен настоящий путь очистки

Удаленная оболочка-обертка должна владеть PID рабочей нагрузки, обрабатывать ожидаемые сигналы завершения, обращаться к группе нагрузки, немного ждать и записывать итог. Не используйте `pkill command-name`, не анализируйте неструктурированный список процессов и не убивайте все процессы учетной записи. Эти сокращенные пути работают до того дня, когда два запуска агента используют одного пользователя, имя хоста меняется или имя команды совпадает с чужой работой.

Эта заготовка ориентирована на Linux и намеренно проста. Она создает защищенный каталог запуска, запускает одну рабочую нагрузку в новой сессии, пишет вывод в файлы и завершает группу нагрузки, когда обертка получает `TERM`, `INT` или `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"
```

Эта обертка предотвращает одну конкретную ошибку: сигнал отмены доходит до обертки, но она убивает только себя и оставляет рабочую нагрузку. Она не обещает остановить намеренно отсоединенных потомков и не должна этого обещать.

В руководстве Linux для `setsid(2)` сказано, что `setsid()` создает новую сессию и делает вызывающий процесс лидером новой группы, изначально без управляющего терминала. Команда util-linux `setsid` запускает программу в новой сессии и при необходимости создает дочерний процесс. Поэтому это практическая граница для запуска команды, а не волшебный переключатель очистки.

Держите обертку узкой. Она должна запускать, записывать, останавливать и сообщать. Не прячьте в ней бизнес-логику. У рабочей нагрузки по-прежнему должны быть собственные правила обработки транзакций, очистки временных файлов и идемпотентности.

## Чистое отключение транспорта не гарантирует очистку

Пользователи SSH часто делают слишком далеко идущие выводы из проверки в терминале. Они запускают команду с PTY, закрывают терминал, видят завершение процесса после `SIGHUP` и решают, что очистка при отключении работает. Затем агент использует неинтерактивный SSH-канал без PTY, и поведение меняется.

PTY создает семантику терминала. Закрытие терминала может привести к доставке `SIGHUP`, но только при условиях, связанных с управляющими терминалами и группами процессов переднего плана. В руководстве Linux `SIGHUP` описан как закрытие управляющего терминала или завершение управляющего процесса. Там не сказано, что каждое отключение SSH сигнализирует каждому процессу, запущенному через SSH.

Для агентов обычно лучше использовать неинтерактивный SSH: вывод получается чище, а сюрпризов от запуска оболочки меньше. Кроме того, вы не зависите случайно от поведения терминала. Используйте PTY только когда он нужен удаленной программе, например старому установщику, который иначе отказывается работать. В таком случае зафиксируйте, что PTY входит в поведение команды, и тестируйте его отдельно.

Есть три случая отключения, которые стоит различать:

### Клиент отправляет намеренную отмену

Локальный супервизор все еще поддерживает соединение и может отправить протокольный сигнал либо открыть отдельную аутентифицированную управляющую команду, которая подаст сигнал записанному PGID. Это лучший случай. Удаленная обертка получает `TERM`, выполняет очистку и записывает `cancelled:TERM`.

Не полагайтесь на то, что SSH-клиент всегда одинаково обработает локальный `SIGINT,` если вы не проверили конкретную библиотеку и команду, которые поставляете. Терминальный клиент, встроенная SSH-библиотека и MCP-инструмент могут по-разному отображать локальную отмену. Одни закрывают сокет, другие завершают локальный процесс, третьи могут отправить SSH-запрос `signal`. Это разные реализации интерфейса, который пользователь называет «отменой».

### Локальный клиент падает или теряет сеть

Удаленная команда может продолжить работу. Сервер не может отличить временную проблему маршрутизации от намерения пользователя продолжить работу, если протокол не объясняет ему, что делать. Честный ответ здесь, удаленная аренда.

При запуске запишите `deadline_epoch` в каталог удаленного запуска. Локальный супервизор продлевает его, пока запуск остается разрешенным. Удаленный watchdog проверяет значение и после истечения срока вызывает тот же путь очистки группы процессов. Выбирайте срок аренды под операцию. Пяти минут может хватить для shell-команды, но это может быть опасно мало для сборки с долгими, но нормальными периодами тишины.

### Удаленный хост выходит из строя или перезагружается

Вы можете потерять и процесс, и итоговый статус. Не сообщайте «отменено» или «завершено» только потому, что SSH-соединение оборвалось. Пометьте запуск как неизвестный, пока последующая сверка не прочитает журнал хоста, состояние развертывания, запись блокировки или результат, специфичный для приложения.

Сложность не в выдаче слова статуса. Сложность в отказе от статуса, который ваша система не может подтвердить.

## Частичный вывод подтверждает наблюдение, но не завершение

Поток отвечает на вопрос «какие байты клиент получил до сих пор?». Он не отвечает на вопрос «в каком состоянии удаленная команда оставила систему?». Ошибка возникает, когда удаленная программа печатает `done` до сброса последнего файла или сеть пропадает после завершения команды, но до получения клиентом кода выхода SSH.

Храните две записи:

- `stdout.log` и `stderr.log` содержат диагностический вывод по мере его появления.
- `status.json` содержит небольшую итоговую запись, которую обертка атомарно создает после выхода или обработки отмены.

Команда `mv` в обертке важна. Сначала запишите временный файл статуса в том же каталоге, затем переименуйте его. Читатель увидит либо полностью старый файл, либо полностью новый. Он не должен разобрать половину JSON и придумать результат.

У вывода тоже должны быть правила. Команда может сильно буферизовать вывод, когда stdout направлен в файл, а не в терминал. Если важен прогресс, пусть рабочая нагрузка пишет явный построчный статус в stderr или использует файл прогресса уровня приложения. Не решайте проблему буферизации выделением PTY для каждой команды. Это меняет поведение и может объединить stdout со stderr, что ухудшит аудит и анализ ошибок.

Различайте эти случаи в интерфейсе агента или журнале:

| Удаленная запись | Состояние потока | Значение |
| --- | --- | --- |
| `finished`, код выхода присутствует | завершен | Обертка наблюдала штатное завершение. |
| `cancelled:TERM` | может завершиться резко | Обертка начала отмену и остановила группу. |
| только `cancelling:TERM` | отключен | Очистка началась, но итоговая запись не была замечена. Нужна сверка. |
| только `running`, аренда действительна | отключен | Работа может продолжаться. Не повторяйте запуск вслепую. |
| нет пригодной записи | отключен | Состояние неизвестно. Проверьте побочные эффекты до нового запуска. |

Не помещайте в эти журналы токены доступа, неотредактированные дампы конфигурации или учетные данные. Передача SSH-учетных данных может оставить закрытый ключ у агента, но удаленная команда все равно способна вывести секреты из собственного окружения или конфигурации. Хранение вывода входит в проектирование команды, а не добавляется в последний момент.

## Тестируйте деревья процессов, а не одну сонную оболочку

`trap 'exit' TERM; sleep 600` это слабый тест отмены. Он показывает, что одна оболочка переднего плана получила один сигнал. Он не проверяет потомков, группы процессов, отложенную очистку, сохранность вывода или удаленную оболочку, исчезшую в неподходящий момент.

Используйте рабочую нагрузку, которая создает видимое дерево процессов и записывает каждый сигнал. Сохраните ее как `worker.sh` на временном Linux-хосте:

```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
```

Запустите ее через обертку со случайным идентификатором. В другом SSH-сеансе проверьте дерево процессов и каталог запуска:

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

В реальном запуске точное экранирование будет другим. Это нормально. Но свидетельства теста должны быть теми же: идентификатор удаленного запуска, PID обертки, PGID рабочей нагрузки, расположение журналов и итоговый статус.

Затем по очереди проверьте такие сценарии:

1. Отправьте `TERM` PID обертки. Убедитесь, что и родитель, и внук записали завершение, а `ps` не находит процессов в записанном PGID.
2. Отправьте `TERM` непосредственно PID рабочей нагрузки. Убедитесь, что безопасность ее дочерних процессов не предполагается автоматически. Этот тест объясняет, почему обертка обращается к группе.
3. Убейте локальный SSH-клиент, не отправляя удаленный сигнал. Убедитесь, что рабочая нагрузка остается активной до истечения удаленной аренды. Если она останавливается сразу, запишите причину, например поведение PTY при закрытии, и не считайте результат универсальным.
4. Отключитесь после записи оберткой `cancelling:TERM`, но до записи итогового статуса. Убедитесь, что сверка отличает неполное наблюдение от нового работающего задания.
5. Запустите два процесса под одной учетной записью, отмените один и докажите, что другой остается жив. Так выявляются опасные вызовы `pkill` и очистка всей учетной записи.

Во время тестов используйте `ps` и `pgrep -a -g "$pgid"`, затем повторите проверку после льготного периода. Сначала проверьте таблицу процессов, а уже потом удаленный файл статуса и журналы. Запись со статусом «отменено» при работающих worker-процессах означает ошибку супервизора, а не безобидное расхождение отчетов.

## Сигналы завершения родителя помогают только внутри контролируемого worker-процесса

Linux предоставляет `PR_SET_PDEATHSIG`. С его помощью процесс просит ядро отправить ему сигнал, когда завершается поток, который его создал. Это полезно для небольшого собственного нативного помощника, который создает непосредственного потомка и хочет остановить его после собственной смерти. В обычных случаях настройка переживает `execve`, но руководство описывает важные исключения, включая смену учетных данных.

Сам по себе этот механизм не решает очистку удаленного агента.

Во-первых, процесс SSH-сервера не обязательно является родителем, который вас интересует. Во-вторых, сигнал применяется к процессу, настроившему его, а не автоматически ко всем потомкам. В-третьих, удаленная команда, которая делает fork, двойной fork или передает работу другому сервису, разрывает эту связь. В-четвертых, механизм специфичен для Linux, что важно при смешанном парке Unix-хостов.

Используйте его только как дополнительную защиту там, где дерево процессов принадлежит вам. Например, небольшой Linux-worker может настроить `PR_SET_PDEATHSIG` перед запуском одной контролируемой дочерней программы, а внешняя обертка по-прежнему владеет группой процессов и арендой. Так вы получаете два детектора отказа с разным охватом. Это не позволяет отказаться от границы группы или удаленной итоговой записи.

То же предупреждение относится к `nohup`, `disown` и `setsid` внутри рабочей нагрузки. Они полезны, когда человек намеренно хочет пережить терминал. Они несовместимы с обещанием, что отмена запуска агента остановит работу. Сделайте выбор явным при запуске.

## Второй канал управления часто лучше, чем убийство первого

Когда агент отменяет выполняющийся SSH-вызов, его собственный локальный контекст уже может завершаться. Если полагаться на этот исчезающий процесс в отправке последнего протокольного сигнала, возникают гонки. Отдельный процесс-супервизор должен владеть отменой и сверкой.

Один из рабочих вариантов выглядит так:

1. Супервизор создает криптографически случайный идентификатор и вызывает удаленную обертку.
2. Обертка записывает PID, PGID, время запуска и статус в каталог с этим идентификатором.
3. Супервизор сохраняет идентификатор и удаленный хост до начала чтения вывода.
4. При отмене супервизор открывает новое управляющее действие, читает запись запуска, проверяет ожидаемого владельца и возраст, а затем отправляет сигнал записанному PGID.
5. Супервизор опрашивает итоговую запись, пока не увидит конечный статус или не достигнет срока отчетности.

Перед отправкой сигнала управляющее действие должно проверить запись запуска. Как минимум убедитесь, что каталог принадлежит ожидаемому пользователю, PID все еще существует, записанный PGID совпадает с `ps`, а время запуска соответствует запущенному процессу. Linux может переиспользовать PID. Старый файл PID и безусловный `kill` однажды приведут к завершению чужой работы.

Не прячьте команду очистки за общей инструкцией агента вроде «убей процесс моей предыдущей задачи». Агент должен получать непрозрачный идентификатор запуска. Супервизор преобразует его в строго ограниченное удаленное действие. Это также делает аудит понятным: проверяющий видит, что запуск `6c4...` запросил отмену PGID `24182` на одном хосте, а не что агент сформировал произвольную команду завершения.

Для автономных агентов программирования Sallyport может выполнять SSH-действия, не раскрывая SSH-ключ агенту. Это отделяет хранение учетных данных от договора об отмене, которому по-прежнему нужны идентификатор запуска, проверка группы, удаленная аренда и итоговая запись.

## Правильное решение о повторном запуске зависит от побочного эффекта

Команду, которая только читает файл, обычно можно повторить после неизвестного отключения. Команду, которая создает пользователя, применяет миграцию, меняет сертификат или начинает развертывание, повторять нельзя без проверки. SSH-уровень не знает, безопасно ли снова выполнить операцию.

Дайте удаленным командам с побочными эффектами токен идемпотентности, производный от идентификатора запуска. Удаленная программа должна сохранять токен вместе с результатом операции и возвращать существующий результат, если снова видит тот же токен. Если это невозможно, добавьте предварительный запрос, который определяет, произошло ли требуемое изменение.

Не заменяйте идемпотентность очисткой. Даже идеальный `TERM` может прийти после того, как удаленный API принял запрос, но до того, как команда вывела ответ. Даже `KILL` может прийти после фиксации транзакции базы данных. Очистка процессов отвечает на вопрос, продолжает ли worker выполнение. Она не откатывает внешние эффекты.

В системе управления храните три результата: завершено, отменено с подтвержденной очисткой и неизвестно. Неизвестность неудобна, но она дает следующему оператору нужную информацию: проверь удаленное состояние до нового действия. Это намного лучше зеленой кнопки повтора, основанной на надежде.

Функция отмены готова, когда вы можете прервать ее на каждой границе и объяснить судьбу оставшегося дерева процессов, вывода на диске, итоговой записи и решения о повторе. Если вы не можете сделать это на временном хосте, не доверяйте такой функции рабочий хост в два часа ночи.
