Агент v13: журнал изменений в refresh (#39)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 10:31:47 +04:00
parent de2d5b5252
commit 71e182a162
5 changed files with 301 additions and 3 deletions

View File

@ -11,7 +11,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |

View File

@ -174,7 +174,7 @@ python3 agent.py prune
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
Правила работы:
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
@ -203,6 +203,8 @@ python3 agent.py prune
Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
выкатывается только после v12 (design хаба `projects`, решение 14).
Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая
загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`).
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему.
@ -252,6 +254,10 @@ python3 agent.py prune
`/issue/{key}/comment`.
С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается.
С v13 после состояния `refresh` читает журнал изменений (если хаб передал `history`): по задачам этого прогона с
`updated` не раньше `history.since` и по стартовой выборке `history.jql`. Неполный журнал дочитывается
`GET /issue/{key}?expand=changelog`, всё ещё неполный — в логе «получено N из M» и в `result.history.incomplete`.
Ошибка Jira или хаба (не 2xx) на этом шаге не роняет `refresh`: текст в `result.history.error` или `backfill_error`.
5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
`\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
завершающий перевод строки) — изменение.

View File

@ -33,7 +33,7 @@ import uuid
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple
AGENT_VERSION = 12
AGENT_VERSION = 13
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__))
@ -363,6 +363,9 @@ class HubClient(Http):
def known_keys(self) -> List[str]:
return self.call("GET", "/agent/known-keys")["keys"]
def history(self, payload: dict) -> dict:
return self.call("POST", "/agent/history", payload)
def file(self, sha256: str) -> dict:
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
return self.call("GET", "/agent/files/%s" % sha256)
@ -1199,6 +1202,9 @@ class Executor:
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
"page": pages, "last_page": i + 100 >= len(issues)})
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
history = self.refresh_history(p, cmd, everything)
if history is not None:
result["history"] = history
if missing:
result["missing"] = list(dict.fromkeys(missing))[:50]
if types:
@ -1206,6 +1212,102 @@ class Executor:
return self._out("done", result)
# --- журнал изменений (v13, spec agent «Журнал изменений») -----------------------------------------------
HISTORY_BATCH = 500
def search_changelog(self, jql: str) -> List[dict]:
out, start = [], 0
while True:
page = self.jira.get(API + "/search", jql=jql, fields="key", expand="changelog", startAt=start, maxResults=100)
got = page.get("issues") or []
out += got
start += len(got)
if not got or start >= int(page.get("total") or 0):
return out
def full_changelog(self, raw: dict, incomplete: List[str]) -> List[dict]:
"""Журнал задачи из ответа search; неполный — дочитать GET /issue, всё ещё неполный — что есть, ключ в incomplete."""
key, cl = raw["key"], raw.get("changelog") or {}
got = list(cl.get("histories") or [])
total = cl.get("total")
if isinstance(total, int) and total > len(got):
more = (self.jira.get(API + "/issue/%s" % key, fields="key", expand="changelog").get("changelog") or {})
more_got = list(more.get("histories") or [])
if len(more_got) > len(got):
got = more_got
if total > len(got):
log("журнал %s: получено %d из %d" % (key, len(got), total))
incomplete.append(key)
return got
def refresh_history(self, p: dict, cmd: dict, fetched: List[dict]) -> Optional[dict]:
"""Журнал после состояния: since — задачи этого refresh с updated не раньше since, jql — стартовая загрузка.
Ответ не 2xx Jira или хаба — текст в error (since, отправка) или backfill_error (jql), refresh остаётся done;
обрыв связи — как во всём refresh."""
h = p.get("history")
if not isinstance(h, dict):
return None
out: Dict[str, Any] = {"issues": 0, "changes": 0, "incomplete": []}
names: Dict[str, Any] = {}
def field_id_of(name: str) -> Optional[str]:
if "map" not in names: # один GET /field за refresh, только по нужде
names["map"] = {f.get("name"): f.get("id") for f in self.jira.get(API + "/field") or []
if isinstance(f, dict)}
return names["map"].get(name)
items: List[dict] = []
read = set()
def collect(raws: List[dict], since: Optional[datetime]) -> None:
for raw in raws:
if raw["key"] in read:
continue
read.add(raw["key"])
items.extend(self.changelog_items(raw["key"], self.full_changelog(raw, out["incomplete"]), since,
field_id_of))
if h.get("since"):
since = _jira_time(h["since"])
try:
if since is None:
raise ValueError("history.since — неразбираемое время: %r" % h["since"])
changed = []
for r in fetched:
upd = _jira_time(((r.get("fields") or {}).get("updated")))
if upd is not None and upd >= since and r["key"] not in changed:
changed.append(r["key"])
for i in range(0, len(changed), 100):
collect(self.search_changelog("key in (%s)" % ",".join(changed[i:i + 100])), since)
except HttpError as e:
out["error"] = "HTTP %d %s" % (e.code, e.text[:500])
log("refresh: журнал не прочитан: %s" % out["error"])
except ValueError as e:
out["error"] = str(e)
log("refresh: %s" % e)
if h.get("jql"):
try:
collect(self.search_changelog(h["jql"]), None)
except HttpError as e:
out["backfill_error"] = "HTTP %d %s" % (e.code, e.text[:500])
log("refresh: стартовая загрузка журнала не прочитана: %s" % out["backfill_error"])
sent_keys = set()
for i in range(0, len(items), self.HISTORY_BATCH):
chunk = items[i:i + self.HISTORY_BATCH]
try:
self.hub.history({"command_id": cmd.get("id"), "changes": chunk})
except HttpError as e:
out["error"] = "хаб: HTTP %d %s" % (e.code, e.text[:500])
log("refresh: журнал не принят: %s" % out["error"])
break
except ConnectionError as e:
raise HubError(e) from None
out["changes"] += len(chunk)
sent_keys.update(c["key"] for c in chunk)
out["issues"] = len(sent_keys)
return out
# ---------------------------------------------------------------------------
# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
# ---------------------------------------------------------------------------

View File

@ -3,6 +3,7 @@ import base64
import hashlib
import json
import re
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -28,6 +29,9 @@ class FakeHub:
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
self.file_requests = 0
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
self.histories = [] # тела POST /agent/history
self.reject_history = None # (код, тело) — ответ на /agent/history ошибкой; "drop" — оборвать без ответа
self.posts = [] # порядок POST /agent/state и /agent/history
self.lock = threading.Lock()
hub = self
@ -81,6 +85,10 @@ class FakeHub:
if h.headers.get("Authorization") != "Bearer " + self.token:
return self._send(h, 401, {"error": "unauthorized"})
path = h.path.split("?")[0]
if path == "/agent/history" and self.reject_history == "drop":
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # хаб недоступен: ответа клиент не получит
return
if path == "/agent/claim" and self.claim_wait:
end = time.monotonic() + self.claim_wait
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
@ -123,7 +131,14 @@ class FakeHub:
return 200, self.settings
if path == "/agent/known-keys":
return 200, {"keys": list(self.known)}
if path == "/agent/history":
self.posts.append(path)
if self.reject_history:
return self.reject_history
self.histories.append(body)
return 200, {"accepted": len(body.get("changes") or []), "duplicates": 0}
if path == "/agent/state":
self.posts.append(path)
self.states.append(body)
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
m = re.match(r"^/agent/files/([^/]+)$", path)

View File

@ -107,5 +107,180 @@ class FakeChangelogTest(Base):
self.assertTrue(any(f["name"] == "Плановое окончание" for f in self.jira.get(agent.API + "/field")))
SINCE = "2026-10-22T08:45:00Z"
CHANGED = "2026-10-22T12:30:00.000+0300" # 09:30 UTC — позже SINCE
BASE = "project = AURORA AND labels = AWPCA"
class RefreshHistoryTest(Base):
def refresh(self, history=None, **params):
p = dict({"jql": BASE}, **params)
if history is not None:
p["history"] = history
return self.ex.run({"id": 5, "type": "refresh", "params": p})
def changelog_queries(self):
return [q for q in self.fake.queries if q.get("expand") == "changelog"]
def sent(self):
return [c for b in self.hub.histories for c in b["changes"]]
def test_nothing_changed_no_changelog_calls(self): # «Ничего не менялось»
for n in range(3):
self.fake.add("AURORA-%d" % n)
out = self.refresh({"since": SINCE})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"]["history"], {"issues": 0, "changes": 0, "incomplete": []})
self.assertEqual(self.changelog_queries(), [])
self.assertEqual(self.hub.histories, [])
def test_two_changed_one_search(self): # «Две задачи изменились»
self.fake.add("X", updated=CHANGED)
self.fake.add("Y", updated=CHANGED)
self.fake.add("Z")
self.fake.add_history("X", "2026-10-21T12:00:00.000+0300", [_st("status", "a", "b")])
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "b", "c"), _st("resolution", None, "Готово")])
self.fake.add_history("Y", "2026-10-22T12:30:00.000+0300", [_st("description", "старое", "новое")])
out = self.refresh({"since": SINCE})
qs = self.changelog_queries()
self.assertEqual(len(qs), 1)
self.assertEqual(qs[0]["jql"], "key in (X,Y)")
self.assertEqual(qs[0]["fields"], "key")
self.assertEqual([(c["key"], c["seq"]) for c in self.sent()], [("X", 0), ("X", 1), ("Y", 0)])
self.assertEqual(self.hub.histories[0]["command_id"], 5)
self.assertEqual(out["result"]["history"], {"issues": 2, "changes": 3, "incomplete": []})
self.assertEqual(self.hub.posts, ["/agent/state", "/agent/history"]) # журнал — после состояния
def test_backfill_by_jql(self): # «Стартовая загрузка»
jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE
keys = ["AURORA-%d" % n for n in range(12)]
for k in keys:
self.fake.add(k)
self.fake.add_history(k, "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")])
self.fake.jql_results[jql] = keys
out = self.refresh({"jql": jql})
self.assertEqual([q["jql"] for q in self.changelog_queries()], [jql])
self.assertEqual(len(self.sent()), 12)
self.assertEqual(out["result"]["history"], {"issues": 12, "changes": 12, "incomplete": []})
def test_since_and_jql_dedup(self): # Review Focus 4
jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE
self.fake.add("X", updated=CHANGED)
self.fake.add("Y")
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
self.fake.add_history("Y", "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")])
self.fake.jql_results[jql] = ["X", "Y"]
out = self.refresh({"since": SINCE, "jql": jql})
self.assertEqual(sorted(c["key"] for c in self.sent()), ["X", "Y"])
self.assertEqual(out["result"]["history"]["changes"], 2)
def test_neighbor_changed_included(self): # Review Focus 5
self.fake.add("X")
self.fake.add("N", in_base=False, updated=CHANGED)
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "X", "target": "N"})
self.fake.add_history("N", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
self.refresh({"since": SINCE})
self.assertEqual([c["key"] for c in self.sent()], ["N"])
def test_jira_500_degrades(self): # «Ошибка Jira при чтении журнала»
self.fake.add("X", updated=CHANGED)
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
orig = self.fake.route
def route(method, path, q, body):
if q.get("expand") == "changelog":
return 500, {"errorMessages": ["сбой"]}
return orig(method, path, q, body)
self.fake.route = route
out = self.refresh({"since": SINCE})
self.assertEqual(out["status"], "done")
self.assertTrue(out["result"]["history"]["error"].startswith("HTTP 500"))
self.assertNotIn("retryable", out)
self.assertTrue(self.hub.states)
self.assertTrue(any("журнал" in l for l in self.logs))
def test_backfill_400(self): # «Ошибка стартовой загрузки»
jql = "bad jql"
self.fake.add("X", updated=CHANGED)
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
orig = self.fake.route
def route(method, path, q, body):
if q.get("jql") == jql:
return 400, {"errorMessages": ["Значение не существует"]}
return orig(method, path, q, body)
self.fake.route = route
out = self.refresh({"since": SINCE, "jql": jql})
h = out["result"]["history"]
self.assertEqual(out["status"], "done")
self.assertTrue(h["backfill_error"].startswith("HTTP 400"))
self.assertNotIn("error", h)
self.assertEqual([c["key"] for c in self.sent()], ["X"])
def test_incomplete(self): # «Неполный журнал»
self.fake.add("X", updated=CHANGED)
for n in range(250):
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
self.fake.changelog_cap = 100
out = self.refresh({"since": SINCE})
self.assertEqual(out["result"]["history"]["incomplete"], ["X"])
self.assertEqual(len(self.sent()), 100)
self.assertTrue(any("X: получено 100 из 250" in l for l in self.logs))
self.assertIn(("GET", "/rest/api/2/issue/X"), self.fake.requests)
def test_truncated_search_completed_by_issue(self):
self.fake.add("X", updated=CHANGED)
for n in range(150):
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
self.fake.changelog_cap = 100
self.fake.issue_changelog_cap = 150
out = self.refresh({"since": SINCE})
self.assertEqual(out["result"]["history"], {"issues": 1, "changes": 150, "incomplete": []})
def test_custom_field_lookup_once(self): # «Плановая дата без fieldId»
self.fake.add("X", updated=CHANGED)
self.fake.add("Y", updated=CHANGED)
for k in ("X", "Y"):
self.fake.add_history(k, "2026-10-22T12:10:00.000+0300",
[_st("Плановое окончание", "2026-10-25", "2026-10-30", fieldtype="custom")])
self.refresh({"since": SINCE})
self.assertEqual([c["field_id"] for c in self.sent()], ["customfield_10201", "customfield_10201"])
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1)
def test_hub_422_degrades(self): # Review Focus 3
self.fake.add("X", updated=CHANGED)
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
self.hub.reject_history = (422, {"error": "bad_history", "detail": "changes[0].seq"})
out = self.refresh({"since": SINCE})
self.assertEqual(out["status"], "done")
self.assertTrue(out["result"]["history"]["error"].startswith("хаб: "))
self.assertNotIn("retryable", out)
def test_hub_down_fails(self):
self.fake.add("X", updated=CHANGED)
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
self.hub.reject_history = "drop"
out = self.refresh({"since": SINCE})
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
def test_batches_of_500(self):
self.fake.add("X", updated=CHANGED)
for n in range(600):
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
out = self.refresh({"since": SINCE})
self.assertEqual([len(b["changes"]) for b in self.hub.histories], [500, 100])
self.assertEqual(out["result"]["history"]["changes"], 600)
def test_no_history_param(self): # «Хаб без журнала»
self.fake.add("X", updated=CHANGED)
out = self.refresh()
self.assertNotIn("history", out["result"])
self.assertEqual(self.changelog_queries(), [])
def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 13)
if __name__ == "__main__":
unittest.main()