1752 lines
94 KiB
Markdown
1752 lines
94 KiB
Markdown
# 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` |
|