feat: надёжность моста — довыдача команды без отчёта, миграция v2, отчёты (#9) #10
@ -104,6 +104,8 @@ def h_agent_result(hub, conn, m, q, body):
|
||||
body.get("jira_calls", 0), body.get("rate_limited", 0))
|
||||
except KeyError:
|
||||
raise ApiError(404, "not_found", "command")
|
||||
except queue.NotClaimedError as e:
|
||||
raise ApiError(409, "not_claimed", str(e))
|
||||
except ValueError as e:
|
||||
raise ApiError(422, "bad_status", str(e))
|
||||
return 200, cmd
|
||||
|
||||
@ -85,6 +85,11 @@ def validate(commands) -> list[dict]:
|
||||
if ctype == "ensure_issue":
|
||||
defined.add(params["temp_key"])
|
||||
out.append({"type": ctype, "params": params})
|
||||
summaries = [c["params"]["summary"].strip() for c in out if c["type"] == "ensure_issue"]
|
||||
repeated = sorted({s for s in summaries if summaries.count(s) > 1})
|
||||
if repeated:
|
||||
raise CommandError("повторяется тема ensure_issue: " + ", ".join(f"«{s}»" for s in repeated)
|
||||
+ " — в Jira получится одна задача на два временных ключа")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@ -22,6 +22,10 @@ class ClaimedError(Exception):
|
||||
self.claimed_at = claimed_at
|
||||
|
||||
|
||||
class NotClaimedError(Exception):
|
||||
"""Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409."""
|
||||
|
||||
|
||||
def _cmd(row) -> dict:
|
||||
d = dict(row)
|
||||
d["params"] = json.loads(d["params"])
|
||||
@ -212,7 +216,17 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
||||
cmd = get_command(conn, command_id)
|
||||
if cmd is None:
|
||||
raise KeyError(command_id)
|
||||
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"
|
||||
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"])
|
||||
return get_command(conn, command_id)
|
||||
return cmd # идемпотентно; cancelled не затирается
|
||||
now = db.now()
|
||||
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
|
||||
|
||||
@ -313,3 +313,19 @@ def test_relay_dir_put_validation(hub):
|
||||
assert c.put("/settings", {"relay_dir": "/tmp/jira-bridge"})["settings_version"] == before + 1
|
||||
assert c.put("/settings", {"relay_dir": None})["settings_version"] == before + 2
|
||||
assert "relay_dir" not in hub["agent"].get("/agent/settings")
|
||||
|
||||
|
||||
def test_result_for_queued_command_is_409(hub):
|
||||
b = hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
|
||||
cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"]
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["agent"].post(f"/agent/commands/{cid}/result", {"status": "done"})
|
||||
assert e.value.status == 409 and e.value.error == "not_claimed"
|
||||
|
||||
|
||||
def test_duplicate_summary_batch_is_422(hub):
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post("/batches", {"title": "x", "commands": [
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Шахматка: комментарии"}},
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Шахматка: комментарии"}}]})
|
||||
assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail
|
||||
|
||||
@ -149,3 +149,13 @@ def test_ensure_issue_two_matches_takes_newest():
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-3'")
|
||||
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok and result == {"key": "A-3"}
|
||||
|
||||
|
||||
def test_validate_rejects_duplicate_issue_summary():
|
||||
cmds = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Отчёт"}},
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": " Отчёт "}},
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:3", "issue_type": "task", "summary": "отчёт"}}]
|
||||
with pytest.raises(commands.CommandError) as e:
|
||||
commands.validate(cmds)
|
||||
assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value)
|
||||
commands.validate([cmds[0], cmds[2]]) # регистр различается — пакет допустим
|
||||
|
||||
@ -350,3 +350,37 @@ def test_migrated_claimed_is_redelivered():
|
||||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,))
|
||||
assert queue.claim(conn)["id"] == cid
|
||||
assert queue.get_command(conn, cid)["deliveries"] == 2
|
||||
|
||||
|
||||
def test_report_for_queued_command_rejected():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
|
||||
with pytest.raises(queue.NotClaimedError):
|
||||
queue.report_result(conn, cid, "done")
|
||||
assert queue.get_command(conn, cid)["status"] == "queued"
|
||||
|
||||
|
||||
def test_late_report_after_force_cancel_keeps_issue_key():
|
||||
conn = fresh()
|
||||
mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import")
|
||||
queue.create_batch(conn, "x", "claude", [
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
||||
c = queue.claim(conn)
|
||||
cancelled = queue.cancel_command(conn, c["id"], force=True)
|
||||
late = queue.report_result(conn, c["id"], "done", result={"key": "A-20", "created": True}, jira_calls=3)
|
||||
assert late["status"] == "cancelled" and late["result"] == {"key": "A-20", "created": True} and late["jira_calls"] == 3
|
||||
assert late["finished_at"] == cancelled["finished_at"]
|
||||
assert mirror.get_note(conn, "A-20")["fact_status"] == "В работе" and mirror.get_note(conn, "new:7") is None
|
||||
again = queue.report_result(conn, c["id"], "done", result={"key": "A-21"})
|
||||
assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет
|
||||
|
||||
|
||||
def test_late_failed_report_after_force_cancel_changes_nothing():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
||||
c = queue.claim(conn)
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user