sdlc-hub/docs/superpowers/plans/2026-10-14-bridge-hygiene-hub.md
2026-10-14 12:28:39 +04:00

43 KiB
Raw Permalink Blame History

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 <noreply@anthropic.com>.
  • Решения, принятые исполнителем без обсуждения, — в 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 (импорты — в начало файла):

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 целиком:
"""Где лежат данные хаба: каталог, база, резервные копии."""
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 целиком:

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
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 <noreply@anthropic.com>"

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:")):

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:
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
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 <noreply@anthropic.com>"

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) с текстом команда <i> (<тип>): <параметр> — <ожидаемое>.

  • Step 1: Write the failing tests — дописать в tests/test_commands.py:

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:
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 без полей):

        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
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 <noreply@anthropic.com>"

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:

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:
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"
  1. report_result: в начале функции, после проверки status, — error = None if error is None else str(error)[:ERROR_LIMIT]. Ветку позднего отчёта заменить:
        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                                         # идемпотентно; терминальный статус не затирается
  1. Пометка соседей по полному прогону — только при непустой загрузке (M-12):
            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
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 <noreply@anthropic.com>"

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

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' …:

        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:

            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
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 <noreply@anthropic.com>"

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:

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:

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

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

    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
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 <noreply@anthropic.com>"

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 — в начало файла):

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:

    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
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 <noreply@anthropic.com>"

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
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 <noreply@anthropic.com>"