43 KiB
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; основной checkoutjira-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
- Параллельные
connectк базе v1 (старт хаба иjira-hub importодновременно): одна копия, миграция один раз, ни одного исключения — тестtest_parallel_connect_makes_one_backupв Task 1. - Поздний отчёт по
ensure_issue, ставшейfailedпо пределу выдач, приходит дважды: второй ничего не меняет — тест в Task 4. - Слияние заметок, когда у временной заметки поле пустая строка
""(а неNULL): под ключом сохраняется значение — тест в Task 2. refreshдовыдан и снова загружает состояние; затем полный прогон завершён: соседи помечаются поstarted_atпервой выдачи, счётчики равны одной загрузке — тест в Task 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:
- Константа рядом с
MAX_DELIVERIES:ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands). _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"
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 # идемпотентно; терминальный статус не затирается
- Пометка соседей по полному прогону — только при непустой загрузке (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, docstringbatch_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>"