Merge pull request 'Пакет C: гигиена агента моста, v4 (bridge-hygiene, #15)' (#16) from feat/15-bridge-hygiene into main

This commit is contained in:
aep 2026-10-14 12:22:18 +03:00
commit 1170e85f06
11 changed files with 503 additions and 36 deletions

1
.gitignore vendored
View File

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

View File

@ -10,10 +10,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — | | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `urllib` | | HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `urllib` |
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Папка | `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` | файловая система | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба | | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `main` | команды `run`, `check`, `prune`; обработка сигналов | `Runner`, `Journal` | | CLI | `cmd_run`, `cmd_check`, `cmd_prune`, `main` | команды `run`, `check`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
## Границы ## Границы
- Пароль и токен Jira — только в `agent.json`. В хаб, журнал и логи не попадают. - Пароль и токен Jira — только в `agent.json`. В хаб, журнал и логи не попадают.
@ -28,6 +28,5 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
- Тесты не ходят во внешние сервисы; фейки — `tests/fake_jira.py` и `tests/fake_hub.py`. - Тесты не ходят во внешние сервисы; фейки — `tests/fake_jira.py` и `tests/fake_hub.py`.
## Открытые вопросы ## Открытые вопросы
- HTTPS хаба с корпоративным CA: `HubClient` не принимает `ca_bundle`/`verify_ssl` (ограничение README, пункт 3). - HTTPS хаба с корпоративным CA: `HubClient` не принимает `ca_bundle`/`verify_ssl` (ограничение README, пункт 2).
- Защита `prune` от параллельной записи: блокировки нет (ограничение README, пункт 1).
- Спринт задач закрытых спринтов: `sprint = null` (рулинг R-6). - Спринт задач закрытых спринтов: `sprint = null` (рулинг R-6).

View File

@ -17,7 +17,7 @@
## Архитектура ## Архитектура
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `jira-hub/openspec/specs/agent/`), - `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `jira-hub/openspec/specs/agent/`),
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал → `Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `main`). выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `cmd_prune`, `main`).
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`). - Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira. `Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. - `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
@ -28,7 +28,7 @@
- `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые ресурсы (так проверяем перед коммитом). - `/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 check` — доступность хаба и Jira, без записи в Jira.
- `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`). - `/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`. - Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: Jira и хаб заменяют фейки `tests/fake_jira.py` и `tests/fake_hub.py`.

View File

@ -15,7 +15,7 @@
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini; - `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
- `jira.base_url` — адрес Jira банка; - `jira.base_url` — адрес Jira банка;
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`; - `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
- `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 3 ниже); - `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 2 ниже);
- `timeout` — таймаут запроса в секундах (по умолчанию 60); - `timeout` — таймаут запроса в секундах (по умолчанию 60);
- `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5). - `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5).
3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки. 3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки.
@ -121,7 +121,7 @@ launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist
python3 agent.py prune python3 agent.py prune
``` ```
Не запускать при работающем агенте (ограничение 1 ниже). При работающем агенте `prune` откажет («агент запущен…», код 1): сначала остановить агента.
## Что делает агент с командами ## Что делает агент с командами
@ -153,8 +153,9 @@ python3 agent.py prune
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление. по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 3) отправляется в каждом `claim`, в каждом отчёте - **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 4) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`. (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой.
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.
@ -180,21 +181,20 @@ python3 agent.py prune
В порядке важности: В порядке важности:
1. **`prune` не запускать при работающем агенте.** Блокировки нет. Запись, добавленная агентом во время `prune`, 1. **Остановка во время паузы или между claim и выполнением (R-9).** Команда остаётся `claimed` в хабе, но в журнале
может пропасть при перезаписи файла. Сначала остановить агента.
2. **Остановка во время паузы или между claim и выполнением (R-9).** Команда остаётся `claimed` в хабе, но в журнале
есть запись `claimed`: при перезапуске агент запрашивает её у хаба и выполняет с паузой темпа (время последнего есть запись `claimed`: при перезапуске агент запрашивает её у хаба и выполняет с паузой темпа (время последнего
вызова Jira берётся из журнала). Отмена с force и повтор не нужны. Если журнал потерян, хаб довыдаст команду при вызова Jira берётся из журнала). Отмена с force и повтор не нужны. Если журнал потерян, хаб довыдаст команду при
следующем `claim` — агент выполнит её через проверку. следующем `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` работают. должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают.
4. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по 3. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`.
5. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в 4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в
`result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`). `result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`).
Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50). Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50).
6. **Сравнение описания в `ensure_fields` точное.** Если Jira нормализует переводы строк (CRLF), будут лишние 5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
записи. Наблюдать на реальной Jira. `\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
7. **429/503 без потолка.** Агент ждёт `Retry-After` в секундах сколько бы он ни был (без верхней границы): не более 4 ожиданий, завершающий перевод строки) — изменение.
6. **429/503 без потолка.** Агент ждёт `Retry-After` в секундах сколько бы он ни был (без верхней границы): не более 4 ожиданий,
на пятой неудаче — ошибка. Длинный `Retry-After` держит очередь. Формат HTTP-date в `Retry-After` не разбирается: на пятой неудаче — ошибка. Длинный `Retry-After` держит очередь. Формат HTTP-date в `Retry-After` не разбирается:
тогда ждётся 60 с × номер попытки. Нецелый `Retry-After` (например, `1.5`) тоже не разбирается: те же 60 с × попытка. тогда ждётся 60 с × номер попытки. Нецелый `Retry-After` (например, `1.5`) тоже не разбирается: те же 60 с × попытка.

View File

@ -0,0 +1,352 @@
# bridge-hygiene — агент: план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** агент v4: сравнение текстов без различия `\r\n`/`\n`, честное время последнего вызова Jira в `claim`, полная
пауза при времени журнала в будущем, устойчивость к ответу папки с неверными типами, `prune` под блокировкой.
**Architecture:** точечные правки в `src/agent.py` (`Executor.do_ensure_fields`, `do_ensure_comment`,
`Runner.restore_pace`, `FolderTransport`, CLI `prune`); контракт с хабом не меняется.
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`.
**Spec:** `../jira-hub/.wt/hygiene/openspec/changes/bridge-hygiene/` (design.md решения 16–21, specs/agent, specs/commands);
агентские решения — `openspec/changes/bridge-hygiene/design.md` в этом репозитории.
## Global Constraints
- Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/hygiene` (ветка `feat/15-bridge-hygiene`).
- Только синтаксис и stdlib Python 3.9: никаких `X | Y` в аннотациях функций, `match`, сторонних импортов.
- Весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` (до начала: 141 OK).
- `AGENT_VERSION = 4`.
- Комментарии, docstring, логи и коммиты — по-русски; коммит с суффиксом `(#15)` и строкой
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Решения без обсуждения — в `openspec/changes/bridge-hygiene/design.md` этого репозитория, следующий маркер `R-12`.
## Review Focus
1. Описание в Jira `None`, а в команде `""` (или наоборот) — изменений нет, как и до правки — тест в Task 1.
2. Перезапуск с журналом, где последняя запись в будущем, и сразу Ctrl-C в паузе — пауза прерывается, команда остаётся на
восстановление — существующий `test_stop_during_pause` (Task 2 не должен его ломать).
3. Ответ папки `{"status": true, …}` (bool — подкласс int в Python) — нечитаемый ответ, а не статус 1 — тест в Task 3.
4. `prune` при работающем агенте — отказ без изменения журнала; после остановки — работает — тест в Task 4.
5. Первый `claim` после перезапуска несёт `last_jira_call_at = null`, после первого вызова Jira — настоящее время —
тест в Task 2.
---
### Task 1: Сравнение описания и комментария без различия переводов строк
**Files:**
- Modify: `src/agent.py` (новая `_nl` рядом с `_date`; `do_ensure_fields`, `do_ensure_comment`)
- Test: `tests/test_executor_fields.py`
**Interfaces:**
- Produces: `agent._nl(value) -> str` — `(value or "").replace("\r\n", "\n")`.
- [ ] **Step 1: Write the failing tests** — дописать в класс `ExecutorFieldsTest`:
```python
def test_fields_description_crlf_equals_lf(self):
self.fake.add("AURORA-1", description="а\r\nб") # так Jira хранит текст, правленный в UI
out = self.run_cmd("ensure_fields", key="AURORA-1", description="а\nб")
self.assertEqual(out["result"]["changed"], [])
out = self.run_cmd("ensure_fields", key="AURORA-1", description="а\nв")
self.assertEqual(out["result"]["changed"], ["description"])
self.assertEqual(self.fake.issues["AURORA-1"]["description"], "а\nв") # отправлен текст команды как есть
def test_fields_description_none_equals_empty(self):
self.fake.add("AURORA-1", description=None)
out = self.run_cmd("ensure_fields", key="AURORA-1", description="")
self.assertEqual(out["result"]["changed"], [])
def test_comment_crlf_equals_lf(self):
self.fake.add("AURORA-1")
self.fake.comments["AURORA-1"] = [{"id": "10", "body": "*Актуализация 10.10.2026.* строка1\r\nстрока2"}]
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="строка1\nстрока2")
self.assertEqual(out["result"], {"comment_id": "10", "changed": False})
```
Сверить формат элемента `fake.comments[key]` с `tests/fake_jira.py` (около строки 204) и подогнать словарь под него.
- [ ] **Step 2: Run tests to verify they fail**
Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v`
Expected: FAIL — `changed == ["description"]` для CRLF; комментарий `changed: True`.
- [ ] **Step 3: Implement** — в `src/agent.py` сразу после `_date`:
```python
def _nl(value) -> str:
"""Текст для сравнения: Jira хранит правленное в UI с \r\n, команды приходят с \n (T3.9, проверено 13.10.2026)."""
return (value or "").replace("\r\n", "\n")
```
В `do_ensure_fields` строку сравнения описания:
```python
if "description" in p and _nl(cur.get(self.fid("description"))) != _nl(p["description"]):
```
В `do_ensure_comment` строку проверки совпадения:
```python
if existing and _nl(existing.get("body")) == _nl(body):
```
- [ ] **Step 4: Run tests**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK (включая `test_fields_description_exact_compare` — разница в завершающем `\n` по-прежнему изменение).
- [ ] **Step 5: Commit**
```bash
git add src/agent.py tests/test_executor_fields.py
git commit -m "fix(agent): описание и комментарий сравниваются без различия CRLF/LF (#15)
T3.9 (agent#4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: Темп после перезапуска — будущее время журнала и `last_jira_call_at`
**Files:**
- Modify: `src/agent.py` (`Runner.restore_pace`)
- Modify: `tests/fake_hub.py` (запоминать тела `claim`)
- Test: `tests/test_runner.py`
**Interfaces:**
- Consumes: `JiraClient.last_call_at` (monotonic), `JiraClient.last_call_iso` (ISO UTC, ставится в `JiraClient` при
каждом вызове Jira).
- Produces: `FakeHub.claim_bodies: list[dict]` — тела всех `POST /agent/claim` по порядку.
- [ ] **Step 1: `tests/fake_hub.py`** — в `__init__` добавить `self.claim_bodies = []`; в `route` в ветке
`if path == "/agent/claim":` первой строкой `self.claim_bodies.append(body or {})`.
- [ ] **Step 2: Write the failing tests** — в `tests/test_runner.py` заменить `test_future_journal_time_ignored` и
дописать новый тест:
```python
def test_future_journal_time_means_full_pause(self):
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30)
self.runner.loop()
self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # часы ушли назад — пауза полная (M-6 #12)
self.assertEqual(self.hub.results[1]["status"], "done")
def test_claim_after_restart_sends_no_journal_estimate(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertIsNone(self.hub.claim_bodies[0].get("last_jira_call_at")) # оценка журнала — только для паузы (M-7 #12)
self.assertIsNotNone(self.hub.claim_bodies[-1].get("last_jira_call_at")) # после вызова Jira — настоящее время
self.assertIsNotNone(self.runner.jira.last_call_at)
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `/usr/bin/python3 -m unittest tests.test_runner -v`
Expected: FAIL — пауза 0 при будущем времени; первый `claim` несёт время журнала.
- [ ] **Step 4: Implement** — `Runner.restore_pace` целиком:
```python
def restore_pace(self) -> None:
"""После перезапуска время последнего вызова Jira неизвестно (живёт в памяти). Берём самое позднее время записи
журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9).
Время в будущем (часы ушли назад) — считаем «сейчас»: пауза полная (M-6 #12). Оценка служит только паузе:
last_call_iso (уходит хабу в claim) ставит лишь настоящий вызов Jira (M-7 #12)."""
if self.jira.last_call_at is not None:
return
at = self.journal.last_at()
if at is None:
return
ago = (datetime.now(timezone.utc) - at).total_seconds()
if ago < 0:
log("журнал: время последней записи в будущем (%s) — пауза перед первой командой полная" % at.isoformat())
ago = 0
self.jira.last_call_at = time.monotonic() - ago
log("темп: последний вызов Jira не позже %s (по журналу)"
% at.astimezone(timezone.utc).replace(microsecond=0).isoformat())
```
- [ ] **Step 5: Run tests**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK. Если другой тест проверял `last_call_iso` после `restore_pace` — привести его к спеке (оценка в
`last_call_iso` не попадает) и записать рулинг `R-12` в design.md.
- [ ] **Step 6: Commit**
```bash
git add src/agent.py tests/fake_hub.py tests/test_runner.py
git commit -m "fix(agent): полная пауза при времени журнала в будущем, claim без оценки из журнала (#15)
M-6, M-7 (agent#12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Ответ папки с неверными типами; текст ошибки без «хаб: хаб»
**Files:**
- Modify: `src/agent.py` (`FolderTransport.call`, `FolderTransport._take`)
- Test: `tests/test_folder.py`
**Interfaces:**
- Consumes: `FolderTransport(root, authorization)`, `FolderTransport.BAD_JSON_WAIT`, `FolderTransport._take(path)`.
- [ ] **Step 1: Write the failing tests** — дописать в `FolderTransportTest`:
```python
def _bad_response(self, payload):
path = os.path.join(self.root, "resp-bad.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f)
ft = agent.FolderTransport(self.root, "Bearer x")
ft.BAD_JSON_WAIT = 0.3
return ft, path
def test_wrong_status_type_is_unreadable(self):
for payload in ({"id": "x", "status": "ok", "body": "{}"}, {"id": "x", "status": True, "body": "{}"},
{"id": "x", "status": 200, "body": 5}):
ft, path = self._bad_response(payload)
with self.assertRaises(ConnectionError) as e:
ft._take(path)
self.assertIn("нечитаемый ответ", str(e.exception))
def test_no_answer_message_has_no_hub_prefix(self):
hub = self.client(timeout=0.3)
with self.assertRaises(ConnectionError) as e:
hub.settings()
self.assertFalse(str(e.exception).startswith("хаб")) # цикл сам пишет «хаб: …» (O-3 agent#8)
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
Expected: FAIL — `ValueError: invalid literal for int()` / `TypeError` вместо `ConnectionError`; текст начинается с «хаб».
- [ ] **Step 3: Implement** — в `FolderTransport._take` после проверки `if not isinstance(resp, dict): raise ValueError(...)`:
```python
status, body = resp.get("status"), resp.get("body")
if type(status) is not int or not (body is None or isinstance(body, str)):
raise ValueError("неверные типы status/body") # bool — подкласс int, поэтому type(), не isinstance (M-2 agent#8)
```
В `FolderTransport.call` последнюю строку заменить на
`raise ConnectionError("не ответил через папку %s за %.0f с" % (self.root, timeout))`.
- [ ] **Step 4: Run tests**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK (тесты с «не ответил через папку» проходят — подстрока сохранена).
- [ ] **Step 5: Commit**
```bash
git add src/agent.py tests/test_folder.py
git commit -m "fix(agent): ответ папки с неверными типами — ConnectionError; лог без повтора «хаб» (#15)
M-2, O-3 (agent#8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: `prune` под блокировкой, версия 4, README, `.gitignore`
**Files:**
- Modify: `src/agent.py` (`AGENT_VERSION`, новая `cmd_prune`, `main`)
- Modify: `README.md`, `.gitignore`, `CLAUDE.md`
- Test: `tests/test_runner.py`, `tests/test_folder.py`
**Interfaces:**
- Consumes: `agent.acquire_lock(journal_path) -> file | None`, `Journal(path).prune(days) -> int`.
- Produces: `agent.cmd_prune(journal_path: str) -> int` — 0 после очистки, 1 если журнал занят работающим агентом.
- [ ] **Step 1: Write the failing tests**
В `tests/test_runner.py` (класс `RunnerTest`):
```python
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(), [])
```
В `tests/test_folder.py` переименовать `test_version_is_3` в `test_version_is_4` с `self.assertEqual(agent.AGENT_VERSION, 4)`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `/usr/bin/python3 -m unittest tests.test_runner tests.test_folder -v`
Expected: FAIL — `AttributeError: module 'agent' has no attribute 'cmd_prune'`; версия 3.
- [ ] **Step 3: Implement** — в `src/agent.py`: `AGENT_VERSION = 4`; после `cmd_run`:
```python
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()
```
В `main` ветку `prune` заменить на `return cmd_prune(args.journal)`.
- [ ] **Step 4: Документы**
- `.gitignore`: дописать `.wt/`.
- `README.md`:
- раздел про `prune` (около строки 121): «Не запускать при работающем агенте (ограничение 1 ниже).» →
«При работающем агенте `prune` откажет («агент запущен…», код 1): сначала остановить агента.»;
- ограничение 1 («`prune` не запускать при работающем агенте. Блокировки нет…») удалить, перенумеровать список;
- ограничение «Сравнение описания в `ensure_fields` точное…» заменить на «**Сравнение текстов без различия
переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на `\n` (Jira хранит правленное в
UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы, завершающий перевод строки) — изменение.»;
- «Версия»: «(сейчас 3)» → «(сейчас 4)»; добавить строку: «Версия 4 — сравнение текстов без различия `\r\n`/`\n`,
полная пауза при времени журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой.»
- `CLAUDE.md`: в «Команды» у `prune` «(не при работающем агенте)» → «(при работающем агенте откажет)».
- [ ] **Step 5: Run tests**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . && git status --short`
Expected: OK; `.wt/` не виден в `git status`.
- [ ] **Step 6: Commit**
```bash
git add src/agent.py tests/test_runner.py tests/test_folder.py README.md .gitignore CLAUDE.md
git commit -m "feat(agent): prune под блокировкой журнала, версия 4, README (#15)
T2.3 (agent#4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

View File

@ -0,0 +1,26 @@
# bridge-hygiene — решения агента
Спека и дизайн изменения — в `jira-hub/openspec/changes/bridge-hygiene/` (issue aep/jira-hub#13, aep/jira-agent#15).
Агентская часть — решения 16–21 дизайна хаба:
- **T3.9 (agent#4)** — сравнение описания (`ensure_fields`) и тела комментария (`ensure_comment`) после замены `\r\n` на
`\n` с обеих сторон; отправляется текст команды как есть (решение владельца 14.10.2026).
- **M-6 (agent#12)** — время последней записи журнала в будущем принимается за «сейчас»: пауза первой команды полная.
- **M-7 (agent#12)** — `restore_pace` заполняет только `last_call_at`; `last_call_iso` — только при настоящем вызове Jira,
до него в `claim` уходит `null` (решение владельца 14.10.2026).
- **M-2 (agent#8)** — ответ папки со `status` не целым или `body` не строкой — как нечитаемый JSON: перечитать до 2 с,
затем `ConnectionError`.
- **O-3 (agent#8)** — текст `ConnectionError` транспорта папки без слова «хаб» (его добавляет цикл).
- **T2.3 (agent#4)** — `prune` берёт `acquire_lock`; занята — «агент запущен…», код 1; README без «Блокировки нет».
- Версия агента — 4.
Закрываются без кода: **O-2 (agent#8)** — то же, что закрытый M-9 (`restore_pace`, PR #10); **O-1 (agent#12)** —
`ensure_link` вживую, переносится в отдельную живую проверку.
## Decisions made without discussion
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-11 (design #21 хаба) | 2026-10-14 | Версия агента 4, `min_agent_version` не поднимается | поведение сравнения и темпа изменилось; v3 продолжает работать | нет |
| R-12 | 2026-10-14 | README: после удаления ограничения «prune без блокировки» список перенумерован, ссылки «ограничение 3» (README) и «пункт 3/пункт 1» (ARCHITECTURE.md) приведены к новой нумерации; строка «блокировки нет» снята из открытых вопросов ARCHITECTURE.md | документы не должны ссылаться на снятое ограничение (финальное ревью I-1) | неверная ссылка в документации |
| R-13 | 2026-10-14 | Ответ папки с `body: null` или без `body` считается читаемым (пустое тело), нечитаемым — только `body` иного типа | совместимость с прежним поведением (`body or ""`); реле хаба всегда пишет строку; спека уточняется: «`body` не строка и не `null`» (финальное ревью M-1) | нет: при 2xx агент получает `{}`, как раньше |

View File

@ -27,7 +27,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
AGENT_VERSION = 3 AGENT_VERSION = 4
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
API = "/rest/api/2" API = "/rest/api/2"
AGILE = "/rest/agile/1.0" AGILE = "/rest/agile/1.0"
@ -219,7 +219,7 @@ class FolderTransport:
os.remove(os.path.join(req_dir, rid + ".json")) os.remove(os.path.join(req_dir, rid + ".json"))
except OSError: except OSError:
pass pass
raise ConnectionError("хаб не ответил через папку %s за %.0f с" % (self.root, timeout)) raise ConnectionError("не ответил через папку %s за %.0f с" % (self.root, timeout))
@staticmethod @staticmethod
def _write(directory: str, name: str, payload: dict) -> None: def _write(directory: str, name: str, payload: dict) -> None:
@ -238,6 +238,9 @@ class FolderTransport:
resp = json.load(f) resp = json.load(f)
if not isinstance(resp, dict): if not isinstance(resp, dict):
raise ValueError("ответ не объект") raise ValueError("ответ не объект")
status, body = resp.get("status"), resp.get("body")
if type(status) is not int or not (body is None or isinstance(body, str)):
raise ValueError("неверные типы status/body") # bool — подкласс int, поэтому type(), не isinstance (M-2 agent#8)
break break
except (ValueError, OSError): except (ValueError, OSError):
if time.monotonic() >= end: if time.monotonic() >= end:
@ -453,6 +456,11 @@ def _date(value) -> Optional[str]:
return (value or "")[:10] or None return (value or "")[:10] or None
def _nl(value) -> str:
r"""Текст для сравнения: Jira хранит правленное в UI с \r\n, команды приходят с \n (T3.9, проверено 13.10.2026)."""
return (value or "").replace("\r\n", "\n")
class ConfigGap(Exception): class ConfigGap(Exception):
"""В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле.""" """В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле."""
@ -567,7 +575,7 @@ class Executor:
if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""): if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""):
fields["summary"] = p["summary"] fields["summary"] = p["summary"]
changed.append("summary") changed.append("summary")
if "description" in p and (cur.get(self.fid("description")) or "") != (p["description"] or ""): if "description" in p and _nl(cur.get(self.fid("description"))) != _nl(p["description"]):
fields[self.fid("description")] = p["description"] fields[self.fid("description")] = p["description"]
changed.append("description") changed.append("description")
if "priority" in p: if "priority" in p:
@ -622,7 +630,7 @@ class Executor:
start += len(comments) start += len(comments)
if not comments or page.get("total") is None or start >= int(page["total"]): if not comments or page.get("total") is None or start >= int(page["total"]):
break break
if existing and (existing.get("body") or "") == body: if existing and _nl(existing.get("body")) == _nl(body):
return self._out("done", {"comment_id": existing["id"], "changed": False}) return self._out("done", {"comment_id": existing["id"], "changed": False})
if existing: if existing:
self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body}) self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body})
@ -935,7 +943,9 @@ class Runner:
def restore_pace(self) -> None: def restore_pace(self) -> None:
"""После перезапуска время последнего вызова Jira неизвестно (живёт в памяти). Берём самое позднее время записи """После перезапуска время последнего вызова Jira неизвестно (живёт в памяти). Берём самое позднее время записи
журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9).""" журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9).
Время в будущем (часы ушли назад) — считаем «сейчас»: пауза полная (M-6 #12). Оценка служит только паузе:
last_call_iso (уходит хабу в claim) ставит лишь настоящий вызов Jira (M-7 #12)."""
if self.jira.last_call_at is not None: if self.jira.last_call_at is not None:
return return
at = self.journal.last_at() at = self.journal.last_at()
@ -943,11 +953,11 @@ class Runner:
return return
ago = (datetime.now(timezone.utc) - at).total_seconds() ago = (datetime.now(timezone.utc) - at).total_seconds()
if ago < 0: if ago < 0:
log("журнал: время последней записи в будущем (%s) — темп не восстанавливаю" % at.isoformat()) log("журнал: время последней записи в будущем (%s) — пауза перед первой командой полная" % at.isoformat())
return ago = 0
self.jira.last_call_at = time.monotonic() - ago self.jira.last_call_at = time.monotonic() - ago
self.jira.last_call_iso = at.astimezone(timezone.utc).replace(microsecond=0).isoformat() log("темп: последний вызов Jira не позже %s (по журналу)"
log("темп: последний вызов Jira не позже %s (по журналу)" % self.jira.last_call_iso) % at.astimezone(timezone.utc).replace(microsecond=0).isoformat())
def send_result(self, rec: dict) -> bool: def send_result(self, rec: dict) -> bool:
"""Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена """Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена
@ -1144,6 +1154,21 @@ def cmd_run(cfg: dict, journal_path: str) -> int:
lock.close() 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: def _run(cfg: dict, journal_path: str) -> int:
runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path)) runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path))
@ -1202,9 +1227,7 @@ def main(argv=None) -> int:
if args.cmd == "check": if args.cmd == "check":
return cmd_check(cfg) return cmd_check(cfg)
if args.cmd == "prune": if args.cmd == "prune":
removed = Journal(args.journal).prune(30) return cmd_prune(args.journal)
log("журнал: удалено записей %d" % removed)
return 0
return cmd_run(cfg, args.journal) return cmd_run(cfg, args.journal)

View File

@ -16,6 +16,7 @@ class FakeHub:
self.states = [] self.states = []
self.known = [] self.known = []
self.claims = 0 self.claims = 0
self.claim_bodies = []
self.fail_results = 0 self.fail_results = 0
self.reject_results = {} self.reject_results = {}
self.in_flight = None self.in_flight = None
@ -68,6 +69,7 @@ class FakeHub:
def route(self, method, path, body): def route(self, method, path, body):
if path == "/agent/claim": if path == "/agent/claim":
self.claim_bodies.append(body or {})
self.claims += 1 self.claims += 1
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed": 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"]}

View File

@ -101,6 +101,25 @@ class ExecutorFieldsTest(unittest.TestCase):
self.assertEqual(len(self.fake.comments["AURORA-1"]), 1) self.assertEqual(len(self.fake.comments["AURORA-1"]), 1)
self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё") self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё")
def test_fields_description_crlf_equals_lf(self):
self.fake.add("AURORA-1", description="а\r\nб") # так Jira хранит текст, правленный в UI
out = self.run_cmd("ensure_fields", key="AURORA-1", description="а\nб")
self.assertEqual(out["result"]["changed"], [])
out = self.run_cmd("ensure_fields", key="AURORA-1", description="а\nв")
self.assertEqual(out["result"]["changed"], ["description"])
self.assertEqual(self.fake.issues["AURORA-1"]["description"], "а\nв") # отправлен текст команды как есть
def test_fields_description_none_equals_empty(self):
self.fake.add("AURORA-1", description=None)
out = self.run_cmd("ensure_fields", key="AURORA-1", description="")
self.assertEqual(out["result"]["changed"], [])
def test_comment_crlf_equals_lf(self):
self.fake.add("AURORA-1")
self.fake.comments["AURORA-1"] = [{"id": "10", "body": "*Актуализация 10.10.2026.* строка1\r\nстрока2"}]
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="строка1\nстрока2")
self.assertEqual(out["result"], {"comment_id": "10", "changed": False})
def test_unknown_type(self): def test_unknown_type(self):
self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed") self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed")

View File

@ -88,6 +88,28 @@ class FolderTransportTest(unittest.TestCase):
hub.settings() hub.settings()
self.assertIn("нечитаемый ответ", str(e.exception)) self.assertIn("нечитаемый ответ", str(e.exception))
def _bad_response(self, payload):
path = os.path.join(self.root, "resp-bad.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f)
ft = agent.FolderTransport(self.root, "Bearer x")
ft.BAD_JSON_WAIT = 0.3
return ft, path
def test_wrong_status_type_is_unreadable(self):
for payload in ({"id": "x", "status": "ok", "body": "{}"}, {"id": "x", "status": True, "body": "{}"},
{"id": "x", "status": 200, "body": 5}):
ft, path = self._bad_response(payload)
with self.assertRaises(ConnectionError) as e:
ft._take(path)
self.assertIn("нечитаемый ответ", str(e.exception))
def test_no_answer_message_has_no_hub_prefix(self):
hub = self.client(timeout=0.3)
with self.assertRaises(ConnectionError) as e:
hub.settings()
self.assertFalse(str(e.exception).startswith("хаб")) # цикл сам пишет «хаб: …» (O-3 agent#8)
def test_late_response_is_not_reused(self): def test_late_response_is_not_reused(self):
self.run_relay("late", delay=1.0) self.run_relay("late", delay=1.0)
with self.assertRaises(ConnectionError): with self.assertRaises(ConnectionError):
@ -135,8 +157,8 @@ class FolderIntegrationTest(unittest.TestCase):
def cfg(self): def cfg(self):
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version_is_3(self): def test_version_is_4(self):
self.assertEqual(agent.AGENT_VERSION, 3) self.assertEqual(agent.AGENT_VERSION, 4)
def test_runner_through_folder(self): def test_runner_through_folder(self):
self.fake.add("AURORA-1", duedate="2026-10-20") self.fake.add("AURORA-1", duedate="2026-10-20")

View File

@ -60,14 +60,23 @@ class RunnerTest(unittest.TestCase):
self.runner.loop() self.runner.loop()
self.assertAlmostEqual(sum(self.sleeps), 20, delta=1.5) self.assertAlmostEqual(sum(self.sleeps), 20, delta=1.5)
def test_future_journal_time_ignored(self): def test_future_journal_time_means_full_pause(self):
at = (datetime.now(timezone.utc) + timedelta(hours=1)).replace(microsecond=0).isoformat() at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at}) self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30) self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30)
self.runner.loop() self.runner.loop()
self.assertEqual(self.sleeps, []) # время из будущего — как пустой журнал self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # часы ушли назад — пауза полная (M-6 #12)
self.assertEqual(self.hub.results[1]["status"], "done") self.assertEqual(self.hub.results[1]["status"], "done")
def test_claim_after_restart_sends_no_journal_estimate(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertIsNone(self.hub.claim_bodies[0].get("last_jira_call_at")) # оценка журнала — только для паузы (M-7 #12)
self.assertIsNotNone(self.hub.claim_bodies[-1].get("last_jira_call_at")) # после вызова Jira — настоящее время
self.assertIsNotNone(self.runner.jira.last_call_at)
def test_restore_pace_keeps_existing_last_call(self): def test_restore_pace_keeps_existing_last_call(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=100)).replace(microsecond=0).isoformat() at = (datetime.now(timezone.utc) - timedelta(seconds=100)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at}) self.journal.append({"kind": "acked", "command_id": 99, "at": at})
@ -428,6 +437,20 @@ class RunnerTest(unittest.TestCase):
self.assertEqual(seen["code"], 130) 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): def test_second_process_refused(self):
"""I-1 финального ревью: второй run с тем же журналом не стартует — иначе довыдача хаба отдаст ему ту же команду.""" """I-1 финального ревью: второй run с тем же журналом не стартует — иначе довыдача хаба отдаст ему ту же команду."""
from unittest import mock from unittest import mock