diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 2f8a6b6..10e7b08 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -13,6 +13,7 @@ log = logging.getLogger(__name__) TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"} MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2) +ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands) ONLINE_MINUTES = 5 @@ -71,7 +72,7 @@ def _status_of(cmds, cancelled_at=None) -> str: if not sts: return "done" if all(s in TERMINAL for s in sts): - return "cancelled" if cancelled_at and all(s == "cancelled" for s in sts) else "done" + return "cancelled" if cancelled_at else "done" # отменённый пакет — отменён, даже с частью done (T5.6) if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts): return "running" return "queued" @@ -212,6 +213,7 @@ def record_state(conn, command_id, issues_count, links_count) -> None: def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict: if status not in ("done", "failed"): raise ValueError("status — done или failed") + error = None if error is None else str(error)[:ERROR_LIMIT] with db.transaction(conn): cmd = get_command(conn, command_id) if cmd is None: @@ -219,13 +221,15 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= if cmd["status"] == "queued": raise NotClaimedError(f"команда {command_id} не выдана") if cmd["status"] in TERMINAL: - # поздний отчёт о созданной задаче по отменённой команде: ключ не теряем, статус не трогаем (M-5) - if (cmd["status"] == "cancelled" and cmd["type"] == "ensure_issue" and status == "done" + # поздний отчёт о созданной задаче по команде, которую хаб уже закрыл (force-отмена — M-5, предел выдач — + # M-4 #12): ключ не теряем, статус не трогаем + if (cmd["status"] in ("cancelled", "failed") and cmd["type"] == "ensure_issue" and status == "done" and result and result.get("key") and not cmd["result"]): conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", (_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id)) mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) - log.warning("команда %s отменена, но агент создал %s — ключ записан в результат", command_id, result["key"]) + log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат", + command_id, cmd["status"], result["key"]) return get_command(conn, command_id) return cmd # идемпотентно; cancelled не затирается now = db.now() @@ -239,9 +243,10 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) if status == "done" and is_full_refresh(conn, command_id): - # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед - run = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (command_id,)).fetchone() - if run: + # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) — + # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12) + run = conn.execute("SELECT started_at, issues_count FROM sync_run WHERE command_id = ?", (command_id,)).fetchone() + if run and run["issues_count"] > 0: conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],)) if cmd["type"] == "refresh" and status == "done": reconcile(conn) diff --git a/tests/test_queue.py b/tests/test_queue.py index 053d827..ec9d5e4 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -379,3 +379,57 @@ def test_late_failed_report_after_force_cancel_changes_nothing(): queue.cancel_command(conn, c["id"], force=True) late = queue.report_result(conn, c["id"], "failed", error="HTTP 400") assert late["status"] == "cancelled" and late["result"] is None and late["error"] is None + + +def _fail_by_limit(conn, cid): + for _ in range(queue.MAX_DELIVERIES - 1): + assert queue.claim(conn)["id"] == cid + queue.claim(conn) # четвёртый claim: cid → failed по пределу + assert queue.get_command(conn, cid)["status"] == "failed" + + +def test_error_is_trimmed_to_1500(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + c = queue.claim(conn) + out = queue.report_result(conn, c["id"], "failed", error="е" * 5000) + assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500 + + +def test_late_done_after_delivery_limit_keeps_issue_key(): + conn = fresh() + mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import") + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + _fail_by_limit(conn, c["id"]) + late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2) + assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True} + assert late["error"] == "агент не отчитался после 3 выдач" + assert mirror.get_note(conn, "A-30")["fact_status"] == "План" and mirror.get_note(conn, "new:8") is None + again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"}) + assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет + + +def test_cancelled_batch_with_done_command_is_cancelled(): + conn = fresh() + b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")]) + first = queue.claim(conn) + queue.report_result(conn, first["id"], "done", result={"changed": []}) + second = queue.claim(conn) + queue.cancel_batch(conn, b["id"]) + queue.report_result(conn, second["id"], "done", result={"changed": []}) + view = queue.batch_view(conn, b["id"]) + assert view["status"] == "cancelled" + assert view["counts"]["done"] == 2 + + +def test_empty_full_refresh_keeps_neighbors(): + conn = fresh() + jql = settings.get(conn, "base_jql") + mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [], + fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона + queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}]) + r = queue.claim(conn) + queue.report_result(conn, r["id"], "done") # загрузок состояния не было: 0 задач + assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0