fix: ответ PUT /settings, «создание задачи отменено», CRLF в подтверждении, реле по resolve() (O-11, O-10, O-9, minor-1, T3.1 #16) (#32)

PUT /settings отвечает всеми настройками (R-3); пропуск при cancelled-создателе — «создание задачи new:N отменено» (R-4); ensure_fields сравнивает строки без различия CRLF/LF; _cleaned по resolve(); _type_error по веткам (решения 9–12, 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 10:42:03 +04:00
parent 8a319ea595
commit e180bb4e5f
9 changed files with 146 additions and 19 deletions

View File

@ -144,7 +144,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок |
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) |
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) |
```bash
TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p')

View File

@ -306,7 +306,8 @@ def h_settings_get(hub, conn, m, q, body):
def h_settings_put(hub, conn, m, q, body):
"""Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
(settings.validate): ошибка — 422, версия настроек не меняется."""
(settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у
GET /settings (hub-hygiene-d, решение 9)."""
bad = [k for k in body if k in settings.PROTECTED]
if bad:
raise ApiError(422, "protected", ", ".join(bad))
@ -315,11 +316,10 @@ def h_settings_put(hub, conn, m, q, body):
settings.validate(key, value)
except ValueError as e:
raise ApiError(422, "bad_setting", str(e))
version = settings.get(conn, "settings_version")
with db.transaction(conn):
for key, value in body.items():
version = settings.set_(conn, key, value)
return 200, {"settings_version": version}
settings.set_(conn, key, value)
return 200, settings.get_all(conn)
ROUTES = [

View File

@ -57,15 +57,16 @@ def _type_error(ctype, name, value) -> str | None:
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}"
return None if _is_str_list(value) else f"{name} — список строк"
if name in BOOL_PARAMS:
return None if isinstance(value, bool) else f"{name} — true или false"
if ctype == "ensure_status" and name == "target":
return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк"
return None if isinstance(value, str) else f"{name} — строка"
def _is_str_list(value) -> bool:
return isinstance(value, list) and all(isinstance(v, str) for v in value)
def validate(commands) -> list[dict]:
@ -133,6 +134,14 @@ def estimate(types, pacing: dict) -> int:
return sum(int(pacing.get(t, 0)) for t in types)
def _norm_text(value):
"""Значение поля для сравнения с зеркалом: переводы строки CRLF и LF не различаются (описание, правленное в UI Jira,
приходит с CRLF, O-9 #16); null и пустая строка равны (решение 11)."""
if isinstance(value, str):
value = value.replace("\r\n", "\n")
return value or None
def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются:
потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability,
@ -145,7 +154,7 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
return False, None
if ctype == "ensure_fields":
for field in FIELD_PARAMS:
if field in p and (issue.get(field) or None) != (p[field] or None):
if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]):
return False, None
if any(l not in issue["labels"] for l in p.get("labels_add", [])):
return False, None

View File

@ -114,6 +114,12 @@ def _definer_status(conn, batch_id, temp_key):
return r["status"] if r else "failed"
def _skip_reason(conn, batch_id, unresolved) -> str:
"""Текст skipped для зависимой команды: отменённое создание называется отменой (O-10 #16, решение 10)."""
cancelled = [k for k in unresolved if _definer_status(conn, batch_id, k) == "cancelled"]
return f"создание задачи {cancelled[0]} отменено" if cancelled else f"задача {unresolved[0]} не создана"
def _next(conn) -> dict | None:
rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id "
"WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall()
@ -131,7 +137,7 @@ def _next(conn) -> dict | None:
unresolved = [] if cmd["params"]["keys"] else unresolved
if unresolved:
conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?",
(db.now(), f"задача {unresolved[0]} не создана", cmd["id"]))
(db.now(), _skip_reason(conn, cmd["batch_id"], unresolved), cmd["id"]))
continue
return cmd
return None

View File

@ -61,7 +61,7 @@ class Relay:
self._cleanup_at = 0.0
self._ready = False
self._warned: str | None = None
self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле
self._cleaned: set[Path] = set() # папки (по resolve()), чей work/ вычищен после старта реле
self.workers: list[threading.Thread] = []
# --- цикл ---------------------------------------------------------------------
@ -124,12 +124,13 @@ class Relay:
def _prepare(self) -> None:
for sub in ("req", "resp", "work"):
(self.root / sub).mkdir(exist_ok=True)
if self.root not in self._cleaned:
key = _same_dir_key(self.root)
if key not in self._cleaned:
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
for stale in (self.root / "work").iterdir():
stale.unlink(missing_ok=True)
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
self._cleaned.add(self.root)
self._cleaned.add(key)
else:
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
say(f"relay: папка {self.root} подготовлена заново")
@ -225,6 +226,15 @@ class Relay:
p.unlink(missing_ok=True)
def _same_dir_key(path: Path) -> Path:
"""Ключ папки для «уже вычищена»: resolve() — симлинк и другая запись той же папки совпадают (minor-1 #16,
решение 12); ошибка resolve — путь как записан."""
try:
return path.resolve()
except (OSError, RuntimeError):
return path
def _error(error: str, detail: str) -> str:
return json.dumps({"error": error, "detail": detail}, ensure_ascii=False)

View File

@ -509,3 +509,20 @@ def test_agent_reported_even_with_bad_update_fields(hub, tmp_path):
v = hub["client"].get("/agent")
assert v["agent_reported"] is True and v["launcher_version"] is None
assert v["update_hint"] == "агент запущен без run.py — обновится только вручную"
def test_settings_put_returns_all_settings(hub):
"""O-11 #16, решение 9, спека «Ответ на запись настроек»: все настройки после записи, как GET /settings, без токенов."""
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
out = c.put("/settings", {"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"})
assert out["relay_dir"] == "/Volumes/externalSSD/utm/rshb/jira-bridge" and out["settings_version"] == v0 + 1
assert "tokens" not in out and out["base_jql"] == "project = AURORA AND labels = AWPCA"
assert out == c.get("/settings")
def test_settings_put_empty_body_returns_settings_unchanged(hub):
"""Пустое тело — 200, все настройки, версия не меняется."""
c = hub["client"]
before = c.get("/settings")
assert c.put("/settings", {}) == before

View File

@ -147,3 +147,33 @@ def test_validate_accepts_null_for_clearable_and_path_target():
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
])
assert len(out) == 3
@pytest.mark.parametrize("mirror_value, param, expected", [
("a\r\nb", "a\nb", True), ("a\nb", "a\r\nb", True), ("a\r\nb\r\n", "a\nb\n", True),
("a\r\nb", "a b", False), (None, "", True), ("", None, True), ("a\rb", "a\nb", False),
])
def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expected):
"""O-9 #16, решение 11: CRLF и LF не различаются, null и пустая строка равны; одиночный CR — другой текст.
summary с CRLF в зеркале и LF в команде — в каждом случае: сравнение одно для всех строковых полей."""
conn.execute("UPDATE issue SET description = ?, summary = ? WHERE key = 'A-1'", (mirror_value, "x\r\ny"))
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
"params": {"key": "A-1", "description": param, "summary": "x\ny"}}, LT)
assert ok is expected
@pytest.mark.parametrize("ctype, name, value, msg", [
("ensure_fields", "duedate", True, "duedate — строка"),
("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"),
("ensure_fields", "labels_add", ["x", 1], "labels_add — список строк"),
("ensure_link", "replace", "да", "replace — true или false"),
("ensure_status", "target", 5, "target — строка или список строк"),
("ensure_status", "target", ["Разработка", "Готово"], None),
("ensure_status", "target", "Готово", None),
("ensure_fields", "labels_add", [], None),
("ensure_fields", "assignee", None, None),
("ensure_link", "replace", False, None),
])
def test_type_error_messages(ctype, name, value, msg):
"""T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один)."""
assert commands._type_error(ctype, name, value) == msg

View File

@ -462,3 +462,40 @@ def test_cancel_batch_reports_still_claimed(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"
def test_dependent_skipped_when_creation_cancelled(conn):
"""O-10 #16, решение 10, спека «Создание отменено»: зависимая команда — «создание задачи new:N отменено»."""
b = queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:901", "issue_type": "task", "summary": "Отменённая"}},
{"type": "ensure_comment", "params": {"key": "new:901", "marker": "m", "body": "b"}}])
c = queue.claim(conn)
queue.cancel_command(conn, c["id"], force=True)
assert queue.claim(conn) is None # комментарий и хвостовой refresh пропущены
cmds = queue.batch_view(conn, b["id"])["commands"]
assert [x["status"] for x in cmds] == ["cancelled", "skipped", "skipped"]
assert cmds[1]["error"] == "создание задачи new:901 отменено"
def test_dependent_on_failed_and_cancelled_names_cancelled(conn):
"""Review Focus 4: два временных ключа — создатель первого failed, второго cancelled: текст по отменённому."""
b = queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Упавшая"}},
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Отменённая"}},
{"type": "ensure_link", "params": {"source": "new:1", "target": "new:2", "link_type": "связанные"}}])
first = queue.claim(conn)
queue.report_result(conn, first["id"], "failed", error="HTTP 400")
second = queue.claim(conn)
queue.cancel_command(conn, second["id"], force=True)
queue.claim(conn)
link = queue.batch_view(conn, b["id"])["commands"][2]
assert link["status"] == "skipped" and link["error"] == "создание задачи new:2 отменено"
def test_reconcile_confirms_fields_with_crlf(conn):
"""O-9 #16, решение 11, спека «Описание с CRLF в зеркале»: потерянный отчёт закрывается по состоянию."""
queue.create_batch(conn, "x", "claude", [{"type": "ensure_fields", "params": {"key": "A-1", "description": "a\nb"}}])
c = queue.claim(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], [])
assert queue.reconcile(conn) == [c["id"]]
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"

View File

@ -402,3 +402,21 @@ def test_relay_targets_serve_host(tmp_path, monkeypatch):
stop.set()
srv.server_close()
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)
def test_symlink_to_same_dir_not_cleaned_again(env, tmp_path, capsys):
"""minor-1 #16, решение 12: relay_dir сменили на симлинк к той же папке — work/ не чистится второй раз."""
r, root, h = env["relay"], env["root"], env["hub"]
r.step() # root вычищен
link = tmp_path / "bridge-link"
link.symlink_to(root, target_is_directory=True)
(root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос ещё обрабатывается
conn = h.conn()
settings.set_(conn, "relay_dir", str(link))
conn.close()
r._settings_at = None
capsys.readouterr()
r.step()
out = capsys.readouterr().out
assert (root / "work" / "busy.json").exists()
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out