fix: правки по финальному ревью bridge-hygiene — права backups/, cwd, чистка work/ по папкам, отмена завершённого пакета (#13)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 13:11:03 +04:00
parent 4b76152959
commit 8e82b31d48
11 changed files with 116 additions and 10 deletions

View File

@ -20,7 +20,7 @@
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` |
Цепочки: `api → queue → commands/mirror/settings → db`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
## Потоки данных ## Потоки данных
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.

View File

@ -167,4 +167,4 @@
#### Scenario: Повторная подготовка не трогает взятые запросы #### Scenario: Повторная подготовка не трогает взятые запросы
- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/` - **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/`
- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «перезапуск реле» - **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «удалён незавершённый запрос»

View File

@ -14,6 +14,7 @@ from urllib.parse import parse_qs, urlparse
from . import backup, db, mirror, queue, relay, settings from . import backup, db, mirror, queue, relay, settings
from .commands import CommandError from .commands import CommandError
from .paths import backups_dir as default_backups_dir from .paths import backups_dir as default_backups_dir
from .paths import secure_dir
from .queue import ClaimedError from .queue import ClaimedError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -33,6 +34,7 @@ class Hub:
self.db_path = Path(db_path) self.db_path = Path(db_path)
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.claim_wait = 30 self.claim_wait = 30
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
conn = self.conn() conn = self.conn()
settings.ensure_defaults(conn) settings.ensure_defaults(conn)
conn.close() conn.close()

View File

@ -117,7 +117,7 @@ def connect(path) -> sqlite3.Connection:
"""Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных """Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных
операций — transaction().""" операций — transaction()."""
is_memory = str(path) == ":memory:" is_memory = str(path) == ":memory:"
if not is_memory: if not is_memory and Path(path).parent != Path("."): # путь без каталога — рабочий каталог процесса не трогаем
paths.secure_dir(Path(path).parent) paths.secure_dir(Path(path).parent)
conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False) conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -137,7 +137,8 @@ def migrate(conn: sqlite3.Connection, path=None) -> None:
"""Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы """Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Копия базы
(<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный (<каталог базы>/backups, без ротации) и миграция — под одной блокировкой записи (BEGIN IMMEDIATE): параллельный
connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции.""" отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции.
Вызывается только вне транзакции."""
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
version = _schema_version(conn) version = _schema_version(conn)
if version is None: if version is None:

View File

@ -237,7 +237,7 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат", log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат",
command_id, cmd["status"], result["key"]) command_id, cmd["status"], result["key"])
return get_command(conn, command_id) return get_command(conn, command_id)
return cmd # идемпотентно; cancelled не затирается return cmd # идемпотентно; терминальный статус (cancelled/failed) не затирается
now = db.now() now = db.now()
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
(status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id)) (status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id))
@ -285,7 +285,9 @@ def cancel_batch(conn, batch_id) -> dict:
if conn.execute("SELECT 1 FROM batch WHERE id = ?", (batch_id,)).fetchone() is None: if conn.execute("SELECT 1 FROM batch WHERE id = ?", (batch_id,)).fetchone() is None:
raise KeyError(batch_id) raise KeyError(batch_id)
now = db.now() now = db.now()
conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL", (now, batch_id)) # завершённый пакет (нет queued/claimed) отменённым не становится: отменять нечего
conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL AND EXISTS "
"(SELECT 1 FROM command WHERE batch_id = ? AND status IN ('queued', 'claimed'))", (now, batch_id, batch_id))
conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE batch_id = ? AND status = 'queued'", (now, batch_id)) conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE batch_id = ? AND status = 'queued'", (now, batch_id))
view = batch_view(conn, batch_id) view = batch_view(conn, batch_id)
# взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12) # взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12)

View File

@ -61,7 +61,7 @@ class Relay:
self._cleanup_at = 0.0 self._cleanup_at = 0.0
self._ready = False self._ready = False
self._warned: str | None = None self._warned: str | None = None
self._cleaned: Path | None = None # папка, чей work/ вычищен после старта реле self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле
self.workers: list[threading.Thread] = [] self.workers: list[threading.Thread] = []
# --- цикл --------------------------------------------------------------------- # --- цикл ---------------------------------------------------------------------
@ -124,12 +124,12 @@ class Relay:
def _prepare(self) -> None: def _prepare(self) -> None:
for sub in ("req", "resp", "work"): for sub in ("req", "resp", "work"):
(self.root / sub).mkdir(exist_ok=True) (self.root / sub).mkdir(exist_ok=True)
if self._cleaned != self.root: if self.root not in self._cleaned:
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает # первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
for stale in (self.root / "work").iterdir(): for stale in (self.root / "work").iterdir():
stale.unlink(missing_ok=True) stale.unlink(missing_ok=True)
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)") say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
self._cleaned = self.root self._cleaned.add(self.root)
else: else:
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем # пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
say(f"relay: папка {self.root} подготовлена заново") say(f"relay: папка {self.root} подготовлена заново")

View File

@ -75,6 +75,16 @@ def test_agent_flow(hub):
assert c.get("/agent")["online"] is True assert c.get("/agent")["online"] is True
def test_cancel_batch_api_reports_still_claimed(hub):
c, a = hub["client"], hub["agent"]
b = c.post("/batches", {"title": "x", "author": "claude", "commands": [
{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}},
{"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]})
cmd = a.post("/agent/claim", {})["command"]
out = c.post("/batches/%d/cancel" % b["id"], {})
assert out["still_claimed"] == [{"id": cmd["id"], "type": "ensure_fields"}]
def test_interface_routes(hub): def test_interface_routes(hub):
c = hub["client"] c = hub["client"]
with pytest.raises(HubError) as e: with pytest.raises(HubError) as e:

View File

@ -128,6 +128,22 @@ def test_data_dir_is_private(tmp_path):
assert _mode(home) == 0o700 assert _mode(home) == 0o700
def test_existing_backups_dir_made_private_on_start(tmp_path):
from jira_hub import api
backups = tmp_path / "backups"
backups.mkdir()
backups.chmod(0o755)
api.Hub(tmp_path / "hub.sqlite", backups_dir=backups)
assert _mode(backups) == 0o700
def test_connect_without_directory_does_not_chmod_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
tmp_path.chmod(0o755)
db.connect("hub.sqlite").close()
assert _mode(tmp_path) == 0o755
def test_backups_dir_is_private(tmp_path): def test_backups_dir_is_private(tmp_path):
conn = db.connect(tmp_path / "hub.sqlite") conn = db.connect(tmp_path / "hub.sqlite")
backup.make_backup(conn, tmp_path / "backups", "batch-1") backup.make_backup(conn, tmp_path / "backups", "batch-1")

View File

@ -116,6 +116,18 @@ def test_rename_note_merges_with_existing():
assert mirror.get_note(conn, "new:5") is None assert mirror.get_note(conn, "new:5") is None
def test_rename_note_keeps_newer_real_key_stamp():
conn = db.connect(":memory:")
mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import")
mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human")
conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'")
conn.execute("UPDATE issue_note SET updated_at = '2030-01-01T00:00:00+00:00' WHERE key = 'A-6'")
mirror.rename_note(conn, "new:6", "A-6")
note = mirror.get_note(conn, "A-6")
assert note["updated_by"] == "human" and note["updated_at"] == "2030-01-01T00:00:00+00:00"
assert note["fact_status"] == "В работе" and note["owner_note"] == "позже"
def test_rename_note_empty_string_does_not_overwrite(): def test_rename_note_empty_string_does_not_overwrite():
conn = db.connect(":memory:") conn = db.connect(":memory:")
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human") mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")

View File

@ -435,6 +435,34 @@ def test_empty_full_refresh_keeps_neighbors():
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0 assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
def test_cancel_finished_batch_stays_done():
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1")])
while (c := queue.claim(conn)):
queue.report_result(conn, c["id"], "done", result={"changed": []})
view = queue.cancel_batch(conn, b["id"])
assert view["status"] == "done"
assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None
def test_redelivered_full_refresh_marks_neighbors_once():
conn = fresh()
jql = settings.get(conn, "base_jql")
mirror.apply_state(conn, [{"key": "OLD", "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.record_state(conn, r["id"], 1, 0)
mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], [])
assert queue.claim(conn)["id"] == r["id"] # отчёт потерян — довыдача
assert conn.execute("SELECT deliveries FROM command WHERE id = ?", (r["id"],)).fetchone()["deliveries"] == 2
queue.record_state(conn, r["id"], 1, 0)
mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], [])
queue.report_result(conn, r["id"], "done")
nb = {row["key"]: row["neighbor"] for row in conn.execute("SELECT key, neighbor FROM issue")}
assert nb == {"OLD": 1, "NEW": 0}
assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1
def test_redelivered_refresh_resets_counts(): def test_redelivered_refresh_resets_counts():
conn = fresh() conn = fresh()
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])

View File

@ -1,6 +1,7 @@
import http.client import http.client
import json import json
import os import os
import shutil
import threading import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -349,7 +350,41 @@ def test_reprepare_keeps_work(env, capsys):
r.step() r.step()
assert (root / "req").is_dir() and (root / "work" / "busy.json").exists() assert (root / "req").is_dir() and (root / "work" / "busy.json").exists()
out = capsys.readouterr().out out = capsys.readouterr().out
assert "перезапуск реле" not in out and "подготовлена заново" in out assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out
def test_vanished_tree_keeps_work(env, capsys):
r, root = env["relay"], env["root"]
r.step()
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
capsys.readouterr()
shutil.rmtree(root) # папка пропала целиком и появилась снова
root.mkdir()
(root / "work").mkdir()
(root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос, который ещё обрабатывается
for _ in range(3):
r.step()
assert (root / "work" / "busy.json").exists() and (root / "req").is_dir()
assert "удалён незавершённый запрос" not in capsys.readouterr().out
def test_each_dir_cleaned_once(env, tmp_path, capsys):
r, root, h = env["relay"], env["root"], env["hub"]
other = tmp_path / "other"
(other / "work").mkdir(parents=True)
(other / "work" / "old.json").write_text("{}", encoding="utf-8")
r.step() # root вычищен
conn = h.conn()
settings.set_(conn, "relay_dir", str(other))
r._settings_at = None
r.step()
assert not (other / "work" / "old.json").exists() # новая папка чистится
settings.set_(conn, "relay_dir", str(root))
r._settings_at = None
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
r.step()
conn.close()
assert (root / "work" / "busy.json").exists() # возврат A->B->A: A повторно не чистится
def test_relay_targets_serve_host(tmp_path, monkeypatch): def test_relay_targets_serve_host(tmp_path, monkeypatch):