fix(queue): обрезка error, поздний done по failed, статус отменённого пакета, пустой полный прогон (#13)

M-4 (hub#12), T5.6 и M-12 (hub#4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 12:44:13 +04:00
parent a309c9bfb1
commit 4f0b873db6
2 changed files with 66 additions and 7 deletions

View File

@ -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)

View File

@ -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