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

94 KiB
Raw Permalink Blame History

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 заменить

[tool.pytest.ini_options]
testpaths = ["tests"]

на

[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
"""Общие фикстуры тестов. Соединения 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:

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

В шапке файла заменить

import json
import os
import stat

на

import hashlib
import json
import os
import stat
from pathlib import Path

и дописать в конец файла:



# --- 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: в шапке заменить

import json
from pathlib import Path

from jira_hub import cli, db, paths, settings

на

import json
import os
from pathlib import Path

from jira_hub import cli, db, paths, release, settings

и дописать в конец:



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 уже импортированы):



# --- 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 модуля и импорты: заменить

"""Опубликованная версия агента для самообновления: <данные>/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

на

"""Опубликованная версия агента для самообновления: <данные>/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) добавить:

SHA256_RE = re.compile(r"[0-9a-f]{64}")

_warned: str | None = None          # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5)
_warned_lock = threading.Lock()

Заменить _write_atomic целиком:

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

    """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной)."""

на

    """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).
    Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2):
    опубликованной остаётся прежняя версия, наружу — только PublishError."""

и хвост publish вместе с current — от строки secure_dir(rd) до конца current — заменить на:

    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 заменить конец

    return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")}

на

    try:
        content = data.decode("utf-8")
    except UnicodeDecodeError:                                  # решение 6: 404, а не 500
        log.error("release: agent.py не в UTF-8 — публикации нет")
        return None
    return {"version": m["version"], "sha256": m["sha256"], "content": content}
  • 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
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 после строки

    assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную"

добавить

    assert v["agent_reported"] is True

в test_agent_view_without_release после строки

    assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None

добавить

    assert v["agent_reported"] is False

и дописать в конец файла:



def test_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 — дописать в конец:



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 — дописать в конец:



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__: заменить строку

        self.agent_update = {"update_state": None, "launcher_version": None}   # последнее из claim; в памяти (R-3)

на

        # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
        # неизвестен, а не null (hub-hygiene-d, решение 7)
        self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}

Hub.note_agent_update: заменить

        Значение неверного типа игнорируется — claim из-за него не отвергается."""
        us = body.get("update_state")

на

        Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported)."""
        self.agent_update["reported"] = True
        us = body.get("update_state")

h_agent_get: заменить

    us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"]
    view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv))

на

    au = hub.agent_update
    us, lv, reported = au["update_state"], au["launcher_version"], au["reported"]
    view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported,
                update_hint=release.hint(rel, view, us, lv, reported=reported))

src/jira_hub/release.py, hint: заменить начало функции

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:

на

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: заменить

    lv = a.get("launcher_version")
    out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя")

на

    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
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 — дописать в конец:



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 — дописать в конец:



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 — дописать в конец:



@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 — дописать в конец:



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 заменить

    """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
    (settings.validate): ошибка — 422, версия настроек не меняется."""

на

    """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
    (settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у
    GET /settings (hub-hygiene-d, решение 9)."""

и хвост

    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}

на

    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: вставить

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 заменить

                             (db.now(), f"задача {unresolved[0]} не создана", cmd["id"]))

на

                             (db.now(), _skip_reason(conn, cmd["batch_id"], unresolved), cmd["id"]))

src/jira_hub/commands.py, _type_error (T3.1): тело после docstring

    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}"

заменить на

    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( вставить

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 заменить

            if field in p and (issue.get(field) or None) != (p[field] or None):

на

            if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]):

src/jira_hub/relay.py: в Relay.__init__ комментарий

        self._cleaned: set[Path] = set()   # папки, чей work/ вычищен после старта реле

заменить на

        self._cleaned: set[Path] = set()   # папки (по resolve()), чей work/ вычищен после старта реле

в _prepare заменить

        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)

на

        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: вставить

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
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 — дописать в конец:



@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 — дописать в конец:



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: в шапке заменить

import urllib.request

import pytest

from jira_hub import api, db, release, settings

на

import urllib.request
from datetime import timedelta

import pytest

from jira_hub import api, db, queue, release, settings

test_tick_survives_connection_failure заменить целиком на

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"] == []      # агент не на связи — автообновления нет

и дописать в конец файла:



@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 — дописать в конец:



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: заменить последние строки

    assert (root / "work" / "busy.json").exists() and (root / "req").is_dir()
    assert "удалён незавершённый запрос" not in capsys.readouterr().out

на

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