From bdcdc3accef4a57b4e1aba0f5d977b7b550a892c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:35:21 +0400 Subject: [PATCH 01/30] =?UTF-8?q?plan:=20bridge-core=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20(7?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-10-11-bridge-core-agent.md | 2222 +++++++++++++++++ 1 file changed, 2222 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-11-bridge-core-agent.md diff --git a/docs/superpowers/plans/2026-10-11-bridge-core-agent.md b/docs/superpowers/plans/2026-10-11-bridge-core-agent.md new file mode 100644 index 0000000..d5f1aef --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-bridge-core-agent.md @@ -0,0 +1,2222 @@ +# Мост Jira — агент (jira-agent) — план реализации + +> **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:** Один файл `agent.py` для виртуалки с доступом к Jira: опрашивает хаб, выполняет команды «проверь, потом сделай», ведёт журнал на диске, возвращает хабу состояние задач. + +**Architecture:** `src/agent.py` (Python 3.9, только stdlib): `JiraClient` (REST v2 + Agile, 429/503 по Retry-After), `HubClient`, `Journal` (jsonl, только дозапись), `Executor` (по методу на тип команды, каждая — чтение → сравнение → запись разницы), `Runner` (цикл claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление по журналу при старте). Политику темпа агент не знает: пауза приходит с командой, а зазор внутри многошаговых команд — из настроек хаба. + +**Tech Stack:** Python 3.9.6 (`/usr/bin/python3` и здесь, и на виртуалке), stdlib `urllib`, `json`, `http.server` (только в тестах), `unittest`. + +**Spec:** `../jira-hub/openspec/changes/bridge-core/` — `specs/agent/spec.md` (агент), `specs/commands/spec.md` (контракт команд), `specs/hub-api/spec.md` (маршруты `/agent/*`), `design.md`. План хаба — `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`. + +## Global Constraints + +- Python `>= 3.9`, только стандартная библиотека; никаких `match`, `X | Y` в аннотациях, `str.removeprefix` допустим (3.9). Тесты запускать `/usr/bin/python3 -m unittest discover -s tests -t .`. +- Учётные данные Jira — только в `agent.json`; в хаб, журнал и логи не попадают. +- Одна команда в полёте; `started`/`finished`/`acked` в `journal.jsonl`; отчёт повторяется до подтверждения хаба. +- Все вызовы Jira идут через `JiraClient.call`: счётчики `calls`, `rate_limited`, `last_call_at`; 429/503 — `Retry-After` или 60 с × попытка, до 5 раз. +- Семантика связей: `source` блокирует `target`; `POST /issueLink` — `inwardIssue = source`, `outwardIssue = target`; в GET у X `outwardIssue = Y` значит «X → Y». +- Язык кода и комментариев — русский, идентификаторы — английские; коммиты — русские, с `Co-Authored-By: Claude Fable 5.1 `. +- Файл `agent.py` лежит в `src/` (хук require-plan защищает `src/`); на виртуалку копируется один файл плюс `agent.json`. + +## Review Focus + +1. Описание в `ensure_fields` с переводами строк и wiki-разметкой должно сравниваться точно, без «нормализации» (иначе каждая команда будет перезаписывать описание). Тест — Task 3 (`test_fields_description_exact_compare`). +2. `ensure_status`, когда текущий статус уже внутри пути (например «Разработка» при пути Разработка → Готово): выполняется только остаток пути. Тест — Task 4 (`test_status_resumes_from_middle`). +3. `refresh` при пустом списке `keys` (все временные ключи отвалились) — `done` с нулём задач, без вызовов Jira. Тест — Task 5 (`test_refresh_empty_keys`). +4. Отчёт хабу, когда хаб вернул 404/422 (команда отменена с `force` или удалена): агент помечает `acked` и идёт дальше, не зацикливается. Тест — Task 6 (`test_result_rejected_is_acked`). +5. Ctrl-C во время паузы перед командой: команда не начинается, журнал без `started`, процесс выходит. Тест — Task 6 (`test_stop_during_pause`). + +## Структура файлов + +| Файл | Ответственность | +|---|---| +| `src/agent.py` | весь агент: конфиг, HTTP-клиенты, журнал, исполнители, чтение состояния, цикл, CLI (`run`, `check`, `prune`) | +| `src/agent.example.json` | шаблон настроек | +| `tests/_common.py` | путь к `src`, общие настройки хаба для тестов, фабрики | +| `tests/fake_jira.py` | фейковый Jira Server в памяти (http.server) | +| `tests/fake_hub.py` | фейковый хаб (маршруты `/agent/*`) | +| `tests/test_*.py` | unittest | +| `README.md`, `CLAUDE.md`, `ARCHITECTURE.md` | доставка, запуск, launchd, prune | + +--- + +### Task 1: Скелет агента, HTTP-клиенты, фейковый Jira, `check` + +**Files:** +- Create: `src/agent.py`, `src/agent.example.json`, `tests/__init__.py` (пустой), `tests/_common.py`, `tests/fake_jira.py` +- Test: `tests/test_clients.py` + +**Interfaces:** +- Produces (в `agent.py`): `AGENT_VERSION = 1`, `now_iso() -> str`, `log(msg)`, `load_config(path) -> dict`, + `HttpError(code, text, headers)`, `Http(base, headers, timeout, ctx).call(method, path, body=None, params=None)`, + `JiraClient(cfg).get/post/put/delete`, счётчики `calls`, `rate_limited`, `last_call_at`, `reset_counters()`, `sleep` (подменяемый), + `HubClient(cfg).claim(agent_version, settings_version, last_jira_call_at)`, `.settings()`, `.command(id)`, `.result(id, payload)`, + `.state(payload)`, `.known_keys()`, `main(argv) -> int` с подкомандами `run | check | prune` (run и prune — заглушки до Task 6/2). +- Produces (тесты): `fake_jira.FakeJira()` (`.base_url`, `.add(key, in_base=True, **fields)`, `.issues`, `.links`, `.comments`, + `.sprints`, `.requests`, `.writes()`, `.fail_next`, `.stop()`), `_common.SETTINGS`, `_common.jira_cfg(fake) -> dict`, `_common.agent` (модуль). + +- [ ] **Step 1: Общие тестовые файлы** + +`tests/_common.py`: +```python +"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейковый Jira.""" +import os +import sys + +SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") +if SRC not in sys.path: + sys.path.insert(0, SRC) + +import agent # noqa: E402 + +ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0, + "ensure_sprint": 0, "refresh": 0} + +SETTINGS = { + "base_jql": "project = AURORA AND labels = AWPCA", + "project_key": "AURORA", + "issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}, + "epic_name_field_id": "customfield_15304", + "field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels", + "planned_start": "customfield_10801", "planned_end": "customfield_10802"}, + "epic_link": {"mode": "field", "field_id": "customfield_15302"}, + "priority_map": {"Наивысший": "Критичный", "Высокий": "Высокий", "Средний": "Средний", "Низкий": "Низкий"}, + "priority_value_key": "name", + "labels": ["AWPCA"], + "status_labels": {}, + "kind_labels": {}, + "comment_marker": "Актуализация 10.10.2026", + "transitions_map": {}, + "link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}, + "board_id": 4216, + "pacing": dict(ZERO_PACING), + "min_agent_version": 1, + "settings_version": 1, +} + + +def jira_cfg(fake, **over): + cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token", + "jira": {"base_url": fake.base_url, "auth": {"type": "basic", "user": "me", "password": "secret"}}, + "verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0} + cfg.update(over) + return cfg + + +def no_sleep(_seconds): + pass +``` + +`tests/fake_jira.py`: +```python +"""Фейковый Jira Server для тестов агента: состояние в памяти, http.server в потоке, +только маршруты, которые использует агент. Семантика связей — как у настоящего Jira Server.""" +import json +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +WORKFLOW = { # статус → [(имя перехода, целевой статус)] + "Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")], + "Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")], + "Ожидает анализа": [("Анализ", "Анализ"), ("Снято", "Снято")], + "Анализ": [("Готово", "Готово")], + "Готово к тестированию": [("Тестирование", "Тестирование")], + "Готово": [], + "Снято": [], +} +LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"}, + {"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}] +CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") + + +class FakeJira: + def __init__(self, project="AURORA"): + self.project = project + self.issues = {} + self.comments = {} + self.links = [] + self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}] + self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 + self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой + self.requests = [] # [(метод, путь)] + self.lock = threading.Lock() + fake = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + fake.handle(self, "GET") + + def do_POST(self): + fake.handle(self, "POST") + + def do_PUT(self): + fake.handle(self, "PUT") + + def do_DELETE(self): + fake.handle(self, "DELETE") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + + def add(self, key, in_base=True, **fields): + base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний", + "assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [], + "issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300", + "sprint": None} + base.update({c: None for c in CUSTOM}) + base.update(fields) + self.issues[key] = base + return base + + def writes(self): + return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] + + # --- представление --------------------------------------------------------- + def _user(self, name): + return {"name": name, "displayName": name} if name else None + + def issue_json(self, key): + f = self.issues[key] + fields = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]}, + "priority": {"name": f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]), + "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, + "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} + fields.update({c: f.get(c) for c in CUSTOM}) + return {"key": key, "id": str(10000 + sorted(self.issues).index(key)), "fields": fields} + + def links_of(self, key): + out = [] + for l in self.links: + if l["source"] == key: + out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "outwardIssue": {"key": l["target"]}}) + elif l["target"] == key: + out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "inwardIssue": {"key": l["source"]}}) + return out + + def search_keys(self, jql): + m = re.search(r"key in \(([^)]*)\)", jql or "") + if m: + keys = [k.strip() for k in m.group(1).split(",")] + return [k for k in keys if k in self.issues] + if "reporter = currentUser()" in (jql or ""): + return [k for k, f in self.issues.items() if f["reporter"] == "me"] + return [k for k, f in self.issues.items() if "AWPCA" in f["labels"]] + + # --- маршрутизация ---------------------------------------------------------- + def handle(self, h, method): + url = urlparse(h.path) + q = {k: v[0] for k, v in parse_qs(url.query).items()} + length = int(h.headers.get("Content-Length") or 0) + body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} + with self.lock: + self.requests.append((method, url.path)) + if self.fail_next: + code, headers = self.fail_next.pop(0) + return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers) + try: + code, payload = self.route(method, url.path, q, body) + except KeyError as e: + code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} + self._send(h, code, payload) + + def _send(self, h, code, payload, headers=None): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b"" + h.send_response(code) + for k, v in (headers or {}).items(): + h.send_header(k, v) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(data))) + h.end_headers() + h.wfile.write(data) + + def route(self, method, path, q, body): + p = path + if p == "/rest/api/2/myself": + return 200, {"name": "me", "displayName": "Me"} + if p == "/rest/api/2/search": + keys = self.search_keys(q.get("jql")) + start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) + page = keys[start:start + maxr] + return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": [self.issue_json(k) for k in page]} + if p == "/rest/api/2/issue" and method == "POST": + self.seq += 1 + key = "%s-%d" % (self.project, self.seq) + f = body["fields"] + self.add(key, summary=f.get("summary"), description=f.get("description"), + priority=(f.get("priority") or {}).get("name", "Средний"), duedate=f.get("duedate"), + labels=list(f.get("labels") or []), issuetype=self._type_name(f.get("issuetype")), + reporter="me", created="2026-10-11T12:00:00.000+0300", + **{c: f.get(c) for c in CUSTOM}) + return 201, {"id": "1", "key": key} + if p == "/rest/api/2/issueLinkType": + return 200, {"issueLinkTypes": LINK_TYPES} + if p == "/rest/api/2/issueLink" and method == "POST": + self.link_seq += 1 + self.links.append({"id": self.link_seq, "type": body["type"]["name"], + "source": body["inwardIssue"]["key"], "target": body["outwardIssue"]["key"]}) + return 201, {} + m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p) + if m and method == "DELETE": + self.links = [l for l in self.links if str(l["id"]) != m.group(1)] + return 204, None + m = re.match(r"^/rest/api/2/issue/([^/]+)$", p) + if m: + key = m.group(1) + if method == "GET": + return 200, self.issue_json(key) + if method == "PUT": + f = self.issues[key] + for k, v in body.get("fields", {}).items(): + if k in ("priority", "assignee"): + f[k] = (v or {}).get("name") + elif k == "labels": + f[k] = list(v) + else: + f[k] = v + f["updated"] = "2026-10-11T13:00:00.000+0300" + return 204, None + m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p) + if m: + key = m.group(1) + self.issues[key] + lst = self.comments.setdefault(key, []) + if method == "GET": + return 200, {"comments": lst} + self.comment_seq += 1 + lst.append({"id": str(self.comment_seq), "body": body["body"]}) + return 201, lst[-1] + m = re.match(r"^/rest/api/2/issue/([^/]+)/comment/(\d+)$", p) + if m and method == "PUT": + for c in self.comments.get(m.group(1), []): + if c["id"] == m.group(2): + c["body"] = body["body"] + return 200, {} + m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p) + if m: + key = m.group(1) + trs = WORKFLOW.get(self.issues[key]["status"], []) + if method == "GET": + return 200, {"transitions": [{"id": str(i + 1), "name": n, "to": {"name": to}} for i, (n, to) in enumerate(trs)]} + tid = int(body["transition"]["id"]) - 1 + self.issues[key]["status"] = trs[tid][1] + return 204, None + m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p) + if m: + states = (q.get("state") or "active,future").split(",") + return 200, {"values": [s for s in self.sprints if s["state"] in states], "isLast": True} + m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p) + if m: + sp = next(s for s in self.sprints if s["id"] == int(m.group(1))) + if method == "GET": + keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]] + return 200, {"issues": [{"key": k} for k in keys]} + for k in body["issues"]: + self.issues[k]["sprint"] = sp["name"] + return 204, None + if p == "/rest/agile/1.0/backlog/issue" and method == "POST": + for k in body["issues"]: + self.issues[k]["sprint"] = None + return 204, None + m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) + if m: + name = self.issues[m.group(1)]["sprint"] + sp = next((s for s in self.sprints if s["name"] == name), None) + return 200, {"key": m.group(1), "fields": {"sprint": sp}} + return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]} + + def _type_name(self, ref): + ref = ref or {} + if ref.get("name"): + return ref["name"] + return {"10100": "Задача", "12300": "Фича"}.get(str(ref.get("id")), "Задача") +``` + +- [ ] **Step 2: Тесты клиентов** + +`tests/test_clients.py`: +```python +import unittest + +from _common import agent, jira_cfg, no_sleep +from fake_jira import FakeJira + + +class ClientsTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + + def test_get_and_counters(self): + self.fake.add("AURORA-1") + data = self.jira.get("/rest/api/2/issue/AURORA-1", fields="summary") + self.assertEqual(data["fields"]["summary"], "Тема AURORA-1") + self.assertEqual(self.jira.calls, 1) + self.assertIsNotNone(self.jira.last_call_at) + self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1")) + + def test_retry_on_429_with_retry_after(self): + self.fake.add("AURORA-1") + self.fake.fail_next.append((429, {"Retry-After": "0"})) + self.fake.fail_next.append((503, {})) + data = self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(data["key"], "AURORA-1") + self.assertEqual(self.jira.rate_limited, 2) + self.assertEqual(self.jira.calls, 1) + + def test_http_error_not_retried(self): + with self.assertRaises(agent.HttpError) as cm: + self.jira.get("/rest/api/2/issue/NOPE-1") + self.assertEqual(cm.exception.code, 404) + self.assertIn("Does Not Exist", cm.exception.text) + + def test_gives_up_after_five(self): + self.fake.add("AURORA-1") + self.fake.fail_next.extend([(429, {"Retry-After": "0"})] * 5) + with self.assertRaises(agent.HttpError): + self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(self.jira.rate_limited, 5) + + def test_basic_auth_header_and_no_secret_in_repr(self): + self.assertTrue(self.jira.headers["Authorization"].startswith("Basic ")) + self.assertNotIn("secret", repr(self.jira)) + + def test_load_config_requires_fields(self): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"hub_url": "http://x", "hub_token": "t"}, f) + with self.assertRaises(SystemExit): + agent.load_config(path) + with open(path, "w", encoding="utf-8") as f: + json.dump(jira_cfg(self.fake), f) + cfg = agent.load_config(path) + self.assertEqual(cfg["poll_seconds"], 0) + + def test_check_command(self): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(jira_cfg(self.fake), f) + # хаб недоступен (порт 1), Jira доступен — check печатает оба результата и возвращает 1 + self.assertEqual(agent.main(["--config", path, "check"]), 1) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 3: Запустить — падает** + +Run: `/usr/bin/python3 -m unittest tests.test_clients -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'agent'` (или AttributeError) + +- [ ] **Step 4: agent.py (скелет)** + +`src/agent.example.json`: +```json +{ + "hub_url": "http://192.168.0.106:8790", + "hub_token": "агентский токен из `jira-hub tokens`", + "jira": { + "base_url": "https://sdlc.go.rshbank.ru/jira", + "auth": {"type": "basic", "user": "логин", "password": "пароль", "token": ""} + }, + "verify_ssl": true, + "ca_bundle": null, + "timeout": 60, + "poll_seconds": 5 +} +``` + +`src/agent.py`: +```python +#!/usr/bin/env python3 +"""jira-agent — клиент моста Jira для виртуалки с доступом к Jira банка. + +Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу +«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека, +Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md. + + python3 agent.py check # доступ к хабу и Jira, без записи + python3 agent.py run # основной цикл (по умолчанию) + python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней +""" +import argparse +import base64 +import json +import os +import signal +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +AGENT_VERSION = 1 +HERE = os.path.dirname(os.path.abspath(__file__)) +API = "/rest/api/2" +AGILE = "/rest/agile/1.0" + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def log(msg: str) -> None: + print("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True) + + +def load_config(path: str) -> dict: + try: + with open(path, encoding="utf-8") as f: + cfg = json.load(f) + except FileNotFoundError: + sys.exit("нет файла настроек %s — скопируйте agent.example.json в agent.json и заполните" % path) + for key in ("hub_url", "hub_token", "jira"): + if not cfg.get(key): + sys.exit("в %s нет обязательного поля %s" % (path, key)) + if not cfg["jira"].get("base_url"): + sys.exit("в %s нет jira.base_url" % path) + cfg.setdefault("timeout", 60) + cfg.setdefault("poll_seconds", 5) + cfg.setdefault("verify_ssl", True) + return cfg + + +# --------------------------------------------------------------------------- +# HTTP +# --------------------------------------------------------------------------- +class HttpError(Exception): + def __init__(self, code: int, text: str, headers=None): + super().__init__("HTTP %d: %s" % (code, text[:300])) + self.code, self.text, self.headers = code, text, headers or {} + + +class Http: + def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None): + self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx + + def __repr__(self): + return "<%s %s>" % (self.__class__.__name__, self.base) + + def call(self, method: str, path: str, body=None, params=None) -> Any: + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + for k, v in self.headers.items(): + req.add_header(k, v) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json; charset=utf-8") + try: + with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) if raw else {} + except urllib.error.HTTPError as e: + raise HttpError(e.code, e.read().decode("utf-8", "replace"), dict(e.headers)) from None + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e: + raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None + + +class JiraClient(Http): + """Все вызовы Jira идут здесь: счётчики вызовов и 429/503, время последнего вызова, повтор по Retry-After.""" + + def __init__(self, cfg: dict): + j = cfg["jira"] + auth = j.get("auth") or {} + kind = auth.get("type") or ("bearer" if auth.get("token") else "basic") + if kind == "bearer": + if not auth.get("token"): + sys.exit("jira.auth.token пуст") + header = "Bearer " + auth["token"] + else: + if not (auth.get("user") and auth.get("password")): + sys.exit("jira.auth.user / jira.auth.password пусты") + header = "Basic " + base64.b64encode(("%s:%s" % (auth["user"], auth["password"])).encode()).decode() + ctx = None + if not cfg.get("verify_ssl", True): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + elif cfg.get("ca_bundle"): + ctx = ssl.create_default_context(cafile=cfg["ca_bundle"]) + super().__init__(j["base_url"], {"Authorization": header, "X-Atlassian-Token": "no-check"}, cfg.get("timeout", 60), ctx) + self.calls = 0 + self.rate_limited = 0 + self.last_call_at = None # type: Optional[float] (time.monotonic) + self.last_call_iso = None # type: Optional[str] + self.sleep = time.sleep + + def reset_counters(self) -> None: + self.calls, self.rate_limited = 0, 0 + + def call(self, method, path, body=None, params=None): + for attempt in range(1, 6): + try: + out = super().call(method, path, body, params) + self.calls += 1 + self.last_call_at, self.last_call_iso = time.monotonic(), now_iso() + return out + except HttpError as e: + if e.code in (429, 503) and attempt < 5: + retry = e.headers.get("Retry-After") + wait = int(retry) if retry and retry.isdigit() else 60 * attempt + self.rate_limited += 1 + log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt)) + self.sleep(wait) + continue + if e.code in (429, 503): + self.rate_limited += 1 + raise + + def get(self, path, **params): + return self.call("GET", path, params=params or None) + + def post(self, path, body): + return self.call("POST", path, body) + + def put(self, path, body): + return self.call("PUT", path, body) + + def delete(self, path): + return self.call("DELETE", path) + + +class HubClient(Http): + def __init__(self, cfg: dict): + super().__init__(cfg["hub_url"], {"Authorization": "Bearer " + cfg["hub_token"]}, cfg.get("timeout", 60)) + + def claim(self, agent_version, settings_version, last_jira_call_at) -> dict: + return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, + "last_jira_call_at": last_jira_call_at}) + + def settings(self) -> dict: + return self.call("GET", "/agent/settings") + + def command(self, command_id) -> dict: + return self.call("GET", "/agent/commands/%d" % int(command_id)) + + def result(self, command_id, payload: dict) -> dict: + return self.call("POST", "/agent/commands/%d/result" % int(command_id), payload) + + def state(self, payload: dict) -> dict: + return self.call("POST", "/agent/state", payload) + + def known_keys(self) -> List[str]: + return self.call("GET", "/agent/known-keys")["keys"] + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def cmd_check(cfg: dict) -> int: + ok = True + try: + s = HubClient(cfg).settings() + log("хаб: доступен, настройки v%s, проект %s" % (s.get("settings_version"), s.get("project_key"))) + except (ConnectionError, HttpError) as e: + log("хаб: НЕДОСТУПЕН — %s" % e) + ok = False + try: + me = JiraClient(cfg).get(API + "/myself") + log("Jira: доступен, пользователь %s" % me.get("name")) + except (ConnectionError, HttpError) as e: + log("Jira: НЕДОСТУПЕН — %s" % e) + ok = False + return 0 if ok else 1 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default=os.path.join(HERE, "agent.json")) + ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl")) + ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "prune"]) + args = ap.parse_args(argv) + cfg = load_config(args.config) + if args.cmd == "check": + return cmd_check(cfg) + log("команда %s ещё не реализована" % args.cmd) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 5: Запустить — зелёный** + +Run: `/usr/bin/python3 -m unittest tests.test_clients -v` +Expected: 7 passed + +- [ ] **Step 6: Коммит** + +```bash +git add src/agent.py src/agent.example.json tests/__init__.py tests/_common.py tests/fake_jira.py tests/test_clients.py +git commit -m "feat(agent): скелет, клиенты Jira и хаба, фейковый Jira, команда check + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: Журнал на диске и `prune` + +**Files:** +- Modify: `src/agent.py` (класс `Journal` после `HubClient`; `prune` в `main`) +- Test: `tests/test_journal.py` + +**Interfaces:** +- Produces: `Journal(path)`: `.append(record)`, `.records() -> list`, `.started(command_id)`, `.finished(command_id, status, result, error, jira_calls, rate_limited)`, + `.acked(command_id)`, `.unacked_finished() -> list[dict]` (последняя `finished` на команду без `acked` после неё, в порядке журнала), + `.unfinished_started() -> list[int]`, `.prune(days=30) -> int` (сколько записей удалено). + +- [ ] **Step 1: Тест** + +`tests/test_journal.py`: +```python +import json +import os +import tempfile +import unittest + +from _common import agent + + +class JournalTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.path = os.path.join(self.dir.name, "journal.jsonl") + self.j = agent.Journal(self.path) + + def tearDown(self): + self.dir.cleanup() + + def test_empty(self): + self.assertEqual(self.j.records(), []) + self.assertEqual(self.j.unacked_finished(), []) + self.assertEqual(self.j.unfinished_started(), []) + + def test_lifecycle(self): + self.j.started(1) + self.assertEqual(self.j.unfinished_started(), [1]) + self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0) + self.assertEqual(self.j.unfinished_started(), []) + pending = self.j.unacked_finished() + self.assertEqual(len(pending), 1) + self.assertEqual(pending[0]["command_id"], 1) + self.assertEqual(pending[0]["result"], {"key": "A-1"}) + self.assertEqual(pending[0]["jira_calls"], 2) + self.j.acked(1) + self.assertEqual(self.j.unacked_finished(), []) + self.j.started(2) + self.j.started(3) + self.j.finished(3, "failed", None, "HTTP 400", 1, 0) + self.assertEqual(self.j.unfinished_started(), [2]) + self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3]) + + def test_broken_line_is_skipped(self): + self.j.started(1) + with open(self.path, "a", encoding="utf-8") as f: + f.write("{не json\n") + self.j.finished(1, "done", None, None, 1, 0) + self.assertEqual(len(self.j.records()), 2) + + def test_prune_keeps_unacked_and_recent(self): + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"}) + self.j.started(2) + self.j.finished(2, "done", None, None, 1, 0) + self.j.acked(2) + self.j.started(3) + removed = self.j.prune(days=30) + self.assertEqual(removed, 3) + ids = [(r["kind"], r["command_id"]) for r in self.j.records()] + self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)]) + self.assertEqual(self.j.prune(days=30), 0) + + def test_no_secrets_in_records(self): + self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0) + with open(self.path, encoding="utf-8") as f: + text = f.read() + self.assertNotIn("password", text) + self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `/usr/bin/python3 -m unittest tests.test_journal -v` +Expected: FAIL — `AttributeError: module 'agent' has no attribute 'Journal'` + +- [ ] **Step 3: Реализация** + +В `src/agent.py` после класса `HubClient`: +```python +# --------------------------------------------------------------------------- +# Журнал: только дозапись; started → finished → acked по id команды +# --------------------------------------------------------------------------- +class Journal: + def __init__(self, path: str): + self.path = path + + def append(self, record: dict) -> None: + record = dict(record) + record.setdefault("at", now_iso()) + with open(self.path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + def records(self) -> List[dict]: + if not os.path.exists(self.path): + return [] + out = [] + with open(self.path, encoding="utf-8") as f: + for n, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except ValueError: + log("журнал: строка %d не читается, пропускаю" % n) + return out + + def started(self, command_id) -> None: + self.append({"kind": "started", "command_id": int(command_id)}) + + def finished(self, command_id, status, result, error, jira_calls, rate_limited) -> None: + self.append({"kind": "finished", "command_id": int(command_id), "status": status, "result": result, + "error": error, "jira_calls": int(jira_calls or 0), "rate_limited": int(rate_limited or 0)}) + + def acked(self, command_id) -> None: + self.append({"kind": "acked", "command_id": int(command_id)}) + + def unacked_finished(self) -> List[dict]: + last, acked = {}, set() + for r in self.records(): + if r.get("kind") == "finished": + last[r["command_id"]] = r + acked.discard(r["command_id"]) + elif r.get("kind") == "acked": + acked.add(r["command_id"]) + return [r for cid, r in last.items() if cid not in acked] + + def unfinished_started(self) -> List[int]: + started, finished = [], set() + for r in self.records(): + if r.get("kind") == "started": + started.append(r["command_id"]) + elif r.get("kind") in ("finished", "acked"): + finished.add(r["command_id"]) + return [cid for cid in started if cid not in finished] + + def prune(self, days: int = 30) -> int: + """Удалить записи команд, подтверждённых (acked) раньше, чем days дней назад. Неподтверждённые — остаются.""" + records = self.records() + cutoff = datetime.now(timezone.utc).timestamp() - days * 86400 + old = set() + for r in records: + if r.get("kind") == "acked": + try: + ts = datetime.fromisoformat(r.get("at", "")).timestamp() + except ValueError: + continue + if ts < cutoff: + old.add(r["command_id"]) + keep = [r for r in records if r.get("command_id") not in old] + tmp = self.path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + for r in keep: + f.write(json.dumps(r, ensure_ascii=False) + "\n") + os.replace(tmp, self.path) + return len(records) - len(keep) +``` + +В `main` заменить заглушку: +```python + if args.cmd == "prune": + removed = Journal(args.journal).prune(30) + log("журнал: удалено записей %d" % removed) + return 0 + log("команда %s ещё не реализована" % args.cmd) + return 2 +``` + +- [ ] **Step 4: Запустить — зелёный** + +Run: `/usr/bin/python3 -m unittest tests.test_journal -v` +Expected: 5 passed + +- [ ] **Step 5: Коммит** + +```bash +git add src/agent.py tests/test_journal.py +git commit -m "feat(agent): журнал на диске, восстановление по нему, prune + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: Исполнитель — `ensure_issue`, `ensure_fields`, `ensure_comment` + +**Files:** +- Modify: `src/agent.py` (класс `Executor` после `Journal`) +- Test: `tests/test_executor_fields.py` + +**Interfaces:** +- Produces: `Executor(jira, settings, hub=None)`: `.run(cmd: dict) -> {"status", "result", "error", "jira_calls", "rate_limited"}`, + `.sleep` (подменяемый), `.gap(cmd_type)`, `.fid(logical)`, `.priority_value(name)`, `.issue_type_ref(kind)`, `.epic_fields(epic_key)`, + методы `do_ensure_issue(p, cmd)`, `do_ensure_fields(p, cmd)`, `do_ensure_comment(p, cmd)`. +- Правило сравнения дат: по первым 10 символам (`YYYY-MM-DD`), потому что кастомные даты Jira приходят со временем. + +- [ ] **Step 1: Тесты** + +`tests/test_executor_fields.py`: +```python +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_jira import FakeJira + + +class ExecutorFieldsTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS)) + self.ex.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + + def run_cmd(self, ctype, **params): + return self.ex.run({"id": 1, "type": ctype, "params": params}) + + def test_ensure_issue_creates_then_finds(self): + out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача", description="h3. Контекст", + priority="Высокий", duedate="2026-10-23", planned_start="2026-10-20", labels=["AWPCA", "kind-dev"], + epic_key="AURORA-1350", assignee="ivanov") + self.assertEqual(out["status"], "done") + key = out["result"]["key"] + self.assertTrue(out["result"]["created"]) + f = self.fake.issues[key] + self.assertEqual(f["priority"], "Высокий") + self.assertEqual(f["customfield_10801"], "2026-10-20") + self.assertEqual(f["customfield_15302"], "AURORA-1350") + self.assertEqual(f["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(out["jira_calls"], 2) + again = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача") + self.assertEqual(again["status"], "done") + self.assertEqual(again["result"], {"key": key, "existed": True}) + self.assertEqual(len(self.fake.issues), 1) + self.assertEqual(again["jira_calls"], 1) + + def test_ensure_issue_epic_sets_epic_name(self): + out = self.run_cmd("ensure_issue", temp_key="new:2", issue_type="epic", summary="Приёмка") + f = self.fake.issues[out["result"]["key"]] + self.assertEqual(f["issuetype"], "Фича") + self.assertEqual(f["customfield_15304"], "Приёмка") + + def test_ensure_issue_priority_mapped(self): + out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший") + self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный") + + def test_ensure_fields_writes_only_diff(self): + self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний") + out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний", + labels_add=["kind-dev", "AWPCA"], labels_remove=["fact-open"], assignee="ivanov", + planned_end="2026-10-23", epic_key="AURORA-1350") + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"]["changed"], ["duedate", "planned_end", "epic_key", "assignee", "labels"]) + f = self.fake.issues["AURORA-1"] + self.assertEqual(f["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(f["assignee"], "ivanov") + self.assertEqual(f["customfield_10802"], "2026-10-23") + self.assertEqual(len(self.fake.writes()), 1) + again = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", labels_add=["kind-dev"], assignee="ivanov") + self.assertEqual(again["result"], {"changed": []}) + self.assertEqual(len(self.fake.writes()), 1) + + def test_fields_description_exact_compare(self): + text = "h3. Контекст\n* пункт *жирный*\n\nh3. Что сделать" + self.fake.add("AURORA-1", description=text) + out = self.run_cmd("ensure_fields", key="AURORA-1", description=text) + self.assertEqual(out["result"]["changed"], []) + out = self.run_cmd("ensure_fields", key="AURORA-1", description=text + "\n") + self.assertEqual(out["result"]["changed"], ["description"]) + + def test_fields_date_with_time_from_jira(self): + self.fake.add("AURORA-1", customfield_10802="2026-10-23T00:00:00.000+0300") + out = self.run_cmd("ensure_fields", key="AURORA-1", planned_end="2026-10-23") + self.assertEqual(out["result"]["changed"], []) + + def test_fields_clear_assignee_and_epic(self): + self.fake.add("AURORA-1", assignee="ivanov", customfield_15302="AURORA-1350") + out = self.run_cmd("ensure_fields", key="AURORA-1", assignee=None, epic_key=None) + self.assertEqual(out["result"]["changed"], ["epic_key", "assignee"]) + self.assertIsNone(self.fake.issues["AURORA-1"]["assignee"]) + self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_15302"]) + + def test_fields_missing_issue_is_failed(self): + out = self.run_cmd("ensure_fields", key="AURORA-404", duedate="2026-10-23") + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 404", out["error"]) + + def test_ensure_comment_upsert(self): + self.fake.add("AURORA-1") + out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список") + self.assertEqual(out["status"], "done") + self.assertTrue(out["result"]["created"]) + self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: список") + same = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список") + self.assertEqual(same["result"]["changed"], False) + upd = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: всё") + self.assertEqual(upd["result"]["changed"], True) + self.assertEqual(len(self.fake.comments["AURORA-1"]), 1) + self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё") + + def test_unknown_type(self): + self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v` +Expected: FAIL — `AttributeError: module 'agent' has no attribute 'Executor'` + +- [ ] **Step 3: Реализация** + +В `src/agent.py` после класса `Journal`: +```python +# --------------------------------------------------------------------------- +# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу +# --------------------------------------------------------------------------- +def _date(value) -> Optional[str]: + """Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню.""" + return (value or "")[:10] or None + + +class Executor: + def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None): + self.jira, self.s, self.hub = jira, settings, hub + self.sleep = time.sleep + self._symmetric = None + self._sprints = None + + def run(self, cmd: dict) -> dict: + self.jira.reset_counters() + self._sprints = None + handler = getattr(self, "do_" + str(cmd.get("type")), None) + if handler is None: + return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) + try: + return handler(cmd.get("params") or {}, cmd) + except HttpError as e: + return self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) + except ConnectionError as e: + return self._out("failed", error=str(e)) + + def _out(self, status, result=None, error=None) -> dict: + return {"status": status, "result": result, "error": error, + "jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited} + + def gap(self, cmd_type: str) -> None: + """Зазор между записями внутри одной команды — по политике хаба для этого типа.""" + seconds = int((self.s.get("pacing") or {}).get(cmd_type, 0) or 0) + if seconds > 0: + log(" пауза %d с" % seconds) + self.sleep(seconds) + + def fid(self, logical: str) -> str: + return (self.s.get("field_ids") or {}).get(logical, logical) + + def has_field(self, logical: str) -> bool: + return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical)) + + def priority_value(self, name: str) -> dict: + mapped = (self.s.get("priority_map") or {}).get(name, name) + return {self.s.get("priority_value_key") or "name": mapped} + + def issue_type_ref(self, kind: str) -> dict: + it = (self.s.get("issue_types") or {}).get(kind) or {} + return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")} + + def epic_fields(self, epic_key) -> dict: + ep = self.s.get("epic_link") or {} + if epic_key and ep.get("mode", "field") == "field" and ep.get("field_id"): + return {ep["field_id"]: epic_key} + return {} + + # --- ensure_issue ---------------------------------------------------------- + def do_ensure_issue(self, p: dict, cmd: dict) -> dict: + jql = "project = %s AND reporter = currentUser() AND created >= -7d" % self.s["project_key"] + found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200) + for it in found.get("issues", []): + if (it.get("fields", {}).get("summary") or "") == p["summary"]: + return self._out("done", {"key": it["key"], "existed": True}) + fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]), + "summary": p["summary"]} + if p.get("description") is not None: + fields[self.fid("description")] = p["description"] + if p.get("priority"): + fields[self.fid("priority")] = self.priority_value(p["priority"]) + if p.get("duedate"): + fields[self.fid("duedate")] = p["duedate"] + for logical in ("planned_start", "planned_end"): + if p.get(logical) and self.has_field(logical): + fields[self.fid(logical)] = p[logical] + if p.get("labels") is not None: + fields[self.fid("labels")] = list(p["labels"]) + if p.get("assignee"): + fields["assignee"] = {"name": p["assignee"]} + fields.update(self.epic_fields(p.get("epic_key"))) + if p["issue_type"] == "epic" and self.s.get("epic_name_field_id"): + fields[self.s["epic_name_field_id"]] = p["summary"] + created = self.jira.post(API + "/issue", {"fields": fields}) + return self._out("done", {"key": created["key"], "created": True}) + + # --- ensure_fields --------------------------------------------------------- + def do_ensure_fields(self, p: dict, cmd: dict) -> dict: + key = p["key"] + cur = self.jira.get(API + "/issue/%s" % key)["fields"] + fields, changed = {}, [] + if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""): + fields["summary"] = p["summary"] + changed.append("summary") + if "description" in p and (cur.get(self.fid("description")) or "") != (p["description"] or ""): + fields[self.fid("description")] = p["description"] + changed.append("description") + if "priority" in p: + want = self.priority_value(p["priority"]) + vkey = list(want)[0] + if ((cur.get(self.fid("priority")) or {}).get(vkey)) != want[vkey]: + fields[self.fid("priority")] = want + changed.append("priority") + for logical in ("duedate", "planned_start", "planned_end"): + if logical in p and self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]): + fields[self.fid(logical)] = p[logical] + changed.append(logical) + if "epic_key" in p: + ep = self.s.get("epic_link") or {} + if ep.get("field_id") and (cur.get(ep["field_id"]) or None) != (p["epic_key"] or None): + fields[ep["field_id"]] = p["epic_key"] + changed.append("epic_key") + if "assignee" in p and ((cur.get("assignee") or {}).get("name") or None) != (p["assignee"] or None): + fields["assignee"] = {"name": p["assignee"]} if p["assignee"] else None + changed.append("assignee") + if "labels_add" in p or "labels_remove" in p: + labels = list(cur.get(self.fid("labels")) or []) + remove = set(p.get("labels_remove") or []) + new = [l for l in labels if l not in remove] + [l for l in (p.get("labels_add") or []) if l not in labels] + if new != labels: + fields[self.fid("labels")] = new + changed.append("labels") + if not fields: + return self._out("done", {"changed": []}) + self.jira.put(API + "/issue/%s" % key, {"fields": fields}) + return self._out("done", {"changed": changed}) + + # --- ensure_comment -------------------------------------------------------- + def do_ensure_comment(self, p: dict, cmd: dict) -> dict: + key, marker, body = p["key"], p["marker"], p["body"] + if marker not in body[:80]: + body = "*%s.* %s" % (marker, body) + existing = None + for c in self.jira.get(API + "/issue/%s/comment" % key).get("comments", []): + if marker in (c.get("body") or "")[:80]: + existing = c + break + if existing and (existing.get("body") or "") == body: + return self._out("done", {"comment_id": existing["id"], "changed": False}) + if existing: + self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body}) + return self._out("done", {"comment_id": existing["id"], "changed": True}) + c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body}) + return self._out("done", {"comment_id": c.get("id"), "created": True}) +``` + +- [ ] **Step 4: Запустить — зелёный** + +Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v` +Expected: 10 passed + +- [ ] **Step 5: Коммит** + +```bash +git add src/agent.py tests/test_executor_fields.py +git commit -m "feat(agent): исполнитель — ensure_issue, ensure_fields, ensure_comment + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: Исполнитель — `ensure_status`, `ensure_link`, `ensure_sprint` + +**Files:** +- Modify: `src/agent.py` (методы в `Executor`) +- Test: `tests/test_executor_links.py` + +**Interfaces:** +- Produces: `Executor.do_ensure_status`, `.do_ensure_link`, `.do_ensure_sprint`, `.symmetric_types() -> set`, `.sprints() -> dict[name, sprint]` (активные и будущие доски `board_id`; `{}` без доски). + +- [ ] **Step 1: Тесты** + +`tests/test_executor_links.py`: +```python +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_jira import FakeJira + + +class ExecutorLinksTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS)) + self.gaps = [] + self.ex.sleep = self.gaps.append + self.fake.add("AURORA-1") + self.fake.add("AURORA-2") + + def tearDown(self): + self.fake.stop() + + def run_cmd(self, ctype, **params): + return self.ex.run({"id": 1, "type": ctype, "params": params}) + + # --- статус --- + def test_status_path(self): + out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"]) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 2}) + self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово") + again = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"]) + self.assertEqual(again["result"]["steps"], 0) + self.assertEqual(again["jira_calls"], 1) + + def test_status_resumes_from_middle(self): + self.fake.issues["AURORA-1"]["status"] = "Разработка" + out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово к тестированию"]) + self.assertEqual(out["result"]["steps"], 1) + self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию") + + def test_status_single_and_by_transition_name(self): + out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку") + self.assertEqual(out["result"]["to"], "Взять в разработку") # имя перехода принято как шаг + self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Разработка") + + def test_status_no_transition_is_failed(self): + out = self.run_cmd("ensure_status", key="AURORA-1", target="Тестирование") + self.assertEqual(out["status"], "failed") + self.assertIn("нет перехода из «Ожидание разработки» в «Тестирование»", out["error"]) + self.assertIn("Взять в разработку→Разработка", out["error"]) + self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"}) + + def test_status_gap_between_steps(self): + self.ex.s["pacing"] = {"ensure_status": 7} + self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"]) + self.assertEqual(self.gaps, [7]) + + # --- связи --- + def test_link_create_and_idempotent(self): + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало") + self.assertEqual(out["result"], {"created": True, "removed": []}) + link = self.fake.links[0] + self.assertEqual((link["source"], link["target"], link["type"]), ("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")) + again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало") + self.assertEqual(again["result"], {"existed": True, "removed": []}) + self.assertEqual(len(self.fake.links), 1) + + def test_link_symmetric_any_direction(self): + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"}) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные") + self.assertEqual(out["result"], {"existed": True, "removed": []}) + + def test_link_conflict_without_replace(self): + self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"}) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало") + self.assertEqual(out["status"], "failed") + self.assertIn("обратное направление", out["error"]) + self.assertEqual(len(self.fake.links), 1) + + def test_link_replace_reverse_and_other_type(self): + self.ex.s["pacing"] = {"ensure_link": 3} + self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"}) + self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"}) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало", replace=True) + self.assertEqual(out["status"], "done") + self.assertEqual(sorted(out["result"]["removed"]), ["ASUPR_Блокер_Начала", "ASUPR_Связанные"]) + self.assertTrue(out["result"]["created"]) + self.assertEqual([(l["source"], l["target"], l["type"]) for l in self.fake.links], + [("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")]) + self.assertEqual(self.gaps, [3, 3]) # между двумя DELETE и перед POST + self.assertEqual([r[0] for r in self.fake.writes()], ["DELETE", "DELETE", "POST"]) + + def test_link_unmanaged_type_ignored(self): + self.fake.links.append({"id": 1, "type": "Duplicate", "source": "AURORA-1", "target": "AURORA-2"}) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные") + self.assertEqual(out["result"], {"created": True, "removed": []}) + self.assertEqual(len(self.fake.links), 2) + + # --- спринты --- + def test_sprint_move_and_idempotent(self): + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2") + self.assertEqual(out["result"], {"sprint": "Спринт 2", "from": None}) + self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2") + again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2") + self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False}) + self.assertEqual(again["jira_calls"], 1) + + def test_sprint_backlog(self): + self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1" + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog") + self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"}) + self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"]) + self.assertEqual(self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")["result"], {"sprint": None, "changed": False}) + + def test_sprint_unknown_name(self): + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9") + self.assertEqual(out["status"], "failed") + self.assertIn("Спринт 9", out["error"]) + self.assertIn("Спринт 2", out["error"]) + + def test_sprint_without_board(self): + self.ex.s["board_id"] = None + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2") + self.assertEqual(out["status"], "failed") + self.assertIn("board_id", out["error"]) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `/usr/bin/python3 -m unittest tests.test_executor_links -v` +Expected: FAIL — `AttributeError: 'Executor' object has no attribute 'do_ensure_status'` (команды `failed` с «неизвестный тип») + +- [ ] **Step 3: Реализация** + +В класс `Executor` после `do_ensure_comment`: +```python + # --- ensure_status --------------------------------------------------------- + def do_ensure_status(self, p: dict, cmd: dict) -> dict: + key, target = p["key"], p["target"] + path = list(target) if isinstance(target, list) else [target] + final = path[-1] + cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"] + start = cur + if cur == final: + return self._out("done", {"from": start, "to": final, "steps": 0}) + if cur in path: + path = path[path.index(cur) + 1:] + steps = 0 + for step in path: + if cur == step: + continue + trs = self.jira.get(API + "/issue/%s/transitions" % key).get("transitions", []) + match = [t for t in trs if (t.get("to") or {}).get("name") == step or t.get("name") == step] + if not match: + avail = ", ".join("%s→%s" % (t.get("name"), (t.get("to") or {}).get("name")) for t in trs) + return self._out("failed", {"from": start, "reached": cur}, + "нет перехода из «%s» в «%s»; доступны: %s" % (cur, step, avail or "—")) + if steps: + self.gap("ensure_status") + self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": match[0]["id"]}}) + cur = step + steps += 1 + return self._out("done", {"from": start, "to": cur, "steps": steps}) + + # --- ensure_link ----------------------------------------------------------- + def symmetric_types(self) -> set: + """Типы, у которых inward == outward («Связанные»): направление не важно.""" + if self._symmetric is None: + try: + types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", []) + self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")} + except HttpError: + self._symmetric = set() + return self._symmetric + + def do_ensure_link(self, p: dict, cmd: dict) -> dict: + src, dst = p["source"], p["target"] + types = self.s.get("link_types") or {} + lt = types.get(p["link_type"], p["link_type"]) + managed = set(types.values()) | {lt} + symmetric = self.symmetric_types() + raw = self.jira.get(API + "/issue/%s" % src, fields="issuelinks")["fields"].get("issuelinks") or [] + links = [] # (тип, другая задача, направление, id) + for l in raw: + t = (l.get("type") or {}).get("name") + if l.get("outwardIssue"): + links.append((t, l["outwardIssue"]["key"], "out", l.get("id"))) + if l.get("inwardIssue"): + links.append((t, l["inwardIssue"]["key"], "in", l.get("id"))) + wanted = [x for x in links if x[1] == dst and x[0] == lt and (x[2] == "out" or lt in symmetric)] + conflicts = [x for x in links if x[1] == dst and x[0] in managed and x not in wanted] + if conflicts and not p.get("replace"): + desc = ", ".join("«%s» (%s)" % (t, "обратное направление" if t == lt else "другой тип") for t, _, _, _ in conflicts) + return self._out("failed", error="между %s и %s уже есть: %s; нужен replace" % (src, dst, desc)) + removed = [] + for t, other, direction, lid in conflicts: + if not lid: + return self._out("failed", {"removed": removed}, "у связи «%s» с %s нет id — снять нельзя" % (t, other)) + if removed: + self.gap("ensure_link") + self.jira.delete(API + "/issueLink/%s" % lid) + removed.append(t) + if wanted: + return self._out("done", {"existed": True, "removed": removed}) + if removed: + self.gap("ensure_link") + # inwardIssue — источник (получает outward-описание «Блокирует начало…»), outwardIssue — цель + self.jira.post(API + "/issueLink", {"type": {"name": lt}, "inwardIssue": {"key": src}, "outwardIssue": {"key": dst}}) + return self._out("done", {"created": True, "removed": removed}) + + # --- ensure_sprint --------------------------------------------------------- + def sprints(self) -> Dict[str, dict]: + """Имя → спринт (active и future) доски board_id; без доски — пусто.""" + if self._sprints is None: + out, start = {}, 0 + board = self.s.get("board_id") + while board: + page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50) + for sp in page.get("values", []): + out[sp["name"]] = sp + if page.get("isLast", True) or not page.get("values"): + break + start += len(page["values"]) + self._sprints = out + return self._sprints + + def do_ensure_sprint(self, p: dict, cmd: dict) -> dict: + key, want = p["key"], p["sprint"] + if not self.s.get("board_id"): + return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны") + cur = ((self.jira.get(AGILE + "/issue/%s" % key, fields="sprint")["fields"].get("sprint") or {}).get("name")) + if want == "backlog": + if cur is None: + return self._out("done", {"sprint": None, "changed": False}) + self.jira.post(AGILE + "/backlog/issue", {"issues": [key]}) + return self._out("done", {"sprint": None, "from": cur}) + if cur == want: + return self._out("done", {"sprint": want, "changed": False}) + sp = self.sprints().get(want) + if not sp: + return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s" + % (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—")) + self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) + return self._out("done", {"sprint": want, "from": cur}) +``` + +- [ ] **Step 4: Запустить — зелёный** + +Run: `/usr/bin/python3 -m unittest tests.test_executor_links -v` +Expected: 14 passed + +- [ ] **Step 5: Коммит** + +```bash +git add src/agent.py tests/test_executor_links.py +git commit -m "feat(agent): исполнитель — переходы статусов, связи с заменой, спринты + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: Чтение состояния (`refresh`) + +**Files:** +- Modify: `src/agent.py` (методы в `Executor`) +- Create: `tests/fake_hub.py` +- Test: `tests/test_refresh.py` + +**Interfaces:** +- Produces: `Executor.state_fields() -> str`, `.search_all(jql) -> list[raw]`, `.to_state(raw) -> dict` (ключи зеркала хаба), + `.links_from(raw) -> list[{"source_key","target_key","link_type"}]`, `.sprint_names(keys) -> dict`, `.do_refresh(p, cmd)` + (шлёт `hub.state(...)` страницами по 100, возвращает `{"issues", "neighbors", "pages"}`). +- Produces (тесты): `fake_hub.FakeHub(settings)`: `.base_url`, `.token = "agent-token"`, `.enqueue(type, params, pause=0) -> id`, + `.commands`, `.results`, `.states`, `.known`, `.claims`, `.fail_results` (сколько раз ответить 503 на отчёт), + `.reject_results` ({id: код}), `.stop()`. + +- [ ] **Step 1: Фейковый хаб** + +`tests/fake_hub.py`: +```python +"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти.""" +import json +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class FakeHub: + token = "agent-token" + + def __init__(self, settings): + self.settings = dict(settings) + self.queue = [] + self.commands = {} + self.results = {} + self.states = [] + self.known = [] + self.claims = 0 + self.fail_results = 0 + self.reject_results = {} + self.in_flight = None + self.lock = threading.Lock() + hub = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + hub.handle(self, "GET") + + def do_POST(self): + hub.handle(self, "POST") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + + def enqueue(self, ctype, params, pause=0): + cid = len(self.commands) + 1 + self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"} + self.queue.append(cid) + return cid + + def handle(self, h, method): + length = int(h.headers.get("Content-Length") or 0) + body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} + if h.headers.get("Authorization") != "Bearer " + self.token: + return self._send(h, 401, {"error": "unauthorized"}) + with self.lock: + code, payload = self.route(method, h.path.split("?")[0], body) + self._send(h, code, payload) + + def _send(self, h, code, payload): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + h.send_response(code) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(data))) + h.end_headers() + h.wfile.write(data) + + def route(self, method, path, body): + if path == "/agent/claim": + self.claims += 1 + if self.in_flight and self.commands[self.in_flight]["status"] == "claimed": + return 200, {"command": None, "settings_version": self.settings["settings_version"]} + if not self.queue: + return 200, {"command": None, "settings_version": self.settings["settings_version"]} + cid = self.queue.pop(0) + self.commands[cid]["status"] = "claimed" + self.in_flight = cid + c = self.commands[cid] + return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]}, + "settings_version": self.settings["settings_version"]} + if path == "/agent/settings": + return 200, self.settings + if path == "/agent/known-keys": + return 200, {"keys": list(self.known)} + if path == "/agent/state": + self.states.append(body) + return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])} + m = re.match(r"^/agent/commands/(\d+)$", path) + if m: + c = self.commands.get(int(m.group(1))) + return (200, c) if c else (404, {"error": "not_found"}) + m = re.match(r"^/agent/commands/(\d+)/result$", path) + if m: + cid = int(m.group(1)) + if self.fail_results > 0: + self.fail_results -= 1 + return 503, {"error": "busy"} + if cid in self.reject_results: + return self.reject_results[cid], {"error": "rejected"} + if cid not in self.commands: + return 404, {"error": "not_found"} + self.results[cid] = body + self.commands[cid]["status"] = body.get("status") + if self.in_flight == cid: + self.in_flight = None + return 200, self.commands[cid] + return 404, {"error": "not_found", "detail": path} +``` + +- [ ] **Step 2: Тесты refresh** + +`tests/test_refresh.py`: +```python +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_hub import FakeHub +from fake_jira import FakeJira + + +class RefreshTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.hub = FakeHub(SETTINGS) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.jira = agent.JiraClient(cfg) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg)) + self.ex.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + self.hub.stop() + + def test_to_state_and_links(self): + self.fake.add("AURORA-1", assignee="ivanov", customfield_10801="2026-10-20T00:00:00.000+0300", + customfield_15302="AURORA-1350", labels=["AWPCA", "kind-dev"], sprint="Спринт 2") + self.fake.add("AURORA-2") + self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-1", "target": "AURORA-2"}) + raw = self.fake.issue_json("AURORA-1") + st = self.ex.to_state(raw) + self.assertEqual(st["key"], "AURORA-1") + self.assertEqual(st["assignee"], "ivanov") + self.assertEqual(st["planned_start"], "2026-10-20") + self.assertEqual(st["epic_key"], "AURORA-1350") + self.assertEqual(st["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(st["issue_type"], "Задача") + self.assertEqual(st["jira_updated"], "2026-10-01T10:00:00.000+0300") + self.assertIsNone(st["sprint"]) # спринт заполняет do_refresh + self.assertEqual(self.ex.links_from(raw), [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}]) + self.assertEqual(self.ex.links_from(self.fake.issue_json("AURORA-2")), + [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}]) + self.assertIn("customfield_15302", self.ex.state_fields()) + + def test_full_refresh_with_neighbors_and_sprints(self): + for i in range(1, 4): + self.fake.add("AURORA-%d" % i, sprint="Спринт 2" if i < 3 else None) + self.fake.add("OTHER-9", in_base=False) + self.fake.add("AURORA-7", in_base=False) + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "OTHER-9"}) + self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-7", "target": "AURORA-2"}) + self.hub.known = ["AURORA-7"] + out = self.ex.run({"id": 5, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}}) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"issues": 3, "neighbors": 1, "pages": 1}) + state = self.hub.states[0] + self.assertEqual(state["command_id"], 5) + self.assertEqual(sorted(i["key"] for i in state["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3", "OTHER-9"]) + self.assertEqual(state["neighbor_keys"], ["OTHER-9"]) + self.assertTrue(state["last_page"]) + sprints = {i["key"]: i["sprint"] for i in state["issues"]} + self.assertEqual(sprints["AURORA-1"], "Спринт 2") + self.assertIsNone(sprints["AURORA-3"]) + links = {(l["source_key"], l["target_key"]) for l in state["links"]} + self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")}) + self.assertEqual([r[1] for r in self.fake.requests if r[1] == "/rest/api/2/search"].count("/rest/api/2/search"), 2) + + def test_refresh_by_keys_pages(self): + for i in range(1, 121): + self.fake.add("AURORA-%d" % i) + keys = ["AURORA-%d" % i for i in range(1, 121)] + out = self.ex.run({"id": 6, "type": "refresh", "params": {"keys": keys}}) + self.assertEqual(out["result"], {"issues": 120, "neighbors": 0, "pages": 2}) + self.assertEqual(len(self.hub.states), 2) + self.assertEqual(self.hub.states[0]["page"], 1) + self.assertFalse(self.hub.states[0]["last_page"]) + self.assertTrue(self.hub.states[1]["last_page"]) + self.assertEqual(len(self.hub.states[0]["issues"]) + len(self.hub.states[1]["issues"]), 120) + + def test_refresh_empty_keys(self): + out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}}) + self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0}) + self.assertEqual(self.fake.requests, []) + self.assertEqual(self.hub.states, []) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 3: Запустить — падает** + +Run: `/usr/bin/python3 -m unittest tests.test_refresh -v` +Expected: FAIL — `AttributeError: 'Executor' object has no attribute 'to_state'` + +- [ ] **Step 4: Реализация** + +В класс `Executor` после `do_ensure_sprint`: +```python + # --- refresh --------------------------------------------------------------- + STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", + "issuetype", "created", "updated", "issuelinks"] + + def state_fields(self) -> str: + extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)] + ep = (self.s.get("epic_link") or {}) + if ep.get("mode", "field") == "field" and ep.get("field_id"): + extra.append(ep["field_id"]) + return ",".join(self.STATE_FIELDS + extra) + + def search_all(self, jql: str) -> List[dict]: + issues, start = [], 0 + while True: + page = self.jira.get(API + "/search", jql=jql, fields=self.state_fields(), startAt=start, maxResults=100) + got = page.get("issues") or [] + issues += got + start += len(got) + if not got or start >= int(page.get("total") or 0): + return issues + + def to_state(self, raw: dict) -> dict: + f = raw.get("fields") or {} + + def name(obj): + return (obj or {}).get("name") + + ep = self.s.get("epic_link") or {} + epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None + return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"), + "description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")), + "assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")), + "planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None, + "planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None, + "epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None, + "created": f.get("created"), "jira_updated": f.get("updated")} + + def links_from(self, raw: dict) -> List[dict]: + key, out = raw["key"], [] + for l in (raw.get("fields") or {}).get("issuelinks") or []: + t = (l.get("type") or {}).get("name") + if l.get("outwardIssue"): + out.append({"source_key": key, "target_key": l["outwardIssue"]["key"], "link_type": t}) + if l.get("inwardIssue"): + out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t}) + return out + + def sprint_names(self, keys: List[str]) -> Dict[str, str]: + names = {} + if not keys or not self.s.get("board_id"): + return names + for name, sp in self.sprints().items(): + for i in range(0, len(keys), 100): + chunk = keys[i:i + 100] + page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk), + fields="key", maxResults=200) + for it in page.get("issues") or []: + names[it["key"]] = name + return names + + def do_refresh(self, p: dict, cmd: dict) -> dict: + if p.get("jql"): + raws = self.search_all(p["jql"]) + else: + keys = [k for k in (p.get("keys") or []) if k] + if not keys: + return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0}) + raws = [] + for i in range(0, len(keys), 100): + raws += self.search_all("key in (%s)" % ",".join(keys[i:i + 100])) + fetched = {r["key"] for r in raws} + known = set(self.hub.known_keys()) if self.hub else set() + linked = set() + for r in raws: + for l in self.links_from(r): + linked.add(l["source_key"]) + linked.add(l["target_key"]) + missing = sorted(k for k in linked if k not in fetched and k not in known) + neighbors = [] + for i in range(0, len(missing), 100): + neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100])) + everything = raws + neighbors + names = self.sprint_names([r["key"] for r in everything]) + issues, links, seen = [], [], set() + for r in everything: + st = self.to_state(r) + st["sprint"] = names.get(r["key"]) + issues.append(st) + for l in self.links_from(r): + sig = (l["source_key"], l["target_key"], l["link_type"]) + if sig not in seen: + seen.add(sig) + links.append(l) + neighbor_keys = [r["key"] for r in neighbors] + pages = 0 + for i in range(0, len(issues), 100): + chunk = issues[i:i + 100] + chunk_keys = {x["key"] for x in chunk} + pages += 1 + self.hub.state({"command_id": cmd.get("id"), "issues": chunk, + "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys], + "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], + "page": pages, "last_page": i + 100 >= len(issues)}) + return self._out("done", {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}) +``` + +- [ ] **Step 5: Запустить — зелёный** + +Run: `/usr/bin/python3 -m unittest tests.test_refresh -v` +Expected: 4 passed + +- [ ] **Step 6: Коммит** + +```bash +git add src/agent.py tests/fake_hub.py tests/test_refresh.py +git commit -m "feat(agent): чтение состояния — поиск, соседи по связям, спринты, страницы в хаб + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 6: Цикл агента, восстановление, команда `run` + +**Files:** +- Modify: `src/agent.py` (класс `Runner` после `Executor`, `cmd_run`, `main`) +- Test: `tests/test_runner.py` + +**Interfaces:** +- Produces: `Runner(cfg, hub, jira, journal)`: `.settings`, `.executor`, `.stop` (флаг), `.sleep` (подменяемый), `.idle_limit` (None | int — выйти после N пустых опросов, для тестов), + `.load_settings()`, `.send_result(rec) -> bool`, `.wait_pause(pause_seconds) -> bool`, `.execute(cmd)`, `.recover()`, `.loop()`; + `cmd_run(cfg, journal_path) -> int`. + +- [ ] **Step 1: Тесты** + +`tests/test_runner.py`: +```python +import os +import tempfile +import time +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_hub import FakeHub +from fake_jira import FakeJira + + +class RunnerTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.hub = FakeHub(SETTINGS) + self.dir = tempfile.TemporaryDirectory() + self.journal = agent.Journal(os.path.join(self.dir.name, "journal.jsonl")) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), self.journal) + self.sleeps = [] + self.runner.sleep = self.sleeps.append + self.runner.jira.sleep = no_sleep + self.runner.idle_limit = 1 + self.fake.add("AURORA-1", duedate="2026-10-20") + + def tearDown(self): + self.fake.stop() + self.hub.stop() + self.dir.cleanup() + + def kinds(self): + return [(r["kind"], r["command_id"]) for r in self.journal.records()] + + def test_processes_queue(self): + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.hub.enqueue("refresh", {"keys": ["AURORA-1"]}) + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "done") + self.assertEqual(self.hub.results[1]["result"], {"changed": ["duedate"]}) + self.assertEqual(self.hub.results[1]["jira_calls"], 2) + self.assertEqual(self.hub.results[2]["status"], "done") + self.assertEqual(len(self.hub.states), 1) + self.assertEqual(self.hub.states[0]["issues"][0]["duedate"], "2026-10-23") + self.assertEqual(self.kinds(), [("started", 1), ("finished", 1), ("acked", 1), ("started", 2), ("finished", 2), ("acked", 2)]) + self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0 + + def test_pause_waits_from_last_jira_call(self): + self.runner.jira.last_call_at = time.monotonic() + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=3) + self.runner.loop() + self.assertEqual(len(self.sleeps), 3) + self.assertAlmostEqual(sum(self.sleeps), 3, delta=0.1) + self.assertEqual(self.hub.results[1]["status"], "done") + + def test_result_retried_when_hub_down(self): + self.hub.fail_results = 2 + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "done") + self.assertEqual(self.sleeps, [10, 10]) + self.assertEqual(self.journal.unacked_finished(), []) + + def test_result_rejected_is_acked(self): + self.hub.reject_results = {1: 404} + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertNotIn(1, self.hub.results) + self.assertEqual(self.journal.unacked_finished(), []) + self.assertEqual(self.kinds()[-1], ("acked", 1)) + + def test_recover_unfinished_started(self): + cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}, pause=99) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "claimed" + self.hub.in_flight = cid + self.journal.started(cid) + self.runner.loop() + self.assertEqual(self.hub.results[cid]["result"], {"changed": []}) # уже так в Jira — записи нет + self.assertEqual(self.journal.unfinished_started(), []) + self.assertEqual(self.sleeps, []) # пауза при восстановлении не применяется + self.assertEqual([r[0] for r in self.fake.writes()], []) + + def test_recover_skips_command_not_claimed_anymore(self): + cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "cancelled" + self.journal.started(cid) + self.runner.loop() + self.assertNotIn(cid, self.hub.results) + self.assertEqual(self.kinds(), [("started", cid), ("acked", cid)]) + + def test_recover_unacked_finished(self): + cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "claimed" + self.hub.in_flight = cid + self.journal.started(cid) + self.journal.finished(cid, "done", {"changed": ["duedate"]}, None, 2, 0) + self.hub.enqueue("refresh", {"keys": ["AURORA-1"]}) + self.runner.loop() + self.assertEqual(self.hub.results[cid]["result"], {"changed": ["duedate"]}) + self.assertEqual(self.hub.results[2]["status"], "done") + self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)]) + + def test_stop_during_pause(self): + self.runner.jira.last_call_at = time.monotonic() + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5) + self.runner.sleep = lambda s: setattr(self.runner, "stop", True) + self.runner.loop() + self.assertEqual(self.journal.records(), []) + self.assertEqual(self.hub.results, {}) + self.assertEqual(self.hub.commands[1]["status"], "claimed") + + def test_settings_reload_on_version_change(self): + self.runner.load_settings() + self.runner.settings["settings_version"] = 0 + self.runner.loop() + self.assertEqual(self.runner.settings["settings_version"], 1) + + def test_hub_down_at_start_keeps_polling(self): + calls = [] + + def hub_settings(): + calls.append(1) + if len(calls) < 3: + raise ConnectionError("нет связи") + return dict(SETTINGS) + + self.runner.hub.settings = hub_settings + self.runner.loop() + self.assertEqual(len(calls), 3) + self.assertEqual(self.sleeps, [0, 0]) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `/usr/bin/python3 -m unittest tests.test_runner -v` +Expected: FAIL — `AttributeError: module 'agent' has no attribute 'Runner'` + +- [ ] **Step 3: Реализация** + +В `src/agent.py` после класса `Executor`: +```python +# --------------------------------------------------------------------------- +# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте +# --------------------------------------------------------------------------- +class Runner: + def __init__(self, cfg: dict, hub: HubClient, jira: JiraClient, journal: Journal): + self.cfg, self.hub, self.jira, self.journal = cfg, hub, jira, journal + self.settings = None # type: Optional[dict] + self.executor = None # type: Optional[Executor] + self.stop = False + self.sleep = time.sleep + self.idle_limit = None # type: Optional[int] + + @property + def poll(self) -> float: + return max(float(self.cfg.get("poll_seconds", 5) or 0), 0) + + def load_settings(self) -> None: + self.settings = self.hub.settings() + self.executor = Executor(self.jira, self.settings, self.hub) + self.executor.sleep = self.sleep + log("настройки хаба v%s (проект %s)" % (self.settings.get("settings_version"), self.settings.get("project_key"))) + + def send_result(self, rec: dict) -> bool: + """Отчёт хабу; повторяется, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена или удалена): + считаем закрытой, чтобы не зациклиться.""" + cid = rec["command_id"] + payload = {"status": rec["status"], "result": rec.get("result"), "error": rec.get("error"), + "jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0)} + while not self.stop: + try: + self.hub.result(cid, payload) + self.journal.acked(cid) + return True + except HttpError as e: + if e.code in (404, 409, 422): + log("хаб отверг отчёт по #%s: %s — считаю закрытым" % (cid, e)) + self.journal.acked(cid) + return True + log("хаб: %s, повтор через 10 с" % e) + except ConnectionError as e: + log("хаб недоступен: %s, повтор через 10 с" % e) + self.sleep(10) + return False + + def wait_pause(self, pause_seconds) -> bool: + """Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы.""" + pause = float(pause_seconds or 0) + if pause > 0 and self.jira.last_call_at is not None: + remaining = pause - (time.monotonic() - self.jira.last_call_at) + if remaining > 0: + log(" пауза %.0f с до команды" % remaining) + while remaining > 0 and not self.stop: + step = min(remaining, 1.0) + self.sleep(step) + remaining -= step + return not self.stop + + def execute(self, cmd: dict) -> None: + log("#%s %s %s" % (cmd["id"], cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200])) + if not self.wait_pause(cmd.get("pause_seconds")): + return + self.journal.started(cmd["id"]) + out = self.executor.run(cmd) + self.journal.finished(cmd["id"], out["status"], out.get("result"), out.get("error"), out["jira_calls"], out["rate_limited"]) + log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))) + rec = dict(out) + rec["command_id"] = cmd["id"] + self.send_result(rec) + + def recover(self) -> None: + for rec in self.journal.unacked_finished(): + log("досылаю отчёт по #%s" % rec["command_id"]) + self.send_result(rec) + for cid in self.journal.unfinished_started(): + try: + cmd = self.hub.command(cid) + except HttpError as e: + log("#%s: хаб ответил %s — закрываю запись журнала" % (cid, e)) + self.journal.acked(cid) + continue + if cmd.get("status") != "claimed": + log("#%s уже %s в хабе — пропускаю" % (cid, cmd.get("status"))) + self.journal.acked(cid) + continue + log("#%s начата до перезапуска — выполняю заново через проверку" % cid) + cmd["pause_seconds"] = 0 + self.execute(cmd) + + def loop(self) -> None: + idle, recovered = 0, False + while not self.stop: + if self.settings is None: + try: + self.load_settings() + except (ConnectionError, HttpError) as e: + log("хаб: %s" % e) + self.sleep(self.poll) + continue + if not recovered: + self.recover() + recovered = True + try: + resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso) + except (ConnectionError, HttpError) as e: + log("хаб: %s" % e) + self.sleep(self.poll) + continue + if resp.get("settings_version") != self.settings.get("settings_version"): + try: + self.load_settings() + except (ConnectionError, HttpError) as e: + log("хаб: не перечитать настройки: %s" % e) + cmd = resp.get("command") + if cmd: + idle = 0 + self.execute(cmd) + continue + idle += 1 + if self.idle_limit is not None and idle >= self.idle_limit: + return + self.sleep(self.poll) + + +def cmd_run(cfg: dict, journal_path: str) -> int: + runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path)) + + def on_signal(signum, frame): + if runner.stop: + log("повторный сигнал — выхожу немедленно") + sys.exit(130) + runner.stop = True + log("сигнал %s: доделываю текущую команду и выхожу" % signum) + + signal.signal(signal.SIGINT, on_signal) + signal.signal(signal.SIGTERM, on_signal) + log("jira-agent v%d: хаб %s, Jira %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["jira"]["base_url"], journal_path)) + runner.loop() + log("остановлен") + return 0 +``` + +В `main` заменить хвост: +```python + if args.cmd == "check": + return cmd_check(cfg) + if args.cmd == "prune": + removed = Journal(args.journal).prune(30) + log("журнал: удалено записей %d" % removed) + return 0 + return cmd_run(cfg, args.journal) +``` + +- [ ] **Step 4: Запустить — зелёный, затем весь набор** + +Run: `/usr/bin/python3 -m unittest tests.test_runner -v && /usr/bin/python3 -m unittest discover -s tests -t . -v` +Expected: 10 passed; весь набор зелёный + +- [ ] **Step 5: Коммит** + +```bash +git add src/agent.py tests/test_runner.py +git commit -m "feat(agent): цикл claim → пауза → журнал → отчёт, восстановление после перезапуска, run + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 7: README, CLAUDE.md, ARCHITECTURE.md, проверка синтаксиса 3.9 + +**Files:** +- Create: `README.md`, `tests/test_py39.py`, `.claude/code-paths` (если хук require-plan не видит `src/` по умолчанию — проверить `cat .claude/hooks/*` в репозитории; при стандартной обвязке файл не нужен) +- Modify: `CLAUDE.md`, `ARCHITECTURE.md` + +- [ ] **Step 1: Тест синтаксиса** + +`tests/test_py39.py`: +```python +import ast +import os +import unittest + +from _common import SRC + + +class Py39Test(unittest.TestCase): + def test_agent_parses_as_python_39(self): + with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: + ast.parse(f.read(), feature_version=(3, 9)) + + def test_no_third_party_imports(self): + with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: + tree = ast.parse(f.read()) + allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "sys", "time", "urllib", "datetime", "typing"} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + self.assertIn(a.name.split(".")[0], allowed, a.name) + elif isinstance(node, ast.ImportFrom): + self.assertIn((node.module or "").split(".")[0], allowed, node.module) + + +if __name__ == "__main__": + unittest.main() +``` + +Run: `/usr/bin/python3 -m unittest tests.test_py39 -v` +Expected: 2 passed + +- [ ] **Step 2: README.md** + +Написать полностью на русском по пунктам: +1. Что это и откуда спека (`../jira-hub/openspec/changes/bridge-core/specs/agent/spec.md`); связь с `jira-hub`. +2. Доставка на виртуалку: скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке (например `~/jira-agent/`), переименовать в `agent.json`, заполнить `hub_url` (`http://192.168.0.106:8790`), `hub_token` (из `jira-hub tokens` на Mac mini), `jira.base_url`, `jira.auth` (логин/пароль или `token`), `verify_ssl`/`ca_bundle` при корпоративном сертификате. Напоминание: `agent.json` никуда не копировать, кроме виртуалки. +3. Проверка: `python3 agent.py check` — что печатает в норме и при ошибках. +4. Запуск: `python3 agent.py` в терминале; в фоне — `nohup python3 -u agent.py > agent.log 2>&1 &`; остановка — Ctrl-C или `kill ` (текущая команда доделывается, повторный сигнал — выход немедленно). +5. Автозапуск через launchd: пример `~/Library/LaunchAgents/ru.aepif.jira-agent.plist` (`ProgramArguments` — `/usr/bin/python3`, `-u`, путь к `agent.py`; `WorkingDirectory`; `RunAtLoad`; `KeepAlive`; `StandardOutPath`/`StandardErrorPath`), команды `launchctl load/unload`. +6. Журнал: `journal.jsonl` рядом с агентом — что в нём, зачем, `python3 agent.py prune` (подтверждённые записи старше 30 дней); журнал содержит параметры команд (тексты задач) — не выносить с виртуалки. +7. Что делает агент с командами: таблица типов со ссылкой на спеку; правило «проверь, потом сделай»; паузы приходят от хаба; 429/503 — Retry-After; версия агента `AGENT_VERSION` и предупреждение хаба об устаревшем агенте. +8. Тесты: `/usr/bin/python3 -m unittest discover -s tests -t .` (тот же Python 3.9.6, что на виртуалке). +9. Обновление агента: скопировать новый `agent.py`, перезапустить; журнал и `agent.json` не трогать. + +- [ ] **Step 3: CLAUDE.md и ARCHITECTURE.md** + +Заменить `{{...}}` шаблона: что это (абзац из proposal хаба про агента), стек (Python 3.9.6, только stdlib, unittest), архитектура — +один файл `src/agent.py` с разделами (HTTP-клиенты, `Journal`, `Executor`, `Runner`, CLI) и тесты с фейковыми Jira/хабом, +команды (`/usr/bin/python3 -m unittest discover -s tests -t .`, `python3 src/agent.py check`), тесты (фейки в `tests/fake_*.py`, +внешних сервисов нет), первая подсистема — исполнитель команд. В ARCHITECTURE.md — таблица разделов файла и границы: +«пароль Jira только в `agent.json`», «политика темпа не хранится», «одна команда в полёте», «отчёт до подтверждения». +Указать, что спека и план живут в `jira-hub` (`openspec/changes/bridge-core`, `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`). + +- [ ] **Step 4: Весь набор и коммит** + +Run: `/usr/bin/python3 -m unittest discover -s tests -t . -v` +Expected: все зелёные + +```bash +git add README.md CLAUDE.md ARCHITECTURE.md tests/test_py39.py +git commit -m "docs(agent): README (доставка, запуск, launchd, prune), архитектура, проверка синтаксиса 3.9 + +Co-Authored-By: Claude Fable 5.1 " +``` From ad62822b738aa52537eb4426d14db4e7ecd8b6d6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:26:55 +0400 Subject: [PATCH 02/30] =?UTF-8?q?docs:=20=D0=B7=D0=B0=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20design.md=20(=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=81=D1=83=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 openspec/changes/bridge-core/design.md diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md new file mode 100644 index 0000000..5c7bf3d --- /dev/null +++ b/openspec/changes/bridge-core/design.md @@ -0,0 +1,8 @@ +# bridge-core (агент) — решения + +Спека изменения лежит в репозитории jira-hub: `jira-hub/openspec/changes/bridge-core/` (specs/agent, specs/commands, specs/hub-api, design.md). Здесь — только решения, принятые при реализации агента без обсуждения с человеком. + +## Decisions made without discussion + +| # | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| From e290b4e4a62dfb5bb34c8406bc584aae11f6bb4d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:28:04 +0400 Subject: [PATCH 03/30] =?UTF-8?q?feat(agent):=20=D1=81=D0=BA=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D1=82,=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=8B=20Ji?= =?UTF-8?q?ra=20=D0=B8=20=D1=85=D0=B0=D0=B1=D0=B0,=20=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=D1=8B=D0=B9=20Jira,=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D0=B0=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.example.json | 12 +++ src/agent.py | 216 ++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 7 ++ tests/_common.py | 46 ++++++++ tests/fake_jira.py | 232 +++++++++++++++++++++++++++++++++++++++++ tests/test_clients.py | 78 ++++++++++++++ 6 files changed, 591 insertions(+) create mode 100644 src/agent.example.json create mode 100644 src/agent.py create mode 100644 tests/__init__.py create mode 100644 tests/_common.py create mode 100644 tests/fake_jira.py create mode 100644 tests/test_clients.py diff --git a/src/agent.example.json b/src/agent.example.json new file mode 100644 index 0000000..15d7765 --- /dev/null +++ b/src/agent.example.json @@ -0,0 +1,12 @@ +{ + "hub_url": "http://192.168.0.106:8790", + "hub_token": "агентский токен из `jira-hub tokens`", + "jira": { + "base_url": "https://sdlc.go.rshbank.ru/jira", + "auth": {"type": "basic", "user": "логин", "password": "пароль", "token": ""} + }, + "verify_ssl": true, + "ca_bundle": null, + "timeout": 60, + "poll_seconds": 5 +} diff --git a/src/agent.py b/src/agent.py new file mode 100644 index 0000000..76a29e1 --- /dev/null +++ b/src/agent.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""jira-agent — клиент моста Jira для виртуалки с доступом к Jira банка. + +Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу +«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека, +Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md. + + python3 agent.py check # доступ к хабу и Jira, без записи + python3 agent.py run # основной цикл (по умолчанию) + python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней +""" +import argparse +import base64 +import json +import os +import signal +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +AGENT_VERSION = 1 +HERE = os.path.dirname(os.path.abspath(__file__)) +API = "/rest/api/2" +AGILE = "/rest/agile/1.0" + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def log(msg: str) -> None: + print("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True) + + +def load_config(path: str) -> dict: + try: + with open(path, encoding="utf-8") as f: + cfg = json.load(f) + except FileNotFoundError: + sys.exit("нет файла настроек %s — скопируйте agent.example.json в agent.json и заполните" % path) + for key in ("hub_url", "hub_token", "jira"): + if not cfg.get(key): + sys.exit("в %s нет обязательного поля %s" % (path, key)) + if not cfg["jira"].get("base_url"): + sys.exit("в %s нет jira.base_url" % path) + cfg.setdefault("timeout", 60) + cfg.setdefault("poll_seconds", 5) + cfg.setdefault("verify_ssl", True) + return cfg + + +# --------------------------------------------------------------------------- +# HTTP +# --------------------------------------------------------------------------- +class HttpError(Exception): + def __init__(self, code: int, text: str, headers=None): + super().__init__("HTTP %d: %s" % (code, text[:300])) + self.code, self.text, self.headers = code, text, headers or {} + + +class Http: + def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None): + self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx + + def __repr__(self): + return "<%s %s>" % (self.__class__.__name__, self.base) + + def call(self, method: str, path: str, body=None, params=None) -> Any: + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + for k, v in self.headers.items(): + req.add_header(k, v) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json; charset=utf-8") + try: + with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) if raw else {} + except urllib.error.HTTPError as e: + raise HttpError(e.code, e.read().decode("utf-8", "replace"), dict(e.headers)) from None + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e: + raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None + + +class JiraClient(Http): + """Все вызовы Jira идут здесь: счётчики вызовов и 429/503, время последнего вызова, повтор по Retry-After.""" + + def __init__(self, cfg: dict): + j = cfg["jira"] + auth = j.get("auth") or {} + kind = auth.get("type") or ("bearer" if auth.get("token") else "basic") + if kind == "bearer": + if not auth.get("token"): + sys.exit("jira.auth.token пуст") + header = "Bearer " + auth["token"] + else: + if not (auth.get("user") and auth.get("password")): + sys.exit("jira.auth.user / jira.auth.password пусты") + header = "Basic " + base64.b64encode(("%s:%s" % (auth["user"], auth["password"])).encode()).decode() + ctx = None + if not cfg.get("verify_ssl", True): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + elif cfg.get("ca_bundle"): + ctx = ssl.create_default_context(cafile=cfg["ca_bundle"]) + super().__init__(j["base_url"], {"Authorization": header, "X-Atlassian-Token": "no-check"}, cfg.get("timeout", 60), ctx) + self.calls = 0 + self.rate_limited = 0 + self.last_call_at = None # type: Optional[float] (time.monotonic) + self.last_call_iso = None # type: Optional[str] + self.sleep = time.sleep + + def reset_counters(self) -> None: + self.calls, self.rate_limited = 0, 0 + + def call(self, method, path, body=None, params=None): + for attempt in range(1, 6): + try: + out = super().call(method, path, body, params) + self.calls += 1 + self.last_call_at, self.last_call_iso = time.monotonic(), now_iso() + return out + except HttpError as e: + if e.code in (429, 503) and attempt < 5: + retry = e.headers.get("Retry-After") + wait = int(retry) if retry and retry.isdigit() else 60 * attempt + self.rate_limited += 1 + log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt)) + self.sleep(wait) + continue + if e.code in (429, 503): + self.rate_limited += 1 + raise + + def get(self, path, **params): + return self.call("GET", path, params=params or None) + + def post(self, path, body): + return self.call("POST", path, body) + + def put(self, path, body): + return self.call("PUT", path, body) + + def delete(self, path): + return self.call("DELETE", path) + + +class HubClient(Http): + def __init__(self, cfg: dict): + super().__init__(cfg["hub_url"], {"Authorization": "Bearer " + cfg["hub_token"]}, cfg.get("timeout", 60)) + + def claim(self, agent_version, settings_version, last_jira_call_at) -> dict: + return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, + "last_jira_call_at": last_jira_call_at}) + + def settings(self) -> dict: + return self.call("GET", "/agent/settings") + + def command(self, command_id) -> dict: + return self.call("GET", "/agent/commands/%d" % int(command_id)) + + def result(self, command_id, payload: dict) -> dict: + return self.call("POST", "/agent/commands/%d/result" % int(command_id), payload) + + def state(self, payload: dict) -> dict: + return self.call("POST", "/agent/state", payload) + + def known_keys(self) -> List[str]: + return self.call("GET", "/agent/known-keys")["keys"] + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def cmd_check(cfg: dict) -> int: + ok = True + try: + s = HubClient(cfg).settings() + log("хаб: доступен, настройки v%s, проект %s" % (s.get("settings_version"), s.get("project_key"))) + except (ConnectionError, HttpError) as e: + log("хаб: НЕДОСТУПЕН — %s" % e) + ok = False + try: + me = JiraClient(cfg).get(API + "/myself") + log("Jira: доступен, пользователь %s" % me.get("name")) + except (ConnectionError, HttpError) as e: + log("Jira: НЕДОСТУПЕН — %s" % e) + ok = False + return 0 if ok else 1 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default=os.path.join(HERE, "agent.json")) + ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl")) + ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "prune"]) + args = ap.parse_args(argv) + cfg = load_config(args.config) + if args.cmd == "check": + return cmd_check(cfg) + log("команда %s ещё не реализована" % args.cmd) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b7a4fdc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# Тесты импортируют соседние модули (_common, fake_jira) по простому имени — добавляем каталог в путь. +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) diff --git a/tests/_common.py b/tests/_common.py new file mode 100644 index 0000000..41fc089 --- /dev/null +++ b/tests/_common.py @@ -0,0 +1,46 @@ +"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейковый Jira.""" +import os +import sys + +SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") +if SRC not in sys.path: + sys.path.insert(0, SRC) + +import agent # noqa: E402 + +ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0, + "ensure_sprint": 0, "refresh": 0} + +SETTINGS = { + "base_jql": "project = AURORA AND labels = AWPCA", + "project_key": "AURORA", + "issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}, + "epic_name_field_id": "customfield_15304", + "field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels", + "planned_start": "customfield_10801", "planned_end": "customfield_10802"}, + "epic_link": {"mode": "field", "field_id": "customfield_15302"}, + "priority_map": {"Наивысший": "Критичный", "Высокий": "Высокий", "Средний": "Средний", "Низкий": "Низкий"}, + "priority_value_key": "name", + "labels": ["AWPCA"], + "status_labels": {}, + "kind_labels": {}, + "comment_marker": "Актуализация 10.10.2026", + "transitions_map": {}, + "link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}, + "board_id": 4216, + "pacing": dict(ZERO_PACING), + "min_agent_version": 1, + "settings_version": 1, +} + + +def jira_cfg(fake, **over): + cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token", + "jira": {"base_url": fake.base_url, "auth": {"type": "basic", "user": "me", "password": "secret"}}, + "verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0} + cfg.update(over) + return cfg + + +def no_sleep(_seconds): + pass diff --git a/tests/fake_jira.py b/tests/fake_jira.py new file mode 100644 index 0000000..3075a42 --- /dev/null +++ b/tests/fake_jira.py @@ -0,0 +1,232 @@ +"""Фейковый Jira Server для тестов агента: состояние в памяти, http.server в потоке, +только маршруты, которые использует агент. Семантика связей — как у настоящего Jira Server.""" +import json +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +WORKFLOW = { # статус → [(имя перехода, целевой статус)] + "Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")], + "Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")], + "Ожидает анализа": [("Анализ", "Анализ"), ("Снято", "Снято")], + "Анализ": [("Готово", "Готово")], + "Готово к тестированию": [("Тестирование", "Тестирование")], + "Готово": [], + "Снято": [], +} +LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"}, + {"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}] +CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") + + +class FakeJira: + def __init__(self, project="AURORA"): + self.project = project + self.issues = {} + self.comments = {} + self.links = [] + self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}] + self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 + self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой + self.requests = [] # [(метод, путь)] + self.lock = threading.Lock() + fake = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + fake.handle(self, "GET") + + def do_POST(self): + fake.handle(self, "POST") + + def do_PUT(self): + fake.handle(self, "PUT") + + def do_DELETE(self): + fake.handle(self, "DELETE") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + + def add(self, key, in_base=True, **fields): + base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний", + "assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [], + "issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300", + "sprint": None} + base.update({c: None for c in CUSTOM}) + base.update(fields) + self.issues[key] = base + return base + + def writes(self): + return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] + + # --- представление --------------------------------------------------------- + def _user(self, name): + return {"name": name, "displayName": name} if name else None + + def issue_json(self, key): + f = self.issues[key] + fields = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]}, + "priority": {"name": f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]), + "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, + "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} + fields.update({c: f.get(c) for c in CUSTOM}) + return {"key": key, "id": str(10000 + sorted(self.issues).index(key)), "fields": fields} + + def links_of(self, key): + out = [] + for l in self.links: + if l["source"] == key: + out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "outwardIssue": {"key": l["target"]}}) + elif l["target"] == key: + out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "inwardIssue": {"key": l["source"]}}) + return out + + def search_keys(self, jql): + m = re.search(r"key in \(([^)]*)\)", jql or "") + if m: + keys = [k.strip() for k in m.group(1).split(",")] + return [k for k in keys if k in self.issues] + if "reporter = currentUser()" in (jql or ""): + return [k for k, f in self.issues.items() if f["reporter"] == "me"] + return [k for k, f in self.issues.items() if "AWPCA" in f["labels"]] + + # --- маршрутизация ---------------------------------------------------------- + def handle(self, h, method): + url = urlparse(h.path) + q = {k: v[0] for k, v in parse_qs(url.query).items()} + length = int(h.headers.get("Content-Length") or 0) + body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} + with self.lock: + self.requests.append((method, url.path)) + if self.fail_next: + code, headers = self.fail_next.pop(0) + return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers) + try: + code, payload = self.route(method, url.path, q, body) + except KeyError as e: + code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} + self._send(h, code, payload) + + def _send(self, h, code, payload, headers=None): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b"" + h.send_response(code) + for k, v in (headers or {}).items(): + h.send_header(k, v) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(data))) + h.end_headers() + h.wfile.write(data) + + def route(self, method, path, q, body): + p = path + if p == "/rest/api/2/myself": + return 200, {"name": "me", "displayName": "Me"} + if p == "/rest/api/2/search": + keys = self.search_keys(q.get("jql")) + start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) + page = keys[start:start + maxr] + return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": [self.issue_json(k) for k in page]} + if p == "/rest/api/2/issue" and method == "POST": + self.seq += 1 + key = "%s-%d" % (self.project, self.seq) + f = body["fields"] + self.add(key, summary=f.get("summary"), description=f.get("description"), + priority=(f.get("priority") or {}).get("name", "Средний"), duedate=f.get("duedate"), + labels=list(f.get("labels") or []), issuetype=self._type_name(f.get("issuetype")), + reporter="me", created="2026-10-11T12:00:00.000+0300", + **{c: f.get(c) for c in CUSTOM}) + return 201, {"id": "1", "key": key} + if p == "/rest/api/2/issueLinkType": + return 200, {"issueLinkTypes": LINK_TYPES} + if p == "/rest/api/2/issueLink" and method == "POST": + self.link_seq += 1 + self.links.append({"id": self.link_seq, "type": body["type"]["name"], + "source": body["inwardIssue"]["key"], "target": body["outwardIssue"]["key"]}) + return 201, {} + m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p) + if m and method == "DELETE": + self.links = [l for l in self.links if str(l["id"]) != m.group(1)] + return 204, None + m = re.match(r"^/rest/api/2/issue/([^/]+)$", p) + if m: + key = m.group(1) + if method == "GET": + return 200, self.issue_json(key) + if method == "PUT": + f = self.issues[key] + for k, v in body.get("fields", {}).items(): + if k in ("priority", "assignee"): + f[k] = (v or {}).get("name") + elif k == "labels": + f[k] = list(v) + else: + f[k] = v + f["updated"] = "2026-10-11T13:00:00.000+0300" + return 204, None + m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p) + if m: + key = m.group(1) + self.issues[key] + lst = self.comments.setdefault(key, []) + if method == "GET": + return 200, {"comments": lst} + self.comment_seq += 1 + lst.append({"id": str(self.comment_seq), "body": body["body"]}) + return 201, lst[-1] + m = re.match(r"^/rest/api/2/issue/([^/]+)/comment/(\d+)$", p) + if m and method == "PUT": + for c in self.comments.get(m.group(1), []): + if c["id"] == m.group(2): + c["body"] = body["body"] + return 200, {} + m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p) + if m: + key = m.group(1) + trs = WORKFLOW.get(self.issues[key]["status"], []) + if method == "GET": + return 200, {"transitions": [{"id": str(i + 1), "name": n, "to": {"name": to}} for i, (n, to) in enumerate(trs)]} + tid = int(body["transition"]["id"]) - 1 + self.issues[key]["status"] = trs[tid][1] + return 204, None + m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p) + if m: + states = (q.get("state") or "active,future").split(",") + return 200, {"values": [s for s in self.sprints if s["state"] in states], "isLast": True} + m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p) + if m: + sp = next(s for s in self.sprints if s["id"] == int(m.group(1))) + if method == "GET": + keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]] + return 200, {"issues": [{"key": k} for k in keys]} + for k in body["issues"]: + self.issues[k]["sprint"] = sp["name"] + return 204, None + if p == "/rest/agile/1.0/backlog/issue" and method == "POST": + for k in body["issues"]: + self.issues[k]["sprint"] = None + return 204, None + m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) + if m: + name = self.issues[m.group(1)]["sprint"] + sp = next((s for s in self.sprints if s["name"] == name), None) + return 200, {"key": m.group(1), "fields": {"sprint": sp}} + return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]} + + def _type_name(self, ref): + ref = ref or {} + if ref.get("name"): + return ref["name"] + return {"10100": "Задача", "12300": "Фича"}.get(str(ref.get("id")), "Задача") diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..f315163 --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,78 @@ +import unittest + +from _common import agent, jira_cfg, no_sleep +from fake_jira import FakeJira + + +class ClientsTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + + def test_get_and_counters(self): + self.fake.add("AURORA-1") + data = self.jira.get("/rest/api/2/issue/AURORA-1", fields="summary") + self.assertEqual(data["fields"]["summary"], "Тема AURORA-1") + self.assertEqual(self.jira.calls, 1) + self.assertIsNotNone(self.jira.last_call_at) + self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1")) + + def test_retry_on_429_with_retry_after(self): + self.fake.add("AURORA-1") + self.fake.fail_next.append((429, {"Retry-After": "0"})) + self.fake.fail_next.append((503, {})) + data = self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(data["key"], "AURORA-1") + self.assertEqual(self.jira.rate_limited, 2) + self.assertEqual(self.jira.calls, 1) + + def test_http_error_not_retried(self): + with self.assertRaises(agent.HttpError) as cm: + self.jira.get("/rest/api/2/issue/NOPE-1") + self.assertEqual(cm.exception.code, 404) + self.assertIn("Does Not Exist", cm.exception.text) + + def test_gives_up_after_five(self): + self.fake.add("AURORA-1") + self.fake.fail_next.extend([(429, {"Retry-After": "0"})] * 5) + with self.assertRaises(agent.HttpError): + self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(self.jira.rate_limited, 5) + + def test_basic_auth_header_and_no_secret_in_repr(self): + self.assertTrue(self.jira.headers["Authorization"].startswith("Basic ")) + self.assertNotIn("secret", repr(self.jira)) + + def test_load_config_requires_fields(self): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"hub_url": "http://x", "hub_token": "t"}, f) + with self.assertRaises(SystemExit): + agent.load_config(path) + with open(path, "w", encoding="utf-8") as f: + json.dump(jira_cfg(self.fake), f) + cfg = agent.load_config(path) + self.assertEqual(cfg["poll_seconds"], 0) + + def test_check_command(self): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(jira_cfg(self.fake), f) + # хаб недоступен (порт 1), Jira доступен — check печатает оба результата и возвращает 1 + self.assertEqual(agent.main(["--config", path, "check"]), 1) + + +if __name__ == "__main__": + unittest.main() From b9be64015eb790edccf7f2ce728c350b115d0213 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:28:29 +0400 Subject: [PATCH 04/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 5c7bf3d..f3b8e58 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -6,3 +6,4 @@ | # | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| +| 1 | 2026-10-11 | `tests/__init__.py` добавляет каталог `tests/` в `sys.path` (R-1) | Тесты из плана импортируют `_common`/`fake_jira` по простому имени, при `discover -s tests -t .` иначе ImportError | Мелкая: переписать импорты в тестах на относительные | From 78b350b033db654871b7b4d314a335eab4ecc472 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:29:56 +0400 Subject: [PATCH 05/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index f3b8e58..b862b5b 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -7,3 +7,4 @@ | # | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | 1 | 2026-10-11 | `tests/__init__.py` добавляет каталог `tests/` в `sys.path` (R-1) | Тесты из плана импортируют `_common`/`fake_jira` по простому имени, при `discover -s tests -t .` иначе ImportError | Мелкая: переписать импорты в тестах на относительные | +| 2 | 2026-10-11 | Потолок на `Retry-After` не вводим (R-2) | Спека: «ждать `Retry-After`»; потолок тихо нарушает требование сервера | Агент может уснуть надолго при аномальном заголовке; лечится потолком позже | From 1d5e128bd061f91312f21b1934bcd3db07eb1262 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:30:30 +0400 Subject: [PATCH 06/30] =?UTF-8?q?fix(agent):=20=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=BB=D1=8E=D0=B1?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82,=20Retry-After=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D1=83=D1=87=D1=91=D1=82=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0,=20=D0=BD=D0=B5-JSON?= =?UTF-8?q?=20200,=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=84=D0=B5=D0=B9=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 34 +++++++++++++++----- tests/fake_jira.py | 10 +++++- tests/test_clients.py | 74 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/src/agent.py b/src/agent.py index 76a29e1..2bf1d02 100644 --- a/src/agent.py +++ b/src/agent.py @@ -84,11 +84,25 @@ class Http: try: with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp: raw = resp.read() - return json.loads(raw.decode("utf-8")) if raw else {} + status = resp.status except urllib.error.HTTPError as e: - raise HttpError(e.code, e.read().decode("utf-8", "replace"), dict(e.headers)) from None + try: + text = e.read().decode("utf-8", "replace") + headers = {k.lower(): v for k, v in e.headers.items()} + finally: + e.close() + self._touch() + raise HttpError(e.code, text, headers) from None except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e: raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None + self._touch() + try: + return json.loads(raw.decode("utf-8")) if raw else {} + except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError + raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None + + def _touch(self) -> None: + """Вызывается на каждый полученный HTTP-ответ (любой код).""" class JiraClient(Http): @@ -120,20 +134,24 @@ class JiraClient(Http): self.last_call_iso = None # type: Optional[str] self.sleep = time.sleep + def _touch(self) -> None: + self.calls += 1 + self.last_call_at, self.last_call_iso = time.monotonic(), now_iso() + def reset_counters(self) -> None: self.calls, self.rate_limited = 0, 0 def call(self, method, path, body=None, params=None): for attempt in range(1, 6): try: - out = super().call(method, path, body, params) - self.calls += 1 - self.last_call_at, self.last_call_iso = time.monotonic(), now_iso() - return out + return super().call(method, path, body, params) except HttpError as e: if e.code in (429, 503) and attempt < 5: - retry = e.headers.get("Retry-After") - wait = int(retry) if retry and retry.isdigit() else 60 * attempt + wait = 60 * attempt + try: + wait = int(e.headers.get("retry-after")) + except (TypeError, ValueError): + pass self.rate_limited += 1 log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt)) self.sleep(wait) diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 3075a42..7477583 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -28,6 +28,7 @@ class FakeJira: self.links = [] self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}] self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 + self.id_seq = 10000 self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой self.requests = [] # [(метод, путь)] self.lock = threading.Lock() @@ -58,12 +59,15 @@ class FakeJira: def stop(self): self.server.shutdown() + self.server.server_close() def add(self, key, in_base=True, **fields): base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [], "issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300", "sprint": None} + self.id_seq += 1 + base["id"] = str(self.id_seq) base.update({c: None for c in CUSTOM}) base.update(fields) self.issues[key] = base @@ -83,7 +87,7 @@ class FakeJira: "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} fields.update({c: f.get(c) for c in CUSTOM}) - return {"key": key, "id": str(10000 + sorted(self.issues).index(key)), "fields": fields} + return {"key": key, "id": f["id"], "fields": fields} def links_of(self, key): out = [] @@ -118,6 +122,8 @@ class FakeJira: code, payload = self.route(method, url.path, q, body) except KeyError as e: code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} + except Exception as e: # некорректный запрос к фейку + code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]} self._send(h, code, payload) def _send(self, h, code, payload, headers=None): @@ -158,6 +164,8 @@ class FakeJira: return 201, {} m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p) if m and method == "DELETE": + if not any(str(l["id"]) == m.group(1) for l in self.links): + return 404, {"errorMessages": ["Issue link does not exist: %s" % m.group(1)]} self.links = [l for l in self.links if str(l["id"]) != m.group(1)] return 204, None m = re.match(r"^/rest/api/2/issue/([^/]+)$", p) diff --git a/tests/test_clients.py b/tests/test_clients.py index f315163..70963a7 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -28,13 +28,15 @@ class ClientsTest(unittest.TestCase): data = self.jira.get("/rest/api/2/issue/AURORA-1") self.assertEqual(data["key"], "AURORA-1") self.assertEqual(self.jira.rate_limited, 2) - self.assertEqual(self.jira.calls, 1) + self.assertEqual(self.jira.calls, 3) # считаются все полученные ответы, включая 429/503 def test_http_error_not_retried(self): with self.assertRaises(agent.HttpError) as cm: self.jira.get("/rest/api/2/issue/NOPE-1") self.assertEqual(cm.exception.code, 404) self.assertIn("Does Not Exist", cm.exception.text) + self.assertEqual(self.jira.calls, 1) + self.assertIsNotNone(self.jira.last_call_at) def test_gives_up_after_five(self): self.fake.add("AURORA-1") @@ -42,11 +44,81 @@ class ClientsTest(unittest.TestCase): with self.assertRaises(agent.HttpError): self.jira.get("/rest/api/2/issue/AURORA-1") self.assertEqual(self.jira.rate_limited, 5) + self.assertEqual(self.jira.calls, 5) def test_basic_auth_header_and_no_secret_in_repr(self): self.assertTrue(self.jira.headers["Authorization"].startswith("Basic ")) self.assertNotIn("secret", repr(self.jira)) + def test_retry_after_lowercase_header(self): + waits = [] + self.jira.sleep = waits.append + self.fake.add("AURORA-1") + self.fake.fail_next.append((429, {"retry-after": "7"})) + self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(waits, [7]) + + def test_retry_after_not_a_number(self): + waits = [] + self.jira.sleep = waits.append + self.fake.add("AURORA-1") + self.fake.fail_next.append((503, {"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"})) + self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(waits, [60]) + + def test_bearer_header(self): + cfg = jira_cfg(self.fake) + cfg["jira"]["auth"] = {"type": "bearer", "token": "tok123"} + self.assertEqual(agent.JiraClient(cfg).headers["Authorization"], "Bearer tok123") + + def test_unreachable_jira_raises_connection_error(self): + cfg = jira_cfg(self.fake) + cfg["jira"]["base_url"] = "http://127.0.0.1:1" + with self.assertRaises(ConnectionError): + agent.JiraClient(cfg).get("/rest/api/2/myself") + + def test_non_json_200_becomes_http_error(self): + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + + class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + body = b"\xff\xfe not json" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + srv = HTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + cfg = jira_cfg(self.fake) + cfg["jira"]["base_url"] = "http://127.0.0.1:%d" % srv.server_port + jira = agent.JiraClient(cfg) + with self.assertRaises(agent.HttpError) as cm: + jira.get("/x") + self.assertEqual(cm.exception.code, 200) + self.assertEqual(jira.calls, 1) + finally: + srv.shutdown() + srv.server_close() + + def test_secret_not_in_log_output(self): + import io + from contextlib import redirect_stdout + buf = io.StringIO() + self.fake.add("AURORA-1") + self.fake.fail_next.append((429, {"Retry-After": "0"})) + with redirect_stdout(buf): + self.jira.get("/rest/api/2/issue/AURORA-1") + agent.log("проверка") + out = buf.getvalue() + self.assertNotIn("secret", out) + self.assertNotIn(self.jira.headers["Authorization"], out) + def test_load_config_requires_fields(self): import json import os From 24cfdcf25c3fb1c3eda0698a11a76107cfa3b36d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:31:50 +0400 Subject: [PATCH 07/30] =?UTF-8?q?fix(agent):=20=D0=BE=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20Retry-?= =?UTF-8?q?After,=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B8=20=D1=82=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 8 +++++--- tests/test_clients.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/agent.py b/src/agent.py index 2bf1d02..6e02162 100644 --- a/src/agent.py +++ b/src/agent.py @@ -86,12 +86,14 @@ class Http: raw = resp.read() status = resp.status except urllib.error.HTTPError as e: + self._touch() try: - text = e.read().decode("utf-8", "replace") headers = {k.lower(): v for k, v in e.headers.items()} + text = e.read().decode("utf-8", "replace") + except (OSError, ConnectionError, TimeoutError) as err: + raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None finally: e.close() - self._touch() raise HttpError(e.code, text, headers) from None except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e: raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None @@ -149,7 +151,7 @@ class JiraClient(Http): if e.code in (429, 503) and attempt < 5: wait = 60 * attempt try: - wait = int(e.headers.get("retry-after")) + wait = max(0, int(e.headers.get("retry-after"))) except (TypeError, ValueError): pass self.rate_limited += 1 diff --git a/tests/test_clients.py b/tests/test_clients.py index 70963a7..eb1ae8b 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -66,6 +66,29 @@ class ClientsTest(unittest.TestCase): self.jira.get("/rest/api/2/issue/AURORA-1") self.assertEqual(waits, [60]) + def test_retry_after_negative(self): + waits = [] + self.jira.sleep = waits.append + self.fake.add("AURORA-1") + self.fake.fail_next.append((429, {"Retry-After": "-5"})) + self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(waits, [0]) + + def test_error_body_read_failure_is_connection_error(self): + import io + import urllib.error + from unittest import mock + + class Broken(io.BytesIO): + def read(self, *a): + raise TimeoutError("timed out") + + err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken()) + with mock.patch("urllib.request.urlopen", side_effect=err): + with self.assertRaises(ConnectionError): + self.jira.get("/x") + self.assertEqual(self.jira.calls, 1) + def test_bearer_header(self): cfg = jira_cfg(self.fake) cfg["jira"]["auth"] = {"type": "bearer", "token": "tok123"} From 7fb592e6de8ad23b4146515a8fd4e6503f4add31 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:33:51 +0400 Subject: [PATCH 08/30] =?UTF-8?q?feat(agent):=20=D0=B6=D1=83=D1=80=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=BD=D0=B0=20=D0=B4=D0=B8=D1=81=D0=BA=D0=B5,?= =?UTF-8?q?=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D0=BD=D0=B5=D0=BC?= =?UTF-8?q?=D1=83,=20prune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 83 +++++++++++++++++++++++++++++++++++++++++++ tests/test_journal.py | 77 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 tests/test_journal.py diff --git a/src/agent.py b/src/agent.py index 6e02162..40ae56f 100644 --- a/src/agent.py +++ b/src/agent.py @@ -199,6 +199,85 @@ class HubClient(Http): return self.call("GET", "/agent/known-keys")["keys"] +# --------------------------------------------------------------------------- +# Журнал: только дозапись; started → finished → acked по id команды +# --------------------------------------------------------------------------- +class Journal: + def __init__(self, path: str): + self.path = path + + def append(self, record: dict) -> None: + record = dict(record) + record.setdefault("at", now_iso()) + with open(self.path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + def records(self) -> List[dict]: + if not os.path.exists(self.path): + return [] + out = [] + with open(self.path, encoding="utf-8") as f: + for n, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except ValueError: + log("журнал: строка %d не читается, пропускаю" % n) + return out + + def started(self, command_id) -> None: + self.append({"kind": "started", "command_id": int(command_id)}) + + def finished(self, command_id, status, result, error, jira_calls, rate_limited) -> None: + self.append({"kind": "finished", "command_id": int(command_id), "status": status, "result": result, + "error": error, "jira_calls": int(jira_calls or 0), "rate_limited": int(rate_limited or 0)}) + + def acked(self, command_id) -> None: + self.append({"kind": "acked", "command_id": int(command_id)}) + + def unacked_finished(self) -> List[dict]: + last, acked = {}, set() + for r in self.records(): + if r.get("kind") == "finished": + last[r["command_id"]] = r + acked.discard(r["command_id"]) + elif r.get("kind") == "acked": + acked.add(r["command_id"]) + return [r for cid, r in last.items() if cid not in acked] + + def unfinished_started(self) -> List[int]: + started, finished = [], set() + for r in self.records(): + if r.get("kind") == "started": + started.append(r["command_id"]) + elif r.get("kind") in ("finished", "acked"): + finished.add(r["command_id"]) + return [cid for cid in started if cid not in finished] + + def prune(self, days: int = 30) -> int: + """Удалить записи команд, подтверждённых (acked) раньше, чем days дней назад. Неподтверждённые — остаются.""" + records = self.records() + cutoff = datetime.now(timezone.utc).timestamp() - days * 86400 + old = set() + for r in records: + if r.get("kind") == "acked": + try: + ts = datetime.fromisoformat(r.get("at", "")).timestamp() + except ValueError: + continue + if ts < cutoff: + old.add(r["command_id"]) + keep = [r for r in records if r.get("command_id") not in old] + tmp = self.path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + for r in keep: + f.write(json.dumps(r, ensure_ascii=False) + "\n") + os.replace(tmp, self.path) + return len(records) - len(keep) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -228,6 +307,10 @@ def main(argv=None) -> int: cfg = load_config(args.config) if args.cmd == "check": return cmd_check(cfg) + if args.cmd == "prune": + removed = Journal(args.journal).prune(30) + log("журнал: удалено записей %d" % removed) + return 0 log("команда %s ещё не реализована" % args.cmd) return 2 diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 0000000..016134b --- /dev/null +++ b/tests/test_journal.py @@ -0,0 +1,77 @@ +import contextlib +import io +import json +import os +import tempfile +import unittest + +from _common import agent + + +class JournalTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.path = os.path.join(self.dir.name, "journal.jsonl") + self.j = agent.Journal(self.path) + + def tearDown(self): + self.dir.cleanup() + + def test_empty(self): + self.assertEqual(self.j.records(), []) + self.assertEqual(self.j.unacked_finished(), []) + self.assertEqual(self.j.unfinished_started(), []) + + def test_lifecycle(self): + self.j.started(1) + self.assertEqual(self.j.unfinished_started(), [1]) + self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0) + self.assertEqual(self.j.unfinished_started(), []) + pending = self.j.unacked_finished() + self.assertEqual(len(pending), 1) + self.assertEqual(pending[0]["command_id"], 1) + self.assertEqual(pending[0]["result"], {"key": "A-1"}) + self.assertEqual(pending[0]["jira_calls"], 2) + self.j.acked(1) + self.assertEqual(self.j.unacked_finished(), []) + self.j.started(2) + self.j.started(3) + self.j.finished(3, "failed", None, "HTTP 400", 1, 0) + self.assertEqual(self.j.unfinished_started(), [2]) + self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3]) + + def test_broken_line_is_skipped(self): + self.j.started(1) + with open(self.path, "a", encoding="utf-8") as f: + f.write("{не json\n") + self.j.finished(1, "done", None, None, 1, 0) + out = io.StringIO() + with contextlib.redirect_stdout(out): + records = self.j.records() + self.assertEqual(len(records), 2) + self.assertIn("строка 2 не читается", out.getvalue()) + + def test_prune_keeps_unacked_and_recent(self): + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"}) + self.j.started(2) + self.j.finished(2, "done", None, None, 1, 0) + self.j.acked(2) + self.j.started(3) + removed = self.j.prune(days=30) + self.assertEqual(removed, 3) + ids = [(r["kind"], r["command_id"]) for r in self.j.records()] + self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)]) + self.assertEqual(self.j.prune(days=30), 0) + + def test_no_secrets_in_records(self): + self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0) + with open(self.path, encoding="utf-8") as f: + text = f.read() + self.assertNotIn("password", text) + self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished") + + +if __name__ == "__main__": + unittest.main() From 6ffcf2b92a76d91cf290b9eda2ae5dd842160e76 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:36:19 +0400 Subject: [PATCH 09/30] =?UTF-8?q?fix(agent):=20=D0=B6=D1=83=D1=80=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=20=E2=80=94=20=D0=B4=D0=BE=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8,=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=83=D1=87=D1=91=D1=82=20sta?= =?UTF-8?q?rted,=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20prune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 120 +++++++++++++++++++++++++++++++----------- tests/test_journal.py | 76 +++++++++++++++++++++++--- 2 files changed, 158 insertions(+), 38 deletions(-) diff --git a/src/agent.py b/src/agent.py index 40ae56f..76c4d4c 100644 --- a/src/agent.py +++ b/src/agent.py @@ -206,26 +206,54 @@ class Journal: def __init__(self, path: str): self.path = path + def _torn_tail(self) -> bool: + """Файл не пуст и не заканчивается переводом строки (оборванная запись).""" + try: + if os.path.getsize(self.path) == 0: + return False + except FileNotFoundError: + return False + with open(self.path, "rb") as f: + f.seek(-1, os.SEEK_END) + return f.read(1) != b"\n" + def append(self, record: dict) -> None: record = dict(record) record.setdefault("at", now_iso()) - with open(self.path, "a", encoding="utf-8") as f: - f.write(json.dumps(record, ensure_ascii=False) + "\n") + data = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8") + if self._torn_tail(): + data = b"\n" + data + fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + with os.fdopen(fd, "ab") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) - def records(self) -> List[dict]: + def _scan(self): + """Возвращает (годные записи, номера битых строк). Годная — dict с command_id.""" + good, bad = [], [] if not os.path.exists(self.path): - return [] - out = [] - with open(self.path, encoding="utf-8") as f: - for n, line in enumerate(f, 1): - line = line.strip() + return good, bad + with open(self.path, "rb") as f: + for n, raw in enumerate(f, 1): + line = raw.strip() if not line: continue try: - out.append(json.loads(line)) + r = json.loads(line.decode("utf-8")) except ValueError: - log("журнал: строка %d не читается, пропускаю" % n) - return out + r = None + if isinstance(r, dict) and "command_id" in r: + good.append(r) + else: + bad.append(n) + return good, bad + + def records(self) -> List[dict]: + good, bad = self._scan() + for n in bad: + log("журнал: строка %d не читается, пропускаю" % n) + return good def started(self, command_id) -> None: self.append({"kind": "started", "command_id": int(command_id)}) @@ -248,34 +276,64 @@ class Journal: return [r for cid, r in last.items() if cid not in acked] def unfinished_started(self) -> List[int]: - started, finished = [], set() + """Порядковый учёт: started открывает id, finished/acked закрывает; порядок — первое открытие.""" + opened = {} # command_id -> открыта ли сейчас; dict сохраняет порядок первого открытия for r in self.records(): - if r.get("kind") == "started": - started.append(r["command_id"]) - elif r.get("kind") in ("finished", "acked"): - finished.add(r["command_id"]) - return [cid for cid in started if cid not in finished] + kind, cid = r.get("kind"), r["command_id"] + if kind == "started": + opened[cid] = True + elif kind in ("finished", "acked") and cid in opened: + opened[cid] = False + return [cid for cid, is_open in opened.items() if is_open] def prune(self, days: int = 30) -> int: - """Удалить записи команд, подтверждённых (acked) раньше, чем days дней назад. Неподтверждённые — остаются.""" - records = self.records() + """Удалить записи id до его последнего acked, если тот старше days дней (возраст — по acked) + и после него у id нет started/finished. Возвращает число удалённых годных записей; битые строки + отбрасываются при перезаписи и учитываются в логе.""" + good, bad = self._scan() + if bad: + log("журнал: удаляю битых строк %d" % len(bad)) cutoff = datetime.now(timezone.utc).timestamp() - days * 86400 - old = set() - for r in records: + last_ack, last_open = {}, {} + for i, r in enumerate(good): + cid = r["command_id"] if r.get("kind") == "acked": - try: - ts = datetime.fromisoformat(r.get("at", "")).timestamp() - except ValueError: - continue - if ts < cutoff: - old.add(r["command_id"]) - keep = [r for r in records if r.get("command_id") not in old] + last_ack[cid] = i + elif r.get("kind") in ("started", "finished"): + last_open[cid] = i + drop_upto = {} # command_id -> индекс последнего acked: всё до него включительно удаляется + for cid, i in last_ack.items(): + if last_open.get(cid, -1) > i: + continue + try: + ts = _parse_at(good[i].get("at")) + except (ValueError, TypeError): + continue + if ts < cutoff: + drop_upto[cid] = i + keep = [r for i, r in enumerate(good) + if not (r["command_id"] in drop_upto and i <= drop_upto[r["command_id"]])] tmp = self.path + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: + try: + os.remove(tmp) + except FileNotFoundError: + pass + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as f: for r in keep: - f.write(json.dumps(r, ensure_ascii=False) + "\n") + f.write((json.dumps(r, ensure_ascii=False) + "\n").encode("utf-8")) + f.flush() + os.fsync(f.fileno()) os.replace(tmp, self.path) - return len(records) - len(keep) + return len(good) - len(keep) + + +def _parse_at(value) -> float: + """Метка времени журнала в эпоху; наивное время трактуется как UTC.""" + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() # --------------------------------------------------------------------------- diff --git a/tests/test_journal.py b/tests/test_journal.py index 016134b..9a99603 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -1,9 +1,8 @@ -import contextlib -import io import json import os import tempfile import unittest +from unittest import mock from _common import agent @@ -13,6 +12,9 @@ class JournalTest(unittest.TestCase): self.dir = tempfile.TemporaryDirectory() self.path = os.path.join(self.dir.name, "journal.jsonl") self.j = agent.Journal(self.path) + patcher = mock.patch.object(agent, "log") + self.log = patcher.start() + self.addCleanup(patcher.stop) def tearDown(self): self.dir.cleanup() @@ -45,11 +47,8 @@ class JournalTest(unittest.TestCase): with open(self.path, "a", encoding="utf-8") as f: f.write("{не json\n") self.j.finished(1, "done", None, None, 1, 0) - out = io.StringIO() - with contextlib.redirect_stdout(out): - records = self.j.records() - self.assertEqual(len(records), 2) - self.assertIn("строка 2 не читается", out.getvalue()) + self.assertEqual(len(self.j.records()), 2) + self.log.assert_called_with("журнал: строка 2 не читается, пропускаю") def test_prune_keeps_unacked_and_recent(self): self.j.started(1) @@ -73,5 +72,68 @@ class JournalTest(unittest.TestCase): self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished") + def test_torn_tail_is_not_glued(self): + with open(self.path, "wb") as f: + f.write('{"kind": "star'.encode("utf-8")) + self.j.started(1) + recs = self.j.records() + self.assertEqual([(r["kind"], r["command_id"]) for r in recs], [("started", 1)]) + self.assertEqual(self.j.unfinished_started(), [1]) + + def test_restart_after_acked_reopens(self): + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.acked(1) + self.assertEqual(self.j.unfinished_started(), []) + self.j.started(1) + self.assertEqual(self.j.unfinished_started(), [1]) + + def test_duplicate_started_is_deduplicated_in_first_open_order(self): + self.j.started(2) + self.j.started(1) + self.j.started(1) + self.assertEqual(self.j.unfinished_started(), [2, 1]) + self.j.finished(2, "done", None, None, 1, 0) + self.assertEqual(self.j.unfinished_started(), [1]) + + def test_prune_keeps_id_with_newer_records(self): + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"}) + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + before = self.j.records() + self.assertEqual(self.j.prune(days=30), 0) + self.assertEqual(self.j.records(), before) + + def test_non_dict_and_no_command_id_lines_are_skipped(self): + with open(self.path, "wb") as f: + f.write(b'[1, 2]\n5\n{"kind": "started"}\n') + self.j.started(1) + self.j.finished(1, "done", None, None, 1, 0) + self.j.acked(1) + self.assertEqual(len(self.j.records()), 3) + self.assertEqual(self.j.unacked_finished(), []) + self.assertEqual(self.j.unfinished_started(), []) + self.assertEqual(self.j.prune(days=30), 0) + self.log.assert_any_call("журнал: удаляю битых строк 3") + self.assertEqual(len(self.j.records()), 3) + with open(self.path, encoding="utf-8") as f: + self.assertNotIn("[1, 2]", f.read()) + + def test_naive_and_invalid_at_in_prune(self): + self.j.started(1) + self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00"}) + self.j.started(2) + self.j.append({"kind": "acked", "command_id": 2, "at": "не дата"}) + self.assertEqual(self.j.prune(days=30), 2) + self.assertEqual([(r["kind"], r["command_id"]) for r in self.j.records()], [("started", 2), ("acked", 2)]) + + def test_journal_file_is_private(self): + self.j.started(1) + mode = os.stat(self.path).st_mode & 0o777 + self.assertEqual(mode & 0o077, 0) + + if __name__ == "__main__": unittest.main() From 5de91d645251a8ce0e432ab1215ff742100d4740 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:37:38 +0400 Subject: [PATCH 10/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index b862b5b..388aa66 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -8,3 +8,4 @@ |---|---|---|---|---| | 1 | 2026-10-11 | `tests/__init__.py` добавляет каталог `tests/` в `sys.path` (R-1) | Тесты из плана импортируют `_common`/`fake_jira` по простому имени, при `discover -s tests -t .` иначе ImportError | Мелкая: переписать импорты в тестах на относительные | | 2 | 2026-10-11 | Потолок на `Retry-After` не вводим (R-2) | Спека: «ждать `Retry-After`»; потолок тихо нарушает требование сервера | Агент может уснуть надолго при аномальном заголовке; лечится потолком позже | +| 3 | 2026-10-11 | Журнал: не лечим права существующего файла и не блокируем prune/append flock'ом; запрет `prune` при работающем агенте — в README (R-3) | Журнал создаётся этим же кодом (0600); `prune` — ручная команда раз в месяц | Редкая потеря записи журнала при гонке prune/run; лечится flock | From 07bee853b4ae9381ec08c4885a03bd52f295e25b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:39:04 +0400 Subject: [PATCH 11/30] =?UTF-8?q?feat(agent):=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=E2=80=94=20ensure?= =?UTF-8?q?=5Fissue,=20ensure=5Ffields,=20ensure=5Fcomment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поиск дубля в ensure_issue до создания (автор = currentUser(), точная тема). ensure_fields пишет только отличающиеся поля, описание сравнивается точно, даты — по первым 10 символам. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 147 ++++++++++++++++++++++++++++++++++ tests/test_executor_fields.py | 109 +++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 tests/test_executor_fields.py diff --git a/src/agent.py b/src/agent.py index 76c4d4c..be0bc62 100644 --- a/src/agent.py +++ b/src/agent.py @@ -336,6 +336,153 @@ def _parse_at(value) -> float: return dt.timestamp() +# --------------------------------------------------------------------------- +# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу +# --------------------------------------------------------------------------- +def _date(value) -> Optional[str]: + """Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню.""" + return (value or "")[:10] or None + + +class Executor: + def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None): + self.jira, self.s, self.hub = jira, settings, hub + self.sleep = time.sleep + self._symmetric = None + self._sprints = None + + def run(self, cmd: dict) -> dict: + self.jira.reset_counters() + self._sprints = None + handler = getattr(self, "do_" + str(cmd.get("type")), None) + if handler is None: + return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) + try: + return handler(cmd.get("params") or {}, cmd) + except HttpError as e: + return self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) + except ConnectionError as e: + return self._out("failed", error=str(e)) + + def _out(self, status, result=None, error=None) -> dict: + return {"status": status, "result": result, "error": error, + "jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited} + + def gap(self, cmd_type: str) -> None: + """Зазор между записями внутри одной команды — по политике хаба для этого типа.""" + seconds = int((self.s.get("pacing") or {}).get(cmd_type, 0) or 0) + if seconds > 0: + log(" пауза %d с" % seconds) + self.sleep(seconds) + + def fid(self, logical: str) -> str: + return (self.s.get("field_ids") or {}).get(logical, logical) + + def has_field(self, logical: str) -> bool: + return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical)) + + def priority_value(self, name: str) -> dict: + mapped = (self.s.get("priority_map") or {}).get(name, name) + return {self.s.get("priority_value_key") or "name": mapped} + + def issue_type_ref(self, kind: str) -> dict: + it = (self.s.get("issue_types") or {}).get(kind) or {} + return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")} + + def epic_fields(self, epic_key) -> dict: + ep = self.s.get("epic_link") or {} + if epic_key and ep.get("mode", "field") == "field" and ep.get("field_id"): + return {ep["field_id"]: epic_key} + return {} + + # --- ensure_issue ---------------------------------------------------------- + def do_ensure_issue(self, p: dict, cmd: dict) -> dict: + jql = "project = %s AND reporter = currentUser() AND created >= -7d" % self.s["project_key"] + found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200) + for it in found.get("issues", []): + if (it.get("fields", {}).get("summary") or "") == p["summary"]: + return self._out("done", {"key": it["key"], "existed": True}) + fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]), + "summary": p["summary"]} + if p.get("description") is not None: + fields[self.fid("description")] = p["description"] + if p.get("priority"): + fields[self.fid("priority")] = self.priority_value(p["priority"]) + if p.get("duedate"): + fields[self.fid("duedate")] = p["duedate"] + for logical in ("planned_start", "planned_end"): + if p.get(logical) and self.has_field(logical): + fields[self.fid(logical)] = p[logical] + if p.get("labels") is not None: + fields[self.fid("labels")] = list(p["labels"]) + if p.get("assignee"): + fields["assignee"] = {"name": p["assignee"]} + fields.update(self.epic_fields(p.get("epic_key"))) + if p["issue_type"] == "epic" and self.s.get("epic_name_field_id"): + fields[self.s["epic_name_field_id"]] = p["summary"] + created = self.jira.post(API + "/issue", {"fields": fields}) + return self._out("done", {"key": created["key"], "created": True}) + + # --- ensure_fields --------------------------------------------------------- + def do_ensure_fields(self, p: dict, cmd: dict) -> dict: + key = p["key"] + cur = self.jira.get(API + "/issue/%s" % key)["fields"] + fields, changed = {}, [] + if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""): + fields["summary"] = p["summary"] + changed.append("summary") + if "description" in p and (cur.get(self.fid("description")) or "") != (p["description"] or ""): + fields[self.fid("description")] = p["description"] + changed.append("description") + if "priority" in p: + want = self.priority_value(p["priority"]) + vkey = list(want)[0] + if ((cur.get(self.fid("priority")) or {}).get(vkey)) != want[vkey]: + fields[self.fid("priority")] = want + changed.append("priority") + for logical in ("duedate", "planned_start", "planned_end"): + if logical in p and self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]): + fields[self.fid(logical)] = p[logical] + changed.append(logical) + if "epic_key" in p: + ep = self.s.get("epic_link") or {} + if ep.get("field_id") and (cur.get(ep["field_id"]) or None) != (p["epic_key"] or None): + fields[ep["field_id"]] = p["epic_key"] + changed.append("epic_key") + if "assignee" in p and ((cur.get("assignee") or {}).get("name") or None) != (p["assignee"] or None): + fields["assignee"] = {"name": p["assignee"]} if p["assignee"] else None + changed.append("assignee") + if "labels_add" in p or "labels_remove" in p: + labels = list(cur.get(self.fid("labels")) or []) + remove = set(p.get("labels_remove") or []) + new = [l for l in labels if l not in remove] + [l for l in (p.get("labels_add") or []) if l not in labels] + if new != labels: + fields[self.fid("labels")] = new + changed.append("labels") + if not fields: + return self._out("done", {"changed": []}) + self.jira.put(API + "/issue/%s" % key, {"fields": fields}) + return self._out("done", {"changed": changed}) + + # --- ensure_comment -------------------------------------------------------- + def do_ensure_comment(self, p: dict, cmd: dict) -> dict: + key, marker, body = p["key"], p["marker"], p["body"] + if marker not in body[:80]: + body = "*%s.* %s" % (marker, body) + existing = None + for c in self.jira.get(API + "/issue/%s/comment" % key).get("comments", []): + if marker in (c.get("body") or "")[:80]: + existing = c + break + if existing and (existing.get("body") or "") == body: + return self._out("done", {"comment_id": existing["id"], "changed": False}) + if existing: + self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body}) + return self._out("done", {"comment_id": existing["id"], "changed": True}) + c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body}) + return self._out("done", {"comment_id": c.get("id"), "created": True}) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- diff --git a/tests/test_executor_fields.py b/tests/test_executor_fields.py new file mode 100644 index 0000000..5dd26f5 --- /dev/null +++ b/tests/test_executor_fields.py @@ -0,0 +1,109 @@ +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_jira import FakeJira + + +class ExecutorFieldsTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS)) + self.ex.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + + def run_cmd(self, ctype, **params): + return self.ex.run({"id": 1, "type": ctype, "params": params}) + + def test_ensure_issue_creates_then_finds(self): + out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача", description="h3. Контекст", + priority="Высокий", duedate="2026-10-23", planned_start="2026-10-20", labels=["AWPCA", "kind-dev"], + epic_key="AURORA-1350", assignee="ivanov") + self.assertEqual(out["status"], "done") + key = out["result"]["key"] + self.assertTrue(out["result"]["created"]) + f = self.fake.issues[key] + self.assertEqual(f["priority"], "Высокий") + self.assertEqual(f["customfield_10801"], "2026-10-20") + self.assertEqual(f["customfield_15302"], "AURORA-1350") + self.assertEqual(f["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(out["jira_calls"], 2) + again = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача") + self.assertEqual(again["status"], "done") + self.assertEqual(again["result"], {"key": key, "existed": True}) + self.assertEqual(len(self.fake.issues), 1) + self.assertEqual(again["jira_calls"], 1) + + def test_ensure_issue_epic_sets_epic_name(self): + out = self.run_cmd("ensure_issue", temp_key="new:2", issue_type="epic", summary="Приёмка") + f = self.fake.issues[out["result"]["key"]] + self.assertEqual(f["issuetype"], "Фича") + self.assertEqual(f["customfield_15304"], "Приёмка") + + def test_ensure_issue_priority_mapped(self): + out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший") + self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный") + + def test_ensure_fields_writes_only_diff(self): + self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний") + out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний", + labels_add=["kind-dev", "AWPCA"], labels_remove=["fact-open"], assignee="ivanov", + planned_end="2026-10-23", epic_key="AURORA-1350") + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"]["changed"], ["duedate", "planned_end", "epic_key", "assignee", "labels"]) + f = self.fake.issues["AURORA-1"] + self.assertEqual(f["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(f["assignee"], "ivanov") + self.assertEqual(f["customfield_10802"], "2026-10-23") + self.assertEqual(len(self.fake.writes()), 1) + again = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", labels_add=["kind-dev"], assignee="ivanov") + self.assertEqual(again["result"], {"changed": []}) + self.assertEqual(len(self.fake.writes()), 1) + + def test_fields_description_exact_compare(self): + text = "h3. Контекст\n* пункт *жирный*\n\nh3. Что сделать" + self.fake.add("AURORA-1", description=text) + out = self.run_cmd("ensure_fields", key="AURORA-1", description=text) + self.assertEqual(out["result"]["changed"], []) + out = self.run_cmd("ensure_fields", key="AURORA-1", description=text + "\n") + self.assertEqual(out["result"]["changed"], ["description"]) + + def test_fields_date_with_time_from_jira(self): + self.fake.add("AURORA-1", customfield_10802="2026-10-23T00:00:00.000+0300") + out = self.run_cmd("ensure_fields", key="AURORA-1", planned_end="2026-10-23") + self.assertEqual(out["result"]["changed"], []) + + def test_fields_clear_assignee_and_epic(self): + self.fake.add("AURORA-1", assignee="ivanov", customfield_15302="AURORA-1350") + out = self.run_cmd("ensure_fields", key="AURORA-1", assignee=None, epic_key=None) + self.assertEqual(out["result"]["changed"], ["epic_key", "assignee"]) + self.assertIsNone(self.fake.issues["AURORA-1"]["assignee"]) + self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_15302"]) + + def test_fields_missing_issue_is_failed(self): + out = self.run_cmd("ensure_fields", key="AURORA-404", duedate="2026-10-23") + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 404", out["error"]) + + def test_ensure_comment_upsert(self): + self.fake.add("AURORA-1") + out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список") + self.assertEqual(out["status"], "done") + self.assertTrue(out["result"]["created"]) + self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: список") + same = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список") + self.assertEqual(same["result"]["changed"], False) + upd = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: всё") + self.assertEqual(upd["result"]["changed"], True) + self.assertEqual(len(self.fake.comments["AURORA-1"]), 1) + self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё") + + def test_unknown_type(self): + self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed") + + +if __name__ == "__main__": + unittest.main() From 3957e856567bb769527eb0c531fc8fc610274fb5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:40:46 +0400 Subject: [PATCH 12/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 388aa66..16fed93 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -9,3 +9,4 @@ | 1 | 2026-10-11 | `tests/__init__.py` добавляет каталог `tests/` в `sys.path` (R-1) | Тесты из плана импортируют `_common`/`fake_jira` по простому имени, при `discover -s tests -t .` иначе ImportError | Мелкая: переписать импорты в тестах на относительные | | 2 | 2026-10-11 | Потолок на `Retry-After` не вводим (R-2) | Спека: «ждать `Retry-After`»; потолок тихо нарушает требование сервера | Агент может уснуть надолго при аномальном заголовке; лечится потолком позже | | 3 | 2026-10-11 | Журнал: не лечим права существующего файла и не блокируем prune/append flock'ом; запрет `prune` при работающем агенте — в README (R-3) | Журнал создаётся этим же кодом (0600); `prune` — ручная команда раз в месяц | Редкая потеря записи журнала при гонке prune/run; лечится flock | +| 4 | 2026-10-11 | Minor-замечания ревью Task 3 (T3.1, T3.3–T3.6, T3.8: пагинация поиска дубля, флаги existed/created, режим epic_link, префикс маркера, общий except в run, тесты) чиним одной финальной волной, а не по месту (R-4) | Не блокируют Task 4–5; одна волна дешевле | Task 4–5 унаследуют общий `run`; ошибки будут чиниться после | From a071cc12f8d12e333123b5ad091947e16d8f5ab1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:42:29 +0400 Subject: [PATCH 13/30] =?UTF-8?q?feat(agent):=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D1=8B=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D0=BE=D0=B2,=20=D1=81=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=B8=20=D1=81=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=BE=D0=B9,?= =?UTF-8?q?=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 109 +++++++++++++++++++++++++++++ tests/test_executor_links.py | 128 +++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 tests/test_executor_links.py diff --git a/src/agent.py b/src/agent.py index be0bc62..0bd5f0b 100644 --- a/src/agent.py +++ b/src/agent.py @@ -482,6 +482,115 @@ class Executor: c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body}) return self._out("done", {"comment_id": c.get("id"), "created": True}) + # --- ensure_status --------------------------------------------------------- + def do_ensure_status(self, p: dict, cmd: dict) -> dict: + key, target = p["key"], p["target"] + path = list(target) if isinstance(target, list) else [target] + final = path[-1] + cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"] + start = cur + if cur == final: + return self._out("done", {"from": start, "to": final, "steps": 0}) + if cur in path: + path = path[path.index(cur) + 1:] + steps = 0 + for step in path: + if cur == step: + continue + trs = self.jira.get(API + "/issue/%s/transitions" % key).get("transitions", []) + match = [t for t in trs if (t.get("to") or {}).get("name") == step or t.get("name") == step] + if not match: + avail = ", ".join("%s→%s" % (t.get("name"), (t.get("to") or {}).get("name")) for t in trs) + return self._out("failed", {"from": start, "reached": cur}, + "нет перехода из «%s» в «%s»; доступны: %s" % (cur, step, avail or "—")) + if steps: + self.gap("ensure_status") + self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": match[0]["id"]}}) + cur = step + steps += 1 + return self._out("done", {"from": start, "to": cur, "steps": steps}) + + # --- ensure_link ----------------------------------------------------------- + def symmetric_types(self) -> set: + """Типы, у которых inward == outward («Связанные»): направление не важно.""" + if self._symmetric is None: + try: + types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", []) + self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")} + except HttpError: + self._symmetric = set() + return self._symmetric + + def do_ensure_link(self, p: dict, cmd: dict) -> dict: + src, dst = p["source"], p["target"] + types = self.s.get("link_types") or {} + lt = types.get(p["link_type"], p["link_type"]) + managed = set(types.values()) | {lt} + symmetric = self.symmetric_types() + raw = self.jira.get(API + "/issue/%s" % src, fields="issuelinks")["fields"].get("issuelinks") or [] + links = [] # (тип, другая задача, направление, id) + for l in raw: + t = (l.get("type") or {}).get("name") + if l.get("outwardIssue"): + links.append((t, l["outwardIssue"]["key"], "out", l.get("id"))) + if l.get("inwardIssue"): + links.append((t, l["inwardIssue"]["key"], "in", l.get("id"))) + wanted = [x for x in links if x[1] == dst and x[0] == lt and (x[2] == "out" or lt in symmetric)] + conflicts = [x for x in links if x[1] == dst and x[0] in managed and x not in wanted] + if conflicts and not p.get("replace"): + desc = ", ".join("«%s» (%s)" % (t, "обратное направление" if t == lt else "другой тип") for t, _, _, _ in conflicts) + return self._out("failed", error="между %s и %s уже есть: %s; нужен replace" % (src, dst, desc)) + removed = [] + for t, other, direction, lid in conflicts: + if not lid: + return self._out("failed", {"removed": removed}, "у связи «%s» с %s нет id — снять нельзя" % (t, other)) + if removed: + self.gap("ensure_link") + self.jira.delete(API + "/issueLink/%s" % lid) + removed.append(t) + if wanted: + return self._out("done", {"existed": True, "removed": removed}) + if removed: + self.gap("ensure_link") + # inwardIssue — источник (получает outward-описание «Блокирует начало…»), outwardIssue — цель + self.jira.post(API + "/issueLink", {"type": {"name": lt}, "inwardIssue": {"key": src}, "outwardIssue": {"key": dst}}) + return self._out("done", {"created": True, "removed": removed}) + + # --- ensure_sprint --------------------------------------------------------- + def sprints(self) -> Dict[str, dict]: + """Имя → спринт (active и future) доски board_id; без доски — пусто.""" + if self._sprints is None: + out, start = {}, 0 + board = self.s.get("board_id") + while board: + page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50) + for sp in page.get("values", []): + out[sp["name"]] = sp + if page.get("isLast", True) or not page.get("values"): + break + start += len(page["values"]) + self._sprints = out + return self._sprints + + def do_ensure_sprint(self, p: dict, cmd: dict) -> dict: + key, want = p["key"], p["sprint"] + if not self.s.get("board_id"): + return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны") + cur = ((self.jira.get(AGILE + "/issue/%s" % key, fields="sprint")["fields"].get("sprint") or {}).get("name")) + if want == "backlog": + if cur is None: + return self._out("done", {"sprint": None, "changed": False}) + self.jira.post(AGILE + "/backlog/issue", {"issues": [key]}) + return self._out("done", {"sprint": None, "from": cur}) + if cur == want: + return self._out("done", {"sprint": want, "changed": False}) + sp = self.sprints().get(want) + if not sp: + return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s" + % (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—")) + self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) + return self._out("done", {"sprint": want, "from": cur}) + # --------------------------------------------------------------------------- # CLI diff --git a/tests/test_executor_links.py b/tests/test_executor_links.py new file mode 100644 index 0000000..b5de0d7 --- /dev/null +++ b/tests/test_executor_links.py @@ -0,0 +1,128 @@ +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_jira import FakeJira + + +class ExecutorLinksTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS)) + self.gaps = [] + self.ex.sleep = self.gaps.append + self.fake.add("AURORA-1") + self.fake.add("AURORA-2") + + def tearDown(self): + self.fake.stop() + + def run_cmd(self, ctype, **params): + return self.ex.run({"id": 1, "type": ctype, "params": params}) + + # --- статус --- + def test_status_path(self): + out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"]) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 2}) + self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово") + again = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"]) + self.assertEqual(again["result"]["steps"], 0) + self.assertEqual(again["jira_calls"], 1) + + def test_status_resumes_from_middle(self): + self.fake.issues["AURORA-1"]["status"] = "Разработка" + out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово к тестированию"]) + self.assertEqual(out["result"]["steps"], 1) + self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию") + + def test_status_single_and_by_transition_name(self): + out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку") + self.assertEqual(out["result"]["to"], "Взять в разработку") # имя перехода принято как шаг + self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Разработка") + + def test_status_no_transition_is_failed(self): + out = self.run_cmd("ensure_status", key="AURORA-1", target="Тестирование") + self.assertEqual(out["status"], "failed") + self.assertIn("нет перехода из «Ожидание разработки» в «Тестирование»", out["error"]) + self.assertIn("Взять в разработку→Разработка", out["error"]) + self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"}) + + def test_status_gap_between_steps(self): + self.ex.s["pacing"] = {"ensure_status": 7} + self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"]) + self.assertEqual(self.gaps, [7]) + + # --- связи --- + def test_link_create_and_idempotent(self): + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало") + self.assertEqual(out["result"], {"created": True, "removed": []}) + link = self.fake.links[0] + self.assertEqual((link["source"], link["target"], link["type"]), ("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")) + again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало") + self.assertEqual(again["result"], {"existed": True, "removed": []}) + self.assertEqual(len(self.fake.links), 1) + + def test_link_symmetric_any_direction(self): + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"}) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные") + self.assertEqual(out["result"], {"existed": True, "removed": []}) + + def test_link_conflict_without_replace(self): + self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"}) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало") + self.assertEqual(out["status"], "failed") + self.assertIn("обратное направление", out["error"]) + self.assertEqual(len(self.fake.links), 1) + + def test_link_replace_reverse_and_other_type(self): + self.ex.s["pacing"] = {"ensure_link": 3} + self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"}) + self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"}) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало", replace=True) + self.assertEqual(out["status"], "done") + self.assertEqual(sorted(out["result"]["removed"]), ["ASUPR_Блокер_Начала", "ASUPR_Связанные"]) + self.assertTrue(out["result"]["created"]) + self.assertEqual([(l["source"], l["target"], l["type"]) for l in self.fake.links], + [("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")]) + self.assertEqual(self.gaps, [3, 3]) # между двумя DELETE и перед POST + self.assertEqual([r[0] for r in self.fake.writes()], ["DELETE", "DELETE", "POST"]) + + def test_link_unmanaged_type_ignored(self): + self.fake.links.append({"id": 1, "type": "Duplicate", "source": "AURORA-1", "target": "AURORA-2"}) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные") + self.assertEqual(out["result"], {"created": True, "removed": []}) + self.assertEqual(len(self.fake.links), 2) + + # --- спринты --- + def test_sprint_move_and_idempotent(self): + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2") + self.assertEqual(out["result"], {"sprint": "Спринт 2", "from": None}) + self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2") + again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2") + self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False}) + self.assertEqual(again["jira_calls"], 1) + + def test_sprint_backlog(self): + self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1" + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog") + self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"}) + self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"]) + self.assertEqual(self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")["result"], {"sprint": None, "changed": False}) + + def test_sprint_unknown_name(self): + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9") + self.assertEqual(out["status"], "failed") + self.assertIn("Спринт 9", out["error"]) + self.assertIn("Спринт 2", out["error"]) + + def test_sprint_without_board(self): + self.ex.s["board_id"] = None + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2") + self.assertEqual(out["status"], "failed") + self.assertIn("board_id", out["error"]) + + +if __name__ == "__main__": + unittest.main() From e2fb7254a051bae5907a3231bad8f99d9285caca Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:44:01 +0400 Subject: [PATCH 14/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 16fed93..8dc368d 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -10,3 +10,4 @@ | 2 | 2026-10-11 | Потолок на `Retry-After` не вводим (R-2) | Спека: «ждать `Retry-After`»; потолок тихо нарушает требование сервера | Агент может уснуть надолго при аномальном заголовке; лечится потолком позже | | 3 | 2026-10-11 | Журнал: не лечим права существующего файла и не блокируем prune/append flock'ом; запрет `prune` при работающем агенте — в README (R-3) | Журнал создаётся этим же кодом (0600); `prune` — ручная команда раз в месяц | Редкая потеря записи журнала при гонке prune/run; лечится flock | | 4 | 2026-10-11 | Minor-замечания ревью Task 3 (T3.1, T3.3–T3.6, T3.8: пагинация поиска дубля, флаги existed/created, режим epic_link, префикс маркера, общий except в run, тесты) чиним одной финальной волной, а не по месту (R-4) | Не блокируют Task 4–5; одна волна дешевле | Task 4–5 унаследуют общий `run`; ошибки будут чиниться после | +| 5 | 2026-10-11 | Неизвестный `link_type` берётся как сырое имя типа Jira (fallback), без `failed` (R-5) | Так же делает хаб в `check_confirmed`; допускает ручной ввод имён Jira | Опечатка в типе даст HTTP-ошибку Jira вместо понятной ошибки агента | From fbc9ca90489397309735a7e06bfe7a2cb8cd5168 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:45:40 +0400 Subject: [PATCH 15/30] =?UTF-8?q?fix(agent):=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=E2=80=94=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20Task=204=20(=D0=B2=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20target,=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?,=20=D0=BA=D1=8D=D1=88=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9,=20board=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 48 ++++++++++++++++--------- tests/fake_jira.py | 4 ++- tests/test_executor_links.py | 68 +++++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/src/agent.py b/src/agent.py index 0bd5f0b..8b19dea 100644 --- a/src/agent.py +++ b/src/agent.py @@ -483,9 +483,27 @@ class Executor: return self._out("done", {"comment_id": c.get("id"), "created": True}) # --- ensure_status --------------------------------------------------------- + @staticmethod + def _pick_transition(trs: list, step: str) -> Optional[dict]: + """Переход, ведущий в статус step. При неоднозначности предпочитается он; имя перехода — запасной вариант.""" + for t in trs: + if (t.get("to") or {}).get("name") == step: + return t + for t in trs: + if t.get("name") == step: + return t + return None + def do_ensure_status(self, p: dict, cmd: dict) -> dict: key, target = p["key"], p["target"] - path = list(target) if isinstance(target, list) else [target] + if isinstance(target, str): + path = [target] + elif isinstance(target, list) and all(isinstance(s, str) for s in target): + path = list(target) + else: + return self._out("failed", error="target должен быть строкой или списком строк") + if not path or not all(path): + return self._out("failed", error="пустой путь статусов") final = path[-1] cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"] start = cur @@ -496,34 +514,33 @@ class Executor: steps = 0 for step in path: if cur == step: - continue + continue # повтор того же статуса подряд в пути: перехода не нужно trs = self.jira.get(API + "/issue/%s/transitions" % key).get("transitions", []) - match = [t for t in trs if (t.get("to") or {}).get("name") == step or t.get("name") == step] - if not match: + hit = self._pick_transition(trs, step) + if hit is None: avail = ", ".join("%s→%s" % (t.get("name"), (t.get("to") or {}).get("name")) for t in trs) return self._out("failed", {"from": start, "reached": cur}, "нет перехода из «%s» в «%s»; доступны: %s" % (cur, step, avail or "—")) if steps: self.gap("ensure_status") - self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": match[0]["id"]}}) - cur = step + self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": hit["id"]}}) + cur = (hit.get("to") or {}).get("name") or step steps += 1 return self._out("done", {"from": start, "to": cur, "steps": steps}) # --- ensure_link ----------------------------------------------------------- def symmetric_types(self) -> set: - """Типы, у которых inward == outward («Связанные»): направление не важно.""" + """Типы, у которых inward == outward («Связанные»): направление не важно. + Ошибка HTTP пробрасывается наружу и не кэшируется: следующая команда попробует снова.""" if self._symmetric is None: - try: - types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", []) - self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")} - except HttpError: - self._symmetric = set() + types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", []) + self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")} return self._symmetric def do_ensure_link(self, p: dict, cmd: dict) -> dict: src, dst = p["source"], p["target"] types = self.s.get("link_types") or {} + # намеренный fallback (рулинг R-5): имя, которого нет в link_types, уходит в Jira как есть lt = types.get(p["link_type"], p["link_type"]) managed = set(types.values()) | {lt} symmetric = self.symmetric_types() @@ -574,14 +591,14 @@ class Executor: def do_ensure_sprint(self, p: dict, cmd: dict) -> dict: key, want = p["key"], p["sprint"] - if not self.s.get("board_id"): - return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны") cur = ((self.jira.get(AGILE + "/issue/%s" % key, fields="sprint")["fields"].get("sprint") or {}).get("name")) - if want == "backlog": + if want == "backlog": # в бэклог доска не нужна if cur is None: return self._out("done", {"sprint": None, "changed": False}) self.jira.post(AGILE + "/backlog/issue", {"issues": [key]}) return self._out("done", {"sprint": None, "from": cur}) + if not self.s.get("board_id"): + return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны") if cur == want: return self._out("done", {"sprint": want, "changed": False}) sp = self.sprints().get(want) @@ -591,7 +608,6 @@ class Executor: self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) return self._out("done", {"sprint": want, "from": cur}) - # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 7477583..db43736 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -212,7 +212,9 @@ class FakeJira: m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p) if m: states = (q.get("state") or "active,future").split(",") - return 200, {"values": [s for s in self.sprints if s["state"] in states], "isLast": True} + vals = [s for s in self.sprints if s["state"] in states] + start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) + return 200, {"values": vals[start:start + maxr], "isLast": start + maxr >= len(vals)} m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p) if m: sp = next(s for s in self.sprints if s["id"] == int(m.group(1))) diff --git a/tests/test_executor_links.py b/tests/test_executor_links.py index b5de0d7..6fa0073 100644 --- a/tests/test_executor_links.py +++ b/tests/test_executor_links.py @@ -1,5 +1,7 @@ import unittest +from unittest import mock +import fake_jira from _common import SETTINGS, agent, jira_cfg, no_sleep from fake_jira import FakeJira @@ -38,10 +40,19 @@ class ExecutorLinksTest(unittest.TestCase): self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию") def test_status_single_and_by_transition_name(self): + # имя перехода принято как шаг, но в result.to — реальный статус после перехода out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку") - self.assertEqual(out["result"]["to"], "Взять в разработку") # имя перехода принято как шаг + self.assertEqual(out["result"]["to"], "Разработка") self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Разработка") + def test_status_ambiguous_prefers_target_status(self): + # переход «Готово» ведёт в «Анализ», переход «Старт» ведёт в статус «Готово»: выбирается по целевому статусу + with mock.patch.dict(fake_jira.WORKFLOW, {"Ожидание разработки": [("Готово", "Анализ"), ("Старт", "Готово")]}): + out = self.run_cmd("ensure_status", key="AURORA-1", target="Готово") + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 1}) + self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово") + def test_status_no_transition_is_failed(self): out = self.run_cmd("ensure_status", key="AURORA-1", target="Тестирование") self.assertEqual(out["status"], "failed") @@ -49,6 +60,21 @@ class ExecutorLinksTest(unittest.TestCase): self.assertIn("Взять в разработку→Разработка", out["error"]) self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"}) + def test_status_bad_target(self): + cases = (([], "пустой путь"), ("", "пустой путь"), ([""], "пустой путь"), + (5, "target должен быть"), (["Готово", 1], "target должен быть"), (None, "target должен быть")) + for bad, msg in cases: + out = self.run_cmd("ensure_status", key="AURORA-1", target=bad) + self.assertEqual(out["status"], "failed", bad) + self.assertIn(msg, out["error"]) + self.assertEqual(self.fake.writes(), []) + + def test_status_http_error_is_failed(self): + self.fake.fail_next.append((400, {})) + out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"]) + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + def test_status_gap_between_steps(self): self.ex.s["pacing"] = {"ensure_status": 7} self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"]) @@ -95,6 +121,29 @@ class ExecutorLinksTest(unittest.TestCase): self.assertEqual(out["result"], {"created": True, "removed": []}) self.assertEqual(len(self.fake.links), 2) + def test_link_unknown_type_sent_raw(self): + # намеренный fallback (рулинг R-5): неизвестное имя типа уходит в Jira как есть + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="Duplicate") + self.assertEqual(out["result"], {"created": True, "removed": []}) + self.assertEqual(self.fake.links[-1]["type"], "Duplicate") + + def test_link_http_error_is_failed(self): + self.ex.symmetric_types() # тип уже известен, падает чтение задачи + self.fake.fail_next.append((400, {})) + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало") + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + self.assertEqual(self.fake.links, []) + + def test_symmetric_not_cached_on_error(self): + self.fake.fail_next.append((400, {})) # падает чтение типов связей + out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные") + self.assertEqual(out["status"], "failed") + self.assertIsNone(self.ex._symmetric) # пустой набор в кэш не попал + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"}) + again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные") + self.assertEqual(again["result"], {"existed": True, "removed": []}) + # --- спринты --- def test_sprint_move_and_idempotent(self): out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2") @@ -104,6 +153,15 @@ class ExecutorLinksTest(unittest.TestCase): self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False}) self.assertEqual(again["jira_calls"], 1) + def test_sprint_pagination(self): + for i in range(3, 58): # 57 спринтов: доска отдаёт по 50, нужны две страницы + self.fake.sprints.append({"id": i, "name": "Спринт %d" % i, "state": "future"}) + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 57") + self.assertEqual(out["status"], "done") + self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 57") + pages = [r for r in self.fake.requests if r[1].endswith("/board/4216/sprint")] + self.assertEqual(len(pages), 2) + def test_sprint_backlog(self): self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1" out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog") @@ -111,6 +169,14 @@ class ExecutorLinksTest(unittest.TestCase): self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"]) self.assertEqual(self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")["result"], {"sprint": None, "changed": False}) + def test_sprint_backlog_without_board(self): + self.ex.s["board_id"] = None + self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1" + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog") + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"}) + self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"]) + def test_sprint_unknown_name(self): out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9") self.assertEqual(out["status"], "failed") From 29fefb61145a15a84d6ed7ac1373f8ab166f5dff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:50:42 +0400 Subject: [PATCH 16/30] =?UTF-8?q?feat(agent):=20=D1=87=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA,=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D0=B5=D0=B4=D0=B8=20=D0=BF=D0=BE=20=D1=81=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D1=8F=D0=BC,=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D1=8B,=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20=D0=B2=20=D1=85=D0=B0=D0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 106 +++++++++++++++++++++++++++++++++++++++++ tests/fake_hub.py | 108 ++++++++++++++++++++++++++++++++++++++++++ tests/test_refresh.py | 85 +++++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 tests/fake_hub.py create mode 100644 tests/test_refresh.py diff --git a/src/agent.py b/src/agent.py index 8b19dea..364baee 100644 --- a/src/agent.py +++ b/src/agent.py @@ -608,6 +608,112 @@ class Executor: self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) return self._out("done", {"sprint": want, "from": cur}) + # --- refresh --------------------------------------------------------------- + STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", + "issuetype", "created", "updated", "issuelinks"] + + def state_fields(self) -> str: + extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)] + ep = (self.s.get("epic_link") or {}) + if ep.get("mode", "field") == "field" and ep.get("field_id"): + extra.append(ep["field_id"]) + return ",".join(self.STATE_FIELDS + extra) + + def search_all(self, jql: str) -> List[dict]: + issues, start = [], 0 + while True: + page = self.jira.get(API + "/search", jql=jql, fields=self.state_fields(), startAt=start, maxResults=100) + got = page.get("issues") or [] + issues += got + start += len(got) + if not got or start >= int(page.get("total") or 0): + return issues + + def to_state(self, raw: dict) -> dict: + f = raw.get("fields") or {} + + def name(obj): + return (obj or {}).get("name") + + ep = self.s.get("epic_link") or {} + epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None + return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"), + "description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")), + "assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")), + "planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None, + "planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None, + "epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None, + "created": f.get("created"), "jira_updated": f.get("updated")} + + def links_from(self, raw: dict) -> List[dict]: + key, out = raw["key"], [] + for l in (raw.get("fields") or {}).get("issuelinks") or []: + t = (l.get("type") or {}).get("name") + if l.get("outwardIssue"): + out.append({"source_key": key, "target_key": l["outwardIssue"]["key"], "link_type": t}) + if l.get("inwardIssue"): + out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t}) + return out + + def sprint_names(self, keys: List[str]) -> Dict[str, str]: + names = {} + if not keys or not self.s.get("board_id"): + return names + for name, sp in self.sprints().items(): + for i in range(0, len(keys), 100): + chunk = keys[i:i + 100] + page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk), + fields="key", maxResults=200) + for it in page.get("issues") or []: + names[it["key"]] = name + return names + + def do_refresh(self, p: dict, cmd: dict) -> dict: + if p.get("jql"): + raws = self.search_all(p["jql"]) + else: + keys = [k for k in (p.get("keys") or []) if k] + if not keys: + return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0}) + raws = [] + for i in range(0, len(keys), 100): + raws += self.search_all("key in (%s)" % ",".join(keys[i:i + 100])) + fetched = {r["key"] for r in raws} + known = set(self.hub.known_keys()) if self.hub else set() + linked = set() + for r in raws: + for l in self.links_from(r): + linked.add(l["source_key"]) + linked.add(l["target_key"]) + missing = sorted(k for k in linked if k not in fetched and k not in known) + neighbors = [] + for i in range(0, len(missing), 100): + neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100])) + everything = raws + neighbors + names = self.sprint_names([r["key"] for r in everything]) + issues, links, seen = [], [], set() + for r in everything: + st = self.to_state(r) + st["sprint"] = names.get(r["key"]) + issues.append(st) + for l in self.links_from(r): + sig = (l["source_key"], l["target_key"], l["link_type"]) + if sig not in seen: + seen.add(sig) + links.append(l) + neighbor_keys = [r["key"] for r in neighbors] + pages = 0 + for i in range(0, len(issues), 100): + chunk = issues[i:i + 100] + chunk_keys = {x["key"] for x in chunk} + pages += 1 + self.hub.state({"command_id": cmd.get("id"), "issues": chunk, + "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys], + "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], + "page": pages, "last_page": i + 100 >= len(issues)}) + return self._out("done", {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- diff --git a/tests/fake_hub.py b/tests/fake_hub.py new file mode 100644 index 0000000..5e0ec1a --- /dev/null +++ b/tests/fake_hub.py @@ -0,0 +1,108 @@ +"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти.""" +import json +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class FakeHub: + token = "agent-token" + + def __init__(self, settings): + self.settings = dict(settings) + self.queue = [] + self.commands = {} + self.results = {} + self.states = [] + self.known = [] + self.claims = 0 + self.fail_results = 0 + self.reject_results = {} + self.in_flight = None + self.lock = threading.Lock() + hub = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + hub.handle(self, "GET") + + def do_POST(self): + hub.handle(self, "POST") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + self.server.server_close() + + def enqueue(self, ctype, params, pause=0): + cid = len(self.commands) + 1 + self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"} + self.queue.append(cid) + return cid + + def handle(self, h, method): + length = int(h.headers.get("Content-Length") or 0) + body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} + if h.headers.get("Authorization") != "Bearer " + self.token: + return self._send(h, 401, {"error": "unauthorized"}) + with self.lock: + code, payload = self.route(method, h.path.split("?")[0], body) + self._send(h, code, payload) + + def _send(self, h, code, payload): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + h.send_response(code) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(data))) + h.end_headers() + h.wfile.write(data) + + def route(self, method, path, body): + if path == "/agent/claim": + self.claims += 1 + if self.in_flight and self.commands[self.in_flight]["status"] == "claimed": + return 200, {"command": None, "settings_version": self.settings["settings_version"]} + if not self.queue: + return 200, {"command": None, "settings_version": self.settings["settings_version"]} + cid = self.queue.pop(0) + self.commands[cid]["status"] = "claimed" + self.in_flight = cid + c = self.commands[cid] + return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]}, + "settings_version": self.settings["settings_version"]} + if path == "/agent/settings": + return 200, self.settings + if path == "/agent/known-keys": + return 200, {"keys": list(self.known)} + if path == "/agent/state": + self.states.append(body) + return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])} + m = re.match(r"^/agent/commands/(\d+)$", path) + if m: + c = self.commands.get(int(m.group(1))) + return (200, c) if c else (404, {"error": "not_found"}) + m = re.match(r"^/agent/commands/(\d+)/result$", path) + if m: + cid = int(m.group(1)) + if self.fail_results > 0: + self.fail_results -= 1 + return 503, {"error": "busy"} + if cid in self.reject_results: + return self.reject_results[cid], {"error": "rejected"} + if cid not in self.commands: + return 404, {"error": "not_found"} + self.results[cid] = body + self.commands[cid]["status"] = body.get("status") + if self.in_flight == cid: + self.in_flight = None + return 200, self.commands[cid] + return 404, {"error": "not_found", "detail": path} diff --git a/tests/test_refresh.py b/tests/test_refresh.py new file mode 100644 index 0000000..cf879f5 --- /dev/null +++ b/tests/test_refresh.py @@ -0,0 +1,85 @@ +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_hub import FakeHub +from fake_jira import FakeJira + + +class RefreshTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.hub = FakeHub(SETTINGS) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.jira = agent.JiraClient(cfg) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg)) + self.ex.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + self.hub.stop() + + def test_to_state_and_links(self): + self.fake.add("AURORA-1", assignee="ivanov", customfield_10801="2026-10-20T00:00:00.000+0300", + customfield_15302="AURORA-1350", labels=["AWPCA", "kind-dev"], sprint="Спринт 2") + self.fake.add("AURORA-2") + self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-1", "target": "AURORA-2"}) + raw = self.fake.issue_json("AURORA-1") + st = self.ex.to_state(raw) + self.assertEqual(st["key"], "AURORA-1") + self.assertEqual(st["assignee"], "ivanov") + self.assertEqual(st["planned_start"], "2026-10-20") + self.assertEqual(st["epic_key"], "AURORA-1350") + self.assertEqual(st["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(st["issue_type"], "Задача") + self.assertEqual(st["jira_updated"], "2026-10-01T10:00:00.000+0300") + self.assertIsNone(st["sprint"]) # спринт заполняет do_refresh + self.assertEqual(self.ex.links_from(raw), [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}]) + self.assertEqual(self.ex.links_from(self.fake.issue_json("AURORA-2")), + [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}]) + self.assertIn("customfield_15302", self.ex.state_fields()) + + def test_full_refresh_with_neighbors_and_sprints(self): + for i in range(1, 4): + self.fake.add("AURORA-%d" % i, sprint="Спринт 2" if i < 3 else None) + self.fake.add("OTHER-9", in_base=False) + self.fake.add("AURORA-7", in_base=False) + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "OTHER-9"}) + self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-7", "target": "AURORA-2"}) + self.hub.known = ["AURORA-7"] + out = self.ex.run({"id": 5, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}}) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"issues": 3, "neighbors": 1, "pages": 1}) + state = self.hub.states[0] + self.assertEqual(state["command_id"], 5) + self.assertEqual(sorted(i["key"] for i in state["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3", "OTHER-9"]) + self.assertEqual(state["neighbor_keys"], ["OTHER-9"]) + self.assertTrue(state["last_page"]) + sprints = {i["key"]: i["sprint"] for i in state["issues"]} + self.assertEqual(sprints["AURORA-1"], "Спринт 2") + self.assertIsNone(sprints["AURORA-3"]) + links = {(l["source_key"], l["target_key"]) for l in state["links"]} + self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")}) + self.assertEqual([r[1] for r in self.fake.requests if r[1] == "/rest/api/2/search"].count("/rest/api/2/search"), 2) + + def test_refresh_by_keys_pages(self): + for i in range(1, 121): + self.fake.add("AURORA-%d" % i) + keys = ["AURORA-%d" % i for i in range(1, 121)] + out = self.ex.run({"id": 6, "type": "refresh", "params": {"keys": keys}}) + self.assertEqual(out["result"], {"issues": 120, "neighbors": 0, "pages": 2}) + self.assertEqual(len(self.hub.states), 2) + self.assertEqual(self.hub.states[0]["page"], 1) + self.assertFalse(self.hub.states[0]["last_page"]) + self.assertTrue(self.hub.states[1]["last_page"]) + self.assertEqual(len(self.hub.states[0]["issues"]) + len(self.hub.states[1]["issues"]), 120) + + def test_refresh_empty_keys(self): + out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}}) + self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0}) + self.assertEqual(self.fake.requests, []) + self.assertEqual(self.hub.states, []) + + +if __name__ == "__main__": + unittest.main() From 1b3364be1309dd4027fd679d1298766df7963df5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:53:08 +0400 Subject: [PATCH 17/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 8dc368d..c38c76b 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -11,3 +11,4 @@ | 3 | 2026-10-11 | Журнал: не лечим права существующего файла и не блокируем prune/append flock'ом; запрет `prune` при работающем агенте — в README (R-3) | Журнал создаётся этим же кодом (0600); `prune` — ручная команда раз в месяц | Редкая потеря записи журнала при гонке prune/run; лечится flock | | 4 | 2026-10-11 | Minor-замечания ревью Task 3 (T3.1, T3.3–T3.6, T3.8: пагинация поиска дубля, флаги existed/created, режим epic_link, префикс маркера, общий except в run, тесты) чиним одной финальной волной, а не по месту (R-4) | Не блокируют Task 4–5; одна волна дешевле | Task 4–5 унаследуют общий `run`; ошибки будут чиниться после | | 5 | 2026-10-11 | Неизвестный `link_type` берётся как сырое имя типа Jira (fallback), без `failed` (R-5) | Так же делает хаб в `check_confirmed`; допускает ручной ввод имён Jira | Опечатка в типе даст HTTP-ошибку Jira вместо понятной ошибки агента | +| 6 | 2026-10-11 | Спринт в `refresh` берётся из Agile API по активным/будущим спринтам доски, а не из поля `search`; закрытые спринты дают `sprint = null` (R-6) | В настройках хаба нет id поля спринта (`field_ids` его не содержит); план Task 5 выбрал Agile | Исторические имена спринтов затрутся при полном refresh; лечится полем спринта в настройках | From c64d36b748612a8031619b2e22a2e2c7673836be Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:54:35 +0400 Subject: [PATCH 18/30] =?UTF-8?q?fix(agent):=20refresh=20=E2=80=94=20?= =?UTF-8?q?=D0=B1=D0=B8=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20400=20=D0=BD=D0=B0=20key=20in,=20missing,=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=82=D0=BE=D0=B2,=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=84=D0=B5=D0=B9=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 48 +++++++++++++++++++++++------ tests/fake_hub.py | 2 ++ tests/fake_jira.py | 7 ++++- tests/test_refresh.py | 71 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 12 deletions(-) diff --git a/src/agent.py b/src/agent.py index 364baee..42ff383 100644 --- a/src/agent.py +++ b/src/agent.py @@ -614,7 +614,7 @@ class Executor: def state_fields(self) -> str: extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)] - ep = (self.s.get("epic_link") or {}) + ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан) if ep.get("mode", "field") == "field" and ep.get("field_id"): extra.append(ep["field_id"]) return ",".join(self.STATE_FIELDS + extra) @@ -629,6 +629,22 @@ class Executor: if not got or start >= int(page.get("total") or 0): return issues + def search_tolerant(self, keys: List[str], missing: List[str]) -> List[dict]: + """key in (...): Jira Server отвечает 400 на несуществующий/недоступный ключ. Чанк делим пополам до + одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются.""" + if not keys: + return [] + try: + return self.search_all("key in (%s)" % ",".join(keys)) + except HttpError as e: + if e.code != 400: + raise + if len(keys) == 1: + missing.append(keys[0]) + return [] + mid = len(keys) // 2 + return self.search_tolerant(keys[:mid], missing) + self.search_tolerant(keys[mid:], missing) + def to_state(self, raw: dict) -> dict: f = raw.get("fields") or {} @@ -656,19 +672,28 @@ class Executor: return out def sprint_names(self, keys: List[str]) -> Dict[str, str]: + # Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет, + # поэтому у задач закрытых спринтов sprint = None (принятое ограничение). names = {} if not keys or not self.s.get("board_id"): return names for name, sp in self.sprints().items(): for i in range(0, len(keys), 100): chunk = keys[i:i + 100] - page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk), - fields="key", maxResults=200) - for it in page.get("issues") or []: - names[it["key"]] = name + start = 0 + while True: + page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk), + fields="key", startAt=start, maxResults=50) + got = page.get("issues") or [] + for it in got: + names[it["key"]] = name + start += len(got) + if not got or start >= int(page.get("total") or 0): + break return names def do_refresh(self, p: dict, cmd: dict) -> dict: + missing: List[str] = [] if p.get("jql"): raws = self.search_all(p["jql"]) else: @@ -677,7 +702,7 @@ class Executor: return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0}) raws = [] for i in range(0, len(keys), 100): - raws += self.search_all("key in (%s)" % ",".join(keys[i:i + 100])) + raws += self.search_tolerant(keys[i:i + 100], missing) fetched = {r["key"] for r in raws} known = set(self.hub.known_keys()) if self.hub else set() linked = set() @@ -685,10 +710,10 @@ class Executor: for l in self.links_from(r): linked.add(l["source_key"]) linked.add(l["target_key"]) - missing = sorted(k for k in linked if k not in fetched and k not in known) + absent = sorted(k for k in linked if k not in fetched and k not in known) neighbors = [] - for i in range(0, len(missing), 100): - neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100])) + for i in range(0, len(absent), 100): + neighbors += self.search_tolerant(absent[i:i + 100], missing) everything = raws + neighbors names = self.sprint_names([r["key"] for r in everything]) issues, links, seen = [], [], set() @@ -711,7 +736,10 @@ class Executor: "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys], "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], "page": pages, "last_page": i + 100 >= len(issues)}) - return self._out("done", {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}) + result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} + if missing: + result["missing"] = sorted(missing)[:50] + return self._out("done", result) # --------------------------------------------------------------------------- diff --git a/tests/fake_hub.py b/tests/fake_hub.py index 5e0ec1a..abad0af 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -100,6 +100,8 @@ class FakeHub: return self.reject_results[cid], {"error": "rejected"} if cid not in self.commands: return 404, {"error": "not_found"} + if self.commands[cid]["status"] in ("done", "failed", "cancelled"): + return 200, self.commands[cid] # терминальный статус не перезаписывается (как живой хаб) self.results[cid] = body self.commands[cid]["status"] = body.get("status") if self.in_flight == cid: diff --git a/tests/fake_jira.py b/tests/fake_jira.py index db43736..b4efb55 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -141,6 +141,9 @@ class FakeJira: if p == "/rest/api/2/myself": return 200, {"name": "me", "displayName": "Me"} if p == "/rest/api/2/search": + m = re.search(r"key in \(([^)]*)\)", q.get("jql") or "") + if m and any(k.strip() not in self.issues for k in m.group(1).split(",")): # как Jira Server + return 400, {"errorMessages": ["Задача не существует или у вас нет прав на её просмотр."]} keys = self.search_keys(q.get("jql")) start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) page = keys[start:start + maxr] @@ -220,7 +223,9 @@ class FakeJira: sp = next(s for s in self.sprints if s["id"] == int(m.group(1))) if method == "GET": keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]] - return 200, {"issues": [{"key": k} for k in keys]} + start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) + return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), + "issues": [{"key": k} for k in keys[start:start + maxr]]} for k in body["issues"]: self.issues[k]["sprint"] = sp["name"] return 204, None diff --git a/tests/test_refresh.py b/tests/test_refresh.py index cf879f5..32729e4 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -60,7 +60,8 @@ class RefreshTest(unittest.TestCase): self.assertIsNone(sprints["AURORA-3"]) links = {(l["source_key"], l["target_key"]) for l in state["links"]} self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")}) - self.assertEqual([r[1] for r in self.fake.requests if r[1] == "/rest/api/2/search"].count("/rest/api/2/search"), 2) + searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"] + self.assertEqual(len(searches), 2) # базовая выборка + дочитывание соседей def test_refresh_by_keys_pages(self): for i in range(1, 121): @@ -80,6 +81,74 @@ class RefreshTest(unittest.TestCase): self.assertEqual(self.fake.requests, []) self.assertEqual(self.hub.states, []) + def test_refresh_empty_jql_result(self): + out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}}) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0}) + self.assertEqual(self.hub.states, []) + + def test_keys_with_deleted_key(self): + for i in (1, 2, 3, 4): + self.fake.add("AURORA-%d" % i) + out = self.ex.run({"id": 9, "type": "refresh", + "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-99", "AURORA-3", "AURORA-98"]}}) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-98", "AURORA-99"]}) + self.assertEqual(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"]) + + def test_deleted_neighbor(self): + self.fake.add("AURORA-1") + self.fake.add("AURORA-2", in_base=False) + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "GONE-1"}) + self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"}) + out = self.ex.run({"id": 10, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}}) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"issues": 1, "neighbors": 1, "pages": 1, "missing": ["GONE-1"]}) + self.assertEqual(self.hub.states[0]["neighbor_keys"], ["AURORA-2"]) + + def test_non_400_error_fails(self): + self.fake.add("AURORA-1") + + def boom(jql): + raise agent.HttpError(500, "oops") + self.ex.search_all = boom + out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) + self.assertEqual(out["status"], "failed") + self.assertIn("500", out["error"]) + + def test_link_visible_from_both_ends_deduped(self): + self.fake.add("AURORA-1") + self.fake.add("AURORA-2") + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"}) + self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}}) + self.assertEqual(len(self.hub.states[0]["links"]), 1) + + def test_many_neighbors_several_requests(self): + self.fake.add("AURORA-1") + for i in range(1, 151): + self.fake.add("NB-%d" % i, in_base=False) + self.fake.links.append({"id": i, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "NB-%d" % i}) + out = self.ex.run({"id": 13, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}}) + self.assertEqual(out["result"], {"issues": 1, "neighbors": 150, "pages": 2}) + searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"] + self.assertEqual(len(searches), 1 + 2) # база + два чанка соседей + + def test_sprint_names_paginated(self): + for i in range(1, 121): + self.fake.add("AURORA-%d" % i, sprint="Спринт 2") + names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)]) + self.assertEqual(len(names), 100) + pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"] + self.assertEqual(len(pages), 2) + + def test_fake_hub_terminal_result_not_overwritten(self): + cid = self.hub.enqueue("refresh", {"keys": []}) + hub = agent.HubClient(jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)) + hub.result(cid, {"status": "done", "result": {"a": 1}}) + hub.result(cid, {"status": "failed", "error": "x"}) + self.assertEqual(self.hub.commands[cid]["status"], "done") + self.assertEqual(self.hub.results[cid]["status"], "done") + if __name__ == "__main__": unittest.main() From 1133cee87362a21796cecbd2e31cd52ab8c6a7ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:56:27 +0400 Subject: [PATCH 19/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index c38c76b..9a3a2c7 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -12,3 +12,4 @@ | 4 | 2026-10-11 | Minor-замечания ревью Task 3 (T3.1, T3.3–T3.6, T3.8: пагинация поиска дубля, флаги existed/created, режим epic_link, префикс маркера, общий except в run, тесты) чиним одной финальной волной, а не по месту (R-4) | Не блокируют Task 4–5; одна волна дешевле | Task 4–5 унаследуют общий `run`; ошибки будут чиниться после | | 5 | 2026-10-11 | Неизвестный `link_type` берётся как сырое имя типа Jira (fallback), без `failed` (R-5) | Так же делает хаб в `check_confirmed`; допускает ручной ввод имён Jira | Опечатка в типе даст HTTP-ошибку Jira вместо понятной ошибки агента | | 6 | 2026-10-11 | Спринт в `refresh` берётся из Agile API по активным/будущим спринтам доски, а не из поля `search`; закрытые спринты дают `sprint = null` (R-6) | В настройках хаба нет id поля спринта (`field_ids` его не содержит); план Task 5 выбрал Agile | Исторические имена спринтов затрутся при полном refresh; лечится полем спринта в настройках | +| 7 | 2026-10-11 | Бисекция чанка `key in (...)` при 400 принята без лимита вызовов (до ~2·m·log2(n)); если не найден ни один ключ — ошибка, а не `missing` (R-7) | Иначе refresh с удалённой задачей падает целиком; лимит вызовов усложняет без выгоды при редких удалениях | При массовом удалении задач — всплеск запросов и риск 429 (агент их переживает) | From 8ebf5250b7bd55b00d47f841cf5fe8446b2cfd71 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:57:31 +0400 Subject: [PATCH 20/30] =?UTF-8?q?fix(agent):=20refresh=20=E2=80=94=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20400=20?= =?UTF-8?q?=D1=8D=D1=82=D0=BE=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0,=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B1=D0=B8=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D0=B8?= =?UTF-8?q?,=20missing=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 22 +++++++++++++++------- tests/test_refresh.py | 23 ++++++++++++++++++++++- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/agent.py b/src/agent.py index 42ff383..d9a61e2 100644 --- a/src/agent.py +++ b/src/agent.py @@ -629,9 +629,12 @@ class Executor: if not got or start >= int(page.get("total") or 0): return issues - def search_tolerant(self, keys: List[str], missing: List[str]) -> List[dict]: + def search_tolerant(self, keys: List[str], missing: List[str], errors: List[HttpError]) -> List[dict]: """key in (...): Jira Server отвечает 400 на несуществующий/недоступный ключ. Чанк делим пополам до - одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются.""" + одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются. + Первый 400 копится в errors: вызывающий пробрасывает его, если не найден НИ ОДИН ключ (неверная конфигурация, + а не удалённые задачи). + Стоимость: до ~2·m·log2(n) вызовов на m отсутствующих ключей из n — принятое ограничение, рулинг R-7.""" if not keys: return [] try: @@ -639,11 +642,13 @@ class Executor: except HttpError as e: if e.code != 400: raise + if not errors: + errors.append(e) if len(keys) == 1: missing.append(keys[0]) return [] mid = len(keys) // 2 - return self.search_tolerant(keys[:mid], missing) + self.search_tolerant(keys[mid:], missing) + return self.search_tolerant(keys[:mid], missing, errors) + self.search_tolerant(keys[mid:], missing, errors) def to_state(self, raw: dict) -> dict: f = raw.get("fields") or {} @@ -694,15 +699,18 @@ class Executor: def do_refresh(self, p: dict, cmd: dict) -> dict: missing: List[str] = [] + errors: List[HttpError] = [] if p.get("jql"): raws = self.search_all(p["jql"]) else: - keys = [k for k in (p.get("keys") or []) if k] + keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k)) if not keys: return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0}) raws = [] for i in range(0, len(keys), 100): - raws += self.search_tolerant(keys[i:i + 100], missing) + raws += self.search_tolerant(keys[i:i + 100], missing, errors) + if not raws and errors: # ни один ключ не найден: это конфигурация/запрос, а не удалённые задачи + raise errors[0] fetched = {r["key"] for r in raws} known = set(self.hub.known_keys()) if self.hub else set() linked = set() @@ -713,7 +721,7 @@ class Executor: absent = sorted(k for k in linked if k not in fetched and k not in known) neighbors = [] for i in range(0, len(absent), 100): - neighbors += self.search_tolerant(absent[i:i + 100], missing) + neighbors += self.search_tolerant(absent[i:i + 100], missing, errors) everything = raws + neighbors names = self.sprint_names([r["key"] for r in everything]) issues, links, seen = [], [], set() @@ -738,7 +746,7 @@ class Executor: "page": pages, "last_page": i + 100 >= len(issues)}) result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} if missing: - result["missing"] = sorted(missing)[:50] + result["missing"] = list(dict.fromkeys(missing))[:50] return self._out("done", result) diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 32729e4..2fe2075 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -93,9 +93,30 @@ class RefreshTest(unittest.TestCase): out = self.ex.run({"id": 9, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-99", "AURORA-3", "AURORA-98"]}}) self.assertEqual(out["status"], "done") - self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-98", "AURORA-99"]}) + self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-99", "AURORA-98"]}) self.assertEqual(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"]) + def test_all_keys_400_fails(self): + out = self.ex.run({"id": 14, "type": "refresh", "params": {"keys": ["X-1", "X-2", "X-3"]}}) + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + self.assertEqual(self.hub.states, []) + + def test_bisect_cost_one_missing_of_100(self): + for i in range(1, 100): + self.fake.add("AURORA-%d" % i) + keys = ["AURORA-%d" % i for i in range(1, 101)] # AURORA-100 отсутствует + out = self.ex.run({"id": 15, "type": "refresh", "params": {"keys": keys}}) + self.assertEqual(out["result"]["missing"], ["AURORA-100"]) + searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"] + self.assertLessEqual(len(searches), 2 * 7 + 1) + + def test_missing_deduped_and_truncated(self): + self.fake.add("AURORA-1") + keys = ["AURORA-1"] + ["GONE-%d" % i for i in range(1, 61)] + ["GONE-1"] + out = self.ex.run({"id": 16, "type": "refresh", "params": {"keys": keys}}) + self.assertEqual(out["result"]["missing"], ["GONE-%d" % i for i in range(1, 51)]) + def test_deleted_neighbor(self): self.fake.add("AURORA-1") self.fake.add("AURORA-2", in_base=False) From ea1ab864dae4ea404c582d80f166089569bcc92b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:06:10 +0400 Subject: [PATCH 21/30] =?UTF-8?q?feat(agent):=20=D1=86=D0=B8=D0=BA=D0=BB?= =?UTF-8?q?=20claim=20=E2=86=92=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0=20?= =?UTF-8?q?=E2=86=92=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=E2=86=92=20?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D1=91=D1=82,=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0,=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 172 ++++++++++++++++++++++++++++++- tests/test_runner.py | 238 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 tests/test_runner.py diff --git a/src/agent.py b/src/agent.py index d9a61e2..bd2fd91 100644 --- a/src/agent.py +++ b/src/agent.py @@ -750,6 +750,175 @@ class Executor: return self._out("done", result) +# --------------------------------------------------------------------------- +# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте +# --------------------------------------------------------------------------- +class Runner: + def __init__(self, cfg: dict, hub: HubClient, jira: JiraClient, journal: Journal): + self.cfg, self.hub, self.jira, self.journal = cfg, hub, jira, journal + self.settings = None # type: Optional[dict] + self.executor = None # type: Optional[Executor] + self.stop = False + self.sleep = time.sleep + self.idle_limit = None # type: Optional[int] + + @property + def poll(self) -> float: + return max(float(self.cfg.get("poll_seconds", 5) or 0), 0) + + def load_settings(self) -> None: + self.settings = self.hub.settings() + self.executor = Executor(self.jira, self.settings, self.hub) + self.executor.sleep = self.sleep + log("настройки хаба v%s (проект %s)" % (self.settings.get("settings_version"), self.settings.get("project_key"))) + + def send_result(self, rec: dict) -> bool: + """Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена + или удалена): считаем закрытой, чтобы не зациклиться. При остановке по сигналу делается одна попытка.""" + cid = rec["command_id"] + payload = {"status": rec["status"], "result": rec.get("result"), "error": rec.get("error"), + "jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0)} + while True: + try: + self.hub.result(cid, payload) + self.journal.acked(cid) + return True + except HttpError as e: + if e.code in (404, 409, 422): + log("хаб отверг отчёт по #%s: %s — считаю закрытым" % (cid, e)) + self.journal.acked(cid) + return True + log("хаб: %s, повтор через 10 с" % e) + except ConnectionError as e: + log("хаб недоступен: %s, повтор через 10 с" % e) + if self.stop: + log("остановка: отчёт по #%s не подтверждён, будет дослан при следующем старте" % cid) + return False + self.sleep(10) + + def wait_pause(self, pause_seconds) -> bool: + """Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы.""" + pause = float(pause_seconds or 0) + if pause > 0 and self.jira.last_call_at is not None: + remaining = pause - (time.monotonic() - self.jira.last_call_at) + if remaining > 0: + log(" пауза %.0f с до команды" % remaining) + while remaining > 0 and not self.stop: + step = min(remaining, 1.0) + self.sleep(step) + remaining -= step + return not self.stop + + def execute(self, cmd: dict) -> None: + log("#%s %s %s" % (cmd["id"], cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200])) + if not self.wait_pause(cmd.get("pause_seconds")): + return + self.journal.started(cmd["id"]) # started — только после паузы + try: + out = self.executor.run(cmd) + except Exception as e: # баг в обработчике не должен ронять цикл и зацикливать восстановление + log(" ! необработанная ошибка: %r" % e) + out = {"status": "failed", "result": None, + "error": "внутренняя ошибка агента: %s: %s" % (type(e).__name__, str(e)[:300]), + "jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited} + self.journal.finished(cmd["id"], out["status"], out.get("result"), out.get("error"), out["jira_calls"], out["rate_limited"]) + log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))) + rec = dict(out) + rec["command_id"] = cmd["id"] + self.send_result(rec) + + def fetch_command(self, cid): + """Команда из хаба по id; None — хаб такой не знает; при недоступности хаба ждём и повторяем.""" + while True: + try: + return self.hub.command(cid) + except HttpError as e: + if e.code in (404, 409, 422): + log("#%s: хаб ответил %s" % (cid, e)) + return None + log("хаб: %s, повтор через 10 с" % e) + except ConnectionError as e: + log("хаб недоступен: %s, повтор через 10 с" % e) + if self.stop: + raise InterruptedError(cid) + self.sleep(10) + + def recover(self) -> None: + for rec in self.journal.unacked_finished(): + log("досылаю отчёт по #%s" % rec["command_id"]) + self.send_result(rec) + for cid in self.journal.unfinished_started(): + if self.stop: + return + try: + cmd = self.fetch_command(cid) + except InterruptedError: + return + if cmd is None: + self.journal.acked(cid) + continue + if cmd.get("status") != "claimed": + log("#%s уже %s в хабе — пропускаю" % (cid, cmd.get("status"))) + self.journal.acked(cid) + continue + log("#%s начата до перезапуска — выполняю заново через проверку" % cid) + cmd["pause_seconds"] = 0 + self.execute(cmd) + + def loop(self) -> None: + idle, recovered = 0, False + while not self.stop: + if self.settings is None: + try: + self.load_settings() + except (ConnectionError, HttpError) as e: + log("хаб: %s" % e) + self.sleep(self.poll) + continue + if not recovered: + self.recover() + recovered = not self.stop + continue + try: + resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso) + except (ConnectionError, HttpError) as e: + log("хаб: %s" % e) + self.sleep(self.poll) + continue + if resp.get("settings_version") != self.settings.get("settings_version"): + try: + self.load_settings() # настройки — до выполнения команды + except (ConnectionError, HttpError) as e: + log("хаб: не перечитать настройки: %s" % e) + cmd = resp.get("command") + if cmd: + idle = 0 + self.execute(cmd) + continue + idle += 1 + if self.idle_limit is not None and idle >= self.idle_limit: + return + self.sleep(self.poll) + + +def cmd_run(cfg: dict, journal_path: str) -> int: + runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path)) + + def on_signal(signum, frame): + if runner.stop: + log("повторный сигнал — выхожу немедленно") + sys.exit(130) + runner.stop = True + log("сигнал %s: доделываю текущую команду и выхожу" % signum) + + signal.signal(signal.SIGINT, on_signal) + signal.signal(signal.SIGTERM, on_signal) + log("jira-agent v%d: хаб %s, Jira %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["jira"]["base_url"], journal_path)) + runner.loop() + log("остановлен") + return 0 + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -783,8 +952,7 @@ def main(argv=None) -> int: removed = Journal(args.journal).prune(30) log("журнал: удалено записей %d" % removed) return 0 - log("команда %s ещё не реализована" % args.cmd) - return 2 + return cmd_run(cfg, args.journal) if __name__ == "__main__": diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..f62d905 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,238 @@ +import os +import tempfile +import time +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_hub import FakeHub +from fake_jira import FakeJira + + +class RunnerTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.hub = FakeHub(SETTINGS) + self.dir = tempfile.TemporaryDirectory() + self.journal = agent.Journal(os.path.join(self.dir.name, "journal.jsonl")) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), self.journal) + self.sleeps = [] + self.runner.sleep = self.sleeps.append + self.runner.jira.sleep = no_sleep + self.runner.idle_limit = 1 + self.fake.add("AURORA-1", duedate="2026-10-20") + + def tearDown(self): + self.fake.stop() + self.hub.stop() + self.dir.cleanup() + + def kinds(self): + return [(r["kind"], r["command_id"]) for r in self.journal.records()] + + def claim_in_flight(self, ctype, params, pause=0): + cid = self.hub.enqueue(ctype, params, pause=pause) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "claimed" + self.hub.in_flight = cid + return cid + + def test_processes_queue(self): + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.hub.enqueue("refresh", {"keys": ["AURORA-1"]}) + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "done") + self.assertEqual(self.hub.results[1]["result"], {"changed": ["duedate"]}) + self.assertEqual(self.hub.results[1]["jira_calls"], 2) + self.assertEqual(self.hub.results[2]["status"], "done") + self.assertEqual(len(self.hub.states), 1) + self.assertEqual(self.hub.states[0]["issues"][0]["duedate"], "2026-10-23") + self.assertEqual(self.kinds(), [("started", 1), ("finished", 1), ("acked", 1), ("started", 2), ("finished", 2), ("acked", 2)]) + self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0 + + def test_pause_waits_from_last_jira_call(self): + self.runner.jira.last_call_at = time.monotonic() + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=3) + self.runner.loop() + self.assertEqual(len(self.sleeps), 3) + self.assertAlmostEqual(sum(self.sleeps), 3, delta=0.1) + self.assertEqual(self.hub.results[1]["status"], "done") + + def test_result_retried_when_hub_down(self): + self.hub.fail_results = 2 + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "done") + self.assertEqual(self.sleeps, [10, 10]) + self.assertEqual(self.journal.unacked_finished(), []) + + def test_result_retried_on_connection_error(self): + calls = [] + real = self.runner.hub.result + + def flaky(cid, payload): + calls.append(1) + if len(calls) < 3: + raise ConnectionError("нет связи") + return real(cid, payload) + + self.runner.hub.result = flaky + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertEqual(self.sleeps, [10, 10]) + self.assertEqual(self.hub.results[1]["status"], "done") + + def test_result_rejected_is_acked(self): + for cid, code in ((1, 404), (2, 409), (3, 422)): + self.hub.reject_results = {cid: code} + self.journal.finished(cid, "done", {}, None, 0, 0) + self.assertTrue(self.runner.send_result(self.journal.unacked_finished()[0])) + self.assertEqual(self.journal.unacked_finished(), []) + + def test_result_rejected_in_loop(self): + self.hub.reject_results = {1: 404} + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertNotIn(1, self.hub.results) + self.assertEqual(self.journal.unacked_finished(), []) + self.assertEqual(self.kinds()[-1], ("acked", 1)) + + def test_recover_unfinished_started(self): + cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}, pause=99) + self.journal.started(cid) + self.runner.loop() + self.assertEqual(self.hub.results[cid]["result"], {"changed": []}) # уже так в Jira — записи нет + self.assertEqual(self.journal.unfinished_started(), []) + self.assertEqual(self.sleeps, []) # пауза при восстановлении не применяется + self.assertEqual([r[0] for r in self.fake.writes()], []) + + def test_recover_ensure_issue_finds_duplicate(self): + self.fake.add("AURORA-7", reporter="me", summary="Новая задача") + before = len(self.fake.issues) + cid = self.claim_in_flight("ensure_issue", {"temp_key": "new:1", "issue_type": "task", "summary": "Новая задача"}) + self.journal.started(cid) + self.runner.loop() + self.assertEqual(self.hub.results[cid]["status"], "done") + self.assertEqual(self.hub.results[cid]["result"], {"key": "AURORA-7", "existed": True}) + self.assertEqual(len(self.fake.issues), before) + self.assertEqual(self.fake.writes(), []) + + def test_recover_skips_command_not_claimed_anymore(self): + cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.hub.queue.remove(cid) + self.hub.commands[cid]["status"] = "cancelled" + self.journal.started(cid) + self.runner.loop() + self.assertNotIn(cid, self.hub.results) + self.assertEqual(self.kinds(), [("started", cid), ("acked", cid)]) + + def test_recover_unacked_finished(self): + cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.journal.started(cid) + self.journal.finished(cid, "done", {"changed": ["duedate"]}, None, 2, 0) + self.hub.enqueue("refresh", {"keys": ["AURORA-1"]}) + self.runner.loop() + self.assertEqual(self.hub.results[cid]["result"], {"changed": ["duedate"]}) + self.assertEqual(self.hub.results[2]["status"], "done") + self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)]) + + def test_stop_during_pause(self): + self.runner.jira.last_call_at = time.monotonic() + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5) + self.runner.sleep = lambda s: setattr(self.runner, "stop", True) + self.runner.loop() + self.assertEqual(self.journal.records(), []) + self.assertEqual(self.hub.results, {}) + self.assertEqual(self.hub.commands[1]["status"], "claimed") + + def test_stop_during_execution_finishes_and_reports(self): + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.hub.enqueue("refresh", {"keys": ["AURORA-1"]}) + self.runner.load_settings() + run = self.runner.executor.run + + def run_and_signal(cmd): + self.runner.stop = True # сигнал пришёл во время выполнения + return run(cmd) + + self.runner.executor.run = run_and_signal + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "done") + self.assertNotIn(2, self.hub.results) # вторая команда не начата + self.assertEqual(self.kinds(), [("started", 1), ("finished", 1), ("acked", 1)]) + + def test_stop_during_execution_hub_down_single_attempt(self): + self.hub.fail_results = 5 + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.load_settings() + run = self.runner.executor.run + self.runner.executor.run = lambda cmd: (setattr(self.runner, "stop", True), run(cmd))[1] + self.runner.loop() + self.assertEqual(self.sleeps, []) + self.assertEqual([r["command_id"] for r in self.journal.unacked_finished()], [1]) + + def test_unexpected_exception_becomes_failed(self): + self.runner.load_settings() + + def boom(cmd): + raise KeyError("summary") + + self.runner.executor.run = boom + self.hub.enqueue("ensure_issue", {"temp_key": "new:1", "issue_type": "task"}) + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "failed") + self.assertIn("KeyError", self.hub.results[1]["error"]) + self.assertEqual(self.journal.unfinished_started(), []) + self.assertEqual(self.journal.unacked_finished(), []) + + def test_recover_waits_when_hub_down(self): + cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}) + self.journal.started(cid) + real = self.runner.hub.command + calls = [] + + def flaky(i): + calls.append(1) + if len(calls) == 1: + raise ConnectionError("нет связи") + return real(i) + + self.runner.hub.command = flaky + self.runner.loop() + self.assertEqual(self.sleeps, [10]) + self.assertEqual(self.hub.results[cid]["status"], "done") + + def test_settings_reload_on_version_change(self): + self.runner.load_settings() + self.runner.settings["settings_version"] = 0 + self.runner.loop() + self.assertEqual(self.runner.settings["settings_version"], 1) + + def test_settings_reloaded_before_command(self): + self.runner.load_settings() + self.hub.settings["settings_version"] = 2 + self.hub.settings["pacing"] = dict(self.hub.settings["pacing"], ensure_fields=0) + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + seen = [] + real = self.runner.execute + self.runner.execute = lambda cmd: (seen.append(self.runner.settings["settings_version"]), real(cmd)) + self.runner.loop() + self.assertEqual(seen, [2]) + + def test_hub_down_at_start_keeps_polling(self): + calls = [] + + def hub_settings(): + calls.append(1) + if len(calls) < 3: + raise ConnectionError("нет связи") + return dict(SETTINGS) + + self.runner.hub.settings = hub_settings + self.runner.loop() + self.assertEqual(len(calls), 3) + self.assertEqual(self.sleeps, [0, 0]) + + +if __name__ == "__main__": + unittest.main() From 778c1bee121b5e693de0142da2e377ff524b7c77 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:07:58 +0400 Subject: [PATCH 22/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 9a3a2c7..abf8e62 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -13,3 +13,4 @@ | 5 | 2026-10-11 | Неизвестный `link_type` берётся как сырое имя типа Jira (fallback), без `failed` (R-5) | Так же делает хаб в `check_confirmed`; допускает ручной ввод имён Jira | Опечатка в типе даст HTTP-ошибку Jira вместо понятной ошибки агента | | 6 | 2026-10-11 | Спринт в `refresh` берётся из Agile API по активным/будущим спринтам доски, а не из поля `search`; закрытые спринты дают `sprint = null` (R-6) | В настройках хаба нет id поля спринта (`field_ids` его не содержит); план Task 5 выбрал Agile | Исторические имена спринтов затрутся при полном refresh; лечится полем спринта в настройках | | 7 | 2026-10-11 | Бисекция чанка `key in (...)` при 400 принята без лимита вызовов (до ~2·m·log2(n)); если не найден ни один ключ — ошибка, а не `missing` (R-7) | Иначе refresh с удалённой задачей падает целиком; лимит вызовов усложняет без выгоды при редких удалениях | При массовом удалении задач — всплеск запросов и риск 429 (агент их переживает) | +| 8 | 2026-10-11 | Общий `except Exception` стоит в `Runner.execute` (не в `Executor.run`); minor-находки T3.x/T5.x/T6.x чиним одной финальной волной, включая ложный `failed` для `ensure_issue` при ConnectionError из Jira (R-8) | Исключение вне HttpError/ConnectionError не должно ронять цикл и вызывать зацикленное восстановление; одна волна дешевле | Ложный `failed` у `ensure_issue` оставит временный ключ неразрешённым, а задача в Jira уже создана | From 54f65d033389bdf8e853f0b060b69d3ebf08ef83 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:10:26 +0400 Subject: [PATCH 23/30] =?UTF-8?q?docs(agent):=20README=20(=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B0,=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA,=20launchd,=20prune),=20=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D0=B0,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D0=BA=D1=81=D0=B8=D1=81=D0=B0=203.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: доставка на виртуалку, check/запуск/launchd, журнал и prune, поведение с командами, известные ограничения (prune при работе агента, Ctrl-C во время паузы, https без CA, спринты закрытых спринтов, refresh, сравнение описания, 429/503 без потолка). CLAUDE.md и ARCHITECTURE.md заполнены по src/agent.py. tests/test_py39.py: разбор с feature_version=(3, 9) и разрешённый список импортов (только stdlib). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- ARCHITECTURE.md | 31 +++++++-- CLAUDE.md | 50 ++++++++------ README.md | 164 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_py39.py | 26 +++++++ 4 files changed, 245 insertions(+), 26 deletions(-) create mode 100644 README.md create mode 100644 tests/test_py39.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a85988..f2b4352 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,15 +1,32 @@ # Архитектура -{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед architectural brainstorming. Обновляется вместе со спеками.}} +Агент — один файл `src/agent.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках +Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, план: `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`. -## Модули +## Разделы файла `src/agent.py` -| Модуль | Ответственность | Зависит от | -|---|---|---| -| | | | +| Раздел | Классы и функции | Ответственность | Что использует | +|---|---|---|---| +| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — | +| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 попыток), хранит время последнего вызова | `urllib` | +| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` | файловая система | +| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба | +| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` | +| CLI | `cmd_run`, `cmd_check`, `main` | команды `run`, `check`, `prune`; обработка сигналов | `Runner`, `Journal` | ## Границы -- Что не должно импортировать что. +- Пароль и токен Jira — только в `agent.json`. В хаб, журнал и логи не попадают. +- Политика темпа не хранится в агенте: зазоры (`pacing`) и `pause_seconds` приходят от хаба. Агент досыпает паузу + до зазора от последнего вызова Jira (`JiraClient.last_call_at`). +- Одна команда в полёте: цикл берёт следующую только после выполнения и подтверждения отчёта. +- Отчёт до подтверждения: `send_result` повторяет каждые 10 с; 404/409/422 от хаба считаются закрытием. +- Журнал пишется трижды на команду вокруг выполнения: `started` — после паузы, перед первым обращением к Jira; + `finished` — после результата; `acked` — после подтверждения хаба. Восстановление опирается только на этот порядок. +- Каждый обработчик `Executor` возвращает `status`/`result`/`error` и счётчики. Исключение, которое не обработано + в обработчике, `Runner.execute` превращает в `failed` и не роняет цикл. +- Тесты не ходят во внешние сервисы; фейки — `tests/fake_jira.py` и `tests/fake_hub.py`. ## Открытые вопросы -- +- HTTPS хаба с корпоративным CA: `HubClient` не принимает `ca_bundle`/`verify_ssl` (ограничение README, пункт 3). +- Защита `prune` от параллельной записи: блокировки нет (ограничение README, пункт 1). +- Спринт задач закрытых спринтов: `sprint = null` (рулинг R-6). diff --git a/CLAUDE.md b/CLAUDE.md index 47fba20..c2fcd0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,32 +1,44 @@ # jira-agent ## Что это -{{ОДНИМ АБЗАЦЕМ: что делает система, для кого, главный сценарий.}} +Клиент моста Jira для виртуалки с доступом к Jira банка. Опрашивает хаб `jira-hub` (Mac mini), выполняет команды +«желаемый результат» по правилу «проверь, потом сделай», ведёт журнал на диске и отдаёт хабу состояние задач. +Главный сценарий: хаб ставит команду (например, создать задачу, перевести статус, перенести в спринт), агент +читает задачу в Jira, записывает только разницу и возвращает результат; хаб синхронизирует зеркало через `refresh`. +Спека: `../jira-hub/openspec/changes/bridge-core/` (`specs/agent/spec.md`, `specs/commands/spec.md`). ## Стек и ограничения -- {{Язык, сборка, тесты — например: Python 3.12, uv, pytest.}} -- {{Жёсткие ограничения среды исполнения — без чего код не должен собираться.}} +- Python 3.9.6 (как на виртуалке), только стандартная библиотека, без venv и зависимостей. +- Запрещено: синтаксис 3.10+ (`match`, `X | Y` в аннотациях, `dict | dict`), сторонние импорты. + Проверяет `tests/test_py39.py` (разбор с `feature_version=(3, 9)` и разрешённый список импортов). +- `agent.json` содержит учётные данные Jira — не коммитить, не отправлять в хаб, не выносить с виртуалки. + Шаблон без секретов — `src/agent.example.json`. ## Архитектура -- `src/core/` (или {{ваш путь}}) — чистая логика без зависимостей от фреймворка/платформы. Покрывается тестами полностью. -- `src/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно. -- {{Ключевые интерфейсы/порты между ними, если есть.}} +- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`), + `Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал → + выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `main`). +- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`). + `Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira. +- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. +- Подробнее: `ARCHITECTURE.md`. ## Команды -- `{{npm test / pytest}}` — весь набор тестов. Зелёным считается только весь набор. -- `{{npm run build / ...}}` — сборка. -- `{{npm run dev / ...}}` — локальный запуск. +- `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор. +- `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые ресурсы (так проверяем перед коммитом). +- `/usr/bin/python3 src/agent.py check` — доступность хаба и Jira, без записи в Jira. +- `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`). +- `/usr/bin/python3 src/agent.py prune` — очистка журнала (не при работающем агенте). ## Тесты -- Golden-тесты: `tests/fixtures/.*` + эталон `.expected.*`. Внешние сервисы в тестах мокаются. -- {{Где лежит тестовое окружение (vault, БД, песочница).}} +- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: Jira и хаб заменяют фейки `tests/fake_jira.py` и `tests/fake_hub.py`. +- `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`. + Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`. +- Для каждого типа команды — свой набор: `test_executor_fields.py`, `test_executor_links.py`, `test_refresh.py`; + цикл и восстановление — `test_runner.py`; журнал — `test_journal.py`; HTTP и 429 — `test_clients.py`. +- Golden-фикстур пока нет: каждый тест строит сценарий в коде. ## Первая подсистема -{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}} - -## OpenSpec и superpowers -- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. -- Когда brainstorming классифицировал задачу как architectural, структуру change создать через `openspec new change ` (или `/opsx:propose `), а содержимое proposal.md, specs/ и design.md написать по итогам brainstorming. tasks.md из OpenSpec не заполнять — задачи живут в плане superpowers. -- `/opsx:apply` не использовать: исполнение идёт через writing-plans и SDD/executing-plans. -- `openspec archive ` — после merge PR, как часть чеклиста закрытия. -- Хук require-plan защищает любой каталог `src/` на любой глубине. Если код лежит иначе — перечислить каталоги (префиксы от корня, по одному в строке) в `.claude/code-paths`. +Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, +журнал, восстановление после перезапуска, CLI `run|check|prune`. Задача 7 плана — документация и проверка совместимости с 3.9. +План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..24f64dc --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# jira-agent + +Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`jira-hub` на Mac mini), +выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске +и возвращает хабу состояние задач. Один файл `src/agent.py`, только стандартная библиотека, Python 3.9. + +Спека: `../jira-hub/openspec/changes/bridge-core/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`). +Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP. + +## Доставка на виртуалку + +1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`. +2. Переименовать `agent.example.json` в `agent.json` и заполнить: + - `hub_url` — `http://192.168.0.106:8790`; + - `hub_token` — агентский токен из `jira-hub tokens` на Mac mini; + - `jira.base_url` — адрес Jira банка; + - `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`; + - `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 3 ниже). +3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки. + Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira. + +Остальные настройки (id полей, типы задач, карта переходов, типы связей, доска, темп) агент получает от хаба при +старте и при каждом изменении `settings_version`; в `agent.json` их быть не должно. + +## Проверка + +``` +python3 agent.py check +``` + +В норме печатает две строки: `хаб: доступен, настройки v, проект ` и `Jira: доступен, пользователь `. +Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1. +Команда ничего не пишет в Jira. + +## Запуск + +В терминале: + +``` +python3 agent.py +``` + +В фоне с логом: + +``` +nohup python3 -u agent.py > agent.log 2>&1 & +``` + +Остановка: Ctrl-C или `kill `. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. +Повторный сигнал — немедленный выход (код 130). Команда, которая не успела записаться в журнал `started`, при этом +остаётся `claimed` в хабе (см. ограничение 2). + +## Автозапуск через launchd (опционально) + +Пример `~/Library/LaunchAgents/ru.aepif.jira-agent.plist`: + +```xml + + + + + Labelru.aepif.jira-agent + ProgramArguments + + /usr/bin/python3 + -u + /Users/USER/jira-agent/agent.py + + WorkingDirectory/Users/USER/jira-agent + RunAtLoad + KeepAlive + StandardOutPath/Users/USER/jira-agent/agent.log + StandardErrorPath/Users/USER/jira-agent/agent.log + + +``` + +Подставить свой путь вместо `USER`. Запуск и остановка: + +``` +launchctl load ~/Library/LaunchAgents/ru.aepif.jira-agent.plist +launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist +``` + +`WorkingDirectory` нужен, чтобы `agent.json` и `journal.jsonl` лежали рядом с `agent.py`. + +## Журнал + +`journal.jsonl` лежит рядом с `agent.py`. Это только дозапись: на каждую команду пишутся `started` (после паузы, +перед обращением к Jira), `finished` (результат и счётчики вызовов) и `acked` (хаб подтвердил приём отчёта). +По журналу агент после перезапуска досылает неподтверждённые отчёты и находит команды, начатые, но не завершённые. + +Журнал содержит параметры команд, то есть тексты задач и комментариев. Не выносить его с виртуалки. + +Очистка подтверждённых записей старше 30 дней: + +``` +python3 agent.py prune +``` + +Не запускать при работающем агенте (ограничение 1 ниже). + +## Что делает агент с командами + +Типы команд описаны в спеке `commands/spec.md`. Здесь — что агент делает с каждым: + +| Тип | Что делает | +|---|---| +| `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт; возвращает `key` | +| `ensure_fields` | читает задачу и пишет только отличающиеся поля | +| `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт | +| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | +| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | +| `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог | +| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет | + +Правила работы: +- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. +- **Паузы.** Хаб выдаёт команду с `pause_seconds` — агент досыпает до зазора от последнего вызова Jira. Внутри команды + с несколькими записями зазор берётся из `pacing` настроек хаба. +- **Одна команда в полёте.** Агент берёт следующую команду, только когда предыдущая выполнена и отчёт подтверждён. +- **Отчёт до подтверждения.** Отчёт повторяется каждые 10 с, пока хаб не подтвердит. Ответы 404/409/422 хаб + выдаёт, когда команда уже отменена или удалена: агент считает её закрытой и не зацикливается. +- **429 и 503.** Агент ждёт `Retry-After` из ответа Jira и повторяет запрос, до 5 попыток. Если заголовка нет, + ждёт 60 с, умноженные на номер попытки. Число ожиданий идёт в `rate_limited` отчёта. +- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 1) отправляется в каждом `claim`. + Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды + при этом выдаются по-прежнему. + +## Тесты + +``` +/usr/bin/python3 -m unittest discover -s tests -t . +``` + +Тот же Python 3.9.6, что на виртуалке. Внешних сервисов нет: Jira и хаб заменяют фейки +(`tests/fake_jira.py`, `tests/fake_hub.py`). `tests/test_py39.py` проверяет, что `agent.py` разбирается как Python 3.9 +и импортирует только стандартную библиотеку. + +## Обновление агента + +Скопировать новый `agent.py` поверх старого и перезапустить процесс (или `launchctl unload` + `load`). +`agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте. + +## Известные ограничения / эксплуатация + +В порядке важности: + +1. **`prune` не запускать при работающем агенте.** Блокировки нет. Запись, добавленная агентом во время `prune`, + может пропасть при перезаписи файла. Сначала остановить агента. +2. **Ctrl-C во время паузы перед командой.** Команда остаётся `claimed` в хабе, в журнале о ней ничего нет. + Хаб сам её не переиздаёт. Лечение: в хабе отменить команду с force и повторить: + `POST /commands/{id}/cancel` с телом `{"force": true}`, затем `POST /commands/{id}/retry`. +3. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url` + должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают. +4. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по + этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. +5. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в + `result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`). + Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50). +6. **Сравнение описания в `ensure_fields` точное.** Если Jira нормализует переводы строк (CRLF), будут лишние + записи. Наблюдать на реальной Jira. +7. **429/503 без потолка.** Агент ждёт `Retry-After` сколько бы он ни был (без верхней границы), до 5 попыток. + Длинный `Retry-After` держит очередь. diff --git a/tests/test_py39.py b/tests/test_py39.py new file mode 100644 index 0000000..1763e5f --- /dev/null +++ b/tests/test_py39.py @@ -0,0 +1,26 @@ +import ast +import os +import unittest + +from _common import SRC + + +class Py39Test(unittest.TestCase): + def test_agent_parses_as_python_39(self): + with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: + ast.parse(f.read(), feature_version=(3, 9)) + + def test_no_third_party_imports(self): + with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: + tree = ast.parse(f.read()) + allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "sys", "time", "urllib", "datetime", "typing"} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + self.assertIn(a.name.split(".")[0], allowed, a.name) + elif isinstance(node, ast.ImportFrom): + self.assertIn((node.module or "").split(".")[0], allowed, node.module) + + +if __name__ == "__main__": + unittest.main() From 4eaad42d15745f1904cf080edc74228535179880 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:15:04 +0400 Subject: [PATCH 24/30] =?UTF-8?q?docs(agent):=20fix=20round=201=20?= =?UTF-8?q?=E2=80=94=20OpenSpec-=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20?= =?UTF-8?q?=D0=B2=20CLAUDE.md,=20README=20(=D0=BF=D1=80=D0=B5=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=20=D0=BB=D0=BE=D0=B3=D0=B0,=20timeout/poll,=20429)?= =?UTF-8?q?,=20=D0=BE=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=203.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: восстановлен раздел «OpenSpec и superpowers» из шаблона; убрана фраза про задачу 7; тесты описаны по областям; оговорка про проверку 3.9 (ast не ловит API, только прогон на 3.9.6). - README: пример вывода check с префиксом времени; timeout и poll_seconds в доставке; 429/503 — не более 4 ожиданий, Retry-After в HTTP-date не разбирается. - ARCHITECTURE.md: формулировка повторов 429/503. - tests/test_py39.py: комментарий с ограничением проверки. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- ARCHITECTURE.md | 2 +- CLAUDE.md | 18 +++++++++++++----- README.md | 22 ++++++++++++++++------ tests/test_py39.py | 4 ++++ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f2b4352..a93867a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,7 +8,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл | Раздел | Классы и функции | Ответственность | Что использует | |---|---|---|---| | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — | -| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 попыток), хранит время последнего вызова | `urllib` | +| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `urllib` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` | файловая система | | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` | diff --git a/CLAUDE.md b/CLAUDE.md index c2fcd0e..cb306a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,8 @@ ## Стек и ограничения - Python 3.9.6 (как на виртуалке), только стандартная библиотека, без venv и зависимостей. -- Запрещено: синтаксис 3.10+ (`match`, `X | Y` в аннотациях, `dict | dict`), сторонние импорты. - Проверяет `tests/test_py39.py` (разбор с `feature_version=(3, 9)` и разрешённый список импортов). +- Запрещено: синтаксис 3.10+ и сторонние импорты. + `tests/test_py39.py` ловит синтаксис 3.10+ (match и т. п.) и сторонние импорты; `X | Y` в аннотациях и API новее 3.9 подтверждает только прогон на /usr/bin/python3 3.9.6. - `agent.json` содержит учётные данные Jira — не коммитить, не отправлять в хаб, не выносить с виртуалки. Шаблон без секретов — `src/agent.example.json`. @@ -34,11 +34,19 @@ - Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: Jira и хаб заменяют фейки `tests/fake_jira.py` и `tests/fake_hub.py`. - `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`. Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`. -- Для каждого типа команды — свой набор: `test_executor_fields.py`, `test_executor_links.py`, `test_refresh.py`; - цикл и восстановление — `test_runner.py`; журнал — `test_journal.py`; HTTP и 429 — `test_clients.py`. +- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`), + `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_refresh.py` (`refresh`), + `test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429). - Golden-фикстур пока нет: каждый тест строит сценарий в коде. ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, -журнал, восстановление после перезапуска, CLI `run|check|prune`. Задача 7 плана — документация и проверка совместимости с 3.9. +журнал, восстановление после перезапуска, CLI `run|check|prune`. План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`. + +## OpenSpec и superpowers +- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. +- Когда brainstorming классифицировал задачу как architectural, структуру change создать через `openspec new change ` (или `/opsx:propose `), а содержимое proposal.md, specs/ и design.md написать по итогам brainstorming. tasks.md из OpenSpec не заполнять — задачи живут в плане superpowers. +- `/opsx:apply` не использовать: исполнение идёт через writing-plans и SDD/executing-plans. +- `openspec archive ` — после merge PR, как часть чеклиста закрытия. +- Хук require-plan защищает любой каталог `src/` на любой глубине. Если код лежит иначе — перечислить каталоги (префиксы от корня, по одному в строке) в `.claude/code-paths`. diff --git a/README.md b/README.md index 24f64dc..fc6b291 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ - `hub_token` — агентский токен из `jira-hub tokens` на Mac mini; - `jira.base_url` — адрес Jira банка; - `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`; - - `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 3 ниже). + - `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 3 ниже); + - `timeout` — таймаут запроса в секундах (по умолчанию 60); + - `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5). 3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки. Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira. @@ -28,7 +30,14 @@ python3 agent.py check ``` -В норме печатает две строки: `хаб: доступен, настройки v, проект ` и `Jira: доступен, пользователь `. +В норме печатает две строки с префиксом времени `[ЧЧ:ММ:СС]`, например: + +``` +[10:42:17] хаб: доступен, настройки v3, проект AURORA +[10:42:18] Jira: доступен, пользователь ivanov +``` + +(`хаб: доступен, настройки v, проект ` и `Jira: доступен, пользователь `). Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1. Команда ничего не пишет в Jira. @@ -121,8 +130,8 @@ python3 agent.py prune - **Одна команда в полёте.** Агент берёт следующую команду, только когда предыдущая выполнена и отчёт подтверждён. - **Отчёт до подтверждения.** Отчёт повторяется каждые 10 с, пока хаб не подтвердит. Ответы 404/409/422 хаб выдаёт, когда команда уже отменена или удалена: агент считает её закрытой и не зацикливается. -- **429 и 503.** Агент ждёт `Retry-After` из ответа Jira и повторяет запрос, до 5 попыток. Если заголовка нет, - ждёт 60 с, умноженные на номер попытки. Число ожиданий идёт в `rate_limited` отчёта. +- **429 и 503.** Агент ждёт `Retry-After` из ответа Jira и повторяет запрос: до 5 запросов, то есть не более 4 ожиданий; + на пятой неудаче — ошибка команды. Если заголовка нет или он в формате HTTP-date (не разбирается), ждёт 60 с, умноженные на номер попытки. Число ожиданий идёт в `rate_limited` отчёта. - **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 1) отправляется в каждом `claim`. Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды при этом выдаются по-прежнему. @@ -160,5 +169,6 @@ python3 agent.py prune Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50). 6. **Сравнение описания в `ensure_fields` точное.** Если Jira нормализует переводы строк (CRLF), будут лишние записи. Наблюдать на реальной Jira. -7. **429/503 без потолка.** Агент ждёт `Retry-After` сколько бы он ни был (без верхней границы), до 5 попыток. - Длинный `Retry-After` держит очередь. +7. **429/503 без потолка.** Агент ждёт `Retry-After` в секундах сколько бы он ни был (без верхней границы): не более 4 ожиданий, + на пятой неудаче — ошибка. Длинный `Retry-After` держит очередь. Формат HTTP-date в `Retry-After` не разбирается: + тогда ждётся 60 с × номер попытки. diff --git a/tests/test_py39.py b/tests/test_py39.py index 1763e5f..8235b6c 100644 --- a/tests/test_py39.py +++ b/tests/test_py39.py @@ -4,6 +4,10 @@ import unittest from _common import SRC +# Ограничение проверки: ast.parse(feature_version=(3, 9)) ловит синтаксис 3.10+ (match и т. п.) и сторонние +# импорты; API и конструкции, новые в 3.9 (например, `X | Y` в аннотациях), не проверяет. Их подтверждает только +# прогон на /usr/bin/python3 (3.9.6). + class Py39Test(unittest.TestCase): def test_agent_parses_as_python_39(self): From 4018f994dcc0e3ee62af710716ac01766b53dc9b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:26:22 +0400 Subject: [PATCH 25/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B8=20R-9=E2=80=A6R-12=20(=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index abf8e62..bb96a13 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -14,3 +14,7 @@ | 6 | 2026-10-11 | Спринт в `refresh` берётся из Agile API по активным/будущим спринтам доски, а не из поля `search`; закрытые спринты дают `sprint = null` (R-6) | В настройках хаба нет id поля спринта (`field_ids` его не содержит); план Task 5 выбрал Agile | Исторические имена спринтов затрутся при полном refresh; лечится полем спринта в настройках | | 7 | 2026-10-11 | Бисекция чанка `key in (...)` при 400 принята без лимита вызовов (до ~2·m·log2(n)); если не найден ни один ключ — ошибка, а не `missing` (R-7) | Иначе refresh с удалённой задачей падает целиком; лимит вызовов усложняет без выгоды при редких удалениях | При массовом удалении задач — всплеск запросов и риск 429 (агент их переживает) | | 8 | 2026-10-11 | Общий `except Exception` стоит в `Runner.execute` (не в `Executor.run`); minor-находки T3.x/T5.x/T6.x чиним одной финальной волной, включая ложный `failed` для `ensure_issue` при ConnectionError из Jira (R-8) | Исключение вне HttpError/ConnectionError не должно ронять цикл и вызывать зацикленное восстановление; одна волна дешевле | Ложный `failed` у `ensure_issue` оставит временный ключ неразрешённым, а задача в Jira уже создана | +| 9 | 2026-10-12 | В журнал пишется запись `claimed` сразу после `claim`, до паузы; `unfinished_started`, `recover`, `prune` считают её открывающей; `started` по-прежнему после паузы (R-9) | Иначе остановка агента в паузе/claim оставляет команду `claimed` в хабе навсегда (хаб не переиздаёт), а лечение cancel+retry не восстанавливает зависимые команды пакета | Формат журнала шире, чем в спеке (лишний `kind`); старые журналы совместимы | +| 10 | 2026-10-12 | `Runner.execute` повторяет команду до 3 раз через проверку при ConnectionError/HTTP 5xx, не записывая `finished`; затем `failed` «результат неизвестен, повтор безопасен» (R-10) | Неоднозначный исход записи в Jira (обрыв после POST) нельзя отдавать как `failed`: для `ensure_issue` хаб пропустит зависимые команды; все команды идемпотентны | Лишние задержки при недоступной Jira | +| 11 | 2026-10-12 | Запрошенное, но не настроенное поле (epic_key, planned_start/end) даёт `failed`, а не молчаливый `done` (R-11) | Ложный успех хаб запишет в зеркало как выполненный | Команды падают, пока хаб не настроен | +| 12 | 2026-10-12 | `agent_version` добавляется в отчёт и в `/agent/state` (R-12) | Спека: версия «в каждом отчёте»; хаб лишние поля игнорирует | Нет | From 6bcc6f611fd249d990a76b23eabf48a08b95f0ae Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:31:52 +0400 Subject: [PATCH 26/30] =?UTF-8?q?feat(agent):=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C=20claimed=20=D0=B4=D0=BE=20=D0=BF=D0=B0=D1=83?= =?UTF-8?q?=D0=B7=D1=8B=20(R-9),=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B8=20(R-10),=20agent=5Fversion,=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8=D0=B2=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D1=86=D0=B8=D0=BA=D0=BB=D0=B0=20=D0=BA=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=8F=D0=BC=20=D1=85=D0=B0=D0=B1=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 231 ++++++++++++++++++++++++++++++------------- tests/fake_jira.py | 15 ++- tests/test_py39.py | 2 +- tests/test_runner.py | 149 +++++++++++++++++++++++++++- 4 files changed, 323 insertions(+), 74 deletions(-) diff --git a/src/agent.py b/src/agent.py index bd2fd91..7c45cc8 100644 --- a/src/agent.py +++ b/src/agent.py @@ -11,6 +11,7 @@ Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.m """ import argparse import base64 +import http.client import json import os import signal @@ -70,7 +71,7 @@ class Http: def __repr__(self): return "<%s %s>" % (self.__class__.__name__, self.base) - def call(self, method: str, path: str, body=None, params=None) -> Any: + def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any: url = self.base + path if params: url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) @@ -82,7 +83,7 @@ class Http: if data is not None: req.add_header("Content-Type", "application/json; charset=utf-8") try: - with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp: + with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp: raw = resp.read() status = resp.status except urllib.error.HTTPError as e: @@ -90,12 +91,12 @@ class Http: try: headers = {k.lower(): v for k, v in e.headers.items()} text = e.read().decode("utf-8", "replace") - except (OSError, ConnectionError, TimeoutError) as err: + except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err: raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None finally: e.close() raise HttpError(e.code, text, headers) from None - except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e: + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e: raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None self._touch() try: @@ -181,7 +182,7 @@ class HubClient(Http): def claim(self, agent_version, settings_version, last_jira_call_at) -> dict: return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, - "last_jira_call_at": last_jira_call_at}) + "last_jira_call_at": last_jira_call_at}, timeout=max(self.timeout, 45)) def settings(self) -> dict: return self.call("GET", "/agent/settings") @@ -255,6 +256,10 @@ class Journal: log("журнал: строка %d не читается, пропускаю" % n) return good + def claimed(self, command_id) -> None: + """Команда получена из claim (до паузы): с этого момента хаб считает её выданной агенту.""" + self.append({"kind": "claimed", "command_id": int(command_id)}) + def started(self, command_id) -> None: self.append({"kind": "started", "command_id": int(command_id)}) @@ -276,11 +281,11 @@ class Journal: return [r for cid, r in last.items() if cid not in acked] def unfinished_started(self) -> List[int]: - """Порядковый учёт: started открывает id, finished/acked закрывает; порядок — первое открытие.""" + """Порядковый учёт: claimed/started открывают id, finished/acked закрывает; порядок — первое открытие.""" opened = {} # command_id -> открыта ли сейчас; dict сохраняет порядок первого открытия for r in self.records(): kind, cid = r.get("kind"), r["command_id"] - if kind == "started": + if kind in ("claimed", "started"): opened[cid] = True elif kind in ("finished", "acked") and cid in opened: opened[cid] = False @@ -299,7 +304,7 @@ class Journal: cid = r["command_id"] if r.get("kind") == "acked": last_ack[cid] = i - elif r.get("kind") in ("started", "finished"): + elif r.get("kind") in ("claimed", "started", "finished"): last_open[cid] = i drop_upto = {} # command_id -> индекс последнего acked: всё до него включительно удаляется for cid, i in last_ack.items(): @@ -344,6 +349,10 @@ def _date(value) -> Optional[str]: return (value or "")[:10] or None +class ConfigGap(Exception): + """В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле.""" + + class Executor: def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None): self.jira, self.s, self.hub = jira, settings, hub @@ -359,10 +368,16 @@ class Executor: return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) try: return handler(cmd.get("params") or {}, cmd) - except HttpError as e: - return self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) - except ConnectionError as e: + except ConfigGap as e: return self._out("failed", error=str(e)) + except HttpError as e: + out = self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) + out["retryable"] = e.code >= 500 # исход неизвестен: Runner повторит через проверку (R-10) + return out + except ConnectionError as e: + out = self._out("failed", error=str(e)) + out["retryable"] = True + return out def _out(self, status, result=None, error=None) -> dict: return {"status": status, "result": result, "error": error, @@ -389,19 +404,36 @@ class Executor: it = (self.s.get("issue_types") or {}).get(kind) or {} return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")} - def epic_fields(self, epic_key) -> dict: + def epic_field_id(self) -> str: ep = self.s.get("epic_link") or {} - if epic_key and ep.get("mode", "field") == "field" and ep.get("field_id"): - return {ep["field_id"]: epic_key} - return {} + if ep.get("mode", "field") != "field": + raise ConfigGap("epic_key передан, но epic_link.mode = %r (поддержан только «field»)" % ep.get("mode")) + if not ep.get("field_id"): + raise ConfigGap("epic_key передан, но не настроено epic_link.field_id") + return ep["field_id"] + + def epic_fields(self, epic_key) -> dict: + return {self.epic_field_id(): epic_key} if epic_key else {} + + def need_field(self, logical: str) -> str: + if not self.has_field(logical): + raise ConfigGap("поле передано, но не настроено field_ids.%s" % logical) + return self.fid(logical) # --- ensure_issue ---------------------------------------------------------- def do_ensure_issue(self, p: dict, cmd: dict) -> dict: - jql = "project = %s AND reporter = currentUser() AND created >= -7d" % self.s["project_key"] - found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200) - for it in found.get("issues", []): - if (it.get("fields", {}).get("summary") or "") == p["summary"]: - return self._out("done", {"key": it["key"], "existed": True}) + summary = (p["summary"] or "").strip() + jql = "project = %s AND reporter = currentUser() AND created >= -7d ORDER BY created DESC" % self.s["project_key"] + start = 0 + while start < 1000: + found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200, startAt=start) + issues = found.get("issues", []) + for it in issues: + if (it.get("fields", {}).get("summary") or "").strip() == summary: + return self._out("done", {"key": it["key"], "existed": True}) + start += len(issues) + if not issues or start >= int(found.get("total") or 0): + break fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]), "summary": p["summary"]} if p.get("description") is not None: @@ -411,8 +443,8 @@ class Executor: if p.get("duedate"): fields[self.fid("duedate")] = p["duedate"] for logical in ("planned_start", "planned_end"): - if p.get(logical) and self.has_field(logical): - fields[self.fid(logical)] = p[logical] + if p.get(logical): + fields[self.need_field(logical)] = p[logical] if p.get("labels") is not None: fields[self.fid("labels")] = list(p["labels"]) if p.get("assignee"): @@ -437,17 +469,22 @@ class Executor: if "priority" in p: want = self.priority_value(p["priority"]) vkey = list(want)[0] - if ((cur.get(self.fid("priority")) or {}).get(vkey)) != want[vkey]: + have = (cur.get(self.fid("priority")) or {}).get(vkey) + if (None if have is None else str(have)) != str(want[vkey]): fields[self.fid("priority")] = want changed.append("priority") for logical in ("duedate", "planned_start", "planned_end"): - if logical in p and self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]): + if logical not in p: + continue + if logical != "duedate" and p[logical] and not self.has_field(logical): + self.need_field(logical) + if self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]): fields[self.fid(logical)] = p[logical] changed.append(logical) - if "epic_key" in p: - ep = self.s.get("epic_link") or {} - if ep.get("field_id") and (cur.get(ep["field_id"]) or None) != (p["epic_key"] or None): - fields[ep["field_id"]] = p["epic_key"] + if "epic_key" in p and (p["epic_key"] or (self.s.get("epic_link") or {}).get("field_id")): + fid = self.epic_field_id() + if (cur.get(fid) or None) != (p["epic_key"] or None): + fields[fid] = p["epic_key"] changed.append("epic_key") if "assignee" in p and ((cur.get("assignee") or {}).get("name") or None) != (p["assignee"] or None): fields["assignee"] = {"name": p["assignee"]} if p["assignee"] else None @@ -467,12 +504,19 @@ class Executor: # --- ensure_comment -------------------------------------------------------- def do_ensure_comment(self, p: dict, cmd: dict) -> dict: key, marker, body = p["key"], p["marker"], p["body"] - if marker not in body[:80]: + heads = (marker, "*%s.*" % marker) # маркер — только в начале тела (формат агента: «*маркер.* текст») + if not body.startswith(heads): body = "*%s.* %s" % (marker, body) - existing = None - for c in self.jira.get(API + "/issue/%s/comment" % key).get("comments", []): - if marker in (c.get("body") or "")[:80]: - existing = c + existing, start = None, 0 + while existing is None: + page = self.jira.get(API + "/issue/%s/comment" % key, startAt=start, maxResults=100) + comments = page.get("comments", []) + for c in comments: + if (c.get("body") or "").startswith(heads): + existing = c + break + start += len(comments) + if not comments or page.get("total") is None or start >= int(page["total"]): break if existing and (existing.get("body") or "") == body: return self._out("done", {"comment_id": existing["id"], "changed": False}) @@ -740,7 +784,7 @@ class Executor: chunk = issues[i:i + 100] chunk_keys = {x["key"] for x in chunk} pages += 1 - self.hub.state({"command_id": cmd.get("id"), "issues": chunk, + self.hub.state({"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk, "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys], "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], "page": pages, "last_page": i + 100 >= len(issues)}) @@ -754,6 +798,9 @@ class Executor: # Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте # --------------------------------------------------------------------------- class Runner: + RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Jira / 5xx (R-10) + RETRY_MIN_WAIT = 30 # секунд до повтора + def __init__(self, cfg: dict, hub: HubClient, jira: JiraClient, journal: Journal): self.cfg, self.hub, self.jira, self.journal = cfg, hub, jira, journal self.settings = None # type: Optional[dict] @@ -766,6 +813,14 @@ class Runner: def poll(self) -> float: return max(float(self.cfg.get("poll_seconds", 5) or 0), 0) + def hub_wait(self, e: Exception, base: float) -> float: + """Залогировать сбой хаба и вернуть, сколько ждать. 401/403 — токен неверен: повторять часто бессмысленно.""" + if isinstance(e, HttpError) and e.code in (401, 403): + log("хаб: HTTP %d — неверный hub_token в agent.json, повтор через 60 с" % e.code) + return 60 + log("хаб: %s, повтор через %d с" % (e, base)) + return base + def load_settings(self) -> None: self.settings = self.hub.settings() self.executor = Executor(self.jira, self.settings, self.hub) @@ -777,7 +832,8 @@ class Runner: или удалена): считаем закрытой, чтобы не зациклиться. При остановке по сигналу делается одна попытка.""" cid = rec["command_id"] payload = {"status": rec["status"], "result": rec.get("result"), "error": rec.get("error"), - "jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0)} + "jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0), + "agent_version": AGENT_VERSION} while True: try: self.hub.result(cid, payload) @@ -788,43 +844,65 @@ class Runner: log("хаб отверг отчёт по #%s: %s — считаю закрытым" % (cid, e)) self.journal.acked(cid) return True - log("хаб: %s, повтор через 10 с" % e) + wait = self.hub_wait(e, 10) except ConnectionError as e: - log("хаб недоступен: %s, повтор через 10 с" % e) + wait = self.hub_wait(e, 10) if self.stop: log("остановка: отчёт по #%s не подтверждён, будет дослан при следующем старте" % cid) return False - self.sleep(10) + self.sleep(wait) - def wait_pause(self, pause_seconds) -> bool: - """Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы.""" + def wait_pause(self, pause_seconds, force=False) -> bool: + """Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы. + force: если вызовов Jira ещё не было, считать от текущего момента.""" pause = float(pause_seconds or 0) - if pause > 0 and self.jira.last_call_at is not None: - remaining = pause - (time.monotonic() - self.jira.last_call_at) + if pause > 0 and (self.jira.last_call_at is not None or force): + since = time.monotonic() - self.jira.last_call_at if self.jira.last_call_at is not None else 0 + remaining = pause - since if remaining > 0: - log(" пауза %.0f с до команды" % remaining) + log(" пауза %.0f с" % remaining) while remaining > 0 and not self.stop: step = min(remaining, 1.0) self.sleep(step) remaining -= step return not self.stop - def execute(self, cmd: dict) -> None: - log("#%s %s %s" % (cmd["id"], cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200])) - if not self.wait_pause(cmd.get("pause_seconds")): - return - self.journal.started(cmd["id"]) # started — только после паузы + def run_once(self, cmd: dict) -> dict: try: - out = self.executor.run(cmd) + return self.executor.run(cmd) except Exception as e: # баг в обработчике не должен ронять цикл и зацикливать восстановление log(" ! необработанная ошибка: %r" % e) - out = {"status": "failed", "result": None, + return {"status": "failed", "result": None, "error": "внутренняя ошибка агента: %s: %s" % (type(e).__name__, str(e)[:300]), "jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited} - self.journal.finished(cmd["id"], out["status"], out.get("result"), out.get("error"), out["jira_calls"], out["rate_limited"]) + + def execute(self, cmd: dict) -> None: + cid = cmd["id"] + log("#%s %s %s" % (cid, cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200])) + self.journal.claimed(cid) # сразу после claim, до паузы (R-9): остановка не теряет команду + if not self.wait_pause(cmd.get("pause_seconds")): + return + self.journal.started(cid) # started — только после паузы + calls = limited = attempt = 0 + while True: + out = self.run_once(cmd) + calls += out["jira_calls"] + limited += out["rate_limited"] + if not out.pop("retryable", False): + break + if attempt >= self.RETRY_ATTEMPTS: + out = {"status": "failed", "result": None, + "error": "результат неизвестен (обрыв связи с Jira), повтор безопасен: %s" % out.get("error")} + break + attempt += 1 + log(" ! обрыв связи с Jira, повтор %d/%d через проверку: %s" % (attempt, self.RETRY_ATTEMPTS, out.get("error"))) + if not self.wait_pause(max(float(cmd.get("pause_seconds") or 0), self.RETRY_MIN_WAIT), force=True): + return # остановка: finished не пишем, команду подхватит recover + out["jira_calls"], out["rate_limited"] = calls, limited + self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited) log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))) rec = dict(out) - rec["command_id"] = cmd["id"] + rec["command_id"] = cid self.send_result(rec) def fetch_command(self, cid): @@ -836,12 +914,12 @@ class Runner: if e.code in (404, 409, 422): log("#%s: хаб ответил %s" % (cid, e)) return None - log("хаб: %s, повтор через 10 с" % e) + wait = self.hub_wait(e, 10) except ConnectionError as e: - log("хаб недоступен: %s, повтор через 10 с" % e) + wait = self.hub_wait(e, 10) if self.stop: raise InterruptedError(cid) - self.sleep(10) + self.sleep(wait) def recover(self) -> None: for rec in self.journal.unacked_finished(): @@ -861,10 +939,28 @@ class Runner: log("#%s уже %s в хабе — пропускаю" % (cid, cmd.get("status"))) self.journal.acked(cid) continue - log("#%s начата до перезапуска — выполняю заново через проверку" % cid) + log("#%s выдана до перезапуска — выполняю через проверку" % cid) cmd["pause_seconds"] = 0 self.execute(cmd) + def reload_settings_before(self, cmd) -> bool: + """Настройки сменились: перечитать ДО выполнения. False — остановка, команда не должна выполняться + (если она уже выдана хабом, фиксируем claimed — подхватит recover).""" + while True: + try: + self.load_settings() + return True + except (ConnectionError, HttpError) as e: + wait = self.hub_wait(e, max(self.poll, 1)) + if self.stop: + if cmd: + self.journal.claimed(cmd["id"]) + return False + if not cmd: + self.sleep(wait) + return False + self.sleep(wait) + def loop(self) -> None: idle, recovered = 0, False while not self.stop: @@ -872,8 +968,7 @@ class Runner: try: self.load_settings() except (ConnectionError, HttpError) as e: - log("хаб: %s" % e) - self.sleep(self.poll) + self.sleep(self.hub_wait(e, max(self.poll, 1))) continue if not recovered: self.recover() @@ -882,15 +977,12 @@ class Runner: try: resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso) except (ConnectionError, HttpError) as e: - log("хаб: %s" % e) - self.sleep(self.poll) + self.sleep(self.hub_wait(e, max(self.poll, 1))) continue - if resp.get("settings_version") != self.settings.get("settings_version"): - try: - self.load_settings() # настройки — до выполнения команды - except (ConnectionError, HttpError) as e: - log("хаб: не перечитать настройки: %s" % e) cmd = resp.get("command") + if resp.get("settings_version") != self.settings.get("settings_version"): + if not self.reload_settings_before(cmd): + continue if cmd: idle = 0 self.execute(cmd) @@ -905,11 +997,16 @@ def cmd_run(cfg: dict, journal_path: str) -> int: runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path)) def on_signal(signum, frame): + try: + if runner.stop: + log("повторный сигнал — выхожу немедленно") + else: + log("сигнал %s: доделываю текущую команду и выхожу" % signum) + except RuntimeError: # print внутри обработчика, прервавшего другой print (reentrant call) + pass if runner.stop: - log("повторный сигнал — выхожу немедленно") sys.exit(130) runner.stop = True - log("сигнал %s: доделываю текущую команду и выхожу" % signum) signal.signal(signal.SIGINT, on_signal) signal.signal(signal.SIGTERM, on_signal) diff --git a/tests/fake_jira.py b/tests/fake_jira.py index b4efb55..05add91 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -2,6 +2,7 @@ только маршруты, которые использует агент. Семантика связей — как у настоящего Jira Server.""" import json import re +import socket import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse @@ -31,6 +32,8 @@ class FakeJira: self.id_seq = 10000 self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой self.requests = [] # [(метод, путь)] + self.queries = [] # параметры запросов (q) — для проверки JQL + self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа self.lock = threading.Lock() fake = self @@ -104,7 +107,8 @@ class FakeJira: keys = [k.strip() for k in m.group(1).split(",")] return [k for k in keys if k in self.issues] if "reporter = currentUser()" in (jql or ""): - return [k for k, f in self.issues.items() if f["reporter"] == "me"] + keys = [k for k, f in self.issues.items() if f["reporter"] == "me"] + return keys[::-1] if "ORDER BY created DESC" in jql else keys return [k for k, f in self.issues.items() if "AWPCA" in f["labels"]] # --- маршрутизация ---------------------------------------------------------- @@ -115,6 +119,7 @@ class FakeJira: body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} with self.lock: self.requests.append((method, url.path)) + self.queries.append(q) if self.fail_next: code, headers = self.fail_next.pop(0) return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers) @@ -124,6 +129,11 @@ class FakeJira: code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} except Exception as e: # некорректный запрос к фейку code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]} + if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue": + self.drop_next_post_issue = False + h.close_connection = True + h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит + return self._send(h, code, payload) def _send(self, h, code, payload, headers=None): @@ -193,7 +203,8 @@ class FakeJira: self.issues[key] lst = self.comments.setdefault(key, []) if method == "GET": - return 200, {"comments": lst} + start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 1000)) + return 200, {"startAt": start, "maxResults": maxr, "total": len(lst), "comments": lst[start:start + maxr]} self.comment_seq += 1 lst.append({"id": str(self.comment_seq), "body": body["body"]}) return 201, lst[-1] diff --git a/tests/test_py39.py b/tests/test_py39.py index 8235b6c..170aa5c 100644 --- a/tests/test_py39.py +++ b/tests/test_py39.py @@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase): def test_no_third_party_imports(self): with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: tree = ast.parse(f.read()) - allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "sys", "time", "urllib", "datetime", "typing"} + allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing"} for node in ast.walk(tree): if isinstance(node, ast.Import): for a in node.names: diff --git a/tests/test_runner.py b/tests/test_runner.py index f62d905..6e62b24 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -47,7 +47,7 @@ class RunnerTest(unittest.TestCase): self.assertEqual(self.hub.results[2]["status"], "done") self.assertEqual(len(self.hub.states), 1) self.assertEqual(self.hub.states[0]["issues"][0]["duedate"], "2026-10-23") - self.assertEqual(self.kinds(), [("started", 1), ("finished", 1), ("acked", 1), ("started", 2), ("finished", 2), ("acked", 2)]) + self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)]) self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0 def test_pause_waits_from_last_jira_call(self): @@ -141,10 +141,41 @@ class RunnerTest(unittest.TestCase): self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5) self.runner.sleep = lambda s: setattr(self.runner, "stop", True) self.runner.loop() - self.assertEqual(self.journal.records(), []) + self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"]) # started нет self.assertEqual(self.hub.results, {}) self.assertEqual(self.hub.commands[1]["status"], "claimed") + def test_stop_during_pause_then_restart_executes_once(self): + self.runner.jira.last_call_at = time.monotonic() + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5) + self.runner.sleep = lambda s: setattr(self.runner, "stop", True) + self.runner.loop() + self.assertEqual(self.journal.unfinished_started(), [1]) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + second = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), agent.Journal(self.journal.path)) + second.sleep = self.sleeps.append + second.idle_limit = 1 + second.loop() + self.assertEqual(self.hub.results[1]["status"], "done") + self.assertEqual(len(self.fake.writes()), 1) + self.assertEqual(self.journal.unfinished_started(), []) + self.assertEqual(self.sleeps, []) # при восстановлении пауза не применяется + + def test_stop_between_claim_and_execute_is_recorded(self): + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + real = self.runner.hub.claim + + def claim_then_stop(*a): + resp = real(*a) + self.runner.stop = True + return resp + + self.runner.hub.claim = claim_then_stop + self.runner.loop() + self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"]) + self.assertEqual(self.fake.writes(), []) + self.assertEqual(self.journal.unfinished_started(), [1]) + def test_stop_during_execution_finishes_and_reports(self): self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) self.hub.enqueue("refresh", {"keys": ["AURORA-1"]}) @@ -159,7 +190,7 @@ class RunnerTest(unittest.TestCase): self.runner.loop() self.assertEqual(self.hub.results[1]["status"], "done") self.assertNotIn(2, self.hub.results) # вторая команда не начата - self.assertEqual(self.kinds(), [("started", 1), ("finished", 1), ("acked", 1)]) + self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1)]) def test_stop_during_execution_hub_down_single_attempt(self): self.hub.fail_results = 5 @@ -231,7 +262,117 @@ class RunnerTest(unittest.TestCase): self.runner.hub.settings = hub_settings self.runner.loop() self.assertEqual(len(calls), 3) - self.assertEqual(self.sleeps, [0, 0]) + self.assertEqual(self.sleeps, [1, 1]) # пол паузы на ошибках хаба — 1 с + + def test_retry_after_connection_drop_creates_one_issue(self): + self.fake.drop_next_post_issue = True + self.hub.enqueue("ensure_issue", {"temp_key": "new:1", "issue_type": "task", "summary": "Новая задача"}) + self.runner.loop() + r = self.hub.results[1] + self.assertEqual(r["status"], "done") + self.assertEqual(r["result"]["existed"], True) + self.assertEqual(len([k for k in self.fake.issues if k != "AURORA-1"]), 1) + self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # ожидание 30 с перед повтором + self.assertEqual(r["jira_calls"], 2) # поиск + повторный поиск (оборванный ответ не считается) + self.assertEqual([k for k, _ in self.kinds()].count("started"), 1) + + def test_retry_exhausted_gives_failed_unknown(self): + self.runner.load_settings() + runs = [] + + def down(cmd): + runs.append(1) + return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 1, "rate_limited": 0, "retryable": True} + + self.runner.executor.run = down + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertEqual(len(runs), 4) # первый запуск + 3 повтора + r = self.hub.results[1] + self.assertEqual(r["status"], "failed") + self.assertIn("результат неизвестен", r["error"]) + self.assertEqual(r["jira_calls"], 4) + + def test_stop_during_retry_wait_leaves_command_for_recovery(self): + self.runner.load_settings() + self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "x", "jira_calls": 1, + "rate_limited": 0, "retryable": True} + self.runner.sleep = lambda s: setattr(self.runner, "stop", True) + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertEqual(self.journal.unfinished_started(), [1]) + self.assertEqual(self.hub.results, {}) + + def test_agent_version_in_result(self): + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertEqual(self.hub.results[1]["agent_version"], agent.AGENT_VERSION) + + def test_hub_auth_error_waits_60(self): + def denied(*a): + raise agent.HttpError(401, "unauthorized") + + self.runner.hub.claim = denied + self.runner.sleep = lambda s: (self.sleeps.append(s), setattr(self.runner, "stop", True)) + self.runner.loop() + self.assertEqual(self.sleeps, [60]) + + def test_wrong_hub_token_401_real_hub(self): + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token="bad") + r = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), self.journal) + r.sleep = lambda s: (self.sleeps.append(s), setattr(r, "stop", True)) + r.loop() + self.assertEqual(self.sleeps, [60]) + + def test_settings_reload_failure_retried_before_command(self): + self.runner.load_settings() + self.hub.settings["settings_version"] = 2 + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + real = self.runner.hub.settings + calls = [] + + def flaky(): + calls.append(1) + if len(calls) < 3: + raise ConnectionError("нет связи") + return real() + + self.runner.hub.settings = flaky + self.runner.loop() + self.assertEqual(len(calls), 3) + self.assertEqual(self.sleeps, [1, 1]) + self.assertEqual(self.runner.settings["settings_version"], 2) + self.assertEqual(self.hub.results[1]["status"], "done") + + def test_claim_timeout_at_least_45(self): + seen = [] + real = agent.Http.call + agent.Http.call = lambda self_, m, p, body=None, params=None, timeout=None: (seen.append(timeout), {"command": None, "settings_version": 1})[1] + try: + agent.HubClient(jira_cfg(self.fake, hub_url="http://x", hub_token="t", timeout=5)).claim(1, 1, None) + finally: + agent.Http.call = real + self.assertEqual(seen, [45]) + + def test_signal_handler(self): + import signal + from unittest import mock + handlers = {} + seen = {} + + def fake_loop(runner): + handlers[signal.SIGINT](signal.SIGINT, None) + seen["stop_after_first"] = runner.stop + with self.assertRaises(SystemExit) as cm: + handlers[signal.SIGINT](signal.SIGINT, None) + seen["code"] = cm.exception.code + + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + with mock.patch.object(agent.signal, "signal", lambda n, h: handlers.__setitem__(n, h)), \ + mock.patch.object(agent.Runner, "loop", fake_loop): + agent.cmd_run(cfg, self.journal.path) + self.assertTrue(seen["stop_after_first"]) + self.assertEqual(seen["code"], 130) if __name__ == "__main__": From 2e6f47a51405536646b6b8206fce209bd1cee5c5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:35:06 +0400 Subject: [PATCH 27/30] =?UTF-8?q?fix(agent):=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=20=D0=B4=D1=83=D0=B1=D0=BB=D1=8F=20=D1=81=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=B3=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D0=B5=D0=B9,=20=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA=20(R-11),=20HTTPException=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2,=20=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D0=BA=D0=B5=D1=80=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D1=8F,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- tests/fake_jira.py | 2 +- tests/test_clients.py | 31 ++++++++++++ tests/test_executor_fields.py | 93 +++++++++++++++++++++++++++++++++++ tests/test_refresh.py | 14 ++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 05add91..bef6359 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -86,7 +86,7 @@ class FakeJira: def issue_json(self, key): f = self.issues[key] fields = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]}, - "priority": {"name": f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]), + "priority": {"name": f["priority"], "id": int(f["priority"]) if str(f["priority"]).isdigit() else f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]), "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} fields.update({c: f.get(c) for c in CUSTOM}) diff --git a/tests/test_clients.py b/tests/test_clients.py index eb1ae8b..9467b80 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -89,6 +89,37 @@ class ClientsTest(unittest.TestCase): self.jira.get("/x") self.assertEqual(self.jira.calls, 1) + def test_incomplete_read_is_connection_error(self): + import http.client + from unittest import mock + with mock.patch("urllib.request.urlopen", side_effect=http.client.IncompleteRead(b"ab", 10)): + with self.assertRaises(ConnectionError): + self.jira.get("/x") + + def test_incomplete_read_inside_response_body(self): + import socket + import threading + srv = socket.socket() + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + + def serve(): + c, _ = srv.accept() + c.recv(4096) + c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{\"a\":") + c.close() + + t = threading.Thread(target=serve, daemon=True) + t.start() + cfg = jira_cfg(self.fake) + cfg["jira"]["base_url"] = "http://127.0.0.1:%d" % srv.getsockname()[1] + try: + with self.assertRaises(ConnectionError): + agent.JiraClient(cfg).get("/x") + finally: + t.join(2) + srv.close() + def test_bearer_header(self): cfg = jira_cfg(self.fake) cfg["jira"]["auth"] = {"type": "bearer", "token": "tok123"} diff --git a/tests/test_executor_fields.py b/tests/test_executor_fields.py index 5dd26f5..8d29b78 100644 --- a/tests/test_executor_fields.py +++ b/tests/test_executor_fields.py @@ -104,6 +104,99 @@ class ExecutorFieldsTest(unittest.TestCase): def test_unknown_type(self): self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed") + # --- ensure_issue: поиск дубля ----------------------------------------------- + def test_ensure_issue_finds_on_later_page(self): + self.fake.add("AURORA-500", reporter="me", summary="Нужная задача") + for i in range(250): + self.fake.add("AURORA-%d" % (600 + i), reporter="me", summary="Другая %d" % i) + out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача") + self.assertEqual(out["result"], {"key": "AURORA-500", "existed": True}) + searches = [q for q in self.fake.queries if "jql" in q] + self.assertEqual(len(searches), 2) # вторая страница + self.assertIn("reporter = currentUser()", searches[0]["jql"]) + self.assertIn("created >= -7d", searches[0]["jql"]) + self.assertIn("ORDER BY created DESC", searches[0]["jql"]) + self.assertEqual(searches[0]["fields"], "summary") + self.assertEqual(self.fake.writes(), []) + + def test_ensure_issue_substring_is_not_duplicate_and_strip(self): + self.fake.add("AURORA-500", reporter="me", summary="Нужная задача (старая)") + self.fake.add("AURORA-501", reporter="me", summary=" Точная тема \n") + out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача") + self.assertTrue(out["result"]["created"]) + out = self.run_cmd("ensure_issue", temp_key="n:2", issue_type="task", summary="Точная тема") + self.assertEqual(out["result"], {"key": "AURORA-501", "existed": True}) + + def test_http_error_becomes_failed(self): + self.fake.fail_next.append((400, {})) + out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="x") + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + self.fake.add("AURORA-1") + self.fake.fail_next.append((400, {})) + out = self.run_cmd("ensure_comment", key="AURORA-1", marker="М", body="текст") + self.assertEqual(out["status"], "failed") + + # --- настройки (R-11) -------------------------------------------------------- + def with_settings(self, **over): + s = dict(SETTINGS) + s.update(over) + self.ex = agent.Executor(self.jira, s) + self.ex.sleep = no_sleep + + def test_epic_key_without_field_id_fails(self): + self.fake.add("AURORA-1") + self.with_settings(epic_link={"mode": "field"}) + for ctype, params in (("ensure_issue", dict(temp_key="n", issue_type="task", summary="Э", epic_key="AURORA-9")), + ("ensure_fields", dict(key="AURORA-1", epic_key="AURORA-9"))): + out = self.run_cmd(ctype, **params) + self.assertEqual(out["status"], "failed") + self.assertIn("epic_link.field_id", out["error"]) + self.assertEqual(self.fake.writes(), []) + + def test_epic_key_with_link_mode_fails_same_in_both(self): + self.fake.add("AURORA-1") + self.with_settings(epic_link={"mode": "link", "field_id": "customfield_15302"}) + a = self.run_cmd("ensure_issue", temp_key="n", issue_type="task", summary="Э2", epic_key="AURORA-9") + b = self.run_cmd("ensure_fields", key="AURORA-1", epic_key="AURORA-9") + self.assertEqual((a["status"], b["status"]), ("failed", "failed")) + self.assertIn("epic_link.mode", a["error"]) + self.assertIn("epic_link.mode", b["error"]) + + def test_planned_without_field_id_fails(self): + self.fake.add("AURORA-1") + ids = dict(SETTINGS["field_ids"]) + del ids["planned_start"] + self.with_settings(field_ids=ids) + a = self.run_cmd("ensure_issue", temp_key="n", issue_type="task", summary="П", planned_start="2026-10-20") + b = self.run_cmd("ensure_fields", key="AURORA-1", planned_start="2026-10-20") + for out in (a, b): + self.assertEqual(out["status"], "failed") + self.assertIn("field_ids.planned_start", out["error"]) + self.assertEqual(self.fake.writes(), []) + + # --- комментарии ------------------------------------------------------------- + def test_comment_marker_only_at_start(self): + self.fake.add("AURORA-1") + self.fake.comments["AURORA-1"] = [{"id": "1", "body": "Обсуждали, см. Актуализация 10.10.2026 в письме"}] + out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="новое") + self.assertTrue(out["result"]["created"]) # маркер в середине чужого комментария — не наш + again = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="новое") + self.assertEqual(again["result"]["changed"], False) + + def test_comment_found_on_later_page(self): + self.fake.add("AURORA-1") + self.fake.comments["AURORA-1"] = [{"id": str(i), "body": "чужой %d" % i} for i in range(1, 121)] + self.fake.comments["AURORA-1"].append({"id": "999", "body": "*Маркер.* старое"}) + out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Маркер", body="новое") + self.assertEqual(out["result"], {"comment_id": "999", "changed": True}) + + def test_priority_compare_by_id_as_string(self): + self.fake.add("AURORA-1", priority="3") + self.with_settings(priority_value_key="id", priority_map={}) + out = self.run_cmd("ensure_fields", key="AURORA-1", priority="3") + self.assertEqual(out["result"], {"changed": []}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 2fe2075..b6c16b6 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -117,6 +117,20 @@ class RefreshTest(unittest.TestCase): out = self.ex.run({"id": 16, "type": "refresh", "params": {"keys": keys}}) self.assertEqual(out["result"]["missing"], ["GONE-%d" % i for i in range(1, 51)]) + def test_missing_not_duplicated_when_also_a_neighbor(self): + self.fake.add("AURORA-1") + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "GONE-1"}) + out = self.ex.run({"id": 17, "type": "refresh", "params": {"keys": ["AURORA-1", "GONE-1"]}}) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"]["missing"], ["GONE-1"]) + + def test_input_keys_deduplicated(self): + self.fake.add("AURORA-1") + out = self.ex.run({"id": 18, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-1", "", "AURORA-1"]}}) + self.assertEqual(out["result"], {"issues": 1, "neighbors": 0, "pages": 1}) + self.assertEqual(len(self.hub.states[0]["issues"]), 1) + self.assertEqual(self.hub.states[0]["agent_version"], agent.AGENT_VERSION) + def test_deleted_neighbor(self): self.fake.add("AURORA-1") self.fake.add("AURORA-2", in_base=False) From 22018318d1f1afa46f88352679cfa481c9f5797e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:36:46 +0400 Subject: [PATCH 28/30] =?UTF-8?q?docs(agent):=20=D0=B6=D1=83=D1=80=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=20claimed,=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=B8=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B8,=20=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B2=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5,=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F,=203?= =?UTF-8?q?.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- ARCHITECTURE.md | 2 +- CLAUDE.md | 4 ++-- README.md | 31 +++++++++++++++++++------------ tests/test_py39.py | 4 ++-- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a93867a..504d953 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Архитектура Агент — один файл `src/agent.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках -Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, план: `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`. +Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, план агента: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md` (план хаба: `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`). ## Разделы файла `src/agent.py` diff --git a/CLAUDE.md b/CLAUDE.md index cb306a8..0d1af54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ ## Стек и ограничения - Python 3.9.6 (как на виртуалке), только стандартная библиотека, без venv и зависимостей. - Запрещено: синтаксис 3.10+ и сторонние импорты. - `tests/test_py39.py` ловит синтаксис 3.10+ (match и т. п.) и сторонние импорты; `X | Y` в аннотациях и API новее 3.9 подтверждает только прогон на /usr/bin/python3 3.9.6. + `tests/test_py39.py` ловит синтаксис 3.10+ (match и т. п.) и сторонние импорты; `X | Y` — синтаксис 3.10: в 3.9 аннотации функций вычисляются при импорте, и прогон тестов на /usr/bin/python3 3.9.6 это ловит; аннотации локальных переменных не вычисляются, их и API новее 3.9 проверяет только ревью. - `agent.json` содержит учётные данные Jira — не коммитить, не отправлять в хаб, не выносить с виртуалки. Шаблон без секретов — `src/agent.example.json`. @@ -42,7 +42,7 @@ ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, журнал, восстановление после перезапуска, CLI `run|check|prune`. -План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`. +План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`. ## OpenSpec и superpowers - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. diff --git a/README.md b/README.md index fc6b291..258b960 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ nohup python3 -u agent.py > agent.log 2>&1 & ``` Остановка: Ctrl-C или `kill `. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. -Повторный сигнал — немедленный выход (код 130). Команда, которая не успела записаться в журнал `started`, при этом -остаётся `claimed` в хабе (см. ограничение 2). +Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже +записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку (без паузы). ## Автозапуск через launchd (опционально) @@ -95,11 +95,12 @@ launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist ## Журнал -`journal.jsonl` лежит рядом с `agent.py`. Это только дозапись: на каждую команду пишутся `started` (после паузы, -перед обращением к Jira), `finished` (результат и счётчики вызовов) и `acked` (хаб подтвердил приём отчёта). -По журналу агент после перезапуска досылает неподтверждённые отчёты и находит команды, начатые, но не завершённые. +`journal.jsonl` лежит рядом с `agent.py`. Это только дозапись: на каждую команду пишутся `claimed` (сразу после получения +из хаба, до паузы), `started` (после паузы, перед обращением к Jira), `finished` (результат и счётчики вызовов) и `acked` (хаб подтвердил приём отчёта). +По журналу агент после перезапуска досылает неподтверждённые отчёты и находит команды, полученные или начатые, но не завершённые. -Журнал содержит параметры команд, то есть тексты задач и комментариев. Не выносить его с виртуалки. +В журнале нет параметров команд (текстов задач и комментариев): только id команды, статус, `result`, `error` и счётчики +вызовов. Но `result` и `error` могут содержать ключи задач и фрагменты ответов Jira, поэтому журнал лучше не выносить с виртуалки. Очистка подтверждённых записей старше 30 дней: @@ -131,8 +132,13 @@ python3 agent.py prune - **Отчёт до подтверждения.** Отчёт повторяется каждые 10 с, пока хаб не подтвердит. Ответы 404/409/422 хаб выдаёт, когда команда уже отменена или удалена: агент считает её закрытой и не зацикливается. - **429 и 503.** Агент ждёт `Retry-After` из ответа Jira и повторяет запрос: до 5 запросов, то есть не более 4 ожиданий; - на пятой неудаче — ошибка команды. Если заголовка нет или он в формате HTTP-date (не разбирается), ждёт 60 с, умноженные на номер попытки. Число ожиданий идёт в `rate_limited` отчёта. -- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 1) отправляется в каждом `claim`. + на пятой неудаче — ошибка команды. Если заголовка нет или он в формате HTTP-date (не разбирается), ждёт 60 с, умноженные на номер попытки. Число ответов 429/503 идёт в `rate_limited` отчёта. +- **Обрыв связи с Jira (R-10).** Если Jira оборвала соединение или ответила 5xx, исход записи неизвестен: агент не пишет + `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики + суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке + по сигналу команду подхватит восстановление. +- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 1) отправляется в каждом `claim`, в каждом отчёте + (`agent_version`) и в `/agent/state`. Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды при этом выдаются по-прежнему. @@ -157,9 +163,10 @@ python3 agent.py prune 1. **`prune` не запускать при работающем агенте.** Блокировки нет. Запись, добавленная агентом во время `prune`, может пропасть при перезаписи файла. Сначала остановить агента. -2. **Ctrl-C во время паузы перед командой.** Команда остаётся `claimed` в хабе, в журнале о ней ничего нет. - Хаб сам её не переиздаёт. Лечение: в хабе отменить команду с force и повторить: - `POST /commands/{id}/cancel` с телом `{"force": true}`, затем `POST /commands/{id}/retry`. +2. **Остановка во время паузы или между claim и выполнением (R-9).** Команда остаётся `claimed` в хабе, но в журнале + есть запись `claimed`: при перезапуске агент запрашивает её у хаба и выполняет без паузы. Отмена с force и повтор + больше не нужны. Если журнал потерян, команду придётся отменить с force (`POST /commands/{id}/cancel`, + `{"force": true}`) и повторить (`POST /commands/{id}/retry`). 3. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url` должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают. 4. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по @@ -171,4 +178,4 @@ python3 agent.py prune записи. Наблюдать на реальной Jira. 7. **429/503 без потолка.** Агент ждёт `Retry-After` в секундах сколько бы он ни был (без верхней границы): не более 4 ожиданий, на пятой неудаче — ошибка. Длинный `Retry-After` держит очередь. Формат HTTP-date в `Retry-After` не разбирается: - тогда ждётся 60 с × номер попытки. + тогда ждётся 60 с × номер попытки. Нецелый `Retry-After` (например, `1.5`) тоже не разбирается: те же 60 с × попытка. diff --git a/tests/test_py39.py b/tests/test_py39.py index 170aa5c..c846b4c 100644 --- a/tests/test_py39.py +++ b/tests/test_py39.py @@ -5,8 +5,8 @@ import unittest from _common import SRC # Ограничение проверки: ast.parse(feature_version=(3, 9)) ловит синтаксис 3.10+ (match и т. п.) и сторонние -# импорты; API и конструкции, новые в 3.9 (например, `X | Y` в аннотациях), не проверяет. Их подтверждает только -# прогон на /usr/bin/python3 (3.9.6). +# импорты; API и конструкции новее 3.9 (например, `X | Y` — синтаксис 3.10: аннотации функций +# вычисляются при импорте и их ловит прогон на /usr/bin/python3 3.9.6, аннотации локальных переменных не вычисляются), не проверяет. class Py39Test(unittest.TestCase): From 529d7f3b2d5a4184d9de42e9d04a1c813179eef8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:41:04 +0400 Subject: [PATCH 29/30] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-13=20(F-1=20deferred)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index bb96a13..9350dce 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -18,3 +18,4 @@ | 10 | 2026-10-12 | `Runner.execute` повторяет команду до 3 раз через проверку при ConnectionError/HTTP 5xx, не записывая `finished`; затем `failed` «результат неизвестен, повтор безопасен» (R-10) | Неоднозначный исход записи в Jira (обрыв после POST) нельзя отдавать как `failed`: для `ensure_issue` хаб пропустит зависимые команды; все команды идемпотентны | Лишние задержки при недоступной Jira | | 11 | 2026-10-12 | Запрошенное, но не настроенное поле (epic_key, planned_start/end) даёт `failed`, а не молчаливый `done` (R-11) | Ложный успех хаб запишет в зеркало как выполненный | Команды падают, пока хаб не настроен | | 12 | 2026-10-12 | `agent_version` добавляется в отчёт и в `/agent/state` (R-12) | Спека: версия «в каждом отчёте»; хаб лишние поля игнорирует | Нет | +| 13 | 2026-10-12 | F-1 (повтор по R-10 не ждёт 30 с, если Jira недоступна с первого вызова и `last_call_at` старый) оставлен как есть, deferred (R-13) | Вторая волна фиксов не предусмотрена процессом; последствие — три быстрых повтора и `failed «результат неизвестен», повтор безопасен`: без потери данных и без дублей (поиск дубля идемпотентен) | Недоступная Jira даст `failed` за секунды вместо ~90 с; лечится фиксированным сном `max(pause,30)` от момента отказа в ветке retry | From 527fd89be0255d1b049807d9aa2d5da7bc01a17b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:51:07 +0400 Subject: [PATCH 30/30] =?UTF-8?q?fix(runner):=20=D0=BF=D0=BE=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=20Jira=20=D0=B6=D0=B4=D1=91=D1=82=2030?= =?UTF-8?q?=20=D1=81=20=D0=BE=D1=82=20=D0=BC=D0=BE=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0=20(F-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обрыв соединения не обновляет last_call_at, поэтому пауза перед повтором, считавшаяся от последнего успешного вызова, при старом значении была нулевой и три повтора шли подряд. Ожидание вынесено в Runner.wait_seconds и для повтора считается от момента отказа; wait_pause использует его же и больше не принимает force. Запись R-13 в design.md обновлена. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 2 +- src/agent.py | 29 +++++++++++++++----------- tests/test_runner.py | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 9350dce..3eec1c0 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -18,4 +18,4 @@ | 10 | 2026-10-12 | `Runner.execute` повторяет команду до 3 раз через проверку при ConnectionError/HTTP 5xx, не записывая `finished`; затем `failed` «результат неизвестен, повтор безопасен» (R-10) | Неоднозначный исход записи в Jira (обрыв после POST) нельзя отдавать как `failed`: для `ensure_issue` хаб пропустит зависимые команды; все команды идемпотентны | Лишние задержки при недоступной Jira | | 11 | 2026-10-12 | Запрошенное, но не настроенное поле (epic_key, planned_start/end) даёт `failed`, а не молчаливый `done` (R-11) | Ложный успех хаб запишет в зеркало как выполненный | Команды падают, пока хаб не настроен | | 12 | 2026-10-12 | `agent_version` добавляется в отчёт и в `/agent/state` (R-12) | Спека: версия «в каждом отчёте»; хаб лишние поля игнорирует | Нет | -| 13 | 2026-10-12 | F-1 (повтор по R-10 не ждёт 30 с, если Jira недоступна с первого вызова и `last_call_at` старый) оставлен как есть, deferred (R-13) | Вторая волна фиксов не предусмотрена процессом; последствие — три быстрых повтора и `failed «результат неизвестен», повтор безопасен`: без потери данных и без дублей (поиск дубля идемпотентен) | Недоступная Jira даст `failed` за секунды вместо ~90 с; лечится фиксированным сном `max(pause,30)` от момента отказа в ветке retry | +| 13 | 2026-10-12 | F-1 (повтор по R-10 не ждёт 30 с, если Jira недоступна с первого вызова и `last_call_at` старый) сначала отложен контроллером (вторая волна фиксов не предусмотрена), затем исправлен по команде владельца: ожидание перед повтором — фиксированное `max(pause, 30)` от момента отказа (`Runner.wait_seconds`), не от последнего успешного вызова Jira | Обрыв соединения не обновляет `last_call_at`, поэтому пауза «от последнего вызова» при старом значении была нулевой и три повтора шли подряд | Повтор ждёт полные 30 с даже если с последнего вызова Jira прошло больше; лишнее ожидание не дольше 30 с на попытку | diff --git a/src/agent.py b/src/agent.py index 7c45cc8..d1ec045 100644 --- a/src/agent.py +++ b/src/agent.py @@ -852,19 +852,23 @@ class Runner: return False self.sleep(wait) - def wait_pause(self, pause_seconds, force=False) -> bool: + def wait_seconds(self, seconds) -> bool: + """Спать заданное время шагами по секунде; False — остановлены во время ожидания.""" + remaining = float(seconds or 0) + if remaining > 0: + log(" пауза %.0f с" % remaining) + while remaining > 0 and not self.stop: + step = min(remaining, 1.0) + self.sleep(step) + remaining -= step + return not self.stop + + def wait_pause(self, pause_seconds) -> bool: """Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы. - force: если вызовов Jira ещё не было, считать от текущего момента.""" + Если вызовов Jira ещё не было — пауза не нужна.""" pause = float(pause_seconds or 0) - if pause > 0 and (self.jira.last_call_at is not None or force): - since = time.monotonic() - self.jira.last_call_at if self.jira.last_call_at is not None else 0 - remaining = pause - since - if remaining > 0: - log(" пауза %.0f с" % remaining) - while remaining > 0 and not self.stop: - step = min(remaining, 1.0) - self.sleep(step) - remaining -= step + if pause > 0 and self.jira.last_call_at is not None: + return self.wait_seconds(pause - (time.monotonic() - self.jira.last_call_at)) return not self.stop def run_once(self, cmd: dict) -> dict: @@ -896,7 +900,8 @@ class Runner: break attempt += 1 log(" ! обрыв связи с Jira, повтор %d/%d через проверку: %s" % (attempt, self.RETRY_ATTEMPTS, out.get("error"))) - if not self.wait_pause(max(float(cmd.get("pause_seconds") or 0), self.RETRY_MIN_WAIT), force=True): + # F-1: обрыв не обновляет last_call_at, поэтому ждём фиксированно от момента отказа, а не от последнего вызова + if not self.wait_seconds(max(float(cmd.get("pause_seconds") or 0), self.RETRY_MIN_WAIT)): return # остановка: finished не пишем, команду подхватит recover out["jira_calls"], out["rate_limited"] = calls, limited self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited) diff --git a/tests/test_runner.py b/tests/test_runner.py index 6e62b24..10c2a70 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -276,6 +276,27 @@ class RunnerTest(unittest.TestCase): self.assertEqual(r["jira_calls"], 2) # поиск + повторный поиск (оборванный ответ не считается) self.assertEqual([k for k, _ in self.kinds()].count("started"), 1) + def test_retry_waits_from_failure_even_if_last_call_is_old(self): + # F-1: Jira недоступна с первого вызова, last_call_at старый (обрыв не обновляет его) — + # ожидание перед повтором считается от момента отказа, а не от последнего успешного вызова. + self.runner.load_settings() + self.runner.jira.last_call_at = time.monotonic() - 600 + runs = [] + + def flaky(cmd): + runs.append(1) + if len(runs) == 1: + return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 0, + "rate_limited": 0, "retryable": True} + return {"status": "done", "result": {"changed": []}, "jira_calls": 1, "rate_limited": 0} + + self.runner.executor.run = flaky + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.loop() + self.assertEqual(len(runs), 2) + self.assertEqual(self.hub.results[1]["status"], "done") + self.assertAlmostEqual(sum(self.sleeps), 30, delta=0.1) # полные 30 с от момента отказа + def test_retry_exhausted_gives_failed_unknown(self): self.runner.load_settings() runs = []