feat(queue,mcp): отмена пакета сообщает о команде у агента; подсказка про labels (#13)

cancel_batch возвращает still_claimed — команды пакета в статусе claimed,
которые отмена не останавливает. MCP batch_cancel выводит строку на каждую
такую команду с подсказкой про POST /commands/N/cancel с force. Описание
batch_create предупреждает, что ensure_issue нужны labels с базовой меткой
AWPCA, иначе задача выпадет из базовой JQL и из зеркала.

M-11 (hub#12), T3.7 (agent#4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 12:50:44 +04:00
parent 19aa032a25
commit bd8c769e75
4 changed files with 34 additions and 3 deletions

View File

@ -91,6 +91,9 @@ def fmt_batch(b) -> str:
for c in b.get("commands", []):
lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}"
+ (f" ({c['error']})" if c.get("error") else "") + (f" {json.dumps(c['result'], ensure_ascii=False)}" if c.get("result") else ""))
for s in b.get("still_claimed") or []:
lines.append(f" команда #{s['id']} уже у агента и будет выполнена; остановить — "
f"POST /commands/{s['id']}/cancel с force: true")
return "\n".join(lines)
@ -142,7 +145,9 @@ def notes_set(key: str, fact_status: str | None = None, work_kind: str | None =
@guard
def batch_create(title: str, commands: list[dict]) -> str:
"""Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status|
ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам."""
ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам.
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
задача без неё выпадет из базовой JQL и из зеркала."""
b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands})
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
@ -186,7 +191,8 @@ def command_retry(id: int) -> str:
@mcp.tool()
@guard
def batch_cancel(id: int) -> str:
"""Отменить пакет (только команды в очереди; взятую агентом — command_cancel с force через API)."""
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
в ответе подсказка (POST /commands/N/cancel с force)."""
return fmt_batch(_client().post(f"/batches/{id}/cancel", {}))

View File

@ -287,7 +287,10 @@ def cancel_batch(conn, batch_id) -> dict:
now = db.now()
conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL", (now, batch_id))
conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE batch_id = ? AND status = 'queued'", (now, batch_id))
return batch_view(conn, batch_id)
view = batch_view(conn, batch_id)
# взятые агентом команды отмена пакета не останавливает: они будут выполнены (M-11 #12)
view["still_claimed"] = [{"id": c["id"], "type": c["type"]} for c in view["commands"] if c["status"] == "claimed"]
return view
def cancel_command(conn, command_id, force=False) -> dict:

View File

@ -189,3 +189,16 @@ def test_batch_wait_does_not_block_event_loop(monkeypatch):
out, ticks = asyncio.run(scenario())
assert "done" in out and len(ticks) == 5
def test_fmt_batch_warns_about_claimed():
b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running",
"remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [],
"still_claimed": [{"id": 30, "type": "ensure_fields"}]}
text = mcp_server.fmt_batch(b)
assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text
def test_batch_create_mentions_labels():
doc = mcp_server.batch_create.__doc__ or ""
assert "labels" in doc and "AWPCA" in doc

View File

@ -456,3 +456,12 @@ def test_refresh_failed_by_limit_finishes_sync_run():
queue.claim(conn)
assert queue.get_command(conn, r["id"])["status"] == "failed"
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
def test_cancel_batch_reports_still_claimed():
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn)
view = queue.cancel_batch(conn, b["id"])
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
assert queue.get_command(conn, c["id"])["status"] == "claimed"