From 0bb43431b8e12da6aa0ecf8d022dbcb1b7aaa742 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 01:33:07 +0400 Subject: [PATCH 01/11] =?UTF-8?q?docs(openspec):=20hub-hygiene-d=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F,=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=B0,=20CRLF,=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-hygiene-d/.openspec.yaml | 2 + openspec/changes/hub-hygiene-d/design.md | 105 ++++++++++++++++++ openspec/changes/hub-hygiene-d/proposal.md | 57 ++++++++++ .../hub-hygiene-d/specs/commands/spec.md | 62 +++++++++++ .../hub-hygiene-d/specs/hub-api/spec.md | 99 +++++++++++++++++ .../changes/hub-hygiene-d/specs/mcp/spec.md | 49 ++++++++ 6 files changed, 374 insertions(+) create mode 100644 openspec/changes/hub-hygiene-d/.openspec.yaml create mode 100644 openspec/changes/hub-hygiene-d/design.md create mode 100644 openspec/changes/hub-hygiene-d/proposal.md create mode 100644 openspec/changes/hub-hygiene-d/specs/commands/spec.md create mode 100644 openspec/changes/hub-hygiene-d/specs/hub-api/spec.md create mode 100644 openspec/changes/hub-hygiene-d/specs/mcp/spec.md diff --git a/openspec/changes/hub-hygiene-d/.openspec.yaml b/openspec/changes/hub-hygiene-d/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/hub-hygiene-d/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/hub-hygiene-d/design.md b/openspec/changes/hub-hygiene-d/design.md new file mode 100644 index 0000000..7e5191b --- /dev/null +++ b/openspec/changes/hub-hygiene-d/design.md @@ -0,0 +1,105 @@ +## Context + +Хаб `main` 8229bba (web-app и agent-v9-hardening слиты). Issue aep/jira-hub#32. Источники: #25 (M-1…M-3, M-5…M-7), +#16 (T3.1, T3.2, T5.2, M-8, ре-ревью minor-1, minor-3, O-9, O-10, O-11), #4 (M-9/O-4, M-10). Сверка с кодом — 15.10.2026. + +Найдено при чтении кода: +- `release._write_atomic` — `tmp.write_bytes` + `os.replace`, без `fsync` и без очистки `.tmp` при ошибке; `publish` + пишет `agent.py`, затем манифест. `cli` ловит только `PublishError`. Если манифест не записался, новый `agent.py` + не сходится со старым манифестом по sha → `load` → `None` → 404. +- `release.current`: `int(m["version"])` (6.9 → 6, `true` → 1), `str(m["sha256"])`; `log.warning` на каждый вызов + (каждый `claim` и `GET /agent`). +- `release.load`: `data.decode("utf-8")` после сверки sha, `UnicodeDecodeError` не ловится → 500. +- `Hub.agent_update = {"update_state": None, "launcher_version": None}` — в памяти; после перезапуска хаба до первого + `claim` `launcher_version = None` неотличимо от «агент без запускателя» (`release.hint`, `mcp_server.fmt_agent:115`). +- `h_settings_put` отвечает `{"settings_version": version}`. +- `queue._next`: при неразрешённом временном ключе — `skipped` «задача new:N не создана», независимо от того, + `failed` или `cancelled` команда-создатель (`_definer_status`). +- `commands.check_confirmed`: `(issue.get(field) or None) != (p[field] or None)` — `\r\n` в зеркале против `\n` в + параметре даёт «не совпало». +- `relay._cleaned: set[Path]`, проверка `self.root not in self._cleaned` — по написанию пути. +- Тесты: 249 passed; 87 `ResourceWarning: unclosed database` (test_queue, test_relay, test_commands, test_importer). + +## Goals / Non-Goals + +**Goals:** публикация не оставляет хаб без годной версии при сбое записи и не роняет команду трейсбеком; статус агента +не утверждает неизвестное; подтверждение по состоянию работает на задачах с CRLF; ответы и тексты называют то, что +произошло; тесты закрывают найденные пробелы и чисты от `ResourceWarning`. + +**Non-Goals:** см. proposal. + +## Decisions + +### Публикация агента + +1. **Запись файла** (`_write_atomic`): временный файл `.<имя>.tmp` в том же каталоге, `write` + `flush` + `os.fsync`, + `os.replace`; при `OSError` временный файл удаляется (ошибки удаления игнорируются), исключение пробрасывается. +2. **Порядок публикации:** `agent.py` → `agent.py.prev` (`os.replace`, если `agent.py` есть); новый файл через + `_write_atomic` в `agent.py`; манифест через `_write_atomic`. `OSError` после переименования — `agent.py.prev` + возвращается на место `agent.py` (`os.replace`; если и это не удалось — в лог), и `PublishError("не удалось записать + публикацию: <ошибка>")`. После успешной записи манифеста `agent.py.prev` удаляется. Итог: при любом сбое опубликована + прежняя версия (манифест и `agent.py` согласованы), `release/` без временных файлов. +3. **`cli`** печатает текст `PublishError` в stderr и возвращает 1 — как сейчас; новых веток нет, `OSError` до `cli` не + доходит. +4. **Годный манифест** (`current`): объект; `version` — `int` и не `bool`, ≥ 1; `sha256` — строка, `re.fullmatch(r"[0-9a-f]{64}")`. + Иначе — «публикации нет». Совпадает с проверкой агента v9 (design agent-v9-hardening 10). +5. **Предупреждение о битом манифесте** — один раз на каждый новый текст ошибки: модульная переменная с последним + текстом; успешное чтение или отсутствие манифеста её сбрасывает. Отсутствие манифеста — не ошибка, без лога. +6. **`load`**: `UnicodeDecodeError` → `log.error` «agent.py не в UTF-8 — публикации нет» и `None` (404 `no_release`). + +### Статус агента + +7. **`agent_reported`**: `Hub.agent_update` получает ключ `reported: False`; `note_agent_update` (вызывается на каждом + `claim`) ставит `True`. `GET /agent` отдаёт `agent_reported`. `release.hint(...)` получает признак и не выдаёт + «агент запущен без run.py…», пока `agent_reported = false`; подсказки об откате и отказе — по `update_state` как + сейчас (до первого `claim` он `null`). +8. **MCP `agent_status`**: строка запускателя — «запускатель: v», «без запускателя» (агент сообщил `null`) или + «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (`agent_reported = false`). + Если поля `agent_reported` в ответе нет (старый хаб) — прежнее поведение. + +### Настройки, очередь, подтверждение, реле + +9. **`PUT /settings`** отвечает `settings.get_all(conn)` (токены скрыты) после записи — те же поля, что `GET /settings`, + включая `settings_version`. Клиенты, читающие `settings_version` из ответа (`client`, `mcp_server.settings_set`, + web), не меняются. +10. **Пропуск зависимой команды**: если среди неразрешённых ключей есть ключ, чья `ensure_issue` в `cancelled`, текст — + «создание задачи new:N отменено» (по первому такому ключу); иначе прежний «задача new:N не создана». +11. **Сравнение строк в `check_confirmed`**: для полей `ensure_fields` со строковыми значениями сравнение после замены + `\r\n` на `\n` с обеих сторон; `None` и пустая строка по-прежнему равны. Метки и прочие поля — без изменений. +12. **Реле**: `_cleaned` хранит `Path.resolve()`; проверка — по `self.root.resolve()`. Ошибка `resolve` (папки нет) — + сравнение по исходному пути, как сейчас. + +### Тесты и чистота + +13. Новые тесты: bool в строковом параметре и `[]` в списочном (`test_commands`); `sync_run.finished_at` не ставится + при `failed` не-refresh по пределу выдач (`test_queue`); 501 на `DELETE`/`PATCH` (`test_api`); `epics.csv` без + «Ключ» (`test_importer`); «подготовлена заново» в `test_vanished_tree_keeps_work` (`test_relay`); предупреждение + «обновите агента» при `agent_version < min_agent_version`, офлайн-агент (нет контакта дольше порога) в + `GET /agent`, `tick` — проверка эффекта (`test_api`/`test_queue`); публикация — сбой записи манифеста возвращает + прежний `agent.py`, `.tmp` нет; битый манифест (`6.9`, `true`, короткий sha) — «публикации нет», предупреждение один + раз; не-UTF-8 → 404; `agent_reported` до и после `claim`; `PUT /settings` отдаёт `relay_dir`; текст «отменено»; + CRLF-подтверждение; реле с симлинком на ту же папку. +14. **`ResourceWarning`**: фикстуры и хелперы тестов закрывают соединения (`conn.close()` / `contextlib.closing`); + критерий — `uv run pytest -W error::ResourceWarning` без предупреждений `unclosed database` (pytest + `filterwarnings = error::ResourceWarning` в `pyproject.toml`, если не ломает сторонние пакеты; иначе — проверка в + плане командой). +15. **T3.1**: `_type_error` переписывается без тяжёлой строки с `and` и кортежем — отдельные ветки по типу. + +## Risks / Trade-offs + +- [`agent.py.prev` в `release/`] лишний файл живёт только между переименованием и записью манифеста; при падении + процесса посреди — остаётся; следующая публикация его перезапишет. → не влияет на `load` (читается только `agent.py`). +- [«неизвестно» до первого claim] до 30 с после перезапуска хаба статус неполный. → честнее ложного «без запускателя». +- [`PUT /settings` отдаёт больше полей] клиенты, ожидавшие ровно `{settings_version}`, получают надмножество. → поле на + месте. +- [Конфликт с #29] текстовый в `tests/test_api.py` (одна строка у #29). → кто сливается вторым, разрешает. + +## Decisions made without discussion + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 (design #7) | 2026-10-15 | Признак `agent_reported` в памяти, а не `launcher_version` в БД | без записи на каждый claim; значение из БД после перезапуска могло устареть | до 30 с «неизвестно» после перезапуска хаба | +| R-2 (design #2) | 2026-10-15 | Откат публикации через `agent.py.prev` (переименования, без копии) | переименование не требует места на диске — откат работает и при полном диске | лишний файл в `release/` при падении процесса посреди публикации | +| R-3 (design #9) | 2026-10-15 | `PUT /settings` отвечает всеми настройками, а не `{settings_version, relay_dir}` | один формат с `GET /settings`; O-11 — «не видно, что записалось» относится к любой настройке | надмножество полей в ответе | +| R-4 (design #10) | 2026-10-15 | «Отменено» — только при `cancelled` создателе; при `failed` текст прежний | O-10 описывает только force-отмену; `failed` — задача действительно не создана | нет | +| R-5 (design #14) | 2026-10-15 | Цель — ноль `ResourceWarning` от sqlite в тестах, проверка `-W error::ResourceWarning` | M-9/O-4: 87 предупреждений прячут настоящие утечки | время на правку фикстур | diff --git a/openspec/changes/hub-hygiene-d/proposal.md b/openspec/changes/hub-hygiene-d/proposal.md new file mode 100644 index 0000000..d88372e --- /dev/null +++ b/openspec/changes/hub-hygiene-d/proposal.md @@ -0,0 +1,57 @@ +## Why + +У хаба накопились отложенные находки трёх change'ей: самообновления (#25), пакета C (#16) и первого change'а (#4, +«на потом»). Разбор против `main` 8229bba (15.10.2026) показал, что актуальны все пункты, кроме M-4 #25, который исправило +слияние web-app. Вред у одного пользователя небольшой, но заметный: +- при полном диске `jira-hub agent publish` падает с трейсбеком и оставляет публикацию в состоянии «404 до следующей + публикации»; +- после перезапуска хаба `agent_status` пишет «без запускателя», хотя агент под `run.py`; +- потерянный отчёт `ensure_fields` по задаче, у которой описание с `\r\n` (71 из 180 правлены в UI), закрывается + довыдачей вместо подтверждения по состоянию; +- в тестах 87 незакрытых соединений sqlite и пробелы в покрытии. + +Решения владельца 15.10.2026: всё актуальное — одним пакетом; M-4 #25 закрыть, minor-2 #16 отклонить; агентский +#26 в пакет не входит; работать параллельно с #29 (web-app follow-ups), она трогает только `h_app`. + +## What Changes + +- **Публикация агента** (`release.py`, `cli.py`): запись с `fsync`; прежний `agent.py` переименовывается в + `agent.py.prev`, при сбое записи манифеста возвращается; `OSError` → «не удалось записать публикацию: <ошибка>», + код 1, временные файлы удалены; манифест годен, только если `version` — целое (не bool) и `sha256` — 64 hex-символа; + не-UTF-8 файл при совпавшем sha — 404, а не 500; предупреждение о битом манифесте — один раз на каждую новую ошибку. +- **`GET /agent`**: поле `agent_reported` (был ли `claim` с момента запуска хаба); пока его нет — подсказки про `run.py` + нет; MCP `agent_status` пишет «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)». +- **`PUT /settings`**: ответ — все настройки, как `GET /settings` (включая `settings_version`). +- **Очередь**: зависимая команда, пропущенная из-за force-отменённой `ensure_issue`, получает + «создание задачи new:N отменено». +- **Подтверждение по состоянию**: строки `ensure_fields` сравниваются без различия `\r\n` и `\n`. +- **Реле**: обработанные папки сравниваются по `resolve()`. +- **Тесты**: bool в строковом параметре и пустой список; `finished_at` у `sync_run` только для `refresh`; 501 на прочие + методы; `epics.csv` без ключа; «подготовлена заново» в реле; предупреждение `min_agent_version`; офлайн-агент; `tick` с + проверками; ноль `ResourceWarning`; читаемый `commands._type_error`. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `hub-api`: ответ `PUT /settings`; публикация версии агента (сбой записи, проверка манифеста, `agent_reported`). +- `mcp`: строка запускателя в `agent_status`. +- `commands`: текст `skipped` при отменённом создании; подтверждение `ensure_fields` без различия перевода строки. + +## Non-Goals + +- сохранение `launcher_version` в БД (запись на каждый claim; «неизвестно» честнее устаревшего значения); +- агент (aep/jira-agent#26) — в следующий change агента; +- minor-2 #16 (дубль предупреждения при неудачном `chmod`) — отклонено; +- раздача веб-приложения (`h_app`) — в #29. + +## Impact + +- jira-hub (issue aep/jira-hub#32): `src/jira_hub/release.py`, `cli.py`, `api.py` (`Hub`, `h_agent_get`, + `h_settings_put`), `queue.py` (`_next`), `commands.py` (`check_confirmed`, `_type_error`), `relay.py` (`_cleaned`), + `mcp_server.py` (`fmt_agent`), `tests/`, ARCHITECTURE.md при необходимости. +- Пересечение с `feat/29-web-app-followups`: `api.py` (другие функции) и одна строка `tests/test_api.py`; дельты спек + трогают разные требования (`hub-api` «Раздача веб-приложения» у #29). +- Выкатка: после merge — pull на Mac mini, перезапуск хаба (владелец), `/mcp` → reconnect в сессиях Claude. diff --git a/openspec/changes/hub-hygiene-d/specs/commands/spec.md b/openspec/changes/hub-hygiene-d/specs/commands/spec.md new file mode 100644 index 0000000..e9bc9dc --- /dev/null +++ b/openspec/changes/hub-hygiene-d/specs/commands/spec.md @@ -0,0 +1,62 @@ +## MODIFIED Requirements + +### Requirement: Временные ключи и ссылки внутри пакета +Параметр-ключ любой команды MAY быть временным `new:`. Хаб SHALL выдавать агенту такую команду только после `done` +команды `ensure_issue` с тем же `temp_key` в том же пакете, подставив настоящий ключ. Если `ensure_issue` завершилась +`failed` или `cancelled`, зависимые команды SHALL получить `skipped` с ошибкой «создание задачи new: отменено», если +`ensure_issue` отменена, иначе «задача new: не создана». Временный ключ вне пакета с его `ensure_issue` SHALL +отвергаться при создании пакета (422). + +#### Scenario: Создать и привязать +- **WHEN** пакет: `ensure_issue {temp_key: new:60, …}`, `ensure_link {source: new:60, target: AURORA-1537, …}` +- **THEN** вторая команда выдаётся агенту с `source = AURORA-1590` после завершения первой + +#### Scenario: Создание отменено +- **WHEN** пакет: `ensure_issue {temp_key: new:901, …}` (отменена с `force: true`), `ensure_comment {key: new:901, …}` +- **THEN** `ensure_comment` — `skipped` с ошибкой «создание задачи new:901 отменено» + +#### Scenario: Создание упало +- **WHEN** `ensure_issue {temp_key: new:902, …}` завершилась `failed`, зависимая `ensure_link {source: new:902, …}` +- **THEN** `ensure_link` — `skipped` с ошибкой «задача new:902 не создана» + +### Requirement: Ошибки и подтверждение по состоянию +Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`, +обрезая присланное. Пакет после ошибки продолжается. + +**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому +команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL +сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры, +`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL +перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed` +или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать +сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался +`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести +заметку, не меняя статус (как для отменённой, см. `hub-api`). + +**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб +SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают (строки сравниваются без различия +`\r\n` и `\n`; `null` и пустая строка равны); `ensure_status` — статус равен последнему в пути; `ensure_link` — связь +есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment` и `refresh` по состоянию не +подтверждаются: потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля) +или отменой человеком. + +#### Scenario: Отчёт потерян, факт на месте +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2» +- **THEN** команда `confirmed_by_state` + +#### Scenario: Описание с CRLF в зеркале +- **WHEN** `ensure_fields {key: X, description: "a\nb"}` в `claimed`, после `refresh` в зеркале у X `description = "a\r\nb"` +- **THEN** команда `confirmed_by_state` + +#### Scenario: Ответ на claim потерян +- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim` +- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40 + +#### Scenario: Предел выдач +- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42 +- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42 + +#### Scenario: Потерянный отчёт о создании задачи +- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim` +- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй + задачи нет, заметка `new:5` перенесена на ключ diff --git a/openspec/changes/hub-hygiene-d/specs/hub-api/spec.md b/openspec/changes/hub-hygiene-d/specs/hub-api/spec.md new file mode 100644 index 0000000..abcb3e3 --- /dev/null +++ b/openspec/changes/hub-hygiene-d/specs/hub-api/spec.md @@ -0,0 +1,99 @@ +## MODIFIED Requirements + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, + `q` (подстрока темы), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок; + сокращение до карточки — дело интерфейса. +- `GET /issues/{key}` — задача целиком: поля, связи в обе стороны, заметки, последние 10 команд по ней. +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных + параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с + одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы). +- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды + пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены + агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N». +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`. +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`; + ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +### Requirement: Публикация версии агента +`jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка +`AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `/release/agent.py` +(каталог 0700), затем `manifest.json` `{version, sha256, size, published_at}`; оба — через временный файл с `fsync` и +переименование, манифест последним. Прежний `agent.py` на время публикации SHALL переименовываться в `agent.py.prev` +и удаляться после записи манифеста; если запись нового файла или манифеста не удалась (`OSError`), хаб SHALL вернуть +`agent.py.prev` на место `agent.py`, удалить временные файлы и завершить команду сообщением «не удалось записать +публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя версия. Команда SHALL работать без запущенного +`serve` и печатать «опубликована v, sha256 <первые 12 символов>…, байт»; при ошибке — сообщение и код 1, +опубликованное не меняется. Хаб SHALL читать манифест при каждом `claim`, `GET /agent/release` и `GET /agent`; манифест +годен, только если это объект, `version` — целое число (не логическое) не меньше 1, `sha256` — строка из 64 символов +`[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и предупреждение в лог — одно на каждый новый +текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем sha256, означает «публикации нет» +(`GET /agent/release` — 404 `no_release`). `GET /agent` SHALL дополняться полями `release` (`{version, sha256, +published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` с момента запуска хаба) +и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии агента, агент +онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v на v: » (`rolled_back`), +«v отвергнута: » (`rejected`), иначе `null`. + +#### Scenario: Публикация новой версии +- **WHEN** опубликована v5, `jira-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6` +- **THEN** в `release/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6, + sha256 …»; код 0 + +#### Scenario: Версия не выше опубликованной +- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6` +- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён + +#### Scenario: Нет строки версии +- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками +- **THEN** сообщение об ошибке, код 1; `release/` не изменён + +#### Scenario: Диск полон при записи манифеста +- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError` +- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8, + `GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/` нет + +#### Scenario: Негодный манифест +- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim` +- **THEN** в `claim` `agent_release = null`, `GET /agent/release` — 404; в логе одно предупреждение о манифесте + +#### Scenario: Агент без запускателя +- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null` +- **THEN** `GET /agent` → `agent_reported = true`, `update_hint` = «агент запущен без run.py — обновится только вручную» + +#### Scenario: Хаб перезапущен, агент ещё не выходил на связь +- **WHEN** опубликована v10, агент v9 под запускателем, хаб перезапущен, `claim` после запуска ещё не было +- **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого + `claim` — `agent_reported = true`, `launcher_version` из `claim` diff --git a/openspec/changes/hub-hygiene-d/specs/mcp/spec.md b/openspec/changes/hub-hygiene-d/specs/mcp/spec.md new file mode 100644 index 0000000..700969b --- /dev/null +++ b/openspec/changes/hub-hygiene-d/specs/mcp/spec.md @@ -0,0 +1,49 @@ +## MODIFIED Requirements + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой +проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала. + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» -- 2.47.2 From aa1cca877d1531a5b1cb6d32e152e681ff32386a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 02:04:05 +0400 Subject: [PATCH 02/11] =?UTF-8?q?docs(plan):=20hub-hygiene-d=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=B0=D0=BD=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-15-hub-hygiene-d.md | 1751 +++++++++++++++++ 1 file changed, 1751 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-15-hub-hygiene-d.md diff --git a/docs/superpowers/plans/2026-10-15-hub-hygiene-d.md b/docs/superpowers/plans/2026-10-15-hub-hygiene-d.md new file mode 100644 index 0000000..4039b77 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-hub-hygiene-d.md @@ -0,0 +1,1751 @@ +# hub-hygiene-d — публикация агента, статус агента, CRLF, тесты: план реализации + +> **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:** закрыть отложенные находки #25 (M-1…M-3, M-5…M-7), #16 (T3.1, T3.2, T5.2, M-8, minor-1, minor-3, O-9, O-10, +O-11) и #4 (M-9/O-4, M-10) одним пакетом: публикация агента не ломает опубликованное при сбое записи, статус агента +не утверждает неизвестное, подтверждение по состоянию работает на тексте с CRLF, ответы называют то, что произошло, +тесты закрывают пробелы и чисты от `ResourceWarning`. + +**Architecture:** правки точечные, без новых модулей: `release.py` (запись, откат через `agent.py.prev`, проверка +манифеста, предупреждение один раз), `api.py` (`Hub.agent_update["reported"]`, `h_agent_get`, `h_settings_put`), +`mcp_server.fmt_agent`, `queue._next` (текст пропуска), `commands` (`check_confirmed`, `_type_error`), `relay._prepare`. +Тесты: общая фикстура `open_db` в новом `tests/conftest.py`, фикстуры `conn` в модулях тестов вместо хелперов +`fresh()`/`_mirror()`, `filterwarnings` в `pyproject.toml` делает утечку соединения ошибкой. + +**Tech Stack:** Python 3.13, uv, pytest 9, stdlib (`http.server`, `sqlite3`), пакет `mcp` (без изменений). + +**Spec:** `openspec/changes/hub-hygiene-d/` (proposal.md, design.md — решения 1–15 и рулинги R-1…R-5, +specs/hub-api/spec.md, specs/mcp/spec.md, specs/commands/spec.md). + +## Global Constraints + +- Рабочий каталог — worktree `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2` (ветка + `feat/32-hub-hygiene`, issue aep/jira-hub#32). Не делать `cd` в `/Users/andrey.epifancev/projects/awp-ca/jira-hub` + (основной checkout на main, хук guard-main). Git — только `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 …`; + pytest — `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest …`. +- codegraph: в `codegraph_explore` передавать `projectPath` = `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2`. +- Параллельная ветка `feat/29-web-app-followups` правит `h_app` в `api.py`, `tests/test_web_app.py` и одну строку + `test_bad_requests` в `tests/test_api.py`. Эти три места в этом плане не трогаются; новые тесты `test_api.py` + дописываются в конец файла. +- Комментарии, docstring, сообщения и коммиты — по-русски; идентификаторы — английские. Каждый коммит заканчивается + строкой `Co-Authored-By: Claude Opus 5.5 `; в сообщении — маркеры находок и `(#32)`. Не пушить. +- Тексты из спеки — дословно: + - сбой записи публикации: `не удалось записать публикацию: <ошибка>`, код 1, без трейсбека; + - манифест годен, только если это объект, `version` — `int`, не `bool`, ≥ 1, `sha256` — строка `[0-9a-f]{64}`; + - MCP: `запускатель: v`, `без запускателя`, `запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)`; + - пропуск зависимой команды: `создание задачи new: отменено` (создатель `cancelled`), иначе прежний + `задача new: не создана`; + - подсказка: `агент запущен без run.py — обновится только вручную` — только при `agent_reported = true`. +- `PUT /settings` отвечает `settings.get_all(conn)` (токены скрыты) — те же поля, что `GET /settings`. +- Зелёным считается только весь набор: `uv run --directory … pytest -q -p no:cacheprovider` (после Task 1 в + `pyproject.toml` стоит `filterwarnings`, поэтому любая утечка соединения — ошибка набора). +- Эффект «диск полон» в тестах — только `monkeypatch` (`os.replace`, `os.fsync`), никогда реальный диск. +- Агент (aep/jira-agent#26), `launcher_version` в БД, minor-2 #16 — вне плана (Non-Goals proposal). + +## Review Focus + +1. Процесс упал посреди прошлой публикации: в `release/` лежит `agent.py.prev`, `agent.py` нет — следующая публикация + проходит и не оставляет `.prev` (Task 2, `test_stale_prev_removed_by_next_publish`). +2. Сбой записи и следом сбой возврата `agent.py.prev` (тот же диск, права) — команда всё равно завершается текстом + «не удалось записать публикацию: …» без трейсбека, причина возврата — в логе (Task 2, `test_restore_failure_is_logged_not_raised`). +3. Самая первая публикация (прежней нет) упала на записи манифеста — `release/` пуст, а не с `agent.py` без манифеста + (Task 2, `test_first_publish_failure_leaves_nothing`). +4. Зависимая команда ссылается на два временных ключа: создатель первого `failed`, второго `cancelled` — текст пропуска + называет отмену по отменённому ключу (Task 4, `test_dependent_on_failed_and_cancelled_names_cancelled`). +5. Первый после перезапуска хаба `claim` несёт `update_state`/`launcher_version` неверного типа — поля игнорируются, + но `agent_reported` становится `true` и подсказка «без run.py» снова честная (Task 3, `test_agent_reported_even_with_bad_update_fields`). + +## Карта файлов + +| Файл | Задача | Что меняется | +|---|---|---| +| `tests/conftest.py` (новый) | 1 | фикстура-фабрика `open_db` | +| `tests/test_queue.py`, `test_settings.py`, `test_commands.py`, `test_mirror.py`, `test_importer.py`, `test_db.py` | 1 | хелперы → фикстуры `conn`/`open_db` | +| `pyproject.toml` | 1 | `filterwarnings` | +| `CLAUDE.md`, `ARCHITECTURE.md` | 1, 2, 3 | текущий change, правило тестов, `release`, поток самообновления | +| `src/jira_hub/release.py` | 2, 3 | `_write_atomic`, `publish`, `_undo`, `_manifest_problem`, `_note_manifest`, `current`, `load`; `hint(reported=)` | +| `src/jira_hub/api.py` | 3, 4 | `Hub.agent_update["reported"]`, `note_agent_update`, `h_agent_get`; `h_settings_put` | +| `src/jira_hub/mcp_server.py` | 3 | `fmt_agent` | +| `src/jira_hub/queue.py` | 4 | `_skip_reason`, `_next` | +| `src/jira_hub/commands.py` | 4 | `_type_error`, `_is_str_list`, `_norm_text`, `check_confirmed` | +| `src/jira_hub/relay.py` | 4 | `_same_dir_key`, `_prepare` | +| `README.md` | 2, 3, 4 | публикация при сбое, `agent_reported`, ответ `PUT /settings` | +| `tests/test_release.py`, `test_cli.py`, `test_api.py`, `test_mcp.py`, `test_relay.py` | 2–5 | новые тесты | + +`src/jira_hub/cli.py` не меняется (решение 3: `OSError` до `cli` не доходит, ветка `PublishError` уже есть). + +--- + +### Task 1: Соединения SQLite в тестах закрываются, `ResourceWarning` — ошибка (решение 14, R-5) + +**Модель исполнителя:** sonnet (правка фикстур в шести файлах тестов, проверка по tracemalloc). + +**Источники утечек (замер 15.10.2026, `PYTHONTRACEMALLOC=25`, 88 предупреждений):** все — `db.connect` без `close()` +в коде тестов, ни одного из `src/`: +- `tests/test_queue.py` — хелпер `fresh()` (31) и прямые `db.connect` в `test_claim_is_exclusive` (3), + `test_make_backup_rotates` (2), `test_rotation_keeps_schema_migration_copy` (1); +- `tests/test_commands.py` — хелпер `_mirror()` (18); +- `tests/test_mirror.py` — `conn = db.connect(":memory:")` первой строкой в 15 тестах (15); +- `tests/test_importer.py` — `db.connect(":memory:")` + `ensure_defaults` в 6 тестах; +- `tests/test_settings.py` — хелпер `fresh()` (6); +- `tests/test_db.py` — `test_connect_creates_schema`, `test_connect_is_idempotent`, `test_transaction_rolls_back_on_error`, + `test_nested_transaction_joins_outer`, `test_nested_transaction_error_propagates_to_outer`, + `test_nested_transaction_rollback_is_local`. +`test_relay`, указанный в design как источник, утечек не имеет: pytest приписывает предупреждение тесту, во время которого +сработал сборщик мусора. + +Важно: `-W error::ResourceWarning` сам по себе набор не роняет — предупреждение возникает в `__del__`, pytest превращает +его в `PytestUnraisableExceptionWarning` (проверено: 249 passed, 88 warnings). Поэтому в `filterwarnings` два фильтра. +Сторонние пакеты (`mcp`, anyio) при этих фильтрах набор не роняют (проверено на копии: 249 passed). + +**Files:** +- Create: `tests/conftest.py` +- Modify: `pyproject.toml` (`[tool.pytest.ini_options]`) +- Modify: `tests/test_queue.py`, `tests/test_settings.py`, `tests/test_commands.py`, `tests/test_mirror.py`, + `tests/test_importer.py`, `tests/test_db.py` +- Modify: `CLAUDE.md` (первый абзац, раздел «Тесты»), `ARCHITECTURE.md` («Открытые вопросы») + +**Interfaces:** +- Produces: фикстура `open_db` (`tests/conftest.py`): `open_db(path=":memory:") -> sqlite3.Connection`, все открытые + соединения закрываются после теста. Фикстуры `conn` в модулях: + - `tests/test_queue.py::conn` — база в памяти, `settings.ensure_defaults`, `link_types = LT`; + - `tests/test_settings.py::conn`, `tests/test_importer.py::conn` — база в памяти, `settings.ensure_defaults`; + - `tests/test_commands.py::conn` — зеркало в памяти (A-1, A-2 и связь A-1 → A-2, как прежний `_mirror()`); + - `tests/test_mirror.py::conn` — пустая база в памяти. + Задачи 2–5 пишут тесты на эти фикстуры (`def test_x(conn): …`); хелперов `fresh()` и `_mirror()` после задачи нет. + +- [ ] **Step 1: Включить фильтр — тест на утечку (красный)** + +В `pyproject.toml` заменить + +```toml +[tool.pytest.ini_options] +testpaths = ["tests"] +``` + +на + +```toml +[tool.pytest.ini_options] +testpaths = ["tests"] +# незакрытое соединение sqlite — ошибка набора: ResourceWarning из __del__ pytest выдаёт как +# PytestUnraisableExceptionWarning, поэтому нужны оба фильтра (hub-hygiene-d, решение 14) +filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExceptionWarning"] +``` + +- [ ] **Step 2: Убедиться, что набор падает** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider 2>&1 | tail -3` +Expected: FAIL — несколько тестов (замер: `5 failed, 243 passed, 1 error`) с `PytestUnraisableExceptionWarning … +sqlite3.Connection`; какие именно — зависит от момента сборки мусора. + +- [ ] **Step 3: Создать `tests/conftest.py`** + +```python +"""Общие фикстуры тестов. Соединения SQLite, открытые тестом, закрываются после него — в тестах нет +ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4).""" +import pytest + +from jira_hub import db + + +@pytest.fixture +def open_db(): + """Фабрика соединений: open_db() — база в памяти, open_db(path) — файл. Все закрываются после теста.""" + opened = [] + + def make(path=":memory:"): + conn = db.connect(path) + opened.append(conn) + return conn + + yield make + for conn in opened: + conn.close() +``` + +- [ ] **Step 4: Переписать тесты — хелпер в первой строке теста становится фикстурой `conn`** + +Скрипт находит в каждой функции `test_*` строку(и) создания соединения на верхнем уровне тела, удаляет их и добавляет +первым параметром `conn`. Затем заменяет хелперы фикстурами и правит оставшиеся прямые `db.connect`. Каждая замена +проверяется `assert` — если файл не такой, как ожидается, скрипт падает, ничего не записав в этот файл. + +Run: + +```bash +python3 - <<'EOF' +"""Одноразовая правка тестов (hub-hygiene-d, решение 14): соединение из хелпера → фикстура conn / open_db.""" +import ast +from pathlib import Path + +ROOT = Path("/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2") +RULES = { + "tests/test_queue.py": ["conn = fresh()"], + "tests/test_settings.py": ["conn = fresh()"], + "tests/test_commands.py": ["conn = _mirror()"], + "tests/test_mirror.py": ['conn = db.connect(":memory:")'], + "tests/test_importer.py": ['conn = db.connect(":memory:")', "settings.ensure_defaults(conn)"], +} + + +def rewrite(path: Path, seq: list[str]) -> int: + src = path.read_text(encoding="utf-8") + lines = src.splitlines(keepends=True) + edits = [] + for fn in ast.parse(src).body: + if not isinstance(fn, ast.FunctionDef) or not fn.name.startswith("test_"): + continue + for i in range(fn.lineno, fn.end_lineno - len(seq) + 1): + chunk = lines[i:i + len(seq)] + if [c.strip() for c in chunk] == seq and all(c.startswith(" ") and not c.startswith(" ") for c in chunk): + edits.append((fn, i)) + break + for fn, i in sorted(edits, key=lambda e: -e[1]): + del lines[i:i + len(seq)] + d = fn.lineno - 1 + empty, opened = f"def {fn.name}()", f"def {fn.name}(" + lines[d] = (lines[d].replace(empty, f"def {fn.name}(conn)", 1) if empty in lines[d] + else lines[d].replace(opened, f"def {fn.name}(conn, ", 1)) + path.write_text("".join(lines), encoding="utf-8") + return len(edits) + + +def sub(name, old, new): + p = ROOT / name + t = p.read_text(encoding="utf-8") + assert t.count(old) == 1, (name, old[:60], t.count(old)) + p.write_text(t.replace(old, new), encoding="utf-8") + + +counts = {name: rewrite(ROOT / name, seq) for name, seq in RULES.items()} +print(counts) +assert counts == {"tests/test_queue.py": 33, "tests/test_settings.py": 4, "tests/test_commands.py": 4, + "tests/test_mirror.py": 15, "tests/test_importer.py": 6}, counts + +# --- test_queue.py --------------------------------------------------------------------------- +sub("tests/test_queue.py", '''def fresh(): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", LT) + return conn +''', '''@pytest.fixture +def conn(open_db): + """База очереди в памяти с настройками по умолчанию и типами связей LT; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + settings.set_(c, "link_types", LT) + return c +''') +sub("tests/test_queue.py", '''def test_claim_is_exclusive(tmp_path): + import threading + path = tmp_path / "hub.sqlite" + setup = db.connect(path) + settings.ensure_defaults(setup) + queue.create_batch(setup, "x", "claude", [fields("A-1")]) + conns = [db.connect(path), db.connect(path)]''', '''def test_claim_is_exclusive(tmp_path, open_db): + import threading + path = tmp_path / "hub.sqlite" + setup = open_db(path) + settings.ensure_defaults(setup) + queue.create_batch(setup, "x", "claude", [fields("A-1")]) + conns = [open_db(path), open_db(path)]''') +sub("tests/test_queue.py", '''def test_make_backup_rotates(tmp_path): + from jira_hub import backup + conn = db.connect(tmp_path / "hub.sqlite")''', '''def test_make_backup_rotates(tmp_path, open_db): + from jira_hub import backup + conn = open_db(tmp_path / "hub.sqlite")''') +sub("tests/test_queue.py", ''' assert db.connect(tmp_path / "backups" / names[-1]).execute(''', + ''' assert open_db(tmp_path / "backups" / names[-1]).execute(''') +sub("tests/test_queue.py", '''def test_rotation_keeps_schema_migration_copy(tmp_path): + """Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью).""" + from jira_hub import backup + conn = db.connect(tmp_path / "hub.sqlite")''', '''def test_rotation_keeps_schema_migration_copy(tmp_path, open_db): + """Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью).""" + from jira_hub import backup + conn = open_db(tmp_path / "hub.sqlite")''') + +# --- test_settings.py ------------------------------------------------------------------------ +sub("tests/test_settings.py", '''def fresh(): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + return conn +''', '''@pytest.fixture +def conn(open_db): + """База в памяти с настройками по умолчанию; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + return c +''') + +# --- test_commands.py ------------------------------------------------------------------------ +sub("tests/test_commands.py", "from jira_hub import commands, db, mirror\n", "from jira_hub import commands, mirror\n") +sub("tests/test_commands.py", '''def _mirror(): + conn = db.connect(":memory:") + mirror.apply_state(conn, [''', '''@pytest.fixture +def conn(open_db): + """Зеркало в памяти: A-1 (Готово, Спринт 2, метки AWPCA и kind-dev) и A-2, связь A-1 → A-2; закрывается после теста.""" + c = open_db() + mirror.apply_state(c, [''') +sub("tests/test_commands.py", ''' ], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) + return conn +''', ''' ], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) + return c +''') +sub("tests/test_commands.py", '''def test_check_confirmed(cmd, expected): + ok, _ = commands.check_confirmed(_mirror(), cmd, LT)''', '''def test_check_confirmed(conn, cmd, expected): + ok, _ = commands.check_confirmed(conn, cmd, LT)''') + +# --- test_mirror.py -------------------------------------------------------------------------- +sub("tests/test_mirror.py", "from jira_hub import db, mirror\n\n\n", '''from jira_hub import db, mirror + + +@pytest.fixture +def conn(open_db): + """Пустое зеркало в памяти; закрывается после теста.""" + return open_db() + + +''') + +# --- test_importer.py ------------------------------------------------------------------------ +sub("tests/test_importer.py", "import csv\n\nfrom jira_hub import db, importer, mirror, settings\n", + "import csv\n\nimport pytest\n\nfrom jira_hub import importer, mirror, settings\n") +sub("tests/test_importer.py", "def write_csv(path, header, rows):", '''@pytest.fixture +def conn(open_db): + """База в памяти с настройками по умолчанию; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + return c + + +def write_csv(path, header, rows):''') + +# --- test_db.py ------------------------------------------------------------------------------ +sub("tests/test_db.py", '''def test_connect_creates_schema(): + conn = db.connect(":memory:")''', '''def test_connect_creates_schema(open_db): + conn = open_db()''') +sub("tests/test_db.py", ''' db.connect(path).close() + conn = db.connect(path) + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 +''', ''' db.connect(path).close() + conn = db.connect(path) + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + conn.close() +''') +for name in ("test_transaction_rolls_back_on_error", "test_nested_transaction_joins_outer", + "test_nested_transaction_error_propagates_to_outer", "test_nested_transaction_rollback_is_local"): + sub("tests/test_db.py", f'def {name}(tmp_path):\n conn = db.connect(tmp_path / "hub.sqlite")\n', + f'def {name}(tmp_path, open_db):\n conn = open_db(tmp_path / "hub.sqlite")\n') +print("готово") +EOF +``` + +Expected: печать словаря счётчиков (`test_queue.py: 33, test_settings.py: 4, test_commands.py: 4, test_mirror.py: 15, +test_importer.py: 6`) и `готово`. + +- [ ] **Step 5: Проверить, что хелперов и прямых соединений без закрытия не осталось** + +Run: `grep -n "fresh()\|_mirror()\|db.connect(\":memory:\")" /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2/tests/*.py` +Expected: пусто (оставшиеся `db.connect(` — в `test_db.py`/`test_settings.py`/`test_cli.py` с явным `.close()`). + +Глазами просмотреть `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 diff --stat` — меняются +только шесть файлов тестов и `pyproject.toml`; в `test_queue.py` у `test_backup_failure_does_not_block_claim(conn, caplog)`, +`test_backup_called_for_first_write_only(conn, tmp_path)`, `test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch)` +`conn` стоит первым параметром. + +- [ ] **Step 6: Весь набор зелёный, предупреждений нет** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider 2>&1 | tail -3` +Expected: `249 passed`, строки `warnings` нет. + +Если что-то упало с `PytestUnraisableExceptionWarning`, источник ищется так (тест в отчёте — не обязательно виновник): +`PYTHONTRACEMALLOC=25 uv run --directory … pytest -q -p no:cacheprovider -W always::ResourceWarning -W always::pytest.PytestUnraisableExceptionWarning 2>&1 | grep -A40 "Object allocated at" | grep "tests/"`. + +- [ ] **Step 7: Документация** + +`CLAUDE.md`, первый абзац: заменить + +``` +Спеки: `openspec/specs/`; текущего change нет (последние — `openspec/changes/archive/2026-10-15-web-app/` и `openspec/changes/archive/2026-10-15-agent-v9-hardening/`). +``` + +на + +``` +Спеки: `openspec/specs/`; текущий change — `openspec/changes/hub-hygiene-d/` (#32). +``` + +`CLAUDE.md`, раздел «Тесты»: после строки `- SQLite в памяти или во временном JIRA_HUB_HOME …` добавить строку + +``` +- Соединение SQLite в тесте — через фикстуру `open_db` (`tests/conftest.py`) или фикстуру `conn` модуля, иначе с явным `close()`: `pyproject.toml` (`filterwarnings`) делает незакрытое соединение ошибкой набора. +``` + +`ARCHITECTURE.md`, раздел «Открытые вопросы»: удалить строку + +``` +- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`). +``` + +- [ ] **Step 8: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 add pyproject.toml tests/conftest.py tests/test_queue.py tests/test_settings.py tests/test_commands.py tests/test_mirror.py tests/test_importer.py tests/test_db.py CLAUDE.md ARCHITECTURE.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 commit -m "test: соединения SQLite в тестах закрываются, утечка — ошибка набора (M-9/O-4 #4) (#32)" -m "Фикстура open_db в tests/conftest.py, фикстуры conn вместо хелперов fresh()/_mirror(); filterwarnings: error::ResourceWarning и error::pytest.PytestUnraisableExceptionWarning (решение 14, R-5)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Публикация агента — запись с fsync, откат, проверка манифеста (решения 1–6) + +**Модель исполнителя:** haiku (весь код готов, один модуль `src/` и три файла тестов). + +**Files:** +- Modify: `src/jira_hub/release.py` (`_write_atomic`, `publish`, `current`, `load`; новые `_undo`, `_manifest_problem`, + `_note_manifest`, `SHA256_RE`, `_warned`, `_warned_lock`) +- Test: `tests/test_release.py`, `tests/test_cli.py`, `tests/test_api.py` (дописать в конец) +- Modify: `README.md` («Самообновление агента»), `ARCHITECTURE.md` (строка `release`) + +**Interfaces:** +- Consumes: ничего из Task 1, кроме чистоты набора (новые тесты соединений сами не открывают, кроме фикстуры `hub`). +- Produces: + - `release.publish(release_dir, source) -> dict` — прежняя сигнатура; при `OSError` записи — `PublishError("не удалось записать публикацию: <ошибка>")`. + - `release.current(release_dir) -> dict | None` — `{"version": int, "sha256": str, "published_at": str | None}`; + негодный манифест → `None` и одно предупреждение `log.warning` на каждый новый текст ошибки. + - модульная переменная `release._warned: str | None` (тесты обнуляют её через `monkeypatch.setattr(release, "_warned", None)`). + - `release.load(release_dir) -> dict | None` — не-UTF-8 при совпавшем sha → `None`. + +- [ ] **Step 1: Написать падающие тесты `tests/test_release.py`** + +В шапке файла заменить + +```python +import json +import os +import stat +``` + +на + +```python +import hashlib +import json +import os +import stat +from pathlib import Path +``` + +и дописать в конец файла: + +```python + + +# --- hub-hygiene-d: публикация при сбое записи, проверка манифеста (решения 1–6) --------------------------------- + +def _fail_replace_into(monkeypatch, *names): + """os.replace в файл с одним из имён names падает «диск полон»; остальные переименования работают.""" + real = os.replace + + def fake(src, dst, *a, **kw): + if Path(dst).name in names: + raise OSError(28, "No space left on device") + return real(src, dst, *a, **kw) + + monkeypatch.setattr(release.os, "replace", fake) + + +def _listing(rd): + return {p.name: p.read_bytes() for p in rd.iterdir()} + + +def test_publish_success_leaves_no_prev(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 5)) + release.publish(rd, agent_file(tmp_path, 6)) + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert release.current(rd)["version"] == 6 + + +def test_manifest_write_failure_keeps_previous(tmp_path, monkeypatch): + """M-1 #25, спека «Диск полон при записи манифеста»: прежние v8 agent.py и манифест, ни .tmp, ни agent.py.prev.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + before = _listing(rd) + _fail_replace_into(monkeypatch, "manifest.json") + with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: .*No space left on device"): + release.publish(rd, agent_file(tmp_path, 9, body="print('v9')\n")) + monkeypatch.undo() + assert _listing(rd) == before + assert release.load(rd)["version"] == 8 + + +def test_agent_write_failure_keeps_previous(tmp_path, monkeypatch): + """M-1, M-6 #25: сбой fsync при записи agent.py — временный файл удалён, прежний agent.py на месте.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + before = _listing(rd) + + def no_space(fd): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(release.os, "fsync", no_space) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 9)) + monkeypatch.undo() + assert _listing(rd) == before + assert release.load(rd)["version"] == 8 + + +def test_first_publish_failure_leaves_nothing(tmp_path, monkeypatch): + """Review Focus 3: прежней публикации нет — после сбоя release/ пуст, а не с agent.py без манифеста.""" + rd = tmp_path / "release" + _fail_replace_into(monkeypatch, "manifest.json") + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 1)) + monkeypatch.undo() + assert list(rd.iterdir()) == [] and release.current(rd) is None + + +def test_stale_prev_removed_by_next_publish(tmp_path): + """Review Focus 1: agent.py.prev, оставленный упавшим процессом, убирает следующая успешная публикация.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + (rd / "agent.py").rename(rd / "agent.py.prev") # процесс упал между переименованием и записью + assert release.load(rd) is None + release.publish(rd, agent_file(tmp_path, 7)) + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert release.load(rd)["version"] == 7 + + +def test_restore_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog): + """Review Focus 2: и возврат agent.py.prev не удался — PublishError с текстом, причина возврата в логе.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + _fail_replace_into(monkeypatch, "manifest.json") + real = release.os.replace + + def fake(src, dst, *a, **kw): + if Path(src).name == "agent.py.prev": + raise OSError(13, "Permission denied") + return real(src, dst, *a, **kw) + + monkeypatch.setattr(release.os, "replace", fake) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 9)) + assert "прежняя публикация не восстановлена" in caplog.text + + +@pytest.mark.parametrize("manifest", [ + {"version": 6.9, "sha256": "a" * 64}, {"version": True, "sha256": "a" * 64}, {"version": "7", "sha256": "a" * 64}, + {"version": 0, "sha256": "a" * 64}, {"version": 6, "sha256": "a" * 63}, {"version": 6, "sha256": "A" * 64}, + {"version": 6}, [6, "a" * 64], None, +]) +def test_invalid_manifest_means_no_release(tmp_path, manifest): + """M-7 #25, решение 4: version — целое (не bool) ≥ 1, sha256 — 64 символа [0-9a-f], корень — объект.""" + rd = tmp_path / "release" + rd.mkdir() + (rd / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + assert release.current(rd) is None and release.load(rd) is None + + +def test_invalid_manifest_warns_once_per_new_error(tmp_path, monkeypatch, caplog): + """M-3 #25, решение 5: одно предупреждение на каждый новый текст ошибки; годный манифест сбрасывает память.""" + monkeypatch.setattr(release, "_warned", None) + rd = tmp_path / "release" + rd.mkdir() + manifest = rd / "manifest.json" + manifest.write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") + for _ in range(100): + assert release.current(rd) is None + warnings = lambda: [r for r in caplog.records if "манифест" in r.getMessage()] + assert len(warnings()) == 1 and "6.9" in warnings()[0].getMessage() + manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") # другая ошибка — ещё одно + release.current(rd) + release.current(rd) + assert len(warnings()) == 2 + manifest.write_text('{"version": 6, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") # годный сбрасывает память + assert release.current(rd)["version"] == 6 + manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") + release.current(rd) + assert len(warnings()) == 3 + + +def test_load_non_utf8_with_matching_sha_is_none(tmp_path, caplog): + """M-5 #25, решение 6: sha256 сошёлся, но файл не в UTF-8 — публикации нет, ошибка в логе.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + data = b"AGENT_VERSION = 6\n\xff\xfe\n" + (rd / "agent.py").write_bytes(data) + m = json.loads((rd / "manifest.json").read_text(encoding="utf-8")) + m["sha256"] = hashlib.sha256(data).hexdigest() + (rd / "manifest.json").write_text(json.dumps(m), encoding="utf-8") + assert release.current(rd)["version"] == 6 + assert release.load(rd) is None + assert "agent.py не в UTF-8" in caplog.text +``` + +- [ ] **Step 2: Написать падающие тесты `tests/test_cli.py` и `tests/test_api.py`** + +`tests/test_cli.py`: в шапке заменить + +```python +import json +from pathlib import Path + +from jira_hub import cli, db, paths, settings +``` + +на + +```python +import json +import os +from pathlib import Path + +from jira_hub import cli, db, paths, release, settings +``` + +и дописать в конец: + +```python + + +def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): + """Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25).""" + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + src = tmp_path / "agent.py" + src.write_text("AGENT_VERSION = 8\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 0 + capsys.readouterr() + real = os.replace + + def fake(s, d, *a, **kw): + if Path(d).name == "manifest.json": + raise OSError(28, "No space left on device") + return real(s, d, *a, **kw) + + monkeypatch.setattr(os, "replace", fake) + src.write_text("AGENT_VERSION = 9\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 1 + err = capsys.readouterr().err + assert err.startswith("не удалось записать публикацию: ") and "Traceback" not in err + monkeypatch.setattr(os, "replace", real) + assert release.load(paths.release_dir())["version"] == 8 + assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"] +``` + +`tests/test_api.py` — дописать в конец файла (шапку не трогать: `json`, `pytest`, `release`, `HubError` уже импортированы): + +```python + + +# --- hub-hygiene-d --------------------------------------------------------------------------------------------- + +def test_invalid_manifest_one_warning_for_many_claims(hub, monkeypatch, caplog): + """M-3/M-7 #25, спека «Негодный манифест»: version = 6.9 — публикации нет; 10 claim и GET /agent — одно предупреждение.""" + monkeypatch.setattr(release, "_warned", None) + rd = hub["hub"].release_dir + rd.mkdir() + (rd / "manifest.json").write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") + for _ in range(10): + assert hub["agent"].post("/agent/claim", {"agent_version": 5})["agent_release"] is None + assert hub["client"].get("/agent")["release"] is None + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" + assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1 + + +def test_agent_release_non_utf8_is_404(hub, tmp_path): + """M-5 #25: agent.py не в UTF-8 при совпавшем sha256 — 404 no_release, а не 500.""" + import hashlib + _publish(hub, tmp_path, 6) + rd = hub["hub"].release_dir + data = b"AGENT_VERSION = 6\n\xff\n" + (rd / "agent.py").write_bytes(data) + m = json.loads((rd / "manifest.json").read_text(encoding="utf-8")) + (rd / "manifest.json").write_text(json.dumps({**m, "sha256": hashlib.sha256(data).hexdigest()}), encoding="utf-8") + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" +``` + +- [ ] **Step 3: Убедиться, что новые тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider tests/test_release.py tests/test_cli.py tests/test_api.py -k "failure or prev or manifest or utf8 or non_utf8" 2>&1 | tail -20` +Expected: FAIL — 16 тестов (13 в `test_release.py`: `OSError` вместо `PublishError`, `AttributeError: … _warned`, +`UnicodeDecodeError`, остаток `agent.py.prev`; `test_agent_publish_write_failure` — `OSError`; в `test_api.py` +`assert 500 == 404` и предупреждений больше одного). Уже сейчас проходят `test_publish_success_leaves_no_prev` +(фиксация сценария) и три случая `test_invalid_manifest_means_no_release` (`{"version": 6}`, список, `null`) — их +прежний код тоже отвергал; итог прогона — `16 failed, 8 passed`. + +- [ ] **Step 4: Реализация в `src/jira_hub/release.py`** + +Docstring модуля и импорты: заменить + +```python +"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. +Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5.""" +import hashlib +import json +import logging +import os +import re +from datetime import datetime, timezone +``` + +на + +```python +"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. +Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7.""" +import hashlib +import json +import logging +import os +import re +import threading +from datetime import datetime, timezone +``` + +После строки `VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)` добавить: + +```python +SHA256_RE = re.compile(r"[0-9a-f]{64}") + +_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5) +_warned_lock = threading.Lock() +``` + +Заменить `_write_atomic` целиком: + +```python +def _write_atomic(path: Path, data: bytes) -> None: + """Файл целиком или никак: .<имя>.tmp в том же каталоге, write + flush + fsync, переименование (решение 1). + При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается.""" + tmp = path.with_name("." + path.name + ".tmp") + try: + with open(tmp, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + raise +``` + +В `publish` заменить docstring + +```python + """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" +``` + +на + +```python + """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной). + Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2): + опубликованной остаётся прежняя версия, наружу — только PublishError.""" +``` + +и хвост `publish` вместе с `current` — от строки ` secure_dir(rd)` до конца `current` — заменить на: + +```python + manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), + "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} + agent, prev_file = rd / "agent.py", rd / "agent.py.prev" + moved = False + try: + secure_dir(rd) + if agent.exists(): + os.replace(agent, prev_file) + moved = True + _write_atomic(agent, data) + _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) + except OSError as e: + _undo(agent, prev_file, moved) + raise PublishError(f"не удалось записать публикацию: {e}") from e + try: + prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design) + except OSError as e: + log.warning("release: %s не удалён: %s", prev_file, e) + return manifest + + +def _undo(agent: Path, prev_file: Path, moved: bool) -> None: + """Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было — + убрать записанный agent.py. Сбой возврата — в лог: команда всё равно завершается сообщением.""" + try: + if moved: + os.replace(prev_file, agent) + else: + agent.unlink(missing_ok=True) + except OSError as e: + log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e) + + +def _manifest_problem(m) -> str | None: + """Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9).""" + if not isinstance(m, dict): + return "не объект" + version = m.get("version") + if not isinstance(version, int) or isinstance(version, bool) or version < 1: + return f"version — не целое ≥ 1: {version!r}" + sha = m.get("sha256") + if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha): + return "sha256 — не 64 символа [0-9a-f]" + return None + + +def _note_manifest(problem: str | None, path: Path) -> None: + """Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест + сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков.""" + global _warned + text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет" + with _warned_lock: + if text is not None and text != _warned: + log.warning("%s", text) + _warned = text + + +def current(release_dir) -> dict | None: + """Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять).""" + path = Path(release_dir) / "manifest.json" + try: + m = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, NotADirectoryError): + _note_manifest(None, path) + return None + except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает + _note_manifest(f"не читается ({e})", path) + return None + problem = _manifest_problem(m) + _note_manifest(problem, path) + if problem: + return None + return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")} +``` + +(Заменяемый диапазон начинается строкой ` secure_dir(rd)` — она переезжает внутрь `try` — и включает прежние +строки `manifest = {…}`, оба вызова `_write_atomic`, `return manifest` и всю функцию `current`.) + +В `load` заменить конец + +```python + return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")} +``` + +на + +```python + try: + content = data.decode("utf-8") + except UnicodeDecodeError: # решение 6: 404, а не 500 + log.error("release: agent.py не в UTF-8 — публикации нет") + return None + return {"version": m["version"], "sha256": m["sha256"], "content": content} +``` + +- [ ] **Step 5: Тесты проходят** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider tests/test_release.py tests/test_cli.py tests/test_api.py 2>&1 | tail -3` +Expected: PASS, все. + +- [ ] **Step 6: Документация** + +`README.md`, раздел «Самообновление агента»: после строки `Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером.` добавить + +``` +Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и +выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб +не может разобрать (`version` не целое, `sha256` не 64 hex-символа), означает «публикации нет» — одно предупреждение в +логе хаба на каждую новую ошибку. +``` + +`ARCHITECTURE.md`, таблица модулей: строку + +``` +| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` | +``` + +заменить на + +``` +| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | +``` + +- [ ] **Step 7: Весь набор** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider 2>&1 | tail -3` +Expected: PASS (249 + 20 = 269 passed), предупреждений нет. + +- [ ] **Step 8: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 add src/jira_hub/release.py tests/test_release.py tests/test_cli.py tests/test_api.py README.md ARCHITECTURE.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 commit -m "fix(release): публикация с fsync и откатом через agent.py.prev, проверка манифеста (M-1, M-3, M-5, M-6, M-7 #25) (#32)" -m "Сбой записи — «не удалось записать публикацию: …», код 1, прежняя версия остаётся; манифест годен при целой version ≥ 1 и sha256 из 64 hex; предупреждение о негодном — одно на ошибку; не-UTF-8 agent.py — 404 (решения 1–6)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Статус агента — `agent_reported`, запускатель «неизвестно» (решения 7–8, R-1) + +**Модель исполнителя:** sonnet (интерфейс `release.hint` меняется и используется в `api`; правка трёх модулей). + +**Files:** +- Modify: `src/jira_hub/api.py` (`Hub.__init__`, `Hub.note_agent_update`, `h_agent_get`) +- Modify: `src/jira_hub/release.py` (`hint`) +- Modify: `src/jira_hub/mcp_server.py` (`fmt_agent`) +- Test: `tests/test_api.py` (два новых теста в конец; дополнить `test_hint_without_launcher`, `test_agent_view_without_release`), + `tests/test_release.py`, `tests/test_mcp.py` +- Modify: `README.md` (таблица HTTP API, «Самообновление агента»), `ARCHITECTURE.md` («Потоки данных») + +**Interfaces:** +- Consumes: `release.current` из Task 2 (без изменений сигнатуры), фикстура `hub` в `test_api.py`, `live` в `test_mcp.py`. +- Produces: + - `Hub.agent_update: dict` — ключи `update_state`, `launcher_version`, `reported` (`False` до первого claim). + - `release.hint(rel, agent, update_state, launcher_version, reported=True) -> str | None`. + - `GET /agent` — новое поле `agent_reported: bool`. + - `mcp_server.fmt_agent(a)` — при `a.get("agent_reported") is False` строка «запускатель: неизвестно (агент ещё не + выходил на связь после перезапуска хаба)». + +- [ ] **Step 1: Написать падающие тесты** + +`tests/test_api.py`: в `test_hint_without_launcher` после строки + +```python + assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную" +``` + +добавить + +```python + assert v["agent_reported"] is True +``` + +в `test_agent_view_without_release` после строки + +```python + assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None +``` + +добавить + +```python + assert v["agent_reported"] is False +``` + +и дописать в конец файла: + +```python + + +def test_agent_reported_after_hub_restart(hub, tmp_path): + """M-2 #25, спека «Хаб перезапущен, агент ещё не выходил на связь»: в базе агент онлайн, claim с запуска не было.""" + _publish(hub, tmp_path, 10) + conn = hub["hub"].conn() + conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE id = 1", (db.now(),)) + conn.close() + v = hub["client"].get("/agent") + assert v["online"] is True and v["agent_reported"] is False + assert v["launcher_version"] is None and v["update_hint"] is None + hub["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) + v = hub["client"].get("/agent") + assert v["agent_reported"] is True and v["launcher_version"] == 2 and v["update_hint"] is None + + +def test_agent_reported_even_with_bad_update_fields(hub, tmp_path): + """Review Focus 5: claim с полями неверного типа — claim всё равно был: agent_reported = true.""" + _publish(hub, tmp_path, 6) + hub["agent"].post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"}) + v = hub["client"].get("/agent") + assert v["agent_reported"] is True and v["launcher_version"] is None + assert v["update_hint"] == "агент запущен без run.py — обновится только вручную" +``` + +`tests/test_release.py` — дописать в конец: + +```python + + +def test_hint_unknown_launcher_until_reported(): + """M-2 #25, решение 7: пока после запуска хаба не было claim, «без run.py» не утверждается; откат — по update_state.""" + rel = {"version": 10, "sha256": "s", "published_at": "t"} + online9 = {"agent_version": "9", "online": True} + assert release.hint(rel, online9, None, None, reported=False) is None + assert release.hint(rel, online9, None, None, reported=True) == "агент запущен без run.py — обновится только вручную" + rb = {"state": "rolled_back", "from_version": 9, "to_version": 10, "error": "код 1"} + assert release.hint(rel, online9, rb, None, reported=False) == "откат с v10 на v9: код 1" +``` + +`tests/test_mcp.py` — дописать в конец: + +```python + + +def test_fmt_agent_launcher_unknown_after_restart(): + """M-2 #25, решение 8: agent_reported = false — «неизвестно», а не «без запускателя»; поля нет — прежний текст.""" + base = {"online": True, "agent_version": "9", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], + "warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3, + "release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"}, + "update_state": None, "launcher_version": None, "update_hint": None} + text = mcp_server.fmt_agent({**base, "agent_reported": False}) + assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text + assert "без запускателя" not in text and "агент запущен без run.py" not in text + assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True}) + assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет + + +def test_agent_status_right_after_hub_start(live): + """Спека mcp «Статус агента сразу после перезапуска хаба».""" + assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status() + live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) + text = mcp_server.agent_status() + assert "запускатель: v2" in text and "неизвестно" not in text +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider tests/test_api.py tests/test_release.py tests/test_mcp.py -k "reported or unknown or right_after or hint_without or without_release" 2>&1 | tail -10` +Expected: FAIL — 7 тестов (`KeyError: 'agent_reported'`, `TypeError: hint() got an unexpected keyword argument 'reported'`, +нет строки «неизвестно»). + +- [ ] **Step 3: Реализация** + +`src/jira_hub/api.py`, `Hub.__init__`: заменить строку + +```python + self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3) +``` + +на + +```python + # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version + # неизвестен, а не null (hub-hygiene-d, решение 7) + self.agent_update = {"update_state": None, "launcher_version": None, "reported": False} +``` + +`Hub.note_agent_update`: заменить + +```python + Значение неверного типа игнорируется — claim из-за него не отвергается.""" + us = body.get("update_state") +``` + +на + +```python + Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported).""" + self.agent_update["reported"] = True + us = body.get("update_state") +``` + +`h_agent_get`: заменить + +```python + us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"] + view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv)) +``` + +на + +```python + au = hub.agent_update + us, lv, reported = au["update_state"], au["launcher_version"], au["reported"] + view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported, + update_hint=release.hint(rel, view, us, lv, reported=reported)) +``` + +`src/jira_hub/release.py`, `hint`: заменить начало функции + +```python +def hint(rel, agent, update_state, launcher_version) -> str | None: + """Подсказка для agent_status (design решение 5).""" + try: + agent_version = int(agent.get("agent_version") or 0) + except (TypeError, ValueError): + agent_version = 0 + if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None: +``` + +на + +```python +def hint(rel, agent, update_state, launcher_version, reported=True) -> str | None: + """Подсказка для agent_status (design agent-self-update решение 5). reported=False — после запуска хаба claim + ещё не было: launcher_version неизвестен, подсказки «без run.py» нет (hub-hygiene-d, решение 7).""" + try: + agent_version = int(agent.get("agent_version") or 0) + except (TypeError, ValueError): + agent_version = 0 + if (reported and rel and agent.get("online") and agent_version and rel["version"] > agent_version + and launcher_version is None): +``` + +(остальное тело `hint` — без изменений). + +`src/jira_hub/mcp_server.py`, `fmt_agent`: заменить + +```python + lv = a.get("launcher_version") + out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") +``` + +на + +```python + lv = a.get("launcher_version") + if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт + out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)") + else: + out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") +``` + +- [ ] **Step 4: Тесты проходят** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider tests/test_api.py tests/test_release.py tests/test_mcp.py 2>&1 | tail -3` +Expected: PASS, все. + +- [ ] **Step 5: Документация** + +`README.md`, таблица HTTP API: строку + +``` +| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `update_hint`), журнал загрузок | +``` + +заменить на + +``` +| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | +``` + +`README.md`, «Самообновление агента», после абзаца про «не удалось записать публикацию» (Task 2) добавить: + +``` +Состояние обновления и версию запускателя агент сообщает в каждом `claim`, хаб держит их в памяти: сразу после +перезапуска хаба, пока агент не вышел на связь (до 30 с), `agent_status` пишет «запускатель: неизвестно (агент ещё не +выходил на связь после перезапуска хаба)», а подсказки «агент запущен без run.py» нет. +``` + +`ARCHITECTURE.md`, «Потоки данных»: в строке «Самообновление агента: …» заменить + +``` +состояние обновления агент шлёт в `claim`, хаб держит в памяти и отдаёт в `GET /agent`. +``` + +на + +``` +состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. +``` + +- [ ] **Step 6: Весь набор** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider 2>&1 | tail -3` +Expected: PASS (269 + 5 = 274 passed), предупреждений нет. + +- [ ] **Step 7: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 add src/jira_hub/api.py src/jira_hub/release.py src/jira_hub/mcp_server.py tests/test_api.py tests/test_release.py tests/test_mcp.py README.md ARCHITECTURE.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 commit -m "fix(agent): agent_reported — до первого claim запускатель неизвестен, подсказки «без run.py» нет (M-2 #25) (#32)" -m "GET /agent отдаёт agent_reported; release.hint(reported=); MCP agent_status: «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (решения 7–8, R-1)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Ответ `PUT /settings`, текст «отменено», CRLF в подтверждении, реле по `resolve()`, `_type_error` (решения 9–12, 15) + +**Модель исполнителя:** haiku (четыре независимые точечные правки, весь код готов). + +**Files:** +- Modify: `src/jira_hub/api.py` (`h_settings_put`) +- Modify: `src/jira_hub/queue.py` (новая `_skip_reason`, `_next`) +- Modify: `src/jira_hub/commands.py` (`_type_error`, новые `_is_str_list`, `_norm_text`; `check_confirmed`) +- Modify: `src/jira_hub/relay.py` (`Relay.__init__` — комментарий, `_prepare`, новая `_same_dir_key`) +- Test: `tests/test_api.py` (в конец), `tests/test_queue.py`, `tests/test_commands.py`, `tests/test_relay.py` +- Modify: `README.md` (таблица HTTP API) + +**Interfaces:** +- Consumes: фикстуры `conn` из Task 1 (`test_queue.py` — с `link_types = LT`; `test_commands.py` — зеркало A-1/A-2), + `env` в `test_relay.py`, `hub` в `test_api.py`. +- Produces: + - `PUT /settings` → `200, settings.get_all(conn)`. + - `queue._skip_reason(conn, batch_id, unresolved: list[str]) -> str`. + - `commands._norm_text(value)` (CRLF → LF, пустое → `None`), `commands._is_str_list(value) -> bool`; + `commands._type_error(ctype, name, value) -> str | None` — тексты прежние. + - `relay._same_dir_key(path: Path) -> Path`. + +- [ ] **Step 1: Написать падающие тесты** + +`tests/test_api.py` — дописать в конец: + +```python + + +def test_settings_put_returns_all_settings(hub): + """O-11 #16, решение 9, спека «Ответ на запись настроек»: все настройки после записи, как GET /settings, без токенов.""" + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + out = c.put("/settings", {"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"}) + assert out["relay_dir"] == "/Volumes/externalSSD/utm/rshb/jira-bridge" and out["settings_version"] == v0 + 1 + assert "tokens" not in out and out["base_jql"] == "project = AURORA AND labels = AWPCA" + assert out == c.get("/settings") + + +def test_settings_put_empty_body_returns_settings_unchanged(hub): + """Пустое тело — 200, все настройки, версия не меняется.""" + c = hub["client"] + before = c.get("/settings") + assert c.put("/settings", {}) == before +``` + +`tests/test_queue.py` — дописать в конец: + +```python + + +def test_dependent_skipped_when_creation_cancelled(conn): + """O-10 #16, решение 10, спека «Создание отменено»: зависимая команда — «создание задачи new:N отменено».""" + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:901", "issue_type": "task", "summary": "Отменённая"}}, + {"type": "ensure_comment", "params": {"key": "new:901", "marker": "m", "body": "b"}}]) + c = queue.claim(conn) + queue.cancel_command(conn, c["id"], force=True) + assert queue.claim(conn) is None # комментарий и хвостовой refresh пропущены + cmds = queue.batch_view(conn, b["id"])["commands"] + assert [x["status"] for x in cmds] == ["cancelled", "skipped", "skipped"] + assert cmds[1]["error"] == "создание задачи new:901 отменено" + + +def test_dependent_on_failed_and_cancelled_names_cancelled(conn): + """Review Focus 4: два временных ключа — создатель первого failed, второго cancelled: текст по отменённому.""" + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Упавшая"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Отменённая"}}, + {"type": "ensure_link", "params": {"source": "new:1", "target": "new:2", "link_type": "связанные"}}]) + first = queue.claim(conn) + queue.report_result(conn, first["id"], "failed", error="HTTP 400") + second = queue.claim(conn) + queue.cancel_command(conn, second["id"], force=True) + queue.claim(conn) + link = queue.batch_view(conn, b["id"])["commands"][2] + assert link["status"] == "skipped" and link["error"] == "создание задачи new:2 отменено" + + +def test_reconcile_confirms_fields_with_crlf(conn): + """O-9 #16, решение 11, спека «Описание с CRLF в зеркале»: потерянный отчёт закрывается по состоянию.""" + queue.create_batch(conn, "x", "claude", [{"type": "ensure_fields", "params": {"key": "A-1", "description": "a\nb"}}]) + c = queue.claim(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], []) + assert queue.reconcile(conn) == [c["id"]] + assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" +``` + +(Сценарий «Создание упало» — «задача new:N не создана» при `failed` — уже проверяет `test_temp_key_substitution_and_skip`; +он не меняется и должен остаться зелёным, R-4.) + +`tests/test_commands.py` — дописать в конец: + +```python + + +@pytest.mark.parametrize("mirror_value, param, expected", [ + ("a\r\nb", "a\nb", True), ("a\nb", "a\r\nb", True), ("a\r\nb\r\n", "a\nb\n", True), + ("a\r\nb", "a b", False), (None, "", True), ("", None, True), ("a\rb", "a\nb", False), +]) +def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expected): + """O-9 #16, решение 11: CRLF и LF не различаются, null и пустая строка равны; одиночный CR — другой текст. + summary с CRLF в зеркале и LF в команде — в каждом случае: сравнение одно для всех строковых полей.""" + conn.execute("UPDATE issue SET description = ?, summary = ? WHERE key = 'A-1'", (mirror_value, "x\r\ny")) + ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields", + "params": {"key": "A-1", "description": param, "summary": "x\ny"}}, LT) + assert ok is expected + + +@pytest.mark.parametrize("ctype, name, value, msg", [ + ("ensure_fields", "duedate", True, "duedate — строка"), + ("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"), + ("ensure_fields", "labels_add", ["x", 1], "labels_add — список строк"), + ("ensure_link", "replace", "да", "replace — true или false"), + ("ensure_status", "target", 5, "target — строка или список строк"), + ("ensure_status", "target", ["Разработка", "Готово"], None), + ("ensure_status", "target", "Готово", None), + ("ensure_fields", "labels_add", [], None), + ("ensure_fields", "assignee", None, None), + ("ensure_link", "replace", False, None), +]) +def test_type_error_messages(ctype, name, value, msg): + """T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один).""" + assert commands._type_error(ctype, name, value) == msg +``` + +`tests/test_relay.py` — дописать в конец: + +```python + + +def test_symlink_to_same_dir_not_cleaned_again(env, tmp_path, capsys): + """minor-1 #16, решение 12: relay_dir сменили на симлинк к той же папке — work/ не чистится второй раз.""" + r, root, h = env["relay"], env["root"], env["hub"] + r.step() # root вычищен + link = tmp_path / "bridge-link" + link.symlink_to(root, target_is_directory=True) + (root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос ещё обрабатывается + conn = h.conn() + settings.set_(conn, "relay_dir", str(link)) + conn.close() + r._settings_at = None + capsys.readouterr() + r.step() + out = capsys.readouterr().out + assert (root / "work" / "busy.json").exists() + assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider tests/test_api.py tests/test_queue.py tests/test_commands.py tests/test_relay.py -k "settings_put_returns or empty_body or dependent or crlf or type_error or symlink" 2>&1 | tail -15` +Expected: FAIL — 11 (`KeyError: 'relay_dir'`, «задача new:901 не создана», не подтверждено по CRLF, `busy.json` удалён). +`test_type_error_messages` (10 случаев) проходит уже сейчас: это фиксация текстов перед рефакторингом T3.1. + +- [ ] **Step 3: Реализация** + +`src/jira_hub/api.py`, `h_settings_put`: docstring заменить + +```python + """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения + (settings.validate): ошибка — 422, версия настроек не меняется.""" +``` + +на + +```python + """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения + (settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у + GET /settings (hub-hygiene-d, решение 9).""" +``` + +и хвост + +```python + version = settings.get(conn, "settings_version") + with db.transaction(conn): + for key, value in body.items(): + version = settings.set_(conn, key, value) + return 200, {"settings_version": version} +``` + +на + +```python + with db.transaction(conn): + for key, value in body.items(): + settings.set_(conn, key, value) + return 200, settings.get_all(conn) +``` + +`src/jira_hub/queue.py`: перед `def _next(conn) -> dict | None:` вставить + +```python +def _skip_reason(conn, batch_id, unresolved) -> str: + """Текст skipped для зависимой команды: отменённое создание называется отменой (O-10 #16, решение 10).""" + cancelled = [k for k in unresolved if _definer_status(conn, batch_id, k) == "cancelled"] + return f"создание задачи {cancelled[0]} отменено" if cancelled else f"задача {unresolved[0]} не создана" + + +``` + +и в `_next` заменить + +```python + (db.now(), f"задача {unresolved[0]} не создана", cmd["id"])) +``` + +на + +```python + (db.now(), _skip_reason(conn, cmd["batch_id"], unresolved), cmd["id"])) +``` + +`src/jira_hub/commands.py`, `_type_error` (T3.1): тело после docstring + +```python + if value is None: + return None + if name in LIST_PARAMS: + ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк" + elif name in BOOL_PARAMS: + ok, want = isinstance(value, bool), "true или false" + elif ctype == "ensure_status" and name == "target": + ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value)) + want = "строка или список строк" + else: + ok, want = isinstance(value, str), "строка" + return None if ok else f"{name} — {want}" +``` + +заменить на + +```python + if value is None: + return None + if name in LIST_PARAMS: + return None if _is_str_list(value) else f"{name} — список строк" + if name in BOOL_PARAMS: + return None if isinstance(value, bool) else f"{name} — true или false" + if ctype == "ensure_status" and name == "target": + return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк" + return None if isinstance(value, str) else f"{name} — строка" + + +def _is_str_list(value) -> bool: + return isinstance(value, list) and all(isinstance(v, str) for v in value) +``` + +Перед `def check_confirmed(` вставить + +```python +def _norm_text(value): + """Значение поля для сравнения с зеркалом: переводы строки CRLF и LF не различаются (описание, правленное в UI Jira, + приходит с CRLF, O-9 #16); null и пустая строка равны (решение 11).""" + if isinstance(value, str): + value = value.replace("\r\n", "\n") + return value or None + + +``` + +и в ветке `ensure_fields` заменить + +```python + if field in p and (issue.get(field) or None) != (p[field] or None): +``` + +на + +```python + if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]): +``` + +`src/jira_hub/relay.py`: в `Relay.__init__` комментарий + +```python + self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле +``` + +заменить на + +```python + self._cleaned: set[Path] = set() # папки (по resolve()), чей work/ вычищен после старта реле +``` + +в `_prepare` заменить + +```python + if self.root not in self._cleaned: + # первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает + for stale in (self.root / "work").iterdir(): + stale.unlink(missing_ok=True) + say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)") + self._cleaned.add(self.root) +``` + +на + +```python + key = _same_dir_key(self.root) + if key not in self._cleaned: + # первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает + for stale in (self.root / "work").iterdir(): + stale.unlink(missing_ok=True) + say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)") + self._cleaned.add(key) +``` + +и перед `def _error(error: str, detail: str) -> str:` вставить + +```python +def _same_dir_key(path: Path) -> Path: + """Ключ папки для «уже вычищена»: resolve() — симлинк и другая запись той же папки совпадают (minor-1 #16, + решение 12); ошибка resolve — путь как записан.""" + try: + return path.resolve() + except (OSError, RuntimeError): + return path + + +``` + +- [ ] **Step 4: Тесты проходят** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider tests/test_api.py tests/test_queue.py tests/test_commands.py tests/test_relay.py 2>&1 | tail -3` +Expected: PASS, все (в том числе прежние `test_temp_key_substitution_and_skip`, `test_relay_dir_put_validation`, +`test_settings_put_valid_values_pass`, `test_check_confirmed`). + +- [ ] **Step 5: Документация** + +`README.md`, таблица HTTP API: строку + +``` +| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) | +``` + +заменить на + +``` +| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) | +``` + +- [ ] **Step 6: Весь набор** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider 2>&1 | tail -3` +Expected: PASS (274 + 23 = 297 passed), предупреждений нет. + +- [ ] **Step 7: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 add src/jira_hub/api.py src/jira_hub/queue.py src/jira_hub/commands.py src/jira_hub/relay.py tests/test_api.py tests/test_queue.py tests/test_commands.py tests/test_relay.py README.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 commit -m "fix: ответ PUT /settings, «создание задачи отменено», CRLF в подтверждении, реле по resolve() (O-11, O-10, O-9, minor-1, T3.1 #16) (#32)" -m "PUT /settings отвечает всеми настройками (R-3); пропуск при cancelled-создателе — «создание задачи new:N отменено» (R-4); ensure_fields сравнивает строки без различия CRLF/LF; _cleaned по resolve(); _type_error по веткам (решения 9–12, 15)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Недостающие тесты (решение 13: T3.2, T5.2, M-8, minor-3 #16; M-10 #4) + +**Модель исполнителя:** haiku (только тесты, код готов; `src/` не меняется). + +Тесты этой задачи фиксируют уже правильное поведение — они проходят сразу. Шаг «красный» заменён проверкой, что каждый +тест ловит поломку (мутация в Step 3). + +**Files:** +- Test: `tests/test_commands.py`, `tests/test_queue.py`, `tests/test_api.py` (новые — в конец; дополнить + `test_tick_survives_connection_failure`), `tests/test_importer.py`, `tests/test_relay.py` (дополнить + `test_vanished_tree_keeps_work`) + +**Interfaces:** +- Consumes: фикстуры `conn` (Task 1) в `test_queue.py`, `test_importer.py`; `hub` в `test_api.py`; `env` в `test_relay.py`; + `queue.ONLINE_MINUTES`, `queue.MAX_DELIVERIES`, `db.utcnow`, `db.now`. +- Produces: — + +- [ ] **Step 1: Написать тесты** + +`tests/test_commands.py` — дописать в конец: + +```python + + +@pytest.mark.parametrize("cmd, name", [ + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": True}}, "duedate"), + ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": False}}, "body"), + ({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": True}}, "summary"), +]) +def test_validate_rejects_bool_in_string_param(cmd, name): + """T3.2 #16: true/false в строковом параметре — 422 с именем параметра, а не запись «True» в Jira.""" + with pytest.raises(CommandError, match=f"{name} — строка"): + commands.validate([cmd]) + + +def test_validate_empty_list_params(): + """T3.2 #16: [] в необязательном списке допустим; [] в обязательном или единственном — как отсутствие.""" + out = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": [], "labels_remove": []}}]) + assert out[0]["params"] == {"key": "A-1", "labels_add": [], "labels_remove": []} + with pytest.raises(CommandError, match="нет target"): + commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "target": []}}]) + with pytest.raises(CommandError, match="refresh без keys и jql"): + commands.validate([{"type": "refresh", "params": {"keys": []}}]) +``` + +`tests/test_queue.py` — дописать в конец: + +```python + + +def test_delivery_limit_of_write_command_keeps_open_sync_run(conn): + """T5.2 #16: предел выдач у не-refresh команды не закрывает журнал чужой загрузки (finished_at — только для refresh).""" + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + c = queue.claim(conn) + r = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-9"]}}]) + rid = queue.batch_view(conn, r["id"])["commands"][0]["id"] + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2099-01-01T00:00:00+00:00', deliveries = 1 " + "WHERE id = ?", (rid,)) + conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, 'keys', 'A-9', ?)", (db.now(), rid)) + for _ in range(queue.MAX_DELIVERIES - 1): + assert queue.claim(conn)["id"] == c["id"] + assert queue.claim(conn)["id"] == rid # c — failed по пределу, довыдаётся refresh + assert queue.get_command(conn, c["id"])["status"] == "failed" + assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None +``` + +`tests/test_api.py`: в шапке заменить + +```python +import urllib.request + +import pytest + +from jira_hub import api, db, release, settings +``` + +на + +```python +import urllib.request +from datetime import timedelta + +import pytest + +from jira_hub import api, db, queue, release, settings +``` + +`test_tick_survives_connection_failure` заменить целиком на + +```python +def test_tick_survives_connection_failure(hub, monkeypatch, caplog): + def broken(): + raise OSError("диск недоступен") + monkeypatch.setattr(hub["hub"], "conn", broken) + hub["hub"].tick() # не бросает + assert "автообновление: сбой тика" in caplog.text + monkeypatch.undo() + hub["hub"].tick() + assert hub["client"].get("/batches")["batches"] == [] # агент не на связи — автообновления нет +``` + +и дописать в конец файла: + +```python + + +@pytest.mark.parametrize("method", ["DELETE", "PATCH"]) +def test_other_methods_are_501_not_json(hub, method): + """M-8 #16, спека «Неподдерживаемый метод»: 501 стандартной страницей http.server, не JSON; база не меняется.""" + import http.client + hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", + "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + host, port = hub["base"].removeprefix("http://").split(":") + conn = http.client.HTTPConnection(host, int(port), timeout=10) + try: + conn.request(method, "/batches/1", headers={"Authorization": "Bearer " + hub["client"].token}) + r = conn.getresponse() + r.read() + assert r.status == 501 and not (r.getheader("Content-Type") or "").startswith("application/json") + finally: + conn.close() + assert hub["client"].get("/batches/1")["status"] == "queued" + + +def test_agent_warning_and_offline(hub): + """M-10 #4: «обновите агента» при agent_version < min_agent_version; нет контакта дольше порога — офлайн.""" + c = hub["client"] + c.put("/settings", {"min_agent_version": 3}) + hub["agent"].post("/agent/claim", {"agent_version": 2}) + v = c.get("/agent") + assert v["online"] is True and v["warning"] == "обновите агента" + hub["agent"].post("/agent/claim", {"agent_version": 3}) + assert c.get("/agent")["warning"] is None + old = (db.utcnow() - timedelta(minutes=queue.ONLINE_MINUTES + 1)).isoformat() + conn = hub["hub"].conn() + conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE id = 1", (old,)) + conn.close() + assert c.get("/agent")["online"] is False + + +def test_tick_creates_auto_refresh_once(hub): + """M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята).""" + hub["agent"].post("/agent/claim", {"agent_version": 9}) + hub["hub"].tick() + batches = hub["client"].get("/batches", limit=5)["batches"] + assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub" + hub["hub"].tick() + assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1 +``` + +`tests/test_importer.py` — дописать в конец: + +```python + + +def test_epic_without_key_is_skipped(tmp_path, conn): + """M-8 #16 (T7.3 #4), спека migration «Эпик без ключа»: строка без «Ключ» пропущена, заметки new:* нет.""" + write_csv(tmp_path / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"], + [["NEW-2", "", "Без ключа", "ещё не создан"], ["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"]]) + rep = importer.import_package(conn, tmp_path) + assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1350"] + assert [r["key"] for r in conn.execute("SELECT key FROM issue_note ORDER BY key")] == ["AURORA-1350"] +``` + +`tests/test_relay.py`, `test_vanished_tree_keeps_work`: заменить последние строки + +```python + assert (root / "work" / "busy.json").exists() and (root / "req").is_dir() + assert "удалён незавершённый запрос" not in capsys.readouterr().out +``` + +на + +```python + assert (root / "work" / "busy.json").exists() and (root / "req").is_dir() + out = capsys.readouterr().out + assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out # minor-3 #16 +``` + +- [ ] **Step 2: Тесты проходят** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider -k "bool_in_string or empty_list_params or keeps_open_sync_run or not_json or warning_and_offline or tick or epic_without or vanished_tree" 2>&1 | tail -3` +Expected: PASS — 12 (`test_tick_*` — два: дополненный `test_tick_survives_connection_failure` и новый `test_tick_creates_auto_refresh_once`). + +- [ ] **Step 3: Проверить, что тесты ловят поломку (мутации, затем откат)** + +Каждую мутацию внести, запустить указанный тест, убедиться в FAIL, вернуть файл командой +`git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 checkout -- <файл>`: +- `src/jira_hub/commands.py`: в `_type_error` последнюю строку заменить на `return None` → + `-k bool_in_string` падает; +- `src/jira_hub/queue.py`, `_redeliver`: строку `if cmd["type"] == "refresh":` (перед `UPDATE sync_run SET finished_at`) + заменить на `if True:`, а `WHERE command_id = ?", (now, cmd["id"])` — на `WHERE finished_at IS NULL", (now,)` → + `-k keeps_open_sync_run` падает; +- `src/jira_hub/importer.py`: в цикле `epics.csv` `key = _clean(r.get("Ключ"))` заменить на + `key = _clean(r.get("Ключ")) or "new:1"` → `-k epic_without` падает; +- `src/jira_hub/queue.py`, `agent_view`: `"обновите агента" if …` заменить на `None if …` → `-k warning_and_offline` падает. + +После отката: `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 status --short src/` — пусто. + +- [ ] **Step 4: Весь набор** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 pytest -q -p no:cacheprovider 2>&1 | tail -3` +Expected: PASS (297 + 10 = 307 passed), предупреждений нет. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 add tests/test_commands.py tests/test_queue.py tests/test_api.py tests/test_importer.py tests/test_relay.py +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hub-hygiene2 commit -m "test: bool и [] в параметрах, журнал при пределе выдач, 501, эпик без ключа, «подготовлена заново», предупреждение и офлайн агента, тик (T3.2, T5.2, M-8, minor-3 #16; M-10 #4) (#32)" -m "Решение 13: тесты фиксируют уже верное поведение; каждый проверен мутацией." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## Покрытие спеки + +| Решение / сценарий | Задача | Тест | +|---|---|---| +| 1 запись с fsync, очистка `.tmp` | 2 | `test_agent_write_failure_keeps_previous`, `test_manifest_write_failure_keeps_previous` | +| 2 `agent.py.prev`, откат; сценарий «Диск полон при записи манифеста» | 2 | `test_manifest_write_failure_keeps_previous`, `test_publish_success_leaves_no_prev`, `test_stale_prev_removed_by_next_publish`, `test_restore_failure_is_logged_not_raised`, `test_first_publish_failure_leaves_nothing` | +| 3 `cli` — сообщение и код 1 без трейсбека | 2 | `test_agent_publish_write_failure` | +| 4 годный манифест; сценарий «Негодный манифест» | 2 | `test_invalid_manifest_means_no_release`, `test_invalid_manifest_one_warning_for_many_claims` | +| 5 предупреждение один раз на ошибку | 2 | `test_invalid_manifest_warns_once_per_new_error`, `test_invalid_manifest_one_warning_for_many_claims` | +| 6 не-UTF-8 → 404 | 2 | `test_load_non_utf8_with_matching_sha_is_none`, `test_agent_release_non_utf8_is_404` | +| 7 `agent_reported`; сценарии «Агент без запускателя», «Хаб перезапущен…» | 3 | `test_agent_reported_after_hub_restart`, `test_hint_without_launcher`, `test_agent_view_without_release`, `test_hint_unknown_launcher_until_reported`, `test_agent_reported_even_with_bad_update_fields` | +| 8 MCP «запускатель: неизвестно»; сценарий mcp «Статус агента сразу после перезапуска хаба» | 3 | `test_fmt_agent_launcher_unknown_after_restart`, `test_agent_status_right_after_hub_start`; «после отката» — прежний `test_fmt_agent_update_lines` | +| 9 `PUT /settings`; сценарий «Ответ на запись настроек» | 4 | `test_settings_put_returns_all_settings`, `test_settings_put_empty_body_returns_settings_unchanged` | +| 10 «отменено»; сценарии «Создание отменено», «Создание упало» | 4 | `test_dependent_skipped_when_creation_cancelled`, `test_dependent_on_failed_and_cancelled_names_cancelled`; прежний `test_temp_key_substitution_and_skip` | +| 11 CRLF; сценарий «Описание с CRLF в зеркале» | 4 | `test_ensure_fields_text_compare_ignores_crlf`, `test_reconcile_confirms_fields_with_crlf` | +| 12 реле по `resolve()` | 4 | `test_symlink_to_same_dir_not_cleaned_again` | +| 13 недостающие тесты | 2–5 | Task 5 целиком + тесты задач 2–4 | +| 14 ноль `ResourceWarning` | 1 | `filterwarnings` в `pyproject.toml` — весь набор | +| 15 T3.1 `_type_error` | 4 | `test_type_error_messages` | -- 2.47.2 From be50525ed6980453c71c7e08c66ad5a21e5f0a53 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:23:58 +0400 Subject: [PATCH 03/11] =?UTF-8?q?docs(openspec):=20hub-hygiene-d=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=D0=B8=20R-6?= =?UTF-8?q?=E2=80=A6R-13,=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20Context=20=D0=B8=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=2011,=2013=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-hygiene-d/design.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/openspec/changes/hub-hygiene-d/design.md b/openspec/changes/hub-hygiene-d/design.md index 7e5191b..c51c498 100644 --- a/openspec/changes/hub-hygiene-d/design.md +++ b/openspec/changes/hub-hygiene-d/design.md @@ -18,7 +18,7 @@ - `commands.check_confirmed`: `(issue.get(field) or None) != (p[field] or None)` — `\r\n` в зеркале против `\n` в параметре даёт «не совпало». - `relay._cleaned: set[Path]`, проверка `self.root not in self._cleaned` — по написанию пути. -- Тесты: 249 passed; 87 `ResourceWarning: unclosed database` (test_queue, test_relay, test_commands, test_importer). +- Тесты: 249 passed; 88 `ResourceWarning: unclosed database` (test_queue 37, test_commands 18, test_mirror 15, test_db 6, test_importer 6, test_settings 6; в test_relay их нет). ## Goals / Non-Goals @@ -65,14 +65,14 @@ 10. **Пропуск зависимой команды**: если среди неразрешённых ключей есть ключ, чья `ensure_issue` в `cancelled`, текст — «создание задачи new:N отменено» (по первому такому ключу); иначе прежний «задача new:N не создана». 11. **Сравнение строк в `check_confirmed`**: для полей `ensure_fields` со строковыми значениями сравнение после замены - `\r\n` на `\n` с обеих сторон; `None` и пустая строка по-прежнему равны. Метки и прочие поля — без изменений. + `\r\n` на `\n` с обеих сторон; `None` и пустая строка по-прежнему равны. `\r\n` нормализуется во всех строковых полях `ensure_fields`. Метки и прочие поля — без изменений. 12. **Реле**: `_cleaned` хранит `Path.resolve()`; проверка — по `self.root.resolve()`. Ошибка `resolve` (папки нет) — сравнение по исходному пути, как сейчас. ### Тесты и чистота 13. Новые тесты: bool в строковом параметре и `[]` в списочном (`test_commands`); `sync_run.finished_at` не ставится - при `failed` не-refresh по пределу выдач (`test_queue`); 501 на `DELETE`/`PATCH` (`test_api`); `epics.csv` без + при `failed` не-refresh по пределу выдач (`test_queue`; проверка T5.2 буквально невозможна, тест проверяет соседний эффект, как в плане); 501 на `DELETE`/`PATCH` (`test_api`); `epics.csv` без «Ключ» (`test_importer`); «подготовлена заново» в `test_vanished_tree_keeps_work` (`test_relay`); предупреждение «обновите агента» при `agent_version < min_agent_version`, офлайн-агент (нет контакта дольше порога) в `GET /agent`, `tick` — проверка эффекта (`test_api`/`test_queue`); публикация — сбой записи манифеста возвращает @@ -103,3 +103,11 @@ | R-3 (design #9) | 2026-10-15 | `PUT /settings` отвечает всеми настройками, а не `{settings_version, relay_dir}` | один формат с `GET /settings`; O-11 — «не видно, что записалось» относится к любой настройке | надмножество полей в ответе | | R-4 (design #10) | 2026-10-15 | «Отменено» — только при `cancelled` создателе; при `failed` текст прежний | O-10 описывает только force-отмену; `failed` — задача действительно не создана | нет | | R-5 (design #14) | 2026-10-15 | Цель — ноль `ResourceWarning` от sqlite в тестах, проверка `-W error::ResourceWarning` | M-9/O-4: 87 предупреждений прячут настоящие утечки | время на правку фикстур | +| R-6 (design #14) | 2026-10-15 | В `filterwarnings` два фильтра: `error::ResourceWarning` и `error::pytest.PytestUnraisableExceptionWarning` | утечка соединения рождается в `__del__`, одного ResourceWarning pytest не ловит | утечка останется незамеченной | +| R-7 (design #14) | 2026-10-15 | Соединения закрываются фикстурами/хелперами (conftest `open_db`), а не autouse-подменой `db.connect` | подмена спрятала бы будущие утечки в `src/` | больше правок в тестах | +| R-8 (design #2) | 2026-10-15 | При сбое самой первой публикации (прежнего `agent.py` нет) новый `agent.py` удаляется, `release/` остаётся без публикации | `agent.py` без манифеста даёт 404 так же, но оставляет мусор | пустой `release/` вместо непарного файла | +| R-9 (design #2) | 2026-10-15 | `agent.py.prev` удаляется после любой успешной публикации | файл нужен только для отката | нет | +| R-10 (design #2) | 2026-10-15 | `secure_dir` при публикации вызывается внутри `try` | его `OSError` тоже должен стать `PublishError`, а не трейсбеком | трейсбек в CLI | +| R-11 (design #7) | 2026-10-15 | У `release.hint(..., reported=True)` значение по умолчанию | не переписывать старые вызовы и тесты | забытый вызов без признака даёт прежнее поведение | +| R-12 (design #5) | 2026-10-15 | Запоминание последнего текста ошибки манифеста (`_warned`) под `threading.Lock` | `current` вызывается из потоков HTTP-сервера | двойное предупреждение при гонке | +| R-13 (design #14) | 2026-10-15 | Первый абзац `CLAUDE.md` («текущий change — …») не менять | его правит параллельная ветка #29 (`feat/29-web-app-followups`), конфликт гарантирован; строка перепишется при архивации | до архивации `CLAUDE.md` в ветке называет чужой change | -- 2.47.2 From ea37d5494644a31eb6a0171ccd937dca0141397b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:26:38 +0400 Subject: [PATCH 04/11] =?UTF-8?q?test:=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20SQLite=20=D0=B2=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=85=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=D1=81=D1=8F,=20=D1=83=D1=82=D0=B5=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=E2=80=94=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=20(M-9/O-4=20#4)=20(#32?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фикстура open_db в tests/conftest.py, фикстуры conn вместо хелперов fresh()/_mirror(); filterwarnings: error::ResourceWarning и error::pytest.PytestUnraisableExceptionWarning (решение 14, R-5). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 1 - CLAUDE.md | 1 + pyproject.toml | 3 + tests/conftest.py | 20 +++++++ tests/test_commands.py | 28 +++++---- tests/test_db.py | 21 +++---- tests/test_importer.py | 36 ++++++------ tests/test_mirror.py | 51 +++++++---------- tests/test_queue.py | 127 ++++++++++++++++------------------------- tests/test_settings.py | 22 ++++--- 10 files changed, 144 insertions(+), 166 deletions(-) create mode 100644 tests/conftest.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a1a7162..b22bae3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -48,4 +48,3 @@ ## Открытые вопросы - Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. - Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их. -- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`). diff --git a/CLAUDE.md b/CLAUDE.md index 7e68acc..9799e55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ ## Тесты - Golden-тесты: `tests/fixtures/.*` + эталон `.expected.*`. Внешние сервисы в тестах мокаются. - SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют. +- Соединение SQLite в тесте — через фикстуру `open_db` (`tests/conftest.py`) или фикстуру `conn` модуля, иначе с явным `close()`: `pyproject.toml` (`filterwarnings`) делает незакрытое соединение ошибкой набора. - Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе. ## Первая подсистема diff --git a/pyproject.toml b/pyproject.toml index ac01160..da522bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,3 +20,6 @@ packages = ["src/jira_hub"] [tool.pytest.ini_options] testpaths = ["tests"] +# незакрытое соединение sqlite — ошибка набора: ResourceWarning из __del__ pytest выдаёт как +# PytestUnraisableExceptionWarning, поэтому нужны оба фильтра (hub-hygiene-d, решение 14) +filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExceptionWarning"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2ef29b9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +"""Общие фикстуры тестов. Соединения SQLite, открытые тестом, закрываются после него — в тестах нет +ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4).""" +import pytest + +from jira_hub import db + + +@pytest.fixture +def open_db(): + """Фабрика соединений: open_db() — база в памяти, open_db(path) — файл. Все закрываются после теста.""" + opened = [] + + def make(path=":memory:"): + conn = db.connect(path) + opened.append(conn) + return conn + + yield make + for conn in opened: + conn.close() diff --git a/tests/test_commands.py b/tests/test_commands.py index daae8eb..8f8b746 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,6 @@ import pytest -from jira_hub import commands, db, mirror +from jira_hub import commands, mirror from jira_hub.commands import CommandError NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')" @@ -48,15 +48,17 @@ def test_substitute_and_estimate(): assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152 -def _mirror(): - conn = db.connect(":memory:") - mirror.apply_state(conn, [ +@pytest.fixture +def conn(open_db): + """Зеркало в памяти: A-1 (Готово, Спринт 2, метки AWPCA и kind-dev) и A-2, связь A-1 → A-2; закрывается после теста.""" + c = open_db() + mirror.apply_state(c, [ {"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий", "duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2", "created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"}, {"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"}, ], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) - return conn + return c LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"} @@ -78,27 +80,24 @@ LT = {"связанные": "ASUPR_Связанные", "блокирует на ({"type": "refresh", "params": {"keys": ["A-1"]}}, False), ({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False), ]) -def test_check_confirmed(cmd, expected): - ok, _ = commands.check_confirmed(_mirror(), cmd, LT) +def test_check_confirmed(conn, cmd, expected): + ok, _ = commands.check_confirmed(conn, cmd, LT) assert ok is expected -def test_ensure_link_reverse_direction_with_both_keys_in_mirror(): +def test_ensure_link_reverse_direction_with_both_keys_in_mirror(conn): # A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт - conn = _mirror() ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT) assert ok is False -def test_ensure_link_conflict_other_type_blocks_confirmation(): - conn = _mirror() +def test_ensure_link_conflict_other_type_blocks_confirmation(conn): conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')") ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT) assert ok is False -def test_ensure_link_conflict_reverse_blocks_confirmation(): - conn = _mirror() +def test_ensure_link_conflict_reverse_blocks_confirmation(conn): conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')") ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT) assert ok is False @@ -108,9 +107,8 @@ def _ensure_issue(summary): return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}} -def test_ensure_issue_never_confirmed_by_state(): +def test_ensure_issue_never_confirmed_by_state(conn): """bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом.""" - conn = _mirror() conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None) diff --git a/tests/test_db.py b/tests/test_db.py index 8fd7025..0492a6d 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -7,8 +7,8 @@ import threading from jira_hub import backup, db, paths, queue, settings -def test_connect_creates_schema(): - conn = db.connect(":memory:") +def test_connect_creates_schema(open_db): + conn = open_db() names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" @@ -59,10 +59,11 @@ def test_connect_is_idempotent(tmp_path): db.connect(path).close() conn = db.connect(path) assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + conn.close() -def test_transaction_rolls_back_on_error(tmp_path): - conn = db.connect(tmp_path / "hub.sqlite") +def test_transaction_rolls_back_on_error(tmp_path, open_db): + conn = open_db(tmp_path / "hub.sqlite") try: with db.transaction(conn): conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')") @@ -72,8 +73,8 @@ def test_transaction_rolls_back_on_error(tmp_path): assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 -def test_nested_transaction_joins_outer(tmp_path): - conn = db.connect(tmp_path / "hub.sqlite") +def test_nested_transaction_joins_outer(tmp_path, open_db): + conn = open_db(tmp_path / "hub.sqlite") try: with db.transaction(conn): with db.transaction(conn): @@ -86,8 +87,8 @@ def test_nested_transaction_joins_outer(tmp_path): assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную -def test_nested_transaction_error_propagates_to_outer(tmp_path): - conn = db.connect(tmp_path / "hub.sqlite") +def test_nested_transaction_error_propagates_to_outer(tmp_path, open_db): + conn = open_db(tmp_path / "hub.sqlite") try: with db.transaction(conn): conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')") @@ -103,8 +104,8 @@ def test_parse_and_utcnow(): assert db.parse(db.now()) <= db.utcnow() -def test_nested_transaction_rollback_is_local(tmp_path): - conn = db.connect(tmp_path / "hub.sqlite") +def test_nested_transaction_rollback_is_local(tmp_path, open_db): + conn = open_db(tmp_path / "hub.sqlite") with db.transaction(conn): conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')") try: diff --git a/tests/test_importer.py b/tests/test_importer.py index 6339981..992d3b4 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -1,6 +1,16 @@ import csv -from jira_hub import db, importer, mirror, settings +import pytest + +from jira_hub import importer, mirror, settings + + +@pytest.fixture +def conn(open_db): + """База в памяти с настройками по умолчанию; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + return c def write_csv(path, header, rows): @@ -29,10 +39,8 @@ def make_package(folder): ["new:3", "связанные", "AURORA-1533", "z"]]) -def test_import_notes_and_report(tmp_path): +def test_import_notes_and_report(conn, tmp_path): make_package(tmp_path) - conn = db.connect(":memory:") - settings.ensure_defaults(conn) settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) @@ -60,10 +68,8 @@ def write_tasks(folder, comment): [["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]]) -def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path): +def test_reimport_keeps_hub_edit_when_cell_empty(conn, tmp_path): write_tasks(tmp_path, "Актуализация 10.10.2026") - conn = db.connect(":memory:") - settings.ensure_defaults(conn) importer.import_package(conn, tmp_path) mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub") write_tasks(tmp_path, "") # пустая ячейка в CSV @@ -73,20 +79,16 @@ def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path): assert n["rest_text"] == "Прогнать" -def test_reimport_updates_nonempty_cell(tmp_path): +def test_reimport_updates_nonempty_cell(conn, tmp_path): write_tasks(tmp_path, "Первая версия") - conn = db.connect(":memory:") - settings.ensure_defaults(conn) importer.import_package(conn, tmp_path) write_tasks(tmp_path, "Вторая версия") importer.import_package(conn, tmp_path) assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия" -def test_import_does_not_touch_issue_tables(tmp_path): +def test_import_does_not_touch_issue_tables(conn, tmp_path): make_package(tmp_path) - conn = db.connect(":memory:") - settings.ensure_defaults(conn) mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}], [{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}]) count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] @@ -95,20 +97,16 @@ def test_import_does_not_touch_issue_tables(tmp_path): assert (count("issue"), count("issue_link")) == before -def test_missing_csv_files_give_empty_report(tmp_path): - conn = db.connect(":memory:") - settings.ensure_defaults(conn) +def test_missing_csv_files_give_empty_report(conn, tmp_path): rep = importer.import_package(conn, tmp_path) assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []} -def test_reversed_link_reported_separately(tmp_path): +def test_reversed_link_reported_separately(conn, tmp_path): make_package(tmp_path) write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], [["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"], ["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]]) - conn = db.connect(":memory:") - settings.ensure_defaults(conn) settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) diff --git a/tests/test_mirror.py b/tests/test_mirror.py index e45e428..434be71 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -3,6 +3,12 @@ import pytest from jira_hub import db, mirror +@pytest.fixture +def conn(open_db): + """Пустое зеркало в памяти; закрывается после теста.""" + return open_db() + + def issue(key, **over): base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст", "status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me", @@ -13,8 +19,7 @@ def issue(key, **over): return base -def test_apply_state_upserts_and_replaces_links(): - conn = db.connect(":memory:") +def test_apply_state_upserts_and_replaces_links(conn): links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}] out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links) assert out == {"issues": 2, "links": 1} @@ -26,16 +31,14 @@ def test_apply_state_upserts_and_replaces_links(): assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}] -def test_links_from_other_source_survive(): - conn = db.connect(":memory:") +def test_links_from_other_source_survive(conn): mirror.apply_state(conn, [issue("A-1"), issue("A-2")], [{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]) mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}] -def test_apply_state_marks_neighbors(): - conn = db.connect(":memory:") +def test_apply_state_marks_neighbors(conn): mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"}) assert mirror.get_issue(conn, "B-9")["neighbor"] is True assert mirror.get_issue(conn, "A-1")["neighbor"] is False @@ -44,8 +47,7 @@ def test_apply_state_marks_neighbors(): assert mirror.known_keys(conn) == ["A-1", "B-9"] -def test_partial_load_never_lowers_neighbor(): - conn = db.connect(":memory:") +def test_partial_load_never_lowers_neighbor(conn): mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"}) mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys} assert mirror.get_issue(conn, "B-9")["neighbor"] is True @@ -53,8 +55,7 @@ def test_partial_load_never_lowers_neighbor(): assert mirror.get_issue(conn, "B-9")["neighbor"] is False -def test_note_survives_refresh_and_renames(): - conn = db.connect(":memory:") +def test_note_survives_refresh_and_renames(conn): mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import") mirror.rename_note(conn, "new:7", "A-5") mirror.apply_state(conn, [issue("A-5")], []) @@ -65,8 +66,7 @@ def test_note_survives_refresh_and_renames(): mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x") -def test_list_issues_filters(): - conn = db.connect(":memory:") +def test_list_issues_filters(conn): mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"), issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]), issue("A-3", sprint="Спринт 2", duedate=None)], []) @@ -80,31 +80,27 @@ def test_list_issues_filters(): assert len(mirror.list_issues(conn, None, limit=1)) == 1 -def test_rename_note_same_key_keeps_note(): - conn = db.connect(":memory:") +def test_rename_note_same_key_keeps_note(conn): mirror.apply_state(conn, [issue("A-1")], []) mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x") mirror.rename_note(conn, "A-1", "A-1") assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе" -def test_rename_note_missing_is_noop(): - conn = db.connect(":memory:") +def test_rename_note_missing_is_noop(conn): mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает assert mirror.get_note(conn, "new:404") is None assert mirror.get_note(conn, "A-9") is None -def test_set_note_partial_keeps_other_fields(): - conn = db.connect(":memory:") +def test_set_note_partial_keeps_other_fields(conn): mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x") got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y") assert got["work_kind"] == "разработка" and got["done_text"] == "сделано" assert got["fact_status"] == "Готово" and got["updated_by"] == "y" -def test_rename_note_merges_with_existing(): - conn = db.connect(":memory:") +def test_rename_note_merges_with_existing(conn): mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human") mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import") mirror.rename_note(conn, "new:5", "A-5") @@ -116,8 +112,7 @@ def test_rename_note_merges_with_existing(): assert mirror.get_note(conn, "new:5") is None -def test_rename_note_keeps_newer_real_key_stamp(): - conn = db.connect(":memory:") +def test_rename_note_keeps_newer_real_key_stamp(conn): mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import") mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human") conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'") @@ -128,8 +123,7 @@ def test_rename_note_keeps_newer_real_key_stamp(): assert note["fact_status"] == "В работе" and note["owner_note"] == "позже" -def test_rename_note_empty_string_does_not_overwrite(): - conn = db.connect(":memory:") +def test_rename_note_empty_string_does_not_overwrite(conn): mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human") mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import") mirror.rename_note(conn, "new:6", "A-6") @@ -151,8 +145,7 @@ def _sync_run(conn, started, finished, kind, query, status="done", count=5): (started, finished, kind, query, count, command_id)) -def test_board_snapshot_raw_mirror_data(): - conn = db.connect(":memory:") +def test_board_snapshot_raw_mirror_data(conn): mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None), issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"), issue("AURORA-1621", epic_key="AURORA-1351")], @@ -171,8 +164,7 @@ def test_board_snapshot_raw_mirror_data(): assert snap["last_sync"] is None and snap["generated_at"] -def test_board_last_sync_is_last_successful_full_refresh(): - conn = db.connect(":memory:") +def test_board_last_sync_is_last_successful_full_refresh(conn): _sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198) _sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1) _sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1) @@ -182,9 +174,8 @@ def test_board_last_sync_is_last_successful_full_refresh(): "finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198} -def test_board_inside_open_transaction_keeps_it_open(): +def test_board_inside_open_transaction_keeps_it_open(conn): """M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою.""" - conn = db.connect(":memory:") mirror.apply_state(conn, [issue("AURORA-1620")], []) with db.transaction(conn): assert conn.in_transaction diff --git a/tests/test_queue.py b/tests/test_queue.py index 066f7ca..66ff077 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -7,19 +7,20 @@ from jira_hub.queue import ClaimedError LT = {"связанные": "ASUPR_Связанные"} -def fresh(): - conn = db.connect(":memory:") - settings.ensure_defaults(conn) - settings.set_(conn, "link_types", LT) - return conn +@pytest.fixture +def conn(open_db): + """База очереди в памяти с настройками по умолчанию и типами связей LT; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + settings.set_(c, "link_types", LT) + return c def fields(key, **extra): return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}} -def test_create_batch_adds_tail_refresh_and_estimate(): - conn = fresh() +def test_create_batch_adds_tail_refresh_and_estimate(conn): b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}]) view = queue.batch_view(conn, b["id"]) assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"] @@ -28,16 +29,14 @@ def test_create_batch_adds_tail_refresh_and_estimate(): assert view["status"] == "queued" and view["author"] == "claude" -def test_refresh_only_batch_has_no_tail(): - conn = fresh() +def test_refresh_only_batch_has_no_tail(conn): b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) assert b["count"] == 1 with pytest.raises(CommandError): queue.create_batch(conn, "x", "claude", [{"type": "nope"}]) -def test_claim_order_pause_and_report(): - conn = fresh() +def test_claim_order_pause_and_report(conn): b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")]) b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")]) c = queue.claim(conn, agent_version=1, settings_version=1) @@ -61,13 +60,13 @@ def test_claim_order_pause_and_report(): assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running" -def test_claim_is_exclusive(tmp_path): +def test_claim_is_exclusive(tmp_path, open_db): import threading path = tmp_path / "hub.sqlite" - setup = db.connect(path) + setup = open_db(path) settings.ensure_defaults(setup) queue.create_batch(setup, "x", "claude", [fields("A-1")]) - conns = [db.connect(path), db.connect(path)] + conns = [open_db(path), open_db(path)] barrier, results = threading.Barrier(2), [] def worker(c): @@ -83,8 +82,7 @@ def test_claim_is_exclusive(tmp_path): assert row["n"] == 1 and row["d"] == len(got) -def test_backup_failure_does_not_block_claim(caplog): - conn = fresh() +def test_backup_failure_does_not_block_claim(conn, caplog): queue.create_batch(conn, "x", "claude", [fields("A-1")]) def broken(label): @@ -96,8 +94,7 @@ def test_backup_failure_does_not_block_claim(caplog): assert "не создана" in caplog.text -def test_temp_key_substitution_and_skip(): - conn = fresh() +def test_temp_key_substitution_and_skip(conn): mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import") queue.create_batch(conn, "создать", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}}, @@ -120,8 +117,7 @@ def test_temp_key_substitution_and_skip(): assert view["commands"][3]["error"] == "задача new:61 не создана" -def test_cancel_and_retry(): - conn = fresh() +def test_cancel_and_retry(conn): b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) c = queue.claim(conn) with pytest.raises(ClaimedError): @@ -139,16 +135,14 @@ def test_cancel_and_retry(): assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"] -def test_report_after_force_cancel_keeps_cancelled(): - conn = fresh() +def test_report_after_force_cancel_keeps_cancelled(conn): queue.create_batch(conn, "x", "claude", [fields("A-1")]) c = queue.claim(conn) queue.cancel_command(conn, c["id"], force=True) assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled" -def test_reconcile_confirms_by_state(): - conn = fresh() +def test_reconcile_confirms_by_state(conn): queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) c = queue.claim(conn) b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) @@ -160,8 +154,7 @@ def test_reconcile_confirms_by_state(): assert queue.batch_view(conn, b["id"])["status"] == "done" -def test_auto_refresh_conditions(): - conn = fresh() +def test_auto_refresh_conditions(conn): assert queue.maybe_auto_refresh(conn) is None # агент не на связи queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста b = queue.maybe_auto_refresh(conn) @@ -177,8 +170,7 @@ def test_auto_refresh_conditions(): assert queue.maybe_auto_refresh(conn) is None -def test_backup_called_for_first_write_only(tmp_path): - conn = fresh() +def test_backup_called_for_first_write_only(conn, tmp_path): calls = [] queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) c1 = queue.claim(conn, backup=calls.append) @@ -187,28 +179,27 @@ def test_backup_called_for_first_write_only(tmp_path): assert calls == [f"batch-{c1['batch_id']}"] -def test_no_backup_for_refresh_batch(): - conn = fresh() +def test_no_backup_for_refresh_batch(conn): calls = [] queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) queue.claim(conn, backup=calls.append) assert calls == [] -def test_make_backup_rotates(tmp_path): +def test_make_backup_rotates(tmp_path, open_db): from jira_hub import backup - conn = db.connect(tmp_path / "hub.sqlite") + conn = open_db(tmp_path / "hub.sqlite") for i in range(4): backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite")) assert len(names) == 2 and names[-1].endswith("-b3.sqlite") - assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 -def test_rotation_keeps_schema_migration_copy(tmp_path): +def test_rotation_keeps_schema_migration_copy(tmp_path, open_db): """Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью).""" from jira_hub import backup - conn = db.connect(tmp_path / "hub.sqlite") + conn = open_db(tmp_path / "hub.sqlite") backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0) for i in range(4): backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2) @@ -224,9 +215,8 @@ def _claimed_ensure_issue(conn, summary="Тема"): return c -def test_reconcile_does_not_confirm_ensure_issue_without_reporter(): +def test_reconcile_does_not_confirm_ensure_issue_without_reporter(conn): """ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает.""" - conn = fresh() c = _claimed_ensure_issue(conn) mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t") created = db.utcnow().isoformat() @@ -236,9 +226,8 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter(): assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None -def test_reconcile_does_not_overwrite_finished_command(monkeypatch): +def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch): """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" - conn = fresh() queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) c = queue.claim(conn) mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) @@ -264,8 +253,7 @@ def _two_batches_with_dependent(conn): return a, b, first -def test_redelivered_definer_blocks_dependents(): - conn = fresh() +def test_redelivered_definer_blocks_dependents(conn): a, b, first = _two_batches_with_dependent(conn) assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],)) @@ -273,8 +261,7 @@ def test_redelivered_definer_blocks_dependents(): assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9" -def test_auto_refresh_waits_for_claimed_without_report(): - conn = fresh() +def test_auto_refresh_waits_for_claimed_without_report(conn): queue.claim(conn, agent_version=1, settings_version=1) # агент на связи queue.create_batch(conn, "y", "claude", [fields("A-3")]) queue.claim(conn) # выдана, отчёта нет @@ -283,8 +270,7 @@ def test_auto_refresh_waits_for_claimed_without_report(): assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята -def test_redelivery_of_lost_claim_before_queue(): - conn = fresh() +def test_redelivery_of_lost_claim_before_queue(conn): queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) c = queue.claim(conn) again = queue.claim(conn) @@ -297,8 +283,7 @@ def test_redelivery_of_lost_claim_before_queue(): assert queue.agent_view(conn)["redelivered"] == [] -def test_redelivery_limit_fails_command(): - conn = fresh() +def test_redelivery_limit_fails_command(conn): queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) c = queue.claim(conn) queue.claim(conn) @@ -310,8 +295,7 @@ def test_redelivery_limit_fails_command(): assert queue.MAX_DELIVERIES == 3 -def test_redelivery_oldest_first(): - conn = fresh() +def test_redelivery_oldest_first(conn): queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "y", "claude", [fields("A-2")]) rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall() @@ -320,16 +304,14 @@ def test_redelivery_oldest_first(): assert queue.claim(conn)["id"] == rows[1]["id"] -def test_redelivered_refresh_keeps_one_sync_run(): - conn = fresh() +def test_redelivered_refresh_keeps_one_sync_run(conn): queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) r = queue.claim(conn) assert queue.claim(conn)["id"] == r["id"] assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1 -def test_redelivery_does_not_repeat_backup(): - conn = fresh() +def test_redelivery_does_not_repeat_backup(conn): queue.create_batch(conn, "x", "claude", [fields("A-1")]) labels = [] queue.claim(conn, backup=labels.append) @@ -337,9 +319,8 @@ def test_redelivery_does_not_repeat_backup(): assert len(labels) == 1 -def test_migrated_claimed_is_redelivered(): +def test_migrated_claimed_is_redelivered(conn): """Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2.""" - conn = fresh() queue.create_batch(conn, "x", "claude", [fields("A-1")]) cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,)) @@ -347,8 +328,7 @@ def test_migrated_claimed_is_redelivered(): assert queue.get_command(conn, cid)["deliveries"] == 2 -def test_report_for_queued_command_rejected(): - conn = fresh() +def test_report_for_queued_command_rejected(conn): queue.create_batch(conn, "x", "claude", [fields("A-1")]) cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] with pytest.raises(queue.NotClaimedError): @@ -356,8 +336,7 @@ def test_report_for_queued_command_rejected(): assert queue.get_command(conn, cid)["status"] == "queued" -def test_late_report_after_force_cancel_keeps_issue_key(): - conn = fresh() +def test_late_report_after_force_cancel_keeps_issue_key(conn): mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import") queue.create_batch(conn, "x", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) @@ -371,8 +350,7 @@ def test_late_report_after_force_cancel_keeps_issue_key(): assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет -def test_late_failed_report_after_force_cancel_changes_nothing(): - conn = fresh() +def test_late_failed_report_after_force_cancel_changes_nothing(conn): queue.create_batch(conn, "x", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) c = queue.claim(conn) @@ -388,16 +366,14 @@ def _fail_by_limit(conn, cid): assert queue.get_command(conn, cid)["status"] == "failed" -def test_error_is_trimmed_to_1500(): - conn = fresh() +def test_error_is_trimmed_to_1500(conn): queue.create_batch(conn, "x", "claude", [fields("A-1")]) c = queue.claim(conn) out = queue.report_result(conn, c["id"], "failed", error="е" * 5000) assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500 -def test_late_done_after_delivery_limit_keeps_issue_key(): - conn = fresh() +def test_late_done_after_delivery_limit_keeps_issue_key(conn): mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import") queue.create_batch(conn, "x", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}]) @@ -411,8 +387,7 @@ def test_late_done_after_delivery_limit_keeps_issue_key(): assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет -def test_cancelled_batch_with_done_command_is_cancelled(): - conn = fresh() +def test_cancelled_batch_with_done_command_is_cancelled(conn): b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")]) first = queue.claim(conn) queue.report_result(conn, first["id"], "done", result={"changed": []}) @@ -424,8 +399,7 @@ def test_cancelled_batch_with_done_command_is_cancelled(): assert view["counts"]["done"] == 2 -def test_empty_full_refresh_keeps_neighbors(): - conn = fresh() +def test_empty_full_refresh_keeps_neighbors(conn): jql = settings.get(conn, "base_jql") mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона @@ -435,8 +409,7 @@ def test_empty_full_refresh_keeps_neighbors(): assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0 -def test_cancel_finished_batch_stays_done(): - conn = fresh() +def test_cancel_finished_batch_stays_done(conn): b = queue.create_batch(conn, "x", "claude", [fields("A-1")]) while (c := queue.claim(conn)): queue.report_result(conn, c["id"], "done", result={"changed": []}) @@ -445,8 +418,7 @@ def test_cancel_finished_batch_stays_done(): assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None -def test_redelivered_full_refresh_marks_neighbors_once(): - conn = fresh() +def test_redelivered_full_refresh_marks_neighbors_once(conn): jql = settings.get(conn, "base_jql") mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00") queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}]) @@ -463,8 +435,7 @@ def test_redelivered_full_refresh_marks_neighbors_once(): assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1 -def test_redelivered_refresh_resets_counts(): - conn = fresh() +def test_redelivered_refresh_resets_counts(conn): queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) r = queue.claim(conn) queue.record_state(conn, r["id"], 150, 40) @@ -476,8 +447,7 @@ def test_redelivered_refresh_resets_counts(): assert run["started_at"] == started -def test_refresh_failed_by_limit_finishes_sync_run(): - conn = fresh() +def test_refresh_failed_by_limit_finishes_sync_run(conn): queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) r = queue.claim(conn) for _ in range(queue.MAX_DELIVERIES): @@ -486,8 +456,7 @@ def test_refresh_failed_by_limit_finishes_sync_run(): assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"] -def test_cancel_batch_reports_still_claimed(): - conn = fresh() +def test_cancel_batch_reports_still_claimed(conn): b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) c = queue.claim(conn) view = queue.cancel_batch(conn, b["id"]) diff --git a/tests/test_settings.py b/tests/test_settings.py index 7d689be..10f0276 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,14 +8,15 @@ from jira_hub import db, settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" -def fresh(): - conn = db.connect(":memory:") - settings.ensure_defaults(conn) - return conn +@pytest.fixture +def conn(open_db): + """База в памяти с настройками по умолчанию; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + return c -def test_defaults_and_tokens(): - conn = fresh() +def test_defaults_and_tokens(conn): assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" assert settings.get(conn, "pacing")["ensure_fields"] == 120 tok = settings.tokens(conn) @@ -23,8 +24,7 @@ def test_defaults_and_tokens(): assert "tokens" not in settings.get_all(conn) -def test_set_bumps_version(): - conn = fresh() +def test_set_bumps_version(conn): v0 = settings.get(conn, "settings_version") v1 = settings.set_(conn, "pacing", {"ensure_fields": 5}) assert v1 == v0 + 1 @@ -32,14 +32,12 @@ def test_set_bumps_version(): @pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"]) -def test_protected_keys(key): - conn = fresh() +def test_protected_keys(conn, key): with pytest.raises(ValueError): settings.set_(conn, key, "x") -def test_init_from_config(): - conn = fresh() +def test_init_from_config(conn): cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) keys = settings.init_from_config(conn, cfg) assert "transitions_map" in keys -- 2.47.2 From f977d9bd343b3efafabc4bcf106504357c7ef27b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:31:22 +0400 Subject: [PATCH 05/11] =?UTF-8?q?fix(release):=20=D0=BF=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20fsync=20=D0=B8?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=BE=D0=BC=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20agent.py.prev,=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BC=D0=B0=D0=BD=D0=B8=D1=84=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=20(M-1,=20M-3,=20M-5,=20M-6,=20M-7=20#25)=20?= =?UTF-8?q?(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сбой записи — «не удалось записать публикацию: …», код 1, прежняя версия остаётся; манифест годен при целой version ≥ 1 и sha256 из 64 hex; предупреждение о негодном — одно на ошибку; не-UTF-8 agent.py — 404 (решения 1–6). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- README.md | 4 ++ src/jira_hub/release.py | 103 ++++++++++++++++++++++++---- tests/test_api.py | 31 +++++++++ tests/test_cli.py | 27 +++++++- tests/test_release.py | 146 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b22bae3..d5bee2c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,7 +15,7 @@ | `backup` | копии файла базы, ротация | `paths` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | -| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` | +| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | | `mcp_server` | инструменты MCP по stdio | `client` | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | diff --git a/README.md b/README.md index 381e66f..0154f7c 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,10 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py `agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, запускатель вернёт прежнюю — `agent_status` покажет «откат с v …»; исправление публикуется следующим номером. Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. +Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и +выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб +не может разобрать (`version` не целое, `sha256` не 64 hex-символа), означает «публикации нет» — одно предупреждение в +логе хаба на каждую новую ошибку. ## HTTP API diff --git a/src/jira_hub/release.py b/src/jira_hub/release.py index a0d093a..1f1c7d6 100644 --- a/src/jira_hub/release.py +++ b/src/jira_hub/release.py @@ -1,10 +1,11 @@ """Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. -Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5.""" +Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7.""" import hashlib import json import logging import os import re +import threading from datetime import datetime, timezone from pathlib import Path @@ -15,6 +16,11 @@ log = logging.getLogger(__name__) MAX_SIZE = 1024 * 1024 VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) +SHA256_RE = re.compile(r"[0-9a-f]{64}") + +_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5) +_warned_lock = threading.Lock() + class PublishError(Exception): pass @@ -28,13 +34,27 @@ def parse_version(text: str) -> int: def _write_atomic(path: Path, data: bytes) -> None: + """Файл целиком или никак: .<имя>.tmp в том же каталоге, write + flush + fsync, переименование (решение 1). + При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается.""" tmp = path.with_name("." + path.name + ".tmp") - tmp.write_bytes(data) - os.replace(tmp, path) + try: + with open(tmp, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + raise def publish(release_dir, source) -> dict: - """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" + """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной). + Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2): + опубликованной остаётся прежняя версия, наружу — только PublishError.""" src = Path(source) try: data = src.read_bytes() @@ -51,25 +71,79 @@ def publish(release_dir, source) -> dict: prev = current(rd) if prev and version <= prev["version"]: raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") - secure_dir(rd) manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} - _write_atomic(rd / "agent.py", data) - _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) + agent, prev_file = rd / "agent.py", rd / "agent.py.prev" + moved = False + try: + secure_dir(rd) + if agent.exists(): + os.replace(agent, prev_file) + moved = True + _write_atomic(agent, data) + _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) + except OSError as e: + _undo(agent, prev_file, moved) + raise PublishError(f"не удалось записать публикацию: {e}") from e + try: + prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design) + except OSError as e: + log.warning("release: %s не удалён: %s", prev_file, e) return manifest +def _undo(agent: Path, prev_file: Path, moved: bool) -> None: + """Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было — + убрать записанный agent.py. Сбой возврата — в лог: команда всё равно завершается сообщением.""" + try: + if moved: + os.replace(prev_file, agent) + else: + agent.unlink(missing_ok=True) + except OSError as e: + log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e) + + +def _manifest_problem(m) -> str | None: + """Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9).""" + if not isinstance(m, dict): + return "не объект" + version = m.get("version") + if not isinstance(version, int) or isinstance(version, bool) or version < 1: + return f"version — не целое ≥ 1: {version!r}" + sha = m.get("sha256") + if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha): + return "sha256 — не 64 символа [0-9a-f]" + return None + + +def _note_manifest(problem: str | None, path: Path) -> None: + """Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест + сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков.""" + global _warned + text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет" + with _warned_lock: + if text is not None and text != _warned: + log.warning("%s", text) + _warned = text + + def current(release_dir) -> dict | None: - """Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" + """Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять).""" path = Path(release_dir) / "manifest.json" try: m = json.loads(path.read_text(encoding="utf-8")) - return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")} except (FileNotFoundError, NotADirectoryError): + _note_manifest(None, path) return None - except (OSError, ValueError, KeyError, TypeError) as e: # нет прав, битый JSON: публикации нет, мост работает - log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) + except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает + _note_manifest(f"не читается ({e})", path) return None + problem = _manifest_problem(m) + _note_manifest(problem, path) + if problem: + return None + return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")} def load(release_dir) -> dict | None: @@ -85,7 +159,12 @@ def load(release_dir) -> dict | None: if hashlib.sha256(data).hexdigest() != m["sha256"]: log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет") return None - return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")} + try: + content = data.decode("utf-8") + except UnicodeDecodeError: # решение 6: 404, а не 500 + log.error("release: agent.py не в UTF-8 — публикации нет") + return None + return {"version": m["version"], "sha256": m["sha256"], "content": content} def hint(rel, agent, update_state, launcher_version) -> str | None: diff --git a/tests/test_api.py b/tests/test_api.py index 4f35df8..5a1b0c6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -453,3 +453,34 @@ def test_claim_survives_unreadable_release(hub): assert b["id"] finally: os.chmod(rd, 0o700) + + +# --- hub-hygiene-d --------------------------------------------------------------------------------------------- + +def test_invalid_manifest_one_warning_for_many_claims(hub, monkeypatch, caplog): + """M-3/M-7 #25, спека «Негодный манифест»: version = 6.9 — публикации нет; 10 claim и GET /agent — одно предупреждение.""" + monkeypatch.setattr(release, "_warned", None) + rd = hub["hub"].release_dir + rd.mkdir() + (rd / "manifest.json").write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") + for _ in range(10): + assert hub["agent"].post("/agent/claim", {"agent_version": 5})["agent_release"] is None + assert hub["client"].get("/agent")["release"] is None + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" + assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1 + + +def test_agent_release_non_utf8_is_404(hub, tmp_path): + """M-5 #25: agent.py не в UTF-8 при совпавшем sha256 — 404 no_release, а не 500.""" + import hashlib + _publish(hub, tmp_path, 6) + rd = hub["hub"].release_dir + data = b"AGENT_VERSION = 6\n\xff\n" + (rd / "agent.py").write_bytes(data) + m = json.loads((rd / "manifest.json").read_text(encoding="utf-8")) + (rd / "manifest.json").write_text(json.dumps({**m, "sha256": hashlib.sha256(data).hexdigest()}), encoding="utf-8") + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" diff --git a/tests/test_cli.py b/tests/test_cli.py index ae2e938..b7c6c42 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,8 @@ import json +import os from pathlib import Path -from jira_hub import cli, db, paths, settings +from jira_hub import cli, db, paths, release, settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" @@ -35,3 +36,27 @@ def test_agent_publish(tmp_path, monkeypatch, capsys): assert (paths.release_dir() / "manifest.json").exists() assert cli.main(["agent", "publish", str(src)]) == 1 assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err + + +def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): + """Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25).""" + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + src = tmp_path / "agent.py" + src.write_text("AGENT_VERSION = 8\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 0 + capsys.readouterr() + real = os.replace + + def fake(s, d, *a, **kw): + if Path(d).name == "manifest.json": + raise OSError(28, "No space left on device") + return real(s, d, *a, **kw) + + monkeypatch.setattr(os, "replace", fake) + src.write_text("AGENT_VERSION = 9\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 1 + err = capsys.readouterr().err + assert err.startswith("не удалось записать публикацию: ") and "Traceback" not in err + monkeypatch.setattr(os, "replace", real) + assert release.load(paths.release_dir())["version"] == 8 + assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"] diff --git a/tests/test_release.py b/tests/test_release.py index 041445e..1d777db 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,6 +1,8 @@ +import hashlib import json import os import stat +from pathlib import Path import pytest @@ -101,3 +103,147 @@ def test_hint(): def test_hint_none_when_no_agent(): rel = {"version": 6, "sha256": "s", "published_at": "t"} assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None + + +# --- hub-hygiene-d: публикация при сбое записи, проверка манифеста (решения 1–6) --------------------------------- + +def _fail_replace_into(monkeypatch, *names): + """os.replace в файл с одним из имён names падает «диск полон»; остальные переименования работают.""" + real = os.replace + + def fake(src, dst, *a, **kw): + if Path(dst).name in names: + raise OSError(28, "No space left on device") + return real(src, dst, *a, **kw) + + monkeypatch.setattr(release.os, "replace", fake) + + +def _listing(rd): + return {p.name: p.read_bytes() for p in rd.iterdir()} + + +def test_publish_success_leaves_no_prev(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 5)) + release.publish(rd, agent_file(tmp_path, 6)) + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert release.current(rd)["version"] == 6 + + +def test_manifest_write_failure_keeps_previous(tmp_path, monkeypatch): + """M-1 #25, спека «Диск полон при записи манифеста»: прежние v8 agent.py и манифест, ни .tmp, ни agent.py.prev.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + before = _listing(rd) + _fail_replace_into(monkeypatch, "manifest.json") + with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: .*No space left on device"): + release.publish(rd, agent_file(tmp_path, 9, body="print('v9')\n")) + monkeypatch.undo() + assert _listing(rd) == before + assert release.load(rd)["version"] == 8 + + +def test_agent_write_failure_keeps_previous(tmp_path, monkeypatch): + """M-1, M-6 #25: сбой fsync при записи agent.py — временный файл удалён, прежний agent.py на месте.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + before = _listing(rd) + + def no_space(fd): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(release.os, "fsync", no_space) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 9)) + monkeypatch.undo() + assert _listing(rd) == before + assert release.load(rd)["version"] == 8 + + +def test_first_publish_failure_leaves_nothing(tmp_path, monkeypatch): + """Review Focus 3: прежней публикации нет — после сбоя release/ пуст, а не с agent.py без манифеста.""" + rd = tmp_path / "release" + _fail_replace_into(monkeypatch, "manifest.json") + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 1)) + monkeypatch.undo() + assert list(rd.iterdir()) == [] and release.current(rd) is None + + +def test_stale_prev_removed_by_next_publish(tmp_path): + """Review Focus 1: agent.py.prev, оставленный упавшим процессом, убирает следующая успешная публикация.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + (rd / "agent.py").rename(rd / "agent.py.prev") # процесс упал между переименованием и записью + assert release.load(rd) is None + release.publish(rd, agent_file(tmp_path, 7)) + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert release.load(rd)["version"] == 7 + + +def test_restore_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog): + """Review Focus 2: и возврат agent.py.prev не удался — PublishError с текстом, причина возврата в логе.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + _fail_replace_into(monkeypatch, "manifest.json") + real = release.os.replace + + def fake(src, dst, *a, **kw): + if Path(src).name == "agent.py.prev": + raise OSError(13, "Permission denied") + return real(src, dst, *a, **kw) + + monkeypatch.setattr(release.os, "replace", fake) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 9)) + assert "прежняя публикация не восстановлена" in caplog.text + + +@pytest.mark.parametrize("manifest", [ + {"version": 6.9, "sha256": "a" * 64}, {"version": True, "sha256": "a" * 64}, {"version": "7", "sha256": "a" * 64}, + {"version": 0, "sha256": "a" * 64}, {"version": 6, "sha256": "a" * 63}, {"version": 6, "sha256": "A" * 64}, + {"version": 6}, [6, "a" * 64], None, +]) +def test_invalid_manifest_means_no_release(tmp_path, manifest): + """M-7 #25, решение 4: version — целое (не bool) ≥ 1, sha256 — 64 символа [0-9a-f], корень — объект.""" + rd = tmp_path / "release" + rd.mkdir() + (rd / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + assert release.current(rd) is None and release.load(rd) is None + + +def test_invalid_manifest_warns_once_per_new_error(tmp_path, monkeypatch, caplog): + """M-3 #25, решение 5: одно предупреждение на каждый новый текст ошибки; годный манифест сбрасывает память.""" + monkeypatch.setattr(release, "_warned", None) + rd = tmp_path / "release" + rd.mkdir() + manifest = rd / "manifest.json" + manifest.write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") + for _ in range(100): + assert release.current(rd) is None + warnings = lambda: [r for r in caplog.records if "манифест" in r.getMessage()] + assert len(warnings()) == 1 and "6.9" in warnings()[0].getMessage() + manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") # другая ошибка — ещё одно + release.current(rd) + release.current(rd) + assert len(warnings()) == 2 + manifest.write_text('{"version": 6, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") # годный сбрасывает память + assert release.current(rd)["version"] == 6 + manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") + release.current(rd) + assert len(warnings()) == 3 + + +def test_load_non_utf8_with_matching_sha_is_none(tmp_path, caplog): + """M-5 #25, решение 6: sha256 сошёлся, но файл не в UTF-8 — публикации нет, ошибка в логе.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + data = b"AGENT_VERSION = 6\n\xff\xfe\n" + (rd / "agent.py").write_bytes(data) + m = json.loads((rd / "manifest.json").read_text(encoding="utf-8")) + m["sha256"] = hashlib.sha256(data).hexdigest() + (rd / "manifest.json").write_text(json.dumps(m), encoding="utf-8") + assert release.current(rd)["version"] == 6 + assert release.load(rd) is None + assert "agent.py не в UTF-8" in caplog.text -- 2.47.2 From 57934383e7e0e65b9a853e930f5ef88dcfb0d49f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:33:54 +0400 Subject: [PATCH 06/11] =?UTF-8?q?fix(release):=20=D1=81=D0=B1=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20agent.py=20=D0=BD=D0=B5=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BF=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20agent.py=20(T2-I1)=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Откат удаляет agent.py только при первой публикации; при сбое до переименования прежний файл не трогается. Тест: os.replace падает на agent.py.prev — release/ байт-в-байт прежний. Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/release.py | 11 ++++++----- tests/test_release.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/jira_hub/release.py b/src/jira_hub/release.py index 1f1c7d6..14101a1 100644 --- a/src/jira_hub/release.py +++ b/src/jira_hub/release.py @@ -74,7 +74,7 @@ def publish(release_dir, source) -> dict: manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} agent, prev_file = rd / "agent.py", rd / "agent.py.prev" - moved = False + existed, moved = agent.exists(), False try: secure_dir(rd) if agent.exists(): @@ -83,7 +83,7 @@ def publish(release_dir, source) -> dict: _write_atomic(agent, data) _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) except OSError as e: - _undo(agent, prev_file, moved) + _undo(agent, prev_file, moved, existed) raise PublishError(f"не удалось записать публикацию: {e}") from e try: prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design) @@ -92,13 +92,14 @@ def publish(release_dir, source) -> dict: return manifest -def _undo(agent: Path, prev_file: Path, moved: bool) -> None: +def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None: """Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было — - убрать записанный agent.py. Сбой возврата — в лог: команда всё равно завершается сообщением.""" + убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем. + Сбой возврата — в лог: команда всё равно завершается сообщением.""" try: if moved: os.replace(prev_file, agent) - else: + elif not existed: agent.unlink(missing_ok=True) except OSError as e: log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e) diff --git a/tests/test_release.py b/tests/test_release.py index 1d777db..597089f 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -247,3 +247,23 @@ def test_load_non_utf8_with_matching_sha_is_none(tmp_path, caplog): assert release.current(rd)["version"] == 6 assert release.load(rd) is None assert "agent.py не в UTF-8" in caplog.text + + +def test_move_failure_keeps_previous_agent(tmp_path, monkeypatch): + """T2-I1: сбой переименования agent.py -> agent.py.prev при существующем agent.py — прежний agent.py не трогаем.""" + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 8)) + before = _listing(rd) + real = release.os.replace + + def fake(src, dst, *a, **kw): + if Path(dst).name == "agent.py.prev": + raise OSError(13, "Permission denied") + return real(src, dst, *a, **kw) + + monkeypatch.setattr(release.os, "replace", fake) + with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: "): + release.publish(rd, agent_file(tmp_path, 9)) + monkeypatch.undo() + assert _listing(rd) == before + assert release.load(rd)["version"] == 8 -- 2.47.2 From 8a319ea5953a0e666bf5b83af9e5f97cc5ee65ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:36:19 +0400 Subject: [PATCH 07/11] =?UTF-8?q?fix(agent):=20agent=5Freported=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20claim=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD,=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=B8=20=C2=AB=D0=B1=D0=B5=D0=B7=20run.py?= =?UTF-8?q?=C2=BB=20=D0=BD=D0=B5=D1=82=20(M-2=20#25)=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /agent отдаёт agent_reported; release.hint(reported=); MCP agent_status: «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (решения 7–8, R-1). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- README.md | 6 +++++- src/jira_hub/api.py | 13 +++++++++---- src/jira_hub/mcp_server.py | 5 ++++- src/jira_hub/release.py | 8 +++++--- tests/test_api.py | 25 +++++++++++++++++++++++++ tests/test_mcp.py | 21 +++++++++++++++++++++ tests/test_release.py | 10 ++++++++++ 8 files changed, 80 insertions(+), 10 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d5bee2c..d0b3edc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. -- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти и отдаёт в `GET /agent`. +- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. - Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. ## Границы diff --git a/README.md b/README.md index 0154f7c..d2b3f36 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py не может разобрать (`version` не целое, `sha256` не 64 hex-символа), означает «публикации нет» — одно предупреждение в логе хаба на каждую новую ошибку. +Состояние обновления и версию запускателя агент сообщает в каждом `claim`, хаб держит их в памяти: сразу после +перезапуска хаба, пока агент не вышел на связь (до 30 с), `agent_status` пишет «запускатель: неизвестно (агент ещё не +выходил на связь после перезапуска хаба)», а подсказки «агент запущен без run.py» нет. + ## HTTP API Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON, @@ -139,7 +143,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /batches/{id}/cancel` | клиент | отмена пакета | | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | -| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `update_hint`), журнал загрузок | +| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) | ```bash diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 14f57e3..1c53223 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -50,7 +50,9 @@ class Hub: self.web_dir = Path(web_dir) if web_dir else WEB_APP self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" - self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3) + # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version + # неизвестен, а не null (hub-hygiene-d, решение 7) + self.agent_update = {"update_state": None, "launcher_version": None, "reported": False} self.claim_wait = 30 secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии conn = self.conn() @@ -74,7 +76,8 @@ class Hub: def note_agent_update(self, body): """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). - Значение неверного типа игнорируется — claim из-за него не отвергается.""" + Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported).""" + self.agent_update["reported"] = True us = body.get("update_state") if us is None or isinstance(us, dict): if us is not None and isinstance(us.get("error"), str): @@ -285,8 +288,10 @@ def h_refresh(hub, conn, m, q, body): def h_agent_get(hub, conn, m, q, body): view = queue.agent_view(conn) rel = release.current(hub.release_dir) - us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"] - view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv)) + au = hub.agent_update + us, lv, reported = au["update_state"], au["launcher_version"], au["reported"] + view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported, + update_hint=release.hint(rel, view, us, lv, reported=reported)) return 200, view diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 02d93e4..e538b1a 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -112,7 +112,10 @@ def fmt_agent(a) -> str: if us: out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}") lv = a.get("launcher_version") - out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") + if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт + out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)") + else: + out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") if a.get("update_hint"): out.append(a["update_hint"]) return "\n".join(out) diff --git a/src/jira_hub/release.py b/src/jira_hub/release.py index 14101a1..e27b3b2 100644 --- a/src/jira_hub/release.py +++ b/src/jira_hub/release.py @@ -168,13 +168,15 @@ def load(release_dir) -> dict | None: return {"version": m["version"], "sha256": m["sha256"], "content": content} -def hint(rel, agent, update_state, launcher_version) -> str | None: - """Подсказка для agent_status (design решение 5).""" +def hint(rel, agent, update_state, launcher_version, reported=True) -> str | None: + """Подсказка для agent_status (design agent-self-update решение 5). reported=False — после запуска хаба claim + ещё не было: launcher_version неизвестен, подсказки «без run.py» нет (hub-hygiene-d, решение 7).""" try: agent_version = int(agent.get("agent_version") or 0) except (TypeError, ValueError): agent_version = 0 - if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None: + if (reported and rel and agent.get("online") and agent_version and rel["version"] > agent_version + and launcher_version is None): return "агент запущен без run.py — обновится только вручную" us = update_state or {} if us.get("state") == "rolled_back": diff --git a/tests/test_api.py b/tests/test_api.py index 5a1b0c6..e41ee42 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -429,11 +429,13 @@ def test_hint_without_launcher(hub, tmp_path): hub["agent"].post("/agent/claim", {"agent_version": 5}) v = hub["client"].get("/agent") assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную" + assert v["agent_reported"] is True def test_agent_view_without_release(hub): v = hub["client"].get("/agent") assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None + assert v["agent_reported"] is False def test_claim_survives_unreadable_release(hub): @@ -484,3 +486,26 @@ def test_agent_release_non_utf8_is_404(hub, tmp_path): with pytest.raises(HubError) as e: hub["agent"].get("/agent/release") assert e.value.status == 404 and e.value.error == "no_release" + + +def test_agent_reported_after_hub_restart(hub, tmp_path): + """M-2 #25, спека «Хаб перезапущен, агент ещё не выходил на связь»: в базе агент онлайн, claim с запуска не было.""" + _publish(hub, tmp_path, 10) + conn = hub["hub"].conn() + conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE id = 1", (db.now(),)) + conn.close() + v = hub["client"].get("/agent") + assert v["online"] is True and v["agent_reported"] is False + assert v["launcher_version"] is None and v["update_hint"] is None + hub["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) + v = hub["client"].get("/agent") + assert v["agent_reported"] is True and v["launcher_version"] == 2 and v["update_hint"] is None + + +def test_agent_reported_even_with_bad_update_fields(hub, tmp_path): + """Review Focus 5: claim с полями неверного типа — claim всё равно был: agent_reported = true.""" + _publish(hub, tmp_path, 6) + hub["agent"].post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"}) + v = hub["client"].get("/agent") + assert v["agent_reported"] is True and v["launcher_version"] is None + assert v["update_hint"] == "агент запущен без run.py — обновится только вручную" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 26abec3..029be47 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -215,3 +215,24 @@ def test_fmt_agent_update_lines(): assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text + + +def test_fmt_agent_launcher_unknown_after_restart(): + """M-2 #25, решение 8: agent_reported = false — «неизвестно», а не «без запускателя»; поля нет — прежний текст.""" + base = {"online": True, "agent_version": "9", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], + "warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3, + "release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"}, + "update_state": None, "launcher_version": None, "update_hint": None} + text = mcp_server.fmt_agent({**base, "agent_reported": False}) + assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text + assert "без запускателя" not in text and "агент запущен без run.py" not in text + assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True}) + assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет + + +def test_agent_status_right_after_hub_start(live): + """Спека mcp «Статус агента сразу после перезапуска хаба».""" + assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status() + live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) + text = mcp_server.agent_status() + assert "запускатель: v2" in text and "неизвестно" not in text diff --git a/tests/test_release.py b/tests/test_release.py index 597089f..4cf2c6a 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -267,3 +267,13 @@ def test_move_failure_keeps_previous_agent(tmp_path, monkeypatch): monkeypatch.undo() assert _listing(rd) == before assert release.load(rd)["version"] == 8 + + +def test_hint_unknown_launcher_until_reported(): + """M-2 #25, решение 7: пока после запуска хаба не было claim, «без run.py» не утверждается; откат — по update_state.""" + rel = {"version": 10, "sha256": "s", "published_at": "t"} + online9 = {"agent_version": "9", "online": True} + assert release.hint(rel, online9, None, None, reported=False) is None + assert release.hint(rel, online9, None, None, reported=True) == "агент запущен без run.py — обновится только вручную" + rb = {"state": "rolled_back", "from_version": 9, "to_version": 10, "error": "код 1"} + assert release.hint(rel, online9, rb, None, reported=False) == "откат с v10 на v9: код 1" -- 2.47.2 From e180bb4e5ff2bdf59e5f2359c2915f946f5b86e9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:42:03 +0400 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20P?= =?UTF-8?q?UT=20/settings,=20=C2=AB=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=BE=C2=BB,=20CRLF=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8,=20=D1=80=D0=B5=D0=BB=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=20resolve()=20(O-11,=20O-10,=20O-9,=20minor-1,=20T3.1=20?= =?UTF-8?q?#16)=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /settings отвечает всеми настройками (R-3); пропуск при cancelled-создателе — «создание задачи new:N отменено» (R-4); ensure_fields сравнивает строки без различия CRLF/LF; _cleaned по resolve(); _type_error по веткам (решения 9–12, 15). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/jira_hub/api.py | 8 ++++---- src/jira_hub/commands.py | 29 +++++++++++++++++++---------- src/jira_hub/queue.py | 8 +++++++- src/jira_hub/relay.py | 16 +++++++++++++--- tests/test_api.py | 17 +++++++++++++++++ tests/test_commands.py | 30 ++++++++++++++++++++++++++++++ tests/test_queue.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_relay.py | 18 ++++++++++++++++++ 9 files changed, 146 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d2b3f36..03e5d97 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | -| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) | +| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) | ```bash TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 1c53223..2a20aae 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -306,7 +306,8 @@ def h_settings_get(hub, conn, m, q, body): def h_settings_put(hub, conn, m, q, body): """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения - (settings.validate): ошибка — 422, версия настроек не меняется.""" + (settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у + GET /settings (hub-hygiene-d, решение 9).""" bad = [k for k in body if k in settings.PROTECTED] if bad: raise ApiError(422, "protected", ", ".join(bad)) @@ -315,11 +316,10 @@ def h_settings_put(hub, conn, m, q, body): settings.validate(key, value) except ValueError as e: raise ApiError(422, "bad_setting", str(e)) - version = settings.get(conn, "settings_version") with db.transaction(conn): for key, value in body.items(): - version = settings.set_(conn, key, value) - return 200, {"settings_version": version} + settings.set_(conn, key, value) + return 200, settings.get_all(conn) ROUTES = [ diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index a1752fc..9ffd44f 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -57,15 +57,16 @@ def _type_error(ctype, name, value) -> str | None: if value is None: return None if name in LIST_PARAMS: - ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк" - elif name in BOOL_PARAMS: - ok, want = isinstance(value, bool), "true или false" - elif ctype == "ensure_status" and name == "target": - ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value)) - want = "строка или список строк" - else: - ok, want = isinstance(value, str), "строка" - return None if ok else f"{name} — {want}" + return None if _is_str_list(value) else f"{name} — список строк" + if name in BOOL_PARAMS: + return None if isinstance(value, bool) else f"{name} — true или false" + if ctype == "ensure_status" and name == "target": + return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк" + return None if isinstance(value, str) else f"{name} — строка" + + +def _is_str_list(value) -> bool: + return isinstance(value, list) and all(isinstance(v, str) for v in value) def validate(commands) -> list[dict]: @@ -133,6 +134,14 @@ def estimate(types, pacing: dict) -> int: return sum(int(pacing.get(t, 0)) for t in types) +def _norm_text(value): + """Значение поля для сравнения с зеркалом: переводы строки CRLF и LF не различаются (описание, правленное в UI Jira, + приходит с CRLF, O-9 #16); null и пустая строка равны (решение 11).""" + if isinstance(value, str): + value = value.replace("\r\n", "\n") + return value or None + + def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, @@ -145,7 +154,7 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: return False, None if ctype == "ensure_fields": for field in FIELD_PARAMS: - if field in p and (issue.get(field) or None) != (p[field] or None): + if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]): return False, None if any(l not in issue["labels"] for l in p.get("labels_add", [])): return False, None diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 2d46c71..3faead8 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -114,6 +114,12 @@ def _definer_status(conn, batch_id, temp_key): return r["status"] if r else "failed" +def _skip_reason(conn, batch_id, unresolved) -> str: + """Текст skipped для зависимой команды: отменённое создание называется отменой (O-10 #16, решение 10).""" + cancelled = [k for k in unresolved if _definer_status(conn, batch_id, k) == "cancelled"] + return f"создание задачи {cancelled[0]} отменено" if cancelled else f"задача {unresolved[0]} не создана" + + def _next(conn) -> dict | None: rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id " "WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall() @@ -131,7 +137,7 @@ def _next(conn) -> dict | None: unresolved = [] if cmd["params"]["keys"] else unresolved if unresolved: conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?", - (db.now(), f"задача {unresolved[0]} не создана", cmd["id"])) + (db.now(), _skip_reason(conn, cmd["batch_id"], unresolved), cmd["id"])) continue return cmd return None diff --git a/src/jira_hub/relay.py b/src/jira_hub/relay.py index 68ffb61..9f90f3c 100644 --- a/src/jira_hub/relay.py +++ b/src/jira_hub/relay.py @@ -61,7 +61,7 @@ class Relay: self._cleanup_at = 0.0 self._ready = False self._warned: str | None = None - self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле + self._cleaned: set[Path] = set() # папки (по resolve()), чей work/ вычищен после старта реле self.workers: list[threading.Thread] = [] # --- цикл --------------------------------------------------------------------- @@ -124,12 +124,13 @@ class Relay: def _prepare(self) -> None: for sub in ("req", "resp", "work"): (self.root / sub).mkdir(exist_ok=True) - if self.root not in self._cleaned: + key = _same_dir_key(self.root) + if key not in self._cleaned: # первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает for stale in (self.root / "work").iterdir(): stale.unlink(missing_ok=True) say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)") - self._cleaned.add(self.root) + self._cleaned.add(key) else: # пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем say(f"relay: папка {self.root} подготовлена заново") @@ -225,6 +226,15 @@ class Relay: p.unlink(missing_ok=True) +def _same_dir_key(path: Path) -> Path: + """Ключ папки для «уже вычищена»: resolve() — симлинк и другая запись той же папки совпадают (minor-1 #16, + решение 12); ошибка resolve — путь как записан.""" + try: + return path.resolve() + except (OSError, RuntimeError): + return path + + def _error(error: str, detail: str) -> str: return json.dumps({"error": error, "detail": detail}, ensure_ascii=False) diff --git a/tests/test_api.py b/tests/test_api.py index e41ee42..384812a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -509,3 +509,20 @@ def test_agent_reported_even_with_bad_update_fields(hub, tmp_path): v = hub["client"].get("/agent") assert v["agent_reported"] is True and v["launcher_version"] is None assert v["update_hint"] == "агент запущен без run.py — обновится только вручную" + + +def test_settings_put_returns_all_settings(hub): + """O-11 #16, решение 9, спека «Ответ на запись настроек»: все настройки после записи, как GET /settings, без токенов.""" + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + out = c.put("/settings", {"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"}) + assert out["relay_dir"] == "/Volumes/externalSSD/utm/rshb/jira-bridge" and out["settings_version"] == v0 + 1 + assert "tokens" not in out and out["base_jql"] == "project = AURORA AND labels = AWPCA" + assert out == c.get("/settings") + + +def test_settings_put_empty_body_returns_settings_unchanged(hub): + """Пустое тело — 200, все настройки, версия не меняется.""" + c = hub["client"] + before = c.get("/settings") + assert c.put("/settings", {}) == before diff --git a/tests/test_commands.py b/tests/test_commands.py index 8f8b746..5041530 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -147,3 +147,33 @@ def test_validate_accepts_null_for_clearable_and_path_target(): {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}}, ]) assert len(out) == 3 + + +@pytest.mark.parametrize("mirror_value, param, expected", [ + ("a\r\nb", "a\nb", True), ("a\nb", "a\r\nb", True), ("a\r\nb\r\n", "a\nb\n", True), + ("a\r\nb", "a b", False), (None, "", True), ("", None, True), ("a\rb", "a\nb", False), +]) +def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expected): + """O-9 #16, решение 11: CRLF и LF не различаются, null и пустая строка равны; одиночный CR — другой текст. + summary с CRLF в зеркале и LF в команде — в каждом случае: сравнение одно для всех строковых полей.""" + conn.execute("UPDATE issue SET description = ?, summary = ? WHERE key = 'A-1'", (mirror_value, "x\r\ny")) + ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields", + "params": {"key": "A-1", "description": param, "summary": "x\ny"}}, LT) + assert ok is expected + + +@pytest.mark.parametrize("ctype, name, value, msg", [ + ("ensure_fields", "duedate", True, "duedate — строка"), + ("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"), + ("ensure_fields", "labels_add", ["x", 1], "labels_add — список строк"), + ("ensure_link", "replace", "да", "replace — true или false"), + ("ensure_status", "target", 5, "target — строка или список строк"), + ("ensure_status", "target", ["Разработка", "Готово"], None), + ("ensure_status", "target", "Готово", None), + ("ensure_fields", "labels_add", [], None), + ("ensure_fields", "assignee", None, None), + ("ensure_link", "replace", False, None), +]) +def test_type_error_messages(ctype, name, value, msg): + """T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один).""" + assert commands._type_error(ctype, name, value) == msg diff --git a/tests/test_queue.py b/tests/test_queue.py index 66ff077..7391674 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -462,3 +462,40 @@ def test_cancel_batch_reports_still_claimed(conn): view = queue.cancel_batch(conn, b["id"]) assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}] assert queue.get_command(conn, c["id"])["status"] == "claimed" + + +def test_dependent_skipped_when_creation_cancelled(conn): + """O-10 #16, решение 10, спека «Создание отменено»: зависимая команда — «создание задачи new:N отменено».""" + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:901", "issue_type": "task", "summary": "Отменённая"}}, + {"type": "ensure_comment", "params": {"key": "new:901", "marker": "m", "body": "b"}}]) + c = queue.claim(conn) + queue.cancel_command(conn, c["id"], force=True) + assert queue.claim(conn) is None # комментарий и хвостовой refresh пропущены + cmds = queue.batch_view(conn, b["id"])["commands"] + assert [x["status"] for x in cmds] == ["cancelled", "skipped", "skipped"] + assert cmds[1]["error"] == "создание задачи new:901 отменено" + + +def test_dependent_on_failed_and_cancelled_names_cancelled(conn): + """Review Focus 4: два временных ключа — создатель первого failed, второго cancelled: текст по отменённому.""" + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Упавшая"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Отменённая"}}, + {"type": "ensure_link", "params": {"source": "new:1", "target": "new:2", "link_type": "связанные"}}]) + first = queue.claim(conn) + queue.report_result(conn, first["id"], "failed", error="HTTP 400") + second = queue.claim(conn) + queue.cancel_command(conn, second["id"], force=True) + queue.claim(conn) + link = queue.batch_view(conn, b["id"])["commands"][2] + assert link["status"] == "skipped" and link["error"] == "создание задачи new:2 отменено" + + +def test_reconcile_confirms_fields_with_crlf(conn): + """O-9 #16, решение 11, спека «Описание с CRLF в зеркале»: потерянный отчёт закрывается по состоянию.""" + queue.create_batch(conn, "x", "claude", [{"type": "ensure_fields", "params": {"key": "A-1", "description": "a\nb"}}]) + c = queue.claim(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], []) + assert queue.reconcile(conn) == [c["id"]] + assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" diff --git a/tests/test_relay.py b/tests/test_relay.py index 088e806..6f92209 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -402,3 +402,21 @@ def test_relay_targets_serve_host(tmp_path, monkeypatch): stop.set() srv.server_close() assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8) + + +def test_symlink_to_same_dir_not_cleaned_again(env, tmp_path, capsys): + """minor-1 #16, решение 12: relay_dir сменили на симлинк к той же папке — work/ не чистится второй раз.""" + r, root, h = env["relay"], env["root"], env["hub"] + r.step() # root вычищен + link = tmp_path / "bridge-link" + link.symlink_to(root, target_is_directory=True) + (root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос ещё обрабатывается + conn = h.conn() + settings.set_(conn, "relay_dir", str(link)) + conn.close() + r._settings_at = None + capsys.readouterr() + r.step() + out = capsys.readouterr().out + assert (root / "work" / "busy.json").exists() + assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out -- 2.47.2 From 1f1dad691153e18312f375541aea7031327e1f7e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:46:13 +0400 Subject: [PATCH 09/11] =?UTF-8?q?test:=20bool=20=D0=B8=20[]=20=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=B0=D1=85,?= =?UTF-8?q?=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=B5=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B0=D1=87,=20501,=20=D1=8D=D0=BF=D0=B8=D0=BA=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0,=20=C2=AB=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=D0=BE=D0=B2=D0=BE=C2=BB,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B8=20=D0=BE=D1=84=D0=BB=D0=B0=D0=B9=D0=BD=20?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0,=20=D1=82=D0=B8=D0=BA=20(T?= =?UTF-8?q?3.2,=20T5.2,=20M-8,=20minor-3=20#16;=20M-10=20#4)=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решение 13: тесты фиксируют уже верное поведение; каждый проверен мутацией. Co-Authored-By: Claude Opus 5.5 --- tests/test_api.py | 51 ++++++++++++++++++++++++++++++++++++++++-- tests/test_commands.py | 21 +++++++++++++++++ tests/test_importer.py | 9 ++++++++ tests/test_queue.py | 16 +++++++++++++ tests/test_relay.py | 3 ++- 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 384812a..a153aa8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,10 +3,11 @@ import os import threading import urllib.error import urllib.request +from datetime import timedelta import pytest -from jira_hub import api, db, release, settings +from jira_hub import api, db, queue, release, settings from jira_hub.client import HubClient, HubError @@ -209,13 +210,15 @@ def test_numeric_param_400_internal_bug_500(hub, monkeypatch): assert e.value.status == 500 -def test_tick_survives_connection_failure(hub, monkeypatch): +def test_tick_survives_connection_failure(hub, monkeypatch, caplog): def broken(): raise OSError("диск недоступен") monkeypatch.setattr(hub["hub"], "conn", broken) hub["hub"].tick() # не бросает + assert "автообновление: сбой тика" in caplog.text monkeypatch.undo() hub["hub"].tick() + assert hub["client"].get("/batches")["batches"] == [] # агент не на связи — автообновления нет def test_claim_long_poll_returns_new_command(hub): @@ -526,3 +529,47 @@ def test_settings_put_empty_body_returns_settings_unchanged(hub): c = hub["client"] before = c.get("/settings") assert c.put("/settings", {}) == before + + +@pytest.mark.parametrize("method", ["DELETE", "PATCH"]) +def test_other_methods_are_501_not_json(hub, method): + """M-8 #16, спека «Неподдерживаемый метод»: 501 стандартной страницей http.server, не JSON; база не меняется.""" + import http.client + hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", + "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + host, port = hub["base"].removeprefix("http://").split(":") + conn = http.client.HTTPConnection(host, int(port), timeout=10) + try: + conn.request(method, "/batches/1", headers={"Authorization": "Bearer " + hub["client"].token}) + r = conn.getresponse() + r.read() + assert r.status == 501 and not (r.getheader("Content-Type") or "").startswith("application/json") + finally: + conn.close() + assert hub["client"].get("/batches/1")["status"] == "queued" + + +def test_agent_warning_and_offline(hub): + """M-10 #4: «обновите агента» при agent_version < min_agent_version; нет контакта дольше порога — офлайн.""" + c = hub["client"] + c.put("/settings", {"min_agent_version": 3}) + hub["agent"].post("/agent/claim", {"agent_version": 2}) + v = c.get("/agent") + assert v["online"] is True and v["warning"] == "обновите агента" + hub["agent"].post("/agent/claim", {"agent_version": 3}) + assert c.get("/agent")["warning"] is None + old = (db.utcnow() - timedelta(minutes=queue.ONLINE_MINUTES + 1)).isoformat() + conn = hub["hub"].conn() + conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE id = 1", (old,)) + conn.close() + assert c.get("/agent")["online"] is False + + +def test_tick_creates_auto_refresh_once(hub): + """M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята).""" + hub["agent"].post("/agent/claim", {"agent_version": 9}) + hub["hub"].tick() + batches = hub["client"].get("/batches", limit=5)["batches"] + assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub" + hub["hub"].tick() + assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1 diff --git a/tests/test_commands.py b/tests/test_commands.py index 5041530..c310ee3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -177,3 +177,24 @@ def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expe def test_type_error_messages(ctype, name, value, msg): """T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один).""" assert commands._type_error(ctype, name, value) == msg + + +@pytest.mark.parametrize("cmd, name", [ + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": True}}, "duedate"), + ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": False}}, "body"), + ({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": True}}, "summary"), +]) +def test_validate_rejects_bool_in_string_param(cmd, name): + """T3.2 #16: true/false в строковом параметре — 422 с именем параметра, а не запись «True» в Jira.""" + with pytest.raises(CommandError, match=f"{name} — строка"): + commands.validate([cmd]) + + +def test_validate_empty_list_params(): + """T3.2 #16: [] в необязательном списке допустим; [] в обязательном или единственном — как отсутствие.""" + out = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": [], "labels_remove": []}}]) + assert out[0]["params"] == {"key": "A-1", "labels_add": [], "labels_remove": []} + with pytest.raises(CommandError, match="нет target"): + commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "target": []}}]) + with pytest.raises(CommandError, match="refresh без keys и jql"): + commands.validate([{"type": "refresh", "params": {"keys": []}}]) diff --git a/tests/test_importer.py b/tests/test_importer.py index 992d3b4..00edce1 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -113,3 +113,12 @@ def test_reversed_link_reported_separately(conn, tmp_path): rep = importer.import_package(conn, tmp_path) assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}] assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] + + +def test_epic_without_key_is_skipped(tmp_path, conn): + """M-8 #16 (T7.3 #4), спека migration «Эпик без ключа»: строка без «Ключ» пропущена, заметки new:* нет.""" + write_csv(tmp_path / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"], + [["NEW-2", "", "Без ключа", "ещё не создан"], ["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"]]) + rep = importer.import_package(conn, tmp_path) + assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1350"] + assert [r["key"] for r in conn.execute("SELECT key FROM issue_note ORDER BY key")] == ["AURORA-1350"] diff --git a/tests/test_queue.py b/tests/test_queue.py index 7391674..a6732ae 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -499,3 +499,19 @@ def test_reconcile_confirms_fields_with_crlf(conn): mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], []) assert queue.reconcile(conn) == [c["id"]] assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" + + +def test_delivery_limit_of_write_command_keeps_open_sync_run(conn): + """T5.2 #16: предел выдач у не-refresh команды не закрывает журнал чужой загрузки (finished_at — только для refresh).""" + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + c = queue.claim(conn) + r = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-9"]}}]) + rid = queue.batch_view(conn, r["id"])["commands"][0]["id"] + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2099-01-01T00:00:00+00:00', deliveries = 1 " + "WHERE id = ?", (rid,)) + conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, 'keys', 'A-9', ?)", (db.now(), rid)) + for _ in range(queue.MAX_DELIVERIES - 1): + assert queue.claim(conn)["id"] == c["id"] + assert queue.claim(conn)["id"] == rid # c — failed по пределу, довыдаётся refresh + assert queue.get_command(conn, c["id"])["status"] == "failed" + assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None diff --git a/tests/test_relay.py b/tests/test_relay.py index 6f92209..4469904 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -365,7 +365,8 @@ def test_vanished_tree_keeps_work(env, capsys): for _ in range(3): r.step() assert (root / "work" / "busy.json").exists() and (root / "req").is_dir() - assert "удалён незавершённый запрос" not in capsys.readouterr().out + out = capsys.readouterr().out + assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out # minor-3 #16 def test_each_dir_cleaned_once(env, tmp_path, capsys): -- 2.47.2 From 7d3f648d2cd691e491caad04e35ecf70a38e7f4d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:56:51 +0400 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?=E2=80=94=20M-1/T3.1=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20agent=5Fupdate,=20T1.1/T1.2=20open=5Fdb,=20T2.3,=20T4.1=20(#?= =?UTF-8?q?32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/api.py | 8 +++++--- tests/test_cli.py | 7 +++---- tests/test_commands.py | 8 ++++++++ tests/test_release.py | 10 +++++++++- tests/test_settings.py | 7 +++---- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 2a20aae..3580f59 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -77,15 +77,17 @@ class Hub: def note_agent_update(self, body): """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported).""" - self.agent_update["reported"] = True + new = dict(self.agent_update) + new["reported"] = True us = body.get("update_state") if us is None or isinstance(us, dict): if us is not None and isinstance(us.get("error"), str): us = {**us, "error": us["error"][:500]} - self.agent_update["update_state"] = us + new["update_state"] = us lv = body.get("launcher_version") if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)): - self.agent_update["launcher_version"] = lv + new["launcher_version"] = lv + self.agent_update = new def make_backup(self, label): conn = self.conn() diff --git a/tests/test_cli.py b/tests/test_cli.py index b7c6c42..8ea8b83 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,19 +2,19 @@ import json import os from pathlib import Path -from jira_hub import cli, db, paths, release, settings +from jira_hub import cli, paths, release, settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" -def test_init_import_tokens(tmp_path, monkeypatch, capsys): +def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db): monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) assert cli.main(["init", "--config", str(FIXTURE)]) == 0 out = capsys.readouterr().out assert "transitions_map" in out and "агентский токен" in out assert cli.main(["init", "--config", str(FIXTURE)]) == 1 assert "уже инициализирован" in capsys.readouterr().err - conn = db.connect(paths.db_path()) + conn = open_db(paths.db_path()) assert settings.get(conn, "board_id") == 4216 (tmp_path / "pkg").mkdir() (tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8") @@ -23,7 +23,6 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys): assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"] assert cli.main(["tokens"]) == 0 assert settings.tokens(conn)["client"] in capsys.readouterr().out - conn.close() def test_agent_publish(tmp_path, monkeypatch, capsys): diff --git a/tests/test_commands.py b/tests/test_commands.py index c310ee3..62e15ef 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -162,6 +162,14 @@ def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expe assert ok is expected +def test_ensure_fields_summary_difference_is_detected(conn): + """T4.1 #32: CRLF в summary игнорируется, но реальное расхождение текста — False.""" + conn.execute("UPDATE issue SET summary = ? WHERE key = 'A-1'", ("x\r\ny",)) + ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields", + "params": {"key": "A-1", "summary": "x\nz"}}, LT) + assert ok is False + + @pytest.mark.parametrize("ctype, name, value, msg", [ ("ensure_fields", "duedate", True, "duedate — строка"), ("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"), diff --git a/tests/test_release.py b/tests/test_release.py index 4cf2c6a..5b5345d 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -186,6 +186,8 @@ def test_restore_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog): """Review Focus 2: и возврат agent.py.prev не удался — PublishError с текстом, причина возврата в логе.""" rd = tmp_path / "release" release.publish(rd, agent_file(tmp_path, 8)) + before = _listing(rd) + new_src = agent_file(tmp_path, 9) _fail_replace_into(monkeypatch, "manifest.json") real = release.os.replace @@ -196,8 +198,14 @@ def test_restore_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog): monkeypatch.setattr(release.os, "replace", fake) with pytest.raises(release.PublishError, match="не удалось записать публикацию"): - release.publish(rd, agent_file(tmp_path, 9)) + release.publish(rd, new_src) assert "прежняя публикация не восстановлена" in caplog.text + monkeypatch.undo() + after = _listing(rd) # задокументированное состояние: новый agent.py, старый agent.py.prev, старый манифест + assert sorted(after) == ["agent.py", "agent.py.prev", "manifest.json"] + assert after["agent.py"] == new_src.read_bytes() + assert after["agent.py.prev"] == before["agent.py"] + assert after["manifest.json"] == before["manifest.json"] @pytest.mark.parametrize("manifest", [ diff --git a/tests/test_settings.py b/tests/test_settings.py index 10f0276..4926338 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from jira_hub import db, settings +from jira_hub import settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" @@ -53,8 +53,8 @@ def test_init_from_config(conn): settings.init_from_config(conn, cfg, force=True) -def test_relay_dir_default_and_validation(tmp_path): - conn = db.connect(tmp_path / "hub.sqlite") +def test_relay_dir_default_and_validation(tmp_path, open_db): + conn = open_db(tmp_path / "hub.sqlite") settings.ensure_defaults(conn) assert settings.get(conn, "relay_dir") is None settings.validate("relay_dir", None) @@ -63,4 +63,3 @@ def test_relay_dir_default_and_validation(tmp_path): with pytest.raises(ValueError): settings.validate("relay_dir", bad) assert "relay_dir" not in settings.AGENT_KEYS - conn.close() -- 2.47.2 From bc377eda2ca9f81fe48e60199ffe8eea7012ef3a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:56:51 +0400 Subject: [PATCH 11/11] =?UTF-8?q?docs:=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?=E2=80=94=20M-2,=20M-3,=20M-4/T2.5,=20M-5,=20R-13,=20R-14=20(#3?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- openspec/changes/hub-hygiene-d/design.md | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 03e5d97..28c2c8e 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,14 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py `agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, запускатель вернёт прежнюю — `agent_status` покажет «откат с v …»; исправление публикуется следующим номером. Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. + Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб -не может разобрать (`version` не целое, `sha256` не 64 hex-символа), означает «публикации нет» — одно предупреждение в +не может разобрать (`version` не целое, `sha256` не 64 символа `[0-9a-f]`), означает «публикации нет» — одно предупреждение в логе хаба на каждую новую ошибку. Состояние обновления и версию запускателя агент сообщает в каждом `claim`, хаб держит их в памяти: сразу после -перезапуска хаба, пока агент не вышел на связь (до 30 с), `agent_status` пишет «запускатель: неизвестно (агент ещё не +перезапуска хаба, пока агент не вышел на связь (до первого `claim` агента, обычно до 30 с), `agent_status` пишет «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)», а подсказки «агент запущен без run.py» нет. ## HTTP API diff --git a/openspec/changes/hub-hygiene-d/design.md b/openspec/changes/hub-hygiene-d/design.md index c51c498..719e6eb 100644 --- a/openspec/changes/hub-hygiene-d/design.md +++ b/openspec/changes/hub-hygiene-d/design.md @@ -88,8 +88,8 @@ ## Risks / Trade-offs - [`agent.py.prev` в `release/`] лишний файл живёт только между переименованием и записью манифеста; при падении - процесса посреди — остаётся; следующая публикация его перезапишет. → не влияет на `load` (читается только `agent.py`). -- [«неизвестно» до первого claim] до 30 с после перезапуска хаба статус неполный. → честнее ложного «без запускателя». + процесса посреди — остаётся; следующая публикация его перезапишет. → при падении процесса между переименованием и записью `agent.py` нет вовсе, и `GET /agent/release` отдаёт 404 до следующей публикации (как и прежде при падении между записью agent.py и манифеста). +- [«неизвестно» до первого claim] до первого `claim` агента (обычно до 30 с) после перезапуска хаба статус неполный. → честнее ложного «без запускателя». - [`PUT /settings` отдаёт больше полей] клиенты, ожидавшие ровно `{settings_version}`, получают надмножество. → поле на месте. - [Конфликт с #29] текстовый в `tests/test_api.py` (одна строка у #29). → кто сливается вторым, разрешает. @@ -110,4 +110,5 @@ | R-10 (design #2) | 2026-10-15 | `secure_dir` при публикации вызывается внутри `try` | его `OSError` тоже должен стать `PublishError`, а не трейсбеком | трейсбек в CLI | | R-11 (design #7) | 2026-10-15 | У `release.hint(..., reported=True)` значение по умолчанию | не переписывать старые вызовы и тесты | забытый вызов без признака даёт прежнее поведение | | R-12 (design #5) | 2026-10-15 | Запоминание последнего текста ошибки манифеста (`_warned`) под `threading.Lock` | `current` вызывается из потоков HTTP-сервера | двойное предупреждение при гонке | -| R-13 (design #14) | 2026-10-15 | Первый абзац `CLAUDE.md` («текущий change — …») не менять | его правит параллельная ветка #29 (`feat/29-web-app-followups`), конфликт гарантирован; строка перепишется при архивации | до архивации `CLAUDE.md` в ветке называет чужой change | +| R-13 (design #14) | 2026-10-15 | Первый абзац `CLAUDE.md` («текущий change — …») не менять | его правит параллельная ветка #29 (`feat/29-web-app-followups`), конфликт гарантирован; строка перепишется при архивации | до архивации `CLAUDE.md` в ветке называет чужой change. Итог: после слияния #29 первый абзац `CLAUDE.md` в main общий («активные change — `openspec/changes/` (кроме `archive/`)»), поэтому при архивации его переписывать не нужно | +| R-14 (merge) | 2026-10-15 | main не сливается в ветку; конфликт `pyproject.toml` (блок `[tool.uv]` из #29 против `filterwarnings`) разрешается при merge PR: оставить обе секции | инструкция «не мержить»; конфликт тривиален, пробное слияние даёт 317 passed | PR показывает конфликт до ручного разрешения | -- 2.47.2