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

864 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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` (импорты — в начало файла):
```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 <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:")`):
```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 <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`:
```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 <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`:
```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 <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):
```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 <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`:
```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 <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` — в начало файла):
```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 <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**
```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 <noreply@anthropic.com>"
```