diff --git a/docs/superpowers/plans/2026-10-14-bridge-hygiene-hub.md b/docs/superpowers/plans/2026-10-14-bridge-hygiene-hub.md new file mode 100644 index 0000000..cb70a53 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-bridge-hygiene-hub.md @@ -0,0 +1,863 @@ +# bridge-hygiene — хаб: план реализации + +> **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:** пакет C гигиены хаба: закрытый каталог данных, одна копия при миграции, слияние заметок, проверка типов +параметров, корректные статусы и счётчики очереди, поздний отчёт по `failed`, информативная отмена пакета, устойчивое реле, +документы. + +**Architecture:** правки точечные, внутри существующих модулей: `paths`/`db`/`backup` (права, миграция), `mirror` +(`rename_note`), `commands` (`validate`), `queue` (статусы, отчёты, довыдача, отмена), `relay`, `mcp_server`. Контракт +агента не меняется; новая только строка `still_claimed` в ответе отмены пакета. + +**Tech Stack:** Python 3.13, uv, pytest, SQLite (stdlib), stdlib `http.server`/`urllib`. + +**Spec:** `openspec/changes/bridge-hygiene/` (proposal.md, design.md, specs/{hub-api,data-model,commands,mcp,migration,agent}). + +## Global Constraints + +- Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/hygiene` (ветка `feat/13-bridge-hygiene`). + Все команды — с этим cwd; основной checkout `jira-hub/` (main) не трогать. +- Комментарии, docstring, сообщения ошибок и коммитов — по-русски; идентификаторы — английские. +- `uv run pytest -q` — зелёным считается только весь набор (до начала: 169 passed). +- Новых зависимостей нет; миграции схемы нет (`SCHEMA_VERSION` остаётся 2). +- `error` команды хранится не длиннее 1500 символов; текст ошибки предела выдач — ровно `агент не отчитался после 3 выдач`. +- Каталог данных и `backups/` — права `0700`; сбой `chmod` — предупреждение в лог, не исключение. +- Каждая задача заканчивается коммитом с заголовком на русском, суффиксом `(#13)` и строкой + `Co-Authored-By: Claude Opus 5.5 `. +- Решения, принятые исполнителем без обсуждения, — в `openspec/changes/bridge-hygiene/design.md`, раздел + `## Decisions made without discussion`, со следующим свободным маркером `R-n` (занято R-1…R-11). + +## Review Focus + +1. Параллельные `connect` к базе v1 (старт хаба и `jira-hub import` одновременно): одна копия, миграция один раз, ни + одного исключения — тест `test_parallel_connect_makes_one_backup` в Task 1. +2. Поздний отчёт по `ensure_issue`, ставшей `failed` по пределу выдач, приходит дважды: второй ничего не меняет — + тест в Task 4. +3. Слияние заметок, когда у временной заметки поле пустая строка `""` (а не `NULL`): под ключом сохраняется значение — + тест в Task 2. +4. `refresh` довыдан и снова загружает состояние; затем полный прогон завершён: соседи помечаются по `started_at` первой + выдачи, счётчики равны одной загрузке — тест в Task 5. +5. Реле: папка пропала и появилась снова, в `work/` лежит взятый запрос — файл не удалён, лог без «перезапуск реле» — + тест в Task 7. + +--- + +### Task 1: Закрытый каталог данных и одна копия при миграции + +**Files:** +- Modify: `src/jira_hub/paths.py` (новая `secure_dir`) +- Modify: `src/jira_hub/db.py` (`connect`, `migrate`) +- Modify: `src/jira_hub/backup.py` (`make_backup`) +- Test: `tests/test_db.py` + +**Interfaces:** +- Produces: `paths.secure_dir(path: Path) -> None` — создать каталог (с родителями) и выставить `0700`; при `OSError` — + `log.warning`, без исключения. `db.migrate(conn, path=None)` — сигнатура прежняя. + +- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_db.py` (импорты — в начало файла): + +```python +import logging +import os +import stat +import threading + +from jira_hub import backup, paths, queue, settings + + +def _mode(p): + return stat.S_IMODE(p.stat().st_mode) + + +def test_data_dir_is_private(tmp_path): + home = tmp_path / "data" + home.mkdir() + home.chmod(0o755) + db.connect(home / "hub.sqlite").close() + assert _mode(home) == 0o700 + + +def test_backups_dir_is_private(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + backup.make_backup(conn, tmp_path / "backups", "batch-1") + conn.close() + assert _mode(tmp_path / "backups") == 0o700 + + +def test_secure_dir_failure_only_warns(tmp_path, monkeypatch, caplog): + def deny(*a, **k): + raise PermissionError("нет прав") + monkeypatch.setattr(os, "chmod", deny) + with caplog.at_level(logging.WARNING): + paths.secure_dir(tmp_path / "x") + assert (tmp_path / "x").is_dir() and "0700" in caplog.text + + +def test_parallel_connect_makes_one_backup(tmp_path): + path = tmp_path / "hub.sqlite" + _v1_database(path) + barrier, errors = threading.Barrier(4), [] + + def go(): + try: + barrier.wait() + db.connect(path).close() + except Exception as e: # noqa: BLE001 + errors.append(e) + + threads = [threading.Thread(target=go) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join(30) + assert errors == [] + assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + conn.close() + + +def test_migrated_claimed_redelivered_end_to_end(tmp_path): + """База v1 с командой в claimed → миграция (deliveries = 1) → claim довыдаёт её с deliveries = 2 (M-10 #12).""" + path = tmp_path / "hub.sqlite" + _v1_database(path) + conn = db.connect(path) + settings.ensure_defaults(conn) + cmd = queue.claim(conn) + assert cmd["id"] == 1 + assert queue.get_command(conn, 1)["deliveries"] == 2 + conn.close() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_db.py -q` +Expected: FAIL — `test_data_dir_is_private` (0o755), `test_backups_dir_is_private`, `test_secure_dir_failure_only_warns` +(`AttributeError: module 'jira_hub.paths' has no attribute 'secure_dir'`); `test_parallel_connect_makes_one_backup` — +больше одной копии (может пройти случайно — это гонка; после Step 3 обязан проходить стабильно); +`test_migrated_claimed_redelivered_end_to_end` уже может проходить — это закрепление поведения. + +- [ ] **Step 3: Implement** — `src/jira_hub/paths.py` целиком: + +```python +"""Где лежат данные хаба: каталог, база, резервные копии.""" +import logging +import os +from pathlib import Path + +log = logging.getLogger(__name__) + + +def home() -> Path: + return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub"))) + + +def db_path() -> Path: + return home() / "hub.sqlite" + + +def backups_dir() -> Path: + return home() / "backups" + + +def secure_dir(path: Path) -> None: + """Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) — + предупреждение, хаб продолжает работу.""" + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + try: + os.chmod(path, 0o700) + except OSError as e: + log.warning("каталог %s не закрыт (0700): %s", path, e) +``` + +В `src/jira_hub/backup.py` заменить `backups_dir.mkdir(parents=True, exist_ok=True)` на `paths.secure_dir(backups_dir)` +и добавить импорт `from . import paths`. + +В `src/jira_hub/db.py`: импорт `from . import backup, paths` (если `backup` уже импортирован — добавить `paths`). В `connect` +заменить `Path(path).parent.mkdir(parents=True, exist_ok=True)` на `paths.secure_dir(Path(path).parent)`. `migrate` +целиком: + +```python +def migrate(conn: sqlite3.Connection, path=None) -> None: + """Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы + (<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный + connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает + отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции.""" + conn.executescript(SCHEMA) + version = _schema_version(conn) + if version is None: + conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),)) + elif version < SCHEMA_VERSION: + with transaction(conn): + current = _schema_version(conn) + if current < SCHEMA_VERSION: + if path is not None: + reader = sqlite3.connect(str(path), timeout=10) + try: + backup.make_backup(reader, Path(path).parent / "backups", f"schema-v{current}", keep=0) + finally: + reader.close() + while current < SCHEMA_VERSION: + for sql in MIGRATIONS[current]: + conn.execute(sql) + current += 1 + conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),)) + conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") +``` + +Проверить, что `backup` не импортирует `db` (циклов нет: `paths` ← `backup` ← `db`). + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_db.py -q && for i in 1 2 3 4 5; do uv run pytest tests/test_db.py::test_parallel_connect_makes_one_backup -q || break; done && uv run pytest -q` +Expected: PASS; тест параллельного connect — 5 из 5; весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/paths.py src/jira_hub/db.py src/jira_hub/backup.py tests/test_db.py +git commit -m "fix(db): каталог данных 0700, одна копия при миграции под блокировкой (#13) + +M-7 (hub#4), M-2 и M-10 (hub#12). + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Слияние заметок при переносе `new:N` → ключ + +**Files:** +- Modify: `src/jira_hub/mirror.py` (`rename_note`) +- Test: `tests/test_mirror.py` + +**Interfaces:** +- Consumes: `mirror.NOTE_FIELDS`, `mirror.get_note`, `mirror.set_note`, `db.transaction`. +- Produces: `mirror.rename_note(conn, old_key, new_key) -> None` — сигнатура прежняя, поведение при существующей + заметке под `new_key` — слияние. + +- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_mirror.py` (функция `fresh()`/соединение — как в + соседних тестах файла; если хелпера нет — `conn = db.connect(":memory:")`): + +```python +def test_rename_note_merges_with_existing(): + conn = db.connect(":memory:") + mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human") + mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import") + mirror.rename_note(conn, "new:5", "A-5") + note = mirror.get_note(conn, "A-5") + assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает + assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом + assert note["fact_status"] == "В работе" + assert note["updated_by"] == "import" # временная заметка записана позже + assert mirror.get_note(conn, "new:5") is None + + +def test_rename_note_empty_string_does_not_overwrite(): + conn = db.connect(":memory:") + mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human") + mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import") + mirror.rename_note(conn, "new:6", "A-6") + note = mirror.get_note(conn, "A-6") + assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_mirror.py -q` +Expected: FAIL — `owner_note` и `rest_text` пропали (`None`): заметка под ключом удаляется. + +- [ ] **Step 3: Implement** — заменить `rename_note` в `src/jira_hub/mirror.py`: + +```python +def rename_note(conn, old_key, new_key) -> None: + """После создания задачи: заметка с временного ключа переезжает на настоящий. Если под настоящим ключом заметка уже + есть — слияние (M-3 #4): поле временной заметки побеждает, если оно непустое; updated_at/updated_by — от более + поздней; временная строка удаляется.""" + if old_key == new_key: + return + with db.transaction(conn): + old = get_note(conn, old_key) + if old is None: + return + cur = get_note(conn, new_key) + if cur is None: + conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key)) + return + merged = [old[f] if old[f] not in (None, "") else cur[f] for f in NOTE_FIELDS] + newer = old if (old["updated_at"] or "") >= (cur["updated_at"] or "") else cur + conn.execute(f"UPDATE issue_note SET {', '.join(f'{f} = ?' for f in NOTE_FIELDS)}, updated_at = ?, updated_by = ? " + "WHERE key = ?", merged + [newer["updated_at"], newer["updated_by"], new_key]) + conn.execute("DELETE FROM issue_note WHERE key = ?", (old_key,)) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_mirror.py -q && uv run pytest -q` +Expected: PASS, весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/mirror.py tests/test_mirror.py +git commit -m "fix(mirror): rename_note сливает заметки, а не удаляет заметку под ключом (#13) + +M-3 (hub#4, PR #2). + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Проверка типов параметров команд + +**Files:** +- Modify: `src/jira_hub/commands.py` (`validate`, новые `LIST_PARAMS`, `BOOL_PARAMS`, `_type_error`) +- Test: `tests/test_commands.py` + +**Interfaces:** +- Consumes: `commands.SPEC`, `CommandError`. +- Produces: `commands.validate(commands) -> list[dict]` — прежняя сигнатура; неверный тип значения → `CommandError` + (наружу 422) с текстом `команда (<тип>): <параметр> — <ожидаемое>`. + +- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_commands.py`: + +```python +import pytest + +from jira_hub import commands as C + + +@pytest.mark.parametrize("cmd, name", [ + ({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": 42}}, "summary"), + ({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": "AWPCA"}}, "labels_add"), + ({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["x", 1]}}, "labels_remove"), + ({"type": "ensure_fields", "params": {"key": ["A-1"], "duedate": "2026-10-23"}}, "key"), + ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": {"t": 1}}}, "body"), + ({"type": "ensure_status", "params": {"key": "A-1", "target": 5}}, "target"), + ({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", 2]}}, "target"), + ({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": "да"}}, "replace"), + ({"type": "refresh", "params": {"keys": "A-1"}}, "keys"), +]) +def test_validate_rejects_wrong_types(cmd, name): + with pytest.raises(C.CommandError) as e: + C.validate([cmd]) + assert name in str(e.value) + + +def test_validate_accepts_null_for_clearable_and_path_target(): + out = C.validate([ + {"type": "ensure_fields", "params": {"key": "A-1", "assignee": None, "epic_key": None, "duedate": None}}, + {"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}}, + {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}}, + ]) + assert len(out) == 3 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_commands.py -q` +Expected: FAIL — для `summary: 42` `AttributeError: 'int' object has no attribute 'strip'`, для остальных — нет исключения. + +- [ ] **Step 3: Implement** — в `src/jira_hub/commands.py` после `WRITE_TYPES`: + +```python +LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} +BOOL_PARAMS = {"replace"} + + +def _type_error(ctype, name, value) -> str | None: + """Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500. + null допустим везде — обязательность проверяется отдельно.""" + 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}" +``` + +В `validate` сразу после проверки `unknown` (до проверки `ensure_fields без полей`): + +```python + bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] + if bad: + raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad)) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_commands.py -q && uv run pytest -q` +Expected: PASS, весь набор зелёный (если упал старый тест, отправлявший параметр другого типа, — исправить тест под +контракт спеки и записать рулинг R-n в design.md). + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/commands.py tests/test_commands.py +git commit -m "fix(commands): проверка типов параметров — 422 вместо 500 (#13) + +M-3 (hub#12). + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Отчёты — обрезка ошибки, поздний `done` по `failed`, статус отменённого пакета, пустой полный прогон + +**Files:** +- Modify: `src/jira_hub/queue.py` (`_status_of`, `report_result`) +- Test: `tests/test_queue.py` + +**Interfaces:** +- Consumes: `queue.claim`, `queue.cancel_batch`, `queue.cancel_command`, `queue.MAX_DELIVERIES`, `mirror.rename_note` + (Task 2). +- Produces: `queue.report_result(...)` — сигнатура прежняя; `queue.ERROR_LIMIT = 1500`. + +- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_queue.py`: + +```python +def _fail_by_limit(conn, cid): + for _ in range(queue.MAX_DELIVERIES - 1): + assert queue.claim(conn)["id"] == cid + queue.claim(conn) # четвёртый claim: cid → failed по пределу + assert queue.get_command(conn, cid)["status"] == "failed" + + +def test_error_is_trimmed_to_1500(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + c = queue.claim(conn) + out = queue.report_result(conn, c["id"], "failed", error="е" * 5000) + assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500 + + +def test_late_done_after_delivery_limit_keeps_issue_key(): + conn = fresh() + mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import") + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + _fail_by_limit(conn, c["id"]) + late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2) + assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True} + assert late["error"] == "агент не отчитался после 3 выдач" + assert mirror.get_note(conn, "A-30")["fact_status"] == "План" and mirror.get_note(conn, "new:8") is None + again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"}) + assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет + + +def test_cancelled_batch_with_done_command_is_cancelled(): + conn = fresh() + b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")]) + first = queue.claim(conn) + queue.report_result(conn, first["id"], "done", result={"changed": []}) + second = queue.claim(conn) + queue.cancel_batch(conn, b["id"]) + queue.report_result(conn, second["id"], "done", result={"changed": []}) + view = queue.batch_view(conn, b["id"]) + assert view["status"] == "cancelled" + assert view["counts"]["done"] == 2 + + +def test_empty_full_refresh_keeps_neighbors(): + conn = fresh() + jql = settings.get(conn, "base_jql") + mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [], + fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона + queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}]) + r = queue.claim(conn) + queue.report_result(conn, r["id"], "done") # загрузок состояния не было: 0 задач + assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0 +``` + +`mirror.apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True)`; `fetched_at` в прошлом +нужен, чтобы до исправления задача попала под `fetched_at < started_at` и тест падал. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py -q` +Expected: FAIL — `ERROR_LIMIT` нет, ошибка 5000 символов; поздний `done` по `failed` отброшен (`result is None`); статус +отменённого пакета `done`; задача `A-1` помечена `neighbor = 1`. + +- [ ] **Step 3: Implement** — в `src/jira_hub/queue.py`: + +1. Константа рядом с `MAX_DELIVERIES`: `ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands)`. +2. `_status_of`: + +```python +def _status_of(cmds, cancelled_at=None) -> str: + sts = [c["status"] for c in cmds] + if not sts: + return "done" + if all(s in TERMINAL for s in sts): + return "cancelled" if cancelled_at else "done" # отменённый пакет — отменён, даже с частью done (T5.6) + if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts): + return "running" + return "queued" +``` + +3. `report_result`: в начале функции, после проверки `status`, — + `error = None if error is None else str(error)[:ERROR_LIMIT]`. Ветку позднего отчёта заменить: + +```python + if cmd["status"] in TERMINAL: + # поздний отчёт о созданной задаче по команде, которую хаб уже закрыл (force-отмена — M-5, предел выдач — + # M-4 #12): ключ не теряем, статус не трогаем + if (cmd["status"] in ("cancelled", "failed") and cmd["type"] == "ensure_issue" and status == "done" + and result and result.get("key") and not cmd["result"]): + conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", + (_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id)) + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат", + command_id, cmd["status"], result["key"]) + return get_command(conn, command_id) + return cmd # идемпотентно; терминальный статус не затирается +``` + +4. Пометка соседей по полному прогону — только при непустой загрузке (M-12): + +```python + if status == "done" and is_full_refresh(conn, command_id): + # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) — + # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12) + run = conn.execute("SELECT started_at, issues_count FROM sync_run WHERE command_id = ?", (command_id,)).fetchone() + if run and run["issues_count"] > 0: + conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],)) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_queue.py -q && uv run pytest -q` +Expected: PASS, весь набор зелёный. Если старый тест полного прогона рассчитывал на пометку соседей без загрузки +состояния — дописать в нём загрузку хотя бы одной задачи через `record_state`/`POST /agent/state`, смысл теста не менять. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/queue.py tests/test_queue.py +git commit -m "fix(queue): обрезка error, поздний done по failed, статус отменённого пакета, пустой полный прогон (#13) + +M-4 (hub#12), T5.6 и M-12 (hub#4). + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Довыдача `refresh` — счётчики и `finished_at` + +**Files:** +- Modify: `src/jira_hub/queue.py` (`claim` — ветка довыдачи, `_redeliver`) +- Test: `tests/test_queue.py` + +**Interfaces:** +- Consumes: `queue.record_state(conn, command_id, issues_count, links_count)`, `queue.MAX_DELIVERIES`. +- Produces: поведение — довыдача `refresh` обнуляет `sync_run.issues_count/links_count` (`started_at` прежний); перевод + `refresh` в `failed` по пределу проставляет `sync_run.finished_at`. + +- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_queue.py`; заодно переименовать + `test_fresh_definer_still_blocks_queue` в `test_redelivered_definer_blocks_dependents` (тело не менять; M-10 #12): + +```python +def test_redelivered_refresh_resets_counts(): + conn = fresh() + queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + r = queue.claim(conn) + queue.record_state(conn, r["id"], 150, 40) + started = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["started_at"] + assert queue.claim(conn)["id"] == r["id"] # отчёт потерян — довыдача + queue.record_state(conn, r["id"], 150, 40) + run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone() + assert (run["issues_count"], run["links_count"]) == (150, 40) + assert run["started_at"] == started + + +def test_refresh_failed_by_limit_finishes_sync_run(): + conn = fresh() + queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + r = queue.claim(conn) + for _ in range(queue.MAX_DELIVERIES): + queue.claim(conn) + assert queue.get_command(conn, r["id"])["status"] == "failed" + assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py -q` +Expected: FAIL — `issues_count = 300`; `finished_at` пуст. + +- [ ] **Step 3: Implement** — в `src/jira_hub/queue.py`: + +В `_redeliver`, сразу после `UPDATE command SET status = 'failed' …`: + +```python + if cmd["type"] == "refresh": # загрузка закончилась ничем — журнал синхронизаций закрыт (M-4 #12) + conn.execute("UPDATE sync_run SET finished_at = ? WHERE command_id = ?", (now, cmd["id"])) +``` + +В `claim`, в ветке `if cmd is not None:` (довыдача), после `UPDATE command SET claimed_at …, deliveries = deliveries + 1`: + +```python + if cmd["type"] == "refresh": + # повторное выполнение загрузит то же заново: счётчики с нуля; started_at — граница пометки соседей, + # не сдвигается (M-5 #12) + conn.execute("UPDATE sync_run SET issues_count = 0, links_count = 0 WHERE command_id = ?", (cmd["id"],)) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_queue.py -q && uv run pytest -q` +Expected: PASS, весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/queue.py tests/test_queue.py +git commit -m "fix(queue): довыдача refresh не удваивает счётчики, предел выдач закрывает sync_run (#13) + +M-4, M-5, M-10 (hub#12). + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Отмена пакета с командой у агента; подсказка про метки в MCP + +**Files:** +- Modify: `src/jira_hub/queue.py` (`cancel_batch`) +- Modify: `src/jira_hub/mcp_server.py` (`fmt_batch`, docstring `batch_cancel`, `batch_create`) +- Test: `tests/test_queue.py`, `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `queue.batch_view`. +- Produces: `queue.cancel_batch(conn, batch_id) -> dict` — вид пакета + `still_claimed: list[{"id": int, "type": str}]`; + `mcp_server.fmt_batch(b)` выводит строку на каждую запись `still_claimed`. + +- [ ] **Step 1: Write the failing tests** + +В `tests/test_queue.py`: + +```python +def test_cancel_batch_reports_still_claimed(): + conn = fresh() + b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + view = queue.cancel_batch(conn, b["id"]) + assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}] + assert queue.get_command(conn, c["id"])["status"] == "claimed" +``` + +В `tests/test_mcp.py`: + +```python +def test_fmt_batch_warns_about_claimed(): + b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running", + "remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [], + "still_claimed": [{"id": 30, "type": "ensure_fields"}]} + text = mcp_server.fmt_batch(b) + assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text + + +def test_batch_create_mentions_labels(): + doc = mcp_server.batch_create.__doc__ or "" + assert "labels" in doc and "AWPCA" in doc +``` + +Если `batch_create` после декораторов `@mcp.tool()`/`@guard` теряет `__doc__` — взять описание так, как его видит MCP +(посмотреть, как это делает `test_async_tools_are_coroutines_and_registered`), и проверять его. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_mcp.py -q` +Expected: FAIL — `KeyError: 'still_claimed'`; строки о команде у агента нет; в описании нет `labels`. + +- [ ] **Step 3: Implement** + +`queue.cancel_batch` — вместо `return batch_view(conn, batch_id)`: + +```python + view = batch_view(conn, batch_id) + # взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12) + view["still_claimed"] = [{"id": c["id"], "type": c["type"]} for c in view["commands"] if c["status"] == "claimed"] + return view +``` + +`mcp_server.fmt_batch` — перед `return "\n".join(lines)`: + +```python + for s in b.get("still_claimed") or []: + lines.append(f" команда #{s['id']} ({s['type']}) уже у агента и будет выполнена; остановить — " + f"POST /commands/{s['id']}/cancel с force: true") +``` + +Docstring `batch_cancel`: `"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает — +в ответе подсказка (POST /commands/N/cancel с force)."""` + +Docstring `batch_create` дополнить предложением: `ensure_issue передавайте с labels, включающими базовую метку проекта +(AWPCA): хаб и агент метки сами не добавляют, задача без неё выпадет из базовой JQL и из зеркала.` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_queue.py tests/test_mcp.py -q && uv run pytest -q` +Expected: PASS, весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/queue.py src/jira_hub/mcp_server.py tests/test_queue.py tests/test_mcp.py +git commit -m "feat(queue,mcp): отмена пакета сообщает о команде у агента; подсказка про labels (#13) + +M-11 (hub#12), T3.7 (agent#4). + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Реле — 502 на сбой протокола, `work/` чистится один раз на папку, `serve --host` + +**Files:** +- Modify: `src/jira_hub/relay.py` (`__init__`, `_prepare`, `_forward`) +- Test: `tests/test_relay.py` + +**Interfaces:** +- Consumes: `api.start(hub, host, port) -> (server, stop_event)`; `relay.Relay(hub, port, host="127.0.0.1", poll=0.1)`. +- Produces: `Relay._cleaned: Path | None` — папка, чей `work/` уже вычищен после старта. + +- [ ] **Step 1: Write the failing tests** — дописать в `tests/test_relay.py` (импорт `http.client` — в начало файла): + +```python +def test_protocol_error_gives_502(env, monkeypatch): + r, root = env["relay"], env["root"] + r.step() + + def broken(*a, **k): + raise http.client.BadStatusLine("мусор") + monkeypatch.setattr("urllib.request.urlopen", broken) + put_request(root, "b1", "GET", "/agent/settings", env["agent"]) + run_once(r) + resp = response(root, "b1") + assert resp["status"] == 502 and "хаб недоступен" in resp["body"] + + +def test_reprepare_keeps_work(env, capsys): + r, root = env["relay"], env["root"] + r.step() # первая подготовка + (root / "work" / "busy.json").write_text("{}", encoding="utf-8") + (root / "req").rmdir() # пропал подкаталог — реле подготовит папку заново + for _ in range(3): + r.step() + assert (root / "req").is_dir() and (root / "work" / "busy.json").exists() + out = capsys.readouterr().out + assert "перезапуск реле" not in out and "подготовлена заново" in out + + +def test_relay_targets_serve_host(tmp_path, monkeypatch): + seen = [] + + class Spy(relay.Relay): + def __init__(self, hub, port, host="127.0.0.1", poll=0.1): + seen.append(host) + super().__init__(hub, port, host, poll) + + monkeypatch.setattr(relay, "Relay", Spy) + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + for host in ("0.0.0.0", "localhost"): + srv, stop = api.start(h, host, 0) + stop.set() + srv.server_close() + assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_relay.py -q` +Expected: FAIL — `b1` без ответа (исключение в потоке); `busy.json` удалён и в логе «перезапуск реле»; +`test_relay_targets_serve_host` может пройти сразу — это закрепление (M-1 #8). + +- [ ] **Step 3: Implement** — в `src/jira_hub/relay.py`: + +- импорт `import http.client`; +- в `__init__`: `self._cleaned: Path | None = None # папка, чей work/ вычищен после старта реле`; +- `_prepare`: + +```python + def _prepare(self) -> None: + for sub in ("req", "resp", "work"): + (self.root / sub).mkdir(exist_ok=True) + if self._cleaned != self.root: + # первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает + for stale in (self.root / "work").iterdir(): + stale.unlink(missing_ok=True) + say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)") + self._cleaned = self.root + else: + # пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем + say(f"relay: папка {self.root} подготовлена заново") + self._ready, self._warned = True, None +``` + +- в `_forward` последний `except` расширить: `except (urllib.error.URLError, http.client.HTTPException, OSError) as e:` + (текст ответа прежний: `хаб недоступен изнутри: {e}`). + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_relay.py -q && uv run pytest -q` +Expected: PASS, весь набор зелёный (`test_work_cleared_on_prepare` проходит: первая подготовка чистит). + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/relay.py tests/test_relay.py +git commit -m "fix(relay): 502 на сбой протокола, work/ чистится один раз на папку, тест serve --host (#13) + +M-1, M-3, M-4 (hub#8). + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Документы и `.gitignore` + +**Files:** +- Modify: `.gitignore`, `ARCHITECTURE.md`, `CLAUDE.md` + +**Interfaces:** нет (только документы). + +- [ ] **Step 1: `.gitignore`** — дописать строку `.wt/` (worktree внутри репозитория не должны попадать в `git status`). +- [ ] **Step 2: `ARCHITECTURE.md`**: + - строка `paths`: `каталог данных (\`JIRA_HUB_HOME\`), пути базы и \`backups/\`, права 0700 (\`secure_dir\`)`; + - строка `db`: зависимость `backup`, `paths`; строка `backup`: зависимость `paths`; + - строка `relay`: `реле общей папки (настройка \`relay_dir\`): поток в \`serve\`, файлы \`req/\` → свой HTTP (адрес + \`serve\`, для \`0.0.0.0\` — \`127.0.0.1\`) → \`resp/\`; агент с \`hub_url = folder:<путь>\` ходит через него; HTTP-обработчики + о нём не знают`; + - в «Потоки данных» строка: `- Агент за VPN: агент → файлы в общей папке (\`relay_dir\`) → \`relay\` → HTTP хаба → ответ файлом.` +- [ ] **Step 3: `CLAUDE.md`** — в «Что это» заменить «текущего change нет (последний — …bridge-reliability/)» на + «текущий change — `openspec/changes/bridge-hygiene/`». В «Стек и ограничения» к строке про данные добавить + «каталог данных и `backups/` — права 0700». +- [ ] **Step 4: Проверка** + +Run: `git status --short && openspec validate bridge-hygiene --strict && openspec validate --specs --strict && uv run pytest -q` +Expected: `.wt/` не виден в `git status`; обе проверки OpenSpec зелёные; весь набор тестов зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add .gitignore ARCHITECTURE.md CLAUDE.md +git commit -m "docs: реле и relay_dir в ARCHITECTURE, права каталога, текущий change, .wt/ в .gitignore (#13) + +Co-Authored-By: Claude Opus 5.5 " +``` diff --git a/openspec/changes/bridge-hygiene/design.md b/openspec/changes/bridge-hygiene/design.md index 5c5101d..d7c316d 100644 --- a/openspec/changes/bridge-hygiene/design.md +++ b/openspec/changes/bridge-hygiene/design.md @@ -59,11 +59,14 @@ строк; `labels`, `labels_add`, `labels_remove`, `keys` — списки строк; `replace` — bool. 9. **Миграция под блокировкой (M-2 #12).** `migrate`: версия читается вне транзакции; если ниже текущей — `BEGIN IMMEDIATE` (блокировка записи), повторное чтение версии, и только если всё ещё ниже — копия базы и миграция в той же - транзакции. Копия — `sqlite3.Connection.backup` из того же соединения, работает при открытой транзакции (читает - снимок). Второй `connect`, дождавшись блокировки, видит новую версию и копию не делает. Docstring исправляется. + транзакции. Копия делается отдельным соединением к тому же файлу (`VACUUM INTO` внутри транзакции SQLite запрещает, + а читатель при WAL работает и под чужой блокировкой записи и видит последнее зафиксированное состояние — базу до + миграции; проверено 14.10.2026). Второй `connect`, дождавшись блокировки, видит новую версию и копию не делает. + Docstring исправляется. 10. **Реле (M-3 #8, M-4 #8).** `_forward` ловит `http.client.HTTPException` вместе с `URLError`/`OSError` → 502. Чистка - `work/` выносится из `_prepare` в старт реле (первый `_prepare` после запуска потока); повторная подготовка (смена - `relay_dir`, пропала папка) создаёт подкаталоги без чистки и пишет «relay: папка подготовлена заново». Запросы, + `work/` — только при первой подготовке каждой папки после старта реле (реле помнит, какую папку уже чистило; новая + папка после смены `relay_dir` чистится); повторная подготовка той же папки (пропал подкаталог, не было доступа) + создаёт подкаталоги без чистки и пишет «relay: папка подготовлена заново». Запросы, взятые до пропажи папки, обрабатываются своими потоками или пропадают вместе с папкой. 11. **Отмена пакета с взятой командой (M-11 #12).** `cancel_batch` возвращает вид пакета с полем `still_claimed` — список `{id, type}` команд пакета в `claimed`. MCP `batch_cancel` выводит строку «команда #N уже у агента и будет @@ -132,8 +135,8 @@ | R-4 (design #6) | 2026-10-14 | Довыдача `refresh` обнуляет счётчики, `started_at` не трогает | `started_at` — граница пометки соседей; сдвиг пометил бы прочитанное первым выполнением | только отображение счётчиков | | R-5 (design #7) | 2026-10-14 | Соседи по полному прогону помечаются только при `issues_count > 0` | пустая выборка валидной JQL — почти всегда сбой (метка, права) | настоящий «0 задач» не пометит соседей до следующего прогона | | R-6 (design #8) | 2026-10-14 | Проверка типов всех параметров команд, не только `summary` | M-3 #12 — частный случай; одна проверка дешевле серии 500 | слишком строгая проверка отвергнет допустимый пакет — 422 с именем параметра, видно сразу | -| R-7 (design #9) | 2026-10-14 | Копия перед миграцией — внутри `BEGIN IMMEDIATE`, после повторной проверки версии | одна копия при параллельных `connect` (M-2 #12) | блокировка записи на долю секунды при миграции | -| R-8 (design #10) | 2026-10-14 | `work/` чистится только при первой подготовке после старта потока реле | при повторной подготовке взятые запросы ещё обрабатываются своими потоками (M-4 #8) | после пропажи папки в `work/` может остаться файл до перезапуска — безвредно | +| R-7 (design #9) | 2026-10-14 | Копия перед миграцией — под `BEGIN IMMEDIATE`, после повторной проверки версии, отдельным соединением-читателем | одна копия при параллельных `connect` (M-2 #12) | блокировка записи на долю секунды при миграции | +| R-8 (design #10) | 2026-10-14 | `work/` чистится только при первой подготовке каждой папки после старта реле | при повторной подготовке взятые запросы ещё обрабатываются своими потоками (M-4 #8) | после пропажи папки в `work/` может остаться файл до перезапуска — безвредно | | R-9 (design #11) | 2026-10-14 | Отмена пакета возвращает `still_claimed`; MCP-инструмент отмены команды не добавляется | M-11 #12 — про информирование; force-отмена нужна редко | для остановки взятой команды нужен вызов API, не MCP | | R-10 (design #13) | 2026-10-14 | Расхождения спеки и кода из разбора D закрываются правкой спеки, не кода | код разумнее текста спеки (спринты с доски, автообновление от `sync_run`), потребитель один | нет | | R-11 (design #21) | 2026-10-14 | Агент — версия 4, `min_agent_version` не поднимается | поведение сравнения и темпа изменилось; v3 продолжает работать | нет | diff --git a/openspec/changes/bridge-hygiene/specs/hub-api/spec.md b/openspec/changes/bridge-hygiene/specs/hub-api/spec.md index 22ffef8..8ffb82c 100644 --- a/openspec/changes/bridge-hygiene/specs/hub-api/spec.md +++ b/openspec/changes/bridge-hygiene/specs/hub-api/spec.md @@ -134,8 +134,9 @@ ошибки в `body`; - нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог. -При старте потока реле (первая подготовка папки) SHALL удалить файлы из `work/` с записью в лог. Повторная подготовка -(смена `relay_dir`, пропавший подкаталог) SHALL только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту +При первой подготовке каждой папки после старта реле (в том числе новой папки после смены `relay_dir`) реле SHALL +удалить файлы из `work/` с записью в лог. Повторная подготовка той же папки (пропал подкаталог, не было доступа) SHALL +только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay <секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются.