diff --git a/.gitignore b/.gitignore index fb103f0..5760a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ tests/fixtures/*.m4a tests/fixtures/*.webm .DS_Store journal.jsonl.lock +.wt/ diff --git a/CLAUDE.md b/CLAUDE.md index 460420e..f4c5629 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ - `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые ресурсы (так проверяем перед коммитом). - `/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 prune` — очистка журнала (при работающем агенте откажет). ## Тесты - Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: Jira и хаб заменяют фейки `tests/fake_jira.py` и `tests/fake_hub.py`. diff --git a/README.md b/README.md index 8d232b2..35d1d12 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - `hub_token` — агентский токен из `jira-hub tokens` на Mac mini; - `jira.base_url` — адрес Jira банка; - `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`; - - `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 3 ниже); + - `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 2 ниже); - `timeout` — таймаут запроса в секундах (по умолчанию 60); - `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5). 3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки. @@ -121,7 +121,7 @@ launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist python3 agent.py prune ``` -Не запускать при работающем агенте (ограничение 1 ниже). +При работающем агенте `prune` откажет («агент запущен…», код 1): сначала остановить агента. ## Что делает агент с командами @@ -153,8 +153,9 @@ python3 agent.py prune `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке по сигналу команду подхватит восстановление. -- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 3) отправляется в каждом `claim`, в каждом отчёте - (`agent_version`) и в `/agent/state`. +- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 4) отправляется в каждом `claim`, в каждом отчёте + (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени + журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды при этом выдаются по-прежнему. @@ -180,21 +181,20 @@ python3 agent.py prune В порядке важности: -1. **`prune` не запускать при работающем агенте.** Блокировки нет. Запись, добавленная агентом во время `prune`, - может пропасть при перезаписи файла. Сначала остановить агента. -2. **Остановка во время паузы или между claim и выполнением (R-9).** Команда остаётся `claimed` в хабе, но в журнале +1. **Остановка во время паузы или между claim и выполнением (R-9).** Команда остаётся `claimed` в хабе, но в журнале есть запись `claimed`: при перезапуске агент запрашивает её у хаба и выполняет с паузой темпа (время последнего вызова Jira берётся из журнала). Отмена с force и повтор не нужны. Если журнал потерян, хаб довыдаст команду при следующем `claim` — агент выполнит её через проверку. -3. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url` +2. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url` должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают. -4. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по +3. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. -5. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в +4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в `result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`). Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50). -6. **Сравнение описания в `ensure_fields` точное.** Если Jira нормализует переводы строк (CRLF), будут лишние - записи. Наблюдать на реальной Jira. -7. **429/503 без потолка.** Агент ждёт `Retry-After` в секундах сколько бы он ни был (без верхней границы): не более 4 ожиданий, +5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на + `\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы, + завершающий перевод строки) — изменение. +6. **429/503 без потолка.** Агент ждёт `Retry-After` в секундах сколько бы он ни был (без верхней границы): не более 4 ожиданий, на пятой неудаче — ошибка. Длинный `Retry-After` держит очередь. Формат HTTP-date в `Retry-After` не разбирается: тогда ждётся 60 с × номер попытки. Нецелый `Retry-After` (например, `1.5`) тоже не разбирается: те же 60 с × попытка. diff --git a/src/agent.py b/src/agent.py index 64a9eae..1f0a770 100644 --- a/src/agent.py +++ b/src/agent.py @@ -27,7 +27,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional -AGENT_VERSION = 3 +AGENT_VERSION = 4 HERE = os.path.dirname(os.path.abspath(__file__)) API = "/rest/api/2" AGILE = "/rest/agile/1.0" @@ -1154,6 +1154,21 @@ def cmd_run(cfg: dict, journal_path: str) -> int: lock.close() +def cmd_prune(journal_path: str) -> int: + """Удалить подтверждённые записи старше 30 дней. Под той же блокировкой, что run: prune перезаписывает файл, и запись, + добавленная работающим агентом (finished — неотправленный отчёт), пропала бы (T2.3).""" + lock = acquire_lock(journal_path) + if lock is None: + log("агент запущен с журналом %s — prune невозможен, сначала остановите агента" % journal_path) + return 1 + try: + removed = Journal(journal_path).prune(30) + log("журнал: удалено записей %d" % removed) + return 0 + finally: + lock.close() + + def _run(cfg: dict, journal_path: str) -> int: runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path)) @@ -1212,9 +1227,7 @@ def main(argv=None) -> int: if args.cmd == "check": return cmd_check(cfg) if args.cmd == "prune": - removed = Journal(args.journal).prune(30) - log("журнал: удалено записей %d" % removed) - return 0 + return cmd_prune(args.journal) return cmd_run(cfg, args.journal) diff --git a/tests/test_folder.py b/tests/test_folder.py index bc00d74..bb01e31 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -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_3(self): - self.assertEqual(agent.AGENT_VERSION, 3) + def test_version_is_4(self): + self.assertEqual(agent.AGENT_VERSION, 4) def test_runner_through_folder(self): self.fake.add("AURORA-1", duedate="2026-10-20") diff --git a/tests/test_runner.py b/tests/test_runner.py index 9d06504..caedae8 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -437,6 +437,20 @@ class RunnerTest(unittest.TestCase): self.assertEqual(seen["code"], 130) + def test_prune_refused_while_agent_runs(self): + old = "2020-01-01T00:00:00+00:00" + self.journal.append({"kind": "finished", "command_id": 7, "status": "done", "result": None, "error": None, + "jira_calls": 1, "rate_limited": 0, "at": old}) + self.journal.append({"kind": "acked", "command_id": 7, "at": old}) + held = agent.acquire_lock(self.journal.path) + try: + self.assertEqual(agent.cmd_prune(self.journal.path), 1) + self.assertEqual(len(self.journal.records()), 2) # журнал не тронут (T2.3 agent#4) + finally: + held.close() + self.assertEqual(agent.cmd_prune(self.journal.path), 0) + self.assertEqual(self.journal.records(), []) + def test_second_process_refused(self): """I-1 финального ревью: второй run с тем же журналом не стартует — иначе довыдача хаба отдаст ему ту же команду.""" from unittest import mock