feat(agent): prune под блокировкой журнала, версия 4, README (#15)

T2.3 (agent#4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 12:48:23 +04:00
parent 4f1b85c9c0
commit cd2dafe487
6 changed files with 48 additions and 20 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ tests/fixtures/*.m4a
tests/fixtures/*.webm
.DS_Store
journal.jsonl.lock
.wt/

View File

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

View File

@ -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 с × попытка.

View File

@ -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)

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_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")

View File

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