feat(queue): 409 на отчёт по невыданной команде, ключ из позднего отчёта по отменённой ensure_issue, 422 на дубль темы (#9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 22:46:41 +04:00
parent 958dbe956f
commit bf73299732
6 changed files with 81 additions and 0 deletions

View File

@ -104,6 +104,8 @@ def h_agent_result(hub, conn, m, q, body):
body.get("jira_calls", 0), body.get("rate_limited", 0)) body.get("jira_calls", 0), body.get("rate_limited", 0))
except KeyError: except KeyError:
raise ApiError(404, "not_found", "command") raise ApiError(404, "not_found", "command")
except queue.NotClaimedError as e:
raise ApiError(409, "not_claimed", str(e))
except ValueError as e: except ValueError as e:
raise ApiError(422, "bad_status", str(e)) raise ApiError(422, "bad_status", str(e))
return 200, cmd return 200, cmd

View File

@ -85,6 +85,11 @@ def validate(commands) -> list[dict]:
if ctype == "ensure_issue": if ctype == "ensure_issue":
defined.add(params["temp_key"]) defined.add(params["temp_key"])
out.append({"type": ctype, "params": params}) 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 return out

View File

@ -22,6 +22,10 @@ class ClaimedError(Exception):
self.claimed_at = claimed_at self.claimed_at = claimed_at
class NotClaimedError(Exception):
"""Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409."""
def _cmd(row) -> dict: def _cmd(row) -> dict:
d = dict(row) d = dict(row)
d["params"] = json.loads(d["params"]) 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) cmd = get_command(conn, command_id)
if cmd is None: if cmd is None:
raise KeyError(command_id) raise KeyError(command_id)
if cmd["status"] == "queued":
raise NotClaimedError(f"команда {command_id} не выдана")
if cmd["status"] in TERMINAL: 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 не затирается return cmd # идемпотентно; cancelled не затирается
now = db.now() now = db.now()
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",

View File

@ -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": "/tmp/jira-bridge"})["settings_version"] == before + 1
assert c.put("/settings", {"relay_dir": None})["settings_version"] == before + 2 assert c.put("/settings", {"relay_dir": None})["settings_version"] == before + 2
assert "relay_dir" not in hub["agent"].get("/agent/settings") 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

View File

@ -149,3 +149,13 @@ def test_ensure_issue_two_matches_takes_newest():
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-3'") conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-3'")
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
assert ok and result == {"key": "A-3"} 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]]) # регистр различается — пакет допустим

View File

@ -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,)) 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.claim(conn)["id"] == cid
assert queue.get_command(conn, cid)["deliveries"] == 2 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