fix(agent): ошибка спринтов refresh называет доску, доска-канбан пропускается (I-1, M-2..M-5, #35)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 20:31:59 +04:00
parent 719d4bceef
commit ced48cf9db
7 changed files with 47 additions and 10 deletions

View File

@ -242,7 +242,8 @@ python3 agent.py prune
3. **Спринт в `refresh` — только активные и будущие спринты scrum-досок.** Поле `sprint` берётся из Agile API по
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. Задачи канбан-досок и досок неизвестного
типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по списку `boards`. Если
scrum-доска не отдала спринты (удалена, стала канбаном), `refresh` завершается `failed` с текстом Jira (R-32).
scrum-доска стала канбаном, `refresh` пропускает её и сообщает хабу новый тип (R-36); если доска не отдала спринты по
другой причине (удалена, нет доступа), `refresh` завершается `failed` с номером доски и текстом Jira (R-32).
4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в
`result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`).
Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50).

View File

@ -1,2 +1,3 @@
schema: spec-driven
created: 2026-10-19
skip_specs: true

View File

@ -17,3 +17,4 @@
| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет |
| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе |
| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` |
| R-36 | 2026-10-19 | По находке I-1 финального ревью: если scrum-доска не отдала спринты в `refresh`, агент перепроверяет её тип; стала `kanban` — доска пропускается, в результате `boards: {"<id>": "kanban"}`, `refresh` `done`; иначе `failed` с текстом «доска <id>: …» (код и `retryable` прежние) | Смена доски на канбан лечится без правки базы; при прочих ошибках владелец видит, какую доску править (R-32 расширил зону поражения на все проекты) | Лишний `GET /board/{id}` при ошибке спринтов; типы досок, определённые в упавшем прогоне, не доходят до хаба — следующий `refresh` спросит снова |

View File

@ -1043,7 +1043,9 @@ class Executor:
types: Dict[str, Optional[str]] = {}
seen = set()
for b in p.get("boards") or []:
bid = (b or {}).get("id")
if not isinstance(b, dict): # хаб шлёт [{id, type}]; иное не роняет refresh (M-2)
continue
bid = b.get("id")
if bid is None or str(bid) in seen:
continue
seen.add(str(bid))
@ -1054,15 +1056,28 @@ class Executor:
scrum.append(bid)
return scrum, (types or None)
def sprint_names(self, keys: List[str], boards: List[Any]) -> Dict[str, str]:
def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]:
"""Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается
(R-36); иначе ошибка Jira с номером доски, refresh failed (R-32, I-1)."""
try:
return self.sprints(board)
except HttpError as e:
if self.board_type(board) == "kanban":
types[str(board)] = "kanban"
return {}
raise HttpError(e.code, "доска %s: %s" % (board, e.text), e.headers) from None
def sprint_names(self, keys: List[str], boards: List[Any],
types: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, str]:
# Источник спринта — Agile по активным и будущим спринтам досок (R-6): id поля спринта в настройках хаба нет,
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение). Задача в спринтах двух досок —
# побеждает последнее найденное имя.
names = {}
if not keys:
return names
types = {} if types is None else types
for board in boards:
for name, sp in self.sprints(board).items():
for name, sp in self.board_sprints(board, types).items():
for i in range(0, len(keys), 100):
chunk = keys[i:i + 100]
start = 0
@ -1107,7 +1122,8 @@ class Executor:
for i in range(0, len(absent), 100):
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
everything = raws + neighbors
names = self.sprint_names([r["key"] for r in everything], scrum)
types = dict(types or {})
names = self.sprint_names([r["key"] for r in everything], scrum, types)
issues, links, seen = [], [], set()
for r in everything:
st = self.to_state(r)
@ -1134,7 +1150,7 @@ class Executor:
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
if missing:
result["missing"] = list(dict.fromkeys(missing))[:50]
if types is not None:
if types:
result["boards"] = types
return self._out("done", result)

View File

@ -361,6 +361,8 @@ class FakeJira:
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
if m:
board = int(m.group(1))
if board not in self.boards:
return 404, {"errorMessages": ["Board does not exist"]}
if self.boards.get(board) == "kanban":
return 400, {"errorMessages": ["The board does not support sprints"]}
states = (q.get("state") or "active,future").split(",")

View File

@ -219,6 +219,7 @@ class ExecutorLinksTest(unittest.TestCase):
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
def test_sprint_unknown_name_names_param_board(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
self.assertEqual(out["status"], "failed")

View File

@ -302,7 +302,6 @@ class RefreshTest(unittest.TestCase):
self.assertTrue(out["retryable"])
self.assertNotIn("peer", out)
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
def refresh(self, **params):
@ -374,6 +373,7 @@ class RefreshTest(unittest.TestCase):
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": None})
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
self.assertEqual(len(self.hub.states), 1) # M-4: состояние ушло в хаб
def test_board_other_type_is_null(self):
self.fake.boards[7000] = "simple"
@ -404,14 +404,29 @@ class RefreshTest(unittest.TestCase):
self.refresh(jql=self.JQL, boards=both[::-1])
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
def test_scrum_board_sprints_error_fails_refresh(self): # R-32
def test_scrum_board_turned_kanban_self_heals(self): # I-1, R-36
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}])
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_scrum_board_sprints_error_fails_refresh(self): # R-32, I-1: ошибка называет доску
self.fake.add("AURORA-1")
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": "scrum"}])
out = self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": "scrum"}]) # доски нет в Jira
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertIn("HTTP 404", out["error"])
self.assertIn("доска 9999", out["error"])
self.assertFalse(out["retryable"])
self.assertEqual(self.hub.states, [])
def test_non_dict_board_item_skipped(self): # M-2
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[4216, None, {"id": 5120, "type": None}])
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
if __name__ == "__main__":
unittest.main()