docs: план хаба bridge-hygiene; уточнения дизайна (копия при миграции, чистка work/) (#13)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 12:28:39 +04:00
parent a7fed00419
commit 70bcd4cf25
3 changed files with 875 additions and 8 deletions

View File

@ -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 <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>"
```

View File

@ -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 продолжает работать | нет |

View File

@ -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 <METHOD> <path> <status>
<секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются.