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