sdlc-hub/docs/superpowers/plans/2026-10-15-hub-hygiene-d.md
Andrey Epifancev aa1cca877d docs(plan): hub-hygiene-d — план (#32)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:04:05 +04:00

1752 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <noreply@anthropic.com>`; в сообщении — маркеры находок и `(#32)`. Не пушить.
- Тексты из спеки — дословно:
- сбой записи публикации: `не удалось записать публикацию: <ошибка>`, код 1, без трейсбека;
- манифест годен, только если это объект, `version` — `int`, не `bool`, ≥ 1, `sha256` — строка `[0-9a-f]{64}`;
- MCP: `запускатель: v<N>`, `без запускателя`, `запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)`;
- пропуск зависимой команды: `создание задачи new:<n> отменено` (создатель `cancelled`), иначе прежний
`задача new:<n> не создана`;
- подсказка: `агент запущен без 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
## Покрытие спеки
| Решение / сценарий | Задача | Тест |
|---|---|---|
| 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` |