feat(queue): 409 на отчёт по невыданной команде, ключ из позднего отчёта по отменённой ensure_issue, 422 на дубль темы (#9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
958dbe956f
commit
bf73299732
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 = ?",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]]) # регистр различается — пакет допустим
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user