Compare commits

...

8 Commits

Author SHA1 Message Date
Andrey Epifancev
20b858c984 fix(agent): run.py — выход 0 без Ctrl-C как падение, сигнал перед запуском, pause без отрицательного sleep (I-1, M-1, M-2, #19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:40:59 +04:00
Andrey Epifancev
866f9d7b4d test(agent): сквозной тест самообновления; README, ARCHITECTURE, CLAUDE.md — run.py и публикация (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:26:53 +04:00
Andrey Epifancev
7512acb083 feat(agent): запускатель run.py — подмена версии, пробный период, откат, перезапуск (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:24:30 +04:00
Andrey Epifancev
3aa8823929 feat(agent): самообновление в паузе — скачивание, sha256, selftest, выход 75 (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:22:32 +04:00
Andrey Epifancev
140f921ce5 test(agent): версия 5 в test_folder (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:19:29 +04:00
Andrey Epifancev
82428ea869 feat(agent): update.json, selftest, update_state и launcher_version в claim, версия 5 (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:17:52 +04:00
Andrey Epifancev
d2cefa72c1 docs(plan): agent-self-update — план агента (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:59:03 +04:00
Andrey Epifancev
0c2a539963 docs(openspec): agent-self-update — решения агента (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:49:21 +04:00
16 changed files with 2573 additions and 48 deletions

View File

@ -1,6 +1,6 @@
# Архитектура
Агент — один файл `src/agent.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках
Агент — один файл `src/agent.py` и запускатель `src/run.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках
Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, план агента: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md` (план хаба: `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`).
## Разделы файла `src/agent.py`
@ -12,10 +12,20 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `cmd_prune`, `main` | команды `run`, `check`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
## Запускатель `src/run.py`
Не импортирует `agent.py` (тот меняется). `Launcher` запускает `agent.py run` дочерним процессом с
`JIRA_AGENT_LAUNCHER`; коды 0/130 — выход; 75 — `apply_update` (копия в `agent.py.prev`, `os.replace`, `trial`);
падение в `trial` или 10 минут без `ok` — `rollback`; иначе перезапуск с паузой 5…300 с. Блокировка `run.py.lock`;
SIGINT при живом агенте игнорирует (Ctrl-C получает агент), SIGTERM пересылает. Спека — «Запускатель run.py».
## Границы
- Код новой версии приходит только через API хаба (`GET /agent/release`) и пишется в каталог агента; общая папка для
кода не используется.
- Пароль и токен Jira — только в `agent.json`. В хаб, журнал и логи не попадают.
- Политика темпа не хранится в агенте: зазоры (`pacing`) и `pause_seconds` приходят от хаба. Агент досыпает паузу
до зазора от последнего вызова Jira (`JiraClient.last_call_at`).

View File

@ -17,7 +17,9 @@
## Архитектура
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `jira-hub/openspec/specs/agent/`),
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `cmd_prune`, `main`).
выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`,
`cmd_selftest`, `cmd_run`, `cmd_prune`, `main`).
- `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует.
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
@ -29,6 +31,8 @@
- `/usr/bin/python3 src/agent.py check` — доступность хаба и Jira, без записи в Jira.
- `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`).
- `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет).
- `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3.
- `/usr/bin/python3 src/run.py` — запуск через запускатель (самообновление).
## Тесты
- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: Jira и хаб заменяют фейки `tests/fake_jira.py` и `tests/fake_hub.py`.
@ -36,14 +40,24 @@
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_refresh.py` (`refresh`),
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429).
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429),
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).
- Golden-фикстур пока нет: каждый тест строит сценарий в коде.
## Первая подсистема
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
журнал, восстановление после перезапуска, CLI `run|check|prune`.
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
Текущий change — `../jira-hub/openspec/changes/agent-self-update/`, план — `docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`.
План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
## Выкатка новой версии
Самообновление (с v5): после merge PR агента в main — поднять `AGENT_VERSION` (если не поднят в PR) и опубликовать:
`uv run --project ../jira-hub jira-hub agent publish src/agent.py` (из основного checkout `jira-agent` на main).
Через несколько минут MCP `agent_status` должен показать «обновление: ok v<старая> → v<новая>»; откат — «откат с v<N>…»
(причина — в логе агента на виртуалке). Это пункт чеклиста «закрывай» для PR агента. `run.py` публикацией не
обновляется — только вручную через общую папку.
## OpenSpec и superpowers
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.
- Когда brainstorming классифицировал задачу как architectural, структуру change создать через `openspec new change <id>` (или `/opsx:propose <id>`), а содержимое proposal.md, specs/ и design.md написать по итогам brainstorming. tasks.md из OpenSpec не заполнять — задачи живут в плане superpowers.

View File

@ -2,14 +2,16 @@
Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`jira-hub` на Mac mini),
выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске
и возвращает хабу состояние задач. Один файл `src/agent.py`, только стандартная библиотека, Python 3.9.
и возвращает хабу состояние задач. Один файл `src/agent.py` и запускатель `src/run.py`, только стандартная
библиотека, Python 3.9.
Спека: `../jira-hub/openspec/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`).
Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP.
## Доставка на виртуалку
1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`.
1. Скопировать `src/agent.py`, `src/run.py` и `src/agent.example.json` через общую папку в локальный каталог на
виртуалке, например `~/jira-agent/`. Запускать из этого каталога, не из общей папки.
2. Переименовать `agent.example.json` в `agent.json` и заполнить:
- `hub_url` — `http://<адрес Mac mini>:8790`, или, если сеть до Mac mini закрыта VPN (Континент), `folder:/Volumes/My Shared Files/RSHB/jira-bridge` — обмен через общую папку UTM (на хабе должна быть задана настройка `relay_dir`, см. README хаба);
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
@ -17,7 +19,8 @@
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
- `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 2 ниже);
- `timeout` — таймаут запроса в секундах (по умолчанию 60);
- `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5).
- `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5);
- `auto_update` — самообновление (по умолчанию `true`; `false` — агент не обновляется сам).
3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки.
Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira.
@ -41,6 +44,9 @@ python3 agent.py check
Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1.
Команда ничего не пишет в Jira.
`python3 agent.py selftest` — то же, что `check`, плюс первая строка `jira-agent v<N>`; код 0 — всё доступно, 3 — что-то
недоступно. Её вызывает предыдущая версия агента перед самообновлением.
## Связь с хабом через общую папку
Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`:
@ -59,19 +65,41 @@ python3 agent.py check
В терминале:
```
python3 agent.py
python3 run.py
```
В фоне с логом:
```
nohup python3 -u agent.py > agent.log 2>&1 &
nohup python3 -u run.py > agent.log 2>&1 &
```
Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
`run.py` запускает `agent.py` дочерним процессом и обновляет его (раздел «Самообновление»). Запуск
`python3 agent.py` без запускателя работает, но тогда агент себя не обновляет и раз в час пишет «доступна v<N>; для
самообновления запускайте через run.py».
Остановка: Ctrl-C в терминале или `kill <pid run.py>` (не pid агента: агент, вышедший без сигнала запускателю,
считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.
## Самообновление
С версии 5 агент обновляется сам. После `jira-hub agent publish` на Mac mini хаб сообщает новую версию в ответе на
`claim`. Агент в паузе между командами (очередь пуста, отчёты подтверждены) скачивает файл по `GET /agent/release`,
сверяет sha256, компилирует, проверяет `AGENT_VERSION`, кладёт рядом `agent.py.new`, запускает его `selftest` и выходит
с кодом 75. `run.py` копирует текущий `agent.py` в `agent.py.prev`, ставит новый и запускает его в пробном режиме:
версия подтверждена, когда она получила первый ответ хаба (`update.json` → `ok`). Если новая версия упала или не вышла
на связь за 10 минут — `run.py` возвращает `agent.py.prev` и пишет `rolled_back`; эту версию агент больше не ставит,
хаб показывает откат в `agent_status`. Код в общую папку не попадает: файлы пишутся только в каталог `run.py`.
Файлы рядом с `agent.py`: `update.json` (состояние: `staged`, `trial`, `ok`, `rolled_back`, `rejected`),
`agent.py.prev` (прежняя версия), `run.py.lock`. Не удалять во время работы.
Если агент упал уже после подтверждения, `run.py` перезапускает ту же версию с паузой 5 с, удваивая её до 300 с при
повторных падениях. Выключить самообновление: `"auto_update": false` в `agent.json`. `run.py` обновляется вручную
(меняется редко).
## Автозапуск через launchd (опционально)
Пример `~/Library/LaunchAgents/ru.aepif.jira-agent.plist`:
@ -86,7 +114,7 @@ nohup python3 -u agent.py > agent.log 2>&1 &
<array>
<string>/usr/bin/python3</string>
<string>-u</string>
<string>/Users/USER/jira-agent/agent.py</string>
<string>/Users/USER/jira-agent/run.py</string>
</array>
<key>WorkingDirectory</key><string>/Users/USER/jira-agent</string>
<key>RunAtLoad</key><true/>
@ -153,9 +181,9 @@ python3 agent.py prune
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 4) отправляется в каждом `claim`, в каждом отчёте
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 5) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой.
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему.
@ -171,11 +199,12 @@ python3 agent.py prune
## Обновление агента
Остановить старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать новый `agent.py` поверх
старого и запустить снова (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
С версии 5 — само (раздел «Самообновление»). Вручную обновляются только `run.py` и переход с версии 4: остановить
старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать `agent.py` и `run.py` в каталог агента,
`python3 agent.py check`, запустить `python3 run.py` (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс
не стартует («агент уже запущен…», код 1) — блокировка `journal.jsonl.lock` рядом с журналом.
`agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
`agent.json`, `journal.jsonl` и `update.json` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
## Известные ограничения / эксплуатация

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
# agent-self-update — решения агента
Спека и дизайн изменения — в `jira-hub/openspec/changes/agent-self-update/` (issue aep/jira-hub#22, aep/jira-agent#19).
Агентская часть — решения 7–20 дизайна хаба (агент v5, `selftest`, `update.json`, запускатель `run.py`) и 21–24
(совместимость, последнее ручное обновление, публикация в чеклисте закрытия, живая проверка).
Решения владельца 14.10.2026: доставка через хаб (`GET /agent/release`), запускатель с откатом, код пишется и
запускается только в каталоге `run.py` на виртуалке, не в общей папке.
## Decisions made without discussion
Рулинги R-4…R-13, R-16, R-17 записаны в `jira-hub/openspec/changes/agent-self-update/design.md`
(раздел «Decisions made without discussion»); здесь — только решения, принятые при исполнении агентской части.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-19 | 2026-10-14 | `test_version_is_4` → `test_version_is_5` | план не учёл старый тест версии; спека требует v5 | нет |
| R-20 (design #16, #17 хаба) | 2026-10-14 | Выход 0/130 — остановка человеком, только если запускатель сам получил SIGINT/SIGTERM; иначе падение (в `trial` — откат); Ctrl-C при выходе с 75 — подмена и выход; агент, получивший Ctrl-C при подготовке, выходит с 0 | финальное ревью I-1: версия, сама вышедшая с 0, останавливала мост без отката | `kill <pid агента>` перезапускает агента — останавливать `run.py` |
| R-21 | 2026-10-14 | M-1 финального ревью (отрицательный `sleep` в `pause`) повышена до Important и исправлена | падение запускателя останавливает мост до человека, `run.py` обновляется вручную | нет |

View File

@ -8,5 +8,6 @@
"verify_ssl": true,
"ca_bundle": null,
"timeout": 60,
"poll_seconds": 5
"poll_seconds": 5,
"auto_update": true
}

View File

@ -8,16 +8,22 @@ Python 3.9. Спека: jira-hub/openspec/specs/agent/spec.md.
python3 agent.py check # доступ к хабу и Jira, без записи
python3 agent.py run # основной цикл (по умолчанию)
python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3
python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней
Самообновление — через запускатель run.py (спека agent, «Самообновление агента»).
"""
import argparse
import base64
import fcntl
import hashlib
import http.client
import json
import os
import re
import signal
import ssl
import subprocess
import sys
import time
import urllib.error
@ -27,7 +33,8 @@ import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
AGENT_VERSION = 4
AGENT_VERSION = 5
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__))
API = "/rest/api/2"
AGILE = "/rest/agile/1.0"
@ -61,6 +68,32 @@ def load_config(path: str) -> dict:
return cfg
# ---------------------------------------------------------------------------
# Самообновление: update.json рядом с agent.py (спека agent «Самообновление агента», design 7)
# ---------------------------------------------------------------------------
def read_update_state(path: str) -> Optional[dict]:
"""Состояние обновления; нет файла или мусор — None."""
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return None
except (OSError, ValueError):
log("update.json не читается — считаю, что состояния нет")
return None
return data if isinstance(data, dict) else None
def write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict:
data = {"state": state, "from_version": from_version, "to_version": to_version, "sha256": sha256,
"error": error, "at": now_iso()}
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path)
return data
# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------
@ -273,9 +306,13 @@ class HubClient(Http):
path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
return self.folder.call(method, path, body, timeout or self.timeout)
def claim(self, agent_version, settings_version, last_jira_call_at) -> dict:
def claim(self, agent_version, settings_version, last_jira_call_at, update_state=None, launcher_version=None) -> dict:
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
"last_jira_call_at": last_jira_call_at}, timeout=max(self.timeout, 45))
"last_jira_call_at": last_jira_call_at, "update_state": update_state,
"launcher_version": launcher_version}, timeout=max(self.timeout, 45))
def release(self) -> dict:
return self.call("GET", "/agent/release")
def settings(self) -> dict:
return self.call("GET", "/agent/settings")
@ -911,6 +948,132 @@ class Executor:
# ---------------------------------------------------------------------------
# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
# ---------------------------------------------------------------------------
class Updater:
"""Самообновление агента (спека agent «Самообновление агента», design 7–12). Работает только под run.py."""
RETRY_SECONDS = 300 # временный сбой подготовки — повтор не раньше (R-8)
HINT_SECONDS = 3600 # подсказка «запускайте через run.py» — не чаще (R-10)
SELFTEST_TIMEOUT = 120
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)
def __init__(self, hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True,
config_path: Optional[str] = None):
self.hub, self.dir, self.launcher_version, self.enabled = hub, base_dir, launcher_version, enabled
self.config_path = config_path or os.path.join(base_dir, "agent.json")
self.state_path = os.path.join(base_dir, "update.json")
self.new_path = os.path.join(base_dir, "agent.py.new")
self.agent_path = os.path.join(base_dir, "agent.py")
self.retry_after = {} # (version, sha256) -> clock(), раньше которого не повторять
self.hint_at = None # type: Optional[float]
self.clock = time.monotonic
self.should_stop = lambda: False
self.run_selftest = self._run_selftest
def state(self) -> Optional[dict]:
return read_update_state(self.state_path)
def confirm(self) -> None:
"""Первый успешный claim новой версии: trial этой версии → ok (запускатель перестаёт ждать отката)."""
st = self.state()
if st and st.get("state") == "trial" and st.get("to_version") == AGENT_VERSION:
write_update_state(self.state_path, "ok", st.get("from_version"), AGENT_VERSION, st.get("sha256"))
log("обновление до v%d подтверждено" % AGENT_VERSION)
def check(self, release) -> bool:
"""Вызывается после claim без команды. True — agent.py.new проверен, update.json = staged: run выходит с 75."""
if not self.enabled or not isinstance(release, dict):
return False
try:
version, sha = int(release.get("version")), str(release.get("sha256"))
except (TypeError, ValueError):
return False
if version <= AGENT_VERSION:
return False
st = self.state()
if st and st.get("state") in ("rejected", "rolled_back") and st.get("to_version") == version \
and st.get("sha256") == sha:
return False
if self.launcher_version is None:
now = self.clock()
if self.hint_at is None or now - self.hint_at >= self.HINT_SECONDS:
self.hint_at = now
log("доступна v%d; для самообновления запускайте через run.py" % version)
return False
until = self.retry_after.get((version, sha))
if until is not None and self.clock() < until:
return False
return self.prepare(version, sha)
def prepare(self, version: int, sha: str) -> bool:
log("доступна v%d — готовлю обновление" % version)
try:
rel = self.hub.release()
except (ConnectionError, HttpError) as e:
return self._transient(version, sha, "скачивание: %s" % e)
content = rel.get("content") if isinstance(rel, dict) else None
if not isinstance(content, str) or rel.get("version") != version or rel.get("sha256") != sha:
return self._transient(version, sha, "публикация сменилась между запросами")
data = content.encode("utf-8")
if hashlib.sha256(data).hexdigest() != sha:
return self._reject(version, sha, "sha256 не сходится")
try:
compile(content, "agent.py.new", "exec")
except (SyntaxError, ValueError) as e:
return self._reject(version, sha, "не компилируется: %s" % e)
if self.VERSION_RE.findall(content) != [str(version)]:
return self._reject(version, sha, "AGENT_VERSION в файле не %d" % version)
tmp = self.new_path + ".tmp"
with open(tmp, "wb") as f:
f.write(data)
try:
os.chmod(tmp, os.stat(self.agent_path).st_mode & 0o777)
except OSError:
pass
os.replace(tmp, self.new_path)
if self.should_stop():
self._drop_new()
return False
code, out = self.run_selftest(self.new_path)
if self.should_stop(): # Ctrl-C уронил и selftest — это не отказ версии
self._drop_new()
return False
if code == 0:
write_update_state(self.state_path, "staged", AGENT_VERSION, version, sha)
log("v%d готова, перезапуск через run.py" % version)
return True
if code is None:
return self._transient(version, sha, "selftest: таймаут %d с" % self.SELFTEST_TIMEOUT)
if code == 3:
return self._transient(version, sha, "selftest: хаб или Jira недоступны")
return self._reject(version, sha, "selftest: код %s: %s" % (code, out[-500:]))
def _run_selftest(self, path: str):
try:
p = subprocess.run([sys.executable, path, "--config", self.config_path, "selftest"], cwd=self.dir,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.SELFTEST_TIMEOUT)
except subprocess.TimeoutExpired:
return None, ""
return p.returncode, p.stdout.decode("utf-8", "replace")
def _drop_new(self) -> None:
for path in (self.new_path, self.new_path + ".tmp"):
try:
os.remove(path)
except FileNotFoundError:
pass
def _transient(self, version, sha, reason) -> bool:
self._drop_new()
self.retry_after[(version, sha)] = self.clock() + self.RETRY_SECONDS
log("обновление до v%d отложено на %d мин: %s" % (version, self.RETRY_SECONDS // 60, reason))
return False
def _reject(self, version, sha, reason) -> bool:
self._drop_new()
write_update_state(self.state_path, "rejected", AGENT_VERSION, version, sha, reason)
log("v%d отвергнута: %s" % (version, reason))
return False
class Runner:
RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Jira / 5xx (R-10)
RETRY_MIN_WAIT = 30 # секунд до повтора
@ -922,6 +1085,8 @@ class Runner:
self.stop = False
self.sleep = time.sleep
self.idle_limit = None # type: Optional[int]
self.updater = None # type: Optional[Updater]
self.exit_code = 0 # EXIT_UPDATE — выход для подмены версии
@property
def poll(self) -> float:
@ -1113,10 +1278,14 @@ class Runner:
recovered = not self.stop
continue
try:
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso)
upd = self.updater
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso,
upd.state() if upd else None, upd.launcher_version if upd else None)
except (ConnectionError, HttpError) as e:
self.sleep(self.hub_wait(e, max(self.poll, 1)))
continue
if self.updater:
self.updater.confirm()
cmd = resp.get("command")
if resp.get("settings_version") != self.settings.get("settings_version"):
if not self.reload_settings_before(cmd):
@ -1125,6 +1294,10 @@ class Runner:
idle = 0
self.execute(cmd)
continue
if self.updater and not self.stop and self.updater.check(resp.get("agent_release")):
if not self.stop: # Ctrl-C во время подготовки — обычная остановка; staged доделает run.py
self.exit_code = EXIT_UPDATE
return
idle += 1
if self.idle_limit is not None and idle >= self.idle_limit:
return
@ -1143,13 +1316,13 @@ def acquire_lock(journal_path: str):
return f
def cmd_run(cfg: dict, journal_path: str) -> int:
def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int:
lock = acquire_lock(journal_path)
if lock is None:
log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path)
return 1
try:
return _run(cfg, journal_path)
return _run(cfg, journal_path, config_path)
finally:
lock.close()
@ -1169,8 +1342,18 @@ def cmd_prune(journal_path: str) -> int:
lock.close()
def _run(cfg: dict, journal_path: str) -> int:
def launcher_version() -> Optional[int]:
"""Версия запускателя из окружения (run.py ставит JIRA_AGENT_LAUNCHER); без запускателя — None."""
try:
return int(os.environ["JIRA_AGENT_LAUNCHER"])
except (KeyError, ValueError):
return None
def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int:
runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path))
runner.updater = Updater(runner.hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path)
runner.updater.should_stop = lambda: runner.stop
def on_signal(signum, frame):
try:
@ -1188,8 +1371,8 @@ def _run(cfg: dict, journal_path: str) -> int:
signal.signal(signal.SIGTERM, on_signal)
log("jira-agent v%d: хаб %s, Jira %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["jira"]["base_url"], journal_path))
runner.loop()
log("остановлен")
return 0
log("выход для обновления (код %d)" % EXIT_UPDATE if runner.exit_code == EXIT_UPDATE else "остановлен")
return runner.exit_code
# ---------------------------------------------------------------------------
@ -1217,18 +1400,27 @@ def cmd_check(cfg: dict) -> int:
return 0 if ok else 1
def cmd_selftest(cfg: dict) -> int:
"""Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться:
0 — хаб и Jira доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10)."""
log("jira-agent v%d" % AGENT_VERSION)
return 0 if cmd_check(cfg) == 0 else 3
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--config", default=os.path.join(HERE, "agent.json"))
ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl"))
ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "prune"])
ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"])
args = ap.parse_args(argv)
cfg = load_config(args.config)
if args.cmd == "check":
return cmd_check(cfg)
if args.cmd == "selftest":
return cmd_selftest(cfg)
if args.cmd == "prune":
return cmd_prune(args.journal)
return cmd_run(cfg, args.journal)
return cmd_run(cfg, args.journal, args.config)
if __name__ == "__main__":

238
src/run.py Normal file
View File

@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""run.py — запускатель jira-agent: запускает agent.py дочерним процессом, ставит новую версию после самообновления
агента, откатывает её, если она упала или не вышла на связь, и поднимает агента после падения.
Только стандартная библиотека, Python 3.9. agent.py не импортирует: тот меняется, запускатель — нет.
Спека: jira-hub/openspec/specs/agent/spec.md, «Запускатель run.py».
python3 run.py [--config agent.json] [--journal journal.jsonl] # аргументы уходят в agent.py run
Обновляется вручную (редко): остановить, заменить файл, запустить.
"""
import fcntl
import hashlib
import json
import os
import signal
import subprocess
import sys
import time
from datetime import datetime, timezone
from typing import List, Optional
LAUNCHER_VERSION = 1
EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged»
TRIAL_SECONDS = 600 # новая версия должна записать ok за это время
KILL_GRACE = 60 # после SIGTERM до SIGKILL
BACKOFF_START, BACKOFF_MAX = 5, 300
HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала
STOP_CODES = (0, 130, -signal.SIGINT) # выход агента после Ctrl-C (-SIGINT — Ctrl-C до установки его обработчиков)
def log(msg: str) -> None:
print("[%s] run: %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True)
def read_state(path: str) -> Optional[dict]:
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def write_state(path: str, state: str, base: Optional[dict], error=None) -> None:
base = base or {}
data = {"state": state, "from_version": base.get("from_version"), "to_version": base.get("to_version"),
"sha256": base.get("sha256"), "error": error,
"at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path)
def sha256_file(path: str) -> str:
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
def acquire_lock(base_dir: str):
"""Один запускатель на каталог. None — уже занято."""
f = open(os.path.join(base_dir, "run.py.lock"), "a")
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
f.close()
return None
return f
class Launcher:
def __init__(self, base_dir: str, args: List[str]):
self.dir, self.args = base_dir, list(args)
self.agent = os.path.join(base_dir, "agent.py")
self.new = os.path.join(base_dir, "agent.py.new")
self.prev = os.path.join(base_dir, "agent.py.prev")
self.state_path = os.path.join(base_dir, "update.json")
self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE
self.backoff = BACKOFF_START
self.stopping = False
self.interrupted = False # был Ctrl-C: выход агента с 0/130 — остановка человеком, а не сбой версии
self.child = None
# --- сигналы ---------------------------------------------------------------
def on_sigint(self, signum, frame):
"""Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он, запускатель только запоминает,
что человек остановил (I-1). Без агента (пауза) — выход."""
self.interrupted = True
if self.child is None:
self.stopping = True
def on_sigterm(self, signum, frame):
self.stopping = True
child = self.child
if child is not None and hasattr(child, "send_signal"):
child.send_signal(signal.SIGTERM)
# --- файлы версии ----------------------------------------------------------
def recover_files(self) -> None:
if not os.path.exists(self.agent) and os.path.exists(self.prev):
os.replace(self.prev, self.agent)
log("agent.py не найден — восстановлен из agent.py.prev")
if os.path.exists(self.new):
st = read_state(self.state_path)
if st and st.get("state") == "staged":
log("найдена подготовленная версия — ставлю")
self.apply_update()
else:
os.remove(self.new)
def apply_update(self) -> None:
st = read_state(self.state_path)
if not (st and st.get("state") == "staged" and os.path.exists(self.new) and sha256_file(self.new) == st.get("sha256")):
if os.path.exists(self.new):
os.remove(self.new)
write_state(self.state_path, "rejected", st, "файл изменился после проверки")
log("обновление отвергнуто: файл изменился после проверки")
return
tmp = self.prev + ".tmp"
with open(self.agent, "rb") as src, open(tmp, "wb") as dst:
dst.write(src.read())
os.replace(tmp, self.prev)
os.replace(self.new, self.agent) # agent.py существует на каждом шаге
write_state(self.state_path, "trial", st)
log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version")))
def rollback(self, st: Optional[dict], reason: str) -> bool:
if not os.path.exists(self.prev):
log("откат невозможен: нет agent.py.prev")
return False
os.replace(self.prev, self.agent)
write_state(self.state_path, "rolled_back", st, reason)
log("откат v%s → v%s: %s" % ((st or {}).get("to_version"), (st or {}).get("from_version"), reason))
return True
# --- процесс агента --------------------------------------------------------
def spawn(self):
env = dict(os.environ, JIRA_AGENT_LAUNCHER=str(LAUNCHER_VERSION))
return subprocess.Popen([sys.executable, "-u", self.agent, "run"] + self.args, env=env, cwd=self.dir)
def terminate(self, child) -> None:
child.send_signal(signal.SIGTERM)
try:
child.wait(self.kill_grace)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
def wait_child(self, child, trial: bool):
"""Код выхода и признак «пробный период истёк». В пробный период ждём ok не дольше trial_seconds."""
start = time.monotonic()
while True:
try:
return child.wait(1), False
except subprocess.TimeoutExpired:
pass
if trial:
st = read_state(self.state_path)
if st and st.get("state") == "ok":
trial = False
elif time.monotonic() - start >= self.trial_seconds:
self.terminate(child)
return child.returncode, True
def pause(self, seconds: float) -> bool:
"""Пауза перед перезапуском; False — остановлены во время паузы."""
end = time.monotonic() + seconds
while not self.stopping:
left = end - time.monotonic() # одно чтение часов: остаток не уйдёт в минус между проверкой и sleep (M-1)
if left <= 0:
break
time.sleep(min(1.0, left))
return not self.stopping
def next_backoff(self, uptime: float) -> int:
if uptime >= HEALTHY_UPTIME:
self.backoff = BACKOFF_START
wait = self.backoff
self.backoff = min(self.backoff * 2, BACKOFF_MAX)
return wait
def run(self) -> int:
self.recover_files()
code = 0
while True:
if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2)
return code
st = read_state(self.state_path)
trial = bool(st and st.get("state") == "trial")
started = time.monotonic()
self.interrupted = False # Ctrl-C относится к этому запуску агента
self.child = self.spawn()
if self.stopping: # SIGTERM между Popen и присваиванием child до агента не дошёл
self.child.send_signal(signal.SIGTERM)
code, timed_out = self.wait_child(self.child, trial)
self.child = None
uptime = time.monotonic() - started
st = read_state(self.state_path)
if timed_out:
if self.rollback(st, "не вышла на связь за 10 мин"):
continue
elif self.stopping or (self.interrupted and code in STOP_CODES):
return code
elif code == EXIT_UPDATE:
self.apply_update()
self.backoff = BACKOFF_START
if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте
return 0
continue
elif trial and not (st and st.get("state") == "ok"):
if self.rollback(st, "код %s через %d с" % (code, uptime)):
continue
if self.stopping:
return code
wait = self.next_backoff(uptime)
log("агент упал (код %s), перезапуск через %d с" % (code, wait))
if not self.pause(wait):
return code
def main(argv=None) -> int:
base = os.path.dirname(os.path.abspath(__file__))
lock = acquire_lock(base)
if lock is None:
log("запускатель уже работает в %s — второй не стартует" % base)
return 1
try:
launcher = Launcher(base, sys.argv[1:] if argv is None else argv)
signal.signal(signal.SIGINT, launcher.on_sigint)
signal.signal(signal.SIGTERM, launcher.on_sigterm)
log("запускатель v%d: агент %s" % (LAUNCHER_VERSION, launcher.agent))
return launcher.run()
finally:
lock.close()
if __name__ == "__main__":
sys.exit(main())

View File

@ -20,6 +20,8 @@ class FakeHub:
self.fail_results = 0
self.reject_results = {}
self.in_flight = None
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
self.release_requests = 0
self.lock = threading.Lock()
hub = self
@ -50,6 +52,10 @@ class FakeHub:
self.queue.append(cid)
return cid
def _release_ref(self):
r = self.release
return {"version": r["version"], "sha256": r["sha256"]} if r else None
def handle(self, h, method):
length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
@ -72,15 +78,20 @@ class FakeHub:
self.claim_bodies.append(body or {})
self.claims += 1
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
return 200, {"command": None, "settings_version": self.settings["settings_version"]}
return 200, {"command": None, "settings_version": self.settings["settings_version"],
"agent_release": self._release_ref()}
if not self.queue:
return 200, {"command": None, "settings_version": self.settings["settings_version"]}
return 200, {"command": None, "settings_version": self.settings["settings_version"],
"agent_release": self._release_ref()}
cid = self.queue.pop(0)
self.commands[cid]["status"] = "claimed"
self.in_flight = cid
c = self.commands[cid]
return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]},
"settings_version": self.settings["settings_version"]}
"settings_version": self.settings["settings_version"], "agent_release": self._release_ref()}
if path == "/agent/release":
self.release_requests += 1
return (200, self.release) if self.release else (404, {"error": "no_release"})
if path == "/agent/settings":
return 200, self.settings
if path == "/agent/known-keys":

View File

@ -200,5 +200,22 @@ class ClientsTest(unittest.TestCase):
self.assertEqual(agent.main(["--config", path, "check"]), 1)
def test_selftest_command(self):
import contextlib
import io
import json
import os
import tempfile
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "agent.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(jira_cfg(self.fake), f)
out = io.StringIO()
with contextlib.redirect_stdout(out):
# хаб недоступен (порт 1), Jira доступен — selftest печатает версию и возвращает 3, а не 1
self.assertEqual(agent.main(["--config", path, "selftest"]), 3)
self.assertIn("jira-agent v5", out.getvalue())
self.assertIn("Jira: доступен", out.getvalue())
if __name__ == "__main__":
unittest.main()

View File

@ -157,8 +157,8 @@ class FolderIntegrationTest(unittest.TestCase):
def cfg(self):
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version_is_4(self):
self.assertEqual(agent.AGENT_VERSION, 4)
def test_version_is_5(self):
self.assertEqual(agent.AGENT_VERSION, 5)
def test_runner_through_folder(self):
self.fake.add("AURORA-1", duedate="2026-10-20")

292
tests/test_launcher.py Normal file
View File

@ -0,0 +1,292 @@
import hashlib
import json
import os
import signal
import subprocess
import sys
import tempfile
import textwrap
import unittest
from _common import SRC
if SRC not in sys.path:
sys.path.insert(0, SRC)
import run # noqa: E402
# Поддельные агенты: маленькие скрипты, которые ведут себя как agent.py по контракту (коды выхода, update.json).
STATE_HELPERS = textwrap.dedent('''
import json, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
STATE = os.path.join(HERE, "update.json")
def state():
try:
return json.load(open(STATE, encoding="utf-8"))
except Exception:
return None
def write(**kw):
json.dump(kw, open(STATE, "w", encoding="utf-8"))
def mark(name):
open(os.path.join(HERE, "runs.log"), "a").write(name + "\\n")
def human_stop(code=0):
# Ctrl-C в терминале получает вся группа: запускатель тоже видит SIGINT, затем агент выходит
import signal
os.kill(os.getppid(), signal.SIGINT)
sys.exit(code)
''')
def fake(name, body):
return STATE_HELPERS + "mark(%r)\n" % name + textwrap.dedent(body)
# v5 «нашёл» новую версию: кладёт agent.py.new из next.py, пишет staged и выходит с 75; после отката/отказа — выход 0.
V5_UPDATES = fake("v5", '''
import hashlib
st = state()
if st is None:
data = open(os.path.join(HERE, "next.py"), "rb").read()
open(os.path.join(HERE, "agent.py.new"), "wb").write(data)
write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest())
sys.exit(75)
human_stop()
''')
# v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new.
V5_BAD_STAGE = fake("v5", '''
st = state()
if st is None:
open(os.path.join(HERE, "agent.py.new"), "w").write("мусор")
write(state="staged", from_version=5, to_version=6, sha256="чужой")
sys.exit(75)
human_stop()
''')
V6_OK_THEN_STOP = fake("v6", '''
st = state()
write(**dict(st, state="ok"))
human_stop()
''')
V6_CRASH = fake("v6", 'sys.exit(1)\n')
V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n')
class LauncherTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.l = run.Launcher(self.dir, ["--journal", "j.jsonl"])
self.pauses = []
self.l.pause = lambda s: (self.pauses.append(s), True)[1]
self.old_sigint = signal.signal(signal.SIGINT, self.l.on_sigint) # как в run.main: Ctrl-C видит и запускатель
def tearDown(self):
signal.signal(signal.SIGINT, self.old_sigint)
self.tmp.cleanup()
def put(self, name, text):
with open(os.path.join(self.dir, name), "w", encoding="utf-8") as f:
f.write(text)
def read(self, name):
with open(os.path.join(self.dir, name), encoding="utf-8") as f:
return f.read()
def state(self):
return json.loads(self.read("update.json"))
def runs(self):
return self.read("runs.log").split()
def next_version(self, text):
self.put("next.py", text)
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def stage(self, new_text, to_version=6):
self.put("agent.py.new", new_text)
sha = hashlib.sha256(new_text.encode("utf-8")).hexdigest()
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": to_version, "sha256": sha}))
return sha
def test_update_applied_and_confirmed(self):
self.put("agent.py", V5_UPDATES)
sha = self.next_version(V6_OK_THEN_STOP)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP)
self.assertEqual(self.read("agent.py.prev"), V5_UPDATES)
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
st = self.state()
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 5, 6, sha))
self.assertEqual(self.runs(), ["v5", "v6"])
def test_crash_in_trial_rolls_back(self):
self.put("agent.py", V5_UPDATES)
sha = self.next_version(V6_CRASH)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES)
st = self.state()
self.assertEqual((st["state"], st["to_version"], st["sha256"]), ("rolled_back", 6, sha))
self.assertRegex(st["error"], r"^код 1 через \d+ с$")
self.assertEqual(self.runs(), ["v5", "v6", "v5"])
self.assertEqual(self.pauses, []) # откат без паузы
def test_hang_in_trial_rolls_back(self):
self.l.trial_seconds = 1
self.l.kill_grace = 5
self.put("agent.py", V5_UPDATES)
self.next_version(V6_HANG)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES)
self.assertEqual(self.state()["error"], "не вышла на связь за 10 мин")
def test_crash_after_ok_restarts_with_backoff(self):
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
self.put("agent.py", fake("v6", '''
n = len(open(os.path.join(HERE, "runs.log")).read().split())
human_stop() if n > 2 else sys.exit(1)
'''))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.pauses, [5, 10])
self.assertEqual(self.state()["state"], "ok") # отката нет
def test_backoff_caps_and_resets(self):
self.assertEqual([self.l.next_backoff(10) for _ in range(8)], [5, 10, 20, 40, 80, 160, 300, 300])
self.assertEqual(self.l.next_backoff(600), 5) # проработал 10 минут — с начала
def test_75_without_staged_rejected(self):
self.put("agent.py", V5_BAD_STAGE)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_BAD_STAGE)
self.assertEqual(self.runs(), ["v5", "v5"])
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
st = self.state()
self.assertEqual((st["state"], st["error"]), ("rejected", "файл изменился после проверки"))
def test_staged_at_start_is_applied(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
self.stage(V6_OK_THEN_STOP)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6"])
self.assertEqual(self.state()["state"], "ok")
def test_stray_new_removed_at_start(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
self.put("agent.py.new", "остаток")
self.assertEqual(self.l.run(), 0)
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
def test_missing_agent_restored_from_prev(self):
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v5"])
def test_garbage_update_json_is_no_state(self):
self.put("update.json", '{"state": "tri')
self.put("agent.py", fake("v5", "human_stop()\n"))
self.assertEqual(self.l.run(), 0)
def test_env_and_args_passed(self):
self.put("agent.py", fake("v5", '''
open(os.path.join(HERE, "env.txt"), "w").write(os.environ.get("JIRA_AGENT_LAUNCHER", "") + " " + " ".join(sys.argv[1:]))
human_stop(130)
'''))
self.assertEqual(self.l.run(), 130)
self.assertEqual(self.read("env.txt"), "1 run --journal j.jsonl")
def test_sigint_without_child_stops(self):
self.l.on_sigint(signal.SIGINT, None)
self.assertTrue(self.l.stopping)
def test_sigint_with_child_ignored(self):
self.l.child = object()
self.l.on_sigint(signal.SIGINT, None)
self.assertFalse(self.l.stopping)
self.assertTrue(self.l.interrupted) # выход агента с 0 — остановка человеком
def test_exit_0_without_signal_in_trial_rolls_back(self):
self.put("agent.py", V5_UPDATES)
self.next_version(fake("v6", "sys.exit(0)\n")) # сама вышла, никто не нажимал Ctrl-C (I-1)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES)
st = self.state()
self.assertEqual(st["state"], "rolled_back")
self.assertRegex(st["error"], r"^код 0 через \d+ с$")
self.assertEqual(self.runs(), ["v5", "v6", "v5"])
def test_exit_0_without_signal_after_ok_restarts(self):
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
self.put("agent.py", fake("v6", '''
n = len(open(os.path.join(HERE, "runs.log")).read().split())
human_stop() if n > 1 else sys.exit(0)
'''))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.pauses, [5]) # выход 0 без сигнала — как падение
self.assertEqual(self.runs(), ["v6", "v6"])
def test_ctrl_c_with_75_applies_and_stops(self):
self.put("next.py", V6_OK_THEN_STOP)
self.put("agent.py", fake("v5", '''
import hashlib, signal
data = open(os.path.join(HERE, "next.py"), "rb").read()
open(os.path.join(HERE, "agent.py.new"), "wb").write(data)
write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest())
os.kill(os.getppid(), signal.SIGINT) # Ctrl-C пришёл, когда версия уже подготовлена
sys.exit(75)
'''))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP) # подмена сделана, новая не запущена
self.assertEqual(self.state()["state"], "trial") # пробный период начнётся при старте
self.assertEqual(self.runs(), ["v5"])
def test_stale_interrupt_does_not_hide_exit_0(self):
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
self.put("agent.py", fake("v6", '''
import signal
n = len(open(os.path.join(HERE, "runs.log")).read().split())
if n == 1:
os.kill(os.getppid(), signal.SIGINT) # Ctrl-C до установки обработчиков агента
sys.exit(1)
human_stop() if n > 2 else sys.exit(0)
'''))
self.l.stopping = False
orig = self.l.on_sigint
def sigint_child_alive(signum, frame): # агент жив — запускатель только запоминает Ctrl-C
self.l.child = self.l.child or object()
orig(signum, frame)
signal.signal(signal.SIGINT, sigint_child_alive)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6", "v6", "v6"]) # второй выход 0 без сигнала — перезапуск
self.assertEqual(len(self.pauses), 2)
def test_stop_before_spawn(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
self.l.stopping = True # SIGTERM пришёл между запусками (M-2)
self.assertEqual(self.l.run(), 0)
self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log")))
def test_pause_never_sleeps_negative(self):
clock = iter([0.0, 0.5, 1.5, 1.6]) # остаток перешёл через ноль между вызовами (M-1)
slept = []
orig_m, orig_s = run.time.monotonic, run.time.sleep
run.time.monotonic, run.time.sleep = (lambda: next(clock)), slept.append
try:
self.assertTrue(run.Launcher.pause(self.l, 1.0))
finally:
run.time.monotonic, run.time.sleep = orig_m, orig_s
self.assertTrue(all(x >= 0 for x in slept), slept)
def test_second_launcher_refused(self):
with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f:
self.put("run.py", f.read())
self.put("agent.py", fake("v5", "sys.exit(0)\n"))
lock = run.acquire_lock(self.dir)
try:
p = subprocess.run([sys.executable, os.path.join(self.dir, "run.py")], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=30)
finally:
lock.close()
self.assertEqual(p.returncode, 1)
self.assertIn("запускатель уже работает", p.stdout.decode("utf-8"))
self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log")))
if __name__ == "__main__":
unittest.main()

View File

@ -10,21 +10,24 @@ from _common import SRC
class Py39Test(unittest.TestCase):
def test_agent_parses_as_python_39(self):
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
ast.parse(f.read(), feature_version=(3, 9))
FILES = ("agent.py", "run.py")
def test_parses_as_python_39(self):
for name in self.FILES:
with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f:
ast.parse(f.read(), feature_version=(3, 9))
def test_no_third_party_imports(self):
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
tree = ast.parse(f.read())
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl"}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
self.assertIn(a.name.split(".")[0], allowed, a.name)
elif isinstance(node, ast.ImportFrom):
self.assertIn((node.module or "").split(".")[0], allowed, node.module)
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "subprocess"}
for name in self.FILES:
with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
self.assertIn(a.name.split(".")[0], allowed, "%s: %s" % (name, a.name))
elif isinstance(node, ast.ImportFrom):
self.assertIn((node.module or "").split(".")[0], allowed, "%s: %s" % (name, node.module))
if __name__ == "__main__":
unittest.main()

View File

@ -51,6 +51,53 @@ class RunnerTest(unittest.TestCase):
self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)])
self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0
def test_claim_sends_update_state_and_launcher(self):
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
agent.write_update_state(self.runner.updater.state_path, "trial", 4, agent.AGENT_VERSION, "s")
self.runner.loop()
body = self.hub.claim_bodies[0]
self.assertEqual(body["launcher_version"], 1)
self.assertEqual(body["update_state"]["state"], "trial")
self.assertEqual(agent.read_update_state(self.runner.updater.state_path)["state"], "ok") # первый успешный claim
self.assertEqual(self.hub.claim_bodies[0]["agent_version"], 5)
def test_claim_without_updater_sends_nulls(self):
self.runner.loop()
self.assertIsNone(self.hub.claim_bodies[0]["update_state"])
self.assertIsNone(self.hub.claim_bodies[0]["launcher_version"])
def test_idle_with_release_exits_for_update(self):
rel = {"version": 6, "sha256": "s", "content": "x"}
self.hub.release = rel
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
self.runner.updater.check = lambda r: r == {"version": 6, "sha256": "s"}
self.runner.idle_limit = 5
self.runner.loop()
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
self.assertEqual(self.hub.claims, 1)
def test_stop_during_check_exits_0(self):
self.hub.release = {"version": 6, "sha256": "s", "content": "x"}
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
def check(r):
self.runner.stop = True # Ctrl-C пришёл, когда версия уже staged
return True
self.runner.updater.check = check
self.runner.idle_limit = 5
self.runner.loop()
self.assertEqual(self.runner.exit_code, 0) # остановка человеком; staged доделает run.py при старте
def test_command_first_then_update(self):
self.hub.release = {"version": 6, "sha256": "s", "content": "x"}
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
seen = []
self.runner.updater.check = lambda r: (seen.append(r), True)[1]
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "done") # команда выполнена и подтверждена
self.assertEqual(len(seen), 1) # проверка — только после claim без команды
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
def test_restart_pace_from_old_finished(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "finished", "command_id": 99, "status": "done", "result": None, "error": None,

View File

@ -0,0 +1,78 @@
import hashlib
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import unittest
from _common import SETTINGS, SRC
from fake_hub import FakeHub
from fake_jira import FakeJira
def wait_for(pred, timeout=30):
end = time.monotonic() + timeout
while time.monotonic() < end:
if pred():
return True
time.sleep(0.1)
return False
class SelfUpdateE2E(unittest.TestCase):
"""run.py + настоящий agent.py: v5 видит публикацию v6, ставит её, v6 подтверждает; Ctrl-C группе — выход 0."""
def setUp(self):
self.jira = FakeJira()
self.hub = FakeHub(SETTINGS)
self.tmp = tempfile.TemporaryDirectory()
d = self.tmp.name
shutil.copy(os.path.join(SRC, "run.py"), d)
shutil.copy(os.path.join(SRC, "agent.py"), d)
with open(os.path.join(d, "agent.json"), "w", encoding="utf-8") as f:
json.dump({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5, "poll_seconds": 0.2,
"jira": {"base_url": self.jira.base_url,
"auth": {"type": "basic", "user": "me", "password": "secret"}}}, f)
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
src = f.read()
content = src.replace("AGENT_VERSION = 5\n", "AGENT_VERSION = 6\n", 1)
self.assertNotEqual(content, src)
self.hub.release = {"version": 6, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content}
self.content = content
self.log = open(os.path.join(d, "out.log"), "wb")
self.proc = subprocess.Popen([sys.executable, os.path.join(d, "run.py")], stdout=self.log,
stderr=subprocess.STDOUT, start_new_session=True)
def tearDown(self):
if self.proc.poll() is None:
os.killpg(self.proc.pid, signal.SIGKILL)
self.proc.wait()
self.log.close()
self.hub.stop()
self.jira.stop()
self.tmp.cleanup()
def state(self):
try:
with open(os.path.join(self.tmp.name, "update.json"), encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return None
def test_update_to_v6_and_stop_by_ctrl_c(self):
ok = wait_for(lambda: (self.state() or {}).get("state") == "ok", 60)
with open(os.path.join(self.tmp.name, "out.log"), encoding="utf-8", errors="replace") as f:
out = f.read()
self.assertTrue(ok, out)
with open(os.path.join(self.tmp.name, "agent.py"), encoding="utf-8") as f:
self.assertEqual(f.read(), self.content)
self.assertTrue(os.path.exists(os.path.join(self.tmp.name, "agent.py.prev")))
self.assertTrue(wait_for(lambda: any(b.get("agent_version") == 6 for b in self.hub.claim_bodies)))
last = [b for b in self.hub.claim_bodies if b.get("agent_version") == 6][-1]
self.assertEqual(last["launcher_version"], 1)
os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе
self.assertEqual(self.proc.wait(30), 0)

211
tests/test_update.py Normal file
View File

@ -0,0 +1,211 @@
import hashlib
import json
import os
import tempfile
import unittest
from _common import SETTINGS, agent
from fake_hub import FakeHub
class UpdateStateTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.path = os.path.join(self.dir.name, "update.json")
def tearDown(self):
self.dir.cleanup()
def test_write_and_read(self):
d = agent.write_update_state(self.path, "staged", 5, 6, "abc")
self.assertEqual(agent.read_update_state(self.path), d)
self.assertEqual((d["state"], d["from_version"], d["to_version"], d["sha256"], d["error"]), ("staged", 5, 6, "abc", None))
self.assertTrue(d["at"])
self.assertEqual(os.listdir(self.dir.name), ["update.json"]) # временный файл не остался
def test_read_missing(self):
self.assertIsNone(agent.read_update_state(self.path))
def test_read_update_state_garbage(self):
for text in ('{"state": "tri', "[1, 2]", ""):
with open(self.path, "w", encoding="utf-8") as f:
f.write(text)
self.assertIsNone(agent.read_update_state(self.path))
def test_confirm_trial_of_this_version(self):
u = agent.Updater(None, self.dir.name, 1)
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s")
u.confirm()
st = agent.read_update_state(u.state_path)
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 4, agent.AGENT_VERSION, "s"))
def test_confirm_ignores_other_states(self):
u = agent.Updater(None, self.dir.name, 1)
u.confirm()
self.assertIsNone(u.state()) # нет файла — не создаётся
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION + 1, "s")
u.confirm()
self.assertEqual(u.state()["state"], "trial") # пробная другая версия — не наша
agent.write_update_state(u.state_path, "rolled_back", 5, 6, "s", "код 1 через 3 с")
u.confirm()
self.assertEqual(u.state()["state"], "rolled_back")
def make_release(version, body="print('ok')\n"):
content = "AGENT_VERSION = %d\n%s" % (version, body)
return {"version": version, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content}
class UpdaterCheckTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.hub = FakeHub(SETTINGS)
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
self.u = agent.Updater(agent.HubClient(cfg), self.dir.name, 1)
self.selftests = []
self.code = 0
self.u.run_selftest = lambda path: (self.selftests.append(path), (self.code, "вывод selftest"))[1]
self.now = [1000.0]
self.u.clock = lambda: self.now[0]
def tearDown(self):
self.hub.stop()
self.dir.cleanup()
def ref(self, rel):
return {"version": rel["version"], "sha256": rel["sha256"]}
def test_good_version_is_staged(self):
rel = make_release(6)
self.hub.release = rel
self.assertTrue(self.u.check(self.ref(rel)))
with open(self.u.new_path, encoding="utf-8") as f:
self.assertEqual(f.read(), rel["content"])
st = self.u.state()
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("staged", 5, 6, rel["sha256"]))
self.assertEqual(self.selftests, [self.u.new_path])
def test_not_newer_or_disabled_or_no_release(self):
self.hub.release = make_release(5)
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertFalse(self.u.check(None))
self.hub.release = make_release(6)
self.u.enabled = False
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertEqual(self.hub.release_requests, 0)
def test_sha_mismatch_rejected_once(self):
rel = make_release(6)
self.hub.release = dict(rel, content=rel["content"] + "# подмена\n")
ref = self.ref(rel)
self.hub.release["sha256"] = rel["sha256"]
self.assertFalse(self.u.check(ref))
st = self.u.state()
self.assertEqual((st["state"], st["to_version"], st["error"]), ("rejected", 6, "sha256 не сходится"))
self.assertFalse(os.path.exists(self.u.new_path))
self.assertFalse(self.u.check(ref))
self.assertEqual(self.hub.release_requests, 1) # отвергнутую версию больше не качает
def test_syntax_error_and_wrong_version_rejected(self):
bad = make_release(6, body="def (:\n")
self.hub.release = bad
self.assertFalse(self.u.check(self.ref(bad)))
self.assertIn("не компилируется", self.u.state()["error"])
wrong = make_release(7)
wrong = dict(wrong, version=8)
self.hub.release = wrong
self.assertFalse(self.u.check(self.ref(wrong)))
self.assertIn("AGENT_VERSION", self.u.state()["error"])
self.assertEqual(self.selftests, [])
def test_selftest_bad_code_rejected(self):
self.hub.release = make_release(6)
self.code = 1
self.assertFalse(self.u.check(self.ref(self.hub.release)))
st = self.u.state()
self.assertEqual(st["state"], "rejected")
self.assertTrue(st["error"].startswith("selftest: код 1"))
self.assertIn("вывод selftest", st["error"])
self.assertFalse(os.path.exists(self.u.new_path))
def test_selftest_unavailable_is_transient(self):
self.hub.release = make_release(6)
self.code = 3
ref = self.ref(self.hub.release)
self.assertFalse(self.u.check(ref))
self.assertIsNone(self.u.state()) # update.json не тронут
self.assertFalse(os.path.exists(self.u.new_path))
self.now[0] += 299
self.assertFalse(self.u.check(ref))
self.assertEqual(len(self.selftests), 1) # раньше 5 минут не повторяет
self.now[0] += 2
self.code = 0
self.assertTrue(self.u.check(ref))
def test_selftest_timeout_is_transient(self):
self.hub.release = make_release(6)
self.code = None
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertIsNone(self.u.state())
def test_download_failure_is_transient(self):
rel = make_release(6) # в claim есть, а /agent/release — 404
self.assertFalse(self.u.check(self.ref(rel)))
self.assertIsNone(self.u.state())
def test_release_changed_between_requests_is_transient(self):
old, new = make_release(6), make_release(7)
self.hub.release = new
self.assertFalse(self.u.check(self.ref(old)))
self.assertIsNone(self.u.state())
self.assertEqual(self.selftests, [])
def test_rolled_back_same_sha_not_retried(self):
rel = make_release(6)
self.hub.release = rel
agent.write_update_state(self.u.state_path, "rolled_back", 5, 6, rel["sha256"], "код 1 через 3 с")
self.assertFalse(self.u.check(self.ref(rel)))
fixed = make_release(6, body="print('fixed')\n")
self.hub.release = fixed
self.assertTrue(self.u.check(self.ref(fixed))) # тот же номер, другой sha — пробуем
def test_without_launcher_only_hint(self):
self.u.launcher_version = None
self.hub.release = make_release(6)
logs = []
orig = agent.log
agent.log = logs.append
try:
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.now[0] += 3600
self.assertFalse(self.u.check(self.ref(self.hub.release)))
finally:
agent.log = orig
self.assertEqual(logs.count("доступна v6; для самообновления запускайте через run.py"), 2)
self.assertEqual(self.hub.release_requests, 0)
def test_stop_during_selftest_is_not_reject(self):
self.hub.release = make_release(6)
stop = [False]
self.u.should_stop = lambda: stop[0]
def selftest(path):
stop[0] = True # Ctrl-C пришёл и selftest упал с 1
return 1, "KeyboardInterrupt"
self.u.run_selftest = selftest
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertIsNone(self.u.state())
self.assertFalse(os.path.exists(self.u.new_path))
def test_real_selftest_runs_new_file(self):
u = agent.Updater(None, self.dir.name, 1, config_path=os.path.join(self.dir.name, "agent.json"))
path = os.path.join(self.dir.name, "agent.py.new")
with open(path, "w", encoding="utf-8") as f:
f.write("import sys\nprint('args', sys.argv[1:])\nsys.exit(3)\n")
code, out = u._run_selftest(path)
self.assertEqual(code, 3)
self.assertIn("'--config', '%s', 'selftest'" % os.path.join(self.dir.name, "agent.json"), out)
if __name__ == "__main__":
unittest.main()