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:
parent
4f1b85c9c0
commit
cd2dafe487
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,3 +8,4 @@ tests/fixtures/*.m4a
|
||||
tests/fixtures/*.webm
|
||||
.DS_Store
|
||||
journal.jsonl.lock
|
||||
.wt/
|
||||
|
||||
@ -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`.
|
||||
|
||||
26
README.md
26
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 с × попытка.
|
||||
|
||||
21
src/agent.py
21
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)
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user