fix(queue): ensure_issue не подтверждается по состоянию без автора (R-11, I-1); reconcile не затирает отчёт и отмену (M-1)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 23:07:52 +04:00
parent 3c37e851e9
commit a09b794302
4 changed files with 66 additions and 13 deletions

View File

@ -108,17 +108,18 @@ def estimate(types, pacing: dict) -> int:
def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]: def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются. """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.
reporter: если задан, ensure_issue подтверждается только задачей, заведённой этим пользователем.""" reporter: логин Jira, под которым агент заводит задачи. ensure_issue подтверждается ТОЛЬКО задачей этого
автора; пока reporter неизвестен (None), по состоянию не подтверждается (R-11): совпадение темы с чужой
задачей не доказательство, а запись в чужую задачу недопустима."""
ctype, p = cmd["type"], cmd["params"] ctype, p = cmd["type"], cmd["params"]
if ctype in ("ensure_comment", "refresh"): if ctype in ("ensure_comment", "refresh"):
return False, None return False, None
if ctype == "ensure_issue": if ctype == "ensure_issue":
sql = "SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days')" if reporter is None:
args = [p["summary"]] return False, None
if reporter is not None: row = conn.execute("SELECT key FROM issue WHERE summary = ? AND reporter = ? "
sql += " AND reporter = ?" "AND substr(created, 1, 10) >= date('now', '-7 days') ORDER BY created DESC LIMIT 1",
args.append(reporter) (p["summary"], reporter)).fetchone()
row = conn.execute(sql + " ORDER BY created DESC LIMIT 1", args).fetchone()
return (True, {"key": row["key"]}) if row else (False, None) return (True, {"key": row["key"]}) if row else (False, None)
issue = mirror.get_issue(conn, p.get("key") or p.get("source")) issue = mirror.get_issue(conn, p.get("key") or p.get("source"))
if issue is None: if issue is None:

View File

@ -206,7 +206,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
def reconcile(conn) -> list[int]: def reconcile(conn) -> list[int]:
"""Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state.""" """Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state."""
link_types = settings.get(conn, "link_types") or {} link_types = settings.get(conn, "link_types") or {}
reporter = settings.get(conn, "reporter") reporter = settings.get(conn, "reporter") # задел: сейчас не задаётся, ensure_issue по состоянию не подтверждается (R-11);
# позже сюда придёт jira_user от агента
changed = [] changed = []
for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall(): for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall():
cmd = _cmd(row) cmd = _cmd(row)
@ -214,8 +215,11 @@ def reconcile(conn) -> list[int]:
if not ok: if not ok:
continue continue
with db.transaction(conn): with db.transaction(conn):
conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? WHERE id = ?", cur = conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? "
(db.now(), _dump(result) if result else None, cmd["id"])) "WHERE id = ? AND status = 'claimed'",
(db.now(), _dump(result) if result else None, cmd["id"]))
if cur.rowcount == 0: # отчёт агента или отмена опередили сверку
continue
if cmd["type"] == "ensure_issue" and result: if cmd["type"] == "ensure_issue" and result:
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
changed.append(cmd["id"]) changed.append(cmd["id"])

View File

@ -111,17 +111,25 @@ def _ensure_issue(summary):
def test_check_confirmed_issue_by_summary(): def test_check_confirmed_issue_by_summary():
conn = _mirror() conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT) ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
assert ok and result == {"key": "A-1"} assert ok and result == {"key": "A-1"}
ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT) ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT, reporter="petrov")
assert not ok assert not ok
def test_ensure_issue_without_reporter_never_confirmed():
"""R-11: пока автор неизвестен, совпадение темы не подтверждает ensure_issue."""
conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter=None) == (False, None)
def test_ensure_issue_older_than_7_days_not_confirmed(): def test_ensure_issue_older_than_7_days_not_confirmed():
conn = _mirror() conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-30 days') WHERE key = 'A-1'") conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-30 days') WHERE key = 'A-1'")
ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT) ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
assert ok is False assert ok is False

View File

@ -225,3 +225,43 @@ def test_reconcile_respects_reporter_setting():
mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", "created": created, "labels": []}], []) mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", "created": created, "labels": []}], [])
assert queue.reconcile(conn) == [c["id"]] assert queue.reconcile(conn) == [c["id"]]
assert queue.get_command(conn, c["id"])["result"] == {"key": "A-8"} assert queue.get_command(conn, c["id"])["result"] == {"key": "A-8"}
def _claimed_ensure_issue(conn, summary="Тема"):
queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}])
c = queue.claim(conn)
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
return c
def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
"""R-11: настройка reporter не задана — совпадение темы не подтверждает, заметка new:N не переезжает."""
conn = fresh()
c = _claimed_ensure_issue(conn)
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
created = db.utcnow().isoformat()
mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], [])
assert queue.reconcile(conn) == []
assert queue.get_command(conn, c["id"])["status"] == "claimed"
assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None
def test_reconcile_does_not_overwrite_finished_command(monkeypatch):
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
conn = fresh()
settings.set_(conn, "reporter", "robot")
c = _claimed_ensure_issue(conn)
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot",
"created": db.utcnow().isoformat(), "labels": []}], [])
real = queue.C.check_confirmed
def racing(*a, **kw):
out = real(*a, **kw)
queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки
return out
monkeypatch.setattr(queue.C, "check_confirmed", racing)
assert queue.reconcile(conn) == []
assert queue.get_command(conn, c["id"])["status"] == "cancelled"
assert mirror.get_note(conn, "new:1") is not None # rename_note не выполнялся