File size: 4,242 Bytes
587a933
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/usr/bin/env python3
"""One card, two modes. The QA cycle between dry-run and a live cockpit.

  python -m cli.qa_cycle dry    # propose and park; the shell does not run
  python -m cli.qa_cycle live   # run the plan in .sandbox/, critic passes to done

The provider is forced to mock so this never calls OMP, Hermes, or OpenClaw.
The card is deleted before exit, including on failure. Refuses to start when a
conductor or reaper is already running, so a QA card cannot race the live board.
"""
from __future__ import annotations

import asyncio
import json
import os
import subprocess
import sys

MODE = sys.argv[1] if len(sys.argv) > 1 else ""
if MODE not in ("dry", "live"):
    print("usage: python -m cli.qa_cycle dry|live", file=sys.stderr)
    raise SystemExit(2)

# Set before any KanBot import. workers reads DRY_RUN at import, and the repo
# .env must not override this process (setdefault only fills what is missing).
os.environ["KANBOT_DRY_RUN"] = "1" if MODE == "dry" else "0"
os.environ["KANBOT_PROVIDER"] = "mock"
os.environ["KANBOT_SWARM_N"] = "1"


def _foreign_workers() -> list[str]:
    found = subprocess.run(
        ["pgrep", "-fl", "core.conductor|core.reaper"],
        capture_output=True, text=True,
    )
    if found.returncode != 0:
        return []
    return [line for line in found.stdout.splitlines() if "qa_cycle" not in line]


async def _cycle() -> dict:
    from core import db
    from core.mcp_server import create_card, move_card
    from core.workers import DRY_RUN, claim_and_execute, run_critic

    if MODE == "dry" and not DRY_RUN:
        raise RuntimeError("dry cycle imported workers with DRY_RUN off")
    if MODE == "live" and DRY_RUN:
        raise RuntimeError("live cycle imported workers with DRY_RUN on")

    created = json.loads(await create_card(
        title="QA e2e sandbox ping",
        spec="Print a marker through the sandbox and post it as the artifact.",
        role="executor",
        acceptance_criteria=["the sandbox ran"],
        tags=["qa"],
    ))
    cid = created["id"]
    try:
        promoted = json.loads(await move_card(
            cid, "ready", actor="qa", reason="qa promote"))
        if promoted.get("rejected") or promoted.get("error"):
            raise RuntimeError(f"promote failed: {promoted}")
        card = await db.get_card(cid)
        await claim_and_execute(card)
        card = await db.get_card(cid)
        kinds = [entry.kind for entry in card.thread]
        if MODE == "dry":
            if card.column.value != "blocked":
                raise RuntimeError(f"dry-run left the card in {card.column.value}")
            if "tool_call" not in kinds or "tool_result" in kinds:
                raise RuntimeError(f"dry-run thread kinds: {kinds}")
        else:
            if card.column.value != "review":
                raise RuntimeError(f"live execution left the card in {card.column.value}")
            if "tool_result" not in kinds or "artifact" not in kinds:
                raise RuntimeError(f"live thread kinds: {kinds}")
            blob = "\n".join(entry.content for entry in card.thread)
            if "mock" not in blob:
                raise RuntimeError("sandbox stdout did not land on the thread")
            await run_critic(card)
            card = await db.get_card(cid)
            if card.column.value != "done":
                raise RuntimeError(f"critic left the card in {card.column.value}")
        return {"ok": True, "mode": MODE, "id": cid, "column": card.column.value}
    finally:
        pool = await db.pool()
        async with pool.acquire() as conn:
            await conn.execute("DELETE FROM cards WHERE id=$1", cid)
        await pool.close()


def main() -> int:
    busy = _foreign_workers()
    if busy:
        print(json.dumps({"ok": False, "error": "conductor or reaper is running",
                          "procs": busy}))
        return 3
    try:
        report = asyncio.run(_cycle())
    except Exception as exc:  # noqa: BLE001 — one JSON line for the QA runner
        print(json.dumps({"ok": False, "mode": MODE, "error": f"{type(exc).__name__}: {exc}"}))
        return 1
    print(json.dumps(report))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())